@enderfga/claw-orchestrator 7.14.0 → 7.16.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 (145) hide show
  1. package/README.md +162 -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 +84 -46
  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 +72 -9
  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/claude-goal-session.d.ts +147 -0
  22. package/dist/src/claude-goal-session.js +355 -0
  23. package/dist/src/claude-goal-session.js.map +1 -0
  24. package/dist/src/council.d.ts +8 -0
  25. package/dist/src/council.js +45 -12
  26. package/dist/src/council.js.map +1 -1
  27. package/dist/src/dashboard/index.html +28 -14
  28. package/dist/src/embedded-server.js +27 -8
  29. package/dist/src/embedded-server.js.map +1 -1
  30. package/dist/src/engine-spawn.js +3 -1
  31. package/dist/src/engine-spawn.js.map +1 -1
  32. package/dist/src/fanout.d.ts +13 -3
  33. package/dist/src/fanout.js +10 -0
  34. package/dist/src/fanout.js.map +1 -1
  35. package/dist/src/inbox-manager.d.ts +9 -0
  36. package/dist/src/inbox-manager.js +8 -2
  37. package/dist/src/inbox-manager.js.map +1 -1
  38. package/dist/src/index.d.ts +2 -0
  39. package/dist/src/index.js +135 -53
  40. package/dist/src/index.js.map +1 -1
  41. package/dist/src/kernel/agent-step.d.ts +6 -0
  42. package/dist/src/kernel/agent-step.js.map +1 -1
  43. package/dist/src/kernel/conditions.d.ts +1 -1
  44. package/dist/src/kernel/conditions.js +1 -1
  45. package/dist/src/kernel/engine.d.ts +28 -8
  46. package/dist/src/kernel/engine.js +93 -19
  47. package/dist/src/kernel/engine.js.map +1 -1
  48. package/dist/src/kernel/nodes/agent.js +2 -0
  49. package/dist/src/kernel/nodes/agent.js.map +1 -1
  50. package/dist/src/kernel/nodes/council.js +9 -4
  51. package/dist/src/kernel/nodes/council.js.map +1 -1
  52. package/dist/src/kernel/nodes/fanout.js +5 -0
  53. package/dist/src/kernel/nodes/fanout.js.map +1 -1
  54. package/dist/src/kernel/nodes/goal.d.ts +19 -5
  55. package/dist/src/kernel/nodes/goal.js +81 -34
  56. package/dist/src/kernel/nodes/goal.js.map +1 -1
  57. package/dist/src/kernel/nodes/subflow.js +9 -1
  58. package/dist/src/kernel/nodes/subflow.js.map +1 -1
  59. package/dist/src/kernel/nodes/ultraapp.d.ts +1 -0
  60. package/dist/src/kernel/nodes/ultraapp.js +1 -0
  61. package/dist/src/kernel/nodes/ultraapp.js.map +1 -1
  62. package/dist/src/kernel/nodes/verifier.js +5 -1
  63. package/dist/src/kernel/nodes/verifier.js.map +1 -1
  64. package/dist/src/kernel/projections.d.ts +1 -0
  65. package/dist/src/kernel/projections.js +7 -1
  66. package/dist/src/kernel/projections.js.map +1 -1
  67. package/dist/src/kernel/templates/index.d.ts +16 -11
  68. package/dist/src/kernel/templates/index.js +14 -8
  69. package/dist/src/kernel/templates/index.js.map +1 -1
  70. package/dist/src/kernel/types.d.ts +37 -13
  71. package/dist/src/kernel/types.js.map +1 -1
  72. package/dist/src/logger.d.ts +6 -0
  73. package/dist/src/logger.js +14 -0
  74. package/dist/src/logger.js.map +1 -1
  75. package/dist/src/mcp-annotations.d.ts +13 -0
  76. package/dist/src/mcp-annotations.js +100 -0
  77. package/dist/src/mcp-annotations.js.map +1 -0
  78. package/dist/src/models.d.ts +7 -0
  79. package/dist/src/models.js +9 -0
  80. package/dist/src/models.js.map +1 -1
  81. package/dist/src/openai-compat.d.ts +11 -5
  82. package/dist/src/openai-compat.js +55 -54
  83. package/dist/src/openai-compat.js.map +1 -1
  84. package/dist/src/persistent-acp-session.d.ts +7 -0
  85. package/dist/src/persistent-acp-session.js +26 -2
  86. package/dist/src/persistent-acp-session.js.map +1 -1
  87. package/dist/src/persistent-codex-app-session.d.ts +2 -0
  88. package/dist/src/persistent-codex-app-session.js +18 -9
  89. package/dist/src/persistent-codex-app-session.js.map +1 -1
  90. package/dist/src/persistent-codex-session.d.ts +14 -3
  91. package/dist/src/persistent-codex-session.js +22 -19
  92. package/dist/src/persistent-codex-session.js.map +1 -1
  93. package/dist/src/persistent-custom-session.d.ts +9 -0
  94. package/dist/src/persistent-custom-session.js +41 -21
  95. package/dist/src/persistent-custom-session.js.map +1 -1
  96. package/dist/src/persistent-grok-session.js +1 -1
  97. package/dist/src/persistent-grok-session.js.map +1 -1
  98. package/dist/src/persistent-session.d.ts +9 -0
  99. package/dist/src/persistent-session.js +23 -0
  100. package/dist/src/persistent-session.js.map +1 -1
  101. package/dist/src/run-ledger.d.ts +3 -3
  102. package/dist/src/session-manager.d.ts +45 -1
  103. package/dist/src/session-manager.js +199 -34
  104. package/dist/src/session-manager.js.map +1 -1
  105. package/dist/src/types.d.ts +34 -11
  106. package/dist/src/types.js.map +1 -1
  107. package/dist/src/ultraapp/build.d.ts +5 -11
  108. package/dist/src/ultraapp/build.js +5 -11
  109. package/dist/src/ultraapp/build.js.map +1 -1
  110. package/dist/src/ultraapp/council-adapter.d.ts +5 -2
  111. package/dist/src/ultraapp/council-adapter.js +6 -6
  112. package/dist/src/ultraapp/council-adapter.js.map +1 -1
  113. package/dist/src/ultraapp/files.d.ts +7 -0
  114. package/dist/src/ultraapp/files.js +7 -1
  115. package/dist/src/ultraapp/files.js.map +1 -1
  116. package/dist/src/ultraapp/manager.d.ts +23 -3
  117. package/dist/src/ultraapp/manager.js +170 -72
  118. package/dist/src/ultraapp/manager.js.map +1 -1
  119. package/dist/src/ultraapp/narrator.js +1 -1
  120. package/dist/src/ultraapp/narrator.js.map +1 -1
  121. package/dist/src/ultraapp/versions.d.ts +27 -5
  122. package/dist/src/ultraapp/versions.js +58 -13
  123. package/dist/src/ultraapp/versions.js.map +1 -1
  124. package/openclaw.plugin.json +4 -13
  125. package/package.json +13 -6
  126. package/skills/SKILL.md +52 -24
  127. package/skills/references/acp.md +140 -61
  128. package/skills/references/autoloop.md +148 -38
  129. package/skills/references/claude-cli-tracking.md +9 -6
  130. package/skills/references/cli.md +139 -108
  131. package/skills/references/council.md +44 -22
  132. package/skills/references/dashboard.md +37 -21
  133. package/skills/references/getting-started.md +49 -15
  134. package/skills/references/inbox.md +13 -8
  135. package/skills/references/mcp.md +25 -17
  136. package/skills/references/multi-engine.md +161 -145
  137. package/skills/references/observability.md +90 -58
  138. package/skills/references/openai-compat.md +172 -82
  139. package/skills/references/sessions.md +101 -65
  140. package/skills/references/tools.md +217 -158
  141. package/skills/references/ultra.md +7 -7
  142. package/skills/references/ultraapp.md +101 -56
  143. package/skills/references/verification.md +112 -56
  144. package/skills/references/workflow.md +275 -97
  145. 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';
