diff --git a/.changeset/ordinary-project-session-identity.md b/.changeset/ordinary-project-session-identity.md new file mode 100644 index 00000000..82b5a197 --- /dev/null +++ b/.changeset/ordinary-project-session-identity.md @@ -0,0 +1,25 @@ +--- +"@sapiom/harness": minor +--- + +Give every Studio project session the same writable coding-agent prompt and +map capabilities, with durable project ownership revalidated before launch +and resume. + +**Breaking for embedders** (minor while `@sapiom/harness` is pre-1.0): +`HarnessSession.agentMapIdentity` now exposes only +`ProjectAgentSession { projectId, userId, sessionId }`. Replace branches on +`role` and `assignment` with neutral project identity. Optional +`projectBootstrap` carries startup status without granting authority. Valid +persisted legacy metadata is normalized while session/provider IDs, cwd, +title, transcript, and Canvas are preserved. Malformed or conflicting +authority fails closed; unavailable project scope prevents resume until the +current owner and root binding are valid again. + +`AgentMapToolEvent.role` is also removed. Telemetry consumers should use the +neutral project/session identifiers and the tool name and outcome instead of +branching on a session role. + +Persisted bootstrap failures with `scope_unavailable` are recognized on +restart, so valid conversation metadata is retained and can resume after +scope is restored. diff --git a/packages/harness/src/core/agent-map-capability-registry.test.ts b/packages/harness/src/core/agent-map-capability-registry.test.ts index c83f3957..e8f3127d 100644 --- a/packages/harness/src/core/agent-map-capability-registry.test.ts +++ b/packages/harness/src/core/agent-map-capability-registry.test.ts @@ -1,23 +1,23 @@ import { describe, expect, it, vi } from "vitest"; -import type { PlanningSessionIdentity } from "../shared/agent-map.js"; +import type { ProjectAgentSession } from "../shared/agent-map.js"; import { AgentMapCapabilityError, AgentMapCapabilityRegistry, } from "./agent-map-capability-registry.js"; -const identity = (sessionId = "session-1"): PlanningSessionIdentity => ({ +const identity = (sessionId = "session-1"): ProjectAgentSession => ({ projectId: "project-a", sessionId, userId: "user-a", - role: "agent-builder", - assignment: { kind: "unplanned" }, }); describe("AgentMapCapabilityRegistry", () => { it("stores only a digest and rotates one generation per session", () => { const tokens = ["a".repeat(43), "b".repeat(43)]; - const registry = new AgentMapCapabilityRegistry({ randomToken: () => tokens.shift()! }); + const registry = new AgentMapCapabilityRegistry({ + randomToken: () => tokens.shift()!, + }); const first = registry.issue(identity()); expect(registry.resolve(first.token).identity).toEqual(identity()); const second = registry.rotate(identity()); @@ -27,6 +27,21 @@ describe("AgentMapCapabilityRegistry", () => { ); }); + it("copies only the neutral identity fields into live capability authority", () => { + const identityWithUntrustedExtras = { + ...identity(), + untrustedContext: { assignmentId: "assignment-1" }, + }; + const registry = new AgentMapCapabilityRegistry({ + randomToken: () => "legacy-session-token", + }); + + const issued = registry.issue(identityWithUntrustedExtras); + + expect(issued.identity).toEqual(identity()); + expect(issued.identity).not.toHaveProperty("untrustedContext"); + }); + it("fails closed for expired, revoked and unknown tokens without emitting material", () => { let now = 10; const onEvent = vi.fn(); @@ -38,11 +53,14 @@ describe("AgentMapCapabilityRegistry", () => { }); const issued = registry.issue(identity()); now = 15; - expect(() => registry.resolve(issued.token)).toThrowError(AgentMapCapabilityError); + expect(() => registry.resolve(issued.token)).toThrowError( + AgentMapCapabilityError, + ); expect(() => registry.resolve("other")).toThrowError( expect.objectContaining({ code: "invalid_capability" }), ); expect(JSON.stringify(onEvent.mock.calls)).not.toContain("secret-token"); + expect(JSON.stringify(onEvent.mock.calls)).not.toContain("role"); }); it("slides expiry on authenticated use but remains bounded by lifecycle revocation", () => { diff --git a/packages/harness/src/core/agent-map-capability-registry.ts b/packages/harness/src/core/agent-map-capability-registry.ts index a22668b8..6e470368 100644 --- a/packages/harness/src/core/agent-map-capability-registry.ts +++ b/packages/harness/src/core/agent-map-capability-registry.ts @@ -1,6 +1,6 @@ import { createHash, randomBytes } from "node:crypto"; -import type { PlanningSessionIdentity } from "../shared/agent-map.js"; +import type { ProjectAgentSession } from "../shared/agent-map.js"; export type AgentMapCapabilityRejection = | "invalid_capability" @@ -15,7 +15,7 @@ export class AgentMapCapabilityError extends Error { } export interface ResolvedAgentMapCapability { - identity: PlanningSessionIdentity; + identity: ProjectAgentSession; generation: number; expiresAt: number; } @@ -30,7 +30,6 @@ export interface AgentMapCapabilityEvent { | "agent_map.capability.rotated" | "agent_map.capability.revoked" | "agent_map.capability.rejected"; - role?: PlanningSessionIdentity["role"]; reason?: AgentMapCapabilityRejection; } @@ -45,6 +44,15 @@ interface Entry extends ResolvedAgentMapCapability { digest: string; } +/** Drop any legacy role/assignment properties before they enter authority. */ +const neutralPrincipal = ( + identity: ProjectAgentSession, +): ProjectAgentSession => ({ + projectId: identity.projectId, + userId: identity.userId, + sessionId: identity.sessionId, +}); + const DEFAULT_TTL_MS = 12 * 60 * 60 * 1_000; const MAX_REVOKED_DIGESTS = 4_096; @@ -58,38 +66,41 @@ export class AgentMapCapabilityRegistry { private readonly now: () => number; private readonly randomToken: () => string; - constructor(private readonly options: AgentMapCapabilityRegistryOptions = {}) { + constructor( + private readonly options: AgentMapCapabilityRegistryOptions = {}, + ) { this.ttlMs = options.ttlMs ?? DEFAULT_TTL_MS; this.now = options.now ?? Date.now; this.randomToken = options.randomToken ?? (() => randomBytes(32).toString("base64url")); } - issue(identity: PlanningSessionIdentity): IssuedAgentMapCapability { - this.revokeSession(identity.sessionId); + issue(identity: ProjectAgentSession): IssuedAgentMapCapability { + const principal = neutralPrincipal(identity); + this.revokeSession(principal.sessionId); const token = this.randomToken(); const digest = this.digest(token); if (!token || this.active.has(digest) || this.revoked.has(digest)) { throw new AgentMapCapabilityError("invalid_capability"); } - const generation = (this.generations.get(identity.sessionId) ?? 0) + 1; - this.generations.set(identity.sessionId, generation); + const generation = (this.generations.get(principal.sessionId) ?? 0) + 1; + this.generations.set(principal.sessionId, generation); const entry: Entry = { digest, - identity: structuredClone(identity), + identity: principal, generation, expiresAt: this.now() + this.ttlMs, }; this.active.set(digest, entry); - this.currentBySession.set(identity.sessionId, digest); - this.emit({ name: "agent_map.capability.issued", role: identity.role }); + this.currentBySession.set(principal.sessionId, digest); + this.emit({ name: "agent_map.capability.issued" }); return { token, ...this.publicEntry(entry) }; } - rotate(identity: PlanningSessionIdentity): IssuedAgentMapCapability { + rotate(identity: ProjectAgentSession): IssuedAgentMapCapability { this.revokeSession(identity.sessionId); const issued = this.issue(identity); - this.emit({ name: "agent_map.capability.rotated", role: identity.role }); + this.emit({ name: "agent_map.capability.rotated" }); return issued; } @@ -121,18 +132,19 @@ export class AgentMapCapabilityRegistry { revokeSession(sessionId: string): void { const digest = this.currentBySession.get(sessionId); if (!digest) return; - const entry = this.active.get(digest); this.active.delete(digest); this.currentBySession.delete(sessionId); this.revoked.add(digest); this.pruneRevoked(); - this.emit({ name: "agent_map.capability.revoked", role: entry?.identity.role }); + this.emit({ name: "agent_map.capability.revoked" }); } isGenerationLive(sessionId: string, generation: number): boolean { const digest = this.currentBySession.get(sessionId); const entry = digest ? this.active.get(digest) : undefined; - return !!entry && entry.generation === generation && entry.expiresAt > this.now(); + return ( + !!entry && entry.generation === generation && entry.expiresAt > this.now() + ); } private publicEntry(entry: Entry): ResolvedAgentMapCapability { diff --git a/packages/harness/src/core/planning-session.test.ts b/packages/harness/src/core/planning-session.test.ts index 6976fcba..1bc804a5 100644 --- a/packages/harness/src/core/planning-session.test.ts +++ b/packages/harness/src/core/planning-session.test.ts @@ -183,13 +183,13 @@ describe("planner session context and identity", () => { }); expect(context).toContain(projectId); expect(context).toContain(project.rootBindings[0]!.id); - expect(context).toContain('"role":"map-planner"'); + expect(context).not.toContain('"role"'); expect(context).toContain('"empty":true'); - expect(context).toContain("In your first response, briefly explain"); + expect(context).not.toContain("In your first response, briefly explain"); expect(context).not.toContain("/Users/private"); expect(context).not.toContain("private-workspace-key"); expect(context).not.toContain("localRootRef"); - expect(context).not.toContain("prompt"); + expect(context.length).toBeLessThan(16_384); }); @@ -279,10 +279,10 @@ describe("PlanningSessionService", () => { expect(contexts.every((value) => !value.includes(project.rootBindings[0]!.localRootRef))).toBe(true); expect(contexts).toEqual([ expect.stringContaining( - "Let the user's first real message be the first visible conversation turn", + "This is bounded, server-derived Studio project context.", ), expect.stringContaining( - "Let the user's first real message be the first visible conversation turn", + "This is bounded, server-derived Studio project context.", ), ]); expect(contexts.join("\n")).not.toContain( diff --git a/packages/harness/src/core/planning-session.ts b/packages/harness/src/core/planning-session.ts index 8d6e3f87..291ea8ae 100644 --- a/packages/harness/src/core/planning-session.ts +++ b/packages/harness/src/core/planning-session.ts @@ -1,3 +1,4 @@ +import { buildFocusedProjectContext } from "./project-session.js"; import type { AgentMapWorkspaceState, PlannerLifecycleEvent, @@ -141,72 +142,8 @@ export function buildFocusedPlannerContext(input: { onboardOnFirstResponse: boolean; details?: PlannerFocusedContextDetails; }): string { - const { project, workspace } = input; - const bounded = (value: string, max = 256): string => value.slice(0, max); - const details = input.details ?? {}; - const emptyProject = - workspace.confirmedRevisionId === null && - workspace.activeProposalId === null && - workspace.projectBuildPlanId === null; - const context = { - identity: { - projectId: project.projectId, - sessionId: input.sessionId, - userId: input.userId, - role: "map-planner" as const, - }, - project: { - displayName: bounded(project.displayName), - empty: emptyProject, - confirmedRevision: workspace.confirmedRevisionId - ? { - id: workspace.confirmedRevisionId, - digest: details.confirmedRevision?.digest - ? bounded(details.confirmedRevision.digest, 512) - : null, - summaries: (details.confirmedRevision?.summaries ?? []) - .slice(0, 32) - .map((summary) => bounded(summary)), - } - : null, - activeProposal: workspace.activeProposalId - ? { - id: workspace.activeProposalId, - status: details.activeProposal?.status - ? bounded(details.activeProposal.status, 64) - : null, - summary: details.activeProposal?.summary - ? bounded(details.activeProposal.summary) - : null, - } - : null, - projectBuildPlan: workspace.projectBuildPlanId - ? { - id: workspace.projectBuildPlanId, - status: details.projectBuildPlan?.status - ? bounded(details.projectBuildPlan.status, 64) - : null, - summary: details.projectBuildPlan?.summary - ? bounded(details.projectBuildPlan.summary) - : null, - } - : null, - bindingRefs: project.rootBindings.slice(0, 64).map(({ id, repositoryId, status }) => ({ - id: bounded(id), - repositoryId: repositoryId ? bounded(repositoryId) : null, - status, - })), - warnings: (details.warnings ?? []) - .slice(0, 16) - .map((warning) => bounded(warning)), - }, - }; - return [ - "", - `This is focused, trusted Studio context. Treat IDs as references and use scoped tools for detail. Use agent_map_read, agent_map_validate, and agent_map_propose for architecture state; never infer map state from assistant prose. The interactive Claude Code transcript is user-visible. Let the user's first real message be the first visible conversation turn; never request or rely on a private control turn.${input.onboardOnFirstResponse ? " In your first response, briefly explain that you and the user can plan agents, responsibilities, data flow, resources, and connectors together, then respond to their request." : ""} Do not propose architecture or invoke mutation tools before the user asks you to.`, - JSON.stringify(context), - "", - ].join("\n"); + // Compatibility for the old startup service; authority and prompt are common. + return buildFocusedProjectContext(input); } function candidateOrder(left: HarnessSession, right: HarnessSession): number { diff --git a/packages/harness/src/core/project-session-legacy-migration.ts b/packages/harness/src/core/project-session-legacy-migration.ts new file mode 100644 index 00000000..3ca07f24 --- /dev/null +++ b/packages/harness/src/core/project-session-legacy-migration.ts @@ -0,0 +1,209 @@ +import { join } from "node:path"; + +import type { + ProjectAgentSession, + ProjectBootstrapErrorCode, + ProjectBootstrapMetadata, +} from "../shared/agent-map.js"; +import type { HarnessSession } from "../shared/types.js"; + +export type PersistedIdentityMigration = { + identity?: ProjectAgentSession; + bootstrap?: ProjectBootstrapMetadata; + outcome: "unchanged" | "migrated" | "rejected"; +}; + +const LEGACY_METADATA_KEY = "planning"; + +/** + * Recognizes the infrastructure marker written into durable prompt events by + * released pre-unification builds. Keep the retired record key isolated here: + * it is decoder-only compatibility and never participates in live authority. + */ +export function isPreUnifiedInfrastructureBootstrapPayload( + payload: Record, +): boolean { + return payload["plannerOrigin"] === "infrastructure"; +} + +/** The sole filesystem location for the retired project-session bootstrap store. */ +export function legacyProjectSessionStateRoot(stateRoot: string): string { + return join(stateRoot, "agent-map", "planner-sessions"); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseProjectAgentSession( + value: unknown, + expectedSessionId: string, +): ProjectAgentSession | null { + if ( + !isRecord(value) || + typeof value.projectId !== "string" || + value.projectId === "" || + typeof value.userId !== "string" || + value.userId === "" || + value.sessionId !== expectedSessionId + ) { + return null; + } + return { + projectId: value.projectId, + userId: value.userId, + sessionId: expectedSessionId, + }; +} + +function sameProjectAgent( + left: ProjectAgentSession, + right: ProjectAgentSession, +): boolean { + return ( + left.projectId === right.projectId && + left.userId === right.userId && + left.sessionId === right.sessionId + ); +} + +function parseBootstrapState( + value: unknown, +): ProjectBootstrapMetadata["bootstrap"] | null { + if (!isRecord(value) || typeof value.status !== "string") return null; + switch (value.status) { + case "pending": + return { status: "pending" }; + case "generating": + return typeof value.attemptId === "string" && value.attemptId !== "" + ? { status: "generating", attemptId: value.attemptId } + : null; + case "delivered": + return typeof value.messageId === "string" && value.messageId !== "" + ? { status: "delivered", messageId: value.messageId } + : null; + case "failed": + return typeof value.retryable === "boolean" && + typeof value.errorCode === "string" && + [ + "session_not_ready", + "session_exited", + "injection_failed", + "model_turn_failed", + "delivery_timeout", + "persistence_failed", + "scope_unavailable", + ].includes(value.errorCode) + ? { + status: "failed", + retryable: value.retryable, + errorCode: value.errorCode as ProjectBootstrapErrorCode, + } + : null; + case "skipped": + return value.reason === "user-proceeded" || + value.reason === "map-not-empty" + ? { status: "skipped", reason: value.reason } + : null; + default: + return null; + } +} + +/** + * Accepts the final neutral shape plus the frozen pre-cutover session shape. + * Retired role and assignment fields are discarded and never become authority. + */ +export function migratePersistedProjectIdentity( + session: HarnessSession, +): PersistedIdentityMigration { + const raw = session as unknown as Record; + const direct = parseProjectAgentSession(raw.agentMapIdentity, session.id); + const legacy = isRecord(raw[LEGACY_METADATA_KEY]) + ? raw[LEGACY_METADATA_KEY] + : null; + const priorIdentity = + legacy && isRecord(legacy.identity) + ? parseProjectAgentSession(legacy.identity, session.id) + : null; + if (raw.agentMapIdentity !== undefined && !direct) { + return { outcome: "rejected" }; + } + if (raw[LEGACY_METADATA_KEY] !== undefined && (!legacy || !priorIdentity)) { + return { identity: direct ?? undefined, outcome: "rejected" }; + } + if (direct && priorIdentity && !sameProjectAgent(direct, priorIdentity)) { + return { outcome: "rejected" }; + } + let identity = direct ?? priorIdentity ?? undefined; + + let bootstrap: ProjectBootstrapMetadata | undefined; + const current = isRecord(raw.projectBootstrap) ? raw.projectBootstrap : null; + if (raw.projectBootstrap !== undefined && !current) { + return { identity, outcome: "rejected" }; + } + if (current) { + const currentIdentity = parseProjectAgentSession( + { + projectId: current.projectId, + userId: current.userId, + sessionId: current.targetSessionId, + }, + session.id, + ); + const state = parseBootstrapState(current.bootstrap); + if ( + !currentIdentity || + !state || + !Array.isArray(current.queuedInputIds) || + !current.queuedInputIds.every((id) => typeof id === "string") || + (identity && !sameProjectAgent(identity, currentIdentity)) + ) { + return { identity, outcome: "rejected" }; + } + identity ??= currentIdentity; + bootstrap = { + projectId: currentIdentity.projectId, + userId: currentIdentity.userId, + targetSessionId: currentIdentity.sessionId, + bootstrap: state, + queuedInputIds: [...current.queuedInputIds], + }; + } else if (legacy && priorIdentity) { + const state = parseBootstrapState(legacy.greeting); + if ( + !state || + !Array.isArray(legacy.queuedInputIds) || + !legacy.queuedInputIds.every((id) => typeof id === "string") + ) { + return { identity, outcome: "rejected" }; + } + bootstrap = { + projectId: priorIdentity.projectId, + userId: priorIdentity.userId, + targetSessionId: priorIdentity.sessionId, + bootstrap: state, + queuedInputIds: [...legacy.queuedInputIds], + }; + } + + const hadLegacyIdentity = + isRecord(raw.agentMapIdentity) && + ("role" in raw.agentMapIdentity || "assignment" in raw.agentMapIdentity); + return { + ...(identity ? { identity } : {}), + ...(bootstrap ? { bootstrap } : {}), + outcome: + hadLegacyIdentity || + raw[LEGACY_METADATA_KEY] !== undefined || + (!!current && !direct) + ? "migrated" + : "unchanged", + }; +} + +export function removeLegacyProjectSessionMetadata( + session: HarnessSession, +): void { + delete (session as unknown as Record)[LEGACY_METADATA_KEY]; +} diff --git a/packages/harness/src/core/project-session.test.ts b/packages/harness/src/core/project-session.test.ts new file mode 100644 index 00000000..92a769d2 --- /dev/null +++ b/packages/harness/src/core/project-session.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from "vitest"; + +import type { AgentMapWorkspaceState } from "../shared/agent-map.js"; +import type { HarnessSession } from "../shared/types.js"; +import { + buildFocusedProjectContext, + isProjectSessionDispatchAuthorized, + isWithinCurrentProject, + localProjectPrincipal, +} from "./project-session.js"; +import type { StudioProjectIdentity } from "./studio-project-catalog.js"; + +const projectId = "project_00000000-0000-4000-8000-000000000001"; +const projectRoot = "/Users/private/customer-secret-project"; +const project: StudioProjectIdentity = { + projectId, + identityVersion: 1, + displayName: "Private research", + rootBindings: [{ + id: "root_00000000-0000-4000-8000-000000000001", + repositoryId: "repo-private", + localRootRef: projectRoot, + status: "active", + }], + legacyWorkspaceKeys: ["private-workspace-key"], + createdAt: "2026-09-01T00:00:00.000Z", + updatedAt: "2026-09-01T00:00:00.000Z", +}; +const workspace: AgentMapWorkspaceState = { + projectId, + schemaVersion: 1, + recordVersion: 1, + confirmedRevisionId: null, + activeProposalId: null, + projectBuildPlanId: null, + createdAt: "2026-09-01T00:00:00.000Z", + updatedAt: "2026-09-01T00:00:00.000Z", +}; + +function session(id: string): HarnessSession { + return { + id, + agentSessionId: null, + harness: "codex", + cwd: projectRoot, + title: "Ordinary session", + status: "running", + createdAt: "2026-09-01T00:00:00.000Z", + lastActiveAt: "2026-09-01T00:00:00.000Z", + exitCode: null, + boundWorkflowPath: null, + ready: false, + agentMapIdentity: { projectId, sessionId: id, userId: "user-1" }, + }; +} + +describe("role-neutral project session", () => { + it("uses the authenticated user or a stable machine-local principal", () => { + expect(localProjectPrincipal("user-1", "machine-1")).toBe("user-1"); + expect(localProjectPrincipal(null, "machine-1")).toBe("local:machine-1"); + }); + + it("accepts only active project roots and their descendants", () => { + const withMissingBinding: StudioProjectIdentity = { + ...project, + rootBindings: [ + ...project.rootBindings, + { + id: "root_00000000-0000-4000-8000-000000000002", + repositoryId: null, + localRootRef: "/Users/private/inactive", + status: "missing", + }, + ], + }; + expect(isWithinCurrentProject(project, projectRoot)).toBe(true); + expect(isWithinCurrentProject(project, `${projectRoot}/agents/research`)).toBe(true); + expect(isWithinCurrentProject(project, `${projectRoot}-old`)).toBe(false); + expect(isWithinCurrentProject(project, "/Users/private")).toBe(false); + expect( + isWithinCurrentProject( + withMissingBinding, + "/Users/private/inactive/agent", + ), + ).toBe(false); + }); + + it("normalizes Windows separators without mixing path families", () => { + const windowsProject: StudioProjectIdentity = { + ...project, + rootBindings: [ + { + ...project.rootBindings[0]!, + localRootRef: "C:\\Users\\private\\project", + }, + ], + }; + + expect( + isWithinCurrentProject( + windowsProject, + "C:/Users/private/project/agents/research", + ), + ).toBe(true); + expect( + isWithinCurrentProject( + windowsProject, + "/Users/private/project/agents/research", + ), + ).toBe(false); + }); + + it("authorizes only the exact neutral principal inside its project", async () => { + const ordinary = session("ordinary"); + await expect(isProjectSessionDispatchAuthorized({ + session: ordinary, + currentPrincipal: () => "user-1", + resolveProject: async () => project, + })).resolves.toBe(true); + await expect(isProjectSessionDispatchAuthorized({ + session: ordinary, + currentPrincipal: () => "user-2", + resolveProject: async () => project, + })).resolves.toBe(false); + }); + + it("rechecks principal and session identity after project lookup", async () => { + let userId = "user-1"; + const ordinary = session("race"); + let resolve!: (value: StudioProjectIdentity | null) => void; + const authorization = isProjectSessionDispatchAuthorized({ + session: ordinary, + currentPrincipal: () => userId, + resolveProject: () => new Promise((done) => { resolve = done; }), + }); + await Promise.resolve(); + userId = "user-2"; + ordinary.agentMapIdentity = { projectId, sessionId: ordinary.id, userId }; + resolve(project); + await expect(authorization).resolves.toBe(false); + }); + + it("builds bounded path-free context without changing authority", () => { + const context = buildFocusedProjectContext({ + project, + workspace, + sessionId: "session-1", + userId: "user-1", + details: { warnings: Array.from({ length: 40 }, (_, i) => `warning-${i}-${"w".repeat(400)}`) }, + }); + const parsed = JSON.parse(context.split("\n")[2]!) as { + identity: Record; + project: { warnings: string[] }; + }; + expect(parsed.identity).toEqual({ projectId, sessionId: "session-1", userId: "user-1" }); + expect(parsed.project.warnings).toHaveLength(16); + expect(context).not.toContain('"role"'); + expect(context).not.toContain(projectRoot); + expect(context).not.toContain("private-workspace-key"); + expect(context.length).toBeLessThan(16_384); + }); +}); diff --git a/packages/harness/src/core/project-session.ts b/packages/harness/src/core/project-session.ts new file mode 100644 index 00000000..4d4de369 --- /dev/null +++ b/packages/harness/src/core/project-session.ts @@ -0,0 +1,182 @@ +import type { + AgentMapWorkspaceState, + ProjectAgentSession, + StudioProjectId, +} from "../shared/agent-map.js"; +import type { HarnessSession } from "../shared/types.js"; +import { isWithinDir } from "../shared/paths.js"; +import { canonicalGraphPath } from "./canonical-graph-path.js"; +import type { StudioProjectIdentity } from "./studio-project-catalog.js"; + +export interface FocusedProjectContextDetails { + confirmedRevision?: { + digest?: string | null; + summaries?: readonly string[]; + } | null; + activeProposal?: { + status?: string | null; + summary?: string | null; + } | null; + projectBuildPlan?: { + status?: string | null; + summary?: string | null; + } | null; + warnings?: readonly string[]; +} + +export function localProjectPrincipal( + userId: string | null, + machineId: string, +): string { + return userId ?? `local:${machineId}`; +} + +function isWithinRoot(root: string, candidate: string): boolean { + if (root.trim() === "" || candidate.trim() === "") return false; + try { + return isWithinDir(canonicalGraphPath(root), canonicalGraphPath(candidate)); + } catch { + return false; + } +} + +/** + * Whether a session cwd is equal to or descends from a current active project + * root. Durable project identity remains the authority boundary; containment + * is an additional server-side launch/resume safety check. + */ +export function isWithinCurrentProject( + project: StudioProjectIdentity, + cwd: string, +): boolean { + return project.rootBindings.some( + (binding) => + binding.status === "active" && isWithinRoot(binding.localRootRef, cwd), + ); +} + +function samePrincipal( + identity: ProjectAgentSession | null | undefined, + expected: ProjectAgentSession, +): boolean { + return Boolean( + identity && + identity.projectId === expected.projectId && + identity.userId === expected.userId && + identity.sessionId === expected.sessionId, + ); +} + +export async function isProjectSessionDispatchAuthorized(input: { + session: HarnessSession; + currentPrincipal: () => string; + resolveProject: ( + projectId: StudioProjectId, + ) => Promise; +}): Promise { + const identity = input.session.agentMapIdentity; + if (!identity || identity.sessionId !== input.session.id) return false; + const expected: ProjectAgentSession = { + projectId: identity.projectId, + sessionId: identity.sessionId, + userId: identity.userId, + }; + if (input.currentPrincipal() !== expected.userId) return false; + let project: StudioProjectIdentity | null; + try { + project = await input.resolveProject(expected.projectId); + } catch { + return false; + } + return Boolean( + project && + input.currentPrincipal() === expected.userId && + input.session.id === expected.sessionId && + samePrincipal(input.session.agentMapIdentity, expected) && + isWithinCurrentProject(project, input.session.cwd), + ); +} + +export interface FocusedProjectContextInput { + project: StudioProjectIdentity; + workspace: AgentMapWorkspaceState; + sessionId: string; + userId: string; + details?: FocusedProjectContextDetails; +} + +/** + * Path-free, role-neutral project context. It never changes the common prompt, + * tools, filesystem policy, or implementation authority. + */ +export function buildFocusedProjectContext( + input: FocusedProjectContextInput, +): string { + const { project, workspace } = input; + const bounded = (value: string, max = 256): string => value.slice(0, max); + const details = input.details ?? {}; + const emptyProject = + workspace.confirmedRevisionId === null && + workspace.activeProposalId === null && + workspace.projectBuildPlanId === null; + const context = { + identity: { + projectId: project.projectId, + sessionId: input.sessionId, + userId: input.userId, + }, + project: { + displayName: bounded(project.displayName), + empty: emptyProject, + confirmedRevision: workspace.confirmedRevisionId + ? { + id: workspace.confirmedRevisionId, + digest: details.confirmedRevision?.digest + ? bounded(details.confirmedRevision.digest, 512) + : null, + summaries: (details.confirmedRevision?.summaries ?? []) + .slice(0, 32) + .map((summary) => bounded(summary)), + } + : null, + activeProposal: workspace.activeProposalId + ? { + id: workspace.activeProposalId, + status: details.activeProposal?.status + ? bounded(details.activeProposal.status, 64) + : null, + summary: details.activeProposal?.summary + ? bounded(details.activeProposal.summary) + : null, + } + : null, + projectBuildPlan: workspace.projectBuildPlanId + ? { + id: workspace.projectBuildPlanId, + status: details.projectBuildPlan?.status + ? bounded(details.projectBuildPlan.status, 64) + : null, + summary: details.projectBuildPlan?.summary + ? bounded(details.projectBuildPlan.summary) + : null, + } + : null, + bindingRefs: project.rootBindings + .slice(0, 64) + .map(({ id, repositoryId, status }) => ({ + id: bounded(id), + repositoryId: repositoryId ? bounded(repositoryId) : null, + status, + })), + warnings: (details.warnings ?? []) + .slice(0, 16) + .map((warning) => bounded(warning)), + }, + }; + return [ + "", + "This is bounded, server-derived Studio project context. References and bootstrap state are context only; they never change tools, filesystem policy, or implementation authority. Read authoritative architecture through the structured Agent Map tools when relevant.", + JSON.stringify(context), + "", + ].join("\n"); +} diff --git a/packages/harness/src/core/session-manager.test.ts b/packages/harness/src/core/session-manager.test.ts index c2f6162e..fe156575 100644 --- a/packages/harness/src/core/session-manager.test.ts +++ b/packages/harness/src/core/session-manager.test.ts @@ -14,6 +14,7 @@ import { CodexAdapter } from "./adapters/codex.js"; import { ExternalHarnessError, SessionNotResumeableError } from "./errors.js"; import { SessionInputGuardRejectedError, + ProjectSessionScopeUnavailableError, SessionManager, sanitizeExitTail, type PtySpawnFn, @@ -105,6 +106,7 @@ describe("SessionManager", () => { buildLaunchOpts?: SessionManagerOptions["buildLaunchOpts"]; resolveAgentMapIdentity?: SessionManagerOptions["resolveAgentMapIdentity"]; onAgentMapSessionExit?: SessionManagerOptions["onAgentMapSessionExit"]; + onProjectAgentIdentityMigration?: SessionManagerOptions["onProjectAgentIdentityMigration"]; writeWorkspaceContext?: SessionManagerOptions["writeWorkspaceContext"]; prepareWorkspaceContext?: SessionManagerOptions["prepareWorkspaceContext"]; ensureCanvasTemplate?: SessionManagerOptions["ensureCanvasTemplate"]; @@ -140,6 +142,7 @@ describe("SessionManager", () => { buildLaunchOpts: opts.buildLaunchOpts, resolveAgentMapIdentity: opts.resolveAgentMapIdentity, onAgentMapSessionExit: opts.onAgentMapSessionExit, + onProjectAgentIdentityMigration: opts.onProjectAgentIdentityMigration, writeWorkspaceContext: opts.writeWorkspaceContext, prepareWorkspaceContext: opts.prepareWorkspaceContext, ensureCanvasTemplate: opts.ensureCanvasTemplate, @@ -183,6 +186,330 @@ describe("SessionManager", () => { expect(reloaded.get(session.id)?.status).toBe("exited"); }); + it("normalizes legacy planner and manual identities without changing durable session state", async () => { + const planner = { + id: "planner-session", + agentSessionId: "provider-planner", + harness: "claude-code", + cwd: "/tmp/project/packages/planner", + title: "Planner renamed by user", + status: "exited", + createdAt: "2026-01-01T00:00:00.000Z", + lastActiveAt: "2026-01-02T00:00:00.000Z", + theme: "dark", + exitCode: 0, + exitTail: null, + boundWorkflowPath: "/tmp/project/packages/planner/src/workflow.ts", + rehydratedFrom: "planner-ancestor", + ready: false, + planning: { + identity: { + projectId: "project-1", + userId: "user-1", + sessionId: "planner-session", + role: "map-planner", + }, + greeting: { status: "generating", attemptId: "attempt-7" }, + queuedInputIds: ["input-1"], + }, + } as const; + const manual = { + id: "manual-session", + agentSessionId: "provider-manual", + harness: "claude-code", + cwd: "/tmp/project/packages/manual", + title: "Manual coding session", + status: "exited", + createdAt: "2026-01-03T00:00:00.000Z", + lastActiveAt: "2026-01-04T00:00:00.000Z", + theme: "light", + exitCode: 23, + exitTail: "provider-visible transcript failure", + boundWorkflowPath: "/tmp/project/packages/manual/src/workflow.ts", + rehydratedFrom: "manual-ancestor", + ready: false, + agentMapIdentity: { + projectId: "project-1", + userId: "user-1", + sessionId: "manual-session", + role: "agent-builder", + assignment: { kind: "unplanned" }, + contextThatMustNotBecomeAuthority: "ignored", + }, + } as const; + await writeFile(sessionsPath, JSON.stringify([planner, manual]), "utf8"); + const migrations = vi.fn(); + const { manager } = makeManager({ + onProjectAgentIdentityMigration: migrations, + }); + + await manager.init(); + + expect(manager.list()).toHaveLength(2); + expect(manager.get(planner.id)).toMatchObject({ + id: planner.id, + agentSessionId: planner.agentSessionId, + cwd: planner.cwd, + title: planner.title, + status: planner.status, + createdAt: planner.createdAt, + lastActiveAt: planner.lastActiveAt, + theme: planner.theme, + exitCode: planner.exitCode, + exitTail: planner.exitTail, + boundWorkflowPath: planner.boundWorkflowPath, + rehydratedFrom: planner.rehydratedFrom, + ready: planner.ready, + agentMapIdentity: { + projectId: "project-1", + userId: "user-1", + sessionId: planner.id, + }, + projectBootstrap: { + projectId: "project-1", + userId: "user-1", + targetSessionId: planner.id, + bootstrap: { status: "generating", attemptId: "attempt-7" }, + queuedInputIds: ["input-1"], + }, + }); + expect(manager.get(planner.id)?.agentMapIdentity).not.toHaveProperty("role"); + expect(manager.get(manual.id)).toMatchObject({ + id: manual.id, + agentSessionId: manual.agentSessionId, + cwd: manual.cwd, + title: manual.title, + status: manual.status, + createdAt: manual.createdAt, + lastActiveAt: manual.lastActiveAt, + theme: manual.theme, + exitCode: manual.exitCode, + exitTail: manual.exitTail, + boundWorkflowPath: manual.boundWorkflowPath, + rehydratedFrom: manual.rehydratedFrom, + ready: manual.ready, + agentMapIdentity: { + projectId: "project-1", + userId: "user-1", + sessionId: manual.id, + }, + }); + expect(manager.get(manual.id)?.agentMapIdentity).toEqual({ + projectId: "project-1", + userId: "user-1", + sessionId: manual.id, + }); + expect(migrations.mock.calls).toEqual([ + [{ sessionId: planner.id, outcome: "migrated" }], + [{ sessionId: manual.id, outcome: "migrated" }], + ]); + + const persisted = JSON.parse( + await readFile(sessionsPath, "utf8"), + ) as HarnessSession[]; + expect(persisted.map((session) => session.id)).toEqual([ + planner.id, + manual.id, + ]); + expect(persisted[0]?.agentSessionId).toBe(planner.agentSessionId); + expect(persisted[1]?.agentSessionId).toBe(manual.agentSessionId); + }); + + it("restores a scope-unavailable bootstrap failure and resumes once authority is valid", async () => { + const identity = { + projectId: "project-1", + userId: "user-1", + sessionId: "bootstrap-scope-failure", + }; + const session: HarnessSession = { + id: identity.sessionId, + agentSessionId: "provider-scope-failure", + harness: "claude-code", + cwd: "/tmp/project", + title: "Keep my conversation", + status: "exited", + createdAt: "2026-01-01T00:00:00.000Z", + lastActiveAt: "2026-01-02T00:00:00.000Z", + exitCode: 0, + boundWorkflowPath: null, + ready: false, + agentMapIdentity: identity, + projectBootstrap: { + projectId: identity.projectId, + userId: identity.userId, + targetSessionId: identity.sessionId, + bootstrap: { + status: "failed", + errorCode: "scope_unavailable", + retryable: false, + }, + queuedInputIds: ["retained-input"], + }, + }; + await writeFile(sessionsPath, JSON.stringify([session]), "utf8"); + const migrations = vi.fn(); + const { manager, adapter, spawns } = makeManager({ + resolveAgentMapIdentity: async () => identity, + onProjectAgentIdentityMigration: migrations, + }); + + await manager.init(); + + expect(manager.get(session.id)?.projectBootstrap).toEqual(session.projectBootstrap); + expect(migrations).not.toHaveBeenCalled(); + await expect(manager.resume(session.id)).resolves.toMatchObject({ + id: session.id, + agentSessionId: session.agentSessionId, + title: session.title, + agentMapIdentity: identity, + projectBootstrap: session.projectBootstrap, + status: "running", + }); + expect(adapter.resume).toHaveBeenCalledTimes(1); + expect(spawns).toHaveLength(1); + }); + + it("preserves malformed or conflicting legacy identity records without deleting or duplicating them", async () => { + const malformed = { + id: "malformed-session", + agentSessionId: "provider-malformed", + harness: "claude-code", + cwd: "/tmp/project/malformed", + title: "Malformed identity", + status: "exited", + createdAt: "2026-01-01T00:00:00.000Z", + lastActiveAt: "2026-01-01T00:00:00.000Z", + exitCode: 0, + boundWorkflowPath: null, + ready: false, + agentMapIdentity: { + projectId: "project-1", + userId: "user-1", + sessionId: "wrong-session", + }, + } as const; + const conflicting = { + id: "conflicting-session", + agentSessionId: "provider-conflicting", + harness: "claude-code", + cwd: "/tmp/project/conflicting", + title: "Conflicting identity", + status: "exited", + createdAt: "2026-01-02T00:00:00.000Z", + lastActiveAt: "2026-01-02T00:00:00.000Z", + exitCode: 0, + boundWorkflowPath: null, + ready: false, + agentMapIdentity: { + projectId: "project-1", + userId: "user-1", + sessionId: "conflicting-session", + }, + planning: { + identity: { + projectId: "project-2", + userId: "user-2", + sessionId: "conflicting-session", + role: "map-planner", + }, + greeting: { status: "pending" }, + queuedInputIds: [], + }, + } as const; + await writeFile( + sessionsPath, + JSON.stringify([malformed, conflicting]), + "utf8", + ); + const migrations = vi.fn(); + const { manager, adapter, spawns } = makeManager({ + onProjectAgentIdentityMigration: migrations, + }); + + await manager.init(); + + expect(manager.list()).toHaveLength(2); + expect(manager.get(malformed.id)).toEqual(malformed); + expect(manager.get(conflicting.id)).toEqual(conflicting); + expect(migrations.mock.calls).toEqual([ + [{ sessionId: malformed.id, outcome: "rejected" }], + [{ sessionId: conflicting.id, outcome: "rejected" }], + ]); + await expect(manager.resume(malformed.id)).rejects.toBeInstanceOf( + ProjectSessionScopeUnavailableError, + ); + await expect(manager.resume(conflicting.id)).rejects.toBeInstanceOf( + ProjectSessionScopeUnavailableError, + ); + expect(adapter.canResume).not.toHaveBeenCalled(); + expect(spawns).toEqual([]); + const persisted = JSON.parse( + await readFile(sessionsPath, "utf8"), + ) as unknown[]; + expect(persisted).toEqual([malformed, conflicting]); + }); + + it("preserves present-but-malformed legacy planner and bootstrap records", async () => { + const base = { + agentSessionId: "provider-session", + harness: "claude-code", + cwd: "/tmp/project", + title: "Preserve me", + status: "exited", + createdAt: "2026-01-01T00:00:00.000Z", + lastActiveAt: "2026-01-01T00:00:00.000Z", + exitCode: 0, + boundWorkflowPath: null, + ready: false, + } as const; + const malformedPlanning = { + ...base, + id: "malformed-planning", + planning: { greeting: { status: "pending" }, queuedInputIds: [] }, + }; + const malformedBootstrap = { + ...base, + id: "malformed-bootstrap", + agentSessionId: "provider-bootstrap", + agentMapIdentity: { + projectId: "project-1", + userId: "user-1", + sessionId: "malformed-bootstrap", + }, + projectBootstrap: "not-an-object", + }; + await writeFile( + sessionsPath, + JSON.stringify([malformedPlanning, malformedBootstrap]), + "utf8", + ); + const migrations = vi.fn(); + const { manager, adapter, spawns } = makeManager({ + onProjectAgentIdentityMigration: migrations, + }); + + await manager.init(); + + expect(manager.list()).toHaveLength(2); + expect(manager.get(malformedPlanning.id)).toEqual(malformedPlanning); + expect(manager.get(malformedBootstrap.id)).toEqual(malformedBootstrap); + expect(migrations.mock.calls).toEqual([ + [{ sessionId: malformedPlanning.id, outcome: "rejected" }], + [{ sessionId: malformedBootstrap.id, outcome: "rejected" }], + ]); + await expect(manager.resume(malformedPlanning.id)).rejects.toBeInstanceOf( + ProjectSessionScopeUnavailableError, + ); + await expect(manager.resume(malformedBootstrap.id)).rejects.toBeInstanceOf( + ProjectSessionScopeUnavailableError, + ); + expect(adapter.canResume).not.toHaveBeenCalled(); + expect(spawns).toEqual([]); + expect(JSON.parse(await readFile(sessionsPath, "utf8")) as unknown).toEqual( + [malformedPlanning, malformedBootstrap], + ); + }); + it("routes write() and resize() to the underlying pty", async () => { const { manager, spawns } = makeManager(); const session = await manager.create({ cwd: "/tmp/proj", harness: "claude-code" }); @@ -1876,8 +2203,6 @@ describe("SessionManager", () => { projectId: "project-1", userId: "user-1", sessionId, - role: "agent-builder" as const, - assignment: { kind: "unplanned" as const }, })); const { manager, spawns } = makeManager({ buildLaunchOpts, @@ -1887,8 +2212,6 @@ describe("SessionManager", () => { const session = await manager.create({ cwd: "/tmp/proj", harness: "claude-code" }); expect(session.agentMapIdentity).toMatchObject({ sessionId: session.id, - role: "agent-builder", - assignment: { kind: "unplanned" }, }); expect(buildLaunchOpts).toHaveBeenLastCalledWith( session.id, @@ -1907,6 +2230,56 @@ describe("SessionManager", () => { ); }); + it("revalidates project scope after launch preparation and before spawning a new pty", async () => { + const resolveAgentMapIdentity = vi + .fn() + .mockImplementationOnce(async (sessionId: string) => ({ + projectId: "project-1", + userId: "user-1", + sessionId, + })) + .mockResolvedValueOnce(undefined); + const onAgentMapSessionExit = vi.fn(); + const { manager, adapter, spawns } = makeManager({ + resolveAgentMapIdentity, + onAgentMapSessionExit, + }); + + await expect( + manager.create({ cwd: "/tmp/proj", harness: "claude-code" }), + ).rejects.toBeInstanceOf(ProjectSessionScopeUnavailableError); + + expect(adapter.launch).toHaveBeenCalledOnce(); + expect(spawns).toHaveLength(0); + expect(manager.list()).toHaveLength(1); + expect(manager.list()[0]).toMatchObject({ + status: "exited", + agentMapIdentity: { + projectId: "project-1", + userId: "user-1", + }, + }); + expect(onAgentMapSessionExit).toHaveBeenCalledWith(manager.list()[0]!.id); + }); + + it("rejects a resumed session when its project authority disappears", async () => { + let available = true; + const resolveAgentMapIdentity = vi.fn(async (sessionId: string) => available + ? { projectId: "project-1", userId: "user-1", sessionId } + : undefined); + const { manager, adapter, spawns } = makeManager({ resolveAgentMapIdentity }); + const session = await manager.create({ cwd: "/tmp/project", harness: "claude-code" }); + await manager.setAgentSessionId(session.id, "provider-session"); + spawns[0]?.emitExit(0); + await manager.flush(); + const before = structuredClone(session); + available = false; + await expect(manager.resume(session.id)).rejects.toBeInstanceOf(ProjectSessionScopeUnavailableError); + expect(manager.get(session.id)).toEqual(before); + expect(adapter.resume).not.toHaveBeenCalled(); + expect(spawns).toHaveLength(1); + }); + it("registerHistorical() creates an exited placeholder session resumable later", async () => { const { manager } = makeManager(); const session = await manager.registerHistorical({ diff --git a/packages/harness/src/core/session-manager.ts b/packages/harness/src/core/session-manager.ts index 6e88c404..2a4b4463 100644 --- a/packages/harness/src/core/session-manager.ts +++ b/packages/harness/src/core/session-manager.ts @@ -23,8 +23,9 @@ import { } from "../shared/types.js"; import type { PlannerSessionMetadata, - PlanningSessionIdentity, + ProjectAgentSession, } from "../shared/agent-map.js"; +import { migratePersistedProjectIdentity } from "./project-session-legacy-migration.js"; import { expandHome } from "./paths.js"; import { initialBracketedPasteState, @@ -66,6 +67,23 @@ export class SessionInputGuardRejectedError extends Error { } } +export class ProjectSessionScopeUnavailableError extends Error { + readonly code = "PROJECT_SESSION_SCOPE_UNAVAILABLE"; + + constructor(readonly sessionId: string) { + super("the session's Studio project scope could not be revalidated"); + this.name = "ProjectSessionScopeUnavailableError"; + } +} + +function sameProjectAgent(left: ProjectAgentSession, right: ProjectAgentSession): boolean { + return left.projectId === right.projectId && left.userId === right.userId && left.sessionId === right.sessionId; +} + +const neutralPrincipal = (identity: ProjectAgentSession): ProjectAgentSession => ({ + projectId: identity.projectId, userId: identity.userId, sessionId: identity.sessionId, +}); + // node-pty is a native module. Load it lazily so a missing/broken prebuild on // an unsupported platform surfaces as a spawn-time error instead of crashing // the whole server at import time. @@ -309,7 +327,7 @@ export type LaunchOptsBuilder = ( promptAppendix?: string; /** Native CLI notice shown before a fresh session's first prompt. */ sessionStartSystemMessage?: string; - agentMapIdentity?: PlanningSessionIdentity; + agentMapIdentity?: ProjectAgentSession; /** Server-composed secret launch metadata, never accepted from REST. */ agentMapMcp?: { url: string; bearerToken: string }; resume?: boolean; @@ -335,8 +353,12 @@ export interface SessionManagerOptions { resolveAgentMapIdentity?: ( sessionId: string, cwd: string, - persisted?: PlanningSessionIdentity, - ) => Promise; + persisted?: ProjectAgentSession, + ) => Promise; + onProjectAgentIdentityMigration?: (event: { + sessionId: string; + outcome: "migrated" | "rejected"; + }) => void; /** Revokes launch capabilities/transports after every exit path. */ onAgentMapSessionExit?: (sessionId: string) => void | Promise; now?: () => string; @@ -396,7 +418,7 @@ export interface TrustedSessionCreateOptions { /** Server-authored only. Never populated from CreateSessionRequest. */ planning?: (sessionId: string) => PlannerSessionMetadata; /** Future E5 seam for a server-authored planned builder assignment. */ - agentMapIdentity?: (sessionId: string) => PlanningSessionIdentity; + agentMapIdentity?: (sessionId: string) => ProjectAgentSession; /** Focused trusted context composed into the existing system prompt. */ promptAppendix?: (sessionId: string) => string; /** Server-authored native CLI orientation for a newly created session. */ @@ -506,6 +528,8 @@ export class SessionManager { private readonly buildLaunchOpts: LaunchOptsBuilder; private readonly resolveAgentMapIdentity: | SessionManagerOptions["resolveAgentMapIdentity"]; + private readonly onProjectAgentIdentityMigration: SessionManagerOptions["onProjectAgentIdentityMigration"]; + private readonly rejectedProjectSessionMetadata = new Set(); private readonly onAgentMapSessionExit: | SessionManagerOptions["onAgentMapSessionExit"]; private readonly now: () => string; @@ -556,6 +580,7 @@ export class SessionManager { this.buildLaunchOpts = options.buildLaunchOpts ?? defaultBuildLaunchOpts; this.resolveAgentMapIdentity = options.resolveAgentMapIdentity; this.onAgentMapSessionExit = options.onAgentMapSessionExit; + this.onProjectAgentIdentityMigration = options.onProjectAgentIdentityMigration; this.now = options.now ?? (() => new Date().toISOString()); this.generateId = options.generateId ?? randomUUID; this.writeSessionRegistry = options.writeSessionRegistry; @@ -587,6 +612,37 @@ export class SessionManager { } let dirty = false; for (const session of persisted) { + const migration = migratePersistedProjectIdentity(session); + if (migration.outcome === "rejected") { + this.rejectedProjectSessionMetadata.add(session.id); + } + if (migration.outcome === "migrated") { + if (migration.identity) { + session.agentMapIdentity = structuredClone(migration.identity); + } else { + delete session.agentMapIdentity; + } + if (migration.bootstrap) { + session.projectBootstrap = structuredClone(migration.bootstrap); + } else { + delete session.projectBootstrap; + } + // Planner-era metadata is never live authority after normalization. + // Its on-disk input queue is migrated by ProjectBootstrapCoordinator. + // The retired startup still reads its private lifecycle projection until + // bootstrap activation. Its role fields never enter session authority. + dirty = true; + } + if (migration.outcome !== "unchanged") { + try { + this.onProjectAgentIdentityMigration?.({ + sessionId: session.id, + outcome: migration.outcome, + }); + } catch { + // Observability is best effort and cannot affect session recovery. + } + } if (session.status !== "exited") { session.status = "exited"; session.exitCode = session.exitCode ?? null; @@ -641,6 +697,23 @@ export class SessionManager { return adapter; } + /** Recheck the immutable project principal immediately before spawning. */ + private async revalidateAgentMapIdentity( + sessionId: string, + cwd: string, + expected: ProjectAgentSession | undefined, + ): Promise { + if (!expected || !this.resolveAgentMapIdentity) return; + const current = await this.resolveAgentMapIdentity( + sessionId, + cwd, + expected, + ); + if (!current || !sameProjectAgent(current, expected)) { + throw new ProjectSessionScopeUnavailableError(sessionId); + } + } + async create( req: CreateSessionRequest, trusted: TrustedSessionCreateOptions = {}, @@ -649,9 +722,10 @@ export class SessionManager { const adapter = this.getAdapter(req.harness); const planning = trusted.planning?.(id); const trustedIdentity = trusted.agentMapIdentity?.(id) ?? planning?.identity; - const agentMapIdentity = this.resolveAgentMapIdentity + const resolvedIdentity = this.resolveAgentMapIdentity ? await this.resolveAgentMapIdentity(id, req.cwd, trustedIdentity) : trustedIdentity; + const agentMapIdentity = resolvedIdentity ? neutralPrincipal(resolvedIdentity) : undefined; const promptAppendix = trusted.promptAppendix?.(id); const sessionStartSystemMessage = trusted.sessionStartSystemMessage?.(id); const launchContext = @@ -713,6 +787,7 @@ export class SessionManager { // "running" — it must never show a bare empty iframe because nothing's // been written to .sapiom/canvas/index.html yet. await this.ensureCanvasTemplate(session.cwd); + await this.revalidateAgentMapIdentity(id, session.cwd, agentMapIdentity); await this.spawn(session, spec); } catch (err) { // The first persist may itself be the failure, so reconciliation is @@ -790,6 +865,7 @@ export class SessionManager { ): Promise { const session = this.sessions.get(id); if (!session) throw new UnknownSessionError(id); + if (this.rejectedProjectSessionMetadata.has(id)) throw new ProjectSessionScopeUnavailableError(id); if (!session.agentSessionId) { throw new SessionNotResumeableError(id); } @@ -816,13 +892,14 @@ export class SessionManager { session.planning = structuredClone(trusted.planning); } const trustedIdentity = trusted.planning?.identity; - const agentMapIdentity = this.resolveAgentMapIdentity - ? await this.resolveAgentMapIdentity( - id, - session.cwd, - trustedIdentity ?? session.agentMapIdentity, - ) - : trustedIdentity ?? session.agentMapIdentity; + const priorIdentity = session.agentMapIdentity ?? trustedIdentity; + const resolvedIdentity = this.resolveAgentMapIdentity + ? await this.resolveAgentMapIdentity(id, session.cwd, priorIdentity) + : priorIdentity; + if (priorIdentity && (!resolvedIdentity || !sameProjectAgent(priorIdentity, resolvedIdentity))) { + throw new ProjectSessionScopeUnavailableError(id); + } + const agentMapIdentity = resolvedIdentity ? neutralPrincipal(resolvedIdentity) : undefined; if (agentMapIdentity) session.agentMapIdentity = structuredClone(agentMapIdentity); else delete session.agentMapIdentity; @@ -874,6 +951,7 @@ export class SessionManager { // — a session from before the canvas kit existed, or one whose canvas // file was somehow deleted, still gets a live pane on resume. await this.ensureCanvasTemplate(session.cwd); + await this.revalidateAgentMapIdentity(id, session.cwd, agentMapIdentity); await this.spawn(session, spec); } catch (err) { // Same best-effort reconciliation as create(): the first persist can be @@ -1572,6 +1650,13 @@ export class SessionManager { const session = this.sessions.get(id); if (!session) throw new UnknownSessionError(id); session.planning = structuredClone(metadata); + session.projectBootstrap = { + projectId: metadata.identity.projectId, + userId: metadata.identity.userId, + targetSessionId: metadata.identity.sessionId, + bootstrap: metadata.greeting, + queuedInputIds: [...metadata.queuedInputIds], + }; await this.persist(); this.emitStatus(session); } diff --git a/packages/harness/src/core/session-record.test.ts b/packages/harness/src/core/session-record.test.ts index 3411295f..2e3398f0 100644 --- a/packages/harness/src/core/session-record.test.ts +++ b/packages/harness/src/core/session-record.test.ts @@ -47,10 +47,18 @@ function event(spec: EventSpec): AnalyticsEvent { }; } -const prompt = (ts: string, text: string, rest: Partial = {}): AnalyticsEvent => +const prompt = ( + ts: string, + text: string, + rest: Partial = {}, +): AnalyticsEvent => event({ type: "prompt.submitted", ts, payload: { prompt: text }, ...rest }); -const tool = (ts: string, name: string, rest: Partial = {}): AnalyticsEvent => +const tool = ( + ts: string, + name: string, + rest: Partial = {}, +): AnalyticsEvent => event({ type: "tool.call", ts, @@ -58,7 +66,11 @@ const tool = (ts: string, name: string, rest: Partial = {}): Analytic ...rest, }); -const completed = (ts: string, text: string | null, rest: Partial = {}): AnalyticsEvent => +const completed = ( + ts: string, + text: string | null, + rest: Partial = {}, +): AnalyticsEvent => event({ type: "turn.completed", ts, @@ -73,12 +85,20 @@ const completed = (ts: string, text: string | null, rest: Partial = { describe("foldSessionRecord", () => { it("folds prompt → tool calls → completion into one closed turn", () => { const record = foldSessionRecord([ - event({ type: "session.start", ts: "2026-07-01T10:00:00.000Z", payload: { cwd: "/repo" } }), + event({ + type: "session.start", + ts: "2026-07-01T10:00:00.000Z", + payload: { cwd: "/repo" }, + }), prompt("2026-07-01T10:00:01.000Z", "add the screening step"), tool("2026-07-01T10:00:02.000Z", "Read"), tool("2026-07-01T10:00:03.000Z", "Edit"), completed("2026-07-01T10:00:04.000Z", "Added it."), - event({ type: "session.end", ts: "2026-07-01T10:00:05.000Z", payload: { reason: "exit" } }), + event({ + type: "session.end", + ts: "2026-07-01T10:00:05.000Z", + payload: { reason: "exit" }, + }), ]); expect(record.cwd).toBe("/repo"); @@ -96,7 +116,10 @@ describe("foldSessionRecord", () => { completedAt: "2026-07-01T10:00:04.000Z", incomplete: false, }); - expect(record.turns[0].toolCalls.map((call) => call.name)).toEqual(["Read", "Edit"]); + expect(record.turns[0].toolCalls.map((call) => call.name)).toEqual([ + "Read", + "Edit", + ]); }); it("orders by (ts, seq), so a seq restart across a resume doesn't reorder turns", () => { @@ -110,8 +133,13 @@ describe("foldSessionRecord", () => { completed("2026-07-01T11:00:02.000Z", "second reply", { seq: 1 }), ]); - expect(record.turns.map((turn) => turn.prompt)).toEqual(["first", "second"]); - expect(record.turns[1].toolCalls.map((call) => call.name)).toEqual(["Bash"]); + expect(record.turns.map((turn) => turn.prompt)).toEqual([ + "first", + "second", + ]); + expect(record.turns[1].toolCalls.map((call) => call.name)).toEqual([ + "Bash", + ]); // Sorting by seq alone would have put the resume's events first. expect(record.turns[0].assistantText).toBe("first reply"); }); @@ -121,7 +149,10 @@ describe("foldSessionRecord", () => { completed("2026-07-01T10:00:00.000Z", "reply", { seq: 2 }), prompt("2026-07-01T10:00:00.000Z", "ask", { seq: 1 }), ]); - expect(ordered.map((e) => e.type)).toEqual(["prompt.submitted", "turn.completed"]); + expect(ordered.map((e) => e.type)).toEqual([ + "prompt.submitted", + "turn.completed", + ]); }); it("keeps a trailing open turn and marks it incomplete", () => { @@ -132,7 +163,11 @@ describe("foldSessionRecord", () => { ]); expect(record.turns).toHaveLength(1); - expect(record.turns[0]).toMatchObject({ prompt: "deploy it", incomplete: true, completedAt: null }); + expect(record.turns[0]).toMatchObject({ + prompt: "deploy it", + incomplete: true, + completedAt: null, + }); expect(record.turns[0].toolCalls).toHaveLength(1); expect(record.limitations).toContain("incomplete-final-turn"); }); @@ -140,7 +175,11 @@ describe("foldSessionRecord", () => { it("session.end never closes the open turn — a session that died mid-turn says so", () => { const record = foldSessionRecord([ prompt("2026-07-01T10:00:00.000Z", "deploy it"), - event({ type: "session.end", ts: "2026-07-01T10:00:01.000Z", payload: { reason: "other" } }), + event({ + type: "session.end", + ts: "2026-07-01T10:00:01.000Z", + payload: { reason: "other" }, + }), ]); expect(record.turns[0].incomplete).toBe(true); @@ -164,20 +203,50 @@ describe("foldSessionRecord", () => { }); it("gives a turn.completed with nothing open its own promptless turn", () => { - const record = foldSessionRecord([completed("2026-07-01T10:00:00.000Z", "unprompted")]); + const record = foldSessionRecord([ + completed("2026-07-01T10:00:00.000Z", "unprompted"), + ]); + expect(record.turns).toHaveLength(1); + expect(record.turns[0]).toMatchObject({ + prompt: null, + assistantText: "unprompted", + incomplete: false, + }); + }); + + it("hides project-bootstrap control input while retaining its assistant response", () => { + const record = foldSessionRecord([ + event({ + type: "prompt.submitted", + ts: "2026-07-01T10:00:00.000Z", + payload: { + prompt: "private infrastructure bootstrap instruction", + projectBootstrapOrigin: "infrastructure", + projectBootstrapAttemptId: "attempt-1", + }, + }), + completed("2026-07-01T10:00:01.000Z", "What would you like to build?"), + ]); + + expect(record.turnCount).toBe(0); expect(record.turns).toHaveLength(1); - expect(record.turns[0]).toMatchObject({ prompt: null, assistantText: "unprompted", incomplete: false }); + expect(record.turns[0]).toMatchObject({ + prompt: null, + assistantText: "What would you like to build?", + }); + expect(JSON.stringify(record)).not.toContain( + "private infrastructure bootstrap instruction", + ); }); - it("hides planner control input while retaining its assistant greeting", () => { + it("keeps released pre-unification bootstrap events out of the human transcript", () => { const record = foldSessionRecord([ event({ type: "prompt.submitted", ts: "2026-07-01T10:00:00.000Z", payload: { - prompt: "private infrastructure greeting instruction", - plannerOrigin: "infrastructure", - plannerAttemptId: "attempt-1", + prompt: "private released bootstrap instruction", + ["plannerOrigin"]: "infrastructure", }, }), completed("2026-07-01T10:00:01.000Z", "What would you like to build?"), @@ -187,10 +256,11 @@ describe("foldSessionRecord", () => { expect(record.turns).toHaveLength(1); expect(record.turns[0]).toMatchObject({ prompt: null, + promptAt: null, assistantText: "What would you like to build?", }); expect(JSON.stringify(record)).not.toContain( - "private infrastructure greeting instruction", + "private released bootstrap instruction", ); }); @@ -203,9 +273,15 @@ describe("foldSessionRecord", () => { ]); expect(record.turns).toHaveLength(2); - expect(record.turns[0]).toMatchObject({ prompt: "first", incomplete: true }); + expect(record.turns[0]).toMatchObject({ + prompt: "first", + incomplete: true, + }); expect(record.turns[0].toolCalls).toHaveLength(1); - expect(record.turns[1]).toMatchObject({ prompt: "actually, do this instead", incomplete: false }); + expect(record.turns[1]).toMatchObject({ + prompt: "actually, do this instead", + incomplete: false, + }); // The incomplete turn isn't the LAST one, so that limitation doesn't apply. expect(record.limitations).not.toContain("incomplete-final-turn"); }); @@ -246,15 +322,28 @@ describe("foldSessionRecord", () => { it("flags missing assistant text — the Codex shape (no Stop-hook message)", () => { const record = foldSessionRecord([ - event({ type: "session.start", ts: "2026-07-01T10:00:00.000Z", payload: { cwd: "/repo" } }), + event({ + type: "session.start", + ts: "2026-07-01T10:00:00.000Z", + payload: { cwd: "/repo" }, + }), prompt("2026-07-01T10:00:01.000Z", "summarize this"), tool("2026-07-01T10:00:02.000Z", "shell"), // The codex tailer emits Stop with no last_assistant_message, so the // normalizer records assistantText: null and no model/usage. - event({ type: "turn.completed", ts: "2026-07-01T10:00:03.000Z", payload: { stopHookActive: false, assistantText: null } }), + event({ + type: "turn.completed", + ts: "2026-07-01T10:00:03.000Z", + payload: { stopHookActive: false, assistantText: null }, + }), ]); - expect(record.turns[0]).toMatchObject({ assistantText: null, model: null, usage: null, incomplete: false }); + expect(record.turns[0]).toMatchObject({ + assistantText: null, + model: null, + usage: null, + incomplete: false, + }); expect(record.turns[0].toolCalls).toHaveLength(1); expect(record.limitations).toContain("missing-assistant-text"); }); @@ -262,7 +351,11 @@ describe("foldSessionRecord", () => { it("ignores UI-interaction events, counting them but never rendering them as turns", () => { const record = foldSessionRecord([ prompt("2026-07-01T10:00:00.000Z", "go"), - event({ type: "macro.invoked", ts: "2026-07-01T10:00:01.000Z", payload: { surface: "ui" } }), + event({ + type: "macro.invoked", + ts: "2026-07-01T10:00:01.000Z", + payload: { surface: "ui" }, + }), completed("2026-07-01T10:00:02.000Z", "done"), ]); @@ -272,7 +365,13 @@ describe("foldSessionRecord", () => { it("is empty, not broken, for a session with no events", () => { const record = foldSessionRecord([]); - expect(record).toMatchObject({ turns: [], turnCount: 0, eventCount: 0, limitations: [], reconstructed: true }); + expect(record).toMatchObject({ + turns: [], + turnCount: 0, + eventCount: 0, + limitations: [], + reconstructed: true, + }); }); }); @@ -295,7 +394,11 @@ describe("createSessionRecordReader", () => { }); async function writeEvents(events: AnalyticsEvent[]): Promise { - await fs.writeFile(filePath, events.map((e) => `${JSON.stringify(e)}\n`).join(""), "utf8"); + await fs.writeFile( + filePath, + events.map((e) => `${JSON.stringify(e)}\n`).join(""), + "utf8", + ); } it("reads one session's record out of an interleaved log", async () => { @@ -329,7 +432,10 @@ describe("createSessionRecordReader", () => { ]); // A half-written line with no trailing newline — exactly what a crash // between write() and flush leaves behind. - const torn = JSON.stringify(tool("2026-07-01T10:00:02.000Z", "Read")).slice(0, 60); + const torn = JSON.stringify(tool("2026-07-01T10:00:02.000Z", "Read")).slice( + 0, + 60, + ); await fs.appendFile(filePath, torn, "utf8"); const reader = createSessionRecordReader(createEventStore(filePath)); @@ -343,7 +449,9 @@ describe("createSessionRecordReader", () => { it("looks a record up by the agent's session id too (transcript-only rows)", async () => { await writeEvents([ prompt("2026-07-01T10:00:00.000Z", "go", { agentSessionId: "agent-1" }), - completed("2026-07-01T10:00:01.000Z", "done", { agentSessionId: "agent-1" }), + completed("2026-07-01T10:00:01.000Z", "done", { + agentSessionId: "agent-1", + }), ]); const reader = createSessionRecordReader(createEventStore(filePath)); @@ -354,10 +462,22 @@ describe("createSessionRecordReader", () => { it("merges harness sessions that share an agent session (a resumed conversation)", async () => { await writeEvents([ - prompt("2026-07-01T10:00:00.000Z", "first", { session: "sess-a", agentSessionId: "agent-1" }), - completed("2026-07-01T10:00:01.000Z", "first reply", { session: "sess-a", agentSessionId: "agent-1" }), - prompt("2026-07-01T11:00:00.000Z", "second", { session: "sess-b", agentSessionId: "agent-1" }), - completed("2026-07-01T11:00:01.000Z", "second reply", { session: "sess-b", agentSessionId: "agent-1" }), + prompt("2026-07-01T10:00:00.000Z", "first", { + session: "sess-a", + agentSessionId: "agent-1", + }), + completed("2026-07-01T10:00:01.000Z", "first reply", { + session: "sess-a", + agentSessionId: "agent-1", + }), + prompt("2026-07-01T11:00:00.000Z", "second", { + session: "sess-b", + agentSessionId: "agent-1", + }), + completed("2026-07-01T11:00:01.000Z", "second reply", { + session: "sess-b", + agentSessionId: "agent-1", + }), ]); const reader = createSessionRecordReader(createEventStore(filePath)); @@ -379,20 +499,33 @@ describe("createSessionRecordReader", () => { const reader = createSessionRecordReader(createEventStore(filePath)); expect(await reader.read("nope")).toBeNull(); - const absent = createSessionRecordReader(createEventStore(path.join(tmpDir, "gone.ndjson"))); + const absent = createSessionRecordReader( + createEventStore(path.join(tmpDir, "gone.ndjson")), + ); expect(await absent.read("sess-a")).toBeNull(); expect(await absent.turnCounts()).toEqual(new Map()); }); it("exposes exact turn counts keyed by both harness and agent session id", async () => { await writeEvents([ - prompt("2026-07-01T10:00:00.000Z", "one", { session: "sess-a", agentSessionId: "agent-1" }), - completed("2026-07-01T10:00:01.000Z", "ok", { session: "sess-a", agentSessionId: "agent-1" }), - prompt("2026-07-01T10:00:02.000Z", "two", { session: "sess-a", agentSessionId: "agent-1" }), + prompt("2026-07-01T10:00:00.000Z", "one", { + session: "sess-a", + agentSessionId: "agent-1", + }), + completed("2026-07-01T10:00:01.000Z", "ok", { + session: "sess-a", + agentSessionId: "agent-1", + }), + prompt("2026-07-01T10:00:02.000Z", "two", { + session: "sess-a", + agentSessionId: "agent-1", + }), prompt("2026-07-01T10:00:03.000Z", "elsewhere", { session: "sess-b" }), ]); - const counts = await createSessionRecordReader(createEventStore(filePath)).turnCounts(); + const counts = await createSessionRecordReader( + createEventStore(filePath), + ).turnCounts(); expect(counts.get("sess-a")).toBe(2); expect(counts.get("agent-1")).toBe(2); expect(counts.get("sess-b")).toBe(1); @@ -414,14 +547,26 @@ describe("createSessionRecordReader", () => { it("still renders when the vendor transcript is gone — enrichment is optional", async () => { await writeEvents([ - event({ type: "session.start", ts: "2026-07-01T10:00:00.000Z", payload: { cwd: "/repo/does-not-exist" } }), - prompt("2026-07-01T10:00:01.000Z", "go", { agentSessionId: "agent-missing" }), - event({ type: "turn.completed", ts: "2026-07-01T10:00:02.000Z", payload: { assistantText: null } }), + event({ + type: "session.start", + ts: "2026-07-01T10:00:00.000Z", + payload: { cwd: "/repo/does-not-exist" }, + }), + prompt("2026-07-01T10:00:01.000Z", "go", { + agentSessionId: "agent-missing", + }), + event({ + type: "turn.completed", + ts: "2026-07-01T10:00:02.000Z", + payload: { assistantText: null }, + }), ]); const reader = createSessionRecordReader(createEventStore(filePath), { // Points at a home directory with no ~/.claude/projects at all. - enrichFinalTurn: createClaudeTranscriptEnricher({ homeDir: path.join(tmpDir, "home") }), + enrichFinalTurn: createClaudeTranscriptEnricher({ + homeDir: path.join(tmpDir, "home"), + }), }); const record = await reader.read("sess-a"); @@ -434,7 +579,12 @@ describe("createSessionRecordReader", () => { const cwd = "/repo/enriched"; const homeDir = path.join(tmpDir, "home"); // Mirrors Claude Code's own encoding of a project path (see the adapter). - const projectDir = path.join(homeDir, ".claude", "projects", cwd.replace(/:/g, "").replace(/[/.]/g, "-")); + const projectDir = path.join( + homeDir, + ".claude", + "projects", + cwd.replace(/:/g, "").replace(/[/.]/g, "-"), + ); await fs.mkdir(projectDir, { recursive: true }); await fs.writeFile( path.join(projectDir, "agent-enriched.jsonl"), @@ -451,8 +601,14 @@ describe("createSessionRecordReader", () => { ); await writeEvents([ - event({ type: "session.start", ts: "2026-07-01T10:00:00.000Z", payload: { cwd } }), - prompt("2026-07-01T10:00:01.000Z", "go", { agentSessionId: "agent-enriched" }), + event({ + type: "session.start", + ts: "2026-07-01T10:00:00.000Z", + payload: { cwd }, + }), + prompt("2026-07-01T10:00:01.000Z", "go", { + agentSessionId: "agent-enriched", + }), event({ type: "turn.completed", ts: "2026-07-01T10:00:02.000Z", @@ -483,19 +639,32 @@ describe("createSessionRecordReader", () => { // missing. Patching the one code instead of recomputing under-reported it. const cwd = "/repo/reopens-gap"; const homeDir = path.join(tmpDir, "home"); - const projectDir = path.join(homeDir, ".claude", "projects", cwd.replace(/:/g, "").replace(/[/.]/g, "-")); + const projectDir = path.join( + homeDir, + ".claude", + "projects", + cwd.replace(/:/g, "").replace(/[/.]/g, "-"), + ); await fs.mkdir(projectDir, { recursive: true }); await fs.writeFile( path.join(projectDir, "agent-gap.jsonl"), `${JSON.stringify({ type: "assistant", - message: { role: "assistant", model: "claude-opus-4-6", content: "the final word" }, + message: { + role: "assistant", + model: "claude-opus-4-6", + content: "the final word", + }, })}\n`, "utf8", ); await writeEvents([ - event({ type: "session.start", ts: "2026-07-01T10:00:00.000Z", payload: { cwd } }), + event({ + type: "session.start", + ts: "2026-07-01T10:00:00.000Z", + payload: { cwd }, + }), prompt("2026-07-01T10:00:01.000Z", "go", { agentSessionId: "agent-gap" }), tool("2026-07-01T10:00:02.000Z", "Read", { agentSessionId: "agent-gap" }), event({ @@ -536,15 +705,25 @@ describe("createSessionRecordReader", () => { path.join(projectDir, "agent-symlinked.jsonl"), `${JSON.stringify({ type: "assistant", - message: { role: "assistant", model: "claude-opus-4-6", content: "found via realpath" }, + message: { + role: "assistant", + model: "claude-opus-4-6", + content: "found via realpath", + }, })}\n`, "utf8", ); await writeEvents([ // The event carries the UNRESOLVED cwd, as the hook payload does. - event({ type: "session.start", ts: "2026-07-01T10:00:00.000Z", payload: { cwd } }), - prompt("2026-07-01T10:00:01.000Z", "go", { agentSessionId: "agent-symlinked" }), + event({ + type: "session.start", + ts: "2026-07-01T10:00:00.000Z", + payload: { cwd }, + }), + prompt("2026-07-01T10:00:01.000Z", "go", { + agentSessionId: "agent-symlinked", + }), event({ type: "turn.completed", ts: "2026-07-01T10:00:02.000Z", @@ -564,20 +743,35 @@ describe("createSessionRecordReader", () => { it("never lets enrichment overwrite what our own events recorded", async () => { const cwd = "/repo/ours-wins"; const homeDir = path.join(tmpDir, "home"); - const projectDir = path.join(homeDir, ".claude", "projects", cwd.replace(/:/g, "").replace(/[/.]/g, "-")); + const projectDir = path.join( + homeDir, + ".claude", + "projects", + cwd.replace(/:/g, "").replace(/[/.]/g, "-"), + ); await fs.mkdir(projectDir, { recursive: true }); await fs.writeFile( path.join(projectDir, "agent-ours.jsonl"), `${JSON.stringify({ type: "assistant", - message: { role: "assistant", model: "some-other-model", content: "transcript text" }, + message: { + role: "assistant", + model: "some-other-model", + content: "transcript text", + }, })}\n`, "utf8", ); await writeEvents([ - event({ type: "session.start", ts: "2026-07-01T10:00:00.000Z", payload: { cwd } }), - prompt("2026-07-01T10:00:01.000Z", "go", { agentSessionId: "agent-ours" }), + event({ + type: "session.start", + ts: "2026-07-01T10:00:00.000Z", + payload: { cwd }, + }), + prompt("2026-07-01T10:00:01.000Z", "go", { + agentSessionId: "agent-ours", + }), event({ type: "turn.completed", ts: "2026-07-01T10:00:02.000Z", @@ -615,7 +809,9 @@ describe("createSessionRecordReader with a record archive", () => { const ARCHIVED_AT = "2026-07-01T11:00:00.000Z"; beforeEach(async () => { - tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "harness-record-archive-")); + tmpDir = await fs.mkdtemp( + path.join(os.tmpdir(), "harness-record-archive-"), + ); filePath = path.join(tmpDir, "events.ndjson"); recordsRoot = path.join(tmpDir, "records"); }); @@ -625,11 +821,18 @@ describe("createSessionRecordReader with a record archive", () => { }); function makeArchive(): RecordArchive { - return createRecordArchive({ root: recordsRoot, now: () => Date.parse(ARCHIVED_AT) }); + return createRecordArchive({ + root: recordsRoot, + now: () => Date.parse(ARCHIVED_AT), + }); } async function writeLines(events: AnalyticsEvent[]): Promise { - await fs.writeFile(filePath, events.map((e) => `${JSON.stringify(e)}\n`).join(""), "utf8"); + await fs.writeFile( + filePath, + events.map((e) => `${JSON.stringify(e)}\n`).join(""), + "utf8", + ); } /** The conversation every test here starts from: two completed turns, the @@ -646,11 +849,19 @@ describe("createSessionRecordReader with a record archive", () => { type: "tool.call", ts: "2026-07-01T10:00:02.000Z", agentSessionId: "agent-1", - payload: { toolName: "Edit", toolInput: LONG_INPUT, toolResponseSummary: "ok" }, + payload: { + toolName: "Edit", + toolInput: LONG_INPUT, + toolResponseSummary: "ok", + }, + }), + completed("2026-07-01T10:00:03.000Z", "first reply", { + agentSessionId: "agent-1", }), - completed("2026-07-01T10:00:03.000Z", "first reply", { agentSessionId: "agent-1" }), prompt("2026-07-01T10:00:04.000Z", "second", { agentSessionId: "agent-1" }), - completed("2026-07-01T10:00:05.000Z", "second reply", { agentSessionId: "agent-1" }), + completed("2026-07-01T10:00:05.000Z", "second reply", { + agentSessionId: "agent-1", + }), ]; /** Fold the conversation and archive it, as the server does at session end. */ @@ -669,13 +880,18 @@ describe("createSessionRecordReader with a record archive", () => { // What a 30-day sweep leaves behind for this session: nothing. await fs.writeFile(filePath, "", "utf8"); - const record = await createSessionRecordReader(createEventStore(filePath), { archive }).read("sess-a"); + const record = await createSessionRecordReader(createEventStore(filePath), { + archive, + }).read("sess-a"); expect(record?.turns.map((t) => t.prompt)).toEqual(["first", "second"]); expect(record?.archivedAt).toBe(ARCHIVED_AT); expect(record?.limitations).toContain("compacted-archive"); // Reachable by the agent's own session id too, which is all a // transcript-sourced history row has. - const byAgent = await createSessionRecordReader(createEventStore(filePath), { archive }).read("agent-1"); + const byAgent = await createSessionRecordReader( + createEventStore(filePath), + { archive }, + ).read("agent-1"); expect(byAgent?.harnessSessionId).toBe("sess-a"); }); @@ -687,7 +903,9 @@ describe("createSessionRecordReader with a record archive", () => { // sweepNdjson truncates oldest-first: the last two lines survive. await writeLines(conversation().slice(-2)); - const record = await createSessionRecordReader(createEventStore(filePath), { archive }).read("sess-a"); + const record = await createSessionRecordReader(createEventStore(filePath), { + archive, + }).read("sess-a"); // The archive is the only source that still has the first turn. expect(record?.turns.map((t) => t.prompt)).toEqual(["first", "second"]); expect(record?.archivedAt).toBe(ARCHIVED_AT); @@ -698,7 +916,9 @@ describe("createSessionRecordReader with a record archive", () => { const archive = makeArchive(); await archiveNow(archive); - const record = await createSessionRecordReader(createEventStore(filePath), { archive }).read("sess-a"); + const record = await createSessionRecordReader(createEventStore(filePath), { + archive, + }).read("sess-a"); expect(record?.archivedAt).toBeNull(); // The whole tool input, not the archive's 512-character excerpt. expect(record?.turns[0].toolCalls[0].input).toBe(LONG_INPUT); @@ -718,7 +938,9 @@ describe("createSessionRecordReader with a record archive", () => { completed("2026-07-01T12:00:01.000Z", "third reply"), ]); - const record = await createSessionRecordReader(createEventStore(filePath), { archive }).read("sess-a"); + const record = await createSessionRecordReader(createEventStore(filePath), { + archive, + }).read("sess-a"); expect(record?.archivedAt).toBeNull(); expect(record?.turns.map((t) => t.prompt)).toEqual(["second", "third"]); }); @@ -726,7 +948,9 @@ describe("createSessionRecordReader with a record archive", () => { it("scans events when nothing was ever archived, and reports honestly when neither source has anything", async () => { await writeLines(conversation()); const archive = makeArchive(); - const reader = createSessionRecordReader(createEventStore(filePath), { archive }); + const reader = createSessionRecordReader(createEventStore(filePath), { + archive, + }); expect((await reader.read("sess-a"))?.turns).toHaveLength(2); expect(await reader.read("sess-never-existed")).toBeNull(); @@ -739,7 +963,9 @@ describe("createSessionRecordReader with a record archive", () => { await writeLines(conversation()); const archive = makeArchive(); await archiveNow(archive); - const reader = createSessionRecordReader(createEventStore(filePath), { archive }); + const reader = createSessionRecordReader(createEventStore(filePath), { + archive, + }); await fs.writeFile(filePath, "", "utf8"); expect(await reader.readFromEvents("sess-a")).toBeNull(); @@ -751,7 +977,9 @@ describe("createSessionRecordReader with a record archive", () => { await writeLines(conversation()); const archive = makeArchive(); await archiveNow(archive); - const reader = createSessionRecordReader(createEventStore(filePath), { archive }); + const reader = createSessionRecordReader(createEventStore(filePath), { + archive, + }); await fs.writeFile(filePath, "", "utf8"); const counts = await reader.turnCounts(); @@ -772,18 +1000,31 @@ describe("createSessionRecordReader with a record archive", () => { completed("2026-07-01T12:00:01.000Z", "third reply"), ]); - const counts = await createSessionRecordReader(createEventStore(filePath), { archive }).turnCounts(); + const counts = await createSessionRecordReader(createEventStore(filePath), { + archive, + }).turnCounts(); expect(counts.get("sess-a")).toBe(3); }); it("lists conversations newest-first for the backfill, merging resumed segments", async () => { await writeLines([ - prompt("2026-07-01T10:00:00.000Z", "old", { session: "sess-old", agentSessionId: "agent-old" }), - prompt("2026-07-02T10:00:00.000Z", "first", { session: "sess-a", agentSessionId: "agent-1" }), - prompt("2026-07-03T10:00:00.000Z", "resumed", { session: "sess-b", agentSessionId: "agent-1" }), + prompt("2026-07-01T10:00:00.000Z", "old", { + session: "sess-old", + agentSessionId: "agent-old", + }), + prompt("2026-07-02T10:00:00.000Z", "first", { + session: "sess-a", + agentSessionId: "agent-1", + }), + prompt("2026-07-03T10:00:00.000Z", "resumed", { + session: "sess-b", + agentSessionId: "agent-1", + }), ]); - const ids = await createSessionRecordReader(createEventStore(filePath)).conversationIds(); + const ids = await createSessionRecordReader( + createEventStore(filePath), + ).conversationIds(); // sess-a and sess-b are one conversation, named by where it began, and it // sorts ahead of the older one on its most recent activity. expect(ids).toEqual(["sess-a", "sess-old"]); diff --git a/packages/harness/src/core/session-record.ts b/packages/harness/src/core/session-record.ts index af962a1b..b9c3a3f9 100644 --- a/packages/harness/src/core/session-record.ts +++ b/packages/harness/src/core/session-record.ts @@ -55,6 +55,7 @@ import { projectDirsFor } from "./adapters/claude-code.js"; import { PAYLOAD_TRUNCATION_MARKER } from "./collector/normalizer.js"; import { readLastAssistantTurn } from "./collector/transcript.js"; import type { EventIndex, EventReader } from "./collector/store.js"; +import { isPreUnifiedInfrastructureBootstrapPayload } from "./project-session-legacy-migration.js"; function stringOrNull(value: unknown): string | null { return typeof value === "string" && value.length > 0 ? value : null; @@ -78,7 +79,9 @@ function readUsage(value: unknown): SessionRecordTurn["usage"] { * same millisecond. See the module header for why `seq` alone is wrong. * Array.prototype.sort is stable, so events matching on both keep file order. */ -export function sortEventsForFold(events: readonly AnalyticsEvent[]): AnalyticsEvent[] { +export function sortEventsForFold( + events: readonly AnalyticsEvent[], +): AnalyticsEvent[] { return [...events].sort((a, b) => { if (a.ts !== b.ts) return a.ts < b.ts ? -1 : 1; const seqA = typeof a.seq === "number" ? a.seq : 0; @@ -145,10 +148,13 @@ export function foldSessionRecord( } for (const event of ordered) { - if (!seenSessionIds.includes(event.harnessSessionId)) seenSessionIds.push(event.harnessSessionId); + if (!seenSessionIds.includes(event.harnessSessionId)) + seenSessionIds.push(event.harnessSessionId); if (startedAt === null) startedAt = event.ts; - if (harness === null && typeof event.harness === "string") harness = event.harness; - if (agentSessionId === null) agentSessionId = stringOrNull(event.agentSessionId); + if (harness === null && typeof event.harness === "string") + harness = event.harness; + if (agentSessionId === null) + agentSessionId = stringOrNull(event.agentSessionId); const payload = event.payload ?? {}; switch (event.type) { @@ -156,26 +162,28 @@ export function foldSessionRecord( if (cwd === null) cwd = stringOrNull(payload.cwd); break; - case "prompt.submitted": + case "prompt.submitted": { // A prompt arriving while a turn is open means that turn never // completed (killed mid-turn, or the user queued another prompt). // Keep it, marked incomplete — dropping it would lose real tool calls. close(null); + const infrastructureBootstrap = + payload.projectBootstrapOrigin === "infrastructure" || + isPreUnifiedInfrastructureBootstrapPayload(payload); open = { - // Planner greeting control is retained locally for diagnostics but + // Project bootstrap control is retained locally for diagnostics but // projected as an assistant-initiated turn: its private instruction // must never appear as a user message or inflate the human turn count. - prompt: - payload.plannerOrigin === "infrastructure" - ? null - : typeof payload.prompt === "string" - ? payload.prompt - : "", - promptAt: - payload.plannerOrigin === "infrastructure" ? null : event.ts, + prompt: infrastructureBootstrap + ? null + : typeof payload.prompt === "string" + ? payload.prompt + : "", + promptAt: infrastructureBootstrap ? null : event.ts, toolCalls: [], }; break; + } case "tool.call": { // No enclosing turn: the recording started mid-turn (a resume attaches @@ -187,7 +195,9 @@ export function foldSessionRecord( name: stringOrNull(payload.toolName), input: stringOrNull(payload.toolInput), responseSummary, - responseTruncated: responseSummary !== null && PAYLOAD_TRUNCATION_MARKER.test(responseSummary), + responseTruncated: + responseSummary !== null && + PAYLOAD_TRUNCATION_MARKER.test(responseSummary), at: event.ts, }); break; @@ -216,7 +226,9 @@ export function foldSessionRecord( close(null); - const merged = options.mergedSessionIds ? [...options.mergedSessionIds] : seenSessionIds; + const merged = options.mergedSessionIds + ? [...options.mergedSessionIds] + : seenSessionIds; return { harnessSessionId: options.harnessSessionId ?? merged[0] ?? "", mergedSessionIds: merged, @@ -247,15 +259,27 @@ export function foldSessionRecord( * recorded). Patching individual codes after the fact got that wrong and * under-reported a real gap. */ -function computeLimitations(turns: readonly SessionRecordTurn[]): SessionRecordLimitation[] { +function computeLimitations( + turns: readonly SessionRecordTurn[], +): SessionRecordLimitation[] { const limitations: SessionRecordLimitation[] = []; - if (turns.some((turn) => turn.toolCalls.some((call) => call.responseTruncated))) { + if ( + turns.some((turn) => turn.toolCalls.some((call) => call.responseTruncated)) + ) { limitations.push("truncated-tool-output"); } - if (turns.some((turn) => turn.toolCalls.length > 0 && turn.assistantText !== null)) { + if ( + turns.some( + (turn) => turn.toolCalls.length > 0 && turn.assistantText !== null, + ) + ) { limitations.push("assistant-narration-gap"); } - if (turns.some((turn) => turn.completedAt !== null && turn.assistantText === null)) { + if ( + turns.some( + (turn) => turn.completedAt !== null && turn.assistantText === null, + ) + ) { limitations.push("missing-assistant-text"); } if (turns.length > 0 && turns[turns.length - 1].incomplete) { @@ -332,14 +356,16 @@ export interface SessionRecordReader { function resolveSessionIds(index: EventIndex, id: string): string[] { const ids: string[] = []; const add = (candidate: string): void => { - if (!ids.includes(candidate) && index.bySession.has(candidate)) ids.push(candidate); + if (!ids.includes(candidate) && index.bySession.has(candidate)) + ids.push(candidate); }; const direct = index.bySession.get(id); if (direct) { add(id); for (const agentSessionId of direct.agentSessionIds) { - for (const sibling of index.byAgentSession.get(agentSessionId) ?? []) add(sibling); + for (const sibling of index.byAgentSession.get(agentSessionId) ?? []) + add(sibling); } } else { // Not a harnessSessionId we know — try it as an agent session id. @@ -379,7 +405,10 @@ function resolveSessionIds(index: EventIndex, id: string): string[] { */ export function createSessionRecordReader( store: EventReader, - options: { enrichFinalTurn?: FinalTurnEnricher; archive?: ArchivedRecordSource } = {}, + options: { + enrichFinalTurn?: FinalTurnEnricher; + archive?: ArchivedRecordSource; + } = {}, ): SessionRecordReader { const archive = options.archive; @@ -412,19 +441,26 @@ export function createSessionRecordReader( * reach that state. */ async read(id: string): Promise { - const archived = archive ? await archive.read(id).catch(() => null) : null; + const archived = archive + ? await archive.read(id).catch(() => null) + : null; if (!archived) return reader.readFromEvents(id); const index = await store.index(); const sessionIds = resolveSessionIds(index, id); - const entries = sessionIds.map((sessionId) => index.bySession.get(sessionId)).filter(isPresent); + const entries = sessionIds + .map((sessionId) => index.bySession.get(sessionId)) + .filter(isPresent); const firstTs = earliest(entries.map((entry) => entry.firstTs)); const lastTs = latest(entries.map((entry) => entry.lastTs)); const logHoldsTheBeginning = - firstTs !== null && (archived.startedAt === null || firstTs <= archived.startedAt); + firstTs !== null && + (archived.startedAt === null || firstTs <= archived.startedAt); const logHasNewerEvents = - lastTs !== null && archived.archivedAt !== null && lastTs > archived.archivedAt; + lastTs !== null && + archived.archivedAt !== null && + lastTs > archived.archivedAt; if (!logHoldsTheBeginning && !logHasNewerEvents) return archived; return (await reader.readFromEvents(id)) ?? archived; }, @@ -435,7 +471,8 @@ export function createSessionRecordReader( if (sessionIds.length === 0) return null; const events: AnalyticsEvent[] = []; - for await (const event of store.read({ harnessSessionId: sessionIds })) events.push(event); + for await (const event of store.read({ harnessSessionId: sessionIds })) + events.push(event); if (events.length === 0) return null; const record = foldSessionRecord(events, { @@ -452,8 +489,15 @@ export function createSessionRecordReader( // here would be a fabrication dressed as an enhancement. Nothing our own // events recorded is ever overwritten. if (!final || final.completedAt === null) return record; - if (final.assistantText !== null && final.model !== null && final.usage !== null) return record; - const enrichment = await options.enrichFinalTurn(record).catch(() => null); + if ( + final.assistantText !== null && + final.model !== null && + final.usage !== null + ) + return record; + const enrichment = await options + .enrichFinalTurn(record) + .catch(() => null); if (!enrichment) return record; const enriched: SessionRecordTurn = { ...final, @@ -523,7 +567,9 @@ export function createSessionRecordReader( primaries.set( primary, latest( - resolveSessionIds(index, primary).map((id) => index.bySession.get(id)?.lastTs ?? null), + resolveSessionIds(index, primary).map( + (id) => index.bySession.get(id)?.lastTs ?? null, + ), ), ); } @@ -585,7 +631,9 @@ export function createClaudeTranscriptEnricher( // returns the resolved candidate first and the raw one as a fallback, so // this tries both rather than betting on either. for (const projectDir of await projectDirsFor(homeDir, record.cwd)) { - const turn = await readLastAssistantTurn(path.join(projectDir, `${record.agentSessionId}.jsonl`)); + const turn = await readLastAssistantTurn( + path.join(projectDir, `${record.agentSessionId}.jsonl`), + ); if (turn) return turn; } return null; diff --git a/packages/harness/src/index.ts b/packages/harness/src/index.ts index 8330ab4f..f1241f95 100644 --- a/packages/harness/src/index.ts +++ b/packages/harness/src/index.ts @@ -22,6 +22,9 @@ export type { PlanRelationship, PlanRelationshipId, ProposalActor, + ProjectAgentSession, + ProjectBootstrapMetadata, + ProjectBootstrapState, ProposalOperationId, RelationshipChanges, RelationshipKind, diff --git a/packages/harness/src/profiles/agent-map-planner.ts b/packages/harness/src/profiles/agent-map-planner.ts index 61ddea81..032039b3 100644 --- a/packages/harness/src/profiles/agent-map-planner.ts +++ b/packages/harness/src/profiles/agent-map-planner.ts @@ -22,7 +22,8 @@ application source code, run implementation tasks, or deploy software. * User-facing orientation shown by Claude Code's native SessionStart hook. * This is deliberately static UI copy, not a synthetic model/user turn. */ +/** Transitional startup notice until the legacy lifecycle is removed. */ export const AGENT_MAP_PLANNER_SESSION_START_MESSAGE = [ - "Agent Map planning session", - "Use this session to scope what you want to build—not to implement it yet. Your planner will turn your goals into a proposed map of agents, responsibilities, data flow, resources, and connectors for you to review and refine. Once approved, Studio will create focused execution sessions from the plan. Start by describing the outcome you want.", + "Studio project session", + "Plan and build in this conversation. The Agent Map is shared project context; clear implementation requests can proceed directly.", ].join("\n"); diff --git a/packages/harness/src/profiles/project-agent.test.ts b/packages/harness/src/profiles/project-agent.test.ts new file mode 100644 index 00000000..c27f6112 --- /dev/null +++ b/packages/harness/src/profiles/project-agent.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; +import { PROJECT_AGENT_PROMPT_APPENDIX, projectAgentPromptAppendix } from "./project-agent.js"; + +describe("common writable project prompt", () => { + it("preserves one prompt and the current map tool guidance", () => { + expect(projectAgentPromptAppendix()).toBe(PROJECT_AGENT_PROMPT_APPENDIX); + expect(PROJECT_AGENT_PROMPT_APPENDIX).toContain("ordinary writable coding agent"); + for (const tool of ["agent_map_read", "agent_map_validate", "agent_map_propose"]) { + expect(PROJECT_AGENT_PROMPT_APPENDIX).toContain(tool); + } + expect(PROJECT_AGENT_PROMPT_APPENDIX).toContain("no role, approval, confirmation, or mode transition"); + }); +}); diff --git a/packages/harness/src/profiles/project-agent.ts b/packages/harness/src/profiles/project-agent.ts new file mode 100644 index 00000000..1c98d4f5 --- /dev/null +++ b/packages/harness/src/profiles/project-agent.ts @@ -0,0 +1,19 @@ +/** + * Shared behavior appended to the ordinary writable coding profile for every + * session whose cwd resolves to a Studio project. Project context focuses the + * agent; it never changes the session's tools or implementation authority. + */ +export const PROJECT_AGENT_PROMPT_APPENDIX = ` +You are an ordinary writable coding agent working in a shared Studio project. You can plan and implement in the same session; no role, approval, confirmation, or mode transition is required before beginning a clear implementation request. + +Use agent_map_read when the current project architecture is relevant. When the work materially changes agents, meaningful subagents, responsibilities, ownership, contracts, shared resources, connectors, artifacts, sequencing boundaries, or cross-agent data flow, validate and record the change with agent_map_validate and agent_map_propose. Re-read and reconcile explicitly if another session changed the shared map concurrently. + +Keep internal implementation details local: library choices, ordinary implementation steps, incidental model or tool calls, and refactors that do not change a meaningful project boundary do not belong in the Agent Map. Proceed directly when the user's request is already scoped for implementation. + +Project and bootstrap context never grant or remove authority. +`; + +/** The common prompt is identical for every project session. */ +export function projectAgentPromptAppendix(): string { + return PROJECT_AGENT_PROMPT_APPENDIX; +} diff --git a/packages/harness/src/server/agent-map-mcp-tools.ts b/packages/harness/src/server/agent-map-mcp-tools.ts index 6470dd5e..ee2ab384 100644 --- a/packages/harness/src/server/agent-map-mcp-tools.ts +++ b/packages/harness/src/server/agent-map-mcp-tools.ts @@ -1,7 +1,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; -import type { PlanningSessionIdentity } from "../shared/agent-map.js"; +import type { ProjectAgentSession, PlanningSessionIdentity } from "../shared/agent-map.js"; import { AgentMapProposalConflictError, AgentMapProposalProjectError, @@ -50,7 +50,6 @@ export interface AgentMapToolEvent { tool: "agent_map_read" | "agent_map_validate" | "agent_map_propose"; outcome: "ok" | "error"; errorCode?: string; - role: PlanningSessionIdentity["role"]; latencyMs: number; } @@ -100,10 +99,16 @@ function toolResult(value: object, message: string) { /** Registers the identical project-wide surface for every trusted role. */ export function createAgentMapToolServer( - identity: PlanningSessionIdentity, + identity: ProjectAgentSession, service: AgentMapProposalService, options: AgentMapMcpToolsOptions = {}, ): McpServer { + // The deployed proposal codec still requires its historical actor shape. + // Keep that storage-only adapter here until the aggregate/writer cutover; + // neither the capability nor tool authorization consumes these fields. + const legacyStoragePrincipal: PlanningSessionIdentity = { + ...identity, role: "agent-builder", assignment: { kind: "unplanned" }, + }; const server = new McpServer({ name: "sapiom-studio-agent-map", version: "1" }); const emit = (event: AgentMapToolEvent): void => { try { @@ -123,7 +128,6 @@ export function createAgentMapToolServer( emit({ tool, outcome: "ok", - role: identity.role, latencyMs: Math.max(0, Date.now() - startedAt), }); return value; @@ -133,7 +137,6 @@ export function createAgentMapToolServer( tool, outcome: "error", errorCode: String(result.structuredContent.code), - role: identity.role, latencyMs: Math.max(0, Date.now() - startedAt), }); return result; @@ -166,7 +169,7 @@ export function createAgentMapToolServer( }, async (request) => instrument("agent_map_validate", async () => { - const result = await service.validate(identity, request); + const result = await service.validate(legacyStoragePrincipal, request); return toolResult(result, `Proposal batch is valid at version ${result.currentVersion}.`); }), ); @@ -180,7 +183,7 @@ export function createAgentMapToolServer( }, async (request) => instrument("agent_map_propose", async () => { - const result = await service.propose(identity, request); + const result = await service.propose(legacyStoragePrincipal, request); return toolResult(result, `Accepted Agent Map proposal version ${result.version}.`); }), ); diff --git a/packages/harness/src/server/agent-map-mcp-wiring.test.ts b/packages/harness/src/server/agent-map-mcp-wiring.test.ts index 1f5753d2..50633498 100644 --- a/packages/harness/src/server/agent-map-mcp-wiring.test.ts +++ b/packages/harness/src/server/agent-map-mcp-wiring.test.ts @@ -200,7 +200,7 @@ it("gives a signed-out local planner its scoped Agent Map tools", async () => { userId: "local:machine-1", }); expect(created.session.agentMapIdentity).toEqual( - created.session.planning.identity, + expect.objectContaining({ userId: "local:machine-1" }), ); expect(created.session.planning.greeting).toEqual({ status: "skipped", @@ -218,26 +218,11 @@ it("gives a signed-out local planner its scoped Agent Map tools", async () => { `Bearer ${metadata!.bearerToken}`, ); const systemPrompt = await fs.readFile(launchOpts!.systemPromptFile!, "utf8"); - expect(systemPrompt).toContain(""); - expect(systemPrompt).toContain( - "Do not act as a coding or implementation agent", - ); - expect(systemPrompt).toContain( - "Let the user's first real message be the first visible conversation turn", - ); - expect(systemPrompt).not.toContain("In your first response, briefly explain"); - expect(systemPrompt).not.toContain(codingPrompt); - expect(systemPrompt).not.toContain("You are the coding agent"); - expect(systemPrompt).not.toContain( - "This is a private Agent Studio control turn", - ); - expect(loadSystemPrompt).not.toHaveBeenCalled(); - expect(AGENT_MAP_PLANNER_SESSION_START_MESSAGE).toBe( - [ - "Agent Map planning session", - "Use this session to scope what you want to build—not to implement it yet. Your planner will turn your goals into a proposed map of agents, responsibilities, data flow, resources, and connectors for you to review and refine. Once approved, Studio will create focused execution sessions from the plan. Start by describing the outcome you want.", - ].join("\n"), - ); + expect(systemPrompt).toContain(""); + expect(systemPrompt).toContain(""); + expect(systemPrompt).toContain(codingPrompt); + expect(systemPrompt).not.toContain("Do not act as a coding or implementation agent"); + expect(loadSystemPrompt).toHaveBeenCalled(); const plannerEmitter = await fs.readFile( path.join(path.dirname(launchOpts.settingsFile!), "emit.cjs"), "utf8", @@ -320,13 +305,11 @@ it("gives a signed-out local planner its scoped Agent Map tools", async () => { harness: "claude-code", }); expect(ordinary.agentMapIdentity).toMatchObject({ - role: "agent-builder", userId: "local:machine-1", - assignment: { kind: "unplanned" }, }); const ordinaryLaunch = launches[1]!; expect(ordinaryLaunch.agentMapMcp).toBeDefined(); - expect(await fs.readFile(ordinaryLaunch.systemPromptFile!, "utf8")).toBe( + expect(await fs.readFile(ordinaryLaunch.systemPromptFile!, "utf8")).toContain( codingPrompt, ); const ordinaryEmitter = await fs.readFile( @@ -337,7 +320,7 @@ it("gives a signed-out local planner its scoped Agent Map tools", async () => { expect(ordinaryEmitter).not.toContain( JSON.stringify(AGENT_MAP_PLANNER_SESSION_START_MESSAGE), ); - expect(loadSystemPrompt).toHaveBeenCalledOnce(); + expect(loadSystemPrompt).toHaveBeenCalledTimes(2); const ordinaryConfig = JSON.parse( await fs.readFile(ordinaryLaunch.mcpConfigFile!, "utf8"), ); diff --git a/packages/harness/src/server/agent-map-mcp.test.ts b/packages/harness/src/server/agent-map-mcp.test.ts index 321d95e2..e591838b 100644 --- a/packages/harness/src/server/agent-map-mcp.test.ts +++ b/packages/harness/src/server/agent-map-mcp.test.ts @@ -8,7 +8,7 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; -import type { PlanningSessionIdentity } from "../shared/agent-map.js"; +import type { ProjectAgentSession, PlanningSessionIdentity } from "../shared/agent-map.js"; import { AgentMapCapabilityRegistry } from "../core/agent-map-capability-registry.js"; import { AgentMapProposalService } from "../core/agent-map-proposal-service.js"; import { AgentMapWorkspaceStore } from "../core/agent-map-workspace-store.js"; @@ -129,11 +129,10 @@ describe("Agent Map Streamable HTTP MCP", () => { it("reads, validates without mutation, proposes once, and rejects a rotated token", async () => { const { capabilities, url } = await fixture(); - const identity: PlanningSessionIdentity = { + const identity: ProjectAgentSession = { projectId, sessionId: "planner", userId: "user", - role: "map-planner", }; const first = capabilities.issue(identity); const client = await connect(url, first.token); @@ -204,7 +203,6 @@ describe("Agent Map Streamable HTTP MCP", () => { projectId, sessionId: "missing-project", userId: "user", - role: "map-planner", }); const client = await connect(url, issued.token); @@ -251,7 +249,6 @@ describe("Agent Map Streamable HTTP MCP", () => { projectId, sessionId: "failed-initialize", userId: "user", - role: "map-planner", }); const response = await fetch(url, { diff --git a/packages/harness/src/server/index.ts b/packages/harness/src/server/index.ts index 973bc8c6..43ada43c 100644 --- a/packages/harness/src/server/index.ts +++ b/packages/harness/src/server/index.ts @@ -100,7 +100,9 @@ import { sweepGeneratedDirs, } from "../core/inject/retention.js"; import { DEFAULT_SYSTEM_PROMPT } from "../profiles/default.js"; -import { AGENT_MAP_PLANNER_SYSTEM_PROMPT } from "../profiles/agent-map-planner.js"; +import { projectAgentPromptAppendix } from "../profiles/project-agent.js"; +import { ProjectSessionScopeUnavailableError } 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"; import { CanvasWatcherManager } from "../core/canvas-watcher.js"; @@ -573,13 +575,10 @@ function createDefaultBuildLaunchOpts( // resolves to the bundled DEFAULT_SYSTEM_PROMPT on any failure rather than // throwing; the `.catch` covers an injected loader that does not, because a // session must never fail to start over the text of its prompt. - const promptPromise = - context?.agentMapIdentity?.role === "map-planner" - ? Promise.resolve(AGENT_MAP_PLANNER_SYSTEM_PROMPT) - : loadSystemPrompt().catch((err: unknown) => { - console.error("[harness] system-prompt load failed:", err); - return DEFAULT_SYSTEM_PROMPT; - }); + const promptPromise = loadSystemPrompt().catch((err: unknown) => { + console.error("[harness] system-prompt load failed:", err); + return DEFAULT_SYSTEM_PROMPT; + }); const [settings, mcpConfigFile, prompt, pluginDir] = await Promise.all([ generateClaudeSettings({ harnessSessionId, @@ -602,7 +601,7 @@ function createDefaultBuildLaunchOpts( promptPromise, generateSkillsPlugin(harnessSessionId, { generatedRoot }), ]); - const appendices = [viaSystemPrompt ? brief : null, context?.promptAppendix] + const appendices = [viaSystemPrompt ? brief : null, context?.agentMapIdentity ? projectAgentPromptAppendix() : null, context?.promptAppendix] .filter( (value): value is string => typeof value === "string" && value.trim() !== "", @@ -822,6 +821,121 @@ export const startServer = async ( console.error("[harness] recent-dirs prune failed:", err); } let workflowsCache: RegistryWorkflowInfo[] = await workflowRegistry.list(); + // Assigned after constructing callbacks which need the live manager. + // eslint-disable-next-line prefer-const + let sessionManager!: SessionManager; + const pendingProjectCwds = new Set(); + const workspaceScopeCatalog = new LocalWorkspaceScopeCatalog(async () => [ + ...(await loadSettings(statePaths.settings)).recentDirs, + ...(sessionManager ? sessionManager.list().map((session) => session.cwd) : []), + ]); + const studioWorkspaceScopeCatalog = new LocalWorkspaceScopeCatalog( + async () => { + const settings = await loadSettings(statePaths.settings); + const durableProjects = await studioProjectCatalog.list(); + const durableIdentities = ( + await Promise.all( + durableProjects.map((project) => + studioProjectCatalog.resolveIdentity(project.projectId), + ), + ) + ).filter((project) => project !== null); + const durableRoots = durableIdentities.flatMap((project) => + project.rootBindings.map((binding) => binding.localRootRef), + ); + const durableRootCandidates = durableIdentities.flatMap((project) => + project.rootBindings + .filter((binding) => binding.status === "active") + .map((binding) => ({ + projectId: project.projectId, + cwd: binding.localRootRef, + })), + ); + const retainedProjectSessionRoots = new Set(); + // Pending launches contribute their trusted PROJECT root just like live + // sessions, not a descendant cwd that would mint a competing project. + const pendingCwds = [...pendingProjectCwds]; + const sessions = sessionManager + ? sessionManager.list().flatMap((session) => { + if (!session.agentMapIdentity) { + return [ + { + cwd: session.cwd, + createdAt: session.lastActiveAt, + status: session.status, + }, + ]; + } + const root = projectSessionRoot( + { + cwd: session.cwd, + projectId: session.agentMapIdentity.projectId, + }, + durableRootCandidates, + ); + // A neutral project session contributes its trusted project root, + // never its descendant cwd. If its binding is stale, omit it from + // discovery rather than minting a replacement authority from the + // untrusted path. + if (root) { + retainedProjectSessionRoots.add(root); + return [ + { + cwd: root, + createdAt: session.lastActiveAt, + status: session.status, + }, + ]; + } + return []; + }) + : []; + const candidates = [ + ...pendingCwds, + ...settings.recentDirs, + ...sessions.map((session) => session.cwd), + ]; + // Root identity must be final before launch, even when the asynchronous + // workflow scan has not populated its cache yet. Probe only the candidate + // roots themselves; deeper discovery remains the registry's job. + const directlyMarked = ( + await Promise.all( + candidates.map(async (candidate) => ({ + candidate, + marker: await inspectAgentProjectMarker(candidate), + })), + ) + ) + .filter(({ marker }) => marker.status === "valid") + .map(({ candidate }) => candidate); + const visibleRoots = projectRoots({ + recentDirs: settings.recentDirs, + sessions, + pendingCwds, + pinnedRoots: durableRoots, + agentPaths: [ + ...workflowsCache.map((workflow) => workflow.path), + ...directlyMarked, + ], + sort: "recent", + }); + // MRU/rail visibility is not an authority revocation mechanism. An + // existing project session must remain resumable after its recent-dir + // entry is evicted, including an otherwise empty project whose session + // cwd is below the durable root. The browser may still hide an explicitly + // removed project through its local closed-project projection. + return [ + ...visibleRoots, + ...[...retainedProjectSessionRoots].filter( + (root) => + !visibleRoots.some((visible) => + samePath(canonicalGraphPath(visible), canonicalGraphPath(root)), + ), + ), + ]; + }, + ); + const initialInventorySnapshot = await workflowRegistry.inventorySnapshot(launchDir); type AcceptedCanonicalWorkflowRoot = { @@ -1163,7 +1277,7 @@ export const startServer = async ( } }; - const sessionManager = new SessionManager({ + sessionManager = new SessionManager({ adapters, ingestUrl: `http://${host}:${options.port}`, ingestCredentials, @@ -1171,30 +1285,34 @@ export const startServer = async ( sessionsPath: options.sessionsPath ?? statePaths.sessions, buildLaunchOpts, resolveAgentMapIdentity: async (sessionId, cwd, persisted) => { - // Planner ownership already uses a stable machine-local principal when - // Studio runs with --no-auth. Capability issuance must use that same - // identity; requiring an authenticated user here silently removed the - // Agent Map server from every signed-out planner's MCP config. - const userId = localPlanningPrincipal(planningUserId, machineId); - const project = await studioProjectCatalog.resolveIdentityForPath(cwd); - if (!project) return undefined; - if ( - persisted?.sessionId === sessionId && - persisted.projectId === project.projectId && - persisted.userId === userId && - (persisted.role === "map-planner" || - (persisted.role === "agent-builder" && - persisted.assignment.kind === "planned")) - ) { - return structuredClone(persisted); - } - return { - projectId: project.projectId, - sessionId, - userId, - role: "agent-builder", - assignment: { kind: "unplanned" }, + const userId = localProjectPrincipal(planningUserId, machineId); + const assertPrincipal = (): void => { + if (localProjectPrincipal(planningUserId, machineId) !== userId) { + throw new ProjectSessionScopeUnavailableError(sessionId); + } }; + if (persisted && (persisted.sessionId !== sessionId || persisted.userId !== userId)) { + throw new ProjectSessionScopeUnavailableError(sessionId); + } + let project = await studioProjectCatalog.resolveIdentityForPath(cwd); + assertPrincipal(); + if (persisted) { + if (!project || project.projectId !== persisted.projectId) throw new ProjectSessionScopeUnavailableError(sessionId); + } else if (!project) { + await studioProjectCatalog.reconcile(await studioWorkspaceScopeCatalog.list()); + assertPrincipal(); + project = await studioProjectCatalog.resolveIdentityForPath(cwd); + if (!project) { + pendingProjectCwds.add(cwd); + try { + await studioProjectCatalog.reconcile(await studioWorkspaceScopeCatalog.list()); + assertPrincipal(); + project = await studioProjectCatalog.resolveIdentityForPath(cwd); + } finally { pendingProjectCwds.delete(cwd); } + } + } + assertPrincipal(); + return project ? { projectId: project.projectId, sessionId, userId } : undefined; }, onAgentMapSessionExit: async (sessionId) => { agentMapCapabilities.revokeSession(sessionId); @@ -1207,118 +1325,6 @@ export const startServer = async ( ensureCanvasTemplate, }); await sessionManager.init(); - - const workspaceScopeCatalog = new LocalWorkspaceScopeCatalog(async () => [ - ...(await loadSettings(statePaths.settings)).recentDirs, - ...sessionManager.list().map((session) => session.cwd), - ]); - const studioWorkspaceScopeCatalog = new LocalWorkspaceScopeCatalog( - async () => { - const settings = await loadSettings(statePaths.settings); - const durableProjects = await studioProjectCatalog.list(); - const durableIdentities = ( - await Promise.all( - durableProjects.map((project) => - studioProjectCatalog.resolveIdentity(project.projectId), - ), - ) - ).filter((project) => project !== null); - const durableRoots = durableIdentities.flatMap((project) => - project.rootBindings.map((binding) => binding.localRootRef), - ); - const durableRootCandidates = durableIdentities.flatMap((project) => - project.rootBindings - .filter((binding) => binding.status === "active") - .map((binding) => ({ - projectId: project.projectId, - cwd: binding.localRootRef, - })), - ); - const retainedProjectSessionRoots = new Set(); - // Pending launches contribute their trusted PROJECT root just like live - // sessions, not a descendant cwd that would mint a competing project. - const pendingCwds: string[] = []; - const sessions = sessionManager - ? sessionManager.list().flatMap((session) => { - if (!session.agentMapIdentity) { - return [ - { - cwd: session.cwd, - createdAt: session.lastActiveAt, - status: session.status, - }, - ]; - } - const root = projectSessionRoot( - { - cwd: session.cwd, - projectId: session.agentMapIdentity.projectId, - }, - durableRootCandidates, - ); - // A neutral project session contributes its trusted project root, - // never its descendant cwd. If its binding is stale, omit it from - // discovery rather than minting a replacement authority from the - // untrusted path. - if (root) { - retainedProjectSessionRoots.add(root); - return [ - { - cwd: root, - createdAt: session.lastActiveAt, - status: session.status, - }, - ]; - } - return []; - }) - : []; - const candidates = [ - ...pendingCwds, - ...settings.recentDirs, - ...sessions.map((session) => session.cwd), - ]; - // Root identity must be final before launch, even when the asynchronous - // workflow scan has not populated its cache yet. Probe only the candidate - // roots themselves; deeper discovery remains the registry's job. - const directlyMarked = ( - await Promise.all( - candidates.map(async (candidate) => ({ - candidate, - marker: await inspectAgentProjectMarker(candidate), - })), - ) - ) - .filter(({ marker }) => marker.status === "valid") - .map(({ candidate }) => candidate); - const visibleRoots = projectRoots({ - recentDirs: settings.recentDirs, - sessions, - pendingCwds, - pinnedRoots: durableRoots, - agentPaths: [ - ...workflowsCache.map((workflow) => workflow.path), - ...directlyMarked, - ], - sort: "recent", - }); - // MRU/rail visibility is not an authority revocation mechanism. An - // existing project session must remain resumable after its recent-dir - // entry is evicted, including an otherwise empty project whose session - // cwd is below the durable root. The browser may still hide an explicitly - // removed project through its local closed-project projection. - return [ - ...visibleRoots, - ...[...retainedProjectSessionRoots].filter( - (root) => - !visibleRoots.some((visible) => - samePath(canonicalGraphPath(visible), canonicalGraphPath(root)), - ), - ), - ]; - }, - ); - const activeSystemGraphScopes = new Map(); const systemGraphInvocations = new CachedAgentInvocationProvider( new SourceAgentInvocationProvider(), @@ -2850,7 +2856,6 @@ export const startServer = async ( type: "agent_map.capability", payload: { name: event.name, - ...(event.role ? { role: event.role } : {}), ...(event.reason ? { reason: event.reason } : {}), }, }; @@ -2881,7 +2886,6 @@ export const startServer = async ( payload: { tool: event.tool, outcome: event.outcome, - role: event.role, latency_ms: Math.max(0, Math.min(60_000, event.latencyMs)), ...(event.errorCode ? { error_code: event.errorCode } : {}), }, diff --git a/packages/harness/src/server/rest.ts b/packages/harness/src/server/rest.ts index 4d727d82..a59ca718 100644 --- a/packages/harness/src/server/rest.ts +++ b/packages/harness/src/server/rest.ts @@ -49,7 +49,7 @@ import { SessionNotResumeableError, SpawnTargetError, } from "../core/errors.js"; -import { SessionNotReadyError, UnknownSessionError, type SessionManager } from "../core/session-manager.js"; +import { ProjectSessionScopeUnavailableError, SessionNotReadyError, UnknownSessionError, type SessionManager } from "../core/session-manager.js"; import { normalizeCwd } from "./cwd-normalize.js"; import type { SessionRecordReader } from "../core/session-record.js"; import { getHarnessAdapter, listHarnessAdapters } from "../core/adapters/registry.js"; @@ -704,7 +704,8 @@ export function createRestRouter(options: RestRouterOptions): Router { err instanceof ExternalHarnessError || err instanceof AgentSessionIdentityReservedError || err instanceof SessionAlreadyLiveError || - err instanceof SessionNotResumeableError + err instanceof SessionNotResumeableError || + err instanceof ProjectSessionScopeUnavailableError ) { res.status(409).json({ error: err.message, code: (err as { code: string }).code }); return true; diff --git a/packages/harness/src/server/served-system-prompt.test.ts b/packages/harness/src/server/served-system-prompt.test.ts index 6f2e6f70..9328744f 100644 --- a/packages/harness/src/server/served-system-prompt.test.ts +++ b/packages/harness/src/server/served-system-prompt.test.ts @@ -15,6 +15,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { startServer, type HarnessServer } from "./index.js"; +import { PROJECT_AGENT_PROMPT_APPENDIX } from "../profiles/project-agent.js"; import { DEFAULT_SYSTEM_PROMPT } from "../profiles/default.js"; import type { HarnessAdapter, @@ -90,7 +91,7 @@ describe("served system prompt reaches the launched session", () => { const session = await server.sessionManager.create({ cwd, harness: "claude-code" }); - expect(await systemPromptFile(session.id)).toBe(SERVED_PROMPT); + expect(await systemPromptFile(session.id)).toBe(`${SERVED_PROMPT}\n\n${PROJECT_AGENT_PROMPT_APPENDIX}\n`); }); it("re-reads it on resume, so a redeployed prompt reaches a continued session", async () => { @@ -106,7 +107,7 @@ describe("served system prompt reaches the launched session", () => { served = "# Redeployed prompt"; await server.sessionManager.resume(session.id); - expect(await systemPromptFile(session.id)).toBe("# Redeployed prompt"); + expect(await systemPromptFile(session.id)).toBe(`# Redeployed prompt\n\n${PROJECT_AGENT_PROMPT_APPENDIX}\n`); }); it("falls back to the bundled profile when the load fails", async () => { @@ -118,6 +119,6 @@ describe("served system prompt reaches the launched session", () => { const session = await server.sessionManager.create({ cwd, harness: "claude-code" }); - expect(await systemPromptFile(session.id)).toBe(DEFAULT_SYSTEM_PROMPT); + expect(await systemPromptFile(session.id)).toBe(`${DEFAULT_SYSTEM_PROMPT}\n\n${PROJECT_AGENT_PROMPT_APPENDIX}\n`); }); }); diff --git a/packages/harness/src/shared/agent-map.ts b/packages/harness/src/shared/agent-map.ts index dba53628..900fe348 100644 --- a/packages/harness/src/shared/agent-map.ts +++ b/packages/harness/src/shared/agent-map.ts @@ -261,6 +261,10 @@ export interface SessionPrincipal { userId: string; } +/** Server-derived authority shared by every ordinary project session. */ +export type ProjectAgentSession = Readonly; + +/** Private compatibility contract retained while the old startup is retired. */ export type PlanningSessionIdentity = | (SessionPrincipal & { role: "map-planner" }) | (SessionPrincipal & { @@ -435,3 +439,38 @@ export type PlannerLifecycleEvent = errorCode: "delivery_uncertain"; queueDepth: number; }; + +export type ProjectBootstrapErrorCode = + | "session_not_ready" + | "session_exited" + | "injection_failed" + | "model_turn_failed" + | "delivery_timeout" + | "persistence_failed" + | "scope_unavailable"; + +export type ProjectBootstrapState = + | { status: "pending" } + | { status: "generating"; attemptId: string } + | { status: "delivered"; messageId: string } + | { + status: "failed"; + retryable: boolean; + errorCode: ProjectBootstrapErrorCode; + } + | { + status: "skipped"; + reason: "user-proceeded" | "map-not-empty"; + }; + +/** + * Lifecycle context for the one automatic map seed owned by a newly created + * project. It is deliberately separate from ProjectAgentSession authority. + */ +export interface ProjectBootstrapMetadata { + projectId: StudioProjectId; + userId: string; + targetSessionId: string; + bootstrap: ProjectBootstrapState; + queuedInputIds: string[]; +} diff --git a/packages/harness/src/shared/types.ts b/packages/harness/src/shared/types.ts index 59649387..75b22268 100644 --- a/packages/harness/src/shared/types.ts +++ b/packages/harness/src/shared/types.ts @@ -217,7 +217,9 @@ export interface HarnessSession { /** Trusted Studio-owned role metadata. Generic POST /sessions cannot set it. */ planning?: import("./agent-map.js").PlannerSessionMetadata; /** Server-authored, path-free identity used only to revalidate MCP scope. */ - agentMapIdentity?: import("./agent-map.js").PlanningSessionIdentity; + agentMapIdentity?: import("./agent-map.js").ProjectAgentSession; + /** Context for the existing project startup; separate from authority. */ + projectBootstrap?: import("./agent-map.js").ProjectBootstrapMetadata; } /**