@enderfga/claw-orchestrator 7.14.0 → 7.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/README.md +160 -135
  2. package/configs/autoloop-reviewer-prompt.md +1 -1
  3. package/configs/engines/README.md +31 -11
  4. package/dist/bin/acp-server.js +7 -12
  5. package/dist/bin/acp-server.js.map +1 -1
  6. package/dist/bin/cli.js +71 -40
  7. package/dist/bin/cli.js.map +1 -1
  8. package/dist/bin/mcp-server.js +2 -54
  9. package/dist/bin/mcp-server.js.map +1 -1
  10. package/dist/src/acp-server.d.ts +17 -9
  11. package/dist/src/acp-server.js +149 -30
  12. package/dist/src/acp-server.js.map +1 -1
  13. package/dist/src/autoloop/agent-tools.d.ts +2 -0
  14. package/dist/src/autoloop/agent-tools.js +7 -0
  15. package/dist/src/autoloop/agent-tools.js.map +1 -1
  16. package/dist/src/autoloop/dispatcher.d.ts +6 -0
  17. package/dist/src/autoloop/dispatcher.js +23 -1
  18. package/dist/src/autoloop/dispatcher.js.map +1 -1
  19. package/dist/src/autoloop/planner-tools.js +2 -0
  20. package/dist/src/autoloop/planner-tools.js.map +1 -1
  21. package/dist/src/council.d.ts +8 -0
  22. package/dist/src/council.js +45 -12
  23. package/dist/src/council.js.map +1 -1
  24. package/dist/src/dashboard/index.html +28 -14
  25. package/dist/src/embedded-server.js +27 -8
  26. package/dist/src/embedded-server.js.map +1 -1
  27. package/dist/src/engine-spawn.js +3 -1
  28. package/dist/src/engine-spawn.js.map +1 -1
  29. package/dist/src/fanout.d.ts +13 -3
  30. package/dist/src/fanout.js +10 -0
  31. package/dist/src/fanout.js.map +1 -1
  32. package/dist/src/inbox-manager.d.ts +9 -0
  33. package/dist/src/inbox-manager.js +8 -2
  34. package/dist/src/inbox-manager.js.map +1 -1
  35. package/dist/src/index.d.ts +2 -0
  36. package/dist/src/index.js +117 -51
  37. package/dist/src/index.js.map +1 -1
  38. package/dist/src/kernel/conditions.d.ts +1 -1
  39. package/dist/src/kernel/conditions.js +1 -1
  40. package/dist/src/kernel/engine.d.ts +28 -8
  41. package/dist/src/kernel/engine.js +93 -19
  42. package/dist/src/kernel/engine.js.map +1 -1
  43. package/dist/src/kernel/nodes/agent.js +2 -0
  44. package/dist/src/kernel/nodes/agent.js.map +1 -1
  45. package/dist/src/kernel/nodes/council.js +9 -4
  46. package/dist/src/kernel/nodes/council.js.map +1 -1
  47. package/dist/src/kernel/nodes/fanout.js +5 -0
  48. package/dist/src/kernel/nodes/fanout.js.map +1 -1
  49. package/dist/src/kernel/nodes/goal.d.ts +8 -1
  50. package/dist/src/kernel/nodes/goal.js +40 -19
  51. package/dist/src/kernel/nodes/goal.js.map +1 -1
  52. package/dist/src/kernel/nodes/subflow.js +9 -1
  53. package/dist/src/kernel/nodes/subflow.js.map +1 -1
  54. package/dist/src/kernel/nodes/ultraapp.d.ts +1 -0
  55. package/dist/src/kernel/nodes/ultraapp.js +1 -0
  56. package/dist/src/kernel/nodes/ultraapp.js.map +1 -1
  57. package/dist/src/kernel/nodes/verifier.js +5 -1
  58. package/dist/src/kernel/nodes/verifier.js.map +1 -1
  59. package/dist/src/kernel/projections.d.ts +1 -0
  60. package/dist/src/kernel/projections.js +7 -1
  61. package/dist/src/kernel/projections.js.map +1 -1
  62. package/dist/src/kernel/templates/index.d.ts +6 -3
  63. package/dist/src/kernel/templates/index.js +8 -3
  64. package/dist/src/kernel/templates/index.js.map +1 -1
  65. package/dist/src/kernel/types.d.ts +30 -10
  66. package/dist/src/kernel/types.js.map +1 -1
  67. package/dist/src/logger.d.ts +6 -0
  68. package/dist/src/logger.js +14 -0
  69. package/dist/src/logger.js.map +1 -1
  70. package/dist/src/mcp-annotations.d.ts +13 -0
  71. package/dist/src/mcp-annotations.js +100 -0
  72. package/dist/src/mcp-annotations.js.map +1 -0
  73. package/dist/src/models.d.ts +7 -0
  74. package/dist/src/models.js +9 -0
  75. package/dist/src/models.js.map +1 -1
  76. package/dist/src/openai-compat.d.ts +11 -5
  77. package/dist/src/openai-compat.js +55 -54
  78. package/dist/src/openai-compat.js.map +1 -1
  79. package/dist/src/persistent-acp-session.d.ts +7 -0
  80. package/dist/src/persistent-acp-session.js +26 -2
  81. package/dist/src/persistent-acp-session.js.map +1 -1
  82. package/dist/src/persistent-codex-app-session.d.ts +2 -0
  83. package/dist/src/persistent-codex-app-session.js +18 -9
  84. package/dist/src/persistent-codex-app-session.js.map +1 -1
  85. package/dist/src/persistent-codex-session.d.ts +14 -3
  86. package/dist/src/persistent-codex-session.js +22 -19
  87. package/dist/src/persistent-codex-session.js.map +1 -1
  88. package/dist/src/persistent-custom-session.d.ts +9 -0
  89. package/dist/src/persistent-custom-session.js +41 -21
  90. package/dist/src/persistent-custom-session.js.map +1 -1
  91. package/dist/src/persistent-grok-session.js +1 -1
  92. package/dist/src/persistent-grok-session.js.map +1 -1
  93. package/dist/src/run-ledger.d.ts +3 -3
  94. package/dist/src/session-manager.d.ts +34 -1
  95. package/dist/src/session-manager.js +160 -30
  96. package/dist/src/session-manager.js.map +1 -1
  97. package/dist/src/types.d.ts +34 -11
  98. package/dist/src/types.js.map +1 -1
  99. package/dist/src/ultraapp/build.d.ts +5 -11
  100. package/dist/src/ultraapp/build.js +5 -11
  101. package/dist/src/ultraapp/build.js.map +1 -1
  102. package/dist/src/ultraapp/council-adapter.d.ts +5 -2
  103. package/dist/src/ultraapp/council-adapter.js +6 -6
  104. package/dist/src/ultraapp/council-adapter.js.map +1 -1
  105. package/dist/src/ultraapp/files.d.ts +7 -0
  106. package/dist/src/ultraapp/files.js +7 -1
  107. package/dist/src/ultraapp/files.js.map +1 -1
  108. package/dist/src/ultraapp/manager.d.ts +23 -3
  109. package/dist/src/ultraapp/manager.js +170 -72
  110. package/dist/src/ultraapp/manager.js.map +1 -1
  111. package/dist/src/ultraapp/narrator.js +1 -1
  112. package/dist/src/ultraapp/narrator.js.map +1 -1
  113. package/dist/src/ultraapp/versions.d.ts +27 -5
  114. package/dist/src/ultraapp/versions.js +58 -13
  115. package/dist/src/ultraapp/versions.js.map +1 -1
  116. package/openclaw.plugin.json +4 -13
  117. package/package.json +13 -6
  118. package/skills/SKILL.md +52 -24
  119. package/skills/references/acp.md +140 -61
  120. package/skills/references/autoloop.md +141 -35
  121. package/skills/references/claude-cli-tracking.md +8 -6
  122. package/skills/references/cli.md +122 -93
  123. package/skills/references/council.md +44 -22
  124. package/skills/references/dashboard.md +37 -21
  125. package/skills/references/getting-started.md +49 -15
  126. package/skills/references/inbox.md +13 -8
  127. package/skills/references/mcp.md +25 -17
  128. package/skills/references/multi-engine.md +161 -145
  129. package/skills/references/observability.md +90 -58
  130. package/skills/references/openai-compat.md +172 -82
  131. package/skills/references/sessions.md +101 -65
  132. package/skills/references/tools.md +209 -158
  133. package/skills/references/ultra.md +7 -7
  134. package/skills/references/ultraapp.md +101 -56
  135. package/skills/references/verification.md +112 -56
  136. package/skills/references/workflow.md +227 -93
  137. package/assets/banner.jpg +0 -0
