Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 0 additions & 6 deletions .changeset/plan-agents-label.md

This file was deleted.

6 changes: 0 additions & 6 deletions .changeset/planner-owned-agent-creation.md

This file was deleted.

18 changes: 18 additions & 0 deletions .changeset/unified-project-agents.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
"@sapiom/harness": minor
---

Unify Studio project sessions around one ordinary coding-agent identity and
make the project name open the shared Agent Map without starting a session.
Conversation tabs restore their exact session and Canvas independently of the
map and workflow Steps selection.

**Breaking for embedders** (minor while `@sapiom/harness` is pre-1.0):
`HarnessSession.agentMapIdentity` is now the role-neutral
`ProjectAgentSession { projectId, userId, sessionId }`. Stop branching on the
former `role` or `assignment` fields. Valid persisted legacy metadata is
normalized while session/provider IDs, cwd, title, transcript, and Canvas are
preserved. Malformed or conflicting authority fails closed. Optional
`projectBootstrap` describes lifecycle state only. Generic session routes
revalidate project ownership on resume; migrated startup queues retain their
durable FIFO input boundary during the coordinator transition.
116 changes: 41 additions & 75 deletions packages/harness/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,80 +84,46 @@ Architecture: a single Node process (Express + ws + node-pty) serves the built
SPA, a small REST API, terminal WebSocket streams, and the local telemetry
ingest endpoint. The interface contract lives in `src/shared/types.ts`.

### Agent Map planner sessions

In Studio, the pinned **Plan Agents** tab opens the project's Agent Map.
**Plan Agents** names the planning entry point; **Agent Map** remains the name
of the proposal view and its underlying protocol.

The authenticated local API owns planner identity; a model or generic session
request cannot assign itself the `map-planner` role. The public planner surface
is project-scoped:

- `POST /api/projects/:projectId/planner-sessions` with
`{ "mode": "resume-or-create" }` deterministically reuses the latest owned
live/resumable planner or creates one. Use `{ "mode": "fresh" }` to always
create a new planner.
- `POST /api/projects/:projectId/planner-sessions/:sessionId/messages` durably
accepts planner input and releases it FIFO after greeting resolution.
- `POST /api/projects/:projectId/planner-sessions/:sessionId/greeting/retry`
retries an eligible failed automatic greeting.

Planner metadata is part of the session registry. Its input FIFO and greeting
attempt state live at
`<state-root>/agent-map/planner-sessions/<sessionId>/input-queue.json`; corrupt
queue files are quarantined beside that file so one session cannot block boot.
An adjacent content-free `accepted-inputs.json` ledger commits PTY-accepted FIFO
entries before they are removed from the queue, so a failed queue rewrite can
finish after restart without replaying the message. A write-ahead dispatch
intent without that durable acknowledgement is never guessed or automatically
replayed: it is resolved at-most-once with a bounded
`planner_session.input_delivery_uncertain` event, then later FIFO entries may
continue. A PTY write and a filesystem write cannot provide true exactly-once
delivery without an idempotent external acknowledgement.
When vendor resume falls back to a replacement planner, the whole coordinator
directory is atomically handed to that exact successor before it can receive
input. A later replacement follows the queue-owning predecessor while its
focused rehydration brief may still come from an older recorded ancestor, so a
pre-ready exit cannot orphan or duplicate accepted FIFO work.

The focused system context contains only bounded project/session identity,
current workspace pointer IDs, and binding references. The current workspace
store does not yet own revision, proposal, or build-plan detail records, so
their bounded digest, summary, status, and warning slots are honestly
`null`/empty until those records land. Local root paths and source inventories
are never included.

The browser/host token gates every `/api` planner route and is never injected
into a coding-agent PTY. Each PTY instead receives a random `/ingest` capability
bound to its exact session ID; presenting it with another event `sessionId` is
rejected, it grants no `/api` authority, and it is rotated or revoked with the
process lifecycle. A vendor resume pointer is pinned to one harness session;
only a short-lived, one-shot `/clear` or `/resume` transition observed on the
trusted terminal/input path may rotate it, and a pointer already owned by
another harness session is always rejected. Current and rotated pointers are
reserved in a server-private, SHA-256-keyed, mode-`0600` sidecar next to the
session registry; raw historical aliases never enter a browser DTO. Planner
reuse and input additionally require the session cwd to remain one of the
project's current active root bindings and its owner to match the live signed-in
identity (or stable machine-local principal while signed out).

