@inetafrica/open-claudia 3.2.1 → 3.2.3

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/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## v3.2.3 — No watch can end silently: every watch carries a timeout that fires as a wake-up
4
+
5
+ - **The silent-failure hole in watches.** A watch whose check never exits 0 polled forever without a sound — if the watched thing hung, was misconfigured (the unauthenticated-`glab` watch from 2026-08-12 would never have fired), or simply never happened, the agent slept through it and the user had to notice the silence themselves. The working pattern was to pair every watch with a manual backup wakeup; that safety net is now built in.
6
+ - **Every new watch gets a hard expiry.** `watch-add` grows `--timeout <duration|ISO>` (90m, 6h, 1d, or an ISO datetime), default **2h**, stamped as `expiresAt` on the job. On the first poll tick at or past the deadline where the check *still* fails, the watch FIRES anyway: the woken prompt opens with `[Watch expired ...]` plus an explicit "the watched condition did NOT happen — this is a deadline wake-up, not a trigger" brief, and the chat advisory reads `⏰ Watch "label" timed out without triggering — waking anyway`. A check that passes exactly on the deadline tick still reports as a normal trigger.
7
+ - **Expiry ends the watch's window.** An expiry fire is one-shot even for `--repeat` watches (the woken agent re-arms if still wanted), and the expiry mark is persisted so a failed expiry fire retries with the same semantics across restarts — while a retry whose condition came true in the meantime reports as a real trigger, not an expiry. `watch-list` now shows each watch's expiry (or `EXPIRED (fires on next poll)`); the global `/watch off` master switch still pauses everything, expiry included, until re-enabled.
8
+ - **Backward compatible by choice.** Watches persisted before this release carry no `expiresAt` and keep their old poll-forever semantics rather than being retro-expired on upgrade (a pre-upgrade watch older than the default would otherwise fire the instant the bot restarts). `watch-list` flags these as `no expiry (pre-timeout watch)`.
9
+ - Regression coverage in `test-provider-scheduler.js`: future-expiry idle stays silent, lapsed-expiry fire (header, brief, advisory, removal despite `--repeat`), deadline-tick pass is a trigger, default and explicit expiry stamping, and static end-to-end wiring (scheduler default → loopback passthrough → CLI flag).
10
+
11
+ ## v3.2.2 — The chip thinks from first contact, keeps thinking through the tools, and the dedup gate actually runs
12
+
13
+ - **The dead minute before the chip appeared.** The typing heartbeat only started inside `executeAdmittedRun` — but a turn-bound message first crosses the enforcer's inbound triage (an LLM call), and voice notes add a media download plus STT before that. All of it ran with zero presence: on a slow triage the chat sat silent looking ignored, then "thinking" popped up long after the work had begun. The router now starts the heartbeat the moment a turn-bound message arrives (`startPreTurnTyping` in all five inbound handlers — text, voice, audio, photo, document), covering triage, download, and transcription. Non-turn traffic (wizards, vault prompts, credential pastes, commands) never shows a false chip because the start sits after those gates.
14
+ - **The chip decayed to a stale "typing" during tool work.** Any `tool_start` permanently cleared `thinkingPhase`, so from the first tool call onward every 4s tick sent a stateless `typing:start` — clients rendered "typing…" through minutes of tool execution, and "thinking" never came back within a turn. Terminal and text events (`text_delta`, `text_final`, `result`, `error`) still end the thinking phase, but `tool_start` now re-enters it: streaming text reads as "typing", tool stretches read as "thinking" — which is what they are. (Kazee mobile renders a stateless tick as "typing" and auto-clears 5s after the last tick, so the 4s cadence keeps the chip alive and the state accurate on old and new clients alike.)
15
+ - **Handoff without a scramble.** Two starts can now coexist safely: `startTypingHeartbeat` is a singleton per chat state — a new start supersedes the old interval, and every stopper is ownership-aware, acting only while its own interval is current. The router's pre-turn heartbeat hands off to the run's own heartbeat at `executeAdmittedRun` with no gap and no double interval; a stale stopper (a router `finally` after handoff, a superseded run) is a structural no-op. `/stop`, which clears the interval directly and thereby orphans the run's stopper, now emits the explicit `typing:stop` itself in both branches. `test-typing-heartbeat.js` joins the release chain: immediate thinking tick, supersede/ownership semantics, no orphaned timers across a full tick window, and the event-steering contract pinned.
16
+ - **The journal dedup gate never actually ran.** v3.2.0's write-time dedup adjudicator (A1) calls its utility with `purpose: "dedup"` — a purpose `utility-policy` never registered, so every invocation died at validation with `INVALID_UTILITY_PURPOSE` and failed open, silently, by design. Log-proven on the local install. "dedup" is now a first-class purpose riding the review pipeline: `MEMORY_PROVIDER` steers it and `PACK_DEDUP_MODEL` is honored as its model override (both the prefix and the legacy Claude alias, matching what the adjudicator already passes). Pinned in `test-utility-provider-policy.js`.
17
+ - **Not changed, on purpose:** the 5× duplicate voice notes from 2026-08-09 were client multi-tap creating *distinct* messages (fixed client-side in kazee-chat-mobile v3.25.23's optimistic-upload guard); the router's existing `isDuplicate` LRU already covers true socket redeliveries by message id, so no bot-side dedup was added.
18
+
3
19
  ## v3.2.1 — Speech as a general capability, sockets that survive rejection, and calls that greet back
4
20
 
5
21
  - **A rejected handshake no longer strands a bot forever.** socket.io-client permanently stops reconnecting when the *server* rejects a handshake (auth middleware `next(new Error())`): `connect_error` fires with `socket.active === false` and the manager never dials again. During the 2026-08-08 outage chat-central booted before Central's MongoDB and rejected every handshake for a few minutes — terminally stranding the whole bot fleet, pods "Running" but deaf until manually restarted. New `core/socket-reconnect.js` `wireTerminalReconnect()` watches for exactly that terminal state and drives a manual `connect()` with 2s→30s capped backoff; transport-level failures (where the manager retries on its own, `socket.active === true`) are left alone. A deliberate client-side `disconnect()` is intercepted — a wedged socket emits no event for it — and cancels retries until `connect()` re-arms them. Wired into both the kazee and spaces sockets; `test-socket-reconnect.js` joins the pretest chain.
package/bin/cli.js CHANGED
@@ -588,7 +588,7 @@ Background work (only inside an active bot-spawned task):
588
588
  open-claudia cron-add "<sched>" "<prompt>" Recurring; each fire uses your current provider/model selection
589
589
  open-claudia cron-list List all wakeups + crons on this channel
590
590
  open-claudia cron-remove <id> Remove a scheduled job
591
- open-claudia watch-add "<check>" "<prompt>" Silent monitor: poll a shell check, wake only when it exits 0
591
+ open-claudia watch-add "<check>" "<prompt>" Silent monitor: poll a shell check, wake when it exits 0 or on --timeout expiry (default 2h)
592
592
  open-claudia watch-list List watches on this channel + master switch state
593
593
  open-claudia watch-remove <id> Remove a watch
594
594
  open-claudia watch-on | watch-off Global master switch for all watches (owner)
package/bin/schedule.js CHANGED
@@ -16,11 +16,15 @@ Crons (recurring):
16
16
  open-claudia cron-remove <id> # remove by id from cron-list
17
17
 
18
18
  Watches (silent background monitor — polls a shell check, wakes you only on a hit):
19
- open-claudia watch-add "<check-cmd>" "<wake-prompt>" [--label "<label>"] [--every "<cron>"] [--repeat]
19
+ open-claudia watch-add "<check-cmd>" "<wake-prompt>" [--label "<label>"] [--every "<cron>"] [--repeat] [--timeout <when>]
20
20
  <check-cmd>: a shell command polled on a schedule (default every minute).
21
21
  exit 0 = condition met → wake the agent with <wake-prompt>.
22
22
  non-zero = still idle → nothing happens, nothing hits the chat.
23
23
  --repeat: keep polling after it fires (default: fire once, then remove).
24
+ --timeout: how long to keep polling — a duration (90m, 6h, 1d) or ISO
25
+ datetime. Default 2h. A watch that reaches its timeout without
26
+ the check ever passing FIRES anyway on the next poll, with the
27
+ prompt marked "Watch expired" — no watch can end silently.
24
28
  open-claudia watch-list # watches on this channel + master switch state
25
29
  open-claudia watch-remove <id> # remove a watch by id
26
30
  open-claudia watch-on / watch-off # global master switch for ALL watches (owner)
@@ -141,18 +145,29 @@ async function runWatchAdd(args) {
141
145
  const label = takeFlag(rest, "label");
142
146
  const schedule = takeFlag(rest, "every");
143
147
  const project = takeFlag(rest, "project");
148
+ const timeout = takeFlag(rest, "timeout");
144
149
  const repeatIdx = rest.indexOf("--repeat");
145
150
  const repeat = repeatIdx >= 0;
146
151
  if (repeat) rest.splice(repeatIdx, 1);
152
+ let expiresAt = null;
153
+ if (timeout !== null) {
154
+ expiresAt = parseWhen(timeout);
155
+ if (!expiresAt) {
156
+ console.error(`Could not parse --timeout: "${timeout}". Use a duration (90m, 6h, 1d) or ISO datetime.`);
157
+ process.exit(2);
158
+ }
159
+ }
147
160
  const check = rest[0];
148
161
  const prompt = rest.slice(1).join(" ");
149
- if (!check || !prompt) { console.error("Usage: watch-add \"<check-cmd>\" \"<wake-prompt>\" [--every \"<cron>\"] [--repeat]"); process.exit(2); }
162
+ if (!check || !prompt) { console.error("Usage: watch-add \"<check-cmd>\" \"<wake-prompt>\" [--every \"<cron>\"] [--repeat] [--timeout <when>]"); process.exit(2); }
150
163
  try {
151
164
  const request = { check, prompt, repeat, label: label || prompt.slice(0, 60) };
152
165
  if (schedule) request.schedule = schedule;
153
166
  if (project) request.project = project;
167
+ if (expiresAt) request.expiresAt = expiresAt;
154
168
  const res = await postJson("watch-add", request);
155
- console.log(`Added watch ${res.job.id} (${repeat ? "repeating" : "one-shot"}, polls "${res.job.schedule}"). Silent until the check passes.`);
169
+ const expiry = res.job.expiresAt ? `, expires ${new Date(res.job.expiresAt).toISOString()}` : "";
170
+ console.log(`Added watch ${res.job.id} (${repeat ? "repeating" : "one-shot"}, polls "${res.job.schedule}"${expiry}). Silent until the check passes; fires as "Watch expired" if the timeout lapses first.`);
156
171
  process.exit(0);
157
172
  } catch (e) { console.error(e.message); process.exit(1); }
158
173
  }
@@ -165,7 +180,12 @@ async function runWatchList() {
165
180
  if (list.length === 0) { console.log("No watches on this channel."); process.exit(0); }
166
181
  for (const w of list) {
167
182
  const mode = w.repeat ? "repeating" : "one-shot";
168
- console.log(`${w.id} ${mode} polls "${w.schedule}" check: ${w.check} → ${w.label || "(no label)"}`);
183
+ const expiry = Number(w.expiresAt) > 0
184
+ ? (Date.now() >= Number(w.expiresAt)
185
+ ? " EXPIRED (fires on next poll)"
186
+ : ` expires ${new Date(Number(w.expiresAt)).toISOString()}`)
187
+ : " no expiry (pre-timeout watch)";
188
+ console.log(`${w.id} ${mode} polls "${w.schedule}"${expiry} check: ${w.check} → ${w.label || "(no label)"}`);
169
189
  }
170
190
  process.exit(0);
171
191
  } catch (e) { console.error(e.message); process.exit(1); }
@@ -107,6 +107,9 @@ class KazeeCallSession {
107
107
  const created = await this.janus.message(this.publisherHandle, {
108
108
  request: "create", room: this.roomId, publishers: 6, audiocodec: "opus",
109
109
  description: `Call room ${this.roomId}`, permanent: false,
110
+ // Hidden from videoroom "list": the control plane is internet-reachable,
111
+ // so a listable room is eavesdroppable. Join-by-roomId is unaffected.
112
+ is_private: true,
110
113
  });
111
114
  const cdata = created?.plugindata?.data || {};
112
115
  const exists = Number(cdata.error_code) === 427 || /already exists/i.test(String(cdata.error || ""));
package/core/handlers.js CHANGED
@@ -1611,14 +1611,18 @@ register({
1611
1611
  // Stop pending preview flushes but keep statusMessageId/Channel: the
1612
1612
  // killed run's delivery unwind edits the preview to drop its footer.
1613
1613
  if (state.streamInterval) { clearTimeout(state.streamInterval); state.streamInterval = null; }
1614
- if (state.typingHeartbeat) { clearInterval(state.typingHeartbeat); state.typingHeartbeat = null; }
1614
+ // Clearing the interval here orphans the run's own stopper (ownership
1615
+ // check makes it a no-op), so the explicit typing:stop must go out now.
1616
+ if (state.typingHeartbeat) { clearInterval(state.typingHeartbeat); state.typingHeartbeat = null; state.thinkingPhase = false; }
1617
+ try { currentAdapter()?.typingStop?.(currentChannelId()); } catch (_) {}
1615
1618
  await sideCancellation;
1616
1619
  await send(`Cancelled.${queuedNote()}`);
1617
1620
  } else if (state.preparingRun) {
1618
1621
  // Turn is mid-recall/prompt-build — no process to kill yet. Flag it so
1619
1622
  // the runner's pre-spawn shouldAbort checkpoint bails before spawning.
1620
1623
  state.cancelRequested = true;
1621
- if (state.typingHeartbeat) { clearInterval(state.typingHeartbeat); state.typingHeartbeat = null; }
1624
+ if (state.typingHeartbeat) { clearInterval(state.typingHeartbeat); state.typingHeartbeat = null; state.thinkingPhase = false; }
1625
+ try { currentAdapter()?.typingStop?.(currentChannelId()); } catch (_) {}
1622
1626
  await sideCancellation;
1623
1627
  await send(`Cancelled.${queuedNote()}`);
1624
1628
  } else if (await sideCancellation) await send("Cancelled side response.");
package/core/loopback.js CHANGED
@@ -347,6 +347,10 @@ async function handleJson(req, res, url, kind) {
347
347
  label: String(payload.label || payload.prompt).slice(0, 60),
348
348
  schedule: payload.schedule ? String(payload.schedule) : undefined,
349
349
  repeat: !!payload.repeat,
350
+ // Absent/invalid → the scheduler stamps its default watch timeout.
351
+ expiresAt: Number.isFinite(Number(payload.expiresAt)) && Number(payload.expiresAt) > 0
352
+ ? Number(payload.expiresAt)
353
+ : undefined,
350
354
  source: payload.source || "agent",
351
355
  sessionId: payload.sessionId || null,
352
356
  previousSessionId: payload.previousSessionId || null,
@@ -4,7 +4,7 @@ const config = require("../config");
4
4
 
5
5
  const PROVIDER_IDS = Object.freeze(["claude", "codex"]);
6
6
  const PROVIDER_ID_SET = new Set(PROVIDER_IDS);
7
- const PURPOSES = Object.freeze(["subagent", "recall", "review", "dream", "introspection", "enforcer"]);
7
+ const PURPOSES = Object.freeze(["subagent", "recall", "review", "dedup", "dream", "introspection", "enforcer"]);
8
8
  const PURPOSE_SET = new Set(PURPOSES);
9
9
  const TIERS = Object.freeze(["low", "medium", "high"]);
10
10
  const TIER_SET = new Set(TIERS);
@@ -12,6 +12,7 @@ const PURPOSE_PROVIDER_ENV = Object.freeze({
12
12
  subagent: "SUBAGENT_PROVIDER",
13
13
  recall: "RECALL_PROVIDER",
14
14
  review: "MEMORY_PROVIDER",
15
+ dedup: "MEMORY_PROVIDER",
15
16
  dream: "DREAM_PROVIDER",
16
17
  introspection: "DREAM_PROVIDER",
17
18
  enforcer: "ENFORCER_PROVIDER",
@@ -20,12 +21,14 @@ const PURPOSE_MODEL_PREFIX = Object.freeze({
20
21
  subagent: "SUBAGENT_MODEL",
21
22
  recall: "RECALL_MODEL",
22
23
  review: "PACK_REVIEW_MODEL",
24
+ dedup: "PACK_DEDUP_MODEL",
23
25
  dream: "DREAM_MODEL",
24
26
  introspection: "DREAM_MODEL",
25
27
  enforcer: "ENFORCER_MODEL",
26
28
  });
27
29
  const LEGACY_CLAUDE_MODEL_ENV = Object.freeze({
28
30
  review: "PACK_REVIEW_MODEL",
31
+ dedup: "PACK_DEDUP_MODEL",
29
32
  dream: "DREAM_MODEL",
30
33
  introspection: "DREAM_MODEL",
31
34
  enforcer: "ENFORCER_MODEL",
package/core/router.js CHANGED
@@ -12,7 +12,7 @@ const { isChatAuthorized } = require("./access");
12
12
  const introFlow = require("./intro-flow");
13
13
  const { send, deleteMessage } = require("./io");
14
14
  const { currentState, saveState, autoLinkOwnerChannel, getActiveProvider, getProjectKey } = require("./state");
15
- const { admitRunContext, runAgent } = require("./runner");
15
+ const { admitRunContext, runAgent, startTypingHeartbeat } = require("./runner");
16
16
  const { transcribeAudio, cloneVoiceFromClip } = require("./media");
17
17
  const { vault } = require("./vault-store");
18
18
  const { isOnboarded, handleOnboarding } = require("./onboarding");
@@ -203,9 +203,20 @@ async function admitGuardedTurn(askText, envelope, state = currentState()) {
203
203
  return !(triage && triage.held);
204
204
  }
205
205
 
206
+ // Chip-on-receipt: enforcer triage, media download, and STT all run before
207
+ // runAgent — many seconds with no feedback. Start the "thinking" heartbeat as
208
+ // soon as a turn-bound message arrives; the run's own heartbeat supersedes it
209
+ // at executeAdmittedRun, after which this stopper is a no-op. Skipped while a
210
+ // run is active — the chip is already owned by that turn.
211
+ function startPreTurnTyping(state) {
212
+ if (state.runningProcess || state.preparingRun) return () => {};
213
+ try { return startTypingHeartbeat(state); } catch (_) { return () => {}; }
214
+ }
215
+
206
216
  async function handleVoice(envelope) {
207
217
  if (isDuplicate(envelope)) return;
208
218
  const state = currentState();
219
+ let stopPreTurnTyping = () => {};
209
220
  try {
210
221
  const media = envelope.media?.[0];
211
222
  if (!media) return;
@@ -223,7 +234,7 @@ async function handleVoice(envelope) {
223
234
  purpose: "foreground",
224
235
  attachments: [{ type: "voice", mimeType: media.mimeType || "audio/ogg", fileName: media.fileName || null }],
225
236
  });
226
- envelope.adapter.typing(envelope.channelId).catch(() => {});
237
+ stopPreTurnTyping = startPreTurnTyping(state);
227
238
  const tStart = Date.now();
228
239
  const oggPath = await envelope.adapter.downloadMedia(media);
229
240
  const tAfterDownload = Date.now();
@@ -243,11 +254,13 @@ async function handleVoice(envelope) {
243
254
  voiceStream: envelope.adapter?.type === "voice",
244
255
  });
245
256
  } catch (err) { await send(`Voice failed: ${err.message}`); }
257
+ finally { stopPreTurnTyping(); }
246
258
  }
247
259
 
248
260
  async function handleAudio(envelope) {
249
261
  if (isDuplicate(envelope)) return;
250
262
  const state = currentState();
263
+ let stopPreTurnTyping = () => {};
251
264
  try {
252
265
  const media = envelope.media?.[0];
253
266
  if (!media) return;
@@ -260,7 +273,7 @@ async function handleAudio(envelope) {
260
273
  purpose: "foreground",
261
274
  attachments: [{ type: "audio", mimeType: media.mimeType || null, fileName: media.fileName || null }],
262
275
  });
263
- envelope.adapter.typing(envelope.channelId).catch(() => {});
276
+ stopPreTurnTyping = startPreTurnTyping(state);
264
277
  const p = await envelope.adapter.downloadMedia(media);
265
278
  const t = await transcribeAudio(p);
266
279
  try { fs.unlinkSync(p); } catch (e) {}
@@ -272,6 +285,7 @@ async function handleAudio(envelope) {
272
285
  attachments: [{ type: "audio", path: p, mimeType: media.mimeType || null, fileName: media.fileName || null }],
273
286
  });
274
287
  } catch (err) { await send(`Audio failed: ${err.message}`); }
288
+ finally { stopPreTurnTyping(); }
275
289
  }
276
290
 
277
291
  // A clip the owner sent right after /voice clone. Upload it to the speech
@@ -297,6 +311,7 @@ async function captureVoiceClone(envelope, media) {
297
311
  async function handlePhoto(envelope) {
298
312
  if (isDuplicate(envelope)) return;
299
313
  const state = currentState();
314
+ let stopPreTurnTyping = () => {};
300
315
  try {
301
316
  const items = envelope.media || [];
302
317
  if (!items.length) return;
@@ -304,6 +319,7 @@ async function handlePhoto(envelope) {
304
319
  purpose: "foreground",
305
320
  attachments: items.map((item) => ({ type: "image", mimeType: item.mimeType || "image/*", fileName: item.fileName || null })),
306
321
  });
322
+ stopPreTurnTyping = startPreTurnTyping(state);
307
323
  const paths = [];
308
324
  for (const m of items) paths.push(await envelope.adapter.downloadMedia(m));
309
325
  const caption = envelope.caption || "Describe this image. If code/UI/error — explain and fix.";
@@ -327,11 +343,13 @@ async function handlePhoto(envelope) {
327
343
  })),
328
344
  });
329
345
  } catch (err) { await send(`Image failed: ${err.message}`); }
346
+ finally { stopPreTurnTyping(); }
330
347
  }