@@ -115,7 +115,7 @@ import { detectRepoLang } from './kernel/repo.js';
115
115
  import { RunKernel, runDir as kernelRunDir } from './kernel/engine.js';
116
116
  import { registerDefaultExecutors } from './kernel/nodes/index.js';
117
117
  import { autoloopStateFromRecord, makeAutoloopExecutor } from './kernel/nodes/autoloop.js';
118
- import { isValidRunId, loadRun, readNodeOutput } from './kernel/store.js';
118
+ import { isValidRunId, loadRun, readNodeOutput, runExists } from './kernel/store.js';
119
119
  import { LEGACY_NODE, joinFindings, toCouncilSession, toFanoutSession, toUltraplanResult, toUltrareviewResult, } from './kernel/projections.js';
120
120
  import { legacyCouncilWorkflow, legacyFanoutWorkflow, legacyUltraplanWorkflow, splitAgentSecrets, } from './kernel/templates/index.js';
121
121
  import { normalizeContract } from './verify/contract.js';
@@ -139,7 +139,7 @@ import { PersistentCustomSession } from './persistent-custom-session.js';
139
139
  import { PersistentAcpSession } from './persistent-acp-session.js';
140
140
  import { resolveCustomEngine } from './engine-presets.js';
141
141
  import { EFFORT_LEVELS, ENGINE_TYPES, overrideModelPricing, ENGINE_BINARY_NAMES, } from './types.js';
