@inetafrica/open-claudia 3.1.3 → 3.1.7

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,27 @@
1
1
  # Changelog
2
2
 
3
+ ## v3.1.7 — CI spawn-race hardening (first published build of the v3.1.5 changes)
4
+
5
+ - v3.1.6 also never published. The loaded shared CI runner (node cold-boot 100-370ms vs ~50ms locally) keeps picking off the next-tightest spawn-timing test: this time `test-utility-provider-policy`'s shutdown-drain case, which flat-slept 100ms then cancelled — killing the fake agent before it booted, so its SIGTERM handler never wrote the expected signal capture (pipeline 10383; the v3.1.6 run-lifecycle fix itself passed there).
6
+ - Instead of fixing one test per failed pipeline, audited all 13 fake-agent-spawning test files for the pattern and hardened the three racy ones: `test-utility-provider-policy` (abort and drain cases now wait for the child's boot capture before cancelling; timeout case widened to 2500ms deadline / 400ms kill grace; helper wait deadlines to 15s), `test-provider-enforcer` (timeout verdict widened to 2500ms so the hung child's boot capture and SIGTERM record exist before assertions; kill grace 300ms), `test-provider-subagent` (helper wait deadline to 15s). Semantics unchanged everywhere — the same hangs, kills, aborts, and drains are asserted, just on deterministic readiness signals instead of guessed sleeps.
7
+ - Ships everything listed under v3.1.5 below; no product code changes.
8
+
9
+ ## v3.1.6 — CI test-timing fix (also never published — see v3.1.7)
10
+
11
+ - The v3.1.5 tag never published: its CI build failed twice on `test-provider-run-lifecycle`'s forced-termination case, whose 120ms timeout lost the node-boot race on the loaded shared runner (the fake agent was SIGTERMed before it could write the descendant capture). Widened to 2000ms timeout / 500ms kill grace — semantics unchanged (agent still hangs, is force-killed, and the detached TERM-resistant descendant must survive).
12
+ - Ships everything listed under v3.1.5 below; no other code changes.
13
+
14
+ ## v3.1.5 — Pod-identity hosted speech + dream memory access
15
+
16
+ - **Hosted speech now authenticates with the pod's own identity.** The speech-gateway bearer becomes `AGENTSPACE_POD_TOKEN` (minted per pod at provisioning, injected via the `openclaw-secrets` secret) with `SPEECH_API_KEY` kept as fallback — so provisioned bot pods get Pocket TTS/STT without any shared API key. The gateway introspects the token against the Agent Space backend (`/pods/self/speech-authorization`), which scopes it to `speech.tts`/`speech.stt` for bot pods only. Wired through config, media, router, runner, voice-policy, and `/doctor` (which now reports which credential the speech path is using). New hermetic `test-media-speech.js` covers bearer selection and fallback order.
17
+ - **Dream introspection can read memory again.** The nightly dream pass's introspection step was denied read access to the memory substrate; it now runs with explicit read grants (provider allow-list + utility-agent pass-through), with regressions in the claude-provider and dream test suites.
18
+
19
+ ## v3.1.4 — Preserve Kazee inbound image captions
20
+
21
+ - Maps Kazee media message content onto the transport-neutral inbound `caption` field, so image instructions reach the agent prompt instead of being replaced by the generic image-description fallback.
22
+ - Leaves plain Kazee text and Telegram normalization unchanged.
23
+ - Adds a hermetic adapter regression covering captioned images, captionless images, and ordinary text.
24
+
3
25
  ## v3.1.3 — Pocket TTS voice-note provider
4
26
 
5
27
  - Adds Agent Space Pocket TTS as the preferred voice synthesizer for Telegram and streamed voice replies.
