@cozibud/harness 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,24 +1,48 @@
1
- # `@cozibud/harness`
2
-
3
- Internal infrastructure for the Cozibud Watch local-client bridge. It invokes a
4
- supported local AI client and exposes the provider-neutral runtime used by the
5
- bridge engineering tools.
6
-
7
- This package is not a released Watch feature. The authenticated server relay,
8
- device pairing, and production delivery path are not included, and the local
9
- HTTP adapter must not be exposed to a LAN or the internet.
10
-
11
- ## Maintainer smoke check
12
-
13
- After building or installing an explicitly reviewed version:
14
-
15
- ```text
16
- cozibud-harness --help
17
- ```
18
-
19
- Provider setup and request commands are internal acceptance tools. They use the
20
- provider's own installed client and sign-in state; the harness does not accept
21
- or store provider passwords.
22
-
23
- Release and bootstrap procedures live in the private repository's
24
- `docs/NPM-HARNESS-PUBLISHING.md` operator runbook.
1
+ # `@cozibud/harness`
2
+
3
+ Connect a signed-in Codex CLI to your Cozibud household for watch voice.
4
+ Keep the computer awake and the outbound relay running while using voice.
5
+ Watch voice requires Codex CLI 0.159.0 or newer and a server supporting relay
6
+ protocol 2. This source version still needs its package and server release.
7
+
8
+ ## Watch voice
9
+
10
+ After installing the released harness, sign in to Codex with `codex login`.
11
+ Run this single command:
12
+
13
+ ```text
14
+ cozibud-harness relay --provider codex
15
+ ```
16
+
17
+ On the first run, open the displayed Cozibud link and approve your computer
18
+ in the household you use on your watch. The command then connects automatically.
19
+ Later runs reuse the saved pairing. Keep this command running and the computer awake.
20
+ If the watch finds no connected computer, **Open guide on phone** shows the setup steps.
21
+
22
+ On the watch, select the child, speak, and check the transcript. **Send & save**
23
+ authorizes the server to validate and save one record. Ambiguous requests ask
24
+ for clarification. A saved-record receipt appears only after server readback.
25
+ No microphone audio is sent to the bridge; the confirmed transcript and selected
26
+ child context go to your signed-in Codex client and its model service.
27
+
28
+ Your Codex login stays local. Pairing credentials are protected with your
29
+ Windows account's DPAPI on Windows, or a user-only file on other systems.
30
+ Disconnect the computer in Cozibud's Watch settings to revoke its access.
31
+ Use `COZIBUD_RELAY_URL` to choose another HTTPS server before pairing; pairings
32
+ are scoped to that origin. The relay requires no inbound computer port.
33
+ The separate local HTTP adapter must remain on loopback.
34
+
35
+ ## Maintainer smoke check
36
+
37
+ After building or installing an explicitly reviewed version:
38
+
39
+ ```text
40
+ cozibud-harness --help
41
+ ```
42
+
43
+ Provider setup and request commands are internal acceptance tools. They use the
44
+ provider's own installed client and sign-in state; the harness does not accept
45
+ or store provider passwords.
46
+
47
+ Release and bootstrap procedures live in the private repository's
48
+ `docs/NPM-HARNESS-PUBLISHING.md` operator runbook.
package/dist/cli.js CHANGED
@@ -1,6 +1,11 @@
1
1
  #!/usr/bin/env node
2
- import { randomBytes } from "node:crypto";
2
+ import { createHash, randomBytes } from "node:crypto";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { hostname } from "node:os";
6
+ import { ensureRelayPairing, pairRelay, relayOrigin } from "./pairing.js";
3
7
  import { createBuiltInDrivers } from "./process-driver.js";
8
+ import { FileRelayExecutionStore, OutboundRelayBridge, RelayTransportError } from "./relay-client.js";
4
9
  import { HarnessRuntime } from "./runtime.js";
5
10
  import { createHarnessHttpServer } from "./server.js";
6
11
  import { DEFAULT_COZIBUD_MCP_URL, setupProvider } from "./setup.js";
@@ -20,26 +25,96 @@ function promptFrom(args) {
20
25
  return parts.join(" ").trim();
21
26
  }
