@cozibud/harness 0.2.1 → 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
 
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
@@ -74,7 +74,7 @@ export async function pairRelay(options) {
74
74
  continue;
75
75
  }
76
76
  if (result.error?.code === "bridge_limit")
77
- throw new Error("Your account already has two computers paired. Disconnect one in Cozibud under Settings → Assistants & API → Voice with a local agent, then run pair again.");
77
+ throw new Error("Your account already has two computers paired. Disconnect one in Cozibud under Settings → Connected devices → Computers, then run pair again.");
78
78
  throw new Error("Pairing was denied or expired. Run pair again.");
79
79
  }
80
80
  throw new Error("Pairing expired. Run pair again.");
@@ -358,7 +358,7 @@ export const builtInProcessHarnesses = [
358
358
  command: codexExecutable.command,
359
359
  versionArgs: [...codexExecutable.prefixArgs, "--version"],
360
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"]), "-"],
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"]), "-"],
362
362
  },
363
363
  {
364
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.1",
3
+ "version": "0.3.0",
4
4
  "description": "Cozibud local-client bridge and CLI.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",