@@ -131,6 +131,7 @@ import { cloneTranscript, DEFAULT_HANDOFF_CHARS, MIN_HANDOFF_CHARS, newTranscrip
131
131
  import { PersistentGeminiSession } from './persistent-gemini-session.js';
132
132
  import { PersistentCodexSession } from './persistent-codex-session.js';
133
133
  import { PersistentCodexAppServerSession } from './persistent-codex-app-session.js';
134
+ import { ClaudeGoalSession } from './claude-goal-session.js';
134
135
  import { PersistentCursorSession } from './persistent-cursor-session.js';
135
136
  import { PersistentGrokSession } from './persistent-grok-session.js';
136
137
  import { PersistentOpencodeSession } from './persistent-opencode-session.js';
@@ -139,7 +140,7 @@ import { PersistentCustomSession } from './persistent-custom-session.js';
139
140
  import { PersistentAcpSession } from './persistent-acp-session.js';
140
141
  import { resolveCustomEngine } from './engine-presets.js';
141
142
  import { EFFORT_LEVELS, ENGINE_TYPES, overrideModelPricing, ENGINE_BINARY_NAMES, } from './types.js';
142
- import { resolveAlias, isClaudeModel, lookupModel } from './models.js';
143
+ import { resolveAlias, isClaudeModel, lookupModel, resolveEngineAndModel } from './models.js';
143
144
  import { isAgyConversationId } from './agy-conversation.js';
144
145
  import { Council } from './council.js';
145
146
  import { AutoloopRunner } from './autoloop/runner.js';
@@ -217,6 +218,15 @@ function readStoredAutoloopResumeContext(workspace, runId, originalSendTimeoutMs
217
218
  }
218
219
  return { effectiveSendTimeoutMs, pendingDispatch };
219
220
  }