22
27
  function printHelp() {
23
- console.log(`Cozibud local LLM harness
24
-
25
- Usage:
26
- cozibud-harness setup [--provider codex|claude|gemini|cursor] [--mcp-url https://cozibud.com/api/mcp]
27
- cozibud-harness ask [--provider codex|claude|gemini|cursor] [--locale en-US] "When was the last bottle?"
28
- cozibud-harness serve [--host 127.0.0.1] [--port 8798]
29
-
30
- Quick start:
31
- setup prepares a signed-in local client and the MCP access its bridge turns need
32
- ask sends one typed request through an already signed-in provider
33
-
34
- Environment:
35
- COZIBUD_HARNESS_TOKEN bearer token (generated for this run when omitted)
36
- COZIBUD_HARNESS_ORIGINS comma-separated browser origins allowed by CORS
37
- COZIBUD_CODEX_PATH optional Codex executable path
38
- COZIBUD_CLAUDE_PATH optional Claude executable path
39
- COZIBUD_GEMINI_PATH optional Gemini executable path
40
- COZIBUD_CURSOR_PATH optional Cursor Agent executable path
28
+ console.log(`Cozibud local LLM harness
29
+
30
+ Usage:
31
+ cozibud-harness setup [--provider codex|claude|gemini|cursor] [--mcp-url https://cozibud.com/api/mcp]
32
+ cozibud-harness ask [--provider codex|claude|gemini|cursor] [--locale en-US] "When was the last bottle?"
33
+ cozibud-harness relay [--provider codex]
34
+ cozibud-harness pair [--name "My computer"]
35
+ cozibud-harness serve [--host 127.0.0.1] [--port 8798]
36
+
37
+ Quick start:
38
+ setup prepares a signed-in local client and the MCP access its bridge turns need
39
+ ask sends one typed request through an already signed-in provider
40
+ relay pairs this computer if needed, then keeps watch voice connected
41
+
42
+ Environment:
43
+ COZIBUD_HARNESS_TOKEN bearer token (generated for this run when omitted)
44
+ COZIBUD_HARNESS_ORIGINS comma-separated browser origins allowed by CORS
45
+ COZIBUD_RELAY_URL canonical Cozibud origin (defaults to https://cozibud.com)
46
+ COZIBUD_RELAY_CREDENTIAL optional paired credential override (never printed)
47
+ COZIBUD_RELAY_PAIRING protected pairing file path
48
+ COZIBUD_RELAY_STATE local idempotency journal path
49
+ COZIBUD_CODEX_PATH optional Codex executable path
50
+ COZIBUD_CLAUDE_PATH optional Claude executable path
51
+ COZIBUD_GEMINI_PATH optional Gemini executable path
52
+ COZIBUD_CURSOR_PATH optional Cursor Agent executable path
53
+ COZIBUD_CURSOR_WORKSPACE stable dedicated empty Cursor bridge workspace
41
54
  COZIBUD_CURSOR_MCP_CONFIG optional Cursor user MCP config path (test/development override)`);
42
55
  }
56
+ async function relay(args) {
57
+ const provider = value(args, "--provider", "codex");
58
+ const driver = createBuiltInDrivers().find((candidate) => candidate.id === provider);
59
+ if (!driver)
60
+ throw new Error(`Unknown provider: ${provider}. Choose codex, claude, gemini, or cursor.`);
61
+ if (provider !== "codex")
62
+ throw new Error("Watch voice currently requires Codex CLI.");
63
+ const baseUrl = relayOrigin(process.env.COZIBUD_RELAY_URL?.trim() || "https://cozibud.com");
64
+ const available = await driver.probe();
65
+ if (available.state === "unavailable")
66
+ throw new Error(available.message);
67
+ const version = available.version?.match(/(\d+)\.(\d+)\.(\d+)/);
68
+ if (!version || (Number(version[1]) === 0 && Number(version[2]) < 159))
69
+ throw new Error("Watch voice requires Codex CLI 0.159.0 or newer. Update Codex, sign in, and retry.");
70
+ const controller = new AbortController();
71
+ process.once("SIGINT", () => controller.abort());
72
+ process.once("SIGTERM", () => controller.abort());
73
+ let credential = process.env.COZIBUD_RELAY_CREDENTIAL?.trim() || await ensureRelayPairing({ origin: baseUrl,
74
+ name: value(args, "--name", hostname()), path: pairingPath(), write: console.log, signal: controller.signal });
75
+ const createBridge = () => {
76
+ const namespace = createHash("sha256").update(`${baseUrl}\n${credential}`).digest("hex").slice(0, 20);
77
+ const statePath = process.env.COZIBUD_RELAY_STATE?.trim() || join(homedir(), ".cozibud", `relay-state-${namespace}.json`);
78
+ return new OutboundRelayBridge({ baseUrl, credential, driver, store: new FileRelayExecutionStore(statePath) });
79
+ };
80
+ let bridge = createBridge();
81
+ let repaired = false;
82
+ let connected = false;
83
+ let backoffMs = 1_000;
84
+ while (!controller.signal.aborted) {
85
+ try {
86
+ if (!connected) {
87
+ await bridge.connect();
88
+ connected = true;
89
+ backoffMs = 1_000;
90
+ console.log(`Cozibud relay connected with ${driver.label}. No local or LAN listener is open.`);
91
+ }
92
+ const state = await bridge.step();
93
+ if (state === "idle")
94
+ await pause(1_000, controller.signal);
95
+ }
96
+ catch (error) {
97
+ if (error instanceof RelayTransportError && [401, 403, 404].includes(error.status)) {
98
+ await bridge.close();
99
+ if ([401, 403].includes(error.status) && !process.env.COZIBUD_RELAY_CREDENTIAL?.trim() && !repaired && !controller.signal.aborted) {
100
+ repaired = true;
101
+ console.log("This computer's pairing is no longer valid. Approve it again to reconnect.");
102
+ credential = await pairRelay({ origin: baseUrl, name: value(args, "--name", hostname()), path: pairingPath(), write: console.log, signal: controller.signal });
103
+ bridge = createBridge();
104
+ connected = false;
105
+ continue;
106
+ }
107
+ throw new Error("Desktop pairing is expired, revoked, or disabled. Run pair again on the selected server.");
108
+ }
109
+ connected = false;
110
+ console.error("Cozibud relay disconnected; retrying the outbound connection.");
111
+ await pause(backoffMs, controller.signal);
112
+ backoffMs = Math.min(15_000, backoffMs * 2);
113
+ }
114
+ }
115
+ await bridge.close();
116
+ }
117
+ function pairingPath() { return process.env.COZIBUD_RELAY_PAIRING?.trim() || join(homedir(), ".cozibud", "relay-pairing.json"); }
43
118
  async function ask(args) {
44
119
  const provider = value(args, "--provider", "codex");
45
120
  const locale = value(args, "--locale", "en-US");
@@ -118,6 +193,11 @@ try {
118
193
  }
119
194
  else if (command === "ask")
120
195
  await ask(args);
196
+ else if (command === "relay")
197
+ await relay(args);
198
+ else if (command === "pair")
199
+ await pairRelay({ origin: process.env.COZIBUD_RELAY_URL || "https://cozibud.com",
200
+ name: value(args, "--name", hostname()), path: pairingPath(), write: console.log });
121
201
  else if (command === "serve")
122
202
  await serve(args);
123
203
  else
@@ -127,3 +207,12 @@ catch (error) {
127
207
  console.error(error instanceof Error ? error.message : "The local harness failed.");
128
208
  process.exitCode = 1;
129
209
  }
210
+ function pause(ms, signal) {
211
+ return new Promise(resolve => {
212
+ const done = () => { clearTimeout(timer); signal.removeEventListener("abort", done); resolve(); };
213
+ const timer = setTimeout(done, ms);
214
+ signal.addEventListener("abort", done, { once: true });
215
+ if (signal.aborted)
216
+ done();
217
+ });
218
+ }
@@ -7,6 +7,10 @@ export interface HarnessTurnInput {
7
7
  source: HarnessInputSource;
8
8
  client: HarnessClient;
9
9
  locale?: string;
10
+ /** Server request-bound identity that every care-record write must use. */
11
+ careRecordIdempotencyKey?: string;
12
+ /** Server-supplied interpretation instructions; no MCP writes in this mode. */
13
+ interpretation?: string;
10
14
  }
11
15
  export interface HarnessTurnRequest extends HarnessTurnInput {
12
16
  harness: string;
@@ -0,0 +1,3 @@
1
+ export declare const DEFAULT_CURSOR_WORKSPACE: string;
2
+ export declare function cursorWorkspacePath(configured?: string | undefined): string;
3
+ export declare function ensureCursorWorkspace(configured?: string): string;
@@ -0,0 +1,39 @@
1
+ import { chmodSync, lstatSync, mkdirSync, readdirSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { isAbsolute, join, parse, relative, resolve, sep } from "node:path";
4
+ export const DEFAULT_CURSOR_WORKSPACE = join(homedir(), ".cozibud", "cursor-workspace");
5
+ export function cursorWorkspacePath(configured = process.env.COZIBUD_CURSOR_WORKSPACE) {
6
+ const override = configured?.trim();
7
+ if (override && !isAbsolute(override))
8
+ throw new Error("COZIBUD_CURSOR_WORKSPACE must be an absolute path.");
9
+ const candidate = override || DEFAULT_CURSOR_WORKSPACE;
10
+ return resolve(candidate);
11
+ }
12
+ export function ensureCursorWorkspace(configured) {
13
+ const workspace = cursorWorkspacePath(configured);
14
+ const root = parse(workspace).root;
15
+ const current = resolve(process.cwd());
16
+ const isWithin = (parent, candidate) => {
17
+ const path = relative(parent, candidate);
18
+ return path !== "" && !isAbsolute(path) && path !== ".." && !path.startsWith(`..${sep}`);
19
+ };
20
+ if (workspace === root || workspace === current || isWithin(current, workspace) || isWithin(workspace, current)) {
21
+ throw new Error("Cursor bridge workspace must be dedicated and outside the current repository or worktree.");
22
+ }
23
+ try {
24
+ const state = lstatSync(workspace);
25
+ if (state.isSymbolicLink() || !state.isDirectory()) {
26
+ throw new Error("Cursor bridge workspace must be a real directory, not a file or symbolic link.");
27
+ }
28
+ if (readdirSync(workspace).length !== 0) {
29
+ throw new Error("Cursor bridge workspace must be empty and dedicated to Cozibud.");
30
+ }
31
+ }
32
+ catch (error) {
33
+ if (error.code !== "ENOENT")
34
+ throw error;
35
+ mkdirSync(workspace, { recursive: true, mode: 0o700 });
36
+ }
37
+ chmodSync(workspace, 0o700);
38
+ return workspace;
39
+ }
package/dist/index.d.ts CHANGED
@@ -3,3 +3,4 @@ export * from "./process-driver.js";
3
3
  export * from "./runtime.js";
4
4
  export * from "./server.js";
5
5
  export * from "./setup.js";
6
+ export * from "./relay-client.js";
package/dist/index.js CHANGED
@@ -3,3 +3,4 @@ export * from "./process-driver.js";
3
3
  export * from "./runtime.js";
4
4
  export * from "./server.js";
5
5
  export * from "./setup.js";
6
+ export * from "./relay-client.js";
@@ -0,0 +1,8 @@
1
+ export declare function protectPairing(value: string): Promise<{
2
+ protection: string;
3
+ value: string;
4
+ }>;
5
+ export declare function unprotectPairing(row: {
6
+ protection: string;
7
+ value: string;
8
+ }): Promise<string>;
@@ -0,0 +1,34 @@
1
+ import { spawn } from "node:child_process";
2
+ import { join } from "node:path";
3
+ /** Windows DPAPI binds the pairing secret to the signed-in OS user. */
4
+ async function dpapi(value, decrypt) {
5
+ const script = [
6
+ "Add-Type -AssemblyName System.Security;",
7
+ "$inputValue = [Console]::In.ReadToEnd();",
8
+ decrypt
9
+ ? "$bytes = [Convert]::FromBase64String($inputValue); $plain = [Security.Cryptography.ProtectedData]::Unprotect($bytes,$null,[Security.Cryptography.DataProtectionScope]::CurrentUser); [Console]::Out.Write([Text.Encoding]::UTF8.GetString($plain));"
10
+ : "$bytes = [Text.Encoding]::UTF8.GetBytes($inputValue); $protected = [Security.Cryptography.ProtectedData]::Protect($bytes,$null,[Security.Cryptography.DataProtectionScope]::CurrentUser); [Console]::Out.Write([Convert]::ToBase64String($protected));",
11
+ ].join("\n");
12
+ return new Promise((resolve, reject) => {
13
+ const child = spawn(join(process.env.SystemRoot ?? "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe"), ["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")], { windowsHide: true, stdio: ["pipe", "pipe", "ignore"] });
14
+ let output = "";
15
+ const timer = setTimeout(() => child.kill(), 10_000);
16
+ child.stdout.on("data", chunk => { output += chunk.toString(); });
17
+ child.stdin.on("error", () => { });
18
+ child.once("error", () => { clearTimeout(timer); reject(new Error("Could not access the protected desktop pairing.")); });
19
+ child.once("close", code => { clearTimeout(timer); code === 0 ? resolve(output) : reject(new Error("Could not access the protected desktop pairing. Pair again on this computer.")); });
20
+ // Plaintext never appears in argv, environment values or diagnostics.
21
+ child.stdin.end(value);
22
+ });
23
+ }
24
+ export async function protectPairing(value) {
25
+ return process.platform === "win32" ? { protection: "dpapi", value: await dpapi(value, false) }
26
+ : { protection: "user-file", value };
27
+ }
28
+ export async function unprotectPairing(row) {
29
+ if (row.protection === "dpapi" && process.platform === "win32")
30
+ return dpapi(row.value, true);
31
+ if (row.protection === "user-file" && process.platform !== "win32")
32
+ return row.value;
33
+ throw new Error("This pairing cannot be used on this computer. Run pair again.");
34
+ }
@@ -0,0 +1,14 @@
1
+ export declare function relayOrigin(value: string): string;
2
+ export declare function readPairing(path: string, origin: string): Promise<string | null>;
3
+ export declare function pairRelay(options: {
4
+ origin: string;
5
+ name: string;
6
+ path: string;
7
+ write: (message: string) => void;
8
+ fetchImpl?: typeof fetch;
9
+ wait?: (ms: number) => Promise<void>;
10
+ now?: () => number;
11
+ signal?: AbortSignal;
12
+ }): Promise<string>;
13
+ /** Relay is also the first-run setup command; approval remains in the browser. */
14
+ export declare function ensureRelayPairing(options: Parameters<typeof pairRelay>[0]): Promise<string>;
@@ -0,0 +1,87 @@
1
+ import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+ import { protectPairing, unprotectPairing } from "./pairing-secret.js";
4
+ export function relayOrigin(value) {
5
+ const url = new URL(value);
6
+ if (url.username || url.password || url.pathname !== "/" || url.search || url.hash ||
7
+ (url.protocol !== "https:" && !(url.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname)))) {
8
+ throw new Error("Use an HTTPS server origin, or HTTP on loopback for local testing.");
9
+ }
10
+ return url.origin;
11
+ }
12
+ export async function readPairing(path, origin) {
13
+ try {
14
+ const row = JSON.parse(await unprotectPairing(JSON.parse(await readFile(path, "utf8"))));
15
+ if (row.origin !== relayOrigin(origin) || !Number.isFinite(Date.parse(row.expiresAt)) || Date.parse(row.expiresAt) <= Date.now())
16
+ return null;
17
+ if (typeof row.credential !== "string" || !row.credential.startsWith("cbr_"))
18
+ throw new Error("Invalid local pairing. Run pair again.");
19
+ return row.credential;
20
+ }
21
+ catch (error) {
22
+ if (error.code === "ENOENT")
23
+ return null;
24
+ throw error;
25
+ }
26
+ }
27
+ export async function pairRelay(options) {
28
+ const origin = relayOrigin(options.origin), fetcher = options.fetchImpl ?? fetch;
29
+ const now = options.now ?? Date.now;
30
+ const wait = options.wait ?? (ms => new Promise((resolve, reject) => {
31
+ const cleanup = () => { clearTimeout(timer); options.signal?.removeEventListener("abort", abort); };
32
+ const done = () => { cleanup(); resolve(); };
33
+ const abort = () => { cleanup(); reject(options.signal?.reason); };
34
+ const timer = setTimeout(done, ms);
35
+ options.signal?.addEventListener("abort", abort, { once: true });
36
+ if (options.signal?.aborted)
37
+ abort();
38
+ }));
39
+ const post = (path, body) => fetcher(`${origin}${path}`, { method: "POST", redirect: "error",
40
+ headers: { "content-type": "application/json" }, body: JSON.stringify(body), signal: options.signal ? AbortSignal.any([options.signal, AbortSignal.timeout(20_000)]) : AbortSignal.timeout(20_000) });
41
+ const response = await post("/api/watch-relay/v1/bridge-authorizations", { bridgeName: options.name });
42
+ if (!response.ok)
43
+ throw new Error(response.status === 404 ? "Watch voice is not enabled on this server yet." : "Could not start desktop pairing. Try again later.");
44
+ const authorization = await response.json();
45
+ if (!authorization.device_code || !authorization.user_code || !Number.isFinite(authorization.expires_in) || !Number.isFinite(authorization.interval))
46
+ throw new Error("Invalid pairing response.");
47
+ const activation = new URL("/watch/activate", origin);
48
+ activation.searchParams.set("bridge", "1");
49
+ activation.searchParams.set("code", authorization.user_code);
50
+ options.write(`Open ${activation.href}\nSign in to your Cozibud household and approve ${options.name}.`);
51
+ const deadline = now() + authorization.expires_in * 1000;
52
+ let interval = Math.max(5, authorization.interval);
53
+ while (now() < deadline) {
54
+ await wait(interval * 1000);
55
+ options.signal?.throwIfAborted();
56
+ if (now() >= deadline)
57
+ break;
58
+ const exchange = await post("/api/watch-relay/v1/token", { deviceCode: authorization.device_code });
59
+ const result = await exchange.json();
60
+ if (exchange.ok && result.credential?.startsWith("cbr_") && Number.isFinite(result.expiresIn)) {
61
+ await mkdir(dirname(options.path), { recursive: true, mode: 0o700 });
62
+ const protectedPairing = await protectPairing(JSON.stringify({ origin, credential: result.credential, bridgeId: result.bridgeId,
63
+ expiresAt: new Date(now() + result.expiresIn * 1000).toISOString() }));
64
+ await writeFile(options.path, JSON.stringify(protectedPairing), { mode: 0o600 });
65
+ if (process.platform !== "win32")
66
+ await chmod(options.path, 0o600);
67
+ options.write("Desktop paired.");
68
+ return result.credential;
69
+ }
70
+ if (result.error?.code === "authorization_pending")
71
+ continue;
72
+ if (result.error?.code === "slow_down" || exchange.status === 429) {
73
+ interval += 5;
74
+ continue;
75
+ }
76
+ throw new Error("Pairing was denied or expired. Run pair again.");
77
+ }
78
+ throw new Error("Pairing expired. Run pair again.");
79
+ }
80
+ /** Relay is also the first-run setup command; approval remains in the browser. */
81
+ export async function ensureRelayPairing(options) {
82
+ const saved = await readPairing(options.path, options.origin);
83
+ if (saved)
84
+ return saved;
85
+ options.write("This computer needs pairing. Approve it in Cozibud; the relay will start automatically.");
86
+ return pairRelay(options);
87
+ }
@@ -1,4 +1,4 @@
1
- import type { HarnessDriver } from "./contracts.js";
1
+ import type { HarnessDriver, HarnessTurnInput } from "./contracts.js";
2
2
  declare const TEMP_DIRECTORY_CLEANUP_OPTIONS: {
3
3
  readonly recursive: true;
4
4
  readonly force: true;
@@ -13,7 +13,7 @@ export interface ProcessHarnessDefinition {
13
13
  versionArgs?: string[];
14
14
  /** Applied only to a fresh empty directory created by this driver. */
15
15
  emptyDirectoryArgs?: string[];
16
- args(): string[];
16
+ args(input?: HarnessTurnInput): string[];
17
17
  }
18
18
  declare function messageText(value: unknown): string | null;
19
19
  declare function appendNovelText(current: string, candidate: string): {
@@ -4,9 +4,37 @@ import { existsSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { delimiter, isAbsolute, join } from "node:path";
6
6
  import { StringDecoder } from "node:string_decoder";
7
+ import { ensureCursorWorkspace } from "./cursor-workspace.js";
7
8
  const DEFAULT_TIMEOUT_MS = 120_000;
8
9
  const MAX_OUTPUT_BYTES = 1024 * 1024;
9
10
  const TEMP_DIRECTORY_CLEANUP_OPTIONS = { recursive: true, force: true, maxRetries: 5, retryDelay: 100 };
11
+ let cursorRunTail = Promise.resolve();
12
+ async function acquireCursorRun(signal, label) {
13
+ const predecessor = cursorRunTail.catch(() => undefined);
14
+ let release;
15
+ const held = new Promise((resolve) => { release = resolve; });
16
+ cursorRunTail = predecessor.then(() => held);
17
+ if (signal.aborted) {
18
+ void predecessor.then(release);
19
+ throw new Error(`${label} was cancelled.`);
20
+ }
21
+ let cancelWait;
22
+ const cancelled = new Promise((_, reject) => {
23
+ cancelWait = () => reject(new Error(`${label} was cancelled.`));
24
+ signal.addEventListener("abort", cancelWait, { once: true });
25
+ });
26
+ try {
27
+ await Promise.race([predecessor, cancelled]);
28
+ }
29
+ catch (error) {
30
+ void predecessor.then(release);
31
+ throw error;
32
+ }
33
+ finally {
34
+ signal.removeEventListener("abort", cancelWait);
35
+ }
36
+ return release;
37
+ }
10
38
  function stopProcess(child) {
11
39
  if (!child.pid || child.exitCode !== null)
12
40
  return;
@@ -24,6 +52,8 @@ function stopProcess(child) {
24
52
  }
25
53
  }
26
54
  function carePrompt(input) {
55
+ if (input.interpretation)
56
+ return `${input.interpretation}\n<caregiver_request>\n${input.prompt}\n</caregiver_request>`;
27
57
  const origin = `${input.client} ${input.source}`;
28
58
  return [
29
59
  "You are handling a Cozibud family-care request.",
@@ -31,6 +61,10 @@ function carePrompt(input) {
31
61
  "Do not inspect or modify local files and do not run shell commands.",
32
62
  "Ask a short follow-up only when ambiguity would make a care record incorrect.",
33
63
  "Otherwise return a concise, spoken-friendly answer and confirm writes only after the tool succeeds.",
64
+ ...(input.careRecordIdempotencyKey ? [
65
+ `For every Cozibud care-record write in this turn, pass clientEventId exactly ${input.careRecordIdempotencyKey}.`,
66
+ "Do not claim that a care record was saved unless the Cozibud tool succeeds with that identity.",
67
+ ] : []),
34
68
  `Input origin: ${origin}. Locale: ${input.locale ?? "unspecified"}.`,
35
69
  "<caregiver_request>",
36
70
  input.prompt,
@@ -88,8 +122,9 @@ export function createProcessHarnessDriver(definition, options = {}) {
88
122
  command: definition.command,
89
123
  authentication: "existing-login",
90
124
  async probe() {
125
+ const probeCwd = definition.id === "cursor" ? ensureCursorWorkspace(options.cwd) : options.cwd;
91
126
  return await new Promise((resolve) => {
92
- const child = spawn(definition.command, definition.versionArgs ?? ["--version"], { ...spawnOptions, stdio: ["ignore", "pipe", "pipe"] });
127
+ const child = spawn(definition.command, definition.versionArgs ?? ["--version"], { ...spawnOptions, cwd: probeCwd, stdio: ["ignore", "pipe", "pipe"] });
93
128
  let output = "";
94
129
  let bytes = 0;
95
130
  let overflow = false;
@@ -124,130 +159,139 @@ export function createProcessHarnessDriver(definition, options = {}) {
124
159
  async run(input, context) {
125
160
  if (context.signal.aborted)
126
161
  throw new Error(`${definition.label} was cancelled.`);
127
- const ephemeralCwd = options.cwd ? null : await mkdtemp(join(tmpdir(), "cozibud-harness-"));
128
- let turnError;
162
+ const releaseCursor = definition.id === "cursor" ? await acquireCursorRun(context.signal, definition.label) : undefined;
129
163
  try {
130
- if (context.signal.aborted)
131
- throw new Error(`${definition.label} was cancelled.`);
132
- return await new Promise((resolve, reject) => {
133
- const args = [...definition.args(), ...(ephemeralCwd ? definition.emptyDirectoryArgs ?? [] : [])];
134
- const child = spawn(definition.command, args, { ...spawnOptions, cwd: options.cwd ?? ephemeralCwd, stdio: ["pipe", "pipe", "pipe"] });
135
- child.stdin.on("error", () => { });
136
- child.stdin.end(carePrompt(input));
137
- const decoder = new StringDecoder("utf8");
138
- let pending = "";
139
- let response = "";
140
- let outputBytes = 0;
141
- let settled = false;
142
- let timedOut = false;
143
- let failure;
144
- let stopping = false;
145
- const stop = () => { if (!stopping) {
146
- stopping = true;
147
- stopProcess(child);
148
- } };
149
- const finish = (error) => {
150
- if (settled)
151
- return;
152
- settled = true;
153
- clearTimeout(timer);
154
- context.signal.removeEventListener("abort", abort);
155
- if (error)
156
- reject(error);
157
- else
158
- resolve({ text: response.trim() });
159
- };
160
- const abort = () => stop();
161
- context.signal.addEventListener("abort", abort, { once: true });
162
- const timer = setTimeout(() => { timedOut = true; stop(); }, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
163
- const lineReceived = (line) => {
164
- if (failure || context.signal.aborted || timedOut)
165
- return;
166
- try {
167
- const event = JSON.parse(line);
168
- if (event?.type === "error" && event.severity === "warning")
164
+ const stableCwd = definition.id === "cursor" ? ensureCursorWorkspace(options.cwd) : options.cwd;
165
+ const ephemeralCwd = stableCwd ? null : await mkdtemp(join(tmpdir(), "cozibud-harness-"));
166
+ let turnError;
167
+ try {
168
+ if (context.signal.aborted)
169
+ throw new Error(`${definition.label} was cancelled.`);
170
+ return await new Promise((resolve, reject) => {
171
+ const args = [...definition.args(input), ...(ephemeralCwd ? definition.emptyDirectoryArgs ?? [] : [])];
172
+ const child = spawn(definition.command, args, { ...spawnOptions, cwd: stableCwd ?? ephemeralCwd, stdio: ["pipe", "pipe", "pipe"] });
173
+ child.stdin.on("error", () => { });
174
+ child.stdin.end(carePrompt(input));
175
+ const decoder = new StringDecoder("utf8");
176
+ let pending = "";
177
+ let response = "";
178
+ let outputBytes = 0;
179
+ let settled = false;
180
+ let timedOut = false;
181
+ let failure;
182
+ let stopping = false;
183
+ const stop = () => { if (!stopping) {
184
+ stopping = true;
185
+ stopProcess(child);
186
+ } };
187
+ const finish = (error) => {
188
+ if (settled)
169
189
  return;
170
- if (event && (event.is_error === true || event.type === "error" || event.type === "turn.failed" || event.error)) {
171
- failure = new Error(`${definition.label} reported a failed turn. Check the client's sign-in, subscription, and Cozibud MCP authorization, then retry.`);
172
- stop();
190
+ settled = true;
191
+ clearTimeout(timer);
192
+ context.signal.removeEventListener("abort", abort);
193
+ if (error)
194
+ reject(error);
195
+ else
196
+ resolve({ text: response.trim() });
197
+ };
198
+ const abort = () => stop();
199
+ context.signal.addEventListener("abort", abort, { once: true });
200
+ const timer = setTimeout(() => { timedOut = true; stop(); }, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
201
+ const lineReceived = (line) => {
202
+ if (failure || context.signal.aborted || timedOut)
173
203
  return;
204
+ try {
205
+ const event = JSON.parse(line);
206
+ if (event?.type === "error" && event.severity === "warning")
207
+ return;
208
+ if (event && (event.is_error === true || event.type === "error" || event.type === "turn.failed" || event.error)) {
209
+ failure = new Error(`${definition.label} reported a failed turn. Check the client's sign-in, subscription, and Cozibud MCP authorization, then retry.`);
210
+ stop();
211
+ return;
212
+ }
213
+ const candidate = messageText(event);
214
+ if (!candidate)
215
+ return;
216
+ const next = event.type === "message" && event.delta === true
217
+ ? { value: response + candidate, delta: candidate }
218
+ : appendNovelText(response, candidate);
219
+ response = next.value;
220
+ if (next.delta)
221
+ context.emit({ type: "assistant.delta", text: next.delta });
222
+ }
223
+ catch {
224
+ // Never return provider diagnostics as household answers.
225
+ }
226
+ };
227
+ const count = (chunk) => {
228
+ outputBytes += chunk.length;
229
+ if (outputBytes <= MAX_OUTPUT_BYTES && !failure)
230
+ return true;
231
+ if (!failure) {
232
+ failure = new Error("Harness output exceeded the 1 MiB safety limit.");
233
+ pending = "";
234
+ stop();
174
235
  }
175
- const candidate = messageText(event);
176
- if (!candidate)
236
+ return false;
237
+ };
238
+ child.stderr.on("data", (chunk) => { count(chunk); });
239
+ child.stdout.on("data", (chunk) => {
240
+ if (!count(chunk))
177
241
  return;
178
- const next = event.type === "message" && event.delta === true
179
- ? { value: response + candidate, delta: candidate }
180
- : appendNovelText(response, candidate);
181
- response = next.value;
182
- if (next.delta)
183
- context.emit({ type: "assistant.delta", text: next.delta });
242
+ pending += decoder.write(chunk);
243
+ let newline;
244
+ while ((newline = pending.indexOf("\n")) >= 0) {
245
+ const line = pending.slice(0, newline);
246
+ pending = pending.slice(newline + 1);
247
+ lineReceived(line);
248
+ }
249
+ });
250
+ child.once("error", (error) => {
251
+ finish(new Error(error.code === "ENOENT" ? `${definition.command} is not installed or is not on PATH.` : `${definition.label} failed to start.`));
252
+ });
253
+ child.once("close", (code) => {
254
+ if (failure)
255
+ return finish(failure);
256
+ if (timedOut)
257
+ return finish(new Error(`${definition.label} timed out.`));
258
+ if (context.signal.aborted)
259
+ return finish(new Error(`${definition.label} was cancelled.`));
260
+ if (code !== 0)
261
+ return finish(new Error(`${definition.label} exited with code ${code ?? "unknown"}. Check the client's sign-in, subscription, and Cozibud MCP authorization.`));
262
+ lineReceived(pending + decoder.end());
263
+ if (failure)
264
+ return finish(failure);
265
+ if (!response.trim())
266
+ return finish(new Error(`${definition.label} returned no assistant response.`));
267
+ finish();
268
+ });
269
+ });
270
+ }
271
+ catch (error) {
272
+ turnError = error;
273
+ throw error;
274
+ }
275
+ finally {
276
+ if (definition.id === "cursor" && stableCwd)
277
+ ensureCursorWorkspace(stableCwd);
278
+ if (ephemeralCwd) {
279
+ try {
280
+ await (options.removeDirectory ?? rm)(ephemeralCwd, TEMP_DIRECTORY_CLEANUP_OPTIONS);
184
281
  }
185
282
  catch {
186
- // Never return provider diagnostics as household answers.
187
- }
188
- };
189
- const count = (chunk) => {
190
- outputBytes += chunk.length;
191
- if (outputBytes <= MAX_OUTPUT_BYTES && !failure)
192
- return true;
193
- if (!failure) {
194
- failure = new Error("Harness output exceeded the 1 MiB safety limit.");
195
- pending = "";
196
- stop();
197
- }
198
- return false;
199
- };
200
- child.stderr.on("data", (chunk) => { count(chunk); });
201
- child.stdout.on("data", (chunk) => {
202
- if (!count(chunk))
203
- return;
204
- pending += decoder.write(chunk);
205
- let newline;
206
- while ((newline = pending.indexOf("\n")) >= 0) {
207
- const line = pending.slice(0, newline);
208
- pending = pending.slice(newline + 1);
209
- lineReceived(line);
283
+ const cleanupWarning = "Temporary working-directory cleanup did not complete.";
284
+ if (turnError instanceof Error)
285
+ turnError.message = `${turnError.message} ${cleanupWarning}`;
286
+ else
287
+ throw new Error(`${definition.label} completed, but temporary working-directory cleanup did not complete.`);
210
288
  }
211
- });
212
- child.once("error", (error) => {
213
- finish(new Error(error.code === "ENOENT" ? `${definition.command} is not installed or is not on PATH.` : `${definition.label} failed to start.`));
214
- });
215
- child.once("close", (code) => {
216
- if (failure)
217
- return finish(failure);
218
- if (timedOut)
219
- return finish(new Error(`${definition.label} timed out.`));
220
- if (context.signal.aborted)
221
- return finish(new Error(`${definition.label} was cancelled.`));
222
- if (code !== 0)
223
- return finish(new Error(`${definition.label} exited with code ${code ?? "unknown"}. Check the client's sign-in, subscription, and Cozibud MCP authorization.`));
224
- lineReceived(pending + decoder.end());
225
- if (failure)
226
- return finish(failure);
227
- if (!response.trim())
228
- return finish(new Error(`${definition.label} returned no assistant response.`));
229
- finish();
230
- });
231
- });
232
- }
233
- catch (error) {
234
- turnError = error;
235
- throw error;
236
- }
237
- finally {
238
- if (ephemeralCwd) {
239
- try {
240
- await (options.removeDirectory ?? rm)(ephemeralCwd, TEMP_DIRECTORY_CLEANUP_OPTIONS);
241
- }
242
- catch {
243
- const cleanupWarning = "Temporary working-directory cleanup did not complete.";
244
- if (turnError instanceof Error)
245
- turnError.message = `${turnError.message} ${cleanupWarning}`;
246
- else
247
- throw new Error(`${definition.label} completed, but temporary working-directory cleanup did not complete.`);
248
289
  }
249
290
  }
250
291
  }
