@inetafrica/open-claudia 3.1.4 → 3.1.8
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 +23 -0
- package/bin/cli.js +19 -0
- package/core/config.js +7 -2
- package/core/doctor.js +15 -4
- package/core/dream.js +12 -3
- package/core/media.js +43 -5
- package/core/providers/claude.js +15 -0
- package/core/router.js +2 -2
- package/core/runner.js +7 -1
- package/core/system-prompt.js +6 -2
- package/core/utility-agent.js +3 -0
- package/core/voice-policy.js +7 -0
- package/package.json +1 -1
- package/test-provider-claude.js +13 -0
- package/test-provider-dream.js +16 -0
- package/test-provider-enforcer.js +4 -2
- package/test-provider-language.js +26 -1
- package/test-provider-run-lifecycle.js +4 -2
- package/test-provider-subagent.js +2 -1
- package/test-utility-provider-policy.js +29 -11
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v3.1.8 — Hosted pods can actually send voice notes
|
|
4
|
+
|
|
5
|
+
- **FFMPEG is discovered on PATH.** Provisioned pods ship `/usr/bin/ffmpeg` but never set `FFMPEG=` in config, and `config.js` read only the explicit setting — so `FFMPEG` resolved empty and every TTS route (`pocketToVoice`, ElevenLabs, `say`) silently returned null: voice input got text-only replies. `FFMPEG` now falls back to PATH discovery via the existing `executableCandidate` helper; an explicit `FFMPEG=` still wins.
|
|
6
|
+
- **The system prompt's voice-notes flag reflects real TTS capability.** It gated on `WHISPER_CLI && FFMPEG` — the pre-gateway local-Whisper world — so whisper-less hosted pods told the model "Voice notes: disabled" and it refused user requests (the smart-euvy report), even with working gateway speech. New `media.canSpeak()` mirrors the actual pocket → ElevenLabs → macOS `say` fallback chain and now drives the flag; local Whisper is irrelevant to speaking.
|
|
7
|
+
- **New `open-claudia say "<text>"` CLI verb.** `send-voice` needs a ready-made ogg and the model had no way to synthesize one on request. `say` runs `textToVoice` and sends the result through the loopback voice path (same egress guardrail), and is documented in the Delivery prompt block.
|
|
8
|
+
- Regression coverage in `test-media-speech.js`: a whisper-less pod shape must report `canSpeak() === true`, the prompt gate must key on it, and FFMPEG resolution precedence (explicit > PATH discovery > empty) is asserted via subprocess config loads.
|
|
9
|
+
|
|
10
|
+
## v3.1.7 — CI spawn-race hardening (first published build of the v3.1.5 changes)
|
|
11
|
+
|
|
12
|
+
- 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).
|
|
13
|
+
- 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.
|
|
14
|
+
- Ships everything listed under v3.1.5 below; no product code changes.
|
|
15
|
+
|
|
16
|
+
## v3.1.6 — CI test-timing fix (also never published — see v3.1.7)
|
|
17
|
+
|
|
18
|
+
- 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).
|
|
19
|
+
- Ships everything listed under v3.1.5 below; no other code changes.
|
|
20
|
+
|
|
21
|
+
## v3.1.5 — Pod-identity hosted speech + dream memory access
|
|
22
|
+
|
|
23
|
+
- **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.
|
|
24
|
+
- **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.
|
|
25
|
+
|
|
3
26
|
## v3.1.4 — Preserve Kazee inbound image captions
|
|
4
27
|
|
|
5
28
|
- 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.
|
package/bin/cli.js
CHANGED
|
@@ -277,6 +277,24 @@ switch (command) {
|
|
|
277
277
|
break;
|
|
278
278
|
}
|
|
279
279
|
|
|
280
|
+
case "say": {
|
|
281
|
+
const text = args.slice(1).join(" ").trim();
|
|
282
|
+
if (!text) {
|
|
283
|
+
console.error('Usage: open-claudia say "<text>"');
|
|
284
|
+
process.exit(2);
|
|
285
|
+
}
|
|
286
|
+
(async () => {
|
|
287
|
+
const { textToVoice } = require(path.join(botDir, "core", "media"));
|
|
288
|
+
const voicePath = await textToVoice(text);
|
|
289
|
+
if (!voicePath) {
|
|
290
|
+
console.error("No TTS route available — configure the speech gateway, ElevenLabs, or run on macOS.");
|
|
291
|
+
process.exit(1);
|
|
292
|
+
}
|
|
293
|
+
sendViaLoopback("send-voice", [voicePath]);
|
|
294
|
+
})();
|
|
295
|
+
break;
|
|
296
|
+
}
|
|
297
|
+
|
|
280
298
|
case "tw":
|
|
281
299
|
case "transcript-window": {
|
|
282
300
|
require("./transcript-window").run(args.slice(1));
|
|
@@ -441,6 +459,7 @@ Send tools (only work inside an active bot-spawned task):
|
|
|
441
459
|
open-claudia send-file <path> [caption]
|
|
442
460
|
open-claudia send-photo <path> [caption]
|
|
443
461
|
open-claudia send-voice <path>
|
|
462
|
+
open-claudia say "<text>" Synthesize the text (TTS) and send it as a voice note
|
|
444
463
|
|
|
445
464
|
Memory tools:
|
|
446
465
|
open-claudia transcript-search <query> Ranked FTS5 search over indexed transcripts
|
package/core/config.js
CHANGED
|
@@ -137,14 +137,19 @@ const TRANSCRIPT_MAX_ENTRY_CHARS = parseInt(config.TRANSCRIPT_MAX_ENTRY_CHARS ||
|
|
|
137
137
|
const TRANSCRIPTS_DIR = config.TRANSCRIPTS_DIR || process.env.TRANSCRIPTS_DIR || path.join(CONFIG_DIR, "transcripts");
|
|
138
138
|
const WHISPER_CLI = config.WHISPER_CLI || "";
|
|
139
139
|
const WHISPER_MODEL = config.WHISPER_MODEL || "";
|
|
140
|
-
|
|
140
|
+
// Hosted pods ship /usr/bin/ffmpeg but never set FFMPEG=, so without PATH
|
|
141
|
+
// discovery every TTS route silently no-ops (smart-euvy "voice notes disabled").
|
|
142
|
+
const FFMPEG = config.FFMPEG || executableCandidate("ffmpeg") || "";
|
|
141
143
|
const ELEVENLABS_API_KEY = config.ELEVENLABS_API_KEY || process.env.ELEVENLABS_API_KEY || "";
|
|
142
144
|
const ELEVENLABS_VOICE_ID = config.ELEVENLABS_VOICE_ID || process.env.ELEVENLABS_VOICE_ID || "EXAVITQu4vr4xnSDxMaL";
|
|
143
145
|
const ELEVENLABS_MODEL = config.ELEVENLABS_MODEL || process.env.ELEVENLABS_MODEL || "eleven_v3";
|
|
144
146
|
const SPEECH_SERVICE_URL = config.SPEECH_SERVICE_URL || process.env.SPEECH_SERVICE_URL || "https://speech.agents.kazee.africa";
|
|
145
147
|
const SPEECH_API_KEY = config.SPEECH_API_KEY || process.env.SPEECH_API_KEY || "";
|
|
148
|
+
const AGENTSPACE_POD_TOKEN = config.AGENTSPACE_POD_TOKEN || process.env.AGENTSPACE_POD_TOKEN || "";
|
|
146
149
|
const POCKET_TTS_VOICE = config.POCKET_TTS_VOICE || process.env.POCKET_TTS_VOICE || "alba";
|
|
147
150
|
const TTS_PROVIDER = config.TTS_PROVIDER || process.env.TTS_PROVIDER || "pocket";
|
|
151
|
+
const rawVoiceReplyMode = String(config.VOICE_REPLY_MODE || process.env.VOICE_REPLY_MODE || "on-voice-input").trim().toLowerCase();
|
|
152
|
+
const VOICE_REPLY_MODE = ["on-voice-input", "always", "never"].includes(rawVoiceReplyMode) ? rawVoiceReplyMode : "on-voice-input";
|
|
148
153
|
const SOUL_FILE = config.SOUL_FILE || path.join(CONFIG_DIR, "soul.md");
|
|
149
154
|
const CRONS_FILE = config.CRONS_FILE || path.join(CONFIG_DIR, "crons.json");
|
|
150
155
|
const JOBS_FILE = config.JOBS_FILE || path.join(CONFIG_DIR, "jobs.json");
|
|
@@ -374,7 +379,7 @@ module.exports = {
|
|
|
374
379
|
TRANSCRIPTS_DIR,
|
|
375
380
|
WHISPER_CLI, WHISPER_MODEL, FFMPEG,
|
|
376
381
|
ELEVENLABS_API_KEY, ELEVENLABS_VOICE_ID, ELEVENLABS_MODEL,
|
|
377
|
-
SPEECH_SERVICE_URL, SPEECH_API_KEY, POCKET_TTS_VOICE, TTS_PROVIDER,
|
|
382
|
+
SPEECH_SERVICE_URL, SPEECH_API_KEY, AGENTSPACE_POD_TOKEN, POCKET_TTS_VOICE, TTS_PROVIDER, VOICE_REPLY_MODE,
|
|
378
383
|
SOUL_FILE, CRONS_FILE, JOBS_FILE, TASKS_DIR, VAULT_FILE, AUTH_FILE, IDENTITIES_FILE,
|
|
379
384
|
PEOPLE_FILE, INTROS_FILE, AUDIT_FILE,
|
|
380
385
|
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
|
-
|
|
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: "
|
|
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, {
|
|
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
|
|
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
|
-
|
|
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 ${
|
|
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
|
});
|
|
@@ -100,6 +129,15 @@ async function pocketToVoice(clean) {
|
|
|
100
129
|
}
|
|
101
130
|
}
|
|
102
131
|
|
|
132
|
+
// True when at least one textToVoice route can produce audio. Mirrors the
|
|
133
|
+
// pocket -> ElevenLabs -> say fallback chain; local Whisper is deliberately
|
|
134
|
+
// irrelevant here (hosted pods speak via the gateway without it).
|
|
135
|
+
function canSpeak() {
|
|
136
|
+
if (!FFMPEG) return false;
|
|
137
|
+
if (TTS_PROVIDER !== "elevenlabs" && speechBearerToken() && SPEECH_SERVICE_URL) return true;
|
|
138
|
+
return Boolean(ELEVENLABS_API_KEY || TTS_CMD);
|
|
139
|
+
}
|
|
140
|
+
|
|
103
141
|
// Natural voice via ElevenLabs, falling back to macOS `say` only on no-key/error.
|
|
104
142
|
async function textToVoice(text) {
|
|
105
143
|
const clean = cleanForTTS(text);
|
|
@@ -165,4 +203,4 @@ async function synthSentenceMp3(text) {
|
|
|
165
203
|
}
|
|
166
204
|
}
|
|
167
205
|
|
|
168
|
-
module.exports = { transcribeAudio, textToVoice, splitSentences, synthSentenceMp3, pocketToVoice, TTS_CMD };
|
|
206
|
+
module.exports = { transcribeAudio, transcribeAudioLocal, textToVoice, splitSentences, synthSentenceMp3, pocketToVoice, speechBearerToken, canSpeak, TTS_CMD };
|
package/core/providers/claude.js
CHANGED
|
@@ -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
|
-
|
|
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 };
|
package/core/system-prompt.js
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
|
|
4
4
|
const fs = require("fs");
|
|
5
5
|
const path = require("path");
|
|
6
|
-
const { SOUL_FILE, CRONS_FILE, VAULT_FILE, FILES_DIR, BOT_DIR,
|
|
6
|
+
const { SOUL_FILE, CRONS_FILE, VAULT_FILE, FILES_DIR, BOT_DIR, config } = require("./config");
|
|
7
|
+
const { canSpeak } = require("./media");
|
|
7
8
|
const CONFIG_DIR = require("../config-dir");
|
|
8
9
|
const { currentState, getProjectKey, getProviderSession } = require("./state");
|
|
9
10
|
const { currentAdapter, currentChannelId, currentUserId } = require("./context");
|
|
@@ -198,7 +199,9 @@ ${listing}`;
|
|
|
198
199
|
|
|
199
200
|
function buildCoreInstructionsUncached() {
|
|
200
201
|
const soul = loadSoul();
|
|
201
|
-
|
|
202
|
+
// Gate on actual TTS capability, not local Whisper: hosted pods hear and
|
|
203
|
+
// speak through the speech gateway with no whisper binary installed.
|
|
204
|
+
const hasVoice = canSpeak();
|
|
202
205
|
return `
|
|
203
206
|
${soul}
|
|
204
207
|
${buildPersonaBlock()}
|
|
@@ -230,6 +233,7 @@ Reply normally in your final answer. To send a file, image, or voice clip back t
|
|
|
230
233
|
- \`open-claudia send-file <path> [caption]\` — any document/binary
|
|
231
234
|
- \`open-claudia send-photo <path> [caption]\` — image with inline preview
|
|
232
235
|
- \`open-claudia send-voice <path>\` — ogg/opus voice note
|
|
236
|
+
- \`open-claudia say "<text>"\` — synthesize the text and send it as a voice note (use this when asked for a voice reply)
|
|
233
237
|
Never print or embed bot tokens in prompts, commands, logs, or messages.
|
|
234
238
|
|
|
235
239
|
## Background work
|
package/core/utility-agent.js
CHANGED
|
@@ -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 } : {}),
|
package/package.json
CHANGED
package/test-provider-claude.js
CHANGED
|
@@ -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" }]);
|
package/test-provider-dream.js
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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
|
|
|
@@ -161,7 +161,32 @@ assert.match(pkg.description, /provider-agnostic coding-agent harness/i);
|
|
|
161
161
|
// including commit, push, release and deployment.
|
|
162
162
|
// as the preferred voice-note provider, with ElevenLabs fallback and local
|
|
163
163
|
// Whisper transcription unchanged.
|
|
164
|
-
|
|
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
|
+
// 3.1.8 approved by Sumeet 2026-07-21 ("Yeh"): pod voice-note fix. Pods ship
|
|
183
|
+
// /usr/bin/ffmpeg but never set FFMPEG=, so every TTS route silently no-opped,
|
|
184
|
+
// and the system-prompt voice flag still gated on local Whisper — hosted bots
|
|
185
|
+
// told users voice notes were disabled despite a working speech gateway.
|
|
186
|
+
// Fix: FFMPEG PATH discovery in config, media.canSpeak() drives the prompt
|
|
187
|
+
// flag, and a new `open-claudia say "<text>"` verb synthesizes + sends a
|
|
188
|
+
// voice note on request.
|
|
189
|
+
assert.strictEqual(pkg.version, "3.1.8", "release version must remain unchanged without explicit approval");
|
|
165
190
|
for (const keyword of ["claude", "codex", "coding-agent", "provider-agnostic"]) {
|
|
166
191
|
assert.ok(pkg.keywords.includes(keyword), `package keyword missing: ${keyword}`);
|
|
167
192
|
}
|
|
@@ -342,8 +342,10 @@ async function main() {
|
|
|
342
342
|
parserState: createClaudeParserState(),
|
|
343
343
|
},
|
|
344
344
|
cwd: processTreeRoot,
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
419
|
+
killGraceMs: 400,
|
|
418
420
|
});
|
|
419
421
|
const timeoutStarted = Date.now();
|
|
420
422
|
await assert.rejects(
|
|
421
423
|
timeoutService.spawnUtilityAgent("timeout", {
|
|
422
|
-
|
|
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 >=
|
|
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: {
|
|
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
|
-
|
|
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: {
|
|
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:
|
|
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
|
-
|
|
468
|
-
|
|
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);
|