@cozibud/harness 0.2.0 → 0.3.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,9 +1,32 @@
1
1
  # `@cozibud/harness`
2
2
 
3
- Connect a signed-in Codex CLI to your Cozibud household for watch voice.
3
+ Connect a signed-in Codex CLI to your Cozibud household for baby-care questions
4
+ and actions in the web and phone apps, and watch voice.
4
5
  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.
6
+ This harness requires Codex CLI 0.160.0 or newer. Watch voice needs a server supporting relay
7
+ protocol 2. Web and phone baby-care conversations require harness 0.3.0,
8
+ Codex CLI 0.160.0 or newer, and a server supporting relay protocol 3.
9
+ This source version still needs its package and server release.
10
+
11
+ ## Web and phone baby care
12
+
13
+ Enable Voice and text entry in Settings and choose Codex on your computer.
14
+ Ask about the selected baby's last sleep, follow up about its duration, record
15
+ care, manage timers, or ask for summaries, trends, routines and caregiver coverage.
16
+ The server uses the same baby-care MCP handlers and permissions as other clients.
17
+ Availability of medication and symptom recording follows the server's policy.
18
+ Switch the selected child before asking about another baby.
19
+
20
+ Corrections, deletions, restores, bulk changes and care-plan changes show the
21
+ exact proposal before execution. Choose Confirm change to apply it, or Cancel
22
+ to leave it unchanged. Approval expires after five minutes; a record changed
23
+ elsewhere requires a new proposal. A text message alone cannot approve a proposal.
24
+
25
+ The interpreter has no shell, filesystem, browser, image, memory, plugin or
26
+ external MCP access. It proposes operations to a closed server-side baby-care
27
+ allowlist. Mother tools, feedback and unrelated applications are excluded.
28
+ No separate Codex MCP connection is needed for this path. Your signed-in Codex
29
+ client receives the transcript and bounded baby-care context/results.
7
30
 
8
31
  ## Watch voice
9
32
 
@@ -28,6 +51,12 @@ child context go to your signed-in Codex client and its model service.
28
51
  Your Codex login stays local. Pairing credentials are protected with your
29
52
  Windows account's DPAPI on Windows, or a user-only file on other systems.
30
53
  Disconnect the computer in Cozibud's Watch settings to revoke its access.
54
+ Each Cozibud account can pair two computers. The one connected longest answers
55
+ requests from the web app, the phone app and the watch; the other is a backup
56
+ that takes over when the first stops reporting in. Run `pair` again on a computer
57
+ to replace its pairing, or choose the household and account by signing in to the
58
+ one you want when you approve the link. Codex uses whichever account `codex login`
59
+ signed in (set `CODEX_HOME` to keep a separate Codex profile).
31
60
  Use `COZIBUD_RELAY_URL` to choose another HTTPS server before pairing; pairings
32
61
  are scoped to that origin. The relay requires no inbound computer port.
33
62
  The separate local HTTP adapter must remain on loopback.
package/dist/cli.js CHANGED
@@ -7,6 +7,7 @@ import { ensureRelayPairing, pairRelay, relayOrigin } from "./pairing.js";
7
7
  import { createBuiltInDrivers } from "./process-driver.js";
8
8
  import { FileRelayExecutionStore, OutboundRelayBridge, RelayTransportError } from "./relay-client.js";
9
9
  import { HarnessRuntime } from "./runtime.js";
10
+ import { RelayPollDelay, relayPause as pause } from "./relay-polling.js";
10
11
  import { createHarnessHttpServer } from "./server.js";
11
12
  import { DEFAULT_COZIBUD_MCP_URL, setupProvider } from "./setup.js";