331
348
 
332
349
  async function handleDocument(envelope) {
333
350
  if (isDuplicate(envelope)) return;
334
351
  const state = currentState();
352
+ let stopPreTurnTyping = () => {};
335
353
  try {
336
354
  const media = envelope.media?.[0];
337
355
  if (!media) return;
@@ -342,6 +360,7 @@ async function handleDocument(envelope) {
342
360
  purpose: "foreground",
343
361
  attachments: [{ type: "document", mimeType: media.mimeType || null, fileName: media.fileName || null }],
344
362
  });
363
+ stopPreTurnTyping = startPreTurnTyping(state);
345
364
  const savePath = await envelope.adapter.downloadMedia(media);
346
365
  const fileName = media.fileName || path.basename(savePath);
347
366
  const mime = media.mimeType || "";
@@ -361,6 +380,7 @@ async function handleDocument(envelope) {
361
380
  attachments: [{ type: isImage ? "image" : "document", path: savePath, mimeType: mime, fileName }],
362
381
  });
363
382
  } catch (err) { await send(`Failed: ${err.message}`); }
383
+ finally { stopPreTurnTyping(); }
364
384
  }
365
385
 
366
386
  // Provider-agnostic capture of a credential pasted into chat with no auth
@@ -605,7 +625,11 @@ async function handleText(envelope) {
605
625
  // stop, instead of forwarding a secret to the model (Codex refuses those).
606
626
  if (await captureLooseCredential(text.trim(), envelope)) return;
607
627
 
608
- if (!(await admitGuardedTurn(text, envelope, state))) return;
628
+ const stopPreTurnTyping = startPreTurnTyping(state);
629
+ let admitted;
630
+ try { admitted = await admitGuardedTurn(text, envelope, state); }
631
+ catch (e) { stopPreTurnTyping(); throw e; }
632
+ if (!admitted) { stopPreTurnTyping(); return; }
609
633
 
610
634
  const runContext = admitRunContext(text, state.currentSession.dir, envelope.messageId, { purpose: "foreground" });
611
635
  const attachments = [];
package/core/runner.js CHANGED
@@ -1725,17 +1725,24 @@ function decorateForegroundInvocation(invocation, runContext, bundle) {
1725
1725
  return { ...invocation, env };
1726
1726
  }
1727
1727
 
1728
+ // Singleton per chat state: a new start supersedes any running heartbeat
1729
+ // (pre-turn "thinking" started at message receipt hands off to the run's own
1730
+ // heartbeat here). A stopper only acts while its own interval is current, so
1731
+ // a stale stopper from a superseded start can never kill a newer heartbeat.
1728
1732
  function startTypingHeartbeat(state, store) {
1729
1733
  const adapter = store?.adapter || currentAdapter();
1730
1734
  const channelId = store?.channelId || currentChannelId();
1731
1735
  if (!adapter || !channelId) return () => {};
1736
+ if (state.typingHeartbeat) clearInterval(state.typingHeartbeat);
1732
1737
  const tick = () => adapter.typing(channelId, state.thinkingPhase ? "thinking" : undefined).catch(() => {});
1733
1738
  state.thinkingPhase = true;
1734
1739
  tick();
1735
- state.typingHeartbeat = setInterval(tick, 4000);
1740
+ const interval = setInterval(tick, 4000);
1741
+ state.typingHeartbeat = interval;
1736
1742
  return () => {
1743
+ if (state.typingHeartbeat !== interval) return;
1737
1744
  state.thinkingPhase = false;
1738
- if (state.typingHeartbeat) clearInterval(state.typingHeartbeat);
1745
+ clearInterval(interval);
1739
1746
  state.typingHeartbeat = null;
1740
1747
  try { adapter.typingStop?.(channelId); } catch (_) {}
1741
1748
  };
@@ -1880,7 +1887,10 @@ function createForegroundService({ state, store, stopTyping, capture, ownsAdmiss
1880
1887
  observer.onRecallSurfaced(bundle.recallMetadata);
1881
1888
  },
1882
1889
  onEvent(event, runContext) {
1883
- if (["text_delta", "text_final", "tool_start", "result", "error"].includes(event.type)) state.thinkingPhase = false;
1890
+ // Streaming text reads as "typing"; tool work re-enters "thinking" so
1891
+ // the chip doesn't decay to a stale "typing" through long tool stretches.
1892
+ if (["text_delta", "text_final", "result", "error"].includes(event.type)) state.thinkingPhase = false;
1893
+ else if (event.type === "tool_start") state.thinkingPhase = true;
1884
1894
  if (event.type === "tool_start" && ["Shell", "Bash"].includes(event.name)) {
1885
1895
  const command = typeof event.detail === "string" ? event.detail : event.detail?.command;
1886
1896
  if (command) { try { require("./tool-guard").noteRawOp(command); } catch (_) {} }
@@ -2248,6 +2258,7 @@ module.exports = {
2248
2258
  runAgent,
2249
2259
  runAgentCapture,
2250
2260
  runAgentSilent,
2261
+ startTypingHeartbeat,
2251
2262
  shouldRetryRefusedRun,
2252
2263
  quarantineRefusedRun,
2253
2264
  refusalRetryContext,
package/core/scheduler.js CHANGED
@@ -43,6 +43,10 @@ const RETRY_DELAY_MS = 30 * 1000;
43
43
  const BUSY_DELAY_MS = 30 * 1000;
44
44
  // A watch's `check` command is capped so a hung poll can't wedge the tick.
45
45
  const WATCH_CHECK_TIMEOUT_MS = 30 * 1000;
46
+ // Every watch carries a hard expiry so none can idle-poll forever: when the
47
+ // deadline lapses with the check still failing, the next poll tick fires the
48
+ // wake anyway (marked expired) instead of skipping silently.
49
+ const DEFAULT_WATCH_TIMEOUT_MS = 2 * 60 * 60 * 1000;
46
50
  const WATCH_LOG_FILE = process.env.OPEN_CLAUDIA_WATCH_LOG_FILE || path.join(CONFIG_DIR, "watch.log");
47
51
  const WATCHES_ENABLED_FILE = process.env.OPEN_CLAUDIA_WATCHES_ENABLED_FILE || path.join(CONFIG_DIR, "watches-enabled");
48
52
 
@@ -107,6 +111,7 @@ function createSchedulerService(dependencies = {}) {
107
111
  const retryDelayMs = Number.isFinite(dependencies.retryDelayMs) ? dependencies.retryDelayMs : RETRY_DELAY_MS;
108
112
  const busyDelayMs = Number.isFinite(dependencies.busyDelayMs) ? dependencies.busyDelayMs : BUSY_DELAY_MS;
109
113
  const missedGraceMs = Number.isFinite(dependencies.missedGraceMs) ? dependencies.missedGraceMs : MISSED_WAKEUP_GRACE_MS;
114
+ const watchTimeoutMs = Number.isFinite(dependencies.watchTimeoutMs) ? dependencies.watchTimeoutMs : DEFAULT_WATCH_TIMEOUT_MS;
110
115
  const occurrenceIdFactory = dependencies.occurrenceIdFactory || (() => uniqueId("occ"));
111
116
  const legacyCronsFile = dependencies.legacyCronsFile === undefined
112
117
  ? (dependencies.jobsStore ? null : CRONS_FILE)
@@ -273,22 +278,21 @@ function createSchedulerService(dependencies = {}) {
273
278
 
274
279
  function wrappedPrompt(job, target) {
275
280
  const firedAt = new Date(now()).toISOString();
276
- const header = job.check ? "Watch triggered" : job.kind === "cron" ? "Scheduled cron" : "Scheduled wakeup";
277
- if (target.fallbackFrom) {
278
- return [
279
- `[${header} "${job.label}" fired at ${firedAt}; occurrence ${job.occurrenceId}]`,
280
- `[Archived provider-neutral brief: the originating ${target.fallbackFrom} provider is unavailable. Its native session is not being resumed; this explicitly configured fallback starts fresh.]`,
281
- job.prompt,
282
- ].join("\n");
281
+ const expired = !!(job.check && job.watchExpired);
282
+ const header = job.check
283
+ ? (expired ? "Watch expired" : "Watch triggered")
284
+ : job.kind === "cron" ? "Scheduled cron" : "Scheduled wakeup";
285
+ const lines = [`[${header} "${job.label}" fired at ${firedAt}; occurrence ${job.occurrenceId}]`];
286
+ if (expired) {
287
+ lines.push("[This watch reached its timeout without its check ever passing — the watched condition did NOT happen. This is a deadline wake-up, not a trigger: investigate why the condition never became true, and arm a new watch if it is still worth waiting for.]");
283
288
  }
284
- if (target.switchedFrom) {
285
- return [
286
- `[${header} "${job.label}" fired at ${firedAt}; occurrence ${job.occurrenceId}]`,
287
- `[Provider now tracks your current selection (${target.provider}); this was scheduled under ${target.switchedFrom}, whose native session cannot cross providers, so this run starts a fresh thread.]`,
288
- job.prompt,
289
- ].join("\n");
289
+ if (target.fallbackFrom) {
290
+ lines.push(`[Archived provider-neutral brief: the originating ${target.fallbackFrom} provider is unavailable. Its native session is not being resumed; this explicitly configured fallback starts fresh.]`);
291
+ } else if (target.switchedFrom) {
292
+ lines.push(`[Provider now tracks your current selection (${target.provider}); this was scheduled under ${target.switchedFrom}, whose native session cannot cross providers, so this run starts a fresh thread.]`);
290
293
  }
291
- return `[${header} "${job.label}" fired at ${firedAt}; occurrence ${job.occurrenceId}]\n${job.prompt}`;
294
+ lines.push(job.prompt);
295
+ return lines.join("\n");
292
296
  }
293
297
 
294
298
  function unscheduleJob(id) {
@@ -394,7 +398,7 @@ function createSchedulerService(dependencies = {}) {
394
398
 
395
399
  async function fireJob(idOrJob, options = {}) {
396
400
  const requested = typeof idOrJob === "string" ? jobsStore.get(idOrJob) : idOrJob;
397
- const job = requested?.id && jobsStore.get(requested.id) ? jobsStore.get(requested.id) : requested;
401
+ let job = requested?.id && jobsStore.get(requested.id) ? jobsStore.get(requested.id) : requested;
398
402
  if (!job) return { ok: false, status: "missing" };
399
403
  if (job.disabled || job.status === "dead-letter" || job.status === "archived") {
400
404
  return { ok: false, status: job.status, skipped: true };
@@ -424,10 +428,35 @@ function createSchedulerService(dependencies = {}) {
424
428
  catch (_) { check = { code: 1, timedOut: false }; }
425
429
  finally { checksInFlight.delete(job.id); }
426
430
  if (!check || check.code !== 0) {
427
- logWatch(job, check && check.timedOut ? "idle (check timed out)" : `idle (check exit ${check ? check.code : "?"})`);
428
- return { ok: false, status: "watch-idle", skipped: true };
431
+ // Expiry backstop: a watch whose deadline has lapsed fires the wake
432
+ // anyway (marked expired) so no watch can ever end silently. Watches
433
+ // persisted before expiry existed (no expiresAt) keep polling forever.
434
+ const expiresAt = Number(job.expiresAt || 0);
435
+ if (!(expiresAt > 0 && now() >= expiresAt)) {
436
+ logWatch(job, check && check.timedOut ? "idle (check timed out)" : `idle (check exit ${check ? check.code : "?"})`);
437
+ return { ok: false, status: "watch-idle", skipped: true };
438
+ }
439
+ logWatch(job, `expired without firing (check exit ${check ? check.code : "?"}) — waking agent`);
440
+ // Expiry ends the watch's window: one-shot removal even for --repeat
441
+ // (the woken agent re-arms if still wanted). Persisted so a retry of a
442
+ // failed expiry fire keeps the same semantics across restarts.
443
+ try {
444
+ const marked = jobsStore.update(job.id, { repeat: false, watchExpired: true });
445
+ if (marked) job = marked;
446
+ else { job.repeat = false; job.watchExpired = true; }
447
+ } catch (_) { job.repeat = false; job.watchExpired = true; }
448
+ } else {
449
+ logWatch(job, "triggered (check passed) — waking agent");
450
+ if (job.watchExpired) {
451
+ // A prior expiry fire failed and the condition came true before the
452
+ // retry: report it as a real trigger, not an expiry.
453
+ try {
454
+ const cleared = jobsStore.update(job.id, { watchExpired: false });
455
+ if (cleared) job = cleared;
456
+ else job.watchExpired = false;
457
+ } catch (_) { job.watchExpired = false; }
458
+ }
429
459
  }
430
- logWatch(job, "triggered (check passed) — waking agent");
431
460
  }
432
461
 
433
462
  const adapter = resolveAdapter(job);
@@ -504,7 +533,10 @@ function createSchedulerService(dependencies = {}) {
504
533
  return { ok: false, status: "failed", error: stableFailure(error) };
505
534
  }
506
535
 
507
- try { await send(job.check ? `🔔 Watch "${job.label}" triggered` : `Wakeup: ${job.label}`); } catch (_) { /* advisory only */ }
536
+ const advisory = job.check
537
+ ? (job.watchExpired ? `⏰ Watch "${job.label}" timed out without triggering — waking anyway` : `🔔 Watch "${job.label}" triggered`)
538
+ : `Wakeup: ${job.label}`;
539
+ try { await send(advisory); } catch (_) { /* advisory only */ }
508
540
  let result;
509
541
  try { result = await runInProvider(prompt, runContext.cwd, null, { ...runOptions, runContext }); }
510
542
  catch (error) {
@@ -655,15 +687,21 @@ function createSchedulerService(dependencies = {}) {
655
687
  }
656
688
 
657
689
  // A watch is a cron carrying a shell `check`; it rides the migration-verified
658
- // jobs store untouched (check/repeat survive normalizeRunnableJob as extra
659
- // fields). Default schedule polls every minute; default is one-shot.
690
+ // jobs store untouched (check/repeat/expiresAt survive normalizeRunnableJob
691
+ // as extra fields). Default schedule polls every minute; default is one-shot.
692
+ // Every new watch gets an expiry (caller's expiresAt or the default timeout)
693
+ // so it can never idle-poll silently forever.
660
694
  function addWatch(spec) {
695
+ const requestedExpiry = Number(spec.expiresAt);
661
696
  return addJob({
662
697
  ...spec,
663
698
  kind: "cron",
664
699
  schedule: spec.schedule || "* * * * *",
665
700
  check: String(spec.check || "").trim(),
666
701
  repeat: !!spec.repeat,
702
+ expiresAt: Number.isFinite(requestedExpiry) && requestedExpiry > 0
703
+ ? requestedExpiry
704
+ : now() + watchTimeoutMs,
667
705
  label: spec.label || String(spec.prompt || "watch").slice(0, 60),
668
706
  });
669
707
  }
@@ -710,6 +748,7 @@ module.exports = {
710
748
  MISSED_WAKEUP_GRACE_MS,
711
749
  RETRY_DELAY_MS,
712
750
  BUSY_DELAY_MS,
751
+ DEFAULT_WATCH_TIMEOUT_MS,
713
752
  createSchedulerService,
714
753
  parseFallbacks,
715
754
  terminalRunSucceeded,
@@ -255,6 +255,7 @@ Wake-ups and crons (real schedulers, not hallucinations — use them instead of
255
255
 
256
256
  Watches (a silent background monitor — poll a condition WITHOUT waking every tick; wake only when it actually happens). Use this instead of a minute-by-minute cron whenever you'd otherwise fire a turn just to check "is it done yet?" and stay quiet most times:
257
257
  - \`open-claudia watch-add "<check-cmd>" "<wake-prompt>"\` — the scheduler polls \`<check-cmd>\` as a shell command (default every minute). The poll is SILENT: nothing reaches the chat while the check keeps failing. The instant \`<check-cmd>\` exits 0, you wake in this same conversation with \`<wake-prompt>\`, exactly like a wakeup. Non-zero exit = still waiting = nothing happens. Default is ONE-SHOT (fires once, then removes itself); add \`--repeat\` to keep polling after each hit. \`--every "<5-field cron>"\` changes the poll cadence.
258
+ - Every watch has a hard timeout — \`--timeout <duration|ISO>\` (default 2h). A watch that reaches its timeout without the check ever passing FIRES anyway on the next poll, with the prompt marked "Watch expired" so you know the condition did NOT happen. No watch can end silently, so you no longer need a separate backup wakeup for every watch — but size \`--timeout\` honestly to the work being watched.
258
259
  - \`open-claudia watch-list\` / \`open-claudia watch-remove <id>\` — inspect/cancel watches on this channel.
259
260
  - The owner has a global master switch: \`/watch off\` pauses ALL watch polling (and \`/watch on\` resumes). If \`watch-list\` shows the switch OFF, watches won't fire until it's back on.
260
261
  - Example: after kicking off a long external job, \`open-claudia watch-add "test -f /tmp/build-done || exit 1" "the build finished — verify and report"\` beats a wakeup guess, because it fires the moment it's actually done, not on a fixed timer. (Never use a watch to poll harness-tracked work you started — that notifies you automatically. Watches are for EXTERNAL state the harness can't see.)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "3.2.1",
3
+ "version": "3.2.3",
4
4
  "description": "An always-on, provider-agnostic coding-agent harness for Claude Code and OpenAI Codex via chat",
5
5
  "main": "bot.js",
6
6
  "bin": {
@@ -11,7 +11,7 @@
11
11
  "setup": "node setup.js",
12
12
  "start": "node bot.js",
13
13
  "pretest": "node test-provider-fixture.js && node test-provider-core-prompt.js && node test-turn-observer.js && node test-provider-prompt-parity.js && node test-provider-env-isolation.js && node test-provider-resume-parity.js && node test-provider-bootstrap.js && node test-provider-registry.js && node test-provider-stream-decoder.js && node test-provider-events.js && node test-provider-claude.js && node test-provider-codex.js && node test-provider-runner.js && node test-provider-refusal-recovery.js && node test-provider-run-lifecycle.js && node test-provider-gateway.js && node test-provider-side-chat.js && node test-single-runtime.js && node test-provider-migration-backup.js && node test-provider-state-migration.js && node test-provider-session-history.js && node test-provider-session-commands.js && node test-provider-compaction.js && node test-provider-scheduler.js && node test-credential-capture.js && node test-cursor-removal.js && node test-utility-provider-policy.js && node test-provider-subagent.js && node test-provider-recall.js && node test-provider-pack-review.js && node test-memory-mutation-queue.js && node test-provider-dream.js && node test-provider-enforcer.js && node test-provider-tool-hooks.js && node test-provider-boot-matrix.js && node test-provider-capabilities.js && node test-provider-language.js && node test-provider-coupling-audit.js && node test-provider-parity-e2e.js && node test-kazee-channel-health.js && node test-kazee-media-caption.js && node test-socket-reconnect.js && node test-speech-loopback.js && node test-prompt-budget.js && node test-agency-ledger.js && node test-agency-deliberate.js && node test-guardrail.js && node test-spaces-comment-content.js && node test-boot-health.js && node test-lexical-floor.js && node test-safe-mode.js && node test-logout.js && node test-kazee-call-audio.js && node test-kazee-call-janus.js && node test-kazee-call-webrtc.js && node test-kazee-call-speech.js && node test-kazee-call-manager.js && node test-kazee-call-session.js",
14
- "test": "OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node -e \"require('./vault'); console.log('OK')\" && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-runner-watchdog-static.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-usage-accounting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-engine.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-discoverer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-query.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-walker-score.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-score-gate.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-cokept-warming.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-project-transcripts-smoke.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-abilities.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-extraction.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-couse.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-transfer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-tiers.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-merge-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-learning-e2e.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-pack-nesting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-pack-search.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-extractive-injection.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-journal-dedupe.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-read-signal.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tools.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tooling-mode.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-manifest.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-evolution.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-approval-async.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-unified-identity.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-queue-routing.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-persona-packs.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-speaker-persona.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-persona-pipeline.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-relationship-gate.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-relationship.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-enforcer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-mandate-hygiene.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-external-rule-scopes.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-approval-cards.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-identity-prune.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-kazee-message-id.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-kazee-group-gate.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-kazee-group-context.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-people-owner-seed.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-delivery-contract.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-run-lock.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-web-sessions.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-telegram-poll-recovery.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-telegram-409-grace.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-telegram-conflict-heal-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-single-instance.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-streaming-split.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-spaces-autoenable.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-say-loopback.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-journal-union.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-subst-score.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-section-budgets.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-dedup-adjudicator.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-prospective.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-regressions.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-cost-report.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-speaker-context.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-approval-mining.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-certification.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-apprenticeship.js"
14
+ "test": "OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node -e \"require('./vault'); console.log('OK')\" && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-runner-watchdog-static.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-usage-accounting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-engine.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-discoverer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-query.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-walker-score.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-score-gate.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-cokept-warming.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-project-transcripts-smoke.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-abilities.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-extraction.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-couse.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-transfer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-tiers.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-merge-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-learning-e2e.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-pack-nesting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-pack-search.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-extractive-injection.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-journal-dedupe.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-read-signal.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tools.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tooling-mode.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-manifest.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-evolution.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-approval-async.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-unified-identity.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-queue-routing.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-persona-packs.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-speaker-persona.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-persona-pipeline.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-relationship-gate.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-relationship.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-enforcer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-mandate-hygiene.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-external-rule-scopes.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-approval-cards.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-identity-prune.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-kazee-message-id.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-kazee-group-gate.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-kazee-group-context.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-people-owner-seed.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-delivery-contract.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-run-lock.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-typing-heartbeat.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-web-sessions.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-telegram-poll-recovery.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-telegram-409-grace.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-telegram-conflict-heal-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-single-instance.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-streaming-split.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-spaces-autoenable.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-say-loopback.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-journal-union.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-subst-score.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-section-budgets.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-dedup-adjudicator.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-prospective.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-regressions.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-cost-report.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-speaker-context.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-approval-mining.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-certification.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-apprenticeship.js"
15
15
  },
16
16
  "files": [
17
17
  "bot.js",
@@ -351,7 +351,21 @@ assert.match(pkg.description, /provider-agnostic coding-agent harness/i);
351
351
  // fix from the chat-central outage, the call-platform batch (outgoing calls,
352
352
  // greetings, TURN via /webrtc/config, ring-phase teardown fixes), the
353
353
  // received-media .bin extension fix, and the tts/transcribe agent verbs.
354
- assert.strictEqual(pkg.version, "3.2.1", "release version must remain unchanged without explicit approval");
354
+ // 3.2.2 approved by Sumeet 2026-08-09 ("Yeh", answering "Want me to build the
355
+ // chip fix now and fold it in? Then a single /restart live-verifies everything
356
+ // and I cut"): the presence-chip batch — thinking-on-receipt (heartbeat starts
357
+ // the moment a turn-bound message arrives, covering enforcer triage/download/
358
+ // STT dead air), tool stretches re-enter "thinking" instead of decaying the
359
+ // chip, /stop emits an explicit typing:stop, and the dedup adjudicator's
360
+ // "dedup" utility purpose registered so the journal dedup gate actually runs.
361
+ // 3.2.3 approved by Sumeet 2026-08-13 ("While we wait can we update open
362
+ // Claudia so a watcher requires a timeout or defaults to something so that
363
+ // when it expires it acts as a wake up so no watcher can silently fail?"):
364
+ // watch expiry — every new watch carries expiresAt (--timeout flag, default
365
+ // 2h); a lapsed deadline with a still-failing check fires the wake anyway as
366
+ // a marked "Watch expired" prompt and removes the watch, so a watch whose
367
+ // condition never comes true can no longer end silently.
368
+ assert.strictEqual(pkg.version, "3.2.3", "release version must remain unchanged without explicit approval");
355
369
  for (const keyword of ["claude", "codex", "coding-agent", "provider-agnostic"]) {
356
370
  assert.ok(pkg.keywords.includes(keyword), `package keyword missing: ${keyword}`);
357
371
  }
@@ -826,6 +826,40 @@ async function watchGateProbe(createSchedulerService) {
826
826
  assert.strictEqual(repeatJob.status, "pending");
827
827
  assert.strictEqual(repeatJob.occurrenceId, "next-occ-1", "repeat watch advances to a new occurrence");
828
828
 
829
+ // Failing check with a FUTURE expiry → still the normal silent idle skip.
830
+ const pending = schedulerHarness(createSchedulerService, [watchJob({ id: "watch-pending", expiresAt: 1_720_000_100_000 })], {
831
+ runWatchCheck: async () => ({ code: 1, timedOut: false }),
832
+ outcomes: [successOutcome()],
833
+ });
834
+ const pendingResult = await pending.service.fireJob("watch-pending");
835
+ assert.strictEqual(pendingResult.status, "watch-idle");
836
+ assert.strictEqual(pending.runCalls.length, 0, "unexpired failing check stays silent");
837
+
838
+ // Failing check with a LAPSED expiry → fires anyway as an expiry wake-up:
839
+ // distinct header, advisory says timed out, removed even though repeat=true.
840
+ const expired = schedulerHarness(createSchedulerService, [watchJob({ id: "watch-expired", repeat: true, expiresAt: 1_720_000_000_000 })], {
841
+ runWatchCheck: async () => ({ code: 1, timedOut: false }),
842
+ outcomes: [successOutcome()],
843
+ });
844
+ const expiredResult = await expired.service.fireJob("watch-expired");
845
+ assert.strictEqual(expiredResult.ok, true);
846
+ assert.strictEqual(expiredResult.status, "succeeded");
847
+ assert.strictEqual(expired.runCalls.length, 1, "expired watch wakes the agent exactly once");
848
+ assert.match(expired.runCalls[0].prompt, /Watch expired/, "expiry fire carries the Watch expired header");
849
+ assert.match(expired.runCalls[0].prompt, /did NOT happen/, "expiry fire tells the agent the condition never came true");
850
+ assert.ok(expired.sends.some((text) => /timed out without triggering/.test(text)), "expiry advisory says it timed out");
851
+ assert.strictEqual(expired.jobsStore.get("watch-expired"), null, "expired watch is removed even when repeat was requested");
852
+
853
+ // Check that passes ON the expiry tick → a normal trigger, not an expiry.
854
+ const lastGasp = schedulerHarness(createSchedulerService, [watchJob({ id: "watch-lastgasp", expiresAt: 1_720_000_000_000 })], {
855
+ runWatchCheck: async () => ({ code: 0, timedOut: false }),
856
+ outcomes: [successOutcome()],
857
+ });
858
+ await lastGasp.service.fireJob("watch-lastgasp");
859
+ assert.strictEqual(lastGasp.runCalls.length, 1);
860
+ assert.match(lastGasp.runCalls[0].prompt, /Watch triggered/, "a passing check at the deadline is a real trigger");
861
+ assert.ok(!/Watch expired/.test(lastGasp.runCalls[0].prompt), "a passing check never reports as expired");
862
+
829
863
  // Global master switch OFF → skip before the check even runs.
830
864
  fs.writeFileSync(watchesEnabledFile, "off");
831
865
  let checkRan = false;
@@ -841,6 +875,45 @@ async function watchGateProbe(createSchedulerService) {
841
875
  fs.rmSync(watchesEnabledFile, { force: true });
842
876
  }
843
877
 
878
+ function watchExpiryStampProbe(createSchedulerService, DEFAULT_WATCH_TIMEOUT_MS) {
879
+ assert.strictEqual(typeof DEFAULT_WATCH_TIMEOUT_MS, "number", "scheduler must export the default watch timeout");
880
+ const state = {
881
+ userId: "fixture-user",
882
+ currentSession: { name: "alpha", dir: "/workspace/alpha" },
883
+ settings: { backend: "claude" },
884
+ activeSessions: { alpha: { claude: "thread-alpha" } },
885
+ providerSettings: { claude: {} },
886
+ };
887
+ const service = createSchedulerService({
888
+ jobsStore: memoryJobsStore([]),
889
+ currentState: () => state,
890
+ now: () => 1_720_000_000_500,
891
+ setTimer: () => ({}),
892
+ clearTimer: () => {},
893
+ scheduleCron: () => ({ stop() {} }),
894
+ });
895
+ const stamped = service.addWatch({
896
+ adapter: "telegram-fixture",
897
+ adapterType: "telegram",
898
+ channelId: "fixture-channel",
899
+ canonicalUserId: "fixture-user",
900
+ check: "true",
901
+ prompt: "watch prompt",
902
+ });
903
+ assert.strictEqual(stamped.expiresAt, 1_720_000_000_500 + DEFAULT_WATCH_TIMEOUT_MS, "watch-add stamps the default expiry when none is given");
904
+ const explicit = service.addWatch({
905
+ adapter: "telegram-fixture",
906
+ adapterType: "telegram",
907
+ channelId: "fixture-channel",
908
+ canonicalUserId: "fixture-user",
909
+ check: "true",
910
+ prompt: "watch prompt 2",
911
+ expiresAt: 1_720_000_999_999,
912
+ });
913
+ assert.strictEqual(explicit.expiresAt, 1_720_000_999_999, "an explicit --timeout expiry passes through unchanged");
914
+ service.stopAll();
915
+ }
916
+
844
917
  function staticSchedulingProbe() {
845
918
  const files = [
846
919
  "core/jobs.js",
@@ -863,6 +936,12 @@ function staticSchedulingProbe() {
863
936
  assert.match(source["core/loopback.js"], /watch-add/, "loopback must route watch-add");
864
937
  assert.match(source["bin/schedule.js"], /runWatchAdd/, "schedule CLI must expose watch-add");
865
938
  assert.match(source["bin/cli.js"], /watch-add/, "cli must dispatch watch-add");
939
+
940
+ // Watch expiry must be wired end to end: default stamp → gate → CLI flag.
941
+ assert.match(source["core/scheduler.js"], /DEFAULT_WATCH_TIMEOUT_MS/, "scheduler must define the default watch timeout");
942
+ assert.match(source["core/scheduler.js"], /watchExpired/, "scheduler must mark expiry fires distinctly");
943
+ assert.match(source["core/loopback.js"], /expiresAt/, "loopback must pass the watch expiry through");
944
+ assert.match(source["bin/schedule.js"], /--timeout/, "watch-add CLI must expose --timeout");
866
945
  }
867
946
 
868
947
  async function main() {
@@ -884,6 +963,7 @@ async function main() {
884
963
  await unavailableAndFallbackProbe(schedulerApi.createSchedulerService);
885
964
  await cronOccurrenceProbe(schedulerApi.createSchedulerService);
886
965
  await watchGateProbe(schedulerApi.createSchedulerService);
966
+ watchExpiryStampProbe(schedulerApi.createSchedulerService, schedulerApi.DEFAULT_WATCH_TIMEOUT_MS);
887
967
  creationEnrichmentProbe(schedulerApi.createSchedulerService);
888
968
  loopbackMetadataProbe(require("./core/runner"), require("./bin/loopback-client"));
889
969
  staticSchedulingProbe();
@@ -185,6 +185,28 @@ async function main() {
185
185
  "legacy Claude model aliases never leak into Codex",
186
186
  );
187
187
 
188
+ // v3.2.2: "dedup" (journal dedup adjudicator) used to be rejected outright
189
+ // with INVALID_UTILITY_PURPOSE, silently disabling the dedup gate on every
190
+ // memory review. It rides the review pipeline: MEMORY_PROVIDER steers it,
191
+ // PACK_DEDUP_MODEL is its legacy Claude model alias.
192
+ assert.ok(
193
+ createUtilityPolicy({ registry: allReady, env: { UTILITY_PROVIDER: "claude" } })
194
+ .resolve({ purpose: "dedup", tier: "low" }).providerId,
195
+ "dedup is a valid utility purpose",
196
+ );
197
+ assert.strictEqual(
198
+ createUtilityPolicy({ registry: allReady, env: { UTILITY_PROVIDER: "claude", MEMORY_PROVIDER: "codex" } })
199
+ .resolve({ purpose: "dedup", tier: "low" }).providerId,
200
+ "codex",
201
+ "dedup follows the review pipeline's MEMORY_PROVIDER override",
202
+ );
203
+ assert.strictEqual(
204
+ createUtilityPolicy({ registry: allReady, env: { UTILITY_PROVIDER: "claude", PACK_DEDUP_MODEL: "legacy-claude-dedup" } })
205
+ .resolve({ purpose: "dedup", tier: "low" }).model,
206
+ "legacy-claude-dedup",
207
+ "dedup honors its PACK_DEDUP_MODEL legacy alias",
208
+ );
209
+
188
210
  const fallbackRegistry = registry(map, {
189
211
  claude: { availability: { state: "missing", executable: null, reason: "fixture unavailable" } },
190
212
  });