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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/initialize-existing-project-agent-maps.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"@sapiom/harness": minor
---

Delete only legacy format-1 Agent Map workspace records during shared desktop and
CLI startup. Automatically generate missing maps for existing agents in the
background with one isolated, structured Claude Code or Codex inference pass.
Protect authored format-2 history from automatic edits, expose named initialization
status types and authenticated status/retry endpoints, and pack disconnected
components into compact layouts.

Back up and convert exact, unused historical format-2 containers before map
discovery, allowing their existing agents to receive an initial map. Preserve
current format-2 files and historical records containing authored state or history.
Exclude linked dependency/build/metadata directories from static source inspection
just like ordinary ignored directories, without following links into external sources.
14 changes: 14 additions & 0 deletions packages/harness-desktop/src/main/smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -508,6 +508,7 @@ async function checkUnpackedDeps(): Promise<string> {

const targets: Array<[string, string]> = [
["@sapiom/harness", harnessPkg],
["Codex initialization worker", path.join(path.dirname(harnessPkg), "dist", "core", "codex-structured-inference.js")],
["web SPA", resolveWebDir()],
// The ESM entry the Canvas subprocess imports (a stale/absent dist/esm here
// is exactly the ERR_MODULE_NOT_FOUND crash we hit).
Expand All @@ -526,6 +527,18 @@ async function checkUnpackedDeps(): Promise<string> {
return `${targets.length} entry points present on disk (asar-translated)`;
}

/** Import the inference sidecar in a real Node-mode child, without starting a provider. */
async function checkInitializationWorker(): Promise<string> {
const harnessPkg = unpacked(require.resolve("@sapiom/harness/package.json"));
const worker = path.join(path.dirname(harnessPkg), "dist", "core", "codex-structured-inference.js");
const { stdout } = await promisify(execFile)(process.execPath, ["--input-type=module", "-e",
`await import(${JSON.stringify(pathToFileURL(worker).href)}); process.stdout.write("initialization-worker-ready");`], {
cwd: tmpdir(), env: { ...process.env, ELECTRON_RUN_AS_NODE: "1" }, timeout: 10_000, maxBuffer: 16 * 1024,
});
if (stdout !== "initialization-worker-ready") throw new Error("Initialization worker failed to load");
return "unpacked initialization worker and dependencies load in a Node-mode child";
}

type BundleForDeploy = (sourceDir: string) => Promise<{
code: string;
dependencies: Record<string, string>;
Expand Down Expand Up @@ -1072,6 +1085,7 @@ export async function runSmokeChecks(boot: BootResult): Promise<SmokeCheck[]> {
await check("preload-bridge", checkPreloadBridge),
await check("node-pty", checkNodePty),
await check("unpacked-deps", checkUnpackedDeps),
await check("initialization-worker", checkInitializationWorker),
await check("runtime-shims", checkRuntimeShims),
await check("run-local", () => checkRunLocal(base, token)),
await check("deploy-bundle", checkDeployBundle),
Expand Down
53 changes: 53 additions & 0 deletions packages/harness/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,59 @@ Studio guidance.
aliases for the corresponding neutral plan, map and brief contracts; they do
not introduce a second data model.

### Existing projects after an update

Desktop and CLI startup reset only files with outer `storageSchemaVersion: 1`.
The reset deletes that project's `workspace.json` under its normal write lock and
journals completion; agent source, project identity, sessions, and history remain.
Format 2 is never reset. A separate compatibility pass recognizes four exact
historical wrapped-format-2 container shapes, only at their initial revision with
identical creation/update timestamps, null map/plan pointers, and every proposal,
receipt, brief, assignment, approval, consent, and history collection empty.
Under the project lock it durably saves the original bytes to
`workspace.empty-wrapped-v2.<sha256>.backup.json`, then atomically converts that
unused container into current format 2. Shared startup and late reads use the same
conversion, including initialization eligibility reads. A current-format-2 file
is never rewritten by this pass. Authored or uncertain older wrappers retain
their storage error and require separate data-preserving compatibility handling.

Once discovery completes, projects with agents and no authored map receive one
background structured inference pass. Valid unused format-2 containers qualify;
any current map, map version, or accepted operation history prevents automatic
initialization, even if a user emptied the graph. Generation uses static contract
evidence, two concurrent tasks at most, and a three-minute timeout. Queued work
resumes on restart; failed or interrupted work requires **Retry generation**.
The final write rechecks ownership, project access, and absence under the map lock.
A coding session that creates a map first wins; automatic output is discarded.
New-project Plan Agents bootstrap shares this first-map ownership decision.
Static inspection excludes dependency, build, and Studio metadata directories,
including symlinks at those ignored boundaries. Other source links remain opaque
and prevent generation from proceeding with incomplete evidence.

The pass uses the project's latest available coding provider, otherwise the host
default, and its configured default model. Authentication/execution failures do
not switch providers. Claude exposes only its JSON formatter, with coding tools,
hooks, MCP servers, and optional user authentication helpers disabled. Native
OAuth or API-key authentication is retained; helper-only logins cannot initialize
maps in the background. Codex uses an
ephemeral app-server thread with no code environment and an isolated provider
configuration; its login snapshot cannot rotate the native refresh token. Native
file authentication and Mac's direct keychain are supported; unavailable or
unsupported credential stores fail without changing the user's authentication.
These restrictions remove the model's project-write capabilities; native CLIs
still run as the user and remain subject to administrator-managed authentication.
No background session tab, provisional inventory graph, or raw inference task is
published to the browser. Relationships need contract evidence; disconnected
agents remain visible in a compact component layout. Selection survives topology
updates; the view follows updates until the user pans or zooms, and **Fit** resumes it.

`GET /api/projects/:projectId/agent-map/initialization` returns bounded lifecycle
state. The authenticated `POST .../initialization/retry` repeats eligibility checks.
`agent-map.initialization.changed` announces status only; prompts, source paths,
credentials, and raw model output are never included.
While generation is active, the selected project also polls its durable status
so completion by another Studio process is visible without reloading the page.

### Agent Map MCP

Studio exposes a stateful Streamable HTTP MCP endpoint at `/mcp/agent-map` for
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 12 additions & 0 deletions packages/harness/src/core/adapters/claude-code.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,18 @@ describe("ClaudeCodeAdapter", () => {
});

describe("launchTask", () => {
it("restricts inference to structured output while preserving native model selection", () => {
const adapter = new ClaudeCodeAdapter({ binary: "fake-claude" });
const spec = adapter.launchTask({ harnessSessionId: "private-task", cwd: "/isolated", prompt: "contract evidence",
structuredInference: { projectId: "project-test", schema: { type: "object" }, schemaFile: "/isolated/schema.json", systemPrompt: "Return structured JSON" },
mcpConfigFile: "/project/mcp.json", settingsFile: "/project/settings.json", systemPromptFile: "/project/instructions" });
expect(spec.stdin).toBe("contract evidence");
expect(spec.args).toContain("--safe-mode"); expect(spec.args[spec.args.indexOf("--tools") + 1]).toBe("");
expect(JSON.parse(spec.args[spec.args.indexOf("--settings") + 1]!)).toMatchObject({ apiKeyHelper: "", awsAuthRefresh: "", awsCredentialExport: "", gcpAuthRefresh: "" });
expect(spec.args).not.toContain("--model"); expect(spec.args).not.toContain("--mcp-config");
expect(spec.args.join(" ")).not.toContain("/project/"); expect(spec.args).toContain("--no-session-persistence");
});

it("builds a headless -p SpawnSpec with the same config flags plus acceptEdits and stream-json output", async () => {
const promptDir = await mkdtemp(join(tmpdir(), "harness-claude-test-"));
const promptFile = join(promptDir, "prompt.txt");
Expand Down
12 changes: 12 additions & 0 deletions packages/harness/src/core/adapters/claude-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,18 @@ export class ClaudeCodeAdapter implements HarnessAdapter {
if (!opts.prompt) {
throw new Error("claude-code adapter: launchTask requires opts.prompt");
}
if (opts.structuredInference) {
return { command: this.binary, cwd: opts.cwd, env: { CLAUDECODE: null }, stdin: opts.prompt,
args: ["-p", "--safe-mode", "--tools", "", "--no-session-persistence",
// Safe mode retains native authentication. Disable optional executable
// user helpers too; empty strings (unlike null) override native settings.
"--settings", JSON.stringify({ apiKeyHelper: "", awsAuthRefresh: "", awsCredentialExport: "",
gcpAuthRefresh: "", otelHeadersHelper: "", proxyAuthHelper: "" }),
"--system-prompt", opts.structuredInference.systemPrompt,
"--json-schema", JSON.stringify(opts.structuredInference.schema),
"--output-format", "stream-json", "--verbose", "--max-turns", "2",
...(opts.model ? ["--model", opts.model] : [])] };
}
const args = ["-p", opts.prompt, ...buildConfigArgs(opts)];
if (opts.systemPromptFile) {
args.push("--append-system-prompt", readPromptFile(opts.systemPromptFile));
Expand Down
12 changes: 12 additions & 0 deletions packages/harness/src/core/adapters/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
* sees with zero indication why).
*/

import { fileURLToPath } from "node:url";
import { unpackedPath } from "../asar-path.js";
import { execFile } from "node:child_process";
import { readFileSync } from "node:fs";
import { open, readdir, realpath, stat } from "node:fs/promises";
Expand Down Expand Up @@ -299,6 +301,16 @@ export class CodexAdapter implements HarnessAdapter {
}
}

readonly supportsCodingTasks = false;

launchTask(opts: LaunchOpts): SpawnSpec {
if (!opts.prompt || !opts.structuredInference) throw new Error("Codex background tasks require structured inference mode");
return { command: process.execPath,
args: [unpackedPath(fileURLToPath(new URL("../codex-structured-inference.js", import.meta.url))), this.binary],
cwd: opts.cwd, env: { ...(process.versions.electron ? { ELECTRON_RUN_AS_NODE: "1" } : {}) },
stdin: JSON.stringify({ prompt: opts.prompt, systemPrompt: opts.structuredInference.systemPrompt, schema: opts.structuredInference.schema }) };
}

launch(opts: LaunchOpts): SpawnSpec {
const args = buildConfigArgs(opts);
if (opts.initialPrompt) args.push("--", opts.initialPrompt);
Expand Down
Loading
Loading