@ouro.bot/cli 0.1.0-alpha.726 → 0.1.0-alpha.728

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.
Files changed (35) hide show
  1. package/README.md +14 -1
  2. package/assets/bluebubbles-host +264 -0
  3. package/changelog.json +18 -0
  4. package/dist/heart/config.js +1 -2
  5. package/dist/heart/core.js +181 -209
  6. package/dist/heart/daemon/bluebubbles-cli-integration.js +71 -0
  7. package/dist/heart/daemon/bluebubbles-host-protocol.js +433 -0
  8. package/dist/heart/daemon/bluebubbles-host.js +273 -0
  9. package/dist/heart/daemon/cli-defaults.js +8 -1
  10. package/dist/heart/daemon/cli-exec.js +239 -34
  11. package/dist/heart/daemon/cli-help.js +17 -6
  12. package/dist/heart/daemon/cli-parse.js +69 -1
  13. package/dist/heart/daemon/cli-render.js +7 -0
  14. package/dist/heart/daemon/daemon.js +79 -14
  15. package/dist/heart/daemon/doctor.js +21 -1
  16. package/dist/heart/daemon/mcp-canary.js +160 -29
  17. package/dist/heart/daemon/process-manager.js +100 -18
  18. package/dist/heart/daemon/socket-client.js +28 -19
  19. package/dist/heart/providers/anthropic.js +17 -12
  20. package/dist/heart/streaming.js +6 -6
  21. package/dist/heart/versioning/ouro-path-installer.js +30 -24
  22. package/dist/heart/versioning/ouro-recovery-launcher.js +76 -0
  23. package/dist/heart/versioning/version-intent.js +114 -0
  24. package/dist/mind/prompt-budget.js +104 -5
  25. package/dist/repertoire/tools.js +0 -10
  26. package/dist/senses/bluebubbles/client.js +43 -57
  27. package/dist/senses/bluebubbles/context-packet.js +104 -19
  28. package/dist/senses/bluebubbles/entry.js +14 -1
  29. package/dist/senses/bluebubbles/index.js +1398 -719
  30. package/dist/senses/bluebubbles/latest-turn.js +311 -0
  31. package/dist/senses/bluebubbles/outbound-state.js +4 -0
  32. package/dist/senses/bluebubbles/reaction-policy.js +4 -12
  33. package/dist/senses/bluebubbles/semantic-receipts.js +178 -10
  34. package/dist/senses/bluebubbles/webhook-registration.js +228 -0
  35. package/package.json +4 -2
@@ -33,7 +33,10 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.DEFAULT_CANARY_TIMEOUT_MS = void 0;
36
+ exports.SETUP_CANARY_TIMEOUT_MS = exports.DEFAULT_CANARY_TIMEOUT_MS = void 0;
37
+ exports.classifyMcpBoundary = classifyMcpBoundary;
38
+ exports.sanitizeMcpCanaryText = sanitizeMcpCanaryText;
39
+ exports.sanitizeMcpCanaryArgs = sanitizeMcpCanaryArgs;
37
40
  exports.buildMcpBridgeRepairGuidance = buildMcpBridgeRepairGuidance;
38
41
  exports.formatMcpBridgeRepairDetails = formatMcpBridgeRepairDetails;
39
42
  exports.buildMcpDoctorNextSteps = buildMcpDoctorNextSteps;
@@ -47,7 +50,40 @@ const child_process_1 = require("child_process");
47
50
  const path = __importStar(require("path"));
48
51
  const runtime_1 = require("../../nerves/runtime");
49
52
  exports.DEFAULT_CANARY_TIMEOUT_MS = 60_000;
53
+ exports.SETUP_CANARY_TIMEOUT_MS = 10_000;
50
54
  const MCP_PROTOCOL_VERSION = "2024-11-05";