12
13
  function value(args, name, fallback) {
@@ -65,8 +66,8 @@ async function relay(args) {
65
66
  if (available.state === "unavailable")
66
67
  throw new Error(available.message);
67
68
  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.");
69
+ if (!version || (Number(version[1]) === 0 && Number(version[2]) < 160))
70
+ throw new Error("Baby-care relay requires Codex CLI 0.160.0 or newer. Update Codex, sign in, and retry.");
70
71
  const controller = new AbortController();
71
72
  process.once("SIGINT", () => controller.abort());
72
73
  process.once("SIGTERM", () => controller.abort());
@@ -75,25 +76,31 @@ async function relay(args) {
75
76
  const createBridge = () => {
76
77
  const namespace = createHash("sha256").update(`${baseUrl}\n${credential}`).digest("hex").slice(0, 20);
77
78
  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
+ return new OutboundRelayBridge({ baseUrl, credential, driver, store: new FileRelayExecutionStore(statePath), signal: controller.signal });
79
80
  };
80
81
  let bridge = createBridge();
81
82
  let repaired = false;
82
83
  let connected = false;
83
84
  let backoffMs = 1_000;
85
+ const pollDelay = new RelayPollDelay();
84
86
  while (!controller.signal.aborted) {
85
87
  try {
86
88
  if (!connected) {
87
89
  await bridge.connect();
88
90
  connected = true;
89
91
  backoffMs = 1_000;
92
+ pollDelay.reset();
90
93
  console.log(`Cozibud relay connected with ${driver.label}. No local or LAN listener is open.`);
91
94
  }
92
95
  const state = await bridge.step();
93
- if (state === "idle")
94
- await pause(1_000, controller.signal);
96
+ const waitMs = pollDelay.after(state, bridge.hasPendingExecution);
97
+ if (waitMs > 0)
98
+ await pause(waitMs, controller.signal);
95
99
  }
96
100
  catch (error) {
101
+ if (controller.signal.aborted)
102
+ break;
103
+ pollDelay.reset();
97
104
  if (error instanceof RelayTransportError && [401, 403, 404].includes(error.status)) {
98
105
  await bridge.close();
99
106
  if ([401, 403].includes(error.status) && !process.env.COZIBUD_RELAY_CREDENTIAL?.trim() && !repaired && !controller.signal.aborted) {
@@ -207,12 +214,3 @@ catch (error) {
207
214
  console.error(error instanceof Error ? error.message : "The local harness failed.");
208
215
  process.exitCode = 1;
209
216
  }
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
- }
package/dist/pairing.js CHANGED
@@ -73,6 +73,8 @@ export async function pairRelay(options) {
73
73
  interval += 5;
74
74
  continue;
75
75
  }
76
+ if (result.error?.code === "bridge_limit")
77
+ throw new Error("Your account already has two computers paired. Disconnect one in Cozibud under Settings → Connected devices → Computers, then run pair again.");
76
78
  throw new Error("Pairing was denied or expired. Run pair again.");
77
79
  }
78
80
  throw new Error("Pairing expired. Run pair again.");
@@ -1,8 +1,8 @@
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
7
  import { ensureCursorWorkspace } from "./cursor-workspace.js";
8
8
  const DEFAULT_TIMEOUT_MS = 120_000;
@@ -307,17 +307,46 @@ export function executable(configured) {
307
307
  for (const directory of directories) {
308
308
  const base = directory ? join(directory, configured) : configured;
309
309
  const script = /\.ps1$/i.test(base) ? base : `${base}.ps1`;
310
- if (existsSync(script))
311
- return {
312
- command: join(process.env.SystemRoot ?? "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe"),
313
- prefixArgs: ["-NoLogo", "-NoProfile", "-File", script],
314
- };
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
+ };
315
319
  }
316
320
  }
317
321
  return /\.(?:c|m)?js$/i.test(configured)
318
322
  ? { command: process.execPath, prefixArgs: [configured] }
319
323
  : { command: configured, prefixArgs: [] };
320
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
+ }
321
350
  const codexExecutable = executable(process.env.COZIBUD_CODEX_PATH || "codex");
322
351
  const claudeExecutable = executable(process.env.COZIBUD_CLAUDE_PATH || "claude");
323
352
  const geminiExecutable = executable(process.env.COZIBUD_GEMINI_PATH || "gemini");
@@ -329,7 +358,7 @@ export const builtInProcessHarnesses = [
329
358
  command: codexExecutable.command,
330
359
  versionArgs: [...codexExecutable.prefixArgs, "--version"],
331
360
  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"]), "-"],
361
+ ...(input?.interpretation ? ["--ignore-user-config", "--ignore-rules", "-c", "features.shell_tool=false", "-c", "features.unified_exec=false", "-c", "tools.view_image=false", "-c", "features.view_image=false", "-c", "features.image_generation=false", "-c", "features.memories=false", "-c", "features.plugins=false", "-c", "features.remote_plugin=false", "-c", "features.skill_mcp_dependency_install=false", "-c", "features.apps=false", "-c", "features.multi_agent=false", "-c", "features.hooks=false", "-c", "web_search=\"disabled\""] : ["-c", "mcp_servers.cozibud.required=true"]), "-"],
333
362
  },
334
363
  {
335
364
  id: "claude",
@@ -53,7 +53,9 @@ export declare class OutboundRelayBridge {
53
53
  store: RelayExecutionStore;
54
54
  fetchImpl?: Fetch;
55
55
  maxActive?: number;
56
+ signal?: AbortSignal;
56
57
  });
58
+ get hasPendingExecution(): boolean;
57
59
  private get fetcher();
58
60
  private headers;
59
61
  private json;
@@ -65,10 +65,11 @@ export class OutboundRelayBridge {
65
65
  constructor(options) {
66
66
  this.options = options;
67
67
  }
68
+ get hasPendingExecution() { return this.scheduled.size > 0; }
68
69
  get fetcher() { return this.options.fetchImpl ?? fetch; }
69
70
  headers(extra) { return { authorization: `Bearer ${this.options.credential}`, ...extra }; }
70
71
  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
+ const response = await this.fetcher(new URL(path, this.options.baseUrl), { ...init, redirect: "error", signal: this.options.signal ? AbortSignal.any([this.options.signal, AbortSignal.timeout(20_000)]) : AbortSignal.timeout(20_000) });
72
73
  if (!response.ok) {
73
74
  const code = await response.json().then(value => value.error?.code).catch(() => undefined);
74
75
  throw new RelayTransportError(response.status, code ?? `relay_http_${response.status}`);
@@ -145,13 +146,61 @@ export class OutboundRelayBridge {
145
146
  try {
146
147
  const prompt = command.transcript ?? JSON.stringify(command.action);
147
148
  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
- });
149
+ const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(Math.min(300_000, Math.max(1, Date.parse(command.expiresAt) - Date.now())))]);
150
+ const interpret = (interpretation = command.interpretation) => this.options.driver.run({ prompt, source, client: command.client,
151
+ interpretation, careRecordIdempotencyKey: `relay:${command.requestId}` }, { turnId: command.requestId, signal, emit: () => { } });
152
+ let output;
153
+ if (!command.careTools)
154
+ output = await interpret();
155
+ else {
156
+ if (!command.interpretation || this.options.driver.id !== "codex")
157
+ throw new Error("Baby-care interpretation requires the isolated Codex driver.");
158
+ const results = [];
159
+ let next = command.careTools.confirmed;
160
+ let callId = 0;
161
+ let answerReadback = "";
162
+ for (let round = 0;; round += 1) {
163
+ if (round > 12 || signal.aborted)
164
+ throw new Error("The care request reached its limit. Check the current records before trying again.");
165
+ if (!next) {
166
+ output = await interpret(`${command.interpretation}\n<care_tool_results>${JSON.stringify(results)}</care_tool_results>\n${answerReadback}\nReturn the next care tool request or a final answer as JSON.`);
167
+ let parsed;
168
+ try {
169
+ parsed = JSON.parse(output.text);
170
+ }
171
+ catch {
172
+ throw new Error("The care interpreter returned invalid JSON.");
173
+ }
174
+ if (!parsed || typeof parsed !== "object")
175
+ throw new Error("The care interpreter returned invalid JSON.");
176
+ const value = parsed;
177
+ if (value.operation !== "tool") {
178
+ if (!["answer", "clarify", "refuse"].includes(value.operation ?? "") || typeof value.text !== "string" || !value.text.trim())
179
+ throw new Error("The care interpreter returned an invalid answer.");
180
+ if (value.operation === "answer" && callId === 0) {
181
+ answerReadback = "Your answer has no fresh server readback for this message. Return a care tool request to check the referenced record now; prior conversation results alone cannot support a final answer.";
182
+ continue;
183
+ }
184
+ break;
185
+ }
186
+ if (typeof value.name !== "string" || !value.arguments || Array.isArray(value.arguments) || typeof value.arguments !== "object")
187
+ throw new Error("The care interpreter returned an invalid tool request.");
188
+ next = { name: value.name, arguments: value.arguments };
189
+ }
190
+ if (++callId > 12)
191
+ throw new Error("The care request reached its tool limit.");
192
+ const result = await this.json(`/api/watch-relay/v1/deliveries/${command.deliveryId}/care-tools`, { method: "POST", headers: this.headers({ "content-type": "application/json" }),
193
+ body: JSON.stringify({ requestId: command.requestId, connectionId: this.connectionId, callId, ...next }) });
194
+ if (result.retrySameCall)
195
+ callId -= 1;
196
+ results.push({ name: next.name, ...result });
197
+ next = undefined;
198
+ if (result.status === "confirmation_required") {
199
+ output = { text: JSON.stringify({ operation: "clarify", text: "Review this proposed change before applying it." }) };
200
+ break;
201
+ }
202
+ }
203
+ }
155
204
  terminal = { state: "completed", modelOutput: output.text, updatedAt: new Date().toISOString() };
156
205
  }
157
206
  catch (error) {
@@ -0,0 +1,12 @@
1
+ /** Short polls retain the server's 45-second connection lease. Only a quiet,
2
+ * empty bridge backs off: active provider work keeps the existing one-second
3
+ * cancellation cadence. New commands, cancellation and reconnect reset it.
4
+ * No request is deliberately held open. Idle discovery may wait up to 2.5s. The cap stays below ordinary client keep-alive timeouts to avoid turning idle polls into repeated TLS handshakes. */
5
+ export declare class RelayPollDelay {
6
+ private readonly random;
7
+ private nextIdleMs;
8
+ constructor(random?: () => number);
9
+ reset(): void;
10
+ after(state: "idle" | "command" | "cancel", hasPendingExecution: boolean): number;
11
+ }
12
+ export declare function relayPause(ms: number, signal: AbortSignal): Promise<void>;
@@ -0,0 +1,34 @@
1
+ /** Short polls retain the server's 45-second connection lease. Only a quiet,
2
+ * empty bridge backs off: active provider work keeps the existing one-second
3
+ * cancellation cadence. New commands, cancellation and reconnect reset it.
4
+ * No request is deliberately held open. Idle discovery may wait up to 2.5s. The cap stays below ordinary client keep-alive timeouts to avoid turning idle polls into repeated TLS handshakes. */
5
+ export class RelayPollDelay {
6
+ random;
7
+ nextIdleMs = 1_000;
8
+ constructor(random = Math.random) {
9
+ this.random = random;
10
+ }
11
+ reset() { this.nextIdleMs = 1_000; }
12
+ after(state, hasPendingExecution) {
13
+ if (state !== "idle") {
14
+ this.reset();
15
+ return 0;
16
+ }
17
+ if (hasPendingExecution) {
18
+ this.reset();
19
+ return 1_000;
20
+ }
21
+ const delay = Math.round(this.nextIdleMs * (0.8 + this.random() * 0.2));
22
+ this.nextIdleMs = Math.min(2_500, this.nextIdleMs * 2);
23
+ return delay;
24
+ }
25
+ }
26
+ export function relayPause(ms, signal) {
27
+ return new Promise(resolve => {
28
+ const done = () => { clearTimeout(timer); signal.removeEventListener("abort", done); resolve(); };
29
+ const timer = setTimeout(done, ms);
30
+ signal.addEventListener("abort", done, { once: true });
31
+ if (signal.aborted)
32
+ done();
33
+ });
34
+ }
@@ -1,8 +1,8 @@
1
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;
2
+ export declare const WATCH_RELAY_PROTOCOL: "cozibud.watch-relay.v3";
3
+ export declare const WATCH_RELAY_PROTOCOL_VERSION: 3;
4
4
  export type WatchRelayCommand = Readonly<{
5
- protocol: typeof WATCH_RELAY_PROTOCOL;
5
+ protocol: typeof WATCH_RELAY_PROTOCOL | "cozibud.watch-relay.v2";
6
6
  requestId: string;
7
7
  deliveryId: string;
8
8
  attempt: number;
@@ -15,4 +15,11 @@ export type WatchRelayCommand = Readonly<{
15
15
  }>;
16
16
  expiresAt: string;
17
17
  interpretation?: string;
18
+ careTools?: Readonly<{
19
+ enabled: true;
20
+ confirmed?: {
21
+ name: string;
22
+ arguments: Record<string, unknown>;
23
+ };
24
+ }>;
18
25
  }>;
@@ -1,3 +1,3 @@
1
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;
2
+ export const WATCH_RELAY_PROTOCOL = "cozibud.watch-relay.v3";
3
+ export const WATCH_RELAY_PROTOCOL_VERSION = 3;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cozibud/harness",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Cozibud local-client bridge and CLI.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",