292
+ finally {
293
+ releaseCursor?.();
294
+ }
251
295
  },
252
296
  };
253
297
  }
@@ -284,7 +328,8 @@ export const builtInProcessHarnesses = [
284
328
  label: "Codex CLI",
285
329
  command: codexExecutable.command,
286
330
  versionArgs: [...codexExecutable.prefixArgs, "--version"],
287
- args: () => [...codexExecutable.prefixArgs, "exec", "--json", "--ephemeral", "--skip-git-repo-check", "--sandbox", "read-only", "-c", "mcp_servers.cozibud.required=true", "-"],
331
+ args: (input) => [...codexExecutable.prefixArgs, "exec", "--json", "--ephemeral", "--skip-git-repo-check", "--sandbox", "read-only",
332
+ ...(input?.interpretation ? ["--ignore-user-config", "--ignore-rules", "-c", "features.shell_tool=false", "-c", "features.apps=false", "-c", "features.multi_agent=false", "-c", "features.hooks=false", "-c", "web_search=\"disabled\""] : ["-c", "mcp_servers.cozibud.required=true"]), "-"],
288
333
  },
289
334
  {
290
335
  id: "claude",
@@ -305,9 +350,13 @@ export const builtInProcessHarnesses = [
305
350
  id: "cursor",
306
351
  label: "Cursor Agent",
307
352
  command: cursorExecutable.command,
308
- emptyDirectoryArgs: ["--trust"],
309
353
  versionArgs: [...cursorExecutable.prefixArgs, "--version"],
310
- args: () => [...cursorExecutable.prefixArgs, "-p", "--output-format", "stream-json"],
354
+ args: () => [
355
+ ...cursorExecutable.prefixArgs,
356
+ "-p", "--output-format", "stream-json", "--mode", "ask",
357
+ ...(process.platform === "win32" ? [] : ["--sandbox", "enabled"]),
358
+ "--trust",
359
+ ],
311
360
  },
312
361
  ];