221
+ /** A process's command line as `ps` prints it, or undefined when it cannot be read. */
222
+ function processCommand(pid) {
223
+ try {
224
+ return execFileSync('ps', ['-p', String(pid), '-o', 'command='], { encoding: 'utf8', timeout: 3_000 }).trim();
225
+ }
226
+ catch {
227
+ return undefined;
228
+ }
229
+ }
220
230
  function validateSendTimeoutIncrease(value, current) {
221
231
  validateAutoloopTimeoutConfig({ sendTimeoutMs: value });
222
232
  if (value <= current) {
@@ -319,8 +329,24 @@ function validateAutoloopRole(role, engine, customEngine) {
319
329
  throw new Error(`${label} custom engine config is required`);
320
330
  validateAutoloopCustomEngine(role, customEngine);
321
331
  }
332
+ // An Autoloop role is configured by engine name alone and has no field for the
333
+ // agent an `acp` engine starts, so the session could only fail later.
334
+ if (resolved === 'acp')
335
+ throw new Error(`${label} engine 'acp' is not supported by Autoloop`);
322
336
  return resolved;
323
337
  }
338
+ /**
339
+ * The plugin's `defaultModel` for a session on `engine` that names no model, or
340
+ * undefined to let the engine run its own default. Engines do not share a model
341
+ * namespace, so the default goes only to an engine that runs it; the claude
342
+ * engine takes any model, proxying a non-Claude one to the gateway.
343
+ */
344
+ function defaultModelFor(engine, defaultModel) {
345
+ if (!defaultModel || engine === 'claude')
346
+ return defaultModel;
347
+ const owner = resolveEngineAndModel(defaultModel).engine;
348
+ return owner === (engine === 'codex-app' ? 'codex' : engine) ? defaultModel : undefined;
349
+ }
324
350
  function validateAutoloopEffort(role, effort) {
325
351
  if (effort === undefined)
326
352
  return;
@@ -772,6 +798,8 @@ function readClosedLedgerRestore(ledgerDir) {
772
798
  }
773
799
  export class SessionManager {
774
800
  sessions = new Map();
801
+ /** Goal adapters over `claude` sessions, by session name; dropped when the session stops. */
802
+ _claudeGoals = new Map();
775
803
  _pendingSessions = new Map();
776
804
  /**
777
805
  * Starts that passed the capacity check and are not in `sessions` yet. The
@@ -789,6 +817,12 @@ export class SessionManager {
789
817
  /** In-flight proxy startup, so concurrent callers share one server. */
790
818
  _proxyStartPromise = null;
791
819
  _activePids = new Map();
820
+ /**
821
+ * Each recorded pid's command line when it was recorded. An orphan is killed
822
+ * only if it is still that process: this is what lets cleanup recognise an
823
+ * ACP agent or a custom engine, whose binary is no fixed name.
824
+ */
825
+ _pidCommands = new Map();
792
826
  _circuitBreaker = new CircuitBreaker();
793
827
  _inbox = new InboxManager();
794
828
  /** cwd → detected language, so the manifest probe runs once per directory. */
@@ -929,6 +963,7 @@ export class SessionManager {
929
963
  const persisted = skipPersist ? undefined : this.persistedSessions.get(name);
930
964
  // Unified: only use resumeSessionId (claudeResumeId is an internal alias, not exposed)
931
965
  const resumeId = config.resumeSessionId ?? persisted?.claudeSessionId;
966
+ const engine = config.engine || persisted?.engine || 'claude';
932
967
  // ORDER IS LOAD-BEARING — do not "fix" it by moving `...config` up.
933
968
  //
934
969
  // Object spread copies own keys even when their value is `undefined`, so any
@@ -944,7 +979,7 @@ export class SessionManager {
944
979
  cwd: config.cwd || persisted?.cwd || process.cwd(),
945
980
  permissionMode: config.permissionMode || this.pluginConfig.defaultPermissionMode,
946
981
  effort: config.effort || this.pluginConfig.defaultEffort,
947
- model: config.model || persisted?.model || this.pluginConfig.defaultModel,
982
+ model: config.model || persisted?.model || defaultModelFor(engine, this.pluginConfig.defaultModel),
948
983
  sandboxMode: config.sandboxMode ?? persisted?.sandboxMode,
949
984
  autoCompactPercent: config.autoCompactPercent ?? persisted?.autoCompactPercent,
950
985
  chrome: config.chrome ?? persisted?.chrome,
@@ -955,16 +990,15 @@ export class SessionManager {
955
990
  if (fullConfig.model) {
956
991
  fullConfig.resolvedModel = this._resolveModel(fullConfig.model, fullConfig.modelOverrides);
957
992
  }
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
993
  // Write the resolved engine back so downstream consumers of the managed
963
994
  // config (agy resume-id lookups, _persistSession's registry entry) see the
964
995
  // real engine even when it came from the persisted registry.
965
996
  fullConfig.engine = engine;
966
997
  // Circuit breaker — reject early if engine is in backoff
967
998
  this._circuitBreaker.check(engine);
999
+ // Auto-inject proxy baseUrl for non-Claude models on the claude engine.
1000
+ // Starts a local proxy server that converts Anthropic → OpenAI format
1001
+ // and forwards to the OpenClaw gateway. Zero config required.
968
1002
  if (engine === 'claude' && fullConfig.resolvedModel && !fullConfig.baseUrl) {
969
1003
  if (!isClaudeModel(fullConfig.resolvedModel)) {
970
1004
  const proxyPort = await this._ensureProxyServer();
@@ -987,6 +1021,9 @@ export class SessionManager {
987
1021
  // Track child process PID for orphan cleanup
988
1022
  if (session.pid) {
989
1023
  this._activePids.set(name, session.pid);
1024
+ const command = processCommand(session.pid);
1025
+ if (command)
1026
+ this._pidCommands.set(session.pid, command);
990
1027
  this._savePids();
991
1028
  }
992
1029
  const managed = {
@@ -1008,6 +1045,10 @@ export class SessionManager {
1008
1045
  return this._toSessionInfo(name, managed);
1009
1046
  }
1010
1047
  async sendMessage(name, message, options = {}) {
1048
+ return this._sendTurn(name, message, options);
1049
+ }
1050
+ /** `onDispatch` fires once the turn has passed every check and is handed to the engine. */
1051
+ async _sendTurn(name, message, options, onDispatch) {
1011
1052
  const managed = this._getSession(name);
1012
1053
  // Per-session serialization. Two concurrent sendMessage() calls on the
1013
1054
  // same session previously raced on PersistentClaudeSession._streamCallbacks
@@ -1103,7 +1144,14 @@ export class SessionManager {
1103
1144
  // A session handed off from another engine carries that conversation in
1104
1145
  // front of its first message; after that the engine holds it itself.
1105
1146
  const outgoing = managed.pendingHandoff ? `${managed.pendingHandoff}\n\n${message}` : message;
1147
+ onDispatch?.();
1106
1148
  const result = await managed.session.send(outgoing, sendOpts);
1149
+ const after = this._statsSnapshot(managed);
1150
+ const usage = {
1151
+ tokensIn: Math.max(0, after.tokensIn - ledgerBefore.tokensIn),
1152
+ tokensOut: Math.max(0, after.tokensOut - ledgerBefore.tokensOut),
1153
+ costUsd: Math.max(0, after.costUsd - ledgerBefore.costUsd),
1154
+ };
1107
1155
  // Update the resume-capable session ID if available (skip disk persist
1108
1156
  // for ephemeral sessions that were started with skipPersistence)
1109
1157
  const resumableId = this._managedResumeId(managed);
@@ -1145,11 +1193,12 @@ export class SessionManager {
1145
1193
  sessionId: this._managedResumeId(managed),
1146
1194
  error: turnError,
1147
1195
  events: [],
1196
+ usage,
1148
1197
  ...(permissionDenials.length ? { permissionDenials } : {}),
1149
1198
  ...(images.length ? { images } : {}),
1150
1199
  };
1151
1200
  }
1152
- return { output: '', sessionId: this._managedResumeId(managed), events: [] };
1201
+ return { output: '', sessionId: this._managedResumeId(managed), events: [], usage };
1153
1202
  }
1154
1203
  catch (err) {
1155
1204
  turnError = err.message;
@@ -1219,6 +1268,7 @@ export class SessionManager {
1219
1268
  engine: opts.engine,
1220
1269
  ...(opts.model ? { model: opts.model } : {}),
1221
1270
  ...(opts.customEngine ? { customEngine: opts.customEngine } : {}),
1271
+ ...(opts.acpAgent ? { acpAgent: opts.acpAgent } : {}),
1222
1272
  });
1223
1273
  const target = this._getSession(targetName);
1224
1274
  // The new session's record starts as the source's, so a second handoff from
@@ -1534,6 +1584,11 @@ export class SessionManager {
1534
1584
  if (!contract)
1535
1585
  throw new Error('verifyRun requires a contract with at least one recognised check');
1536
1586
  const runId = args.label || `verify-${Date.now().toString(36)}`;
1587
+ // The bundle goes under the run store, keyed by the label. A label naming a
1588
+ // workflow run would plant this evidence in that run's directory.
1589
+ if (runExists(runId)) {
1590
+ throw new Error(`verify_run label '${runId}' names an existing workflow run; choose another label`);
1591
+ }
1537
1592
  const dir = kernelRunDir(runId);
1538
1593
  const evidenceId = 'verify-01';
1539
1594
  const { results, rounds } = await runContract(contract, {
@@ -1558,9 +1613,9 @@ export class SessionManager {
1558
1613
  async stopSession(name, opts = {}) {
1559
1614
  const managed = this._getSession(name);
1560
1615
  managed.session.stop();
1561
- this.sessions.delete(name);
1616
+ this._removeSession(name);
1562
1617
  // Remove PID tracking
1563
- this._activePids.delete(name);
1618
+ this._forgetPid(name);
1564
1619
  this._savePids();
1565
1620
  if (!opts.keepPersisted) {
1566
1621
  // Explicit stop = user intent to end session — remove from disk too.
@@ -1569,6 +1624,9 @@ export class SessionManager {
1569
1624
  // Claude conversation) pass keepPersisted: true.
1570
1625
  this._forgetPersisted(name);
1571
1626
  this._savePersisted();
1627
+ // Its unread mail was for this conversation; a later session that takes
1628
+ // the name is a different one and must not receive it.
1629
+ this._inbox.clear(name);
1572
1630
  }
1573
1631
  }
1574
1632
  hasSession(name) {
@@ -1655,7 +1713,7 @@ export class SessionManager {
1655
1713
  }
1656
1714
  const oldConfig = { ...managed.config };
1657
1715
  managed.session.stop();
1658
- this.sessions.delete(name);
1716
+ this._removeSession(name);
1659
1717
  try {
1660
1718
  return await this.startSession({
1661
1719
  ...oldConfig,
@@ -1720,7 +1778,7 @@ export class SessionManager {
1720
1778
  newDisallowed = newDisallowed.filter((t) => !removeSet.has(t));
1721
1779
  }
1722
1780
  managed.session.stop();
1723
- this.sessions.delete(name);
1781
+ this._removeSession(name);
1724
1782
  try {
1725
1783
  return await this.startSession({
1726
1784
  ...oldConfig,
@@ -1959,8 +2017,12 @@ export class SessionManager {
1959
2017
  this.logger.info(`Stopped session: ${name}`);
1960
2018
  }
1961
2019
  this.sessions.clear();
2020
+ for (const adapter of this._claudeGoals.values())
2021
+ adapter.detach();
2022
+ this._claudeGoals.clear();
1962
2023
  // Clear PID tracking
1963
2024
  this._activePids.clear();
2025
+ this._pidCommands.clear();
1964
2026
  this._savePids();
1965
2027
  // Stop proxy server
1966
2028
  if (this._proxyServer) {
@@ -1977,6 +2039,29 @@ export class SessionManager {
1977
2039
  const session = this.sessions.get(name)?.session;
1978
2040
  return session instanceof PersistentCodexAppServerSession ? session : undefined;
1979
2041
  }
2042
+ /**
2043
+ * The goal surface of the session behind `name`: a `codex-app` session as it
2044
+ * is, a `claude` session through `ClaudeGoalSession` (one per session, made on
2045
+ * first use with what the node knows of the thread). Other engines have none.
2046
+ */
2047
+ goalSession(name, open) {
2048
+ const session = this.sessions.get(name)?.session;
2049
+ if (session instanceof PersistentCodexAppServerSession)
2050
+ return session;
2051
+ if (!(session instanceof PersistentClaudeSession))
2052
+ return undefined;
2053
+ let adapter = this._claudeGoals.get(name);
2054
+ // A session replaced under the same name (model switch, tool update) gets a new adapter.
2055
+ if (adapter && adapter.host !== session) {
2056
+ adapter.detach();
2057
+ adapter = undefined;
2058
+ }
2059
+ if (!adapter) {
2060
+ adapter = new ClaudeGoalSession(session, open);
2061
+ this._claudeGoals.set(name, adapter);
2062
+ }
2063
+ return adapter;
2064
+ }
1980
2065
  /**
1981
2066
  * Act on a `codex-app` session's goal through the app-server's
1982
2067
  * `thread/goal/*` RPCs. Used by the `codex_goal_*` tools; `get` asks the
@@ -2436,6 +2521,18 @@ export class SessionManager {
2436
2521
  }
2437
2522
  // ─── PID Tracking ──────────────────────────────────────────────────────
2438
2523
  static PID_FILE = path.join(os.homedir(), '.openclaw', 'session-pids.json');
2524
+ /** Take a session out of memory, with the goal adapter listening to it. */
2525
+ _removeSession(name) {
2526
+ this._claudeGoals.get(name)?.detach();
2527
+ this._claudeGoals.delete(name);
2528
+ this.sessions.delete(name);
2529
+ }
2530
+ _forgetPid(name) {
2531
+ const pid = this._activePids.get(name);
2532
+ if (pid !== undefined)
2533
+ this._pidCommands.delete(pid);
2534
+ this._activePids.delete(name);
2535
+ }
2439
2536
  _savePids() {
2440
2537
  try {
2441
2538
  const dir = path.dirname(SessionManager.PID_FILE);
@@ -2479,11 +2576,13 @@ export class SessionManager {
2479
2576
  pid: entry.pid,
2480
2577
  ownerPid: entry.ownerPid,
2481
2578
  since: entry.since ?? now,
2579
+ ...(typeof entry.command === 'string' ? { command: entry.command } : {}),
2482
2580
  };
2483
2581
  }
2484
2582
  // Add OUR current entries
2485
2583
  for (const [name, pid] of this._activePids) {
2486
- merged[name] = { pid, ownerPid: process.pid, since: now };
2584
+ const command = this._pidCommands.get(pid);
2585
+ merged[name] = { pid, ownerPid: process.pid, since: now, ...(command ? { command } : {}) };
2487
2586
  }
2488
2587
  fs.writeFileSync(SessionManager.PID_FILE, JSON.stringify(merged));
2489
2588
  }
@@ -2495,7 +2594,7 @@ export class SessionManager {
2495
2594
  * Verify that a PID belongs to a known coding CLI before killing it.
2496
2595
  * Prevents killing unrelated processes if the OS recycled the PID.
2497
2596
  */
2498
- _isKnownCliProcess(pid) {
2597
+ _isKnownCliProcess(pid, recordedCommand) {
2499
2598
  // Match known CLI binaries by basename to avoid false positives
2500
2599
  // (e.g., 'agent' must not match 'ssh-agent' or 'gpg-agent')
2501
2600
  // Anchor each name to executable/path position ((?:^|[/\s])name(?:[\s/]|$))
@@ -2510,16 +2609,14 @@ export class SessionManager {
2510
2609
  ...ENGINE_BINARY_NAMES.map((bin) => new RegExp(`(?:^|[/\\s])${bin}(?:[\\s/]|$)`)),
2511
2610
  /(?:^|\/)agent(?:[\s/]|$)/, // 'agent' only as executable/after a slash (not ssh-agent)
2512
2611
  ];
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 {
2612
+ const cmd = processCommand(pid);
2613
+ if (cmd === undefined)
2521
2614
  return false; // ps failed — process likely dead or not accessible
2522
- }
2615
+ // The same command line as when it was recorded: the process itself, not a
2616
+ // recycled pid — whatever binary it runs.
2617
+ if (recordedCommand && cmd === recordedCommand)
2618
+ return true;
2619
+ return knownPatterns.some((pattern) => pattern.test(cmd));
2523
2620
  }
2524
2621
  _cleanupOrphanedPids() {
2525
2622
  try {
@@ -2530,6 +2627,7 @@ export class SessionManager {
2530
2627
  // Resolve entry shape: legacy = number; current = { pid, ownerPid, since }.
2531
2628
  let pid;
2532
2629
  let ownerPid;
2630
+ let recordedCommand;
2533
2631
  if (typeof raw === 'number') {
2534
2632
  pid = raw;
2535
2633
  ownerPid = null; // unknown owner — treat conservatively (skip kill)
@@ -2540,6 +2638,7 @@ export class SessionManager {
2540
2638
  continue;
2541
2639
  pid = e.pid;
2542
2640
  ownerPid = typeof e.ownerPid === 'number' ? e.ownerPid : null;
2641
+ recordedCommand = typeof e.command === 'string' ? e.command : undefined;
2543
2642
  }
2544
2643
  else {
2545
2644
  continue;
@@ -2572,7 +2671,7 @@ export class SessionManager {
2572
2671
  try {
2573
2672
  process.kill(pid, 0); // check if alive
2574
2673
  // Alive — but verify it's actually a coding CLI, not a recycled PID
2575
- if (!this._isKnownCliProcess(pid)) {
2674
+ if (!this._isKnownCliProcess(pid, recordedCommand)) {
2576
2675
  this.logger.info(`PID ${pid} (session: ${name}) is alive but not a known CLI — skipping kill`);
2577
2676
  continue;
2578
2677
  }
@@ -2804,12 +2903,26 @@ export class SessionManager {
2804
2903
  throw new Error(`Council '${id}' not found`);
2805
2904
  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
2905
  }
2906
+ /**
2907
+ * Abort a council running in this process. One that is not — finished, or
2908
+ * running elsewhere — is left alone and the result says so: its worktrees are
2909
+ * either awaiting accept/reject or in use by the process that runs it.
2910
+ */
2807
2911
  councilAbort(id) {
2808
2912
  // Cancel the run first so the kernel stops advancing, then abort the engine
2809
2913
  // so the current round tears down its worktrees.
2810
- if (!this.kernel.cancel(id) && !loadRun(id))
2914
+ if (this.kernel.cancel(id)) {
2915
+ this.kernel.handle(id, LEGACY_NODE)?.abort();
2916
+ return { aborted: true };
2917
+ }
2918
+ const record = loadRun(id);
2919
+ if (!record)
2811
2920
  throw new Error(`Council '${id}' not found`);
2812
- this.kernel.handle(id, LEGACY_NODE)?.abort();
2921
+ return {
2922
+ aborted: false,
2923
+ state: record.state,
2924
+ reason: `Council '${id}' is ${record.state} and not running in this process — nothing was aborted`,
2925
+ };
2813
2926
  }
2814
2927
  councilInject(id, message) {
2815
2928
  this._liveCouncil(id).injectMessage(message);
@@ -2818,10 +2931,34 @@ export class SessionManager {
2818
2931
  return this._councilForPostProcessing(id).review();
2819
2932
  }
2820
2933
  async councilAccept(id) {
2821
- return this._councilForPostProcessing(id).accept();
2934
+ const result = await this._councilForPostProcessing(id).accept();
2935
+ this._recordCouncilDecision(id, 'accepted');
2936
+ return result;
2822
2937
  }
2823
2938
  async councilReject(id, feedback) {
2824
- return this._councilForPostProcessing(id).reject(feedback);
2939
+ const result = await this._councilForPostProcessing(id).reject(feedback);
2940
+ this._recordCouncilDecision(id, 'rejected');
2941
+ return result;
2942
+ }
2943
+ /**
2944
+ * Write the decision onto the run, which is where `council_status` reads it.
2945
+ * The `Council` that took it is usually rebuilt from the record and dropped,
2946
+ * so its own status would go nowhere.
2947
+ */
2948
+ _recordCouncilDecision(id, status) {
2949
+ // A council live in this process carries the status on its own session.
2950
+ if (this.kernel.handle(id, LEGACY_NODE))
2951
+ return;
2952
+ let recorded = false;
2953
+ let why = 'the run has not finished';
2954
+ try {
2955
+ recorded = this.kernel.amendFinishedNode(id, LEGACY_NODE, { councilStatus: status }, `council ${status}`);
2956
+ }
2957
+ catch (err) {
2958
+ why = err.message;
2959
+ }
2960
+ if (!recorded)
2961
+ this.logger.warn(`[council] ${id}: ${status}, but the status could not be recorded (${why})`);
2825
2962
  }
2826
2963
  /**
2827
2964
  * A `Council` for review / accept / reject.
@@ -2870,6 +3007,7 @@ export class SessionManager {
2870
3007
  synthesize: config.synthesize,
2871
3008
  synthesisEngine: config.synthesisEngine,
2872
3009
  synthesisModel: config.synthesisModel,
3010
+ synthesisAcpAgent: config.synthesisAcpAgent,
2873
3011
  synthesisPermissionMode: config.synthesisPermissionMode,
2874
3012
  maxTurnsPerAgent: config.maxTurnsPerAgent,
2875
3013
  maxBudgetUsd: config.maxBudgetUsd,
@@ -2895,8 +3033,30 @@ export class SessionManager {
2895
3033
  getSession: (name) => this.sessions.get(name),
2896
3034
  exists: (name) => this.sessions.has(name),
2897
3035
  allNames: () => this.sessions.keys(),
3036
+ deliver: (name, text) => this._deliverInboxTurn(name, text),
2898
3037
  };
2899
3038
  }
3039
+ /**
3040
+ * An inbox delivery is a turn like any other — capped, written to the run
3041
+ * ledger and kept in the handoff record — that its sender does not wait on.
3042
+ * Resolves once the turn reaches the engine; a refusal before that (spend cap,
3043
+ * session stopped) rejects so the inbox queues the message instead. A failure
3044
+ * after it belongs to the turn and is recorded with it.
3045
+ */
3046
+ _deliverInboxTurn(name, text) {
3047
+ return new Promise((resolve, reject) => {
3048
+ let dispatched = false;
3049
+ this._sendTurn(name, text, {}, () => {
3050
+ dispatched = true;
3051
+ resolve();
3052
+ }).then(() => resolve(), (err) => {
3053
+ if (!dispatched)
3054
+ reject(err);
3055
+ else
3056
+ this.logger.warn(`[SessionManager] inbox turn on '${name}' failed: ${err.message}`);
3057
+ });
3058
+ });
3059
+ }
2900
3060
  async sessionSendTo(from, to, message, summary) {
2901
3061
  return this._inbox.sendTo(from, to, message, this._sessionLookup, summary, (name, err) => {
2902
3062
  this.logger.error(`Broadcast delivery to '${name}' failed:`, err.message);
@@ -2972,10 +3132,14 @@ export class SessionManager {
2972
3132
  // Every reviewer runs read-only (see below). A custom reviewer has no config
2973
3133
  // to start from, so it is turned away here instead of failing one reviewer
2974
3134
  // at a time. (grok was turned away too until its read-only mode moved onto
2975
- // grok's OS sandbox.)
3135
+ // grok's OS sandbox.) An ACP agent cannot be held read-only at all: its tools
3136
+ // run inside the agent, so the session would refuse to start.
2976
3137
  if ((opts?.engines ?? []).includes('custom')) {
2977
3138
  throw new Error('ultrareview cannot use custom: reviewers run read-only, which a custom engine cannot be configured for here');
2978
3139
  }
3140
+ if ((opts?.engines ?? []).includes('acp')) {
3141
+ throw new Error('ultrareview cannot use acp: reviewers run read-only, which an ACP agent cannot be held to');
3142
+ }
2979
3143
  const id = `ultrareview-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
2980
3144
  const agentCount = Math.min(20, Math.max(1, opts?.agentCount || 5));
2981
3145
  const result = {
@@ -3104,9 +3268,10 @@ export class SessionManager {
3104
3268
  // Review is read-only: reviewers analyse and report without modifying the
3105
3269
  // very code they review. Unlike council, fan-out shares the project dir —
3106
3270
  // 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.
3271
+ // engine. `plan` reaches only the engines with a plan mode (Claude, Grok);
3272
+ // `sandboxMode: 'read-only'` is the engine-agnostic setting, which codex,
3273
+ // agy and opencode map to their own read-only modes. Before it was set, a
3274
+ // codex reviewer ran workspace-write.
3110
3275
  permissionMode: 'plan',
3111
3276
  sandboxMode: 'read-only',
3112
3277
  }));
@@ -3860,14 +4025,14 @@ export class SessionManager {
3860
4025
  catch {
3861
4026
  // Best-effort — session may already be dead; must not block TTL cleanup
3862
4027
  }
3863
- this.sessions.delete(name);
4028
+ this._removeSession(name);
3864
4029
  // The child is gone, so its PID must go too — `stopSession` does this
3865
4030
  // and the TTL path did not, so `_activePids` only ever grew and the
3866
4031
  // next `_savePids()` rewrote those dead PIDs to disk under the current
3867
4032
  // owner. After an unclean exit `_cleanupOrphanedPids()` reads them back
3868
4033
  // and probes each one; a PID the OS has since recycled to a
3869
4034
  // coding-CLI-shaped process gets killed.
3870
- this._activePids.delete(name);
4035
+ this._forgetPid(name);
3871
4036
  pidsChanged = true;
3872
4037
  // NOTE: do NOT delete from persistedSessions — idle cleanup is
3873
4038
  // in-memory only. Persisted entries survive for PERSIST_DISK_TTL_MS