Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
50f982a
fix(harness-desktop): update coding agents before sessions start
ynadge Sep 6, 2026
9fadbae
feat(harness-desktop): install and launch isolated agent CLIs
ynadge Sep 6, 2026
5817fe4
refactor(harness-desktop): separate startup updates from CLI runtime
ynadge Sep 6, 2026
6ef6d87
fix(harness): integrate managed launchers with Agent Map initialization
ynadge Sep 6, 2026
344b611
test(harness): account for initialization icon and canonical temp paths
ynadge Sep 6, 2026
44fb9ec
test(harness): await published project session before submitting input
ynadge Sep 6, 2026
56dd67b
chore(release): synchronize managed desktop candidate with main
ynadge Sep 6, 2026
40fe3e8
fix(harness): stabilize terminal teardown and mock workspace restoration
ynadge Sep 6, 2026
3cacd71
Merge remote-tracking branch 'origin/main' into HEAD
ynadge Sep 6, 2026
0667044
Merge commit '3cacd7171d3a042a32533b85a24d8436c6b69f29' into HEAD
ynadge Sep 6, 2026
56c4cdc
fix(harness-desktop): contain runtime state and own installer process…
ynadge Sep 6, 2026
bfdca2d
Merge commit '56c4cdc8b2c9c85ba6bb8d21d225b77c18de3dbf' into HEAD
ynadge Sep 6, 2026
39c3e64
fix(harness-desktop): clean unpublished provider update attempts
ynadge Sep 6, 2026
1aee7fa
ci(harness): allow expanded browser suites to finish
ynadge Sep 6, 2026
4d59468
Merge commit '1aee7faf7be7c6a448fc5fbe382cc19a057ef224' into HEAD
ynadge Sep 6, 2026
3ef5843
ci(harness): keep browser checks within the repository time budget
ynadge Sep 6, 2026
e89c69d
docs(release): align combined Studio notes with shipped behavior
ynadge Sep 6, 2026
825a752
Merge commit 'e89c69d48923b886f81db8df2bfaa354fb82d69a' into HEAD
ynadge Sep 6, 2026
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
2 changes: 1 addition & 1 deletion .changeset/atomic-project-state-migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@
"@sapiom/harness": minor
---

Migrate persisted project maps atomically to immutable version histories and role-neutral proposal attribution, with storage for shared build plans. Map and brief quotas, malformed aggregates and unsupported storage schemas now report terminal manual-intervention recovery through MCP; operation history is explicitly bounded before writes.
Store project maps atomically with immutable version histories and role-neutral proposal attribution, alongside shared build plans. Map and brief quotas, malformed aggregates and unsupported storage schemas now report terminal manual-intervention recovery through MCP; operation history is explicitly bounded before writes. This storage contract does not preserve format-1 maps: startup resets those maps under the legacy-reset policy.

**Breaking:** `ProposalActor` and proposal-history payloads now contain only trusted `userId` and `sessionId` attribution. Consumers must stop reading or constructing the removed `role` and `assignment` fields and use `sessionId` for attribution. Those fields never represented write or implementation authority.
2 changes: 1 addition & 1 deletion .changeset/bootstrap-coordinator.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
"@sapiom/harness": patch
---

Internal groundwork for automatic Agent Map bootstrap, including recovery, FIFO delivery, and shutdown handling. Recovery events describe committed state. No user-facing behavior changes in this release.
Support automatic Agent Map bootstrap with recovery, FIFO delivery, and shutdown handling. Recovery events describe committed state.
2 changes: 1 addition & 1 deletion .changeset/bootstrap-storage.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
"@sapiom/harness": patch
---

Internal storage groundwork for automatic Agent Map bootstrap. Clean up temporary state after failed writes and ignore unrelated files when reading durable project intents. No user-facing behavior changes in this release.
Add durable project intents for automatic Agent Map bootstrap. Clean up temporary state after failed writes and ignore unrelated files when reading project intents.
7 changes: 7 additions & 0 deletions .changeset/desktop-managed-installs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@sapiom/harness-desktop": patch
---

Install and verify isolated coding-provider versions using the bundled runtime,
with bounded installer processes and cancellation. Keep Electron's runtime
startup flag out of commands launched by managed providers.
8 changes: 8 additions & 0 deletions .changeset/fresh-studio-agent-clis.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@sapiom/harness-desktop": patch
---

Update installed Claude Code and Codex before desktop sessions start. Verify
each new executable before selecting it and retain the working version when
updates fail or the device is offline. Preserve provider configuration and history.
Remove unpublished installation files from failed or cancelled update attempts.
7 changes: 7 additions & 0 deletions .changeset/managed-agent-runtime.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@sapiom/harness": minor
---

Add optional interpreter arguments and environment to coding-provider adapters
and export `createCodexAdapter`. Preserve managed launch configuration across
new sessions, resume, and private structured inference for both providers.
2 changes: 1 addition & 1 deletion .changeset/project-session-shortcut.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@
"@sapiom/harness-desktop": patch
---