55
+ function classifyMcpBoundary(input) {
56
+ if (!input.bridgeHealthy)
57
+ return "ouro-bridge-failed";
58
+ return input.hostStallObserved ? "host-stall-unexplained" : "ouro-bridge-healthy-at-capture";
59
+ }
60
+ function sanitizeMcpCanaryText(text) {
61
+ return text
62
+ .replace(/(https?:\/\/[^\s?]+)\?[^\s]*/g, "$1?[redacted]")
63
+ .replace(/"(?:password|token|secret|api[_-]?key)"\s*:\s*"[^"]*"/gi, '"credential":"[redacted]"')
64
+ .replace(/\bBearer\s+[^\s]+/gi, "Bearer [redacted]")
65
+ .replace(/--(?:password|token|secret|api[_-]?key)(\s+|=)[^\s]+/gi, "--credential$1[redacted]")
66
+ .replace(/\b(?:password|token|secret|api[_-]?key)=([^\s]+)/gi, "credential=[redacted]")
67
+ .trim();
68
+ }
69
+ function sanitizeMcpCanaryArgs(args) {
70
+ const sanitized = [];
71
+ let redactNext = false;
72
+ for (const arg of args) {
73
+ if (redactNext) {
74
+ sanitized.push("[redacted]");
75
+ redactNext = false;
76
+ continue;
77
+ }
78
+ if (/^--(?:password|token|secret|api[_-]?key)$/i.test(arg)) {
79
+ sanitized.push("--credential");
80
+ redactNext = true;
81
+ continue;
82
+ }
83
+ sanitized.push(sanitizeMcpCanaryText(arg));
84
+ }
85
+ return sanitized;
86
+ }
51
87
  function defaultCommandArgs(agent, socketPath) {
52
88
  const entryPath = path.join(__dirname, "ouro-bot-entry.js");
53
89
  return [
@@ -58,18 +94,32 @@ function defaultCommandArgs(agent, socketPath) {
58
94
  ...(socketPath ? ["--socket", socketPath] : []),
59
95
  ];
60
96
  }
61
- function responseText(response) {
62
- const result = response.result;
63
- if (!result || typeof result !== "object" || Array.isArray(result))
64
- return JSON.stringify(response);
65
- const content = result.content;
66
- if (!Array.isArray(content))
67
- return JSON.stringify(response);
68
- const first = content[0];
69
- if (!first || typeof first !== "object" || Array.isArray(first))
70
- return JSON.stringify(response);
71
- const text = first.text;
72
- return typeof text === "string" ? text : JSON.stringify(response);
97
+ function isRecord(value) {
98
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
99
+ }
100
+ function requireInitializeResult(response) {
101
+ if (!isRecord(response.result)) {
102
+ throw new Error("MCP canary received invalid initialize result");
103
+ }
104
+ }
105
+ function requireStatusText(response) {
106
+ if (!isRecord(response.result)) {
107
+ throw new Error("MCP canary received invalid status result");
108
+ }
109
+ if (response.result.isError === true) {
110
+ const errorContent = response.result.content;
111
+ const errorFirst = Array.isArray(errorContent) ? errorContent[0] : undefined;
112
+ const errorText = isRecord(errorFirst) && typeof errorFirst.text === "string"
113
+ ? errorFirst.text
114
+ : "MCP status tool returned an error";
115
+ throw new Error(errorText);
116
+ }
117
+ const content = response.result.content;
118
+ const first = Array.isArray(content) ? content[0] : undefined;
119
+ if (!isRecord(first) || typeof first.text !== "string") {
120
+ throw new Error("MCP canary received invalid status result");
121
+ }
122
+ return first.text;
73
123
  }
74
124
  function parseFields(line) {
75
125
  const parsed = {};
@@ -214,7 +264,23 @@ function validateMcpStatus(parsed, requiredSenses, options = {}) {
214
264
  };
215
265
  }
216
266
  async function runMcpStatusCanary(options) {
267
+ const startedAt = Date.now();
217
268
  const timeoutMs = options.timeoutMs ?? exports.DEFAULT_CANARY_TIMEOUT_MS;
269
+ const deadlineAt = startedAt + timeoutMs;
270
+ const evidence = {
271
+ capturedAt: new Date(startedAt).toISOString(),
272
+ durationMs: 0,
273
+ childPid: null,
274
+ phase: "spawn",
275
+ exitCode: null,
276
+ exitSignal: null,
277
+ stderr: "",
278
+ };
279
+ const decorate = (result, bridgeHealthy = false) => ({
280
+ ...result,
281
+ classification: classifyMcpBoundary({ bridgeHealthy, hostStallObserved: options.hostStallObserved === true }),
282
+ evidence,
283
+ });
218
284
  /* v8 ignore next -- default spawn is exercised by live canaries, while unit tests inject a fake child @preserve */
219
285
  const spawnImpl = options.spawnImpl ?? child_process_1.spawn;
220
286
  const command = options.command ?? process.execPath;
@@ -227,17 +293,38 @@ async function runMcpStatusCanary(options) {
227
293
  meta: {
228
294
  agent: options.agent,
229
295
  command,
230
- commandArgs,
296
+ commandArgs: sanitizeMcpCanaryArgs(commandArgs),
231
297
  timeoutMs,
232
298
  requiredSenses,
233
299
  ignoreOverviewHealth: options.ignoreOverviewHealth === true,
234
300
  ignoreSenseHealth: options.ignoreSenseHealth === true,
235
301
  },
236
302
  });
237
- const child = spawnImpl(command, commandArgs, { stdio: ["pipe", "pipe", "pipe"] });
303
+ let child;
304
+ try {
305
+ child = spawnImpl(command, commandArgs, { stdio: ["pipe", "pipe", "pipe"] });
306
+ evidence.childPid = child.pid ?? null;
307
+ }
308
+ catch (error) {
309
+ const reason = sanitizeMcpCanaryText(error instanceof Error ? error.message : String(error));
310
+ evidence.durationMs = Date.now() - startedAt;
311
+ (0, runtime_1.emitNervesEvent)({
312
+ component: "daemon",
313
+ event: "daemon.mcp_canary_error",
314
+ level: "error",
315
+ message: "MCP status canary failed to spawn",
316
+ meta: { agent: options.agent, reason },
317
+ });
318
+ return decorate({ ok: false, summary: `mcp canary failed: ${reason}`, details: [reason] });
319
+ }
238
320
  let buffer = "";
239
321
  let stderr = "";
240
322
  const pending = new Map();
323
+ let resolveExit;
324
+ const exitObserved = new Promise((resolve) => { resolveExit = resolve; });
325
+ function safeStderr() {
326
+ return sanitizeMcpCanaryText(stderr);
327
+ }
241
328
  function cleanup() {
242
329
  for (const [, request] of pending) {
243
330
  clearTimeout(request.timer);
@@ -268,14 +355,19 @@ async function runMcpStatusCanary(options) {
268
355
  buffer = buffer.slice(idx + 1);
269
356
  if (!line)
270
357
  continue;
271
- let response;
358
+ let parsedResponse;
272
359
  try {
273
- response = JSON.parse(line);
360
+ parsedResponse = JSON.parse(line);
274
361
  }
275
362
  catch {
276
363
  failAll(new Error(`MCP canary received malformed JSON: ${line}`));
277
364
  return;
278
365
  }
366
+ if (!isRecord(parsedResponse)) {
367
+ failAll(new Error("MCP canary received malformed JSON-RPC response"));
368
+ return;
369
+ }
370
+ const response = parsedResponse;
279
371
  const id = typeof response.id === "number" ? response.id : null;
280
372
  if (id === null)
281
373
  continue;
@@ -284,14 +376,27 @@ async function runMcpStatusCanary(options) {
284
376
  continue;
285
377
  pending.delete(id);
286
378
  clearTimeout(request.timer);
379
+ const hasResult = Object.prototype.hasOwnProperty.call(response, "result");
380
+ const hasError = Object.prototype.hasOwnProperty.call(response, "error");
381
+ if (response.jsonrpc !== "2.0" || (!hasResult && !hasError)) {
382
+ request.reject(new Error("MCP canary received malformed JSON-RPC response"));
383
+ continue;
384
+ }
385
+ if (hasError) {
386
+ request.reject(new Error(`MCP canary received JSON-RPC error: ${JSON.stringify(response.error)}`));
387
+ continue;
388
+ }
287
389
  request.resolve(response);
288
390
  }
289
391
  });
290
392
  child.on("error", (error) => failAll(error));
291
393
  child.on("close", (code, signal) => {
394
+ evidence.exitCode = code;
395
+ evidence.exitSignal = signal;
396
+ resolveExit();
292
397
  if (pending.size === 0)
293
398
  return;
294
- failAll(new Error(`MCP canary process closed before response code=${code} signal=${signal ?? "none"} stderr=${stderr.trim()}`));
399
+ failAll(new Error(`MCP canary process closed before response code=${code} signal=${signal ?? "none"} stderr=${safeStderr()}`));
295
400
  });
296
401
  let nextId = 1;
297
402
  function request(method, params) {
@@ -300,36 +405,43 @@ async function runMcpStatusCanary(options) {
300
405
  reject(new Error("MCP canary stdin is not writable"));
301
406
  return;
302
407
  }
408
+ const remainingMs = deadlineAt - Date.now();
409
+ if (remainingMs <= 0) {
410
+ reject(new Error(`MCP canary timed out waiting for ${method}; stderr=${safeStderr()}`));
411
+ return;
412
+ }
303
413
  const id = nextId++;
304
414
  const timer = setTimeout(() => {
305
415
  pending.delete(id);
306
- reject(new Error(`MCP canary timed out waiting for ${method}; stderr=${stderr.trim()}`));
307
- }, timeoutMs);
416
+ reject(new Error(`MCP canary timed out waiting for ${method}; stderr=${safeStderr()}`));
417
+ }, remainingMs);
308
418
  pending.set(id, { resolve, reject, timer });
309
419
  child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
310
420
  });