@@ -146,6 +146,11 @@ class KazeeAdapter {
146
146
  ? msg.medias
147
147
  : (msg.media ? [msg.media] : (msg.attachments || []));
148
148
  if (rawMedia.length) {
149
+ // Kazee carries media captions in the same `content` field used for
150
+ // plain text. The image router reads the transport-neutral `caption`
151
+ // field (matching Telegram), so preserve it explicitly on media
152
+ // envelopes instead of silently falling back to "Describe this image".
153
+ envelope.caption = text;
149
154
  envelope.media = rawMedia.map((a) => {
150
155
  let kind = a.type;
151
156
  if (!kind) {
package/core/config.js CHANGED
@@ -143,8 +143,11 @@ const ELEVENLABS_VOICE_ID = config.ELEVENLABS_VOICE_ID || process.env.ELEVENLABS
143
143
  const ELEVENLABS_MODEL = config.ELEVENLABS_MODEL || process.env.ELEVENLABS_MODEL || "eleven_v3";
144
144
  const SPEECH_SERVICE_URL = config.SPEECH_SERVICE_URL || process.env.SPEECH_SERVICE_URL || "https://speech.agents.kazee.africa";
145
145
  const SPEECH_API_KEY = config.SPEECH_API_KEY || process.env.SPEECH_API_KEY || "";
146
+ const AGENTSPACE_POD_TOKEN = config.AGENTSPACE_POD_TOKEN || process.env.AGENTSPACE_POD_TOKEN || "";
146
147
  const POCKET_TTS_VOICE = config.POCKET_TTS_VOICE || process.env.POCKET_TTS_VOICE || "alba";
147
148
  const TTS_PROVIDER = config.TTS_PROVIDER || process.env.TTS_PROVIDER || "pocket";
149
+ const rawVoiceReplyMode = String(config.VOICE_REPLY_MODE || process.env.VOICE_REPLY_MODE || "on-voice-input").trim().toLowerCase();
150
+ const VOICE_REPLY_MODE = ["on-voice-input", "always", "never"].includes(rawVoiceReplyMode) ? rawVoiceReplyMode : "on-voice-input";
148
151
  const SOUL_FILE = config.SOUL_FILE || path.join(CONFIG_DIR, "soul.md");
149
152
  const CRONS_FILE = config.CRONS_FILE || path.join(CONFIG_DIR, "crons.json");
150
153
  const JOBS_FILE = config.JOBS_FILE || path.join(CONFIG_DIR, "jobs.json");
@@ -374,7 +377,7 @@ module.exports = {
374
377
  TRANSCRIPTS_DIR,
375
378
  WHISPER_CLI, WHISPER_MODEL, FFMPEG,
376
379
  ELEVENLABS_API_KEY, ELEVENLABS_VOICE_ID, ELEVENLABS_MODEL,
377
- SPEECH_SERVICE_URL, SPEECH_API_KEY, POCKET_TTS_VOICE, TTS_PROVIDER,
380
+ SPEECH_SERVICE_URL, SPEECH_API_KEY, AGENTSPACE_POD_TOKEN, POCKET_TTS_VOICE, TTS_PROVIDER, VOICE_REPLY_MODE,
378
381
  SOUL_FILE, CRONS_FILE, JOBS_FILE, TASKS_DIR, VAULT_FILE, AUTH_FILE, IDENTITIES_FILE,
379
382
  PEOPLE_FILE, INTROS_FILE, AUDIT_FILE,
380
383
  STATE_FILE, SESSIONS_FILE,
package/core/doctor.js CHANGED
@@ -6,7 +6,7 @@ const os = require("os");
6
6
  const path = require("path");
7
7
  const { execFileSync } = require("child_process");
8
8
  const {
9
- WHISPER_CLI, WHISPER_MODEL, FFMPEG, WORKSPACE, CONFIG_DIR,
9
+ WHISPER_CLI, WHISPER_MODEL, FFMPEG, SPEECH_SERVICE_URL, SPEECH_API_KEY, AGENTSPACE_POD_TOKEN, WORKSPACE, CONFIG_DIR,
10
10
  botSubprocessEnv,
11
11
  } = require("./config");
12
12
  const { redactSensitive } = require("./redact");
@@ -149,14 +149,25 @@ function runDoctorChecks(options = {}) {
149
149
  : "none",
150
150
  action: providerSummary.defaultProvider.providerId ? "" : providerSummary.defaultProvider.reason,
151
151
  });
152
-
153
- if (FFMPEG || WHISPER_CLI || WHISPER_MODEL) {
152
+ const hostedSpeech = !!SPEECH_SERVICE_URL && !!(AGENTSPACE_POD_TOKEN || SPEECH_API_KEY);
153
+ checks.push({
154
+ ok: hostedSpeech,
155
+ warn: !hostedSpeech,
156
+ label: "Hosted speech",
157
+ detail: hostedSpeech ? `${SPEECH_SERVICE_URL} (${AGENTSPACE_POD_TOKEN ? "pod identity" : "API key"})` : "not configured",
158
+ action: hostedSpeech ? "" : "Set AGENTSPACE_POD_TOKEN (managed pods) or SPEECH_API_KEY.",
159
+ });
160
+ if (hostedSpeech) {
161
+ const ff = binaryCheck(FFMPEG || "ffmpeg", "ffmpeg");
162
+ checks.push({ ...ff, label: "Voice reply transcoder", warn: !ff.ok, action: ff.ok ? "" : "Install ffmpeg or set FFMPEG for Ogg/Opus voice replies." });
163
+ }
164
+ if (!hostedSpeech && (FFMPEG || WHISPER_CLI || WHISPER_MODEL)) {
154
165
  const ff = binaryCheck(FFMPEG || "ffmpeg", "ffmpeg");
155
166
  const wh = binaryCheck(WHISPER_CLI, "Whisper CLI");
156
167
  checks.push({ ...ff, action: ff.ok ? "" : "Install ffmpeg or set FFMPEG." });
157
168
  checks.push({ ...wh, action: wh.ok ? "" : "Install whisper.cpp or set WHISPER_CLI." });
158
169
  checks.push({ ok: !!WHISPER_MODEL && fs.existsSync(WHISPER_MODEL), label: "Whisper model", detail: WHISPER_MODEL || "not configured", action: WHISPER_MODEL ? "Check model path." : "Set WHISPER_MODEL for voice notes." });
159
- } else checks.push({ ok: true, warn: true, label: "Voice stack", detail: "not configured (optional)", action: "Set FFMPEG/WHISPER_CLI/WHISPER_MODEL for voice notes." });
170
+ } else if (!hostedSpeech) checks.push({ ok: true, warn: true, label: "Local voice fallback", detail: "not configured (optional)", action: "Set FFMPEG/WHISPER_CLI/WHISPER_MODEL for local voice-note fallback." });
160
171
 
161
172
  checks.push(checkWritableDir(WORKSPACE, "Workspace writable"));
162
173
  checks.push(checkWritableDir(CONFIG_DIR, "Config dir writable"));
package/core/dream.js CHANGED
@@ -390,7 +390,7 @@ function parseDream(text) {
390
390
  entity_notes: arr("entity_notes"),
391
391
  archive: arr("archive"),
392
392
  lessons: arr("lessons"),
393
- persona: typeof obj.persona === "string" ? obj.persona : null,
393
+ persona: typeof obj.persona === "string" && obj.persona.trim().toLowerCase() !== "null" ? obj.persona : null,
394
394
  tuning: obj.tuning && typeof obj.tuning === "object" && obj.tuning.knob ? obj.tuning : null,
395
395
  report: typeof obj.report === "string" ? obj.report.trim() : "",
396
396
  };
@@ -774,7 +774,7 @@ function parseIntrospection(text) {
774
774
  doc_edits: arr("doc_edits"),
775
775
  entity_edits: arr("entity_edits"),
776
776
  ideas: arr("ideas"),
777
- persona: typeof obj.persona === "string" ? obj.persona : null,
777
+ persona: typeof obj.persona === "string" && obj.persona.trim().toLowerCase() !== "null" ? obj.persona : null,
778
778
  report: typeof obj.report === "string" ? obj.report.trim() : "",
779
779
  };
780
780
  } catch (e) { return null; }
@@ -1018,7 +1018,14 @@ function negativeHebbianPhase(ev, { cap = 12 } = {}) {
1018
1018
  return { lines, nodes, weakened, pruned };
1019
1019
  }
1020
1020
 
1021
- function dreamUtilityOptions(purpose, { provider = null, timeoutMs, outputSchema, systemPrompt, cwd = process.cwd() } = {}) {
1021
+ function dreamUtilityOptions(purpose, {
1022
+ provider = null,
1023
+ timeoutMs,
1024
+ outputSchema,
1025
+ systemPrompt,
1026
+ cwd = process.cwd(),
1027
+ additionalReadOnlyDirectories = null,
1028
+ } = {}) {
1022
1029
  return {
1023
1030
  purpose,
1024
1031
  provider,
@@ -1030,6 +1037,7 @@ function dreamUtilityOptions(purpose, { provider = null, timeoutMs, outputSchema
1030
1037
  effort: DREAM_EFFORT,
1031
1038
  timeoutMs,
1032
1039
  cwd,
1040
+ additionalReadOnlyDirectories,
1033
1041
  systemPrompt,
1034
1042
  };
1035
1043
  }
@@ -1202,6 +1210,7 @@ async function runDreamMutation({ trigger = "manual", provider = null, runUtilit
1202
1210
  const introRun = await runUtility(buildIntrospectionPrompt(), dreamUtilityOptions("introspection", {
1203
1211
  provider,
1204
1212
  cwd: CODE_ROOT,
1213
+ additionalReadOnlyDirectories: [CONFIG_DIR],
1205
1214
  timeoutMs: 12 * 60 * 1000,
1206
1215
  outputSchema: INTROSPECTION_OUTPUT_SCHEMA,
1207
1216
  systemPrompt: "You are Open Claudia's nightly self-introspection. You may READ files (your code, memory, soul) to understand yourself, but read-only mode forbids product side effects. After investigating, reply with ONLY the requested JSON object — no prose, no code fences.",
package/core/media.js CHANGED
@@ -4,11 +4,15 @@
4
4
  const fs = require("fs");
5
5
  const path = require("path");
6
6
  const { execSync } = require("child_process");
7
- const { WHISPER_CLI, WHISPER_MODEL, FFMPEG, TEMP_DIR, ELEVENLABS_API_KEY, ELEVENLABS_VOICE_ID, ELEVENLABS_MODEL, SPEECH_SERVICE_URL, SPEECH_API_KEY, POCKET_TTS_VOICE, TTS_PROVIDER } = require("./config");
7
+ const { WHISPER_CLI, WHISPER_MODEL, FFMPEG, TEMP_DIR, ELEVENLABS_API_KEY, ELEVENLABS_VOICE_ID, ELEVENLABS_MODEL, SPEECH_SERVICE_URL, SPEECH_API_KEY, AGENTSPACE_POD_TOKEN, POCKET_TTS_VOICE, TTS_PROVIDER } = require("./config");
8
8
 
9
9
  const TTS_CMD = process.platform === "darwin" ? "say" : null;
10
10
 
11
- function transcribeAudio(oggPath) {
11
+ function speechBearerToken() {
12
+ return AGENTSPACE_POD_TOKEN || SPEECH_API_KEY || "";
13
+ }
14
+
15
+ function transcribeAudioLocal(oggPath) {
12
16
  if (!WHISPER_CLI || !FFMPEG) return null;
13
17
  const wavPath = oggPath.replace(/\.[^.]+$/, ".wav");
14
18
  execSync(`"${FFMPEG}" -i "${oggPath}" -ar 16000 -ac 1 -y "${wavPath}" 2>/dev/null`);
@@ -19,6 +23,30 @@ function transcribeAudio(oggPath) {
19
23
  .join(" ").trim();
20
24
  }
21
25
 
26
+ async function transcribeAudio(oggPath) {
27
+ const token = speechBearerToken();
28
+ if (SPEECH_SERVICE_URL && token) {
29
+ try {
30
+ const body = new FormData();
31
+ body.append("file", new Blob([fs.readFileSync(oggPath)]), path.basename(oggPath));
32
+ const res = await fetch(`${SPEECH_SERVICE_URL.replace(/\/$/, "")}/v1/audio/transcriptions`, {
33
+ method: "POST",
34
+ headers: { Authorization: `Bearer ${token}` },
35
+ body,
36
+ signal: AbortSignal.timeout(120000),
37
+ });
38
+ if (res.ok) {
39
+ const payload = await res.json();
40
+ return String(payload.text || "").trim();
41
+ }
42
+ console.error(`Hosted STT failed: HTTP ${res.status}`);
43
+ } catch (e) {
44
+ console.error("Hosted STT error:", e.message);
45
+ }
46
+ }
47
+ return transcribeAudioLocal(oggPath);
48
+ }
49
+
22
50
  function cleanForTTS(text) {
23
51
  return text.replace(/[*_`#>\[\]()]/g, "").replace(/\n{2,}/g, ". ").replace(/\n/g, " ").trim();
24
52
  }
@@ -73,12 +101,13 @@ async function elevenLabsToVoice(clean) {
73
101
  // Pocket TTS through the Agent Space speech gateway. The gateway returns WAV;
74
102
  // Telegram and voice transports receive a compact Ogg/Opus file.
75
103
  async function pocketToVoice(clean) {
76
- if (TTS_PROVIDER === "elevenlabs" || !SPEECH_API_KEY || !SPEECH_SERVICE_URL || !FFMPEG) return null;
104
+ const token = speechBearerToken();
105
+ if (TTS_PROVIDER === "elevenlabs" || !token || !SPEECH_SERVICE_URL || !FFMPEG) return null;
77
106
  let wavPath = null;
78
107
  try {
79
108
  const res = await fetch(`${SPEECH_SERVICE_URL.replace(/\/$/, "")}/v1/audio/speech`, {
80
109
  method: "POST",
81
- headers: { Authorization: `Bearer ${SPEECH_API_KEY}`, "Content-Type": "application/json" },
110
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
82
111
  body: JSON.stringify({ input: clean, voice: POCKET_TTS_VOICE, response_format: "wav" }),
83
112
  signal: AbortSignal.timeout(60000),
84
113
  });
@@ -165,4 +194,4 @@ async function synthSentenceMp3(text) {
165
194
  }
166
195
  }
167
196
 
168
- module.exports = { transcribeAudio, textToVoice, splitSentences, synthSentenceMp3, pocketToVoice, TTS_CMD };
197
+ module.exports = { transcribeAudio, transcribeAudioLocal, textToVoice, splitSentences, synthSentenceMp3, pocketToVoice, speechBearerToken, TTS_CMD };
@@ -135,6 +135,20 @@ function appendToolLists(args, context) {
135
135
  }
136
136
  }
137
137
 
138
+ function appendReadOnlyDirectories(args, context) {
139
+ if (context.additionalReadOnlyDirectories === undefined || context.additionalReadOnlyDirectories === null) return;
140
+ if (!context.readOnly) {
141
+ throw providerError("INVALID_PROVIDER_CONTEXT", "additionalReadOnlyDirectories requires readOnly mode");
142
+ }
143
+ if (!Array.isArray(context.additionalReadOnlyDirectories)
144
+ || context.additionalReadOnlyDirectories.some((dir) => typeof dir !== "string" || !dir.trim())) {
145
+ throw providerError("INVALID_PROVIDER_CONTEXT", "additionalReadOnlyDirectories must be an array of non-empty strings");
146
+ }
147
+ for (const dir of new Set(context.additionalReadOnlyDirectories.map((entry) => entry.trim()))) {
148
+ args.push("--add-dir", dir);
149
+ }
150
+ }
151
+
138
152
  function createClaudeProvider(options = {}) {
139
153
  const resolveExecutable = options.resolveExecutable || (() => config.discoverProviderExecutable("claude"));
140
154
  const defaultModel = options.defaultModel || CLAUDE_DEFAULT_MODEL;
@@ -195,6 +209,7 @@ function createClaudeProvider(options = {}) {
195
209
  || context.transcriptPaths?.directory
196
210
  || null;
197
211
  if (transcriptDirectory) args.push("--add-dir", String(transcriptDirectory));
212
+ if (utility) appendReadOnlyDirectories(args, context);
198
213
 
199
214
  if (!utility) appendSessionArgs(args, context);
200
215
  appendModelAndLimits(args, context, defaultModel, utility);
package/core/router.js CHANGED
@@ -193,7 +193,7 @@ async function handleVoice(envelope) {
193
193
  const tStart = Date.now();
194
194
  const oggPath = await envelope.adapter.downloadMedia(media);
195
195
  const tAfterDownload = Date.now();
196
- const transcript = transcribeAudio(oggPath);
196
+ const transcript = await transcribeAudio(oggPath);
197
197
  const tAfterStt = Date.now();
198
198
  try { fs.unlinkSync(oggPath); } catch (e) {}
199
199
  if (!transcript) return send("Couldn't transcribe. Try typing it.");
@@ -222,7 +222,7 @@ async function handleAudio(envelope) {
222
222
  });
223
223
  envelope.adapter.typing(envelope.channelId).catch(() => {});
224
224
  const p = await envelope.adapter.downloadMedia(media);
225
- const t = transcribeAudio(p);
225
+ const t = await transcribeAudio(p);
226
226
  try { fs.unlinkSync(p); } catch (e) {}
227
227
  if (!t) return send("Couldn't transcribe.");
228
228
  await send(`Heard: "${t}"`, { replyTo: envelope.messageId });
package/core/runner.js CHANGED
@@ -1654,7 +1654,10 @@ async function deliverForegroundResult(result, runContext, state = currentState(
1654
1654
  if (!sent.ok) throw new Error(`failed to deliver response chunk ${index + 1}`);
1655
1655
  }
1656
1656
 
1657
- if (result.ok && runContext.lastInputWasVoice) {
1657
+ const { VOICE_REPLY_MODE } = require("./config");
1658
+ const { shouldReplyWithVoice } = require("./voice-policy");
1659
+ const shouldSpeak = shouldReplyWithVoice(VOICE_REPLY_MODE, runContext.lastInputWasVoice);
1660
+ if (result.ok && shouldSpeak) {
1658
1661
  const { currentTransport } = require("./context");
1659
1662
  if (currentTransport() === "voice") {
1660
1663
  const sentences = splitSentences(finalText);
@@ -1668,6 +1671,9 @@ async function deliverForegroundResult(result, runContext, state = currentState(
1668
1671
  if (voicePath) await sendVoice(voicePath);
1669
1672
  }
1670
1673
  await sendVoiceEnd();
1674
+ } else {
1675
+ const voicePath = await textToVoice(finalText);
1676
+ if (voicePath) await sendVoice(voicePath);
1671
1677
  }
1672
1678
  }
1673
1679
  return { ok: true, chunks: chunks.length };
@@ -536,6 +536,9 @@ function createUtilityAgentService(options = {}) {
536
536
  fresh: true,
537
537
  readOnly: request.readOnly !== false,
538
538
  cwd: request.cwd || process.cwd(),
539
+ ...(request.additionalReadOnlyDirectories !== undefined
540
+ ? { additionalReadOnlyDirectories: request.additionalReadOnlyDirectories }
541
+ : {}),
539
542
  ...(request.allowedTools !== undefined ? { allowedTools: request.allowedTools } : {}),
540
543
  ...(request.disallowedTools !== undefined ? { disallowedTools: request.disallowedTools } : {}),
541
544
  ...(request.outputSchema ? { outputSchema: request.outputSchema, outputSchemaPath: schemaResource.file } : {}),
@@ -0,0 +1,7 @@
1
+ "use strict";
2
+
3
+ function shouldReplyWithVoice(mode, lastInputWasVoice) {
4
+ return mode === "always" || (mode === "on-voice-input" && !!lastInputWasVoice);
5
+ }
6
+
7
+ module.exports = { shouldReplyWithVoice };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "3.1.3",
3
+ "version": "3.1.7",
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": {
@@ -9,7 +9,7 @@
9
9
  "scripts": {
10
10
  "setup": "node setup.js",
11
11
  "start": "node bot.js",
12
- "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-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",
12
+ "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-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",
13
13
  "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-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-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"
14
14
  },
15
15
  "files": [
@@ -161,15 +161,28 @@ const utility = validateInvocation(provider.buildUtilityInvocation(deepFreeze({
161
161
  tier: "low",
162
162
  maxTurns: 2,
163
163
  toolHookSettings: "/fixture/utility-hook.json",
164
+ additionalReadOnlyDirectories: ["/fixture/memory", "/fixture/memory"],
164
165
  })));
165
166
  assert.ok(utility.args.includes("--no-session-persistence"));
166
167
  assert.deepStrictEqual(utility.args.slice(utility.args.indexOf("--allowedTools"), utility.args.indexOf("--allowedTools") + 2), ["--allowedTools", "Read,Glob"]);
167
168
  assert.deepStrictEqual(utility.args.slice(utility.args.indexOf("--permission-mode"), utility.args.indexOf("--permission-mode") + 2), ["--permission-mode", "plan"]);
169
+ assert.strictEqual(utility.args.filter((arg) => arg === "/fixture/memory").length, 1, "read-only utility directories are deduplicated");
170
+ assert.deepStrictEqual(utility.args.slice(utility.args.indexOf("/fixture/memory") - 1, utility.args.indexOf("/fixture/memory") + 1), ["--add-dir", "/fixture/memory"]);
168
171
  assert.strictEqual(utility.args[utility.args.indexOf("--model") + 1], "claude-haiku-4-5-20251001");
169
172
  assert.strictEqual(utility.args[utility.args.indexOf("--json-schema") + 1], JSON.stringify({ type: "object", required: ["ok"] }));
170
173
  assert.strictEqual(utility.stdin, "UTILITY PROMPT", "utility prompt uses protected stdin transport");
171
174
  assert.ok(!utility.args.includes("UTILITY PROMPT"), "utility prompt is absent from argv");
172
175
 
176
+ assert.throws(
177
+ () => provider.buildUtilityInvocation(simpleContext({ additionalReadOnlyDirectories: ["/fixture/memory"] })),
178
+ (error) => error && error.code === "INVALID_PROVIDER_CONTEXT",
179
+ "extra readable directories cannot expand a writable utility invocation",
180
+ );
181
+ assert.throws(
182
+ () => provider.buildUtilityInvocation(simpleContext({ readOnly: true, additionalReadOnlyDirectories: [""] })),
183
+ (error) => error && error.code === "INVALID_PROVIDER_CONTEXT",
184
+ );
185
+
173
186
  const decoder = provider.createEventDecoder();
174
187
  const raw = decoder.push(Buffer.from('{"type":"system","subtype":"init","session_id":"bound-session"}\n'));
175
188
  assert.deepStrictEqual(provider.normalizeEvent(raw[0], richInvocation.parserState), [{ type: "session", sessionId: "bound-session" }]);
@@ -148,6 +148,8 @@ async function providerProbe(kind) {
148
148
  readOnly: options.readOnly,
149
149
  hasSchema: !!options.outputSchema,
150
150
  effort: options.effort,
151
+ cwd: options.cwd,
152
+ additionalReadOnlyDirectories: options.additionalReadOnlyDirectories,
151
153
  promptBytes: Buffer.byteLength(prompt),
152
154
  });
153
155
  return utility.spawnUtilityAgent(prompt, options);
@@ -199,6 +201,11 @@ async function providerProbe(kind) {
199
201
  assert.strictEqual(call.effort, "max");
200
202
  assert.ok(call.promptBytes > 100);
201
203
  }
204
+ assert.strictEqual(calls[0].additionalReadOnlyDirectories, null, "consolidation does not gain memory filesystem access");
205
+ assert.strictEqual(calls[1].additionalReadOnlyDirectories, null, "applied consolidation does not gain memory filesystem access");
206
+ assert.deepStrictEqual(calls[2].additionalReadOnlyDirectories, [process.env.OPEN_CLAUDIA_CONFIG_DIR],
207
+ "introspection explicitly receives the memory config directory");
208
+ assert.strictEqual(calls[2].cwd, path.resolve(__dirname), "introspection remains rooted in the source tree");
202
209
 
203
210
  const project = packs.readPack("project");
204
211
  const entity = entities.findEntity("Parity Harness");
@@ -241,6 +248,12 @@ async function providerProbe(kind) {
241
248
  if (kind === "claude") {
242
249
  assert.ok(record.argv.includes("--json-schema"));
243
250
  assert.ok(record.argv.includes("max"), "Claude retains maximum dream effort");
251
+ if (purpose === "introspection") {
252
+ const memoryIndex = record.argv.indexOf(process.env.OPEN_CLAUDIA_CONFIG_DIR);
253
+ assert.ok(memoryIndex > 0, "Claude introspection can read memory outside its cwd");
254
+ assert.strictEqual(record.argv[memoryIndex - 1], "--add-dir");
255
+ assert.ok(record.argv.includes("plan"), "extra memory access does not expand the read-only boundary");
256
+ }
244
257
  } else {
245
258
  assert.ok(record.argv.includes("--output-schema"));
246
259
  assert.ok(record.argv.some((arg) => /model_reasoning_effort=.*max/.test(arg)), "max effort passes through natively since codex 0.144 supports it");
@@ -269,6 +282,9 @@ async function providerProbe(kind) {
269
282
  assert.match(failed.report, /skipped the AI-led merges/i);
270
283
  assert.strictEqual(packs.readPack("family-child").parent, "family", "deterministic filing survives provider failure");
271
284
 
285
+ assert.strictEqual(dream.parseDream('{"persona":"null"}').persona, null, "stringified JSON null is a persona no-op");
286
+ assert.strictEqual(dream.parseIntrospection('{"persona":" null "}').persona, null, "introspection stringified null is a persona no-op");
287
+
272
288
  return {
273
289
  provider: kind,
274
290
  mutations: {
@@ -87,7 +87,7 @@ async function providerProbe(kind) {
87
87
  const utility = createUtilityAgentService({
88
88
  registry: registry([entry]),
89
89
  env: { ENFORCER_PROVIDER: kind, [`ENFORCER_MODEL_${kind.toUpperCase()}`]: model },
90
- killGraceMs: 30,
90
+ killGraceMs: 300,
91
91
  });
92
92
  const enforcer = require("./core/enforcer");
93
93
  assert.strictEqual(typeof enforcer.createEnforcerService, "function", "enforcer exposes an injectable utility-provider service");
@@ -106,7 +106,9 @@ async function providerProbe(kind) {
106
106
  const timeoutService = enforcer.createEnforcerService({
107
107
  spawnUtilityAgent: utility.spawnUtilityAgent,
108
108
  audit,
109
- timeoutMs: 140,
109
+ // 2500ms so the hung child boots on a loaded CI runner (writing capture #5
110
+ // and arming its SIGTERM recorder) before the deadline fires.
111
+ timeoutMs: 2500,
110
112
  });
111
113
  const timedOut = await timeoutService.vetReply(speaker, "timeout verdict fixture");
112
114
 
@@ -157,10 +157,29 @@ assert.match(pkg.description, /provider-agnostic coding-agent harness/i);
157
157
  // never re-offers a sign-in); a 🔁 Switch account button on connected
158
158
  // providers in /login behind an explicit confirm that names the signed-in
159
159
  // account where the CLI exposes it, then chains logout → fresh login.
160
- // 3.1.3 approved by Sumeet 2026-07-21 ("ok fine go ahead") for Pocket TTS
160
+ // 3.1.4 approved by Sumeet 2026-07-21 for the scoped Kazee inbound image-caption fix,
161
+ // including commit, push, release and deployment.
161
162
  // as the preferred voice-note provider, with ElevenLabs fallback and local
162
163
  // Whisper transcription unchanged.
163
- assert.strictEqual(pkg.version, "3.1.3", "release version must remain unchanged without explicit approval");
164
+ // 3.1.5 approved by Sumeet 2026-07-21 ("Yeh" to "Want me to cut 3.1.5 and get
165
+ // going?"): ships pod-identity hosted speech (bearer = AGENTSPACE_POD_TOKEN
166
+ // with SPEECH_API_KEY fallback) so provisioned bot pods can use the Agent
167
+ // Space speech gateway without a shared API key, plus the dream introspection
168
+ // memory-read fix. Release unblocks the fleet pod-voice rollout (canary
169
+ // smart-brenda first).
170
+ // 3.1.6 approved by Sumeet 2026-07-21 ("Go"): identical content to 3.1.5
171
+ // (which CI never published — its build died twice on a 120ms node-boot race
172
+ // in test-provider-run-lifecycle's forced-termination case on the loaded
173
+ // shared runner) plus the test-timing fix (2000ms/500ms). Never retag a
174
+ // failed version — re-cut as +1.
175
+ // 3.1.7 approved by Sumeet 2026-07-21: still the v3.1.5 content, third cut.
176
+ // The 3.1.6 build died on the NEXT tightest spawn race (pipeline 10383:
177
+ // test-utility-provider-policy's drain case flat-slept 100ms then cancelled,
178
+ // killing the fake agent pre-boot so drain-signal.json never appeared; the
179
+ // v3.1.6 run-lifecycle fix itself passed there). Audited all 13 fake-agent
180
+ // test files and hardened the three racy ones (utility-policy, enforcer,
181
+ // subagent): wait on boot captures / generous deadlines, never tight sleeps.
182
+ assert.strictEqual(pkg.version, "3.1.7", "release version must remain unchanged without explicit approval");
164
183
  for (const keyword of ["claude", "codex", "coding-agent", "provider-agnostic"]) {
165
184
  assert.ok(pkg.keywords.includes(keyword), `package keyword missing: ${keyword}`);
166
185
  }
@@ -342,8 +342,10 @@ async function main() {
342
342
  parserState: createClaudeParserState(),
343
343
  },
344
344
  cwd: processTreeRoot,
345
- timeoutMs: 120,
346
- killGraceMs: 250,
345
+ // 120ms lost the node-boot race on loaded CI runners: the parent was
346
+ // SIGTERMed before spawnDescendantFixture ever ran (pipeline 10381, 2x).
347
+ timeoutMs: 2000,
348
+ killGraceMs: 500,
347
349
  cleanup: async () => {},
348
350
  });
349
351
  const treeResult = await realTreeRun;
@@ -25,7 +25,8 @@ const { HELP, parseAgentArgs } = require("./bin/agent");
25
25
 
26
26
  function wait(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }
27
27
 
28
- async function waitForFile(file, timeoutMs = 2000) {
28
+ // Loaded CI runners stretch node cold-boot far past local timings (pipelines 10381/10383).
29
+ async function waitForFile(file, timeoutMs = 15000) {
29
30
  const started = Date.now();
30
31
  while (!fs.existsSync(file)) {
31
32
  if (Date.now() - started > timeoutMs) throw new Error(`timed out waiting for ${path.basename(file)}`);
@@ -30,7 +30,9 @@ function wait(ms) {
30
30
  return new Promise((resolve) => setTimeout(resolve, ms));
31
31
  }
32
32
 
33
- async function waitForFile(file, timeoutMs = 2000) {
33
+ // Loaded CI runners stretch node cold-boot far past local timings (pipelines
34
+ // 10381/10383 died on 100-370ms budgets), so capture waits get generous deadlines.
35
+ async function waitForFile(file, timeoutMs = 15000) {
34
36
  const started = Date.now();
35
37
  while (!fs.existsSync(file)) {
36
38
  if (Date.now() - started > timeoutMs) throw new Error(`timed out waiting for ${path.basename(file)}`);
@@ -38,7 +40,7 @@ async function waitForFile(file, timeoutMs = 2000) {
38
40
  }
39
41
  }
40
42
 
41
- async function waitForPidExit(pid, timeoutMs = 2000) {
43
+ async function waitForPidExit(pid, timeoutMs = 15000) {
42
44
  const started = Date.now();
43
45
  while (Date.now() - started <= timeoutMs) {
44
46
  try { process.kill(pid, 0); }
@@ -414,12 +416,14 @@ async function main() {
414
416
  },
415
417
  })),
416
418
  env: { UTILITY_PROVIDER: "claude" },
417
- killGraceMs: 40,
419
+ killGraceMs: 400,
418
420
  });
419
421
  const timeoutStarted = Date.now();
420
422
  await assert.rejects(
421
423
  timeoutService.spawnUtilityAgent("timeout", {
422
- purpose: "subagent", systemPrompt: "timeout", timeoutMs: 180, cwd: root,
424
+ // 2500ms so the child boots (arming its SIGTERM recorder and spawning
425
+ // its descendant) on a loaded runner before the deadline fires.
426
+ purpose: "subagent", systemPrompt: "timeout", timeoutMs: 2500, cwd: root,
423
427
  }),
424
428
  (error) => {
425
429
  assert.ok(error instanceof UtilityAgentError);
@@ -429,7 +433,7 @@ async function main() {
429
433
  return true;
430
434
  },
431
435
  );
432
- assert.ok(Date.now() - timeoutStarted >= 180, "timeout settles only after its deadline and process close");
436
+ assert.ok(Date.now() - timeoutStarted >= 2500, "timeout settles only after its deadline and process close");
433
437
  await waitForFile(signalCapture);
434
438
  assert.strictEqual(JSON.parse(fs.readFileSync(signalCapture, "utf8")).signal, "SIGTERM");
435
439
  await waitForFile(descendantCapture);
@@ -437,9 +441,14 @@ async function main() {
437
441
  assert.strictEqual(timeoutService.stats().activeGlobal, 0, "timeout cleanup releases concurrency only after close");
438
442
 
439
443
  const abortCapture = path.join(root, "abort-signal.json");
444
+ const abortBootCapture = path.join(root, "abort-boot.json");
440
445
  const abortService = createUtilityAgentService({
441
446
  registry: registry(providers({
442
- codex: { FAKE_AGENT_SCENARIO: "forced-termination", FAKE_AGENT_SIGNAL_CAPTURE: abortCapture },
447
+ codex: {
448
+ FAKE_AGENT_SCENARIO: "forced-termination",
449
+ FAKE_AGENT_SIGNAL_CAPTURE: abortCapture,
450
+ FAKE_AGENT_CAPTURE: abortBootCapture,
451
+ },
443
452
  })),
444
453
  env: { UTILITY_PROVIDER: "codex" },
445
454
  });
@@ -447,25 +456,34 @@ async function main() {
447
456
  const aborted = abortService.spawnUtilityAgent("abort", {
448
457
  purpose: "recall", systemPrompt: "abort", signal: controller.signal, cwd: root,
449
458
  });
450
- await wait(100);
459
+ // Abort only after the child demonstrably booted (boot capture exists) so
460
+ // the TERM lands on an armed signal handler, not a cold spawn.
461
+ await waitForFile(abortBootCapture);
451
462
  controller.abort();
452
463
  await assertRejectsCode(aborted, "UTILITY_CANCELLED");
453
464
  await waitForFile(abortCapture);
454
465
 
455
466
  const drainCapture = path.join(root, "drain-signal.json");
467
+ const drainBootCapture = path.join(root, "drain-boot.json");
456
468
  const drainService = createUtilityAgentService({
457
469
  registry: registry(providers({
458
- claude: { FAKE_AGENT_SCENARIO: "forced-termination", FAKE_AGENT_SIGNAL_CAPTURE: drainCapture },
470
+ claude: {
471
+ FAKE_AGENT_SCENARIO: "forced-termination",
472
+ FAKE_AGENT_SIGNAL_CAPTURE: drainCapture,
473
+ FAKE_AGENT_CAPTURE: drainBootCapture,
474
+ },
459
475
  })),
460
476
  env: { UTILITY_PROVIDER: "claude" },
461
- killGraceMs: 40,
477
+ killGraceMs: 400,
462
478
  });
463
479
  const drainingRun = drainService.spawnUtilityAgent("shutdown drain", {
464
480
  purpose: "review", systemPrompt: "drain", cwd: root,
465
481
  });
466
482
  const drainingAssertion = assertRejectsCode(drainingRun, "UTILITY_CANCELLED");
467
- await wait(100);
468
- const drained = await drainService.cancelAll({ timeoutMs: 1000 });
483
+ // Cancel only after the child booted: pipeline 10383 showed a flat 100ms
484
+ // sleep kills it pre-boot, so its TERM handler never writes drain-signal.json.
485
+ await waitForFile(drainBootCapture);
486
+ const drained = await drainService.cancelAll({ timeoutMs: 5000 });
469
487
  await drainingAssertion;
470
488
  assert.deepStrictEqual(drained, { drained: true, active: 0 }, "bounded shutdown cancellation waits for utility child cleanup");
471
489
  await waitForFile(drainCapture);