The project-row `+` now starts a coding-agent session at that project root. Previously it created an agent. Sapiom agent creation remains owned by Plan Agents.
The project-row `+` now starts an ordinary coding-agent session at that project root. Sessions can create agents and work on the shared Agent Map; Plan Agents is an ordinary session without exclusive creation authority.
7 changes: 7 additions & 0 deletions .changeset/studio-terminal-teardown.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@sapiom/harness": patch
---

Prevent terminal mount cleanup from disposing the renderer before xterm's
queued viewport initialization runs. Ignore callbacks from closed terminal
connections and preserve workspace preferences across reloads in the Studio demo.
4 changes: 3 additions & 1 deletion .github/workflows/harness.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,9 @@ jobs:
# invokes them — they require real agent binaries and credentials not in CI.
playwright-mock:
runs-on: ubuntu-latest
timeout-minutes: 15
# The expanded mock suite has over 560 cases, followed by canvas checks.
# Keep the existing per-test deadlines; allow both suites to finish.
timeout-minutes: 20
# Single Node version is enough — the mock tier tests browser behaviour,
# not Node version compat; that's covered by the matrix job in test.yml.
steps:
Expand Down
30 changes: 30 additions & 0 deletions packages/harness-desktop/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Agent Studio desktop

The Electron host for `@sapiom/harness`. See [CLAUDE.md](CLAUDE.md) for packaging.

## Coding-agent updates

On each normal launch, Studio checks installed Claude Code and Codex against npm
`latest` before starting sessions. If neither is available, setup installs Claude.

Updates use isolated per-user `agent-versions/` directories and pass a version
check before activation. Global installations stay untouched; newer or unknown
versions on PATH are preserved. Previous successful directories remain for running
processes; automatic eviction is not implemented. Failed or cancelled attempts
clean up their own unpublished installation and temporary selector.

Registry requests time out after five seconds; installers are stopped after
90 seconds, with bounded child-process cleanup. Failed/offline updates keep the
working CLI. Closing Studio cancels active updates.

Quit and reopen Studio to check again. New and resumed processes use the selected
CLI; running sessions keep their executable. Authentication, configuration, model
caches, selected models, and history stay intact. Model access also depends on
your provider account.

Studio manages updates for its Claude copies and disables Codex's startup update
check. JavaScript launchers use the bundled runtime, including on Node-less Windows.