**Migration note (breaking):** `POST /api/sessions` now rejects unknown fields,
including client-authored planner metadata. Generic
`POST /api/sessions/:id/input`, `POST /api/sessions/:id/resume`, and
`POST /api/sessions/adopt` reject planner sessions. Adopt also returns a
bounded `AGENT_SESSION_IDENTITY_RESERVED` 409 for any ordinary current-owner
conflict or durable historical alias (including a pre-`/clear` or
pre-`/resume` identity), before probing or spawning an agent.
Clients must open, message, and retry planners through the project-scoped
routes above. Generic coding-agent sessions also use the durable vendor-ID pin;
their only rotation exception is the same trusted `/clear`/`/resume` gesture.
On upgrade, if legacy `sessions.json` rows contain the same vendor resume
pointer, the first persisted row keeps it and later duplicate rows are repaired
to `agentSessionId: null`. This does not delete the provider's transcript or
conversation history, but the losing local row can no longer resume or adopt
that fenced identity. Start a fresh session in the losing row's directory to
continue there.
### Project sessions and Agent Map navigation

Every coding-agent session in a project uses the same writable
`ProjectAgentSession { projectId, userId, sessionId }` identity and common
project instructions. Map access comes from that trusted identity. Roles and
agent assignments do not grant a separate kind of session or capability.

Click the project name to open its shared Agent Map without starting a coding
agent. Conversation tabs identify exact sessions, and selecting a session
restores its own terminal and Canvas. The map selection and agent Steps
view remain independent of the selected conversation. There is no pinned
planning-session tab; existing session titles and conversations are preserved.

The authenticated local API resolves project identity from durable project
roots. New sessions, resume, and transcript adoption use the generic session
routes. Resuming a project session revalidates its current signed-in owner and
active root binding before launching a process. Nested coding-agent directories
resolve to their containing project instead of inventing duplicate roots.

**Migration note (breaking):** `HarnessSession.agentMapIdentity` now contains
only project, user, and session IDs. Stop branching on its former `role` or
`assignment` fields. Valid persisted legacy metadata is normalized while
preserving the session/provider IDs, cwd, title, transcript, and Canvas;
conflicting or malformed authority fails closed. Optional `projectBootstrap`
is lifecycle metadata, not an authority or session type. The legacy startup
projection and project-scoped aliases remain temporarily for existing durable
startup queues. Migrated sessions use ordinary HTTP input, with the server
retaining FIFO ownership until those queues finish.

`POST /api/sessions` accepts the content-free
`initialUserInputPending: true` hint when the caller owns the first prompt.
Client-authored authority and other unknown fields are rejected. The browser
still submits that prompt to the returned exact session after readiness.

The browser/host token gates `/api` routes and is never injected into a
coding-agent PTY. Each PTY receives a separate `/ingest` capability bound to its
session ID. Vendor resume pointers remain pinned to one harness session;
current owners and durable historical aliases cannot be adopted into another
session. Duplicate persisted provider IDs are repaired conservatively during
boot, preserving the first owner and clearing the later duplicate pointer.

### Agent Map MCP

Expand All @@ -173,7 +139,7 @@ renews its inactivity lease, while session exit, resume rotation, signed-in
principal changes, and server shutdown revoke it. Consumers should not copy,
persist, log, or reuse the capability outside the launched session.

Every trusted Agent Map role receives the same three project-wide tools:
Every trusted project session receives the same three project-wide tools:

- `agent_map_read` reads the current confirmed workspace and shared proposal.
- `agent_map_validate` validates one complete operation batch without mutating
Expand Down
34 changes: 32 additions & 2 deletions packages/harness/src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ import {
} from "../core/inject/retention.js";
import { DEFAULT_SYSTEM_PROMPT } from "../profiles/default.js";
import { projectAgentPromptAppendix } from "../profiles/project-agent.js";
import { ProjectSessionScopeUnavailableError } from "../core/session-manager.js";
import { ProjectSessionScopeUnavailableError, SessionNotReadyError } from "../core/session-manager.js";
import { localProjectPrincipal } from "../core/project-session.js";
import { fetchSystemPromptForActiveEnvironment } from "../profiles/system-prompt-fetch.js";
import { agentCoreTemplatesDir } from "../core/agent-core-templates.js";
Expand Down Expand Up @@ -178,7 +178,7 @@ import {
localPlanningPrincipal,
PlanningSessionService,
} from "../core/planning-session.js";
import { PlannerGreetingCoordinator } from "../core/planner-greeting.js";
import { PlannerDispatchForbiddenError, PlannerGreetingCoordinator } from "../core/planner-greeting.js";
import { IngestCredentialRegistry } from "../core/ingest-credentials.js";
import { createStaticRouter } from "./static.js";
import { createTerminalWebSocketHandler } from "./terminal-ws.js";
Expand Down Expand Up @@ -3069,6 +3069,36 @@ export const startServer = async (
workflowsCache.find((w) => w.path === workflowPath) ?? null,
writeWorkspaceContext: writeSessionContext,
renderCanvas,
// Private compatibility boundary: migrated sessions use the ordinary
// HTTP endpoint while their persisted startup FIFO remains authoritative.
// The project bootstrap cutover replaces this coordinator in one place.
submitSessionInput: async (sessionId, text, submit) => {
const session = sessionManager.get(sessionId);
if (session?.planning) {
if (!(await isPlannerDispatchAuthorized({
session,
currentPrincipal: () => localPlanningPrincipal(planningUserId, machineId),
resolveProject: (projectId) => studioProjectCatalog.resolveIdentity(projectId),
}))) throw new ProjectSessionScopeUnavailableError(sessionId);
if (submit) {
try {
await plannerGreeting.enqueue(sessionId, text);
} catch (error) {
if (error instanceof PlannerDispatchForbiddenError) {
throw new ProjectSessionScopeUnavailableError(sessionId);
}
throw error;
}
return true;
}
// Draft text cannot interleave with a pending automatic greeting.
if (session.planning.greeting.status === "pending" ||
session.planning.greeting.status === "generating") {
throw new SessionNotReadyError(sessionId);
}
}
return sessionManager.submitInput(sessionId, text, submit);
},
onTelemetryOptInChange: (optIn) => batcher.setTelemetryOptIn(optIn),
onSessionCreated: (cwd, harnessSessionId) => {
scanWorkflowsAndBroadcast(cwd, "session-create", { dirty: true })
Expand Down
90 changes: 90 additions & 0 deletions packages/harness/src/server/rest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import type {
} from "../shared/types.js";
import {
SessionManager,
ProjectSessionScopeUnavailableError,
SessionNotReadyError,
UnknownSessionError,
} from "../core/session-manager.js";
Expand Down Expand Up @@ -110,6 +111,20 @@ function exitedSession(
};
}

/** A valid legacy record normalized by SessionManager during boot. */
function migratedProjectSession(): HarnessSession {
const identity = { projectId: "project-1", sessionId: "migrated-1", userId: "user-1" };
return exitedSession({
id: identity.sessionId,
agentMapIdentity: identity,
planning: {
identity: { ...identity, role: "map-planner" },
greeting: { status: "delivered", messageId: "message-1" },
queuedInputIds: [],
},
});
}

/** A history adapter whose resumability answer and transcript listing are both
* controllable — the two independent inputs the history endpoint merges. */
function historyAdapter(
Expand Down Expand Up @@ -440,6 +455,27 @@ describe("createRestRouter", () => {
});

describe("POST /sessions", () => {
it("accepts the composer's content-free first-input hint", async () => {
const sessionManager = fakeSessionManager();
(sessionManager.create as ReturnType<typeof vi.fn>).mockResolvedValue(
exitedSession({ status: "starting" }),
);
start({ sessionManager });
const response = await fetch(`${baseUrl}/sessions`, {
method: "POST",
headers: { ...TOKEN_HEADER, "content-type": "application/json" },
body: JSON.stringify({
cwd: "/tmp/proj",
harness: "claude-code",
initialUserInputPending: true,
}),
});
expect(response.status).toBe(201);
expect(sessionManager.create).toHaveBeenCalledWith(
expect.objectContaining({ initialUserInputPending: true }),
);
});

it("calls onSessionCreated with the new session's cwd and id", async () => {
const onSessionCreated = vi.fn();
const sessionManager = fakeSessionManager();
Expand Down Expand Up @@ -738,6 +774,37 @@ describe("createRestRouter", () => {
});

describe("POST /sessions/:id/input", () => {
it("routes migrated session input through the durable lifecycle boundary", async () => {
const session = migratedProjectSession();
const sessionManager = fakeSessionManager([session]);
const submitSessionInput = vi.fn(async () => true);
start({ sessionManager, submitSessionInput });
const response = await fetch(`${baseUrl}/sessions/${session.id}/input`, {
method: "POST",
headers: { ...TOKEN_HEADER, "content-type": "application/json" },
body: JSON.stringify({ text: "keep this request" }),
});
expect(response.status).toBe(200);
expect(submitSessionInput).toHaveBeenCalledWith(session.id, "keep this request", true);
expect(sessionManager.submitInput).not.toHaveBeenCalled();
});

it("returns a scope conflict when a migrated session loses authority", async () => {
const session = migratedProjectSession();
const sessionManager = fakeSessionManager([session]);
const submitSessionInput = vi.fn(async () => {
throw new ProjectSessionScopeUnavailableError(session.id);
});
start({ sessionManager, submitSessionInput });
const response = await fetch(`${baseUrl}/sessions/${session.id}/input`, {
method: "POST",
headers: { ...TOKEN_HEADER, "content-type": "application/json" },
body: JSON.stringify({ text: "must not dispatch" }),
});
expect(response.status).toBe(409);
expect(sessionManager.submitInput).not.toHaveBeenCalled();
});

it("submits input and returns ok:true", async () => {
const sessionManager = fakeSessionManager();
start({ sessionManager });
Expand Down Expand Up @@ -965,6 +1032,19 @@ describe("createRestRouter", () => {
});

describe("POST /sessions/:id/resume — error class → HTTP status mapping", () => {
it("resumes a migrated project session by its exact ordinary session id", async () => {
const session = migratedProjectSession();
const sessionManager = fakeSessionManager([session]);
(sessionManager.resume as ReturnType<typeof vi.fn>).mockResolvedValue(session);
start({ sessionManager });
const response = await fetch(`${baseUrl}/sessions/${session.id}/resume`, {
method: "POST",
headers: TOKEN_HEADER,
});
expect(response.status).toBe(200);
expect(sessionManager.resume).toHaveBeenCalledWith(session.id);
});

it("requires planner resume to use the trusted project resolver", async () => {
const planner = exitedSession({
id: "planner-1",
Expand Down Expand Up @@ -1400,6 +1480,16 @@ describe("createRestRouter", () => {
expect(sessionManager.resume).toHaveBeenCalledWith("sess-existing");
});

it("adopts a migrated project conversation through its existing session", async () => {
const session = { ...migratedProjectSession(), agentSessionId: body.agentSessionId };
const sessionManager = fakeSessionManager([session]);
(sessionManager.resume as ReturnType<typeof vi.fn>).mockResolvedValue(session);
start({ sessionManager, adapters: { "claude-code": historyAdapter() } });
expect((await adopt(body)).status).toBe(200);
expect(sessionManager.registerHistorical).not.toHaveBeenCalled();
expect(sessionManager.resume).toHaveBeenCalledWith(session.id);
});

it("requires an existing foreign-owned planner to use its scoped route without mutation", async () => {
const planner = exitedSession({
id: "planner-existing",
Expand Down
Loading
Loading