142
- import { resolveAlias, isClaudeModel, lookupModel } from './models.js';
142
+ import { resolveAlias, isClaudeModel, lookupModel, resolveEngineAndModel } from './models.js';
143
143
  import { isAgyConversationId } from './agy-conversation.js';
144
144
  import { Council } from './council.js';
145
145
  import { AutoloopRunner } from './autoloop/runner.js';
@@ -217,6 +217,15 @@ function readStoredAutoloopResumeContext(workspace, runId, originalSendTimeoutMs
217
217
  }
218
218
  return { effectiveSendTimeoutMs, pendingDispatch };
219
219
  }
220
+ /** A process's command line as `ps` prints it, or undefined when it cannot be read. */
221
+ function processCommand(pid) {
222
+ try {
223
+ return execFileSync('ps', ['-p', String(pid), '-o', 'command='], { encoding: 'utf8', timeout: 3_000 }).trim();
224
+ }
225
+ catch {
226
+ return undefined;
227
+ }
228
+ }
220
229
  function validateSendTimeoutIncrease(value, current) {
221
230
  validateAutoloopTimeoutConfig({ sendTimeoutMs: value });
222
231
  if (value <= current) {
@@ -319,8 +328,24 @@ function validateAutoloopRole(role, engine, customEngine) {
319
328
  throw new Error(`${label} custom engine config is required`);
320
329
  validateAutoloopCustomEngine(role, customEngine);
321
330
  }
331
+ // An Autoloop role is configured by engine name alone and has no field for the
332
+ // agent an `acp` engine starts, so the session could only fail later.
333
+ if (resolved === 'acp')
334
+ throw new Error(`${label} engine 'acp' is not supported by Autoloop`);
322
335
  return resolved;
323
336
  }
337
+ /**
338
+ * The plugin's `defaultModel` for a session on `engine` that names no model, or
339
+ * undefined to let the engine run its own default. Engines do not share a model
340
+ * namespace, so the default goes only to an engine that runs it; the claude
341
+ * engine takes any model, proxying a non-Claude one to the gateway.
342
+ */
343
+ function defaultModelFor(engine, defaultModel) {
344
+ if (!defaultModel || engine === 'claude')
345
+ return defaultModel;
346
+ const owner = resolveEngineAndModel(defaultModel).engine;
347
+ return owner === (engine === 'codex-app' ? 'codex' : engine) ? defaultModel : undefined;
348
+ }
324
349
  function validateAutoloopEffort(role, effort) {
325
350
  if (effort === undefined)
326
351
  return;
@@ -789,6 +814,12 @@ export class SessionManager {
789
814
  /** In-flight proxy startup, so concurrent callers share one server. */
790
815
  _proxyStartPromise = null;
791
816
  _activePids = new Map();
817
+ /**
818
+ * Each recorded pid's command line when it was recorded. An orphan is killed
819
+ * only if it is still that process: this is what lets cleanup recognise an
820
+ * ACP agent or a custom engine, whose binary is no fixed name.
821
+ */
822
+ _pidCommands = new Map();
792
823
  _circuitBreaker = new CircuitBreaker();
793
824
  _inbox = new InboxManager();
794
825
  /** cwd → detected language, so the manifest probe runs once per directory. */
@@ -929,6 +960,7 @@ export class SessionManager {
929
960
  const persisted = skipPersist ? undefined : this.persistedSessions.get(name);
930
961
  // Unified: only use resumeSessionId (claudeResumeId is an internal alias, not exposed)
931
962
  const resumeId = config.resumeSessionId ?? persisted?.claudeSessionId;
963
+ const engine = config.engine || persisted?.engine || 'claude';
932
964
  // ORDER IS LOAD-BEARING — do not "fix" it by moving `...config` up.
933
965
  //
934
966
  // Object spread copies own keys even when their value is `undefined`, so any
@@ -944,7 +976,7 @@ export class SessionManager {
944
976
  cwd: config.cwd || persisted?.cwd || process.cwd(),
945
977
  permissionMode: config.permissionMode || this.pluginConfig.defaultPermissionMode,
946
978
  effort: config.effort || this.pluginConfig.defaultEffort,
947
- model: config.model || persisted?.model || this.pluginConfig.defaultModel,
979
+ model: config.model || persisted?.model || defaultModelFor(engine, this.pluginConfig.defaultModel),
948
980
  sandboxMode: config.sandboxMode ?? persisted?.sandboxMode,
949
981
  autoCompactPercent: config.autoCompactPercent ?? persisted?.autoCompactPercent,
950
982
  chrome: config.chrome ?? persisted?.chrome,
@@ -955,16 +987,15 @@ export class SessionManager {
955
987
  if (fullConfig.model) {
956
988
  fullConfig.resolvedModel = this._resolveModel(fullConfig.model, fullConfig.modelOverrides);
957
989
  }
958
- // Auto-inject proxy baseUrl for non-Claude models on the claude engine.
959
- // Starts a local proxy server that converts Anthropic → OpenAI format
960
- // and forwards to the OpenClaw gateway. Zero config required.
961
- const engine = fullConfig.engine || persisted?.engine || 'claude';
962
990
  // Write the resolved engine back so downstream consumers of the managed
963
991
  // config (agy resume-id lookups, _persistSession's registry entry) see the
964
992
  // real engine even when it came from the persisted registry.
965
993
  fullConfig.engine = engine;
966
994
  // Circuit breaker — reject early if engine is in backoff
967
995
  this._circuitBreaker.check(engine);
996
+ // Auto-inject proxy baseUrl for non-Claude models on the claude engine.
997
+ // Starts a local proxy server that converts Anthropic → OpenAI format
998
+ // and forwards to the OpenClaw gateway. Zero config required.
968
999
  if (engine === 'claude' && fullConfig.resolvedModel && !fullConfig.baseUrl) {
969
1000
  if (!isClaudeModel(fullConfig.resolvedModel)) {
970
1001
  const proxyPort = await this._ensureProxyServer();
@@ -987,6 +1018,9 @@ export class SessionManager {
987
1018
  // Track child process PID for orphan cleanup
988
1019
  if (session.pid) {
989
1020
  this._activePids.set(name, session.pid);
1021
+ const command = processCommand(session.pid);
1022
+ if (command)
1023
+ this._pidCommands.set(session.pid, command);
990
1024
  this._savePids();
991
1025
  }
992
1026
  const managed = {
@@ -1008,6 +1042,10 @@ export class SessionManager {
1008
1042
  return this._toSessionInfo(name, managed);
1009
1043
  }
1010
1044
  async sendMessage(name, message, options = {}) {
1045
+ return this._sendTurn(name, message, options);
1046
+ }
1047
+ /** `onDispatch` fires once the turn has passed every check and is handed to the engine. */
1048
+ async _sendTurn(name, message, options, onDispatch) {
1011
1049
  const managed = this._getSession(name);
1012
1050
  // Per-session serialization. Two concurrent sendMessage() calls on the
1013
1051
  // same session previously raced on PersistentClaudeSession._streamCallbacks
@@ -1103,7 +1141,14 @@ export class SessionManager {
1103
1141
  // A session handed off from another engine carries that conversation in
1104
1142
  // front of its first message; after that the engine holds it itself.
1105
1143
  const outgoing = managed.pendingHandoff ? `${managed.pendingHandoff}\n\n${message}` : message;
1144
+ onDispatch?.();
1106
1145
  const result = await managed.session.send(outgoing, sendOpts);
1146
+ const after = this._statsSnapshot(managed);
1147
+ const usage = {
1148
+ tokensIn: Math.max(0, after.tokensIn - ledgerBefore.tokensIn),
1149
+ tokensOut: Math.max(0, after.tokensOut - ledgerBefore.tokensOut),
1150
+ costUsd: Math.max(0, after.costUsd - ledgerBefore.costUsd),
1151
+ };
1107
1152
  // Update the resume-capable session ID if available (skip disk persist
1108
1153
  // for ephemeral sessions that were started with skipPersistence)
1109
1154
  const resumableId = this._managedResumeId(managed);
@@ -1145,11 +1190,12 @@ export class SessionManager {
1145
1190
  sessionId: this._managedResumeId(managed),
1146
1191
  error: turnError,
1147
1192
  events: [],
1193
+ usage,
1148
1194
  ...(permissionDenials.length ? { permissionDenials } : {}),
1149
1195
  ...(images.length ? { images } : {}),
1150
1196
  };
1151
1197
  }
1152
- return { output: '', sessionId: this._managedResumeId(managed), events: [] };
1198
+ return { output: '', sessionId: this._managedResumeId(managed), events: [], usage };
1153
1199
  }
1154
1200
  catch (err) {
1155
1201
  turnError = err.message;
@@ -1219,6 +1265,7 @@ export class SessionManager {
1219
1265
  engine: opts.engine,
1220
1266
  ...(opts.model ? { model: opts.model } : {}),
1221
1267
  ...(opts.customEngine ? { customEngine: opts.customEngine } : {}),
1268
+ ...(opts.acpAgent ? { acpAgent: opts.acpAgent } : {}),
1222
1269
  });
1223
1270
  const target = this._getSession(targetName);
1224
1271
  // The new session's record starts as the source's, so a second handoff from
@@ -1534,6 +1581,11 @@ export class SessionManager {
1534
1581
  if (!contract)
1535
1582
  throw new Error('verifyRun requires a contract with at least one recognised check');
1536
1583
  const runId = args.label || `verify-${Date.now().toString(36)}`;
1584
+ // The bundle goes under the run store, keyed by the label. A label naming a
1585
+ // workflow run would plant this evidence in that run's directory.
1586
+ if (runExists(runId)) {
1587
+ throw new Error(`verify_run label '${runId}' names an existing workflow run; choose another label`);
1588
+ }
1537
1589
  const dir = kernelRunDir(runId);
1538
1590
  const evidenceId = 'verify-01';
1539
1591
  const { results, rounds } = await runContract(contract, {
@@ -1560,7 +1612,7 @@ export class SessionManager {
1560
1612
  managed.session.stop();
1561
1613
  this.sessions.delete(name);
1562
1614
  // Remove PID tracking
1563
- this._activePids.delete(name);
1615
+ this._forgetPid(name);
1564
1616
  this._savePids();
1565
1617
  if (!opts.keepPersisted) {
1566
1618
  // Explicit stop = user intent to end session — remove from disk too.
@@ -1569,6 +1621,9 @@ export class SessionManager {
1569
1621
  // Claude conversation) pass keepPersisted: true.
1570
1622
  this._forgetPersisted(name);
1571
1623
  this._savePersisted();
1624
+ // Its unread mail was for this conversation; a later session that takes
1625
+ // the name is a different one and must not receive it.
1626
+ this._inbox.clear(name);
1572
1627
  }
1573
1628
  }
1574
1629
  hasSession(name) {
@@ -1961,6 +2016,7 @@ export class SessionManager {
1961
2016
  this.sessions.clear();
1962
2017
  // Clear PID tracking
1963
2018
  this._activePids.clear();
2019
+ this._pidCommands.clear();
1964
2020
  this._savePids();
1965
2021
  // Stop proxy server
1966
2022
  if (this._proxyServer) {
@@ -2436,6 +2492,12 @@ export class SessionManager {
2436
2492
  }
2437
2493
  // ─── PID Tracking ──────────────────────────────────────────────────────
2438
2494
  static PID_FILE = path.join(os.homedir(), '.openclaw', 'session-pids.json');
2495
+ _forgetPid(name) {
2496
+ const pid = this._activePids.get(name);
2497
+ if (pid !== undefined)
2498
+ this._pidCommands.delete(pid);
2499
+ this._activePids.delete(name);
2500
+ }
2439
2501
  _savePids() {
2440
2502
  try {
2441
2503
  const dir = path.dirname(SessionManager.PID_FILE);
@@ -2479,11 +2541,13 @@ export class SessionManager {
2479
2541
  pid: entry.pid,
2480
2542
  ownerPid: entry.ownerPid,
2481
2543
  since: entry.since ?? now,
2544
+ ...(typeof entry.command === 'string' ? { command: entry.command } : {}),
2482
2545
  };
2483
2546
  }
2484
2547
  // Add OUR current entries
2485
2548
  for (const [name, pid] of this._activePids) {
2486
- merged[name] = { pid, ownerPid: process.pid, since: now };
2549
+ const command = this._pidCommands.get(pid);
2550
+ merged[name] = { pid, ownerPid: process.pid, since: now, ...(command ? { command } : {}) };
2487
2551
  }
2488
2552
  fs.writeFileSync(SessionManager.PID_FILE, JSON.stringify(merged));
2489
2553
  }
@@ -2495,7 +2559,7 @@ export class SessionManager {
2495
2559
  * Verify that a PID belongs to a known coding CLI before killing it.
2496
2560
  * Prevents killing unrelated processes if the OS recycled the PID.
2497
2561
  */
2498
- _isKnownCliProcess(pid) {
2562
+ _isKnownCliProcess(pid, recordedCommand) {
2499
2563
  // Match known CLI binaries by basename to avoid false positives
2500
2564
  // (e.g., 'agent' must not match 'ssh-agent' or 'gpg-agent')
2501
2565
  // Anchor each name to executable/path position ((?:^|[/\s])name(?:[\s/]|$))
@@ -2510,16 +2574,14 @@ export class SessionManager {
2510
2574
  ...ENGINE_BINARY_NAMES.map((bin) => new RegExp(`(?:^|[/\\s])${bin}(?:[\\s/]|$)`)),
2511
2575
  /(?:^|\/)agent(?:[\s/]|$)/, // 'agent' only as executable/after a slash (not ssh-agent)
2512
2576
  ];
2513
- try {
2514
- const cmd = execFileSync('ps', ['-p', String(pid), '-o', 'command='], {
2515
- encoding: 'utf8',
2516
- timeout: 3_000,
2517
- }).trim();
2518
- return knownPatterns.some((pattern) => pattern.test(cmd));
2519
- }
2520
- catch {
2577
+ const cmd = processCommand(pid);
2578
+ if (cmd === undefined)
2521
2579
  return false; // ps failed — process likely dead or not accessible
2522
- }
2580
+ // The same command line as when it was recorded: the process itself, not a
2581
+ // recycled pid — whatever binary it runs.
2582
+ if (recordedCommand && cmd === recordedCommand)
2583
+ return true;
2584
+ return knownPatterns.some((pattern) => pattern.test(cmd));
2523
2585
  }
2524
2586
  _cleanupOrphanedPids() {
2525
2587
  try {
@@ -2530,6 +2592,7 @@ export class SessionManager {
2530
2592
  // Resolve entry shape: legacy = number; current = { pid, ownerPid, since }.
2531
2593
  let pid;
2532
2594
  let ownerPid;
2595
+ let recordedCommand;
2533
2596
  if (typeof raw === 'number') {
2534
2597
  pid = raw;
2535
2598
  ownerPid = null; // unknown owner — treat conservatively (skip kill)
@@ -2540,6 +2603,7 @@ export class SessionManager {
2540
2603
  continue;
2541
2604
  pid = e.pid;
2542
2605
  ownerPid = typeof e.ownerPid === 'number' ? e.ownerPid : null;
2606
+ recordedCommand = typeof e.command === 'string' ? e.command : undefined;
2543
2607
  }
2544
2608
  else {
2545
2609
  continue;
@@ -2572,7 +2636,7 @@ export class SessionManager {
2572
2636
  try {
2573
2637
  process.kill(pid, 0); // check if alive
2574
2638
  // Alive — but verify it's actually a coding CLI, not a recycled PID
2575
- if (!this._isKnownCliProcess(pid)) {
2639
+ if (!this._isKnownCliProcess(pid, recordedCommand)) {
2576
2640
  this.logger.info(`PID ${pid} (session: ${name}) is alive but not a known CLI — skipping kill`);
2577
2641
  continue;
2578
2642
  }
@@ -2804,12 +2868,26 @@ export class SessionManager {
2804
2868
  throw new Error(`Council '${id}' not found`);
2805
2869
  throw new Error(`Council '${id}' is ${record.state} and not running in this process — its record is readable, but there is no live round to act on`);
2806
2870
  }
2871
+ /**
2872
+ * Abort a council running in this process. One that is not — finished, or
2873
+ * running elsewhere — is left alone and the result says so: its worktrees are
2874
+ * either awaiting accept/reject or in use by the process that runs it.
2875
+ */
2807
2876
  councilAbort(id) {
2808
2877
  // Cancel the run first so the kernel stops advancing, then abort the engine
2809
2878
  // so the current round tears down its worktrees.
2810
- if (!this.kernel.cancel(id) && !loadRun(id))
2879
+ if (this.kernel.cancel(id)) {
2880
+ this.kernel.handle(id, LEGACY_NODE)?.abort();
2881
+ return { aborted: true };
2882
+ }
2883
+ const record = loadRun(id);
2884
+ if (!record)
2811
2885
  throw new Error(`Council '${id}' not found`);
2812
- this.kernel.handle(id, LEGACY_NODE)?.abort();
2886
+ return {
2887
+ aborted: false,
2888
+ state: record.state,
2889
+ reason: `Council '${id}' is ${record.state} and not running in this process — nothing was aborted`,
2890
+ };
2813
2891
  }
2814
2892
  councilInject(id, message) {
2815
2893
  this._liveCouncil(id).injectMessage(message);
@@ -2818,10 +2896,34 @@ export class SessionManager {
2818
2896
  return this._councilForPostProcessing(id).review();
2819
2897
  }
2820
2898
  async councilAccept(id) {
2821
- return this._councilForPostProcessing(id).accept();
2899
+ const result = await this._councilForPostProcessing(id).accept();
2900
+ this._recordCouncilDecision(id, 'accepted');
2901
+ return result;
2822
2902
  }
2823
2903
  async councilReject(id, feedback) {
2824
- return this._councilForPostProcessing(id).reject(feedback);
2904
+ const result = await this._councilForPostProcessing(id).reject(feedback);
2905
+ this._recordCouncilDecision(id, 'rejected');
2906
+ return result;
2907
+ }
2908
+ /**
2909
+ * Write the decision onto the run, which is where `council_status` reads it.
2910
+ * The `Council` that took it is usually rebuilt from the record and dropped,
2911
+ * so its own status would go nowhere.
2912
+ */
2913
+ _recordCouncilDecision(id, status) {
2914
+ // A council live in this process carries the status on its own session.
2915
+ if (this.kernel.handle(id, LEGACY_NODE))
2916
+ return;
2917
+ let recorded = false;
2918
+ let why = 'the run has not finished';
2919
+ try {
2920
+ recorded = this.kernel.amendFinishedNode(id, LEGACY_NODE, { councilStatus: status }, `council ${status}`);
2921
+ }
2922
+ catch (err) {
2923
+ why = err.message;
2924
+ }
2925
+ if (!recorded)
2926
+ this.logger.warn(`[council] ${id}: ${status}, but the status could not be recorded (${why})`);
2825
2927
  }
2826
2928
  /**
2827
2929
  * A `Council` for review / accept / reject.
@@ -2870,6 +2972,7 @@ export class SessionManager {
2870
2972
  synthesize: config.synthesize,
2871
2973
  synthesisEngine: config.synthesisEngine,
2872
2974
  synthesisModel: config.synthesisModel,
2975
+ synthesisAcpAgent: config.synthesisAcpAgent,
2873
2976
  synthesisPermissionMode: config.synthesisPermissionMode,
2874
2977
  maxTurnsPerAgent: config.maxTurnsPerAgent,
2875
2978
  maxBudgetUsd: config.maxBudgetUsd,
@@ -2895,8 +2998,30 @@ export class SessionManager {
2895
2998
  getSession: (name) => this.sessions.get(name),
2896
2999
  exists: (name) => this.sessions.has(name),
2897
3000
  allNames: () => this.sessions.keys(),
3001
+ deliver: (name, text) => this._deliverInboxTurn(name, text),
2898
3002
  };
2899
3003
  }
3004
+ /**
3005
+ * An inbox delivery is a turn like any other — capped, written to the run
3006
+ * ledger and kept in the handoff record — that its sender does not wait on.
3007
+ * Resolves once the turn reaches the engine; a refusal before that (spend cap,
3008
+ * session stopped) rejects so the inbox queues the message instead. A failure
3009
+ * after it belongs to the turn and is recorded with it.
3010
+ */
3011
+ _deliverInboxTurn(name, text) {
3012
+ return new Promise((resolve, reject) => {
3013
+ let dispatched = false;
3014
+ this._sendTurn(name, text, {}, () => {
3015
+ dispatched = true;
3016
+ resolve();
3017
+ }).then(() => resolve(), (err) => {
3018
+ if (!dispatched)
3019
+ reject(err);
3020
+ else
3021
+ this.logger.warn(`[SessionManager] inbox turn on '${name}' failed: ${err.message}`);
3022
+ });
3023
+ });
3024
+ }
2900
3025
  async sessionSendTo(from, to, message, summary) {
2901
3026
  return this._inbox.sendTo(from, to, message, this._sessionLookup, summary, (name, err) => {
2902
3027
  this.logger.error(`Broadcast delivery to '${name}' failed:`, err.message);
@@ -2972,10 +3097,14 @@ export class SessionManager {
2972
3097
  // Every reviewer runs read-only (see below). A custom reviewer has no config
2973
3098
  // to start from, so it is turned away here instead of failing one reviewer
2974
3099
  // at a time. (grok was turned away too until its read-only mode moved onto
2975
- // grok's OS sandbox.)
3100
+ // grok's OS sandbox.) An ACP agent cannot be held read-only at all: its tools
3101
+ // run inside the agent, so the session would refuse to start.
2976
3102
  if ((opts?.engines ?? []).includes('custom')) {
2977
3103
  throw new Error('ultrareview cannot use custom: reviewers run read-only, which a custom engine cannot be configured for here');
2978
3104
  }
3105
+ if ((opts?.engines ?? []).includes('acp')) {
3106
+ throw new Error('ultrareview cannot use acp: reviewers run read-only, which an ACP agent cannot be held to');
3107
+ }
2979
3108
  const id = `ultrareview-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
2980
3109
  const agentCount = Math.min(20, Math.max(1, opts?.agentCount || 5));
2981
3110
  const result = {
@@ -3104,9 +3233,10 @@ export class SessionManager {
3104
3233
  // Review is read-only: reviewers analyse and report without modifying the
3105
3234
  // very code they review. Unlike council, fan-out shares the project dir —
3106
3235
  // there is no worktree to contain an edit — so this has to hold on every
3107
- // engine. `plan` constrains Claude only; `sandboxMode: 'read-only'` is the
3108
- // engine-agnostic setting, which codex, agy and opencode map to their own
3109
- // read-only modes. Before it was set, a codex reviewer ran workspace-write.
3236
+ // engine. `plan` reaches only the engines with a plan mode (Claude, Grok);
3237
+ // `sandboxMode: 'read-only'` is the engine-agnostic setting, which codex,
3238
+ // agy and opencode map to their own read-only modes. Before it was set, a
3239
+ // codex reviewer ran workspace-write.
3110
3240
  permissionMode: 'plan',
3111
3241
  sandboxMode: 'read-only',
3112
3242
  }));
@@ -3867,7 +3997,7 @@ export class SessionManager {
3867
3997
  // owner. After an unclean exit `_cleanupOrphanedPids()` reads them back
3868
3998
  // and probes each one; a PID the OS has since recycled to a
3869
3999
  // coding-CLI-shaped process gets killed.
3870
- this._activePids.delete(name);
4000
+ this._forgetPid(name);
3871
4001
  pidsChanged = true;
3872
4002
  // NOTE: do NOT delete from persistedSessions — idle cleanup is
3873
4003
  // in-memory only. Persisted entries survive for PERSIST_DISK_TTL_MS