`--dev`, `--smoke`, and `SAPIOM_DISABLE_AGENT_UPDATES=1` skip registry/install calls
but can reuse selected copies. Progress appears during setup and in `main.log`
under `[boot] agent-update`. Desktop app updates and MCP refreshes run separately.
15 changes: 15 additions & 0 deletions packages/harness-desktop/src/main/agent-install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { readFileSync } from "node:fs";
import * as path from "node:path";
import { promisify } from "node:util";
import { CLAUDE_INSTALL_COMMAND } from "@sapiom/harness";
import { runUpdateCommand } from "./agent-update-process.js";
import {
SAPIOM_CLI_PACKAGE,
SAPIOM_MCP_PACKAGE,
Expand Down Expand Up @@ -125,6 +126,20 @@ export function installClaudeCode(
return installNpmGlobal(packageSpecFromInstallCommand(CLAUDE_INSTALL_COMMAND), onLine);
}

/** Install an exact version into an isolated prefix with a bounded deadline. */
export async function installAgentVersion(
packageSpec: string,
prefix: string,
onLine: (line: string) => void,
): Promise<boolean> {
const result = await runUpdateCommand(process.execPath, [
resolveNpmCli(), "install", "--global", packageSpec, "--prefix", prefix,
"--no-audit", "--no-fund", "--loglevel=info", "--fetch-retries=0", "--fetch-timeout=15000",
], { env: npmInstallEnv(process.env), timeoutMs: 90_000, onLine });
if (!result.ok) onLine(result.detail);
return result.ok;
}

/**
* Install the `sapiom` CLI into the same per-user prefix, for the same reason and
* by the same mechanism as the agent: the app hands the coding agent
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import { describe, expect, it, vi } from "vitest";

const { spawn } = vi.hoisted(() => ({ spawn: vi.fn() }));
vi.mock("node:child_process", async (original) => ({
...await original<typeof import("node:child_process")>(), spawn,
}));
import { runUpdateCommand } from "./agent-update-process.js";

describe.skipIf(process.platform === "win32")("POSIX update group ownership", () => {
it("never signals the old group after its supervisor has exited", async () => {
const child = Object.assign(new EventEmitter(), {
pid: 12345, stdin: new PassThrough(), stdout: new PassThrough(),
stderr: new PassThrough(), kill: vi.fn(),
});
spawn.mockReturnValueOnce(child);
const signal = vi.spyOn(process, "kill").mockReturnValue(true);
try {
const result = runUpdateCommand("installer", [], { env: {}, timeoutMs: 10 });
child.emit("exit", 0);
expect((await result).detail).toContain("Timed out");
expect(signal).not.toHaveBeenCalled();
expect(child.kill).not.toHaveBeenCalled();
} finally { signal.mockRestore(); }
});
});
144 changes: 144 additions & 0 deletions packages/harness-desktop/src/main/agent-update-process.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it, vi } from "vitest";
import { runUpdateCommand, windowsUpdateTree } from "./agent-update-process.js";

describe("Windows update process identity", () => {
const identity = { pid: 10, startedAt: 100, spawnedAt: 110 };
const root = { pid: 10, parentPid: 1, createdAt: 105 };
const child = { pid: 20, parentPid: 10, createdAt: 120 };
const grandchild = { pid: 30, parentPid: 20, createdAt: 130 };

it("stops descendants before the original parent", () => {
expect(windowsUpdateTree([root, child, grandchild], identity, 200)).toEqual(
[grandchild, child, root],
);
});

it("includes children spawned by a live parent while the snapshot was being collected", () => {
const lateChild = { pid: 40, parentPid: 10, createdAt: 220 };
expect(windowsUpdateTree([root, lateChild], identity, 200)).toEqual([
lateChild,
root,
]);
});

it("finds orphaned descendants but excludes children born after npm exited", () => {
const unrelated = { pid: 40, parentPid: 10, createdAt: 180 };
expect(
windowsUpdateTree(
[child, grandchild, unrelated],
{ ...identity, exitedAt: 150 },
200,
),
).toEqual([grandchild, child]);
});

it("never kills or traverses an unrelated process that reused the root PID", () => {
const replacement = { ...root, createdAt: 160 };
const unrelated = { pid: 40, parentPid: 10, createdAt: 180 };
expect(windowsUpdateTree([replacement, unrelated], identity, 200)).toEqual(
[],
);
expect(
windowsUpdateTree([root, child], { ...identity, exitedAt: 150 }, 200),
).toEqual([]);
});
});

describe("bounded update processes", () => {
it("preserves argument boundaries, environment and both output streams", async () => {
const result = await runUpdateCommand(process.execPath, ["-e", `
console.log(JSON.stringify({args:process.argv.slice(1), flag:process.env.ELECTRON_RUN_AS_NODE??null, retained:process.env.STUDIO_UPDATE_TEST}));
console.error('stderr retained');
`, "space and $literal"], {
env: { PATH: process.env.PATH, STUDIO_UPDATE_TEST: "retained" }, timeoutMs: 2_000,
});
expect(result.ok).toBe(true);
expect(JSON.parse(result.stdout)).toEqual({args:["space and $literal"],flag:null,retained:"retained"});
expect(result.detail).toContain("stderr retained");
});

it("reports missing executables and failed commands without hanging startup", async () => {
const opts = { env: process.env, timeoutMs: 1_000 };
const missing = await runUpdateCommand(
"studio-deliberately-missing-executable",
[],
opts,
);
const failed = await runUpdateCommand(
process.execPath,
["-e", "process.exit(1)"],
opts,
);
expect(missing.ok).toBe(false);
expect(failed.ok).toBe(false);
});

it.each([false, true])(
"kills a timed-out process tree, including an already-exited parent (%s)",
async (parentExits) => {
const root = await mkdtemp(join(tmpdir(), "studio-update-timeout-"));
const marker = join(root, "child-pid");
try {
const childCode = "setInterval(() => {}, 1000)";
const code = `const {spawn}=require('node:child_process'); const c=spawn(process.execPath,['-e',${JSON.stringify(childCode)}],{stdio:['ignore',1,2]}); require('node:fs').writeFileSync(${JSON.stringify(marker)},String(c.pid)); ${parentExits ? "process.exit(0)" : "setInterval(()=>{},1000)"};`;
const result = await runUpdateCommand(process.execPath, ["-e", code], {
env: process.env,
timeoutMs: 700,
});
expect(result.ok).toBe(false);
expect(result.detail).toContain("Timed out");
const pid = Number(await readFile(marker, "utf8"));
// Linux can retain a terminated process as a zombie.
await vi.waitFor(
async () => {
if (process.platform === "linux") {
const status = await readFile(
`/proc/${pid}/status`,
"utf8",
).catch(() => "State:\tZ");
expect(status).toMatch(/State:\s+Z/);
} else {
expect(() => process.kill(pid, 0)).toThrow();
}
},
{ timeout: 1_000, interval: 20 },
);
} finally {
await rm(root, { recursive: true, force: true });
}
},
);

it("cancels running updates and prevents new installers when Studio quits during setup", async () => {
vi.resetModules();
const commands = await import("./agent-update-process.js");
let ready!: () => void;
const started = new Promise<void>((resolve) => {
ready = resolve;
});
const command = commands.runUpdateCommand(
process.execPath,
["-e", "console.log('ready');setInterval(()=>{},1000)"],
{
env: process.env,
timeoutMs: 5_000,
onLine: () => ready(),
},
);
await started;
await commands.stopAgentUpdateCommands();
expect((await command).detail).toBe("Studio is quitting");
const rejected = await commands.runUpdateCommand(
process.execPath,
["-e", "process.exit(0)"],
{
env: process.env,
timeoutMs: 1_000,
},
);
expect(rejected.ok).toBe(false);
});
});
Loading
Loading