311
421
  }
312
422
  try {
313
- await request("initialize", {
423
+ evidence.phase = "initialize";
424
+ const initializeResponse = await request("initialize", {
314
425
  protocolVersion: MCP_PROTOCOL_VERSION,
315
426
  capabilities: {},
316
427
  clientInfo: { name: "ouro-mcp-canary", version: "1.0" },
317
428
  });
429
+ requireInitializeResult(initializeResponse);
318
430
  child.stdin?.write(JSON.stringify({ jsonrpc: "2.0", method: "initialized" }) + "\n");
431
+ evidence.phase = "status";
319
432
  const statusResponse = await request("tools/call", {
320
433
  name: "status",
321
434
  arguments: {},
322
435
  });
323
- const result = statusResponse.result;
324
- if (result && typeof result === "object" && !Array.isArray(result) && result.isError === true) {
325
- throw new Error(responseText(statusResponse));
326
- }
327
- const parsed = parseMcpStatusText(responseText(statusResponse));
436
+ const statusText = requireStatusText(statusResponse);
437
+ const bridgeHealthy = true;
438
+ const parsed = parseMcpStatusText(sanitizeMcpCanaryText(statusText));
328
439
  const canary = validateMcpStatus(parsed, requiredSenses, {
329
440
  agent: options.agent,
330
441
  ignoreOverviewHealth: options.ignoreOverviewHealth,
331
442
  ignoreSenseHealth: options.ignoreSenseHealth,
332
443
  });
444
+ evidence.phase = "complete";
333
445
  (0, runtime_1.emitNervesEvent)({
334
446
  component: "daemon",
335
447
  event: canary.ok ? "daemon.mcp_canary_end" : "daemon.mcp_canary_error",
@@ -337,10 +449,10 @@ async function runMcpStatusCanary(options) {
337
449
  message: canary.summary,
338
450
  meta: { agent: options.agent, ok: canary.ok },
339
451
  });
340
- return canary;
452
+ return decorate(canary, bridgeHealthy);
341
453
  }
342
454
  catch (error) {
343
- const reason = error instanceof Error ? error.message : String(error);
455
+ const reason = sanitizeMcpCanaryText(error instanceof Error ? error.message : String(error));
344
456
  (0, runtime_1.emitNervesEvent)({
345
457
  component: "daemon",
346
458
  event: "daemon.mcp_canary_error",
@@ -348,16 +460,30 @@ async function runMcpStatusCanary(options) {
348
460
  message: "MCP status canary failed",
349
461
  meta: { agent: options.agent, reason },
350
462
  });
351
- return { ok: false, summary: `mcp canary failed: ${reason}`, details: [reason] };
463
+ return decorate({ ok: false, summary: `mcp canary failed: ${reason}`, details: [reason] });
352
464
  }
353
465
  finally {
354
466
  child.stdin?.end();
355
467
  cleanup();
468
+ let exitTimer;
469
+ const remainingExitWaitMs = Math.min(100, Math.max(0, deadlineAt - Date.now()));
470
+ await Promise.race([
471
+ exitObserved,
472
+ new Promise((resolve) => { exitTimer = setTimeout(resolve, remainingExitWaitMs); }),
473
+ ]);
474
+ clearTimeout(exitTimer);
475
+ evidence.stderr = safeStderr();
476
+ evidence.durationMs = Date.now() - startedAt;
356
477
  }
357
478
  }
358
479
  function formatMcpStatusCanaryResult(result) {
359
480
  return [
360
481
  result.ok ? "mcp canary: ok" : "mcp canary: failed",
482
+ ...(result.classification ? [`classification: ${result.classification}`] : []),
483
+ ...(result.evidence ? [
484
+ `captured=${result.evidence.capturedAt} durationMs=${result.evidence.durationMs} childPid=${result.evidence.childPid ?? "none"} phase=${result.evidence.phase} exitCode=${result.evidence.exitCode ?? "none"} exitSignal=${result.evidence.exitSignal ?? "none"}`,
485
+ ...(result.evidence.stderr ? [`stderr=${result.evidence.stderr}`] : []),
486
+ ] : []),
361
487
  result.summary,
362
488
  ...result.details.map((line) => ` ${line}`),
363
489
  ].join("\n");
@@ -368,6 +494,11 @@ function formatMcpStatusDoctorResult(result, agent) {
368
494
  && !line.startsWith("verify command="));
369
495
  const lines = [
370
496
  result.ok ? "mcp doctor: ok" : "mcp doctor: failed",
497
+ ...(result.classification ? [`classification: ${result.classification}`] : []),
498
+ ...(result.evidence ? [
499
+ `captured=${result.evidence.capturedAt} durationMs=${result.evidence.durationMs} childPid=${result.evidence.childPid ?? "none"} phase=${result.evidence.phase} exitCode=${result.evidence.exitCode ?? "none"} exitSignal=${result.evidence.exitSignal ?? "none"}`,
500
+ ...(result.evidence.stderr ? [`stderr=${result.evidence.stderr}`] : []),
501
+ ] : []),
371
502
  result.summary,
372
503
  ...rawDetails.map((line) => ` ${line}`),
373
504
  ];
@@ -56,6 +56,9 @@ exports.RESPAWN_GUARD_WINDOW_MS = 10 * 60_000;
56
56
  const MAX_PENDING_IPC_MESSAGES = 20;
57
57
  class DaemonProcessManager {
58
58
  agents = new Map();
59
+ /** `stopAll()` is terminal for a manager instance. Once set, it fences
60
+ * detached restart/start continuations until the owning daemon exits. */
61
+ shutdownRequested = false;
59
62
  maxRestartsPerHour;
60
63
  stabilityThresholdMs;
61
64
  initialBackoffMs;
@@ -67,6 +70,7 @@ class DaemonProcessManager {
67
70
  cooldownRecoveryMs;
68
71
  maxCooldownRetries;
69
72
  startupStaleAfterMs;
73
+ stopTimeoutMs;
70
74
  existsSyncFn;
71
75
  configCheckFn;
72
76
  statusWriterFn;
@@ -143,6 +147,7 @@ class DaemonProcessManager {
143
147
  this.cooldownRecoveryMs = options.cooldownRecoveryMs ?? 5 * 60 * 1_000;
144
148
  this.maxCooldownRetries = options.maxCooldownRetries ?? 3;
145
149
  this.startupStaleAfterMs = options.startupStaleAfterMs ?? 45_000;
150
+ this.stopTimeoutMs = options.stopTimeoutMs ?? 4_000;
146
151
  this.spawnFn = options.spawn ?? ((command, args, spawnOptions) => (0, child_process_1.spawn)(command, args, spawnOptions));
147
152
  this.now = options.now ?? (() => Date.now());
148
153
  this.setTimeoutFn = options.setTimeoutFn ?? ((cb, delay) => setTimeout(cb, delay));
@@ -169,6 +174,7 @@ class DaemonProcessManager {
169
174
  cooldownTimer: null,
170
175
  cooldownRetryCount: 0,
171
176
  fastCrashCount: 0,
177
+ stopFailureActive: false,
172
178
  snapshot: {
173
179
  name: agent.name,
174
180
  channel: agent.channel,
@@ -206,6 +212,8 @@ class DaemonProcessManager {
206
212
  }
207
213
  async startAgent(agent, options = {}) {
208
214
  const state = this.requireAgent(agent);
215
+ if (this.shutdownRequested)
216
+ return;
209
217
  if (state.process || state.startInFlight)
210
218
  return;
211
219
  const attemptId = state.startAttemptId + 1;
@@ -426,25 +434,87 @@ class DaemonProcessManager {
426
434
  return;
427
435
  }
428
436
  const child = state.process;
429
- state.process = null;
430
- state.snapshot.status = "stopped";
431
- state.snapshot.pid = null;
432
- try {
433
- child.kill("SIGTERM");
434
- }
435
- catch {
436
- (0, runtime_1.emitNervesEvent)({
437
- level: "warn",
438
- component: "daemon",
439
- event: "daemon.agent_stop_error",
440
- message: "failed to send SIGTERM to managed agent",
441
- meta: { agent },
442
- });
443
- }
444
- this.notifySnapshotChange(state.snapshot);
437
+ const childPid = child.pid ?? null;
438
+ const childPidLabel = childPid === null ? "unknown" : String(childPid);
439
+ await new Promise((resolve, reject) => {
440
+ let settled = false;
441
+ let timeout = null;
442
+ const finish = (error) => {
443
+ /* v8 ignore next -- defensive idempotence if an injected timer fires after cancellation @preserve */
444
+ if (settled)
445
+ return;
446
+ settled = true;
447
+ child.removeListener("exit", onExit);
448
+ if (timeout !== null)
449
+ this.clearTimeoutFn(timeout);
450
+ if (error)
451
+ reject(error);
452
+ else
453
+ resolve();
454
+ };
455
+ const onExit = () => finish();
456
+ child.once("exit", onExit);
457
+ let signalAccepted = false;
458
+ try {
459
+ signalAccepted = child.kill("SIGTERM");
460
+ }
461
+ catch {
462
+ const errorReason = "failed to send SIGTERM to managed agent; worker remains tracked";
463
+ state.stopFailureActive = true;
464
+ state.snapshot.errorReason = errorReason;
465
+ state.snapshot.fixHint = `Inspect managed worker PID ${childPidLabel} before retrying the stop or restart.`;
466
+ (0, runtime_1.emitNervesEvent)({
467
+ level: "error",
468
+ component: "daemon",
469
+ event: "daemon.agent_stop_error",
470
+ message: errorReason,
471
+ meta: { agent, pid: childPid },
472
+ });
473
+ this.notifySnapshotChange(state.snapshot);
474
+ finish(new Error(errorReason));
475
+ return;
476
+ }
477
+ if (!signalAccepted) {
478
+ const errorReason = "managed agent rejected SIGTERM; worker remains tracked";
479
+ state.stopFailureActive = true;
480
+ state.snapshot.errorReason = errorReason;
481
+ state.snapshot.fixHint = `Inspect managed worker PID ${childPidLabel} before retrying the stop or restart.`;
482
+ (0, runtime_1.emitNervesEvent)({
483
+ level: "error",
484
+ component: "daemon",
485
+ event: "daemon.agent_stop_error",
486
+ message: errorReason,
487
+ meta: { agent, pid: childPid },
488
+ });
489
+ this.notifySnapshotChange(state.snapshot);
490
+ finish(new Error(errorReason));
491
+ return;
492
+ }
493
+ if (settled)
494
+ return;
495
+ timeout = this.setTimeoutFn(() => {
496
+ if (settled)
497
+ return;
498
+ const errorReason = `managed agent did not exit within ${this.stopTimeoutMs}ms; replacement was not started`;
499
+ state.stopFailureActive = true;
500
+ state.snapshot.errorReason = errorReason;
501
+ state.snapshot.fixHint = `Inspect managed worker PID ${childPidLabel}; retry only after that worker has exited.`;
502
+ (0, runtime_1.emitNervesEvent)({
503
+ level: "error",
504
+ component: "daemon",
505
+ event: "daemon.agent_stop_timeout",
506
+ message: errorReason,
507
+ meta: { agent, pid: childPid, timeoutMs: this.stopTimeoutMs },
508
+ });
509
+ this.notifySnapshotChange(state.snapshot);
510
+ finish(new Error(errorReason));
511
+ }, this.stopTimeoutMs);
512
+ });
445
513
  }
446
514
  async restartAgent(agent, options = {}) {
447
515
  const state = this.requireAgent(agent);
516
+ if (this.shutdownRequested)
517
+ return;
448
518
  // Respawn-loop guard: prune timestamps outside the window, then check
449
519
  // whether we've already restarted this agent too many times in it.
450
520
  const now = this.now();
@@ -528,8 +598,14 @@ class DaemonProcessManager {
528
598
  await this.startAgent(agent, options);
529
599
  }
530
600
  async stopAll() {
531
- for (const state of this.agents.values()) {
532
- await this.stopAgent(state.config.name);
601
+ this.shutdownRequested = true;
602
+ const states = [...this.agents.values()];
603
+ const results = await Promise.allSettled(states.map((state) => this.stopAgent(state.config.name)));
604
+ const failures = results.flatMap((result, index) => result.status === "rejected"
605
+ ? [{ agent: states[index].config.name, reason: result.reason }]
606
+ : []);
607
+ if (failures.length > 0) {
608
+ throw new AggregateError(failures.map((failure) => failure.reason), `failed to stop ${failures.length} managed worker(s): ${failures.map((failure) => failure.agent).join(", ")}`);
533
609
  }
534
610
  }
535
611
  resetAgentFailureState(agent) {
@@ -582,6 +658,7 @@ class DaemonProcessManager {
582
658
  return [...this.agents.values()].map((state) => state.snapshot);
583
659
  }
584
660
  onExit(state, child, code, signal) {
661
+ /* v8 ignore next -- defensive: replacement cannot start before this child's one-shot exit listener drains @preserve */
585
662
  if (state.process !== child)
586
663
  return;
587
664
  state.process = null;
@@ -590,6 +667,11 @@ class DaemonProcessManager {
590
667
  state.snapshot.pid = null;
591
668
  state.snapshot.lastExitCode = code;
592
669
  state.snapshot.lastSignal = signal;
670
+ if (state.stopFailureActive) {
671
+ state.stopFailureActive = false;
672
+ state.snapshot.errorReason = null;
673
+ state.snapshot.fixHint = null;
674
+ }
593
675
  const crashed = !state.stopRequested && code !== 0;
594
676
  const now = this.currentTimeMs();
595
677
  const startedAt = state.snapshot.startedAt ? Date.parse(state.snapshot.startedAt) : now;
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.DEFAULT_DAEMON_COMMAND_TIMEOUT_MS = exports.DEFAULT_DAEMON_SOCKET_PATH = void 0;
36
+ exports.DEFAULT_DAEMON_STATUS_TIMEOUT_MS = exports.DEFAULT_DAEMON_COMMAND_TIMEOUT_MS = exports.DEFAULT_DAEMON_SOCKET_PATH = void 0;
37
37
  exports.__bypassVitestGuardForTests = __bypassVitestGuardForTests;
38
38
  exports.sendDaemonCommand = sendDaemonCommand;
39
39
  exports.checkDaemonSocketAlive = checkDaemonSocketAlive;
@@ -44,6 +44,7 @@ const net = __importStar(require("net"));
44
44
  const runtime_1 = require("../../nerves/runtime");
45
45
  exports.DEFAULT_DAEMON_SOCKET_PATH = "/tmp/ouroboros-daemon.sock";
46
46
  exports.DEFAULT_DAEMON_COMMAND_TIMEOUT_MS = 10 * 60 * 1000;
47
+ exports.DEFAULT_DAEMON_STATUS_TIMEOUT_MS = 5_000;
47
48
  /**
48
49
  * Defense-in-depth: detect if we're running under vitest. Tests that forget
49
50
  * to `vi.mock("../../heart/daemon/socket-client")` would otherwise leak real
@@ -130,11 +131,16 @@ function sendDaemonCommand(socketPath, command, options = {}) {
130
131
  let raw = "";
131
132
  let settled = false;
132
133
  const timeoutMs = options.timeoutMs ?? exports.DEFAULT_DAEMON_COMMAND_TIMEOUT_MS;
134
+ let deadlineTimer;
135
+ const clearDeadline = () => {
136
+ clearTimeout(deadlineTimer);
137
+ };
133
138
  const resolveOnce = (response) => {
134
139
  /* v8 ignore next -- duplicate settlement guard; callers also guard event handlers before reaching this helper @preserve */
135
140
  if (settled)
136
141
  return;
137
142
  settled = true;
143
+ clearDeadline();
138
144
  resolve(response);
139
145
  };
140
146
  const rejectOnce = (error) => {
@@ -142,27 +148,30 @@ function sendDaemonCommand(socketPath, command, options = {}) {
142
148
  if (settled)
143
149
  return;
144
150
  settled = true;
151
+ clearDeadline();
145
152
  reject(error);
146
153
  };
147
- if ("setTimeout" in client && typeof client.setTimeout === "function") {
148
- client.setTimeout(timeoutMs, () => {
149
- const error = new Error(`Daemon command ${command.kind} timed out after ${timeoutMs}ms waiting for a response.`);
150
- (0, runtime_1.emitNervesEvent)({
151
- level: "error",
152
- component: "daemon",
153
- event: "daemon.socket_command_timeout",
154
- message: "daemon socket command timed out",
155
- meta: {
156
- socketPath,
157
- kind: command.kind,
158
- timeoutMs,
159
- error: error.message,
160
- },
161
- });
162
- client.destroy();
163
- rejectOnce(error);
154
+ deadlineTimer = setTimeout(() => {
155
+ /* v8 ignore next -- terminal socket events clear this timer; guard protects a queued timer callback race @preserve */
156
+ if (settled)
157
+ return;
158
+ const error = new Error(`Daemon command ${command.kind} timed out after ${timeoutMs}ms waiting for a response.`);
159
+ error.code = "ETIMEDOUT";
160
+ (0, runtime_1.emitNervesEvent)({
161
+ level: "error",
162
+ component: "daemon",
163
+ event: "daemon.socket_command_timeout",
164
+ message: "daemon socket command timed out",
165
+ meta: {
166
+ socketPath,
167
+ kind: command.kind,
168
+ timeoutMs,
169
+ error: error.message,
170
+ },
164
171
  });
165
- }
172
+ client.destroy();
173
+ rejectOnce(error);
174
+ }, timeoutMs);
166
175
  client.on("connect", () => {
167
176
  // Write the command + newline delimiter. DO NOT call client.end()
168
177
  // afterwards — the server closes the connection once it has written
@@ -104,14 +104,13 @@ function parseToolCallInput(argumentsJson) {
104
104
  }
105
105
  }
106
106
  function toAnthropicMessages(messages) {
107
- let system;
107
+ const systemParts = [];
108
108
  const converted = [];
109
109
  for (const msg of messages) {
110
110
  if (msg.role === "system") {
111
- if (!system) {
112
- const text = toAnthropicTextContent(msg.content);
113
- system = text || undefined;
114
- }
111
+ const text = toAnthropicTextContent(msg.content).trim();
112
+ if (text)
113
+ systemParts.push(text);
115
114
  continue;
116
115
  }
117
116
  if (msg.role === "user") {
@@ -173,7 +172,7 @@ function toAnthropicMessages(messages) {
173
172
  });
174
173
  }
175
174
  }
176
- return { system, messages: converted };
175
+ return { system: systemParts.length > 0 ? systemParts.join("\n\n") : undefined, messages: converted };
177
176
  }
178
177
  function toAnthropicTools(tools) {
179
178
  return tools.map((tool) => ({
@@ -231,6 +230,11 @@ function isAnthropicAuthFailure(error) {
231
230
  }
232
231
  async function streamAnthropicMessages(client, model, request) {
233
232
  const { system, messages } = toAnthropicMessages(request.messages);
233
+ const additionalSystemParts = request.messages
234
+ .filter((message) => message.role === "system")
235
+ .slice(1)
236
+ .map((message) => toAnthropicTextContent(message.content).trim())
237
+ .filter((text) => text.length > 0);
234
238
  const anthropicTools = toAnthropicTools(request.activeTools);
235
239
  const modelCaps = (0, model_capabilities_1.getModelCapabilities)(model);
236
240
  const maxTokens = modelCaps.maxOutputTokens ?? 16384;
@@ -255,12 +259,13 @@ async function streamAnthropicMessages(client, model, request) {
255
259
  text: preambleText + "\n\n" + request.systemPrompt.stable,
256
260
  cache_control: { type: "ephemeral" },
257
261
  };
258
- if (request.systemPrompt.volatile) {
259
- params.system = [stableBlock, { type: "text", text: request.systemPrompt.volatile }];
260
- }
261
- else {
262
- params.system = [stableBlock];
263
- }
262
+ params.system = [
263
+ stableBlock,
264
+ ...(request.systemPrompt.volatile
265
+ ? [{ type: "text", text: request.systemPrompt.volatile }]
266
+ : []),
267
+ ...additionalSystemParts.map((text) => ({ type: "text", text })),
268
+ ];
264
269
  }
265
270
  else if (system) {
266
271
  // Fallback: no structured prompt, extract from messages (legacy path)