@cozibud/harness 0.1.0 → 0.2.1

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.
@@ -1,12 +1,40 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { mkdtemp, rm } from "node:fs/promises";
3
- import { existsSync } from "node:fs";
3
+ import { existsSync, readFileSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
- import { delimiter, isAbsolute, join } from "node:path";
5
+ import { delimiter, dirname, isAbsolute, join, resolve } 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.
174
225
  }
175
- const candidate = messageText(event);
176
- if (!candidate)
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();
235
+ }
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
  }
@@ -263,17 +307,46 @@ export function executable(configured) {
263
307
  for (const directory of directories) {
264
308
  const base = directory ? join(directory, configured) : configured;
265
309
  const script = /\.ps1$/i.test(base) ? base : `${base}.ps1`;
266
- if (existsSync(script))
267
- return {
268
- command: join(process.env.SystemRoot ?? "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe"),
269
- prefixArgs: ["-NoLogo", "-NoProfile", "-File", script],
270
- };
310
+ if (!existsSync(script))
311
+ continue;
312
+ const shimmed = nodeShimTarget(script);
313
+ if (shimmed)
314
+ return shimmed;
315
+ return {
316
+ command: join(process.env.SystemRoot ?? "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe"),
317
+ prefixArgs: ["-NoLogo", "-NoProfile", "-File", script],
318
+ };
271
319
  }
272
320
  }
273
321
  return /\.(?:c|m)?js$/i.test(configured)
274
322
  ? { command: process.execPath, prefixArgs: [configured] }
275
323
  : { command: configured, prefixArgs: [] };
276
324
  }
325
+ /**
326
+ * npm, pnpm and Yarn install a CLI on Windows as a PowerShell shim that runs a JavaScript entry point
327
+ * with Node. Running that entry point directly keeps arguments intact: `powershell -File` treats a bare
328
+ * `-` (read the prompt from stdin) as an unnamed parameter and fails before the CLI starts.
329
+ */
330
+ function nodeShimTarget(script) {
331
+ let source;
332
+ try {
333
+ source = readFileSync(script, "utf8");
334
+ }
335
+ catch {
336
+ return null;
337
+ }
338
+ if (source.length > 16_384)
339
+ return null;
340
+ const target = /"\$basedir[\\/]([^"$`]+\.[cm]?js)"/i.exec(source)?.[1];
341
+ if (!target)
342
+ return null;
343
+ const basedir = dirname(script);
344
+ const entry = resolve(basedir, target);
345
+ if (!existsSync(entry))
346
+ return null;
347
+ const bundledNode = join(basedir, "node.exe");
348
+ return { command: existsSync(bundledNode) ? bundledNode : process.execPath, prefixArgs: [entry] };
349
+ }
277
350
  const codexExecutable = executable(process.env.COZIBUD_CODEX_PATH || "codex");
278
351
  const claudeExecutable = executable(process.env.COZIBUD_CLAUDE_PATH || "claude");
279
352
  const geminiExecutable = executable(process.env.COZIBUD_GEMINI_PATH || "gemini");
@@ -284,7 +357,8 @@ export const builtInProcessHarnesses = [
284
357
  label: "Codex CLI",
285
358
  command: codexExecutable.command,
286
359
  versionArgs: [...codexExecutable.prefixArgs, "--version"],
287
- args: () => [...codexExecutable.prefixArgs, "exec", "--json", "--ephemeral", "--skip-git-repo-check", "--sandbox", "read-only", "-c", "mcp_servers.cozibud.required=true", "-"],
360
+ args: (input) => [...codexExecutable.prefixArgs, "exec", "--json", "--ephemeral", "--skip-git-repo-check", "--sandbox", "read-only",
361
+ ...(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
362
  },
289
363
  {
290
364
  id: "claude",
@@ -305,9 +379,13 @@ export const builtInProcessHarnesses = [
305
379
  id: "cursor",
306
380
  label: "Cursor Agent",
307
381
  command: cursorExecutable.command,
308
- emptyDirectoryArgs: ["--trust"],
309
382
  versionArgs: [...cursorExecutable.prefixArgs, "--version"],
310
- args: () => [...cursorExecutable.prefixArgs, "-p", "--output-format", "stream-json"],
383
+ args: () => [
384
+ ...cursorExecutable.prefixArgs,
385
+ "-p", "--output-format", "stream-json", "--mode", "ask",
386
+ ...(process.platform === "win32" ? [] : ["--sandbox", "enabled"]),
387
+ "--trust",
388
+ ],
311
389
  },
312
390
  ];
313
391
  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;