313
362
  export function createBuiltInDrivers() {
@@ -0,0 +1,69 @@
1
+ import type { HarnessDriver } from "./contracts.js";
2
+ export type StoredExecution = {
3
+ state: "started";
4
+ updatedAt: string;
5
+ } | {
6
+ state: "completed";
7
+ modelOutput: string;
8
+ updatedAt: string;
9
+ } | {
10
+ state: "failed" | "cancelled";
11
+ code: string;
12
+ message: string;
13
+ updatedAt: string;
14
+ };
15
+ export interface RelayExecutionStore {
16
+ get(requestId: string): Promise<StoredExecution | null>;
17
+ set(requestId: string, value: StoredExecution): Promise<void>;
18
+ }
19
+ export declare class MemoryRelayExecutionStore implements RelayExecutionStore {
20
+ private readonly values;
21
+ get(id: string): Promise<StoredExecution | null>;
22
+ set(id: string, value: StoredExecution): Promise<void>;
23
+ }
24
+ /** Privacy-bounded local journal: request ids and terminal results only, never prompts or credentials. */
25
+ export declare class FileRelayExecutionStore implements RelayExecutionStore {
26
+ private readonly path;
27
+ private readonly maxEntries;
28
+ private readonly retentionMs;
29
+ private writeQueue;
30
+ constructor(path: string, maxEntries?: number, retentionMs?: number);
31
+ private read;
32
+ get(id: string): Promise<StoredExecution | null>;
33
+ set(id: string, value: StoredExecution): Promise<void>;
34
+ private write;
35
+ }
36
+ type Fetch = typeof fetch;
37
+ export declare class RelayTransportError extends Error {
38
+ readonly status: number;
39
+ readonly code: string;
40
+ constructor(status: number, code: string);
41
+ }
42
+ export declare class OutboundRelayBridge {
43
+ private readonly options;
44
+ readonly connectionId: `${string}-${string}-${string}-${string}-${string}`;
45
+ private readonly active;
46
+ private readonly tasks;
47
+ private readonly scheduled;
48
+ private readonly cancelled;
49
+ constructor(options: {
50
+ baseUrl: string;
51
+ credential: string;
52
+ driver: HarnessDriver;
53
+ store: RelayExecutionStore;
54
+ fetchImpl?: Fetch;
55
+ maxActive?: number;
56
+ });
57
+ private get fetcher();
58
+ private headers;
59
+ private json;
60
+ connect(): Promise<unknown>;
61
+ step(): Promise<"idle" | "command" | "cancel">;
62
+ idle(): Promise<void>;
63
+ close(): Promise<void>;
64
+ private acknowledge;
65
+ private result;
66
+ private fromStored;
67
+ private handle;
68
+ }
69
+ export {};
@@ -0,0 +1,166 @@
1
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ import { WATCH_RELAY_PROTOCOL_VERSION } from "./relay-protocol.js";
5
+ export class MemoryRelayExecutionStore {
6
+ values = new Map();
7
+ async get(id) { return this.values.get(id) ?? null; }
8
+ async set(id, value) { this.values.set(id, value); }
9
+ }
10
+ /** Privacy-bounded local journal: request ids and terminal results only, never prompts or credentials. */
11
+ export class FileRelayExecutionStore {
12
+ path;
13
+ maxEntries;
14
+ retentionMs;
15
+ writeQueue = Promise.resolve();
16
+ constructor(path, maxEntries = 100, retentionMs = 24 * 60 * 60_000) {
17
+ this.path = path;
18
+ this.maxEntries = maxEntries;
19
+ this.retentionMs = retentionMs;
20
+ }
21
+ async read() {
22
+ try {
23
+ return JSON.parse(await readFile(this.path, "utf8"));
24
+ }
25
+ catch (error) {
26
+ if (error.code === "ENOENT")
27
+ return {};
28
+ throw error;
29
+ }
30
+ }
31
+ async get(id) { await this.writeQueue; return (await this.read())[id] ?? null; }
32
+ async set(id, value) {
33
+ const write = this.writeQueue.then(() => this.write(id, value));
34
+ this.writeQueue = write.catch(() => { });
35
+ await write;
36
+ }
37
+ async write(id, value) {
38
+ const values = await this.read();
39
+ values[id] = value;
40
+ const cutoff = Date.now() - this.retentionMs;
41
+ const retained = Object.entries(values).filter(([, row]) => Date.parse(row.updatedAt) >= cutoff)
42
+ .sort((a, b) => Date.parse(b[1].updatedAt) - Date.parse(a[1].updatedAt)).slice(0, this.maxEntries);
43
+ await mkdir(dirname(this.path), { recursive: true });
44
+ const temporary = `${this.path}.${process.pid}.tmp`;
45
+ await writeFile(temporary, JSON.stringify(Object.fromEntries(retained)), { encoding: "utf8", mode: 0o600 });
46
+ await rename(temporary, this.path);
47
+ }
48
+ }
49
+ export class RelayTransportError extends Error {
50
+ status;
51
+ code;
52
+ constructor(status, code) {
53
+ super("Cozibud relay request failed.");
54
+ this.status = status;
55
+ this.code = code;
56
+ }
57
+ }
58
+ export class OutboundRelayBridge {
59
+ options;
60
+ connectionId = randomUUID();
61
+ active = new Map();
62
+ tasks = new Set();
63
+ scheduled = new Set();
64
+ cancelled = new Set();
65
+ constructor(options) {
66
+ this.options = options;
67
+ }
68
+ get fetcher() { return this.options.fetchImpl ?? fetch; }
69
+ headers(extra) { return { authorization: `Bearer ${this.options.credential}`, ...extra }; }
70
+ async json(path, init) {
71
+ const response = await this.fetcher(new URL(path, this.options.baseUrl), { ...init, redirect: "error", signal: AbortSignal.timeout(20_000) });
72
+ if (!response.ok) {
73
+ const code = await response.json().then(value => value.error?.code).catch(() => undefined);
74
+ throw new RelayTransportError(response.status, code ?? `relay_http_${response.status}`);
75
+ }
76
+ return response.status === 204 ? null : response.json();
77
+ }
78
+ async connect() {
79
+ return this.json("/api/watch-relay/v1/connect", { method: "POST", headers: this.headers({ "content-type": "application/json" }),
80
+ body: JSON.stringify({ connectionId: this.connectionId, protocolVersion: WATCH_RELAY_PROTOCOL_VERSION }) });
81
+ }
82
+ async step() {
83
+ const full = this.scheduled.size >= (this.options.maxActive ?? 2);
84
+ const delivery = await this.json(`/api/watch-relay/v1/deliveries/next${full ? "?cancelOnly=1" : ""}`, { method: "GET",
85
+ headers: this.headers({ "x-cozibud-connection": this.connectionId }) });
86
+ if (!delivery)
87
+ return "idle";
88
+ if (delivery.type === "cancel") {
89
+ this.cancelled.add(delivery.requestId);
90
+ this.active.get(delivery.requestId)?.abort();
91
+ return "cancel";
92
+ }
93
+ if (this.scheduled.has(delivery.command.requestId)) {
94
+ await this.acknowledge(delivery.command);
95
+ return "command";
96
+ }
97
+ this.scheduled.add(delivery.command.requestId);
98
+ // A transport loss after the local terminal result is journaled is
99
+ // recovered by stable-id redelivery; do not turn it into an unhandled
100
+ // rejection or execute the provider again.
101
+ const task = this.handle(delivery.command).catch(() => { }).finally(() => { this.scheduled.delete(delivery.command.requestId); this.tasks.delete(task); });
102
+ this.tasks.add(task);
103
+ return "command";
104
+ }
105
+ async idle() { await Promise.all(this.tasks); }
106
+ async close() { for (const controller of this.active.values())
107
+ controller.abort(); await this.idle(); }
108
+ async acknowledge(command) {
109
+ await this.json(`/api/watch-relay/v1/deliveries/${command.deliveryId}/ack`, { method: "POST", headers: this.headers({ "content-type": "application/json" }),
110
+ body: JSON.stringify({ requestId: command.requestId, connectionId: this.connectionId }) });
111
+ }
112
+ async result(command, result) {
113
+ await this.json(`/api/watch-relay/v1/deliveries/${command.deliveryId}/result`, { method: "POST", headers: this.headers({ "content-type": "application/json" }),
114
+ body: JSON.stringify({ requestId: command.requestId, connectionId: this.connectionId, ...result }) });
115
+ }
116
+ fromStored(value) {
117
+ if (value.state === "completed")
118
+ return { state: "completed", modelOutput: value.modelOutput };
119
+ if (value.state === "started")
120
+ return { state: "failed", error: { code: "execution_interrupted", message: "The prior local execution ended without a durable result; it was not repeated." } };
121
+ return { state: value.state, error: { code: value.code, message: value.message } };
122
+ }
123
+ async handle(command) {
124
+ if (this.active.has(command.requestId)) {
125
+ await this.acknowledge(command);
126
+ return;
127
+ }
128
+ const stored = await this.options.store.get(command.requestId);
129
+ if (stored) {
130
+ await this.acknowledge(command);
131
+ await this.result(command, this.fromStored(stored));
132
+ return;
133
+ }
134
+ await this.acknowledge(command);
135
+ await this.options.store.set(command.requestId, { state: "started", updatedAt: new Date().toISOString() });
136
+ const controller = new AbortController();
137
+ this.active.set(command.requestId, controller);
138
+ if (this.cancelled.delete(command.requestId))
139
+ controller.abort();
140
+ let terminal;
141
+ if (controller.signal.aborted) {
142
+ terminal = { state: "cancelled", code: "cancelled", message: "The request was cancelled.", updatedAt: new Date().toISOString() };
143
+ }
144
+ else
145
+ try {
146
+ const prompt = command.transcript ?? JSON.stringify(command.action);
147
+ const source = command.source === "structured-action" ? "quick-action" : command.source;
148
+ const output = await this.options.driver.run({ prompt, source, client: command.client,
149
+ interpretation: command.interpretation,
150
+ careRecordIdempotencyKey: `relay:${command.requestId}` }, {
151
+ turnId: command.requestId, signal: controller.signal,
152
+ // Progress remains local in v1; bounded server progress is reserved by the protocol.
153
+ emit: () => { },
154
+ });
155
+ terminal = { state: "completed", modelOutput: output.text, updatedAt: new Date().toISOString() };
156
+ }
157
+ catch (error) {
158
+ const cancelled = controller.signal.aborted;
159
+ terminal = { state: cancelled ? "cancelled" : "failed", code: cancelled ? "cancelled" : "provider_failed",
160
+ message: cancelled ? "The request was cancelled." : (error instanceof Error ? error.message.slice(0, 512) : "The local provider failed."), updatedAt: new Date().toISOString() };
161
+ }
162
+ this.active.delete(command.requestId);
163
+ await this.options.store.set(command.requestId, terminal);
164
+ await this.result(command, this.fromStored(terminal));
165
+ }
166
+ }
@@ -0,0 +1,18 @@
1
+ /** Versioned wire values consumed by the outbound Cozibud relay client. */
2
+ export declare const WATCH_RELAY_PROTOCOL: "cozibud.watch-relay.v2";
3
+ export declare const WATCH_RELAY_PROTOCOL_VERSION: 2;
4
+ export type WatchRelayCommand = Readonly<{
5
+ protocol: typeof WATCH_RELAY_PROTOCOL;
6
+ requestId: string;
7
+ deliveryId: string;
8
+ attempt: number;
9
+ source: "typed" | "voice-transcript" | "structured-action";
10
+ client: "web" | "phone" | "watch";
11
+ transcript?: string;
12
+ action?: Readonly<{
13
+ id: string;
14
+ arguments: Readonly<Record<string, unknown>>;
15
+ }>;
16
+ expiresAt: string;
17
+ interpretation?: string;
18
+ }>;
@@ -0,0 +1,3 @@
1
+ /** Versioned wire values consumed by the outbound Cozibud relay client. */
2
+ export const WATCH_RELAY_PROTOCOL = "cozibud.watch-relay.v2";
3
+ export const WATCH_RELAY_PROTOCOL_VERSION = 2;
package/dist/setup.d.ts CHANGED
@@ -7,6 +7,7 @@ export type SetupCommandResult = {
7
7
  };
8
8
  export type SetupCommandRunner = (command: string, args: string[], options: {
9
9
  interactive: boolean;
10
+ cwd?: string;
10
11
  }) => Promise<SetupCommandResult>;
11
12
  export type SetupWriter = (message: string) => void;
12
13
  export declare const runSetupCommand: SetupCommandRunner;
@@ -23,6 +24,7 @@ export declare function setupProvider(options?: {
23
24
  run?: SetupCommandRunner;
24
25
  write?: SetupWriter;
25
26
  cursorConfig?: CursorMcpConfigStore;
27
+ cursorWorkspace?: string;
26
28
  }): Promise<void>;
27
29
  export declare function setupCodex(options?: {
28
30
  command?: string;
package/dist/setup.js CHANGED
@@ -3,11 +3,13 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
3
  import { homedir } from "node:os";
4
4
  import { dirname, join } from "node:path";
5
5
  import { executable } from "./process-driver.js";
6
+ import { ensureCursorWorkspace } from "./cursor-workspace.js";
6
7
  export const DEFAULT_COZIBUD_MCP_URL = "https://cozibud.com/api/mcp";
7
8
  const MAX_SETUP_OUTPUT_BYTES = 64 * 1024;
8
9
  export const runSetupCommand = async (command, args, options) => await new Promise((resolve) => {
9
10
  const resolved = executable(command);
10
11
  const child = spawn(resolved.command, [...resolved.prefixArgs, ...args], {
12
+ cwd: options.cwd,
11
13
  windowsHide: true,
12
14
  stdio: options.interactive ? "inherit" : ["ignore", "pipe", "pipe"],
13
15
  });
@@ -242,16 +244,22 @@ async function setupCursorProvider(options) {
242
244
  const executable = providerCommand("cursor", options.command);
243
245
  const args = (...values) => [...executable.prefixArgs, ...values];
244
246
  const missing = `Cursor Agent is not installed or is not on PATH. Install it from ${installUrl("cursor")} and run setup again.`;
247
+ const cwd = ensureCursorWorkspace(options.workspace);
248
+ const runInWorkspace = async (values, interactive) => {
249
+ const result = await options.run(executable.command, values, { interactive, cwd: ensureCursorWorkspace(cwd) });
250
+ ensureCursorWorkspace(cwd);
251
+ return result;
252
+ };
245
253
  options.write("Checking Cursor Agent…");
246
- requireSuccess(await options.run(executable.command, args("--version"), { interactive: false }), missing, "Cursor Agent was found but could not be started.");
247
- const login = await options.run(executable.command, args("status"), { interactive: false });
254
+ requireSuccess(await runInWorkspace(args("--version"), false), missing, "Cursor Agent was found but could not be started.");
255
+ const login = await runInWorkspace(args("status"), false);
248
256
  if (!succeeded(login)) {
249
257
  options.write("Opening Cursor sign-in in your browser…");
250
- requireSuccess(await options.run(executable.command, args("login"), { interactive: true }), missing, "Cursor sign-in did not complete.");
258
+ requireSuccess(await runInWorkspace(args("login"), true), missing, "Cursor sign-in did not complete.");
251
259
  }
252
260
  else
253
261
  options.write("Cursor sign-in is ready.");
254
- requireSuccess(await options.run(executable.command, args("status"), { interactive: false }), missing, "Cursor still reports that you are signed out.");
262
+ requireSuccess(await runInWorkspace(args("status"), false), missing, "Cursor still reports that you are signed out.");
255
263
  const currentConfig = await options.config.read();
256
264
  if (cursorConfiguredUrl(currentConfig) !== options.mcpUrl) {
257
265
  options.write("Adding the Cozibud connection to Cursor's user MCP configuration…");
@@ -260,8 +268,8 @@ async function setupCursorProvider(options) {
260
268
  else
261
269
  options.write("The Cozibud connection is configured with the expected endpoint.");
262
270
  options.write("Opening Cozibud sign-in and permission approval in your browser…");
263
- requireSuccess(await options.run(executable.command, args("mcp", "login", "cozibud"), { interactive: true }), missing, "Cozibud sign-in or permission approval did not complete.");
264
- const verified = await options.run(executable.command, args("mcp", "list"), { interactive: false });
271
+ requireSuccess(await runInWorkspace(args("mcp", "login", "cozibud"), true), missing, "Cozibud sign-in or permission approval did not complete.");
272
+ const verified = await runInWorkspace(args("mcp", "list"), false);
265
273
  requireSuccess(verified, missing, "Cursor could not verify the Cozibud MCP connection after sign-in.");
266
274
  if (!cursorReportsConnected(verified.output))
267
275
  throw new Error("Cursor did not report a ready Cozibud MCP connection after sign-in.");
@@ -277,7 +285,7 @@ export async function setupProvider(options = {}) {
277
285
  if (provider === "codex")
278
286
  await setupCodexProvider({ command: options.command, mcpUrl, run, write });
279
287
  else if (provider === "cursor")
280
- await setupCursorProvider({ command: options.command, mcpUrl, run, write, config: options.cursorConfig ?? cursorConfigStore() });
288
+ await setupCursorProvider({ command: options.command, mcpUrl, run, write, config: options.cursorConfig ?? cursorConfigStore(), workspace: options.cursorWorkspace });
281
289
  else
282
290
  await setupInteractiveProvider(provider, { command: options.command, mcpUrl, run, write });
283
291
  }
package/package.json CHANGED
@@ -1,43 +1,43 @@
1
- {
2
- "name": "@cozibud/harness",
3
- "version": "0.1.0",
4
- "description": "Cozibud local-client bridge and CLI.",
5
- "type": "module",
6
- "license": "Apache-2.0",
7
- "repository": {
8
- "type": "git",
9
- "url": "git+https://github.com/rongduan-zhu/cozibud.git",
10
- "directory": "packages/harness"
11
- },
12
- "homepage": "https://github.com/rongduan-zhu/cozibud/tree/main/packages/harness#readme",
13
- "bugs": {
14
- "url": "https://github.com/rongduan-zhu/cozibud/issues"
15
- },
16
- "files": [
17
- "dist"
18
- ],
19
- "publishConfig": {
20
- "access": "public"
21
- },
22
- "engines": {
23
- "node": ">=22"
24
- },
25
- "main": "./dist/index.js",
26
- "types": "./dist/index.d.ts",
27
- "bin": {
28
- "cozibud-harness": "dist/cli.js"
29
- },
30
- "exports": {
31
- ".": {
32
- "types": "./dist/index.d.ts",
33
- "default": "./dist/index.js"
34
- }
35
- },
36
- "scripts": {
37
- "clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
38
- "build": "npm run clean && tsc -p tsconfig.json",
39
- "prepack": "npm run build",
40
- "typecheck": "tsc -p tsconfig.json --noEmit",
41
- "test": "vitest run --root ../.. packages/harness/src"
42
- }
43
- }
1
+ {
2
+ "name": "@cozibud/harness",
3
+ "version": "0.2.0",
4
+ "description": "Cozibud local-client bridge and CLI.",
5
+ "type": "module",
6
+ "license": "Apache-2.0",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/rongduan-zhu/cozibud.git",
10
+ "directory": "packages/harness"
11
+ },
12
+ "homepage": "https://github.com/rongduan-zhu/cozibud/tree/main/packages/harness#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/rongduan-zhu/cozibud/issues"
15
+ },
16
+ "files": [
17
+ "dist"
18
+ ],
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "engines": {
23
+ "node": ">=22"
24
+ },
25
+ "main": "./dist/index.js",
26
+ "types": "./dist/index.d.ts",
27
+ "bin": {
28
+ "cozibud-harness": "dist/cli.js"
29
+ },
30
+ "exports": {
31
+ ".": {
32
+ "types": "./dist/index.d.ts",
33
+ "default": "./dist/index.js"
34
+ }
35
+ },
36
+ "scripts": {
37
+ "clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
38
+ "build": "npm run clean && tsc -p tsconfig.json",
39
+ "prepack": "npm run build",
40
+ "typecheck": "tsc -p tsconfig.json --noEmit",
41
+ "test": "vitest run --root ../.. packages/harness/src"
42
+ }
43
+ }