@scotthuang/agent-knock-knock 0.2.47 → 0.2.49

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 (48) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +114 -147
  3. package/dist/src/approval-policy.d.ts +2 -1
  4. package/dist/src/approval-policy.js +14 -6
  5. package/dist/src/approval-policy.js.map +1 -1
  6. package/dist/src/claude-hook-protocol.js.map +1 -1
  7. package/dist/src/claude-hook-store.js +17 -6
  8. package/dist/src/claude-hook-store.js.map +1 -1
  9. package/dist/src/claude-local-transcript-provider.d.ts +40 -0
  10. package/dist/src/claude-local-transcript-provider.js +804 -0
  11. package/dist/src/claude-local-transcript-provider.js.map +1 -0
  12. package/dist/src/claude-terminal-agent-adapter.d.ts +3 -0
  13. package/dist/src/claude-terminal-agent-adapter.js +146 -47
  14. package/dist/src/claude-terminal-agent-adapter.js.map +1 -1
  15. package/dist/src/cli.js +2473 -834
  16. package/dist/src/cli.js.map +1 -1
  17. package/dist/src/codex-session-provider.js.map +1 -1
  18. package/dist/src/codex-store-adapter.js.map +1 -1
  19. package/dist/src/codex-terminal-agent-adapter.js.map +1 -1
  20. package/dist/src/doctor-capabilities.d.ts +22 -0
  21. package/dist/src/doctor-capabilities.js +29 -0
  22. package/dist/src/doctor-capabilities.js.map +1 -0
  23. package/dist/src/executors.js.map +1 -1
  24. package/dist/src/openclaw-plugin-helpers.d.ts +49 -0
  25. package/dist/src/openclaw-plugin-helpers.js +246 -0
  26. package/dist/src/openclaw-plugin-helpers.js.map +1 -0
  27. package/dist/src/openclaw-plugin.js +94 -271
  28. package/dist/src/openclaw-plugin.js.map +1 -1
  29. package/dist/src/runtime-log.js +54 -2
  30. package/dist/src/runtime-log.js.map +1 -1
  31. package/dist/src/store.js +432 -12
  32. package/dist/src/store.js.map +1 -1
  33. package/dist/src/terminal-agent-adapter.d.ts +3 -0
  34. package/dist/src/terminal-agent-adapter.js.map +1 -1
  35. package/dist/src/terminal-agent-bridge.d.ts +51 -3
  36. package/dist/src/terminal-agent-bridge.js +276 -39
  37. package/dist/src/terminal-agent-bridge.js.map +1 -1
  38. package/dist/src/terminal-agent-registry.js.map +1 -1
  39. package/dist/src/terminal-control-provider.d.ts +3 -1
  40. package/dist/src/terminal-control-provider.js +41 -37
  41. package/dist/src/terminal-control-provider.js.map +1 -1
  42. package/dist/src/terminal-process-source.d.ts +12 -3
  43. package/dist/src/terminal-process-source.js +37 -6
  44. package/dist/src/terminal-process-source.js.map +1 -1
  45. package/dist/src/transcript.js.map +1 -1
  46. package/openclaw.plugin.json +4 -4
  47. package/package.json +11 -6
  48. package/templates/openclaw-skills/agent-knock-knock/SKILL.md +10 -24
package/dist/src/cli.js CHANGED
@@ -7,29 +7,40 @@ import process from "node:process";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { createCodexTerminalAgentAdapter, detectCodexDurableCompletion } from "./codex-terminal-agent-adapter.js";
9
9
  import { createClaudeTerminalAgentAdapter } from "./claude-terminal-agent-adapter.js";
10
+ import { captureClaudeTranscriptAnchor, defaultClaudeHome, detectClaudeTranscriptCompletion } from "./claude-local-transcript-provider.js";
10
11
  import { ClaudeHookStore, ClaudeHookStoreError, defaultClaudeHookStoreDir } from "./claude-hook-store.js";
11
12
  import { claudePermissionHookOutput } from "./claude-hook-protocol.js";
12
- import { defaultClaudeSettingsPath, installClaudeHooks, loadTrustedClaudeTokenjuiceLaunchers } from "./claude-hook-installer.js";
13
+ import { defaultClaudeSettingsPath, loadTrustedClaudeTokenjuiceLaunchers } from "./claude-hook-installer.js";
13
14
  import { CodexLocalSessionProvider } from "./codex-local-session-provider.js";
14
15
  import { CodexStoreAdapter } from "./codex-store-adapter.js";
15
16
  import { applyMessageToConversation, budgetAction, createConversation, createMessage, executorForConversation, extractStructuredMessage, parseMessageJson, resolveExecutor } from "./protocol.js";
16
17
  import { EXECUTOR_KINDS, acpxCommandForExecutor, executorDefinitionForKind, modelEnvForExecutor, normalizeModelForExecutor, proxyEnvForExecutor } from "./executors.js";
17
18
  import { executorBootstrapPrompt } from "./bootstrap.js";
18
- import { writeRuntimeLog } from "./runtime-log.js";
19
+ import { redactString, writeRuntimeLog } from "./runtime-log.js";
19
20
  import { formatTranscript, readNdjsonLog } from "./transcript.js";
20
- import { appendEvent, defaultStoreDir, listConversations, logPathForStatePath, loadConversationById, loadState, messageEvent, pathsForConversation, pathsForConversationDir, saveState, statePathForConversationId } from "./store.js";
21
+ import { appendEvent, defaultStoreDir, ensureDir, listConversations, logPathForStatePath, loadConversationById, loadState, messageEvent, pathsForConversation, pathsForConversationDir, saveState, statePathForConversationId } from "./store.js";
21
22
  import { planFork, planTakeover } from "./session-takeover-planner.js";
22
- import { StaticTerminalControlProvider, TmuxTerminalControlProvider } from "./terminal-control-provider.js";
23
+ import { StaticTerminalControlProvider, TmuxTerminalControlProvider, terminalPaneContainsProcess } from "./terminal-control-provider.js";
23
24
  import { parseTerminalConversationId } from "./terminal-agent-adapter.js";
24
25
  import { createProductionTerminalAgentRegistry } from "./terminal-agent-registry.js";
25
26
  import { StaticTerminalProcessSource, SystemTerminalProcessSource } from "./terminal-process-source.js";
26
27
  import { TerminalAgentBridge } from "./terminal-agent-bridge.js";
28
+ import { evaluateApprovalPolicy } from "./approval-policy.js";
29
+ import { evaluateDoctorCapabilities } from "./doctor-capabilities.js";
27
30
  const DEFAULT_IDLE_TIMEOUT_MINUTES = 10080;
28
31
  const DEFAULT_AGENT_TIMEOUT_MINUTES = 60;
29
32
  const DEFAULT_AGENT_HARD_TIMEOUT_MINUTES = 720;
30
33
  const DEFAULT_MONITOR_POLL_INTERVAL_MS = 5000;
34
+ const CLAUDE_SCREEN_APPROVAL_TTL_MS = 10 * 60 * 1000;
35
+ const CALLBACK_DELIVERY_TIMEOUT_MS = 30_000;
31
36
  const CALLBACK_RETRY_DELAYS_MS = [5000, 15000, 60000, 60000];
32
- const DEFAULT_CODEX_ACPX_AGENT_COMMAND = "npx -y @agentclientprotocol/codex-acp@^1.1.0";
37
+ const TERMINAL_BRIDGE_MONITOR_LOCK_VERSION = 1;
38
+ const MINIMUM_NODE_VERSION = "22.14.0";
39
+ const PRIVATE_LOCK_FILE_MODE = 0o600;
40
+ const NO_FOLLOW_FLAG = typeof fs.constants.O_NOFOLLOW === "number"
41
+ ? fs.constants.O_NOFOLLOW
42
+ : 0;
43
+ const DEFAULT_CODEX_ACPX_AGENT_COMMAND = "npx -y @agentclientprotocol/codex-acp@1.1.7";
33
44
  const CONVERSATION_STATUSES = new Set([
34
45
  "created",
35
46
  "running",
@@ -102,7 +113,13 @@ catch (error) {
102
113
  process.exit(1);
103
114
  }
104
115
  async function runCommand(commandName, options) {
105
- if (commandName === "new") {
116
+ if (commandName === "help" || commandName === "--help" || commandName === "-h") {
117
+ usage();
118
+ }
119
+ else if (commandName === "version" || commandName === "--version" || commandName === "-v") {
120
+ printVersion();
121
+ }
122
+ else if (commandName === "new") {
106
123
  runNew(options);
107
124
  }
108
125
  else if (commandName === "record") {
@@ -135,11 +152,14 @@ async function runCommand(commandName, options) {
135
152
  else if (commandName === "renew") {
136
153
  await runRenew(options);
137
154
  }
155
+ else if (commandName === "reconcile-monitors") {
156
+ await runReconcileMonitors(options);
157
+ }
138
158
  else if (commandName === "recover") {
139
159
  runRecover(options);
140
160
  }
141
161
  else if (commandName === "close") {
142
- runClose(options);
162
+ await runClose(options);
143
163
  }
144
164
  else if (commandName === "transcript") {
145
165
  runTranscript(options);
@@ -148,7 +168,7 @@ async function runCommand(commandName, options) {
148
168
  runInstallOpenClaw(options);
149
169
  }
150
170
  else if (commandName === "install-claude-hooks") {
151
- runInstallClaudeHooks(options);
171
+ throw new Error("install-claude-hooks is no longer supported; Claude tmux control now works without modifying Claude Code settings");
152
172
  }
153
173
  else if (commandName === "claude-hook") {
154
174
  await runClaudeHook(options);
@@ -223,34 +243,6 @@ function runInstallOpenClaw(options) {
223
243
  : "Agent Knock Knock is installed. Try: AKK list"
224
244
  });
225
245
  }
226
- function runInstallClaudeHooks(options) {
227
- const configuredExecutable = stringValue(options.executablePath ?? options.binPath);
228
- let executablePath;
229
- if (configuredExecutable) {
230
- executablePath = path.resolve(expandHome(configuredExecutable));
231
- }
232
- else {
233
- try {
234
- executablePath = resolveExecutable("agent-knock-knock");
235
- }
236
- catch {
237
- executablePath = path.resolve(process.argv[1]);
238
- }
239
- }
240
- const result = installClaudeHooks({
241
- executablePath,
242
- settingsPath: expandHome(options.settingsPath ?? options.settings),
243
- dryRun: options.dryRun === true
244
- });
245
- printJson({
246
- installed: result.written || !result.changed,
247
- ...result,
248
- executablePath,
249
- next: result.dryRun
250
- ? "Run again without --dry-run to install the Claude Code hooks."
251
- : "Claude Code hooks are configured. Existing sessions pick them up through settings reload; start a new session if needed."
252
- });
253
- }
254
246
  async function runClaudeHook(options) {
255
247
  const rawInput = fs.readFileSync(0, "utf8");
256
248
  let input;
@@ -329,8 +321,18 @@ function installOpenClawPlugin(openclawBin, root) {
329
321
  return { mode: "replaced" };
330
322
  }
331
323
  function runDoctor(options) {
332
- const commands = ["node", "openclaw", "acpx", "codex", "claude", "cursor"];
333
- const checks = commands.map((commandName) => executableCheck(commandName));
324
+ const commands = ["node", "openclaw", "tmux", "acpx", "codex", "claude", "cursor"];
325
+ const checks = commands.map((commandName) => {
326
+ const check = executableCheck(commandName);
327
+ return commandName === "node"
328
+ ? {
329
+ ...check,
330
+ version: process.versions.node,
331
+ version_supported: versionAtLeast(process.versions.node, MINIMUM_NODE_VERSION),
332
+ minimum_version: MINIMUM_NODE_VERSION
333
+ }
334
+ : check;
335
+ });
334
336
  const root = packageRootDir();
335
337
  const packageFiles = [
336
338
  "dist/src/cli.js",
@@ -344,24 +346,44 @@ function runDoctor(options) {
344
346
  exists: fs.existsSync(filePath)
345
347
  };
346
348
  });
347
- const requiredOk = checks
348
- .filter((check) => ["node", "openclaw", "acpx"].includes(check.command))
349
- .every((check) => check.available);
350
- const agentOk = checks
351
- .filter((check) => ["codex", "claude", "cursor"].includes(check.command))
352
- .some((check) => check.available);
349
+ const capabilities = evaluateDoctorCapabilities(checks);
353
350
  const filesOk = packageFiles.every((check) => check.exists);
351
+ const ok = capabilities.coreOk && filesOk && capabilities.transportOk;
354
352
  printJson({
355
- ok: requiredOk && agentOk && filesOk,
353
+ ok,
356
354
  package_root: root,
357
355
  checks,
358
356
  package_files: packageFiles,
357
+ capabilities: {
358
+ tmux: capabilities.tmux,
359
+ acp: capabilities.acp
360
+ },
359
361
  notes: [
360
- "node, openclaw, and acpx are required.",
361
- "At least one local coding agent command should be available: codex, claude, or cursor."
362
+ `Node.js ${MINIMUM_NODE_VERSION}+ and OpenClaw are required.`,
363
+ "Choose tmux (recommended), ACPX/ACP, or install both.",
364
+ "tmux supports Codex and Claude Code; ACPX supports Codex, Claude Code, and Cursor.",
365
+ "Claude tmux completion is hook-free and fails closed unless the local transcript schema is verified."
362
366
  ],
363
367
  options
364
368
  });
369
+ if (!ok) {
370
+ process.exitCode = 1;
371
+ }
372
+ }
373
+ function versionAtLeast(version, minimum) {
374
+ const parsed = version.split(".").slice(0, 3).map((part) => Number.parseInt(part, 10));
375
+ const required = minimum.split(".").slice(0, 3).map((part) => Number.parseInt(part, 10));
376
+ if (parsed.length !== 3 ||
377
+ required.length !== 3 ||
378
+ [...parsed, ...required].some((part) => !Number.isInteger(part) || part < 0)) {
379
+ return false;
380
+ }
381
+ for (let index = 0; index < 3; index += 1) {
382
+ if (parsed[index] !== required[index]) {
383
+ return parsed[index] > required[index];
384
+ }
385
+ }
386
+ return true;
365
387
  }
366
388
  async function runAgent(options) {
367
389
  const agentCommand = required(options.agentCommand, "agent subcommand is required: takeover");
@@ -505,6 +527,7 @@ async function runAgentTakeover(options) {
505
527
  options,
506
528
  takeoverMatchKind: "terminal_control",
507
529
  terminalControl: target.terminalControl,
530
+ terminalAgentPid: target.pid,
508
531
  needsBootstrap: false
509
532
  });
510
533
  return {
@@ -666,7 +689,11 @@ function selectTerminateTarget({ plan, session, activeSessions, expectedPid, all
666
689
  }
667
690
  async function listActiveSessionsWithTerminalControl(provider, options, terminalProvider = createTerminalControlProvider(options)) {
668
691
  const activeSessions = await provider.listActiveSessions();
669
- return createTerminalAgentBridge(options, terminalProvider).attachProcesses(provider.agent, activeSessions);
692
+ const activePids = new Set(activeSessions.map((session) => session.pid));
693
+ const processTree = activePids.size > 0
694
+ ? await createTerminalProcessSource(options).listProcessSnapshots((snapshot) => activePids.has(snapshot.pid), { includeCwd: false, includeAncestors: true })
695
+ : [];
696
+ return createTerminalAgentBridge(options, terminalProvider).attachProcesses(provider.agent, activeSessions, { processTree });
670
697
  }
671
698
  function createTerminalControlProvider(options) {
672
699
  if (options.terminalsJson || options.terminalScreensJson || options.processesJson) {
@@ -689,6 +716,11 @@ function createClaudeHookStore(options = {}) {
689
716
  rootDir: expandHome(configuredRoot ?? defaultClaudeHookStoreDir())
690
717
  });
691
718
  }
719
+ function createConfiguredClaudeHookStore(options = {}) {
720
+ return stringValue(options.claudeHookStoreDir)
721
+ ? createClaudeHookStore(options)
722
+ : undefined;
723
+ }
692
724
  function loadClaudeAgentRows(options = {}) {
693
725
  let value;
694
726
  if (options.claudeAgentsJson !== undefined) {
@@ -741,6 +773,9 @@ function loadClaudeAgentRows(options = {}) {
741
773
  ...(stringValue(row.cwd) ? { cwd: stringValue(row.cwd) } : {}),
742
774
  ...(stringValue(row.kind) ? { kind: stringValue(row.kind) } : {}),
743
775
  ...(stringValue(row.sessionId) ? { sessionId: stringValue(row.sessionId) } : {}),
776
+ ...(Number.isSafeInteger(Number(row.startedAt)) && Number(row.startedAt) > 0
777
+ ? { startedAt: Number(row.startedAt) }
778
+ : {}),
744
779
  ...(stringValue(row.status) ? { status: stringValue(row.status) } : {})
745
780
  }];
746
781
  });
@@ -781,6 +816,7 @@ function isClaudeProcessCommand(commandText) {
781
816
  /[\\/]\.local[\\/]share[\\/]claude[\\/]versions[\\/][^\\/\s]+$/u.test(executable);
782
817
  }
783
818
  function createRuntimeTerminalAgentRegistry(options) {
819
+ const claudeHookStore = createConfiguredClaudeHookStore(options);
784
820
  return createProductionTerminalAgentRegistry({
785
821
  overrides: [
786
822
  createCodexTerminalAgentAdapter({
@@ -820,16 +856,51 @@ function createRuntimeTerminalAgentRegistry(options) {
820
856
  }),
821
857
  createClaudeTerminalAgentAdapter({
822
858
  agentRows: loadClaudeAgentRows(options),
823
- hookStore: createClaudeHookStore(options),
824
- trustedTokenjuiceLaunchers: loadTrustedClaudeTokenjuiceLaunchers(expandHome(options.claudeSettingsPath ?? defaultClaudeSettingsPath()))
859
+ async detectDurableCompletion(request) {
860
+ return detectClaudeTranscriptCompletion(request, {
861
+ claudeHome: expandHome(options.claudeHome),
862
+ agentRows: loadClaudeAgentRows(options)
863
+ });
864
+ },
865
+ ...(claudeHookStore
866
+ ? {
867
+ hookStore: claudeHookStore,
868
+ trustedTokenjuiceLaunchers: loadTrustedClaudeTokenjuiceLaunchers(expandHome(options.claudeSettingsPath ?? defaultClaudeSettingsPath()))
869
+ }
870
+ : {})
825
871
  })
826
872
  ]
827
873
  });
828
874
  }
829
- function createTerminalAgentBridge(options, terminalProvider = createTerminalControlProvider(options)) {
875
+ function createTerminalAgentBridge(options, terminalProvider = createTerminalControlProvider(options), registry = createRuntimeTerminalAgentRegistry(options)) {
876
+ const processSource = createTerminalProcessSource(options);
830
877
  return new TerminalAgentBridge({
831
- registry: createRuntimeTerminalAgentRegistry(options),
832
- terminalProvider
878
+ registry,
879
+ terminalProvider,
880
+ async verifyIdentity({ agent, pid, terminalControl }) {
881
+ const adapter = registry.require(agent);
882
+ const snapshots = await processSource.listProcessSnapshots(undefined, { includeCwd: false });
883
+ const snapshot = snapshots.find((candidate) => candidate.pid === pid);
884
+ if (!snapshot || !adapter.classifyProcess(snapshot)) {
885
+ throw new Error(`terminal conversation agent ${agent} with pid ${pid} is no longer active`);
886
+ }
887
+ const panes = await terminalProvider.listPanes();
888
+ const pane = panes.find((candidate) => candidate.kind === terminalControl.kind &&
889
+ candidate.target === terminalControl.target &&
890
+ candidate.panePid === terminalControl.panePid);
891
+ if (!pane || !terminalPaneContainsProcess(snapshot, pane, snapshots)) {
892
+ throw new Error(`terminal conversation agent ${agent} with pid ${pid} no longer belongs to pane ${terminalControl.target}`);
893
+ }
894
+ return {
895
+ terminalControl: {
896
+ ...terminalControl,
897
+ socketPath: pane.socketPath,
898
+ panePid: pane.panePid,
899
+ currentCommand: pane.currentCommand,
900
+ currentPath: pane.currentPath
901
+ }
902
+ };
903
+ }
833
904
  });
834
905
  }
835
906
  function planTerminalControlTakeover(session, activeSessions) {
@@ -926,6 +997,115 @@ function terminalRuntimeIdentityForConversation(conversation, terminalControl) {
926
997
  terminalTarget: terminalControl.target
927
998
  };
928
999
  }
1000
+ async function migrateLegacyTerminalAgentIdentity({ conversation, statePath, logPath, options }) {
1001
+ const nativeTakeover = isRecord(conversation?.native_session_takeover)
1002
+ ? conversation.native_session_takeover
1003
+ : undefined;
1004
+ const terminalControl = terminalControlFromTakeover(nativeTakeover);
1005
+ if (!nativeTakeover || !terminalControl) {
1006
+ return conversation;
1007
+ }
1008
+ const runtime = terminalRuntimeIdentityForConversation(conversation, terminalControl);
1009
+ if (Number.isInteger(runtime.pid) && Number(runtime.pid) > 0) {
1010
+ return conversation;
1011
+ }
1012
+ const executor = executorForConversation(conversation);
1013
+ const nativeSessionId = stringValue(nativeTakeover.native_session_id);
1014
+ if (executor.kind !== "codex" ||
1015
+ !nativeSessionId ||
1016
+ parseTerminalConversationId(nativeSessionId)) {
1017
+ return conversation;
1018
+ }
1019
+ let matchedProcess;
1020
+ try {
1021
+ const registry = createRuntimeTerminalAgentRegistry(options);
1022
+ const adapter = registry.require("codex");
1023
+ const snapshots = await createTerminalProcessSource(options).listProcessSnapshots((snapshot) => adapter.classifyProcess(snapshot) !== undefined, { includeAncestors: true });
1024
+ const panes = await createTerminalControlProvider(options).listPanes();
1025
+ const matchingPanes = panes.filter((pane) => pane.kind === terminalControl.kind &&
1026
+ pane.target === terminalControl.target &&
1027
+ pane.panePid === terminalControl.panePid);
1028
+ if (matchingPanes.length !== 1) {
1029
+ return conversation;
1030
+ }
1031
+ const candidates = snapshots.flatMap((snapshot) => {
1032
+ const classified = adapter.classifyProcess(snapshot);
1033
+ return classified ? [{ ...classified, agent: "codex" }] : [];
1034
+ });
1035
+ const matches = candidates.filter((candidate) => candidate.sessionId === nativeSessionId &&
1036
+ terminalPaneContainsProcess(candidate, matchingPanes[0], snapshots));
1037
+ if (matches.length !== 1) {
1038
+ return conversation;
1039
+ }
1040
+ matchedProcess = matches[0];
1041
+ }
1042
+ catch (error) {
1043
+ runtimeLog("warn", "legacy_terminal_agent_identity_migration_failed", {
1044
+ conversation_id: conversation.conversation_id,
1045
+ terminal_target: terminalControl.target,
1046
+ reason: error instanceof Error ? error.message : String(error)
1047
+ });
1048
+ return conversation;
1049
+ }
1050
+ if (!matchedProcess) {
1051
+ return conversation;
1052
+ }
1053
+ const releaseLock = acquireFileLock(`${statePath}.lock`);
1054
+ let migratedConversation = conversation;
1055
+ let migrated = false;
1056
+ try {
1057
+ const current = loadState(statePath);
1058
+ const currentTakeover = isRecord(current.native_session_takeover)
1059
+ ? current.native_session_takeover
1060
+ : undefined;
1061
+ const currentControl = terminalControlFromTakeover(currentTakeover);
1062
+ if (!currentTakeover || !currentControl) {
1063
+ return current;
1064
+ }
1065
+ const currentRuntime = terminalRuntimeIdentityForConversation(current, currentControl);
1066
+ if (Number.isInteger(currentRuntime.pid) && Number(currentRuntime.pid) > 0) {
1067
+ return current;
1068
+ }
1069
+ if (currentTakeover.native_session_id !== nativeSessionId ||
1070
+ currentControl.target !== terminalControl.target ||
1071
+ currentControl.socketPath !== terminalControl.socketPath ||
1072
+ currentControl.panePid !== terminalControl.panePid) {
1073
+ return current;
1074
+ }
1075
+ const migratedAt = new Date().toISOString();
1076
+ migratedConversation = {
1077
+ ...current,
1078
+ native_session_takeover: {
1079
+ ...currentTakeover,
1080
+ terminal_agent_pid: matchedProcess.pid,
1081
+ terminal_agent_session_id: matchedProcess.sessionId,
1082
+ terminal_agent_identity_migrated_at: migratedAt
1083
+ },
1084
+ updated_at: migratedAt
1085
+ };
1086
+ saveState(statePath, migratedConversation);
1087
+ migrated = true;
1088
+ }
1089
+ finally {
1090
+ releaseLock();
1091
+ }
1092
+ if (migrated) {
1093
+ appendEvent(logPath, {
1094
+ ts: new Date().toISOString(),
1095
+ conversation_id: migratedConversation.conversation_id,
1096
+ event: "terminal_agent_identity_migrated",
1097
+ terminal_target: terminalControl.target,
1098
+ terminal_agent_pid: matchedProcess.pid,
1099
+ native_session_id: nativeSessionId
1100
+ });
1101
+ runtimeLog("info", "terminal_agent_identity_migrated", {
1102
+ conversation_id: migratedConversation.conversation_id,
1103
+ terminal_target: terminalControl.target,
1104
+ terminal_agent_pid: matchedProcess.pid
1105
+ });
1106
+ }
1107
+ return migratedConversation;
1108
+ }
929
1109
  function isTerminalControlCapability(value) {
930
1110
  return typeof value === "string" && [
931
1111
  "screen_status",
@@ -1029,7 +1209,7 @@ function createForkConversation({ agent, strategy, session, contextPackage, fork
1029
1209
  next: `Use AKK send ${forkedConversation.conversation_id}: <message> to start the forked ${agent} session with the approved summary.`
1030
1210
  };
1031
1211
  }
1032
- function createNativeSessionConversation({ agent, strategy, session, modelInfo, options, takeoverMatchKind = strategy, terminalControl = undefined, needsBootstrap = true }) {
1212
+ function createNativeSessionConversation({ agent, strategy, session, modelInfo, options, takeoverMatchKind = strategy, terminalControl = undefined, terminalAgentPid = undefined, needsBootstrap = true }) {
1033
1213
  const workspace = session.cwd;
1034
1214
  const storeDir = expandHome(options.storeDir ?? options.logDir ?? defaultStoreDir(workspace));
1035
1215
  cleanupIdleConversations(storeDir, options);
@@ -1078,6 +1258,7 @@ function createNativeSessionConversation({ agent, strategy, session, modelInfo,
1078
1258
  native_session_takeover: {
1079
1259
  agent,
1080
1260
  native_session_id: session.id,
1261
+ terminal_agent_pid: terminalAgentPid,
1081
1262
  source_cwd: session.cwd,
1082
1263
  source_title: session.title,
1083
1264
  strategy,
@@ -1325,7 +1506,7 @@ function runDelegate(options) {
1325
1506
  throw new Error(cleanProcessText(ensureSession.stderr || ensureSession.stdout || `acpx ${executor.kind} sessions ensure exited with status ${ensureSession.status}`));
1326
1507
  }
1327
1508
  const outputPath = path.join(newResult.paths.conversationDir, `${executor.kind}-output.log`);
1328
- const outputFd = fs.openSync(outputPath, "a");
1509
+ const outputFd = openPrivateAppendFile(outputPath);
1329
1510
  const child = spawn(acpxPath, acpxArgs, {
1330
1511
  detached: true,
1331
1512
  stdio: ["ignore", outputFd, outputFd],
@@ -1472,12 +1653,12 @@ function startExecutorMonitor({ statePath, logPath, pid, outputPath, agentTimeou
1472
1653
  detached: true,
1473
1654
  stdio: "ignore",
1474
1655
  cwd: process.cwd(),
1475
- env: process.env
1656
+ env: environmentWithoutGatewayTokens()
1476
1657
  });
1477
1658
  child.unref();
1478
1659
  return child;
1479
1660
  }
1480
- function startTerminalBridgeMonitor({ statePath, logPath, agentTimeoutMinutes, agentHardTimeoutMinutes, pollIntervalMs, codexHome, claudeHookStoreDir }) {
1661
+ function startTerminalBridgeMonitor({ statePath, logPath, agentTimeoutMinutes, agentHardTimeoutMinutes, pollIntervalMs, codexHome, claudeHome, claudeHookStoreDir }) {
1481
1662
  const args = [
1482
1663
  new URL(import.meta.url).pathname,
1483
1664
  "monitor",
@@ -1496,6 +1677,9 @@ function startTerminalBridgeMonitor({ statePath, logPath, agentTimeoutMinutes, a
1496
1677
  if (codexHome) {
1497
1678
  args.push("--codex-home", codexHome);
1498
1679
  }
1680
+ if (claudeHome) {
1681
+ args.push("--claude-home", claudeHome);
1682
+ }
1499
1683
  if (claudeHookStoreDir) {
1500
1684
  args.push("--claude-hook-store-dir", claudeHookStoreDir);
1501
1685
  }
@@ -1503,7 +1687,7 @@ function startTerminalBridgeMonitor({ statePath, logPath, agentTimeoutMinutes, a
1503
1687
  detached: true,
1504
1688
  stdio: "ignore",
1505
1689
  cwd: process.cwd(),
1506
- env: process.env
1690
+ env: environmentWithoutGatewayTokens()
1507
1691
  });
1508
1692
  child.unref();
1509
1693
  return child;
@@ -1526,6 +1710,7 @@ function startTerminalBridgeMonitorForConversation({ conversation, statePath, lo
1526
1710
  DEFAULT_AGENT_HARD_TIMEOUT_MINUTES),
1527
1711
  pollIntervalMs: Number(options.monitorPollIntervalMs ?? DEFAULT_MONITOR_POLL_INTERVAL_MS),
1528
1712
  codexHome: options.codexHome,
1713
+ claudeHome: options.claudeHome ?? nativeTakeover?.["claude_home"],
1529
1714
  claudeHookStoreDir: options.claudeHookStoreDir ?? nativeTakeover?.["claude_hook_store_dir"]
1530
1715
  });
1531
1716
  }
@@ -1535,7 +1720,7 @@ function terminalBridgeEnabled(conversation) {
1535
1720
  : undefined;
1536
1721
  return nativeTakeover?.["terminal_bridge"] === true;
1537
1722
  }
1538
- function withTerminalBridgeState({ conversation, message, startedAt, agentTimeoutMinutes, agentHardTimeoutMinutes, preSendScreenFingerprint }) {
1723
+ function withTerminalBridgeState({ conversation, message, requestText, startedAt, agentTimeoutMinutes, agentHardTimeoutMinutes, preSendScreenFingerprint, claudeTranscriptAnchor, claudeHome }) {
1539
1724
  const nativeTakeover = isRecord(conversation.native_session_takeover)
1540
1725
  ? conversation.native_session_takeover
1541
1726
  : {};
@@ -1546,10 +1731,14 @@ function withTerminalBridgeState({ conversation, message, startedAt, agentTimeou
1546
1731
  terminal_bridge: true,
1547
1732
  terminal_bridge_started_at: startedAt,
1548
1733
  terminal_bridge_message_id: message.id,
1549
- terminal_bridge_request_text: String(message.body ?? ""),
1550
- terminal_bridge_request_hash: terminalBridgeRequestFingerprint(message.body),
1734
+ terminal_bridge_request_text: requestText,
1735
+ terminal_bridge_request_hash: terminalBridgeRequestFingerprint(requestText),
1551
1736
  terminal_bridge_pre_send_screen_fingerprint: preSendScreenFingerprint,
1737
+ claude_transcript_anchor: claudeTranscriptAnchor,
1738
+ claude_home: claudeHome,
1552
1739
  terminal_bridge_completion_claim: undefined,
1740
+ terminal_bridge_approval_dispatch: undefined,
1741
+ terminal_bridge_monitor_lock_version: TERMINAL_BRIDGE_MONITOR_LOCK_VERSION,
1553
1742
  terminal_bridge_monitor_started_at: startedAt,
1554
1743
  terminal_bridge_last_activity_at: startedAt,
1555
1744
  terminal_bridge_inactivity_timeout_minutes: agentTimeoutMinutes,
@@ -1561,6 +1750,9 @@ function withTerminalBridgeState({ conversation, message, startedAt, agentTimeou
1561
1750
  };
1562
1751
  }
1563
1752
  function activateClaudeHookLease({ options, conversation, message, terminalControl, expiresAt }) {
1753
+ if (!stringValue(options.claudeHookStoreDir)) {
1754
+ return undefined;
1755
+ }
1564
1756
  const runtime = terminalRuntimeIdentityForConversation(conversation, terminalControl);
1565
1757
  const pid = runtime.pid;
1566
1758
  const agentRow = pid === undefined
@@ -1667,7 +1859,9 @@ function renewClaudeHookLease(conversation, expiresAt) {
1667
1859
  const nativeTakeover = isRecord(conversation?.native_session_takeover)
1668
1860
  ? conversation.native_session_takeover
1669
1861
  : undefined;
1670
- if (nativeTakeover?.agent !== "claude") {
1862
+ if (nativeTakeover?.agent !== "claude" ||
1863
+ nativeTakeover.claude_hook_mode !== "enabled" ||
1864
+ !stringValue(nativeTakeover.claude_hook_store_dir)) {
1671
1865
  return undefined;
1672
1866
  }
1673
1867
  const conversationId = stringValue(conversation.conversation_id);
@@ -1773,16 +1967,23 @@ function modelForExecutor(executor, options = {}) {
1773
1967
  return normalizeModelForExecutor(executor, modelEnvForExecutor(executor, process.env));
1774
1968
  }
1775
1969
  function environmentForExecutor(executor, options = {}) {
1970
+ const environment = environmentWithoutGatewayTokens();
1776
1971
  const proxy = proxyForExecutor(executor, options);
1777
1972
  if (!proxy) {
1778
- return process.env;
1973
+ return environment;
1779
1974
  }
1780
1975
  return {
1781
- ...process.env,
1976
+ ...environment,
1782
1977
  ALL_PROXY: proxy,
1783
1978
  all_proxy: proxy
1784
1979
  };
1785
1980
  }
1981
+ function environmentWithoutGatewayTokens() {
1982
+ const environment = { ...process.env };
1983
+ delete environment.AKK_GATEWAY_TOKEN;
1984
+ delete environment.OPENCLAW_GATEWAY_TOKEN;
1985
+ return environment;
1986
+ }
1786
1987
  async function runList(options) {
1787
1988
  const storeDir = expandHome(options.storeDir ?? options.logDir ?? defaultStoreDir(process.cwd()));
1788
1989
  const cleanup = cleanupIdleConversations(storeDir, options);
@@ -1855,7 +2056,7 @@ async function buildNativeListGroups({ options, agentFilter, statusFilter }) {
1855
2056
  };
1856
2057
  }
1857
2058
  const terminalProvider = createTerminalControlProvider(options);
1858
- const bridge = new TerminalAgentBridge({ registry, terminalProvider });
2059
+ const bridge = createTerminalAgentBridge(options, terminalProvider, registry);
1859
2060
  const terminalScan = options.terminalDebug ? await terminalControlDiagnostics(terminalProvider) : undefined;
1860
2061
  const terminalControlled = [];
1861
2062
  const native = [];
@@ -1863,7 +2064,7 @@ async function buildNativeListGroups({ options, agentFilter, statusFilter }) {
1863
2064
  const errors = [];
1864
2065
  try {
1865
2066
  const processSource = createTerminalProcessSource(options);
1866
- const snapshots = await processSource.listProcessSnapshots((snapshot) => adapters.some((adapter) => adapter.capabilities.processDiscovery && adapter.classifyProcess(snapshot) !== undefined));
2067
+ const snapshots = await processSource.listProcessSnapshots((snapshot) => adapters.some((adapter) => adapter.capabilities.processDiscovery && adapter.classifyProcess(snapshot) !== undefined), { includeAncestors: true });
1867
2068
  const activeSessions = await bridge.listProcesses(snapshots, adapters.map((adapter) => adapter.agent));
1868
2069
  activeCount = activeSessions.length;
1869
2070
  const rootSessions = rootActiveProcesses(activeSessions);
@@ -2030,7 +2231,7 @@ function rootActiveProcesses(processes) {
2030
2231
  const seenTerminalTargets = new Set();
2031
2232
  return roots.filter((process) => {
2032
2233
  const terminalTarget = process.terminalControl?.target
2033
- ? `${process.agent}:${process.terminalControl.target}`
2234
+ ? `${process.agent}:${process.terminalControl.target}:${process.terminalControl.panePid}`
2034
2235
  : undefined;
2035
2236
  if (!terminalTarget) {
2036
2237
  return true;
@@ -2075,6 +2276,7 @@ async function runStatus(options) {
2075
2276
  const terminalStatus = await terminalStatusForControl(terminalConversation.agent, terminalConversation.terminalControl, options, {
2076
2277
  pid: terminalConversation.pid,
2077
2278
  cwd: terminalConversation.terminalControl.currentPath,
2279
+ conversationId: terminalConversation.conversationId,
2078
2280
  terminalTarget: terminalConversation.terminalControl.target
2079
2281
  });
2080
2282
  printJson({
@@ -2091,7 +2293,12 @@ async function runStatus(options) {
2091
2293
  });
2092
2294
  return;
2093
2295
  }
2094
- const { conversation, statePath, logPath } = loadConversationFromOptions(options);
2296
+ const loaded = loadConversationFromOptions(options);
2297
+ const { statePath, logPath } = loaded;
2298
+ const conversation = await migrateLegacyTerminalAgentIdentity({
2299
+ ...loaded,
2300
+ options
2301
+ });
2095
2302
  const events = readExistingEvents(logPath);
2096
2303
  const result = {
2097
2304
  conversation,
@@ -2129,6 +2336,7 @@ async function runDescribe(options) {
2129
2336
  const terminalStatus = await terminalStatusForControl(terminalConversation.agent, terminalConversation.terminalControl, options, {
2130
2337
  pid: terminalConversation.pid,
2131
2338
  cwd: terminalConversation.terminalControl.currentPath,
2339
+ conversationId: terminalConversation.conversationId,
2132
2340
  terminalTarget: terminalConversation.terminalControl.target
2133
2341
  });
2134
2342
  if (terminalConversation.agent !== "codex") {
@@ -2172,7 +2380,12 @@ async function runDescribe(options) {
2172
2380
  }));
2173
2381
  return;
2174
2382
  }
2175
- const { conversation, statePath, logPath } = loadConversationFromOptions(options);
2383
+ const loaded = loadConversationFromOptions(options);
2384
+ const { statePath, logPath } = loaded;
2385
+ const conversation = await migrateLegacyTerminalAgentIdentity({
2386
+ ...loaded,
2387
+ options
2388
+ });
2176
2389
  const events = readExistingEvents(logPath);
2177
2390
  const terminalControl = terminalControlFromTakeover(isRecord(conversation.native_session_takeover) ? conversation.native_session_takeover : undefined);
2178
2391
  const terminalStatus = terminalControl
@@ -2291,21 +2504,49 @@ function terminalBridgeApprovalInstructions({ conversation, terminalControl, ter
2291
2504
  "Do not use raw tmux, shell, or manual key presses for this approval. Do not approve without explicit user confirmation."
2292
2505
  ].filter((line) => line !== undefined).join("\n");
2293
2506
  }
2294
- function recordTerminalBridgeApprovalNotification({ statePath, logPath, terminalControl, terminalStatus, fingerprint }) {
2507
+ function recordTerminalBridgeApprovalNotification({ statePath, logPath, terminalControl, terminalStatus, fingerprint, expectedConversation, onRecorded }) {
2295
2508
  const releaseLock = acquireFileLock(`${statePath}.lock`);
2296
2509
  try {
2297
2510
  const conversation = loadState(statePath);
2511
+ const currentNativeTakeover = isRecord(conversation.native_session_takeover)
2512
+ ? conversation.native_session_takeover
2513
+ : undefined;
2514
+ const currentTerminalControl = terminalControlFromTakeover(currentNativeTakeover);
2515
+ if (!isWaitingForAgent(conversation.status) ||
2516
+ conversation.conversation_id !== expectedConversation.conversationId ||
2517
+ conversation.status !== expectedConversation.status ||
2518
+ conversation.updated_at !== expectedConversation.updatedAt ||
2519
+ currentNativeTakeover?.terminal_bridge !== true ||
2520
+ stringValue(currentNativeTakeover.terminal_bridge_message_id) !==
2521
+ expectedConversation.messageId ||
2522
+ !currentTerminalControl ||
2523
+ currentTerminalControl.kind !== terminalControl.kind ||
2524
+ currentTerminalControl.target !== terminalControl.target ||
2525
+ currentTerminalControl.socketPath !== terminalControl.socketPath ||
2526
+ currentTerminalControl.panePid !== terminalControl.panePid) {
2527
+ return {
2528
+ conversation,
2529
+ duplicate: false,
2530
+ stale: true,
2531
+ recorded: undefined
2532
+ };
2533
+ }
2298
2534
  const nativeTakeover = isRecord(conversation.native_session_takeover)
2299
2535
  ? { ...conversation.native_session_takeover }
2300
2536
  : {};
2301
2537
  const previousApproval = isRecord(nativeTakeover.terminal_bridge_approval)
2302
2538
  ? nativeTakeover.terminal_bridge_approval
2303
2539
  : undefined;
2304
- if (previousApproval?.fingerprint === fingerprint && previousApproval?.notified_at) {
2540
+ const previousNotifiedAt = validTimestampMs(previousApproval?.notified_at);
2541
+ if (previousApproval?.fingerprint === fingerprint &&
2542
+ previousNotifiedAt !== undefined &&
2543
+ Date.now() - previousNotifiedAt <= CLAUDE_SCREEN_APPROVAL_TTL_MS) {
2305
2544
  return {
2306
2545
  conversation,
2307
2546
  duplicate: true,
2308
- previousApproval
2547
+ stale: false,
2548
+ previousApproval,
2549
+ recorded: undefined
2309
2550
  };
2310
2551
  }
2311
2552
  const now = new Date().toISOString();
@@ -2332,54 +2573,34 @@ function recordTerminalBridgeApprovalNotification({ statePath, logPath, terminal
2332
2573
  });
2333
2574
  return {
2334
2575
  conversation: nextConversation,
2335
- duplicate: false
2576
+ duplicate: false,
2577
+ stale: false,
2578
+ recorded: onRecorded?.(nextConversation)
2336
2579
  };
2337
2580
  }
2338
2581
  finally {
2339
2582
  releaseLock();
2340
2583
  }
2341
2584
  }
2342
- async function runSend(options) {
2343
- const messageBody = required(options.message ?? options.request, "--message is required");
2344
- if (options.agentHardTimeoutMinutes !== undefined) {
2345
- positiveMinutes(options.agentHardTimeoutMinutes, "--agent-hard-timeout-minutes");
2346
- }
2347
- cleanupIdleConversations(storeDirFromOptions(options), options);
2348
- const terminalConversation = await resolveTerminalConversationFromOptions(options);
2349
- if (terminalConversation) {
2350
- if (options.background) {
2351
- const managed = createManagedTerminalConversationFromRawId({
2585
+ function prepareManagedSend({ options, statePath, logPath, messageBody, stateLockHeld = false, persist = true, rejectTerminalControl = false }) {
2586
+ if (!stateLockHeld) {
2587
+ const releaseLock = acquireFileLock(`${statePath}.lock`);
2588
+ try {
2589
+ return prepareManagedSend({
2352
2590
  options,
2353
- conversationId: terminalConversation.conversationId,
2354
- agent: terminalConversation.agent,
2355
- pid: terminalConversation.pid,
2591
+ statePath,
2592
+ logPath,
2356
2593
  messageBody,
2357
- terminalControl: terminalConversation.terminalControl
2358
- });
2359
- await runTerminalControlSend({
2360
- options,
2361
- conversation: managed.conversation,
2362
- nextConversation: managed.nextConversation,
2363
- statePath: managed.statePath,
2364
- logPath: managed.logPath,
2365
- executor: managed.executor,
2366
- message: managed.message,
2367
- terminalControl: terminalConversation.terminalControl,
2368
- needsNativeTakeoverBootstrap: true
2594
+ stateLockHeld: true,
2595
+ persist,
2596
+ rejectTerminalControl
2369
2597
  });
2370
- return;
2371
2598
  }
2372
- await runTerminalConversationSend({
2373
- options,
2374
- conversationId: terminalConversation.conversationId,
2375
- agent: terminalConversation.agent,
2376
- pid: terminalConversation.pid,
2377
- messageBody,
2378
- terminalControl: terminalConversation.terminalControl
2379
- });
2380
- return;
2599
+ finally {
2600
+ releaseLock();
2601
+ }
2381
2602
  }
2382
- const { conversation, statePath, logPath } = loadConversationFromOptions(options);
2603
+ const conversation = loadState(statePath);
2383
2604
  if (["done", "failed", "closed", "cancelled"].includes(conversation.status)) {
2384
2605
  throw new Error(`cannot send to ${conversation.conversation_id}; conversation is ${conversation.status}`);
2385
2606
  }
@@ -2390,10 +2611,15 @@ async function runSend(options) {
2390
2611
  throw new Error(`cannot send to ${conversation.conversation_id}; choose a supported model with --model first`);
2391
2612
  }
2392
2613
  const executor = executorForConversation(conversation);
2393
- const type = options.type ?? (conversation.status === "waiting_for_openclaw" ? "answer" : "task");
2614
+ const type = options.type ??
2615
+ (conversation.status === "waiting_for_openclaw" ? "answer" : "task");
2394
2616
  const nativeTakeoverForSend = isRecord(conversation.native_session_takeover)
2395
2617
  ? conversation.native_session_takeover
2396
2618
  : undefined;
2619
+ if (rejectTerminalControl &&
2620
+ terminalControlFromTakeover(nativeTakeoverForSend)) {
2621
+ throw new Error("terminal control changed while waiting to send; refresh status and retry");
2622
+ }
2397
2623
  const forkTakeoverForSend = isRecord(conversation.fork_context_takeover)
2398
2624
  ? conversation.fork_context_takeover
2399
2625
  : undefined;
@@ -2416,7 +2642,9 @@ async function runSend(options) {
2416
2642
  const nextConversation = {
2417
2643
  ...applyMessageToConversation(conversation, message),
2418
2644
  executor,
2419
- claude_session: executor.kind === "claude" ? executor.session : conversation.claude_session,
2645
+ claude_session: executor.kind === "claude"
2646
+ ? executor.session
2647
+ : conversation.claude_session,
2420
2648
  executor_model: options.model ?? conversation.executor_model,
2421
2649
  model_selection: conversation.status === "needs_model_selection"
2422
2650
  ? {
@@ -2426,17 +2654,152 @@ async function runSend(options) {
2426
2654
  }
2427
2655
  : conversation.model_selection
2428
2656
  };
2429
- saveState(statePath, nextConversation);
2430
- appendEvent(logPath, messageEvent(message));
2431
- runtimeLog("info", "message_created", {
2432
- conversation_id: conversation.conversation_id,
2433
- agent: executor.kind,
2434
- executor_session: executor.session,
2435
- message_type: type,
2436
- state_path: statePath,
2437
- event_log_path: logPath,
2438
- message: textSummary(messageBody)
2657
+ if (persist) {
2658
+ saveState(statePath, nextConversation);
2659
+ appendEvent(logPath, messageEvent(message));
2660
+ runtimeLog("info", "message_created", {
2661
+ conversation_id: conversation.conversation_id,
2662
+ agent: executor.kind,
2663
+ executor_session: executor.session,
2664
+ message_type: type,
2665
+ state_path: statePath,
2666
+ event_log_path: logPath,
2667
+ message: textSummary(messageBody)
2668
+ });
2669
+ }
2670
+ return {
2671
+ conversation,
2672
+ executor,
2673
+ nativeTakeoverForSend,
2674
+ forkTakeoverForSend,
2675
+ needsNativeTakeoverBootstrap,
2676
+ needsForkTakeoverBootstrap,
2677
+ message,
2678
+ nextConversation
2679
+ };
2680
+ }
2681
+ async function runSend(options) {
2682
+ const messageBody = required(options.message ?? options.request, "--message is required");
2683
+ if (options.agentHardTimeoutMinutes !== undefined) {
2684
+ positiveMinutes(options.agentHardTimeoutMinutes, "--agent-hard-timeout-minutes");
2685
+ }
2686
+ cleanupIdleConversations(storeDirFromOptions(options), options);
2687
+ const terminalConversation = await resolveTerminalConversationFromOptions(options);
2688
+ if (terminalConversation) {
2689
+ if (options.background) {
2690
+ const releaseTerminalLock = acquireFileLock(terminalBridgeSendLockPath(storeDirFromOptions(options), terminalConversation.terminalControl), { timeoutMs: 30000 });
2691
+ let releaseStateLock;
2692
+ try {
2693
+ const managed = createManagedTerminalConversationFromRawId({
2694
+ options,
2695
+ conversationId: terminalConversation.conversationId,
2696
+ agent: terminalConversation.agent,
2697
+ pid: terminalConversation.pid,
2698
+ messageBody,
2699
+ terminalControl: terminalConversation.terminalControl
2700
+ });
2701
+ ensureDir(path.dirname(managed.statePath));
2702
+ releaseStateLock = acquireFileLock(`${managed.statePath}.lock`);
2703
+ await runTerminalControlSend({
2704
+ options,
2705
+ conversation: managed.conversation,
2706
+ nextConversation: managed.nextConversation,
2707
+ statePath: managed.statePath,
2708
+ logPath: managed.logPath,
2709
+ executor: managed.executor,
2710
+ message: managed.message,
2711
+ terminalControl: terminalConversation.terminalControl,
2712
+ needsNativeTakeoverBootstrap: true,
2713
+ terminalSendLockHeld: true,
2714
+ terminalStateLockHeld: true,
2715
+ recordMessageAfterSend: true,
2716
+ recordRawAttachmentAfterSend: true
2717
+ });
2718
+ }
2719
+ finally {
2720
+ try {
2721
+ releaseStateLock?.();
2722
+ }
2723
+ finally {
2724
+ releaseTerminalLock();
2725
+ }
2726
+ }
2727
+ return;
2728
+ }
2729
+ await runTerminalConversationSend({
2730
+ options,
2731
+ conversationId: terminalConversation.conversationId,
2732
+ agent: terminalConversation.agent,
2733
+ pid: terminalConversation.pid,
2734
+ messageBody,
2735
+ terminalControl: terminalConversation.terminalControl
2736
+ });
2737
+ return;
2738
+ }
2739
+ const loaded = loadConversationFromOptions(options);
2740
+ const { statePath, logPath } = loaded;
2741
+ const migratedConversation = await migrateLegacyTerminalAgentIdentity({
2742
+ ...loaded,
2743
+ options
2744
+ });
2745
+ const migratedTakeover = isRecord(migratedConversation.native_session_takeover)
2746
+ ? migratedConversation.native_session_takeover
2747
+ : undefined;
2748
+ const migratedTerminalControl = terminalControlFromTakeover(migratedTakeover);
2749
+ if (migratedTerminalControl) {
2750
+ const releaseTerminalLock = acquireFileLock(terminalBridgeSendLockPath(storeDirFromOptions(options), migratedTerminalControl), { timeoutMs: 30000 });
2751
+ let releaseStateLock;
2752
+ try {
2753
+ releaseStateLock = acquireFileLock(`${statePath}.lock`);
2754
+ const prepared = prepareManagedSend({
2755
+ options,
2756
+ statePath,
2757
+ logPath,
2758
+ messageBody,
2759
+ stateLockHeld: true,
2760
+ persist: false
2761
+ });
2762
+ const currentTerminalControl = terminalControlFromTakeover(prepared.nativeTakeoverForSend);
2763
+ if (!currentTerminalControl ||
2764
+ currentTerminalControl.kind !== migratedTerminalControl.kind ||
2765
+ currentTerminalControl.target !== migratedTerminalControl.target ||
2766
+ currentTerminalControl.socketPath !== migratedTerminalControl.socketPath ||
2767
+ currentTerminalControl.panePid !== migratedTerminalControl.panePid) {
2768
+ throw new Error("terminal control changed while waiting to send; refresh status and retry");
2769
+ }
2770
+ await runTerminalControlSend({
2771
+ options,
2772
+ conversation: prepared.conversation,
2773
+ nextConversation: prepared.nextConversation,
2774
+ statePath,
2775
+ logPath,
2776
+ executor: prepared.executor,
2777
+ message: prepared.message,
2778
+ terminalControl: currentTerminalControl,
2779
+ needsNativeTakeoverBootstrap: prepared.needsNativeTakeoverBootstrap,
2780
+ terminalSendLockHeld: true,
2781
+ terminalStateLockHeld: true,
2782
+ recordMessageAfterSend: true
2783
+ });
2784
+ }
2785
+ finally {
2786
+ try {
2787
+ releaseStateLock?.();
2788
+ }
2789
+ finally {
2790
+ releaseTerminalLock();
2791
+ }
2792
+ }
2793
+ return;
2794
+ }
2795
+ const prepared = prepareManagedSend({
2796
+ options,
2797
+ statePath,
2798
+ logPath,
2799
+ messageBody,
2800
+ rejectTerminalControl: true
2439
2801
  });
2802
+ const { conversation, executor, nativeTakeoverForSend, forkTakeoverForSend, needsNativeTakeoverBootstrap, needsForkTakeoverBootstrap, message, nextConversation } = prepared;
2440
2803
  const executorEnv = environmentForExecutor(executor, {
2441
2804
  allProxy: options.allProxy ?? conversation.executor_all_proxy
2442
2805
  });
@@ -2566,7 +2929,7 @@ async function runSend(options) {
2566
2929
  const acpxArgs = buildAcpxPromptArgs({ executor, payload, model: executorModel });
2567
2930
  if (options.background) {
2568
2931
  const outputPath = path.join(path.dirname(logPath), `${executor.kind}-followup-output.log`);
2569
- const outputFd = fs.openSync(outputPath, "a");
2932
+ const outputFd = openPrivateAppendFile(outputPath);
2570
2933
  const child = spawn(acpxPath, acpxArgs, {
2571
2934
  detached: true,
2572
2935
  stdio: ["ignore", outputFd, outputFd],
@@ -2743,7 +3106,12 @@ async function runApprove(options) {
2743
3106
  });
2744
3107
  return;
2745
3108
  }
2746
- const { conversation, statePath, logPath } = loadConversationFromOptions(options);
3109
+ const loaded = loadConversationFromOptions(options);
3110
+ const { statePath, logPath } = loaded;
3111
+ const conversation = await migrateLegacyTerminalAgentIdentity({
3112
+ ...loaded,
3113
+ options
3114
+ });
2747
3115
  const nativeTakeover = isRecord(conversation.native_session_takeover)
2748
3116
  ? conversation.native_session_takeover
2749
3117
  : undefined;
@@ -2751,263 +3119,529 @@ async function runApprove(options) {
2751
3119
  if (!terminalControl) {
2752
3120
  throw new Error(`conversation ${conversation.conversation_id} is not controlled through a terminal`);
2753
3121
  }
3122
+ if (["done", "failed", "closed", "cancelled"].includes(conversation.status)) {
3123
+ throw new Error(`cannot approve ${conversation.conversation_id}; conversation is ${conversation.status}`);
3124
+ }
2754
3125
  const executor = executorForConversation(conversation);
2755
3126
  const monitoredApproval = isRecord(nativeTakeover?.["terminal_bridge_approval"])
2756
3127
  ? nativeTakeover.terminal_bridge_approval
2757
3128
  : undefined;
2758
- const expectedFingerprint = stringValue(options.expectedApprovalFingerprint) ??
3129
+ const suppliedExpectedFingerprint = stringValue(options.expectedApprovalFingerprint);
3130
+ const expectedFingerprint = suppliedExpectedFingerprint ??
2759
3131
  stringValue(monitoredApproval?.fingerprint);
3132
+ const hooklessClaudeScreenApproval = executor.kind === "claude" &&
3133
+ nativeTakeover?.claude_hook_mode !== "enabled";
3134
+ if (hooklessClaudeScreenApproval) {
3135
+ const monitoredState = isRecord(monitoredApproval?.approval_state)
3136
+ ? monitoredApproval.approval_state
3137
+ : undefined;
3138
+ const pendingDispatch = isRecord(nativeTakeover?.terminal_bridge_approval_dispatch)
3139
+ ? nativeTakeover.terminal_bridge_approval_dispatch
3140
+ : undefined;
3141
+ const notifiedAt = validTimestampMs(monitoredApproval?.notified_at);
3142
+ if (conversation.status !== "waiting_for_openclaw" ||
3143
+ monitoredState?.decision_mode !== "keys" ||
3144
+ !stringValue(monitoredApproval?.fingerprint)) {
3145
+ printJson({
3146
+ conversation,
3147
+ approved: false,
3148
+ blocked: true,
3149
+ reason: "Claude screen approval requires a current managed-turn approval notification",
3150
+ terminal_control: terminalControl
3151
+ });
3152
+ return;
3153
+ }
3154
+ if (notifiedAt === undefined ||
3155
+ Date.now() - notifiedAt > CLAUDE_SCREEN_APPROVAL_TTL_MS) {
3156
+ printJson({
3157
+ conversation,
3158
+ approved: false,
3159
+ blocked: true,
3160
+ reason: "Claude screen approval expired; inspect and resolve the terminal manually",
3161
+ terminal_control: terminalControl
3162
+ });
3163
+ return;
3164
+ }
3165
+ if (!suppliedExpectedFingerprint ||
3166
+ expectedFingerprint !== monitoredApproval?.fingerprint) {
3167
+ printJson({
3168
+ conversation,
3169
+ approved: false,
3170
+ blocked: true,
3171
+ reason: "Claude screen approval requires the latest notified fingerprint",
3172
+ terminal_control: terminalControl
3173
+ });
3174
+ return;
3175
+ }
3176
+ if (pendingDispatch?.state === "reserved" &&
3177
+ pendingDispatch.terminal_bridge_message_id ===
3178
+ nativeTakeover?.terminal_bridge_message_id) {
3179
+ printJson({
3180
+ conversation,
3181
+ approved: false,
3182
+ blocked: true,
3183
+ reason: "a previous Claude approval dispatch has an uncertain outcome; inspect and resolve the terminal manually",
3184
+ terminal_control: terminalControl
3185
+ });
3186
+ return;
3187
+ }
3188
+ if (expectedFingerprint ===
3189
+ stringValue(nativeTakeover?.terminal_bridge_last_approval_fingerprint)) {
3190
+ printJson({
3191
+ conversation,
3192
+ approved: false,
3193
+ blocked: true,
3194
+ reason: "Claude screen approval fingerprint was already consumed",
3195
+ terminal_control: terminalControl
3196
+ });
3197
+ return;
3198
+ }
3199
+ }
2760
3200
  const autoApproved = options.autoApproved === true;
2761
3201
  const policyRuleId = stringValue(options.policyRuleId);
2762
3202
  const policyFingerprint = stringValue(options.policyFingerprint);
2763
- const approval = await createTerminalAgentBridge(options).approve(executor.kind, terminalControl, {
2764
- expectedFingerprint,
2765
- scrollbackLines: Number(options.scrollbackLines ?? 120),
2766
- runtime: terminalRuntimeIdentityForConversation(conversation, terminalControl),
2767
- requiredDecisionMode: autoApproved && executor.kind === "claude"
2768
- ? "structured"
2769
- : undefined
2770
- });
2771
- const actualFingerprint = approval.fingerprint;
2772
- if (!approval.approved) {
2773
- if (autoApproved) {
2774
- appendEvent(logPath, {
2775
- ts: new Date().toISOString(),
2776
- conversation_id: conversation.conversation_id,
2777
- event: "terminal_auto_approval_decision",
2778
- action: "rejected",
2779
- reason: approval.reason,
2780
- terminal_control: terminalControl,
2781
- expected_fingerprint: expectedFingerprint,
2782
- actual_fingerprint: actualFingerprint,
2783
- policy_rule_id: policyRuleId,
2784
- policy_fingerprint: policyFingerprint
2785
- });
2786
- }
2787
- printJson({
2788
- conversation,
2789
- approved: false,
2790
- blocked: approval.blocked,
2791
- reason: approval.reason,
2792
- terminal_control: terminalControl,
2793
- expected_approval_fingerprint: expectedFingerprint,
2794
- actual_approval_fingerprint: actualFingerprint,
2795
- screen_excerpt: approval.screenExcerpt
2796
- });
2797
- return;
2798
- }
2799
- appendEvent(logPath, {
2800
- ts: new Date().toISOString(),
2801
- conversation_id: conversation.conversation_id,
2802
- event: "terminal_approval_send",
2803
- terminal_control: terminalControl,
2804
- key: approval.key,
2805
- keys: approval.keys,
2806
- label: approval.label,
2807
- decision_mode: approval.decisionMode,
2808
- request_id: approval.requestId,
2809
- approval_fingerprint: actualFingerprint,
2810
- auto_approved: autoApproved,
2811
- policy_rule_id: policyRuleId,
2812
- policy_fingerprint: policyFingerprint
2813
- });
2814
- if (autoApproved) {
2815
- appendEvent(logPath, {
2816
- ts: new Date().toISOString(),
2817
- conversation_id: conversation.conversation_id,
2818
- event: "terminal_auto_approval_decision",
2819
- action: "approved",
2820
- terminal_control: terminalControl,
2821
- approval_fingerprint: actualFingerprint,
2822
- policy_rule_id: policyRuleId,
2823
- policy_fingerprint: policyFingerprint
2824
- });
2825
- }
2826
- runtimeLog("info", "terminal_approval_send", {
2827
- conversation_id: conversation.conversation_id,
2828
- terminal_target: terminalControl.target,
2829
- key: approval.key,
2830
- keys: approval.keys,
2831
- label: approval.label,
2832
- decision_mode: approval.decisionMode,
2833
- request_id: approval.requestId,
2834
- approval_fingerprint: actualFingerprint,
2835
- auto_approved: autoApproved,
2836
- policy_rule_id: policyRuleId,
2837
- policy_fingerprint: policyFingerprint
2838
- });
2839
- const nativeTakeoverForUpdate = isRecord(conversation.native_session_takeover)
2840
- ? { ...conversation.native_session_takeover }
2841
- : {};
2842
- const approvalResolvedAt = new Date().toISOString();
2843
- const agentTimeoutMinutes = Number(options.agentTimeoutMinutes ??
2844
- nativeTakeoverForUpdate.terminal_bridge_inactivity_timeout_minutes ??
2845
- DEFAULT_AGENT_TIMEOUT_MINUTES);
2846
- const agentHardTimeoutMinutes = positiveMinutes(options.agentHardTimeoutMinutes ??
2847
- nativeTakeoverForUpdate.terminal_bridge_hard_timeout_minutes ??
2848
- DEFAULT_AGENT_HARD_TIMEOUT_MINUTES, "--agent-hard-timeout-minutes");
2849
- const nextNativeTakeover = {
2850
- ...nativeTakeoverForUpdate,
2851
- terminal_bridge_approval: undefined,
2852
- terminal_bridge_approval_resolved_at: approvalResolvedAt,
2853
- terminal_bridge_monitor_started_at: approvalResolvedAt,
2854
- terminal_bridge_last_activity_at: approvalResolvedAt,
2855
- terminal_bridge_last_activity_reason: "approval resolved",
2856
- terminal_bridge_inactivity_timeout_minutes: agentTimeoutMinutes,
2857
- terminal_bridge_hard_timeout_minutes: agentHardTimeoutMinutes,
2858
- terminal_bridge_inactivity_deadline_at: deadlineAt(approvalResolvedAt, agentTimeoutMinutes),
2859
- terminal_bridge_hard_deadline_at: deadlineAt(stringValue(nativeTakeoverForUpdate.terminal_bridge_started_at) ?? approvalResolvedAt, agentHardTimeoutMinutes)
3203
+ const autoApprovalPolicy = autoApproved
3204
+ ? parseJsonOption(options.autoApprovalPolicyJson, "--auto-approval-policy-json")
3205
+ : undefined;
3206
+ const runtimeIdentity = terminalRuntimeIdentityForConversation(conversation, terminalControl);
3207
+ let executorPolicyDecision;
3208
+ const releaseTerminalLock = acquireFileLock(terminalBridgeSendLockPath(storeDirFromOptions(options), terminalControl), { timeoutMs: 30000 });
3209
+ let terminalLockReleased = false;
3210
+ const releaseApprovalTerminalLock = () => {
3211
+ if (!terminalLockReleased) {
3212
+ terminalLockReleased = true;
3213
+ releaseTerminalLock();
3214
+ }
2860
3215
  };
2861
- delete nextNativeTakeover.terminal_bridge_approval;
2862
- let nextConversation = {
2863
- ...conversation,
2864
- status: terminalBridgeEnabled(conversation) ? "waiting_for_agent" : conversation.status,
2865
- native_session_takeover: nextNativeTakeover,
2866
- updated_at: approvalResolvedAt
3216
+ let releaseStateLock;
3217
+ const releaseApprovalStateLock = () => {
3218
+ if (releaseStateLock) {
3219
+ const release = releaseStateLock;
3220
+ releaseStateLock = undefined;
3221
+ release();
3222
+ }
2867
3223
  };
2868
- const approvalLeaseDeadlines = [
2869
- stringValue(nextNativeTakeover.terminal_bridge_inactivity_deadline_at),
2870
- stringValue(nextNativeTakeover.terminal_bridge_hard_deadline_at)
2871
- ].filter((value) => Boolean(value));
2872
- if (approvalLeaseDeadlines.length > 0) {
2873
- const renewedLease = renewClaudeHookLease(nextConversation, new Date(Math.min(...approvalLeaseDeadlines.map((value) => Date.parse(value)))).toISOString());
2874
- if (renewedLease) {
2875
- nextConversation = {
2876
- ...nextConversation,
2877
- native_session_takeover: {
2878
- ...nextNativeTakeover,
2879
- claude_hook_lease_id: renewedLease.id
3224
+ try {
3225
+ let approval;
3226
+ let lockedConversation = conversation;
3227
+ const currentConversation = loadState(statePath);
3228
+ const currentTakeover = isRecord(currentConversation.native_session_takeover)
3229
+ ? currentConversation.native_session_takeover
3230
+ : undefined;
3231
+ const currentControl = terminalControlFromTakeover(currentTakeover);
3232
+ const currentApproval = isRecord(currentTakeover?.terminal_bridge_approval)
3233
+ ? currentTakeover.terminal_bridge_approval
3234
+ : undefined;
3235
+ if (currentConversation.status !== conversation.status ||
3236
+ currentTakeover?.terminal_bridge_message_id !== nativeTakeover?.terminal_bridge_message_id ||
3237
+ currentControl?.target !== terminalControl.target ||
3238
+ currentControl?.socketPath !== terminalControl.socketPath ||
3239
+ (hooklessClaudeScreenApproval &&
3240
+ currentApproval?.fingerprint !== monitoredApproval?.fingerprint)) {
3241
+ throw new Error("approval state changed while waiting for terminal control; refresh status and retry");
3242
+ }
3243
+ lockedConversation = currentConversation;
3244
+ approval = await createTerminalAgentBridge(options).approve(executor.kind, terminalControl, {
3245
+ expectedFingerprint,
3246
+ scrollbackLines: Number(options.scrollbackLines ?? 120),
3247
+ runtime: runtimeIdentity,
3248
+ requiredDecisionMode: autoApproved && executor.kind === "claude"
3249
+ ? "structured"
3250
+ : undefined,
3251
+ authorize: autoApproved
3252
+ ? ({ agent, terminalControl: currentTerminalControl, inspection, fingerprint }) => {
3253
+ if (!autoApprovalPolicy) {
3254
+ return {
3255
+ approved: false,
3256
+ reason: "automatic approval requires an executor-side policy"
3257
+ };
3258
+ }
3259
+ const candidate = {
3260
+ agent,
3261
+ kind: inspection.approval.promptKind ?? "unknown",
3262
+ decisionMode: inspection.approval.approvable
3263
+ ? inspection.approval.action.mode ?? "keys"
3264
+ : undefined,
3265
+ command: inspection.approval.command,
3266
+ cwd: inspection.approval.cwd ?? currentTerminalControl.currentPath,
3267
+ fingerprint: fingerprint ?? "",
3268
+ terminalTarget: currentTerminalControl.target
3269
+ };
3270
+ executorPolicyDecision = evaluateApprovalPolicy({
3271
+ policy: autoApprovalPolicy,
3272
+ candidate
3273
+ });
3274
+ if (executorPolicyDecision.action !== "approve") {
3275
+ return {
3276
+ approved: false,
3277
+ reason: `executor-side auto-approval policy rejected the current request: ${executorPolicyDecision.reason}`
3278
+ };
3279
+ }
3280
+ if (policyRuleId && executorPolicyDecision.ruleId !== policyRuleId) {
3281
+ return {
3282
+ approved: false,
3283
+ reason: "executor-side auto-approval rule changed before execution"
3284
+ };
3285
+ }
3286
+ if (policyFingerprint &&
3287
+ executorPolicyDecision.policyFingerprint !== policyFingerprint) {
3288
+ return {
3289
+ approved: false,
3290
+ reason: "executor-side auto-approval policy changed before execution"
3291
+ };
3292
+ }
3293
+ return { approved: true };
2880
3294
  }
2881
- };
3295
+ : undefined,
3296
+ beforeKeyDispatch: hooklessClaudeScreenApproval
3297
+ ? ({ fingerprint, terminalControl: dispatchControl, keys }) => {
3298
+ if (releaseStateLock) {
3299
+ throw new Error("Claude approval dispatch was already reserved");
3300
+ }
3301
+ releaseStateLock = acquireFileLock(`${statePath}.lock`);
3302
+ const latestConversation = loadState(statePath);
3303
+ const latestTakeover = isRecord(latestConversation.native_session_takeover)
3304
+ ? latestConversation.native_session_takeover
3305
+ : undefined;
3306
+ const latestControl = terminalControlFromTakeover(latestTakeover);
3307
+ const latestApproval = isRecord(latestTakeover?.terminal_bridge_approval)
3308
+ ? latestTakeover.terminal_bridge_approval
3309
+ : undefined;
3310
+ const latestNotifiedAt = validTimestampMs(latestApproval?.notified_at);
3311
+ const latestDispatch = isRecord(latestTakeover?.terminal_bridge_approval_dispatch)
3312
+ ? latestTakeover.terminal_bridge_approval_dispatch
3313
+ : undefined;
3314
+ if (!latestTakeover ||
3315
+ latestConversation.status !== "waiting_for_openclaw" ||
3316
+ latestTakeover.terminal_bridge_message_id !==
3317
+ nativeTakeover?.terminal_bridge_message_id ||
3318
+ latestApproval?.fingerprint !== fingerprint ||
3319
+ latestNotifiedAt === undefined ||
3320
+ Date.now() - latestNotifiedAt > CLAUDE_SCREEN_APPROVAL_TTL_MS ||
3321
+ expectedFingerprint !== fingerprint ||
3322
+ latestControl?.target !== dispatchControl.target ||
3323
+ latestControl?.socketPath !== dispatchControl.socketPath) {
3324
+ throw new Error("approval state changed before terminal dispatch; refresh status and retry");
3325
+ }
3326
+ if (latestDispatch?.state === "reserved" &&
3327
+ latestDispatch.terminal_bridge_message_id ===
3328
+ latestTakeover.terminal_bridge_message_id) {
3329
+ throw new Error("a previous Claude approval dispatch has an uncertain outcome; inspect and resolve the terminal manually");
3330
+ }
3331
+ const reservedAt = new Date().toISOString();
3332
+ const reservedConversation = {
3333
+ ...latestConversation,
3334
+ native_session_takeover: {
3335
+ ...latestTakeover,
3336
+ terminal_bridge_approval_dispatch: {
3337
+ state: "reserved",
3338
+ attempt_id: randomUUID(),
3339
+ fingerprint,
3340
+ keys,
3341
+ terminal_target: dispatchControl.target,
3342
+ terminal_bridge_message_id: latestTakeover.terminal_bridge_message_id,
3343
+ reserved_at: reservedAt
3344
+ }
3345
+ },
3346
+ updated_at: reservedAt
3347
+ };
3348
+ saveState(statePath, reservedConversation);
3349
+ lockedConversation = reservedConversation;
3350
+ }
3351
+ : undefined
3352
+ });
3353
+ const actualFingerprint = approval.fingerprint;
3354
+ const effectivePolicyRuleId = executorPolicyDecision?.ruleId ?? policyRuleId;
3355
+ const effectivePolicyFingerprint = executorPolicyDecision?.policyFingerprint ?? policyFingerprint;
3356
+ if (!approval.approved) {
3357
+ releaseApprovalStateLock();
3358
+ releaseApprovalTerminalLock();
3359
+ if (autoApproved) {
3360
+ appendEvent(logPath, {
3361
+ ts: new Date().toISOString(),
3362
+ conversation_id: conversation.conversation_id,
3363
+ event: "terminal_auto_approval_decision",
3364
+ action: "rejected",
3365
+ reason: approval.reason,
3366
+ terminal_control: terminalControl,
3367
+ expected_fingerprint: expectedFingerprint,
3368
+ actual_fingerprint: actualFingerprint,
3369
+ policy_rule_id: effectivePolicyRuleId,
3370
+ policy_fingerprint: effectivePolicyFingerprint
3371
+ });
3372
+ }
3373
+ printJson({
3374
+ conversation,
3375
+ approved: false,
3376
+ blocked: approval.blocked,
3377
+ reason: approval.reason,
3378
+ terminal_control: terminalControl,
3379
+ expected_approval_fingerprint: expectedFingerprint,
3380
+ actual_approval_fingerprint: actualFingerprint,
3381
+ screen_excerpt: approval.screenExcerpt
3382
+ });
3383
+ return;
2882
3384
  }
2883
- }
2884
- saveState(statePath, nextConversation);
2885
- const bridgeMonitor = startTerminalBridgeMonitorForConversation({
2886
- conversation: nextConversation,
2887
- statePath,
2888
- logPath,
2889
- options
2890
- });
2891
- if (bridgeMonitor) {
2892
3385
  appendEvent(logPath, {
2893
3386
  ts: new Date().toISOString(),
2894
3387
  conversation_id: conversation.conversation_id,
2895
- event: "terminal_bridge_monitor_launch",
2896
- pid: bridgeMonitor.pid ?? null,
3388
+ event: "terminal_approval_send",
2897
3389
  terminal_control: terminalControl,
2898
- reason: "approval_resolved",
2899
- agent_timeout_minutes: agentTimeoutMinutes,
2900
- agent_hard_timeout_minutes: agentHardTimeoutMinutes
3390
+ key: approval.key,
3391
+ keys: approval.keys,
3392
+ label: approval.label,
3393
+ decision_mode: approval.decisionMode,
3394
+ request_id: approval.requestId,
3395
+ approval_fingerprint: actualFingerprint,
3396
+ auto_approved: autoApproved,
3397
+ policy_rule_id: effectivePolicyRuleId,
3398
+ policy_fingerprint: effectivePolicyFingerprint
2901
3399
  });
2902
- runtimeLog("info", "terminal_bridge_monitor_launch", {
3400
+ if (autoApproved) {
3401
+ appendEvent(logPath, {
3402
+ ts: new Date().toISOString(),
3403
+ conversation_id: conversation.conversation_id,
3404
+ event: "terminal_auto_approval_decision",
3405
+ action: "approved",
3406
+ terminal_control: terminalControl,
3407
+ approval_fingerprint: actualFingerprint,
3408
+ policy_rule_id: effectivePolicyRuleId,
3409
+ policy_fingerprint: effectivePolicyFingerprint
3410
+ });
3411
+ }
3412
+ runtimeLog("info", "terminal_approval_send", {
2903
3413
  conversation_id: conversation.conversation_id,
2904
- monitor_pid: bridgeMonitor.pid ?? null,
2905
3414
  terminal_target: terminalControl.target,
2906
- reason: "approval_resolved"
3415
+ key: approval.key,
3416
+ keys: approval.keys,
3417
+ label: approval.label,
3418
+ decision_mode: approval.decisionMode,
3419
+ request_id: approval.requestId,
3420
+ approval_fingerprint: actualFingerprint,
3421
+ auto_approved: autoApproved,
3422
+ policy_rule_id: effectivePolicyRuleId,
3423
+ policy_fingerprint: effectivePolicyFingerprint
3424
+ });
3425
+ const nativeTakeoverForUpdate = isRecord(lockedConversation.native_session_takeover)
3426
+ ? { ...lockedConversation.native_session_takeover }
3427
+ : {};
3428
+ const approvalResolvedAt = new Date().toISOString();
3429
+ const agentTimeoutMinutes = Number(options.agentTimeoutMinutes ??
3430
+ nativeTakeoverForUpdate.terminal_bridge_inactivity_timeout_minutes ??
3431
+ DEFAULT_AGENT_TIMEOUT_MINUTES);
3432
+ const agentHardTimeoutMinutes = positiveMinutes(options.agentHardTimeoutMinutes ??
3433
+ nativeTakeoverForUpdate.terminal_bridge_hard_timeout_minutes ??
3434
+ DEFAULT_AGENT_HARD_TIMEOUT_MINUTES, "--agent-hard-timeout-minutes");
3435
+ const nextNativeTakeover = {
3436
+ ...nativeTakeoverForUpdate,
3437
+ terminal_bridge_approval: undefined,
3438
+ terminal_bridge_approval_dispatch: undefined,
3439
+ terminal_bridge_approval_resolved_at: approvalResolvedAt,
3440
+ terminal_bridge_last_approval_fingerprint: actualFingerprint,
3441
+ terminal_bridge_last_approval_at: approvalResolvedAt,
3442
+ terminal_bridge_monitor_lock_version: TERMINAL_BRIDGE_MONITOR_LOCK_VERSION,
3443
+ terminal_bridge_monitor_started_at: approvalResolvedAt,
3444
+ terminal_bridge_last_activity_at: approvalResolvedAt,
3445
+ terminal_bridge_last_activity_reason: "approval resolved",
3446
+ terminal_bridge_inactivity_timeout_minutes: agentTimeoutMinutes,
3447
+ terminal_bridge_hard_timeout_minutes: agentHardTimeoutMinutes,
3448
+ terminal_bridge_inactivity_deadline_at: deadlineAt(approvalResolvedAt, agentTimeoutMinutes),
3449
+ terminal_bridge_hard_deadline_at: deadlineAt(stringValue(nativeTakeoverForUpdate.terminal_bridge_started_at) ?? approvalResolvedAt, agentHardTimeoutMinutes)
3450
+ };
3451
+ delete nextNativeTakeover.terminal_bridge_approval;
3452
+ delete nextNativeTakeover.terminal_bridge_approval_dispatch;
3453
+ let nextConversation = {
3454
+ ...lockedConversation,
3455
+ status: terminalBridgeEnabled(lockedConversation)
3456
+ ? "waiting_for_agent"
3457
+ : lockedConversation.status,
3458
+ native_session_takeover: nextNativeTakeover,
3459
+ updated_at: approvalResolvedAt
3460
+ };
3461
+ const approvalLeaseDeadlines = [
3462
+ stringValue(nextNativeTakeover.terminal_bridge_inactivity_deadline_at),
3463
+ stringValue(nextNativeTakeover.terminal_bridge_hard_deadline_at)
3464
+ ].filter((value) => Boolean(value));
3465
+ if (approvalLeaseDeadlines.length > 0) {
3466
+ const renewedLease = renewClaudeHookLease(nextConversation, new Date(Math.min(...approvalLeaseDeadlines.map((value) => Date.parse(value)))).toISOString());
3467
+ if (renewedLease) {
3468
+ nextConversation = {
3469
+ ...nextConversation,
3470
+ native_session_takeover: {
3471
+ ...nextNativeTakeover,
3472
+ claude_hook_lease_id: renewedLease.id
3473
+ }
3474
+ };
3475
+ }
3476
+ }
3477
+ saveState(statePath, nextConversation);
3478
+ releaseApprovalStateLock();
3479
+ releaseApprovalTerminalLock();
3480
+ const bridgeMonitor = startTerminalBridgeMonitorForConversation({
3481
+ conversation: nextConversation,
3482
+ statePath,
3483
+ logPath,
3484
+ options
3485
+ });
3486
+ if (bridgeMonitor) {
3487
+ appendEvent(logPath, {
3488
+ ts: new Date().toISOString(),
3489
+ conversation_id: conversation.conversation_id,
3490
+ event: "terminal_bridge_monitor_launch",
3491
+ pid: bridgeMonitor.pid ?? null,
3492
+ terminal_control: terminalControl,
3493
+ reason: "approval_resolved",
3494
+ agent_timeout_minutes: agentTimeoutMinutes,
3495
+ agent_hard_timeout_minutes: agentHardTimeoutMinutes
3496
+ });
3497
+ runtimeLog("info", "terminal_bridge_monitor_launch", {
3498
+ conversation_id: conversation.conversation_id,
3499
+ monitor_pid: bridgeMonitor.pid ?? null,
3500
+ terminal_target: terminalControl.target,
3501
+ reason: "approval_resolved"
3502
+ });
3503
+ }
3504
+ printJson({
3505
+ conversation: nextConversation,
3506
+ approved: true,
3507
+ terminal_control: terminalControl,
3508
+ key: approval.key,
3509
+ keys: approval.keys,
3510
+ label: approval.label,
3511
+ decision_mode: approval.decisionMode,
3512
+ request_id: approval.requestId,
3513
+ approval_fingerprint: actualFingerprint,
3514
+ auto_approved: autoApproved,
3515
+ policy_rule_id: effectivePolicyRuleId,
3516
+ monitor_pid: bridgeMonitor?.pid ?? null
2907
3517
  });
2908
3518
  }
2909
- printJson({
2910
- conversation: nextConversation,
2911
- approved: true,
2912
- terminal_control: terminalControl,
2913
- key: approval.key,
2914
- keys: approval.keys,
2915
- label: approval.label,
2916
- decision_mode: approval.decisionMode,
2917
- request_id: approval.requestId,
2918
- approval_fingerprint: actualFingerprint,
2919
- auto_approved: autoApproved,
2920
- policy_rule_id: policyRuleId,
2921
- monitor_pid: bridgeMonitor?.pid ?? null
2922
- });
3519
+ finally {
3520
+ try {
3521
+ releaseApprovalStateLock();
3522
+ }
3523
+ finally {
3524
+ releaseApprovalTerminalLock();
3525
+ }
3526
+ }
2923
3527
  }
2924
3528
  async function runTerminalConversationApprove({ options, conversationId, agent, terminalControl, pid }) {
2925
- const approval = await createTerminalAgentBridge(options).approve(agent, terminalControl, {
2926
- expectedFingerprint: stringValue(options.expectedApprovalFingerprint),
2927
- scrollbackLines: Number(options.scrollbackLines ?? 120),
2928
- runtime: {
2929
- pid,
2930
- cwd: terminalControl.currentPath,
2931
- terminalTarget: terminalControl.target
3529
+ const releaseTerminalLock = acquireFileLock(terminalBridgeSendLockPath(storeDirFromOptions(options), terminalControl), { timeoutMs: 30000 });
3530
+ try {
3531
+ if (agent === "claude") {
3532
+ printJson({
3533
+ conversation_id: conversationId,
3534
+ source: "terminal_control",
3535
+ approved: false,
3536
+ blocked: true,
3537
+ reason: "Claude screen approval requires `send --background` so AKK can bind it to an active managed turn",
3538
+ terminal_control: terminalControl
3539
+ });
3540
+ return;
2932
3541
  }
2933
- });
2934
- if (!approval.approved) {
3542
+ const approval = await createTerminalAgentBridge(options).approve(agent, terminalControl, {
3543
+ expectedFingerprint: stringValue(options.expectedApprovalFingerprint),
3544
+ scrollbackLines: Number(options.scrollbackLines ?? 120),
3545
+ runtime: {
3546
+ pid,
3547
+ cwd: terminalControl.currentPath,
3548
+ conversationId,
3549
+ terminalTarget: terminalControl.target
3550
+ }
3551
+ });
3552
+ if (!approval.approved) {
3553
+ printJson({
3554
+ conversation_id: conversationId,
3555
+ source: "terminal_control",
3556
+ approved: false,
3557
+ blocked: approval.blocked,
3558
+ reason: approval.reason,
3559
+ terminal_control: terminalControl,
3560
+ screen_excerpt: approval.screenExcerpt
3561
+ });
3562
+ return;
3563
+ }
3564
+ runtimeLog("info", "terminal_approval_send", {
3565
+ conversation_id: conversationId,
3566
+ agent,
3567
+ terminal_target: terminalControl.target,
3568
+ key: approval.key,
3569
+ keys: approval.keys,
3570
+ label: approval.label,
3571
+ decision_mode: approval.decisionMode,
3572
+ request_id: approval.requestId
3573
+ });
2935
3574
  printJson({
2936
3575
  conversation_id: conversationId,
2937
3576
  source: "terminal_control",
2938
- approved: false,
2939
- blocked: approval.blocked,
2940
- reason: approval.reason,
3577
+ approved: true,
2941
3578
  terminal_control: terminalControl,
2942
- screen_excerpt: approval.screenExcerpt
3579
+ key: approval.key,
3580
+ keys: approval.keys,
3581
+ label: approval.label,
3582
+ approval_fingerprint: approval.fingerprint,
3583
+ decision_mode: approval.decisionMode,
3584
+ request_id: approval.requestId
2943
3585
  });
2944
- return;
2945
3586
  }
2946
- runtimeLog("info", "terminal_approval_send", {
2947
- conversation_id: conversationId,
2948
- agent,
2949
- terminal_target: terminalControl.target,
2950
- key: approval.key,
2951
- keys: approval.keys,
2952
- label: approval.label,
2953
- decision_mode: approval.decisionMode,
2954
- request_id: approval.requestId
2955
- });
2956
- printJson({
2957
- conversation_id: conversationId,
2958
- source: "terminal_control",
2959
- approved: true,
2960
- terminal_control: terminalControl,
2961
- key: approval.key,
2962
- keys: approval.keys,
2963
- label: approval.label,
2964
- approval_fingerprint: approval.fingerprint,
2965
- decision_mode: approval.decisionMode,
2966
- request_id: approval.requestId
2967
- });
3587
+ finally {
3588
+ releaseTerminalLock();
3589
+ }
2968
3590
  }
2969
3591
  async function runTerminalConversationSend({ options, conversationId, agent, pid, messageBody, terminalControl }) {
2970
- const terminalPayload = terminalSubmissionPayload(String(messageBody));
2971
- const terminalBridge = createTerminalAgentBridge(options);
2972
- if (agent === "claude") {
2973
- const status = await terminalBridge.status(agent, terminalControl, {
2974
- scrollbackLines: Number(options.scrollbackLines ?? 120),
3592
+ const releaseTerminalLock = acquireFileLock(terminalBridgeSendLockPath(storeDirFromOptions(options), terminalControl), { timeoutMs: 30000 });
3593
+ try {
3594
+ const terminalPayload = terminalSubmissionPayload(String(messageBody));
3595
+ const terminalBridge = createTerminalAgentBridge(options);
3596
+ if (agent === "claude") {
3597
+ const status = await terminalBridge.status(agent, terminalControl, {
3598
+ scrollbackLines: Number(options.scrollbackLines ?? 120),
3599
+ runtime: {
3600
+ pid,
3601
+ cwd: terminalControl.currentPath,
3602
+ terminalTarget: terminalControl.target
3603
+ }
3604
+ });
3605
+ assertSafeClaudeTerminalSend(status);
3606
+ }
3607
+ await terminalBridge.send(agent, terminalControl, terminalPayload, {
2975
3608
  runtime: {
2976
3609
  pid,
2977
3610
  cwd: terminalControl.currentPath,
2978
3611
  terminalTarget: terminalControl.target
2979
3612
  }
2980
3613
  });
2981
- assertSafeClaudeTerminalSend(status);
2982
- }
2983
- await terminalBridge.send(agent, terminalControl, terminalPayload);
2984
- runtimeLog("info", "terminal_message_send", {
2985
- conversation_id: conversationId,
2986
- agent,
2987
- terminal_target: terminalControl.target,
2988
- message: textSummary(messageBody)
2989
- });
2990
- printJson({
2991
- conversation_id: conversationId,
2992
- source: "terminal_control",
2993
- delivered: true,
2994
- status: "async_pending",
2995
- background: true,
2996
- callback_expected: false,
2997
- terminal_control: terminalControl,
2998
- message: {
2999
- body: messageBody
3000
- },
3001
- openclaw_next_action: openClawYieldNextAction({
3002
- conversationId,
3614
+ runtimeLog("info", "terminal_message_send", {
3615
+ conversation_id: conversationId,
3616
+ agent,
3617
+ terminal_target: terminalControl.target,
3618
+ message: textSummary(messageBody)
3619
+ });
3620
+ printJson({
3621
+ conversation_id: conversationId,
3003
3622
  source: "terminal_control",
3004
- callbackExpected: false
3005
- })
3006
- });
3623
+ delivered: true,
3624
+ status: "async_pending",
3625
+ background: true,
3626
+ callback_expected: false,
3627
+ terminal_control: terminalControl,
3628
+ message: {
3629
+ body: messageBody
3630
+ },
3631
+ openclaw_next_action: openClawYieldNextAction({
3632
+ conversationId,
3633
+ source: "terminal_control",
3634
+ callbackExpected: false
3635
+ })
3636
+ });
3637
+ }
3638
+ finally {
3639
+ releaseTerminalLock();
3640
+ }
3007
3641
  }
3008
- async function runTerminalControlSend({ options, conversation, nextConversation, statePath, logPath, executor, message, terminalControl, needsNativeTakeoverBootstrap, terminalSendLockHeld = false }) {
3642
+ async function runTerminalControlSend({ options, conversation, nextConversation, statePath, logPath, executor, message, terminalControl, needsNativeTakeoverBootstrap, terminalSendLockHeld = false, terminalStateLockHeld = false, recordMessageAfterSend = false, recordRawAttachmentAfterSend = false }) {
3009
3643
  const bridge = terminalBridgeEnabled(conversation);
3010
- if (bridge && !terminalSendLockHeld) {
3644
+ if (!terminalSendLockHeld) {
3011
3645
  const releaseTerminalLock = acquireFileLock(terminalBridgeSendLockPath(storeDirFromOptions(options), terminalControl), { timeoutMs: 30000 });
3012
3646
  try {
3013
3647
  return await runTerminalControlSend({
@@ -3020,13 +3654,52 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
3020
3654
  message,
3021
3655
  terminalControl,
3022
3656
  needsNativeTakeoverBootstrap,
3023
- terminalSendLockHeld: true
3657
+ terminalSendLockHeld: true,
3658
+ terminalStateLockHeld,
3659
+ recordMessageAfterSend,
3660
+ recordRawAttachmentAfterSend
3024
3661
  });
3025
3662
  }
3026
3663
  finally {
3027
3664
  releaseTerminalLock();
3028
3665
  }
3029
3666
  }
3667
+ if (bridge && !terminalStateLockHeld) {
3668
+ const releaseStateLock = acquireFileLock(`${statePath}.lock`);
3669
+ try {
3670
+ const currentConversation = loadState(statePath);
3671
+ const currentTakeover = isRecord(currentConversation.native_session_takeover)
3672
+ ? currentConversation.native_session_takeover
3673
+ : undefined;
3674
+ const currentControl = terminalControlFromTakeover(currentTakeover);
3675
+ if (currentConversation.conversation_id !== nextConversation.conversation_id ||
3676
+ currentConversation.updated_at !== nextConversation.updated_at ||
3677
+ currentConversation.status !== nextConversation.status ||
3678
+ currentConversation.response_rounds_used !== nextConversation.response_rounds_used ||
3679
+ currentControl?.target !== terminalControl.target ||
3680
+ currentControl?.socketPath !== terminalControl.socketPath) {
3681
+ throw new Error("conversation changed while waiting to send to the terminal; refresh status and retry");
3682
+ }
3683
+ return await runTerminalControlSend({
3684
+ options,
3685
+ conversation: currentConversation,
3686
+ nextConversation: currentConversation,
3687
+ statePath,
3688
+ logPath,
3689
+ executor,
3690
+ message,
3691
+ terminalControl,
3692
+ needsNativeTakeoverBootstrap,
3693
+ terminalSendLockHeld: true,
3694
+ terminalStateLockHeld: true,
3695
+ recordMessageAfterSend,
3696
+ recordRawAttachmentAfterSend
3697
+ });
3698
+ }
3699
+ finally {
3700
+ releaseStateLock();
3701
+ }
3702
+ }
3030
3703
  const terminalBridge = createTerminalAgentBridge(options);
3031
3704
  const bridgeStartedAt = new Date().toISOString();
3032
3705
  const agentTimeoutMinutes = Number(options.agentTimeoutMinutes ?? DEFAULT_AGENT_TIMEOUT_MINUTES);
@@ -3046,6 +3719,10 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
3046
3719
  messageId: message.id
3047
3720
  };
3048
3721
  let preSendScreenFingerprint;
3722
+ let claudeTranscriptAnchor;
3723
+ const claudeHome = executor.kind === "claude"
3724
+ ? path.resolve(expandHome(options.claudeHome) ?? defaultClaudeHome())
3725
+ : undefined;
3049
3726
  if (bridge) {
3050
3727
  try {
3051
3728
  const status = await terminalBridge.status(executor.kind, terminalControl, {
@@ -3055,7 +3732,21 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
3055
3732
  if (executor.kind === "claude") {
3056
3733
  assertSafeClaudeTerminalSend(status);
3057
3734
  }
3058
- preSendScreenFingerprint = terminalBridgeScreenFingerprint(status.screen.excerpt);
3735
+ preSendScreenFingerprint = stringValue(status.screen.digest) ??
3736
+ terminalBridgeScreenFingerprint(status.screen.excerpt);
3737
+ if (executor.kind === "claude") {
3738
+ claudeTranscriptAnchor = captureClaudeTranscriptAnchor({
3739
+ sessionId: preSendRuntime.sessionId,
3740
+ cwd: preSendRuntime.cwd,
3741
+ pid: preSendRuntime.pid,
3742
+ claudeHome,
3743
+ agentRows: loadClaudeAgentRows(options)
3744
+ });
3745
+ if (!claudeTranscriptAnchor &&
3746
+ !stringValue(options.claudeHookStoreDir)) {
3747
+ throw new Error("the hook-free completion monitor could not bind an owner-private Claude transcript boundary");
3748
+ }
3749
+ }
3059
3750
  }
3060
3751
  catch (error) {
3061
3752
  if (executor.kind === "claude") {
@@ -3087,7 +3778,9 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
3087
3778
  ? withClaudeHookLeaseState(nextConversation, claudeHookLeaseState)
3088
3779
  : nextConversation;
3089
3780
  try {
3090
- await terminalBridge.send(executor.kind, terminalControl, terminalPayload);
3781
+ await terminalBridge.send(executor.kind, terminalControl, terminalPayload, {
3782
+ runtime: preSendRuntime
3783
+ });
3091
3784
  }
3092
3785
  catch (error) {
3093
3786
  if (claudeHookLeaseState) {
@@ -3095,39 +3788,17 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
3095
3788
  }
3096
3789
  throw error;
3097
3790
  }
3098
- const supersededConversationIds = bridge
3099
- ? supersedeTerminalBridgeConversations({
3100
- storeDir: storeDirFromOptions(options),
3101
- terminalControl,
3102
- replacementConversationId: conversation.conversation_id
3103
- })
3104
- : [];
3105
- appendEvent(logPath, {
3106
- ts: new Date().toISOString(),
3107
- conversation_id: conversation.conversation_id,
3108
- event: "terminal_message_send",
3109
- executor,
3110
- terminal_control: terminalControl,
3111
- message: textSummary(message.body),
3112
- payload: textSummary(terminalPayload),
3113
- superseded_conversation_ids: supersededConversationIds
3114
- });
3115
- runtimeLog("info", "terminal_message_send", {
3116
- conversation_id: conversation.conversation_id,
3117
- agent: executor.kind,
3118
- terminal_target: terminalControl.target,
3119
- message: textSummary(message.body),
3120
- payload: textSummary(terminalPayload),
3121
- superseded_conversation_ids: supersededConversationIds
3122
- });
3123
3791
  const bridgeConversation = bridge
3124
3792
  ? withTerminalBridgeState({
3125
3793
  conversation: conversationWithHookLease,
3126
3794
  message,
3795
+ requestText: terminalPayload,
3127
3796
  startedAt: bridgeStartedAt,
3128
3797
  agentTimeoutMinutes,
3129
3798
  agentHardTimeoutMinutes,
3130
- preSendScreenFingerprint
3799
+ preSendScreenFingerprint,
3800
+ claudeTranscriptAnchor,
3801
+ claudeHome
3131
3802
  })
3132
3803
  : nextConversation;
3133
3804
  const deliveredConversation = markTakeoverBootstrapped({
@@ -3136,9 +3807,68 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
3136
3807
  logPath,
3137
3808
  executor,
3138
3809
  native: needsNativeTakeoverBootstrap && !bridge,
3139
- fork: false
3810
+ fork: false,
3811
+ stateLockHeld: terminalStateLockHeld
3812
+ });
3813
+ saveState(statePath, deliveredConversation);
3814
+ const supersededConversationIds = bridge
3815
+ ? supersedeTerminalBridgeConversations({
3816
+ storeDir: storeDirFromOptions(options),
3817
+ terminalControl,
3818
+ replacementConversationId: conversation.conversation_id
3819
+ })
3820
+ : [];
3821
+ if (recordRawAttachmentAfterSend) {
3822
+ const sourceConversationId = stringValue(isRecord(conversation.native_session_takeover)
3823
+ ? conversation.native_session_takeover.native_session_id
3824
+ : undefined);
3825
+ appendEvent(logPath, {
3826
+ ts: new Date().toISOString(),
3827
+ conversation_id: conversation.conversation_id,
3828
+ event: "raw_terminal_session_attached",
3829
+ source_conversation_id: sourceConversationId,
3830
+ agent: executor.kind,
3831
+ terminal_control: terminalControl,
3832
+ executor
3833
+ });
3834
+ runtimeLog("info", "raw_terminal_session_attached", {
3835
+ conversation_id: conversation.conversation_id,
3836
+ source_conversation_id: sourceConversationId,
3837
+ terminal_target: terminalControl.target,
3838
+ state_path: statePath,
3839
+ event_log_path: logPath
3840
+ });
3841
+ }
3842
+ if (recordMessageAfterSend) {
3843
+ appendEvent(logPath, messageEvent(message));
3844
+ runtimeLog("info", "message_created", {
3845
+ conversation_id: conversation.conversation_id,
3846
+ agent: executor.kind,
3847
+ executor_session: executor.session,
3848
+ message_type: message.type,
3849
+ state_path: statePath,
3850
+ event_log_path: logPath,
3851
+ message: textSummary(message.body)
3852
+ });
3853
+ }
3854
+ appendEvent(logPath, {
3855
+ ts: new Date().toISOString(),
3856
+ conversation_id: conversation.conversation_id,
3857
+ event: "terminal_message_send",
3858
+ executor,
3859
+ terminal_control: terminalControl,
3860
+ message: textSummary(message.body),
3861
+ payload: textSummary(terminalPayload),
3862
+ superseded_conversation_ids: supersededConversationIds
3863
+ });
3864
+ runtimeLog("info", "terminal_message_send", {
3865
+ conversation_id: conversation.conversation_id,
3866
+ agent: executor.kind,
3867
+ terminal_target: terminalControl.target,
3868
+ message: textSummary(message.body),
3869
+ payload: textSummary(terminalPayload),
3870
+ superseded_conversation_ids: supersededConversationIds
3140
3871
  });
3141
- saveState(statePath, deliveredConversation);
3142
3872
  const bridgeMonitor = bridge
3143
3873
  ? startTerminalBridgeMonitorForConversation({
3144
3874
  conversation: deliveredConversation,
@@ -3182,7 +3912,7 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
3182
3912
  });
3183
3913
  }
3184
3914
  function terminalSubmissionPayload(payload) {
3185
- return payload.replace(/[\r\n]+$/u, "");
3915
+ return payload.trimEnd();
3186
3916
  }
3187
3917
  function createManagedTerminalConversationFromRawId({ options, conversationId, agent, pid, messageBody, terminalControl }) {
3188
3918
  const workspace = terminalControl.currentPath ?? process.cwd();
@@ -3246,23 +3976,6 @@ function createManagedTerminalConversationFromRawId({ options, conversationId, a
3246
3976
  terminal_bridge: true
3247
3977
  }
3248
3978
  }, paths);
3249
- saveState(paths.statePath, attachedConversation);
3250
- appendEvent(paths.logPath, {
3251
- ts: now.toISOString(),
3252
- conversation_id: attachedConversation.conversation_id,
3253
- event: "raw_terminal_session_attached",
3254
- source_conversation_id: conversationId,
3255
- agent,
3256
- terminal_control: terminalControl,
3257
- executor
3258
- });
3259
- runtimeLog("info", "raw_terminal_session_attached", {
3260
- conversation_id: attachedConversation.conversation_id,
3261
- source_conversation_id: conversationId,
3262
- terminal_target: terminalControl.target,
3263
- state_path: paths.statePath,
3264
- event_log_path: paths.logPath
3265
- });
3266
3979
  const message = createMessage({
3267
3980
  conversation: attachedConversation,
3268
3981
  from: "openclaw",
@@ -3276,18 +3989,6 @@ function createManagedTerminalConversationFromRawId({ options, conversationId, a
3276
3989
  }
3277
3990
  });
3278
3991
  const nextConversation = applyMessageToConversation(attachedConversation, message);
3279
- saveState(paths.statePath, nextConversation);
3280
- appendEvent(paths.logPath, messageEvent(message));
3281
- runtimeLog("info", "message_created", {
3282
- conversation_id: nextConversation.conversation_id,
3283
- source_conversation_id: conversationId,
3284
- agent: executor.kind,
3285
- executor_session: executor.session,
3286
- message_type: message.type,
3287
- state_path: paths.statePath,
3288
- event_log_path: paths.logPath,
3289
- message: textSummary(messageBody)
3290
- });
3291
3992
  return {
3292
3993
  conversation: attachedConversation,
3293
3994
  nextConversation,
@@ -3391,7 +4092,7 @@ function runNativeCodexResumeSend({ options, conversation, nextConversation, sta
3391
4092
  });
3392
4093
  if (options.background) {
3393
4094
  const outputPath = path.join(path.dirname(logPath), `${executor.kind}-native-resume-output.log`);
3394
- const outputFd = fs.openSync(outputPath, "a");
4095
+ const outputFd = openPrivateAppendFile(outputPath);
3395
4096
  const child = spawn(codexPath, codexArgs, {
3396
4097
  detached: true,
3397
4098
  stdio: ["ignore", outputFd, outputFd],
@@ -3599,68 +4300,100 @@ function buildAgentSendPayload({ conversation, executor, message, includeNativeT
3599
4300
  function forkTakeoverSummaryText(forkTakeover) {
3600
4301
  return String(isRecord(forkTakeover) ? forkTakeover.summary ?? "" : "").trim();
3601
4302
  }
3602
- function markTakeoverBootstrapped({ conversation, statePath, logPath, executor, native, fork }) {
4303
+ function markTakeoverBootstrapped({ conversation, statePath, logPath, executor, native, fork, stateLockHeld = false }) {
3603
4304
  let nextConversation = conversation;
3604
4305
  if (native) {
3605
- nextConversation = markNativeSessionBootstrapped({ conversation: nextConversation, statePath, logPath, executor });
4306
+ nextConversation = markNativeSessionBootstrapped({
4307
+ conversation: nextConversation,
4308
+ statePath,
4309
+ logPath,
4310
+ executor,
4311
+ stateLockHeld
4312
+ });
3606
4313
  }
3607
4314
  if (fork) {
3608
- nextConversation = markForkSessionBootstrapped({ conversation: nextConversation, statePath, logPath, executor });
4315
+ nextConversation = markForkSessionBootstrapped({
4316
+ conversation: nextConversation,
4317
+ statePath,
4318
+ logPath,
4319
+ executor,
4320
+ stateLockHeld
4321
+ });
3609
4322
  }
3610
4323
  return nextConversation;
3611
4324
  }
3612
- function markNativeSessionBootstrapped({ conversation, statePath, logPath, executor }) {
3613
- const nativeTakeover = isRecord(conversation.native_session_takeover)
3614
- ? conversation.native_session_takeover
3615
- : {};
4325
+ function markNativeSessionBootstrapped({ conversation, statePath, logPath, executor, stateLockHeld = false }) {
3616
4326
  const now = new Date().toISOString();
3617
- const nextConversation = {
3618
- ...conversation,
3619
- native_session_takeover: {
3620
- ...nativeTakeover,
3621
- needs_bootstrap: false,
3622
- bootstrapped_at: now
3623
- },
3624
- updated_at: now
3625
- };
3626
- saveState(statePath, nextConversation);
4327
+ const releaseLock = stateLockHeld
4328
+ ? undefined
4329
+ : acquireFileLock(`${statePath}.lock`);
4330
+ let nextConversation = conversation;
4331
+ try {
4332
+ const current = stateLockHeld ? conversation : loadState(statePath);
4333
+ const nativeTakeover = isRecord(current.native_session_takeover)
4334
+ ? current.native_session_takeover
4335
+ : {};
4336
+ nextConversation = {
4337
+ ...current,
4338
+ native_session_takeover: {
4339
+ ...nativeTakeover,
4340
+ needs_bootstrap: false,
4341
+ bootstrapped_at: now
4342
+ },
4343
+ updated_at: now
4344
+ };
4345
+ saveState(statePath, nextConversation);
4346
+ }
4347
+ finally {
4348
+ releaseLock?.();
4349
+ }
3627
4350
  appendEvent(logPath, {
3628
4351
  ts: now,
3629
- conversation_id: conversation.conversation_id,
4352
+ conversation_id: nextConversation.conversation_id,
3630
4353
  event: "native_session_bootstrapped",
3631
4354
  executor
3632
4355
  });
3633
4356
  runtimeLog("info", "native_session_bootstrapped", {
3634
- conversation_id: conversation.conversation_id,
4357
+ conversation_id: nextConversation.conversation_id,
3635
4358
  agent: executor.kind,
3636
4359
  executor_session: executor.session,
3637
4360
  state_path: statePath
3638
4361
  });
3639
4362
  return nextConversation;
3640
4363
  }
3641
- function markForkSessionBootstrapped({ conversation, statePath, logPath, executor }) {
3642
- const forkTakeover = isRecord(conversation.fork_context_takeover)
3643
- ? conversation.fork_context_takeover
3644
- : {};
4364
+ function markForkSessionBootstrapped({ conversation, statePath, logPath, executor, stateLockHeld = false }) {
3645
4365
  const now = new Date().toISOString();
3646
- const nextConversation = {
3647
- ...conversation,
3648
- fork_context_takeover: {
3649
- ...forkTakeover,
3650
- needs_bootstrap: false,
3651
- bootstrapped_at: now
3652
- },
3653
- updated_at: now
3654
- };
3655
- saveState(statePath, nextConversation);
4366
+ const releaseLock = stateLockHeld
4367
+ ? undefined
4368
+ : acquireFileLock(`${statePath}.lock`);
4369
+ let nextConversation = conversation;
4370
+ try {
4371
+ const current = stateLockHeld ? conversation : loadState(statePath);
4372
+ const forkTakeover = isRecord(current.fork_context_takeover)
4373
+ ? current.fork_context_takeover
4374
+ : {};
4375
+ nextConversation = {
4376
+ ...current,
4377
+ fork_context_takeover: {
4378
+ ...forkTakeover,
4379
+ needs_bootstrap: false,
4380
+ bootstrapped_at: now
4381
+ },
4382
+ updated_at: now
4383
+ };
4384
+ saveState(statePath, nextConversation);
4385
+ }
4386
+ finally {
4387
+ releaseLock?.();
4388
+ }
3656
4389
  appendEvent(logPath, {
3657
4390
  ts: now,
3658
- conversation_id: conversation.conversation_id,
4391
+ conversation_id: nextConversation.conversation_id,
3659
4392
  event: "fork_session_bootstrapped",
3660
4393
  executor
3661
4394
  });
3662
4395
  runtimeLog("info", "fork_session_bootstrapped", {
3663
- conversation_id: conversation.conversation_id,
4396
+ conversation_id: nextConversation.conversation_id,
3664
4397
  agent: executor.kind,
3665
4398
  executor_session: executor.session,
3666
4399
  state_path: statePath
@@ -3747,124 +4480,576 @@ function markConversationNeedsRecovery({ conversation, statePath, logPath, execu
3747
4480
  };
3748
4481
  }
3749
4482
  async function runRenew(options) {
3750
- const { conversation, statePath, logPath } = loadConversationFromOptions(options);
4483
+ const loaded = loadConversationFromOptions(options);
4484
+ const { statePath, logPath } = loaded;
4485
+ const conversation = await migrateLegacyTerminalAgentIdentity({
4486
+ ...loaded,
4487
+ options
4488
+ });
3751
4489
  if (conversation.status === "closed") {
3752
4490
  throw new Error(`cannot renew ${conversation.conversation_id}; conversation is closed`);
3753
4491
  }
3754
- if (conversation.status !== "stalled") {
3755
- throw new Error(`cannot renew ${conversation.conversation_id}; conversation is ${conversation.status}, not stalled`);
4492
+ if (conversation.status !== "stalled") {
4493
+ throw new Error(`cannot renew ${conversation.conversation_id}; conversation is ${conversation.status}, not stalled`);
4494
+ }
4495
+ const nativeTakeover = isRecord(conversation.native_session_takeover)
4496
+ ? conversation.native_session_takeover
4497
+ : undefined;
4498
+ const terminalControl = terminalControlFromTakeover(nativeTakeover);
4499
+ if (!terminalControl || nativeTakeover?.["terminal_bridge"] !== true) {
4500
+ throw new Error(`cannot renew ${conversation.conversation_id}; conversation is not a terminal bridge task`);
4501
+ }
4502
+ const panes = await createTerminalControlProvider(options).listPanes();
4503
+ const terminalExists = panes.some((pane) => pane.target === terminalControl.target &&
4504
+ (terminalControl.socketPath === undefined || pane.socketPath === terminalControl.socketPath));
4505
+ if (!terminalExists) {
4506
+ throw new Error(`cannot renew ${conversation.conversation_id}; terminal ${terminalControl.target} is no longer available`);
4507
+ }
4508
+ const expectedMessageId = stringValue(nativeTakeover?.terminal_bridge_message_id);
4509
+ const expectedStartedAt = stringValue(nativeTakeover?.terminal_bridge_started_at);
4510
+ let renewed = conversation;
4511
+ let renewedTerminalControl = terminalControl;
4512
+ let inactivityTimeoutMinutes = 0;
4513
+ let hardTimeoutMinutes = 0;
4514
+ const releaseStateLock = acquireFileLock(`${statePath}.lock`);
4515
+ try {
4516
+ const current = loadState(statePath);
4517
+ if (current.status !== "stalled") {
4518
+ throw new Error(`cannot renew ${current.conversation_id}; conversation is ${current.status}, not stalled`);
4519
+ }
4520
+ const currentTakeover = isRecord(current.native_session_takeover)
4521
+ ? current.native_session_takeover
4522
+ : undefined;
4523
+ const currentControl = terminalControlFromTakeover(currentTakeover);
4524
+ if (!currentControl || currentTakeover?.terminal_bridge !== true) {
4525
+ throw new Error(`cannot renew ${current.conversation_id}; conversation is not a terminal bridge task`);
4526
+ }
4527
+ if (current.conversation_id !== conversation.conversation_id ||
4528
+ currentControl.target !== terminalControl.target ||
4529
+ currentControl.socketPath !== terminalControl.socketPath ||
4530
+ currentControl.panePid !== terminalControl.panePid ||
4531
+ stringValue(currentTakeover.terminal_bridge_message_id) !== expectedMessageId ||
4532
+ stringValue(currentTakeover.terminal_bridge_started_at) !== expectedStartedAt) {
4533
+ throw new Error("conversation changed while waiting to renew; refresh status and retry");
4534
+ }
4535
+ renewedTerminalControl = currentControl;
4536
+ inactivityTimeoutMinutes = positiveMinutes(options.minutes ??
4537
+ options.agentTimeoutMinutes ??
4538
+ currentTakeover.terminal_bridge_inactivity_timeout_minutes ??
4539
+ DEFAULT_AGENT_TIMEOUT_MINUTES, "--minutes");
4540
+ hardTimeoutMinutes = positiveMinutes(currentTakeover.terminal_bridge_hard_timeout_minutes ??
4541
+ DEFAULT_AGENT_HARD_TIMEOUT_MINUTES, "--agent-hard-timeout-minutes");
4542
+ const startedAt = stringValue(currentTakeover.terminal_bridge_started_at);
4543
+ const startedAtMs = startedAt ? Date.parse(startedAt) : NaN;
4544
+ if (Number.isFinite(startedAtMs) &&
4545
+ Date.now() - startedAtMs >= hardTimeoutMinutes * 60 * 1000) {
4546
+ throw new Error(`cannot renew ${current.conversation_id}; terminal bridge hard lifetime of ${hardTimeoutMinutes} minutes has elapsed`);
4547
+ }
4548
+ const now = new Date().toISOString();
4549
+ const currentMessageId = stringValue(currentTakeover.terminal_bridge_message_id);
4550
+ const hardDeadline = deadlineAt(startedAt ?? now, hardTimeoutMinutes) ??
4551
+ new Date(Date.now() + hardTimeoutMinutes * 60 * 1000).toISOString();
4552
+ const inactivityDeadline = deadlineAt(now, inactivityTimeoutMinutes) ??
4553
+ new Date(Date.now() + inactivityTimeoutMinutes * 60 * 1000).toISOString();
4554
+ const renewedLease = executorForConversation(current).kind === "claude" && currentMessageId
4555
+ ? activateClaudeHookLease({
4556
+ options,
4557
+ conversation: current,
4558
+ message: { id: currentMessageId },
4559
+ terminalControl: currentControl,
4560
+ expiresAt: new Date(Math.min(Date.parse(hardDeadline), Date.parse(inactivityDeadline))).toISOString()
4561
+ })
4562
+ : undefined;
4563
+ const renewedBase = renewedLease
4564
+ ? withClaudeHookLeaseState(current, renewedLease)
4565
+ : current;
4566
+ const renewedNativeTakeover = isRecord(renewedBase.native_session_takeover)
4567
+ ? renewedBase.native_session_takeover
4568
+ : currentTakeover;
4569
+ renewed = {
4570
+ ...renewedBase,
4571
+ status: "waiting_for_agent",
4572
+ native_session_takeover: {
4573
+ ...renewedNativeTakeover,
4574
+ terminal_bridge_monitor_lock_version: TERMINAL_BRIDGE_MONITOR_LOCK_VERSION,
4575
+ terminal_bridge_monitor_started_at: now,
4576
+ terminal_bridge_last_activity_at: now,
4577
+ terminal_bridge_inactivity_timeout_minutes: inactivityTimeoutMinutes,
4578
+ terminal_bridge_hard_timeout_minutes: hardTimeoutMinutes,
4579
+ terminal_bridge_inactivity_deadline_at: inactivityDeadline,
4580
+ terminal_bridge_hard_deadline_at: hardDeadline,
4581
+ terminal_bridge_renewed_at: now
4582
+ },
4583
+ updated_at: now
4584
+ };
4585
+ Reflect.deleteProperty(renewed, "stalled_at");
4586
+ Reflect.deleteProperty(renewed, "stalled_reason");
4587
+ Reflect.deleteProperty(renewed, "stalled_notification_sent_at");
4588
+ Reflect.deleteProperty(renewed, "stalled_notification_message_id");
4589
+ saveState(statePath, renewed);
4590
+ appendEvent(logPath, {
4591
+ ts: now,
4592
+ conversation_id: current.conversation_id,
4593
+ event: "terminal_bridge_renewed",
4594
+ previous_status: current.status,
4595
+ terminal_control: currentControl,
4596
+ agent_timeout_minutes: inactivityTimeoutMinutes,
4597
+ agent_hard_timeout_minutes: hardTimeoutMinutes,
4598
+ last_activity_at: now
4599
+ });
4600
+ runtimeLog("info", "terminal_bridge_renewed", {
4601
+ conversation_id: current.conversation_id,
4602
+ terminal_target: currentControl.target,
4603
+ agent_timeout_minutes: inactivityTimeoutMinutes,
4604
+ agent_hard_timeout_minutes: hardTimeoutMinutes
4605
+ });
4606
+ }
4607
+ finally {
4608
+ releaseStateLock();
4609
+ }
4610
+ const monitor = startTerminalBridgeMonitorForConversation({
4611
+ conversation: renewed,
4612
+ statePath,
4613
+ logPath,
4614
+ options: {
4615
+ ...options,
4616
+ agentTimeoutMinutes: inactivityTimeoutMinutes,
4617
+ agentHardTimeoutMinutes: hardTimeoutMinutes
4618
+ }
4619
+ });
4620
+ if (monitor) {
4621
+ appendEvent(logPath, {
4622
+ ts: new Date().toISOString(),
4623
+ conversation_id: renewed.conversation_id,
4624
+ event: "terminal_bridge_monitor_launch",
4625
+ pid: monitor.pid ?? null,
4626
+ terminal_control: renewedTerminalControl,
4627
+ reason: "renewal",
4628
+ agent_timeout_minutes: inactivityTimeoutMinutes,
4629
+ agent_hard_timeout_minutes: hardTimeoutMinutes
4630
+ });
4631
+ }
4632
+ printJson({
4633
+ conversation: renewed,
4634
+ renewed: true,
4635
+ terminal_control: renewedTerminalControl,
4636
+ agent_timeout_minutes: inactivityTimeoutMinutes,
4637
+ agent_hard_timeout_minutes: hardTimeoutMinutes,
4638
+ monitor_pid: monitor?.pid ?? null
4639
+ });
4640
+ }
4641
+ async function runReconcileMonitors(options) {
4642
+ const storeDir = storeDirFromOptions(options);
4643
+ const conversations = listConversations(storeDir);
4644
+ const items = [];
4645
+ let ignored = 0;
4646
+ let launched = 0;
4647
+ let alreadyRunning = 0;
4648
+ let skipped = 0;
4649
+ let errors = 0;
4650
+ for (const listedConversation of conversations) {
4651
+ const statePath = expandHome(stringValue(listedConversation.state_path) ??
4652
+ statePathForConversationId(listedConversation.conversation_id, storeDir));
4653
+ const logPath = expandHome(stringValue(listedConversation.event_log_path) ??
4654
+ logPathForStatePath(statePath));
4655
+ try {
4656
+ const callbackRecovery = prepareCallbackDeliveryReconciliation({
4657
+ statePath,
4658
+ logPath,
4659
+ delayMs: options.callbackRetryDelayMs
4660
+ });
4661
+ if (callbackRecovery.handled) {
4662
+ if (callbackRecovery.status === "launched") {
4663
+ launched += 1;
4664
+ }
4665
+ else if (callbackRecovery.status === "already_running") {
4666
+ alreadyRunning += 1;
4667
+ }
4668
+ else {
4669
+ skipped += 1;
4670
+ }
4671
+ items.push({
4672
+ conversation_id: callbackRecovery.conversationId,
4673
+ status: callbackRecovery.status,
4674
+ reason: callbackRecovery.reason,
4675
+ ...(callbackRecovery.monitorPid === undefined
4676
+ ? {}
4677
+ : { monitor_pid: callbackRecovery.monitorPid })
4678
+ });
4679
+ continue;
4680
+ }
4681
+ const listedNativeTakeover = isRecord(listedConversation.native_session_takeover)
4682
+ ? listedConversation.native_session_takeover
4683
+ : undefined;
4684
+ if (listedNativeTakeover?.terminal_bridge !== true) {
4685
+ ignored += 1;
4686
+ continue;
4687
+ }
4688
+ const initialConversation = await migrateLegacyTerminalAgentIdentity({
4689
+ conversation: loadState(statePath),
4690
+ statePath,
4691
+ logPath,
4692
+ options
4693
+ });
4694
+ const initialEligibility = terminalBridgeReconciliationEligibility(initialConversation);
4695
+ if (!initialEligibility.eligible) {
4696
+ skipped += 1;
4697
+ items.push({
4698
+ conversation_id: initialConversation.conversation_id,
4699
+ status: "skipped",
4700
+ reason: initialEligibility.reason
4701
+ });
4702
+ continue;
4703
+ }
4704
+ const activeOwner = activeTerminalBridgeMonitorOwner(statePath, initialEligibility.terminalMessageId);
4705
+ if (activeOwner) {
4706
+ alreadyRunning += 1;
4707
+ items.push({
4708
+ conversation_id: initialConversation.conversation_id,
4709
+ status: "already_running",
4710
+ reason: "monitor_lock_owner_alive",
4711
+ monitor_owner_pid: activeOwner.ownerPid ?? null
4712
+ });
4713
+ continue;
4714
+ }
4715
+ const monitorLockVersion = Number(initialEligibility.nativeTakeover.terminal_bridge_monitor_lock_version);
4716
+ if (monitorLockVersion !== TERMINAL_BRIDGE_MONITOR_LOCK_VERSION) {
4717
+ if (Number.isFinite(monitorLockVersion)) {
4718
+ skipped += 1;
4719
+ items.push({
4720
+ conversation_id: initialConversation.conversation_id,
4721
+ status: "skipped",
4722
+ reason: "monitor_lock_version_unsupported",
4723
+ monitor_lock_version: monitorLockVersion
4724
+ });
4725
+ continue;
4726
+ }
4727
+ const legacyLaunchPid = latestTerminalBridgeMonitorLaunchPid(logPath);
4728
+ if (legacyLaunchPid === undefined) {
4729
+ skipped += 1;
4730
+ items.push({
4731
+ conversation_id: initialConversation.conversation_id,
4732
+ status: "skipped",
4733
+ reason: "legacy_monitor_ownership_unknown"
4734
+ });
4735
+ continue;
4736
+ }
4737
+ if (isProcessAlive(legacyLaunchPid)) {
4738
+ alreadyRunning += 1;
4739
+ items.push({
4740
+ conversation_id: initialConversation.conversation_id,
4741
+ status: "already_running",
4742
+ reason: "legacy_monitor_launch_pid_alive",
4743
+ monitor_owner_pid: legacyLaunchPid
4744
+ });
4745
+ continue;
4746
+ }
4747
+ }
4748
+ const prepared = prepareTerminalBridgeMonitorReconciliation({
4749
+ statePath,
4750
+ expectedMessageId: initialEligibility.terminalMessageId
4751
+ });
4752
+ if (!prepared.prepared) {
4753
+ if (prepared.alreadyRunning) {
4754
+ alreadyRunning += 1;
4755
+ items.push({
4756
+ conversation_id: initialConversation.conversation_id,
4757
+ status: "already_running",
4758
+ reason: prepared.reason,
4759
+ monitor_owner_pid: prepared.ownerPid ?? null
4760
+ });
4761
+ }
4762
+ else {
4763
+ skipped += 1;
4764
+ items.push({
4765
+ conversation_id: initialConversation.conversation_id,
4766
+ status: "skipped",
4767
+ reason: prepared.reason
4768
+ });
4769
+ }
4770
+ continue;
4771
+ }
4772
+ const monitor = startTerminalBridgeMonitorForConversation({
4773
+ conversation: prepared.conversation,
4774
+ statePath,
4775
+ logPath,
4776
+ options
4777
+ });
4778
+ if (!monitor) {
4779
+ skipped += 1;
4780
+ items.push({
4781
+ conversation_id: prepared.conversation.conversation_id,
4782
+ status: "skipped",
4783
+ reason: "terminal_bridge_monitor_launch_disabled"
4784
+ });
4785
+ continue;
4786
+ }
4787
+ const launchedAt = new Date().toISOString();
4788
+ appendEvent(logPath, {
4789
+ ts: launchedAt,
4790
+ conversation_id: prepared.conversation.conversation_id,
4791
+ event: "terminal_bridge_monitor_launch",
4792
+ pid: monitor.pid ?? null,
4793
+ terminal_control: prepared.terminalControl,
4794
+ reason: "startup_reconciliation",
4795
+ agent_timeout_minutes: prepared.inactivityTimeoutMinutes,
4796
+ agent_hard_timeout_minutes: prepared.hardTimeoutMinutes
4797
+ });
4798
+ runtimeLog("info", "terminal_bridge_monitor_reconciled", {
4799
+ conversation_id: prepared.conversation.conversation_id,
4800
+ monitor_pid: monitor.pid ?? null,
4801
+ terminal_target: prepared.terminalControl.target
4802
+ });
4803
+ launched += 1;
4804
+ items.push({
4805
+ conversation_id: prepared.conversation.conversation_id,
4806
+ status: "launched",
4807
+ reason: "startup_reconciliation",
4808
+ monitor_pid: monitor.pid ?? null
4809
+ });
4810
+ }
4811
+ catch (error) {
4812
+ errors += 1;
4813
+ items.push({
4814
+ conversation_id: listedConversation.conversation_id,
4815
+ status: "error",
4816
+ reason: error instanceof Error ? error.message : String(error)
4817
+ });
4818
+ }
4819
+ }
4820
+ printJson({
4821
+ reconciled: true,
4822
+ store_dir: storeDir,
4823
+ checked: conversations.length,
4824
+ ignored,
4825
+ launched,
4826
+ already_running: alreadyRunning,
4827
+ skipped,
4828
+ errors,
4829
+ items
4830
+ });
4831
+ }
4832
+ function prepareCallbackDeliveryReconciliation({ statePath, logPath, delayMs }) {
4833
+ const releaseStateLock = acquireFileLock(`${statePath}.lock`);
4834
+ try {
4835
+ const conversation = loadState(statePath);
4836
+ const callbackDelivery = isRecord(conversation.callback_delivery)
4837
+ ? conversation.callback_delivery
4838
+ : undefined;
4839
+ if (!["callback_pending", "callback_failed"].includes(conversation.status)) {
4840
+ return {
4841
+ handled: false
4842
+ };
4843
+ }
4844
+ const conversationId = stringValue(conversation.conversation_id) ?? "unknown";
4845
+ const attempts = Number(callbackDelivery?.attempts ?? 0);
4846
+ if (!["pending", "failed"].includes(String(callbackDelivery?.status ?? "")) ||
4847
+ !isRecord(callbackDelivery?.message)) {
4848
+ return {
4849
+ handled: true,
4850
+ conversationId,
4851
+ status: "skipped",
4852
+ reason: "callback_delivery_metadata_missing"
4853
+ };
4854
+ }
4855
+ if (!Number.isSafeInteger(attempts) || attempts < 1 ||
4856
+ attempts > CALLBACK_RETRY_DELAYS_MS.length) {
4857
+ return {
4858
+ handled: true,
4859
+ conversationId,
4860
+ status: "skipped",
4861
+ reason: "callback_delivery_retries_exhausted"
4862
+ };
4863
+ }
4864
+ const configuredDelayMs = Number(delayMs);
4865
+ const retryDelayMs = Number.isFinite(configuredDelayMs) &&
4866
+ configuredDelayMs >= 0
4867
+ ? configuredDelayMs
4868
+ : CALLBACK_RETRY_DELAYS_MS[Math.max(0, attempts - 1)];
4869
+ const retryMonitor = startCallbackRetryMonitor({
4870
+ statePath,
4871
+ delayMs: retryDelayMs
4872
+ });
4873
+ const launchedAt = new Date().toISOString();
4874
+ const nextAttemptAt = new Date(Date.now() + retryDelayMs).toISOString();
4875
+ const nextConversation = {
4876
+ ...conversation,
4877
+ callback_delivery: {
4878
+ ...callbackDelivery,
4879
+ retry_monitor_pid: retryMonitor.pid ?? null,
4880
+ next_attempt_at: nextAttemptAt
4881
+ },
4882
+ updated_at: launchedAt
4883
+ };
4884
+ saveState(statePath, nextConversation);
4885
+ appendEvent(logPath, {
4886
+ ts: launchedAt,
4887
+ conversation_id: conversationId,
4888
+ event: "callback_retry_monitor_launched",
4889
+ message_id: callbackDelivery.message.id,
4890
+ pid: retryMonitor.pid ?? null,
4891
+ next_attempt_at: nextAttemptAt,
4892
+ reason: "startup_reconciliation"
4893
+ });
4894
+ return {
4895
+ handled: true,
4896
+ conversationId,
4897
+ status: "launched",
4898
+ reason: "callback_delivery_reconciliation",
4899
+ monitorPid: retryMonitor.pid
4900
+ };
4901
+ }
4902
+ finally {
4903
+ releaseStateLock();
3756
4904
  }
3757
- const nativeTakeover = isRecord(conversation.native_session_takeover)
4905
+ }
4906
+ function terminalBridgeReconciliationEligibility(conversation) {
4907
+ const nativeTakeover = isRecord(conversation?.native_session_takeover)
3758
4908
  ? conversation.native_session_takeover
3759
4909
  : undefined;
3760
- const terminalControl = terminalControlFromTakeover(nativeTakeover);
3761
- if (!terminalControl || nativeTakeover?.["terminal_bridge"] !== true) {
3762
- throw new Error(`cannot renew ${conversation.conversation_id}; conversation is not a terminal bridge task`);
4910
+ if (nativeTakeover?.terminal_bridge !== true) {
4911
+ return { eligible: false, reason: "not_terminal_bridge" };
3763
4912
  }
3764
- const panes = await createTerminalControlProvider(options).listPanes();
3765
- const terminalExists = panes.some((pane) => pane.target === terminalControl.target &&
3766
- (terminalControl.socketPath === undefined || pane.socketPath === terminalControl.socketPath));
3767
- if (!terminalExists) {
3768
- throw new Error(`cannot renew ${conversation.conversation_id}; terminal ${terminalControl.target} is no longer available`);
4913
+ if (!conversation.gateway_method) {
4914
+ return { eligible: false, reason: "gateway_method_missing" };
3769
4915
  }
3770
- const inactivityTimeoutMinutes = positiveMinutes(options.minutes ??
3771
- options.agentTimeoutMinutes ??
3772
- nativeTakeover?.["terminal_bridge_inactivity_timeout_minutes"] ??
3773
- DEFAULT_AGENT_TIMEOUT_MINUTES, "--minutes");
3774
- const hardTimeoutMinutes = positiveMinutes(nativeTakeover?.["terminal_bridge_hard_timeout_minutes"] ??
3775
- DEFAULT_AGENT_HARD_TIMEOUT_MINUTES, "--agent-hard-timeout-minutes");
3776
- const startedAt = stringValue(nativeTakeover?.["terminal_bridge_started_at"]);
3777
- const startedAtMs = startedAt ? Date.parse(startedAt) : NaN;
3778
- if (Number.isFinite(startedAtMs) && Date.now() - startedAtMs >= hardTimeoutMinutes * 60 * 1000) {
3779
- throw new Error(`cannot renew ${conversation.conversation_id}; terminal bridge hard lifetime of ${hardTimeoutMinutes} minutes has elapsed`);
4916
+ if (!stringValue(conversation.gateway_session) && !stringValue(conversation.openclaw_session)) {
4917
+ return { eligible: false, reason: "gateway_session_missing" };
3780
4918
  }
3781
- const now = new Date().toISOString();
3782
- const currentMessageId = stringValue(nativeTakeover?.terminal_bridge_message_id);
3783
- const hardDeadline = deadlineAt(startedAt ?? now, hardTimeoutMinutes) ??
3784
- new Date(Date.now() + hardTimeoutMinutes * 60 * 1000).toISOString();
3785
- const inactivityDeadline = deadlineAt(now, inactivityTimeoutMinutes) ??
3786
- new Date(Date.now() + inactivityTimeoutMinutes * 60 * 1000).toISOString();
3787
- const renewedLease = executorForConversation(conversation).kind === "claude" && currentMessageId
3788
- ? activateClaudeHookLease({
3789
- options,
3790
- conversation,
3791
- message: { id: currentMessageId },
3792
- terminalControl,
3793
- expiresAt: new Date(Math.min(Date.parse(hardDeadline), Date.parse(inactivityDeadline))).toISOString()
3794
- })
3795
- : undefined;
3796
- const renewedBase = renewedLease
3797
- ? withClaudeHookLeaseState(conversation, renewedLease)
3798
- : conversation;
3799
- const renewedNativeTakeover = isRecord(renewedBase.native_session_takeover)
3800
- ? renewedBase.native_session_takeover
3801
- : nativeTakeover;
3802
- const renewed = {
3803
- ...renewedBase,
3804
- status: "waiting_for_agent",
3805
- native_session_takeover: {
3806
- ...renewedNativeTakeover,
3807
- terminal_bridge_monitor_started_at: now,
3808
- terminal_bridge_last_activity_at: now,
3809
- terminal_bridge_inactivity_timeout_minutes: inactivityTimeoutMinutes,
3810
- terminal_bridge_hard_timeout_minutes: hardTimeoutMinutes,
3811
- terminal_bridge_inactivity_deadline_at: inactivityDeadline,
3812
- terminal_bridge_hard_deadline_at: hardDeadline,
3813
- terminal_bridge_renewed_at: now
3814
- },
3815
- updated_at: now
4919
+ if (!isWaitingForAgent(conversation.status)) {
4920
+ return {
4921
+ eligible: false,
4922
+ reason: `conversation_status_${String(conversation.status ?? "missing")}`
4923
+ };
4924
+ }
4925
+ const terminalMessageId = stringValue(nativeTakeover.terminal_bridge_message_id);
4926
+ const terminalControl = terminalControlFromTakeover(nativeTakeover);
4927
+ if (!terminalMessageId || !terminalControl) {
4928
+ return { eligible: false, reason: "terminal_bridge_identity_missing" };
4929
+ }
4930
+ const runtime = terminalRuntimeIdentityForConversation(conversation, terminalControl);
4931
+ if (!Number.isInteger(runtime.pid) || Number(runtime.pid) <= 0 || !stringValue(runtime.cwd)) {
4932
+ return { eligible: false, reason: "terminal_agent_identity_missing" };
4933
+ }
4934
+ const inactivityTimeoutMinutes = Number(nativeTakeover.terminal_bridge_inactivity_timeout_minutes);
4935
+ const hardTimeoutMinutes = Number(nativeTakeover.terminal_bridge_hard_timeout_minutes);
4936
+ const startedAtMs = validTimestampMs(nativeTakeover.terminal_bridge_started_at);
4937
+ const lastActivityAtMs = validTimestampMs(nativeTakeover.terminal_bridge_last_activity_at);
4938
+ const inactivityDeadlineAtMs = validTimestampMs(nativeTakeover.terminal_bridge_inactivity_deadline_at);
4939
+ const hardDeadlineAtMs = validTimestampMs(nativeTakeover.terminal_bridge_hard_deadline_at);
4940
+ if (!Number.isFinite(inactivityTimeoutMinutes) ||
4941
+ inactivityTimeoutMinutes <= 0 ||
4942
+ !Number.isFinite(hardTimeoutMinutes) ||
4943
+ hardTimeoutMinutes <= 0 ||
4944
+ startedAtMs === undefined ||
4945
+ lastActivityAtMs === undefined ||
4946
+ inactivityDeadlineAtMs === undefined ||
4947
+ hardDeadlineAtMs === undefined) {
4948
+ return { eligible: false, reason: "terminal_bridge_deadline_metadata_missing" };
4949
+ }
4950
+ return {
4951
+ eligible: true,
4952
+ nativeTakeover,
4953
+ terminalMessageId,
4954
+ terminalControl,
4955
+ runtime,
4956
+ inactivityTimeoutMinutes,
4957
+ hardTimeoutMinutes,
4958
+ inactivityDeadlineAtMs,
4959
+ hardDeadlineAtMs
3816
4960
  };
3817
- Reflect.deleteProperty(renewed, "stalled_at");
3818
- Reflect.deleteProperty(renewed, "stalled_reason");
3819
- Reflect.deleteProperty(renewed, "stalled_notification_sent_at");
3820
- Reflect.deleteProperty(renewed, "stalled_notification_message_id");
3821
- saveState(statePath, renewed);
3822
- appendEvent(logPath, {
3823
- ts: now,
3824
- conversation_id: conversation.conversation_id,
3825
- event: "terminal_bridge_renewed",
3826
- previous_status: conversation.status,
3827
- terminal_control: terminalControl,
3828
- agent_timeout_minutes: inactivityTimeoutMinutes,
3829
- agent_hard_timeout_minutes: hardTimeoutMinutes,
3830
- last_activity_at: now
3831
- });
3832
- runtimeLog("info", "terminal_bridge_renewed", {
3833
- conversation_id: conversation.conversation_id,
3834
- terminal_target: terminalControl.target,
3835
- agent_timeout_minutes: inactivityTimeoutMinutes,
3836
- agent_hard_timeout_minutes: hardTimeoutMinutes
3837
- });
3838
- const monitor = startTerminalBridgeMonitorForConversation({
3839
- conversation: renewed,
3840
- statePath,
3841
- logPath,
3842
- options: {
3843
- ...options,
3844
- agentTimeoutMinutes: inactivityTimeoutMinutes,
3845
- agentHardTimeoutMinutes: hardTimeoutMinutes
4961
+ }
4962
+ function latestTerminalBridgeMonitorLaunchPid(logPath) {
4963
+ let events;
4964
+ try {
4965
+ events = readExistingEvents(logPath);
4966
+ }
4967
+ catch {
4968
+ return undefined;
4969
+ }
4970
+ const launch = [...events].reverse().find((event) => event.event === "terminal_bridge_monitor_launch");
4971
+ const pid = Number(launch?.pid);
4972
+ return Number.isSafeInteger(pid) && pid > 1 ? pid : undefined;
4973
+ }
4974
+ function prepareTerminalBridgeMonitorReconciliation({ statePath, expectedMessageId }) {
4975
+ const releaseStateLock = acquireFileLock(`${statePath}.lock`);
4976
+ try {
4977
+ const conversation = loadState(statePath);
4978
+ const eligibility = terminalBridgeReconciliationEligibility(conversation);
4979
+ if (!eligibility.eligible) {
4980
+ return {
4981
+ prepared: false,
4982
+ alreadyRunning: false,
4983
+ reason: eligibility.reason
4984
+ };
3846
4985
  }
3847
- });
3848
- if (monitor) {
3849
- appendEvent(logPath, {
3850
- ts: new Date().toISOString(),
3851
- conversation_id: conversation.conversation_id,
3852
- event: "terminal_bridge_monitor_launch",
3853
- pid: monitor.pid ?? null,
3854
- terminal_control: terminalControl,
3855
- reason: "renewal",
3856
- agent_timeout_minutes: inactivityTimeoutMinutes,
3857
- agent_hard_timeout_minutes: hardTimeoutMinutes
3858
- });
4986
+ if (eligibility.terminalMessageId !== expectedMessageId) {
4987
+ return {
4988
+ prepared: false,
4989
+ alreadyRunning: false,
4990
+ reason: "terminal_bridge_task_replaced"
4991
+ };
4992
+ }
4993
+ const activeOwner = activeTerminalBridgeMonitorOwner(statePath, eligibility.terminalMessageId);
4994
+ if (activeOwner) {
4995
+ return {
4996
+ prepared: false,
4997
+ alreadyRunning: true,
4998
+ reason: "monitor_lock_owner_alive",
4999
+ ownerPid: activeOwner.ownerPid
5000
+ };
5001
+ }
5002
+ let renewedLease;
5003
+ const usesClaudeHookLease = executorForConversation(conversation).kind === "claude" &&
5004
+ eligibility.nativeTakeover.claude_hook_mode === "enabled";
5005
+ if (usesClaudeHookLease) {
5006
+ const leaseDeadlineAtMs = Math.min(eligibility.inactivityDeadlineAtMs, eligibility.hardDeadlineAtMs);
5007
+ if (leaseDeadlineAtMs <= Date.now()) {
5008
+ return {
5009
+ prepared: false,
5010
+ alreadyRunning: false,
5011
+ reason: "claude_hook_lease_deadline_elapsed"
5012
+ };
5013
+ }
5014
+ renewedLease = renewClaudeHookLease(conversation, new Date(leaseDeadlineAtMs).toISOString());
5015
+ if (!renewedLease) {
5016
+ return {
5017
+ prepared: false,
5018
+ alreadyRunning: false,
5019
+ reason: "claude_hook_lease_refresh_failed"
5020
+ };
5021
+ }
5022
+ }
5023
+ const nextNativeTakeover = {
5024
+ ...eligibility.nativeTakeover,
5025
+ terminal_bridge_monitor_lock_version: TERMINAL_BRIDGE_MONITOR_LOCK_VERSION,
5026
+ ...(renewedLease ? { claude_hook_lease_id: renewedLease.id } : {})
5027
+ };
5028
+ const needsSave = eligibility.nativeTakeover.terminal_bridge_monitor_lock_version !==
5029
+ TERMINAL_BRIDGE_MONITOR_LOCK_VERSION ||
5030
+ (renewedLease !== undefined &&
5031
+ eligibility.nativeTakeover.claude_hook_lease_id !== renewedLease.id);
5032
+ const preparedConversation = needsSave
5033
+ ? {
5034
+ ...conversation,
5035
+ native_session_takeover: nextNativeTakeover,
5036
+ updated_at: new Date().toISOString()
5037
+ }
5038
+ : conversation;
5039
+ if (needsSave) {
5040
+ saveState(statePath, preparedConversation);
5041
+ }
5042
+ return {
5043
+ prepared: true,
5044
+ conversation: preparedConversation,
5045
+ terminalControl: eligibility.terminalControl,
5046
+ inactivityTimeoutMinutes: eligibility.inactivityTimeoutMinutes,
5047
+ hardTimeoutMinutes: eligibility.hardTimeoutMinutes
5048
+ };
5049
+ }
5050
+ finally {
5051
+ releaseStateLock();
3859
5052
  }
3860
- printJson({
3861
- conversation: renewed,
3862
- renewed: true,
3863
- terminal_control: terminalControl,
3864
- agent_timeout_minutes: inactivityTimeoutMinutes,
3865
- agent_hard_timeout_minutes: hardTimeoutMinutes,
3866
- monitor_pid: monitor?.pid ?? null
3867
- });
3868
5053
  }
3869
5054
  function positiveMinutes(value, optionName) {
3870
5055
  const parsed = Number(value);
@@ -3886,7 +5071,12 @@ async function runCancel(options) {
3886
5071
  });
3887
5072
  return;
3888
5073
  }
3889
- const { conversation, statePath, logPath } = loadConversationFromOptions(options);
5074
+ const loaded = loadConversationFromOptions(options);
5075
+ const { statePath, logPath } = loaded;
5076
+ const conversation = await migrateLegacyTerminalAgentIdentity({
5077
+ ...loaded,
5078
+ options
5079
+ });
3890
5080
  if (["closed", "cancelled"].includes(conversation.status)) {
3891
5081
  throw new Error(`cannot cancel ${conversation.conversation_id}; conversation is ${conversation.status}`);
3892
5082
  }
@@ -3964,91 +5154,123 @@ async function runCancel(options) {
3964
5154
  });
3965
5155
  }
3966
5156
  async function runTerminalConversationCancel({ options, conversationId, agent, terminalControl, pid }) {
3967
- const cancellation = await createTerminalAgentBridge(options).cancel(agent, terminalControl, {
3968
- runtime: {
3969
- pid,
3970
- cwd: terminalControl.currentPath,
3971
- terminalTarget: terminalControl.target
3972
- },
3973
- scrollbackLines: Number(options.scrollbackLines ?? 120)
3974
- });
3975
- runtimeLog("info", "terminal_cancel_requested", {
3976
- conversation_id: conversationId,
3977
- agent,
3978
- terminal_target: terminalControl.target,
3979
- key: cancellation.key,
3980
- keys: cancellation.keys,
3981
- denied_approval: cancellation.deniedApproval,
3982
- request_id: cancellation.requestId,
3983
- cancel_requested: cancellation.cancelRequested,
3984
- reason: cancellation.reason
3985
- });
3986
- printJson({
3987
- conversation_id: conversationId,
3988
- source: "terminal_control",
3989
- cancel_requested: cancellation.cancelRequested,
3990
- reason: cancellation.reason,
3991
- terminal_control: terminalControl,
3992
- key: cancellation.key,
3993
- keys: cancellation.keys,
3994
- denied_approval: cancellation.deniedApproval,
3995
- request_id: cancellation.requestId
3996
- });
3997
- }
3998
- async function runTerminalControlCancel({ options, conversation, statePath, logPath, agent, terminalControl }) {
3999
- const cancellation = await createTerminalAgentBridge(options).cancel(agent, terminalControl, {
4000
- runtime: terminalRuntimeIdentityForConversation(conversation, terminalControl),
4001
- scrollbackLines: Number(options.scrollbackLines ?? 120)
4002
- });
4003
- if (!cancellation.cancelRequested) {
5157
+ const releaseTerminalLock = acquireFileLock(terminalBridgeSendLockPath(storeDirFromOptions(options), terminalControl), { timeoutMs: 30000 });
5158
+ try {
5159
+ const cancellation = await createTerminalAgentBridge(options).cancel(agent, terminalControl, {
5160
+ runtime: {
5161
+ pid,
5162
+ cwd: terminalControl.currentPath,
5163
+ terminalTarget: terminalControl.target
5164
+ },
5165
+ scrollbackLines: Number(options.scrollbackLines ?? 120)
5166
+ });
5167
+ runtimeLog("info", "terminal_cancel_requested", {
5168
+ conversation_id: conversationId,
5169
+ agent,
5170
+ terminal_target: terminalControl.target,
5171
+ key: cancellation.key,
5172
+ keys: cancellation.keys,
5173
+ denied_approval: cancellation.deniedApproval,
5174
+ request_id: cancellation.requestId,
5175
+ cancel_requested: cancellation.cancelRequested,
5176
+ reason: cancellation.reason
5177
+ });
4004
5178
  printJson({
4005
- conversation,
4006
- cancel_requested: false,
5179
+ conversation_id: conversationId,
5180
+ source: "terminal_control",
5181
+ cancel_requested: cancellation.cancelRequested,
4007
5182
  reason: cancellation.reason,
4008
5183
  terminal_control: terminalControl,
4009
- budget: budgetAction(conversation)
5184
+ key: cancellation.key,
5185
+ keys: cancellation.keys,
5186
+ denied_approval: cancellation.deniedApproval,
5187
+ request_id: cancellation.requestId
4010
5188
  });
4011
- return;
4012
5189
  }
4013
- const now = new Date().toISOString();
4014
- appendEvent(logPath, {
4015
- ts: now,
4016
- conversation_id: conversation.conversation_id,
4017
- event: "terminal_cancel_requested",
4018
- terminal_control: terminalControl,
4019
- key: cancellation.key,
4020
- keys: cancellation.keys,
4021
- denied_approval: cancellation.deniedApproval,
4022
- request_id: cancellation.requestId
4023
- });
4024
- runtimeLog("info", "terminal_cancel_requested", {
4025
- conversation_id: conversation.conversation_id,
4026
- agent,
4027
- terminal_target: terminalControl.target,
4028
- key: cancellation.key,
4029
- keys: cancellation.keys,
4030
- denied_approval: cancellation.deniedApproval,
4031
- request_id: cancellation.requestId
4032
- });
4033
- const nextConversation = {
4034
- ...conversation,
4035
- status: "cancelled",
4036
- cancelled_at: now,
4037
- terminal_cancel_requested_at: now,
4038
- updated_at: now
4039
- };
4040
- saveState(statePath, nextConversation);
4041
- releaseClaudeHookLease(nextConversation);
4042
- printJson({
4043
- conversation: nextConversation,
4044
- cancel_requested: true,
4045
- terminal_control: terminalControl,
4046
- key: cancellation.key,
4047
- keys: cancellation.keys,
4048
- denied_approval: cancellation.deniedApproval,
4049
- request_id: cancellation.requestId,
4050
- budget: budgetAction(nextConversation)
4051
- });
5190
+ finally {
5191
+ releaseTerminalLock();
5192
+ }
5193
+ }
5194
+ async function runTerminalControlCancel({ options, conversation, statePath, logPath, agent, terminalControl }) {
5195
+ const releaseTerminalLock = acquireFileLock(terminalBridgeSendLockPath(storeDirFromOptions(options), terminalControl), { timeoutMs: 30000 });
5196
+ let releaseStateLock;
5197
+ try {
5198
+ releaseStateLock = acquireFileLock(`${statePath}.lock`);
5199
+ const currentConversation = loadState(statePath);
5200
+ if (["closed", "cancelled"].includes(currentConversation.status)) {
5201
+ throw new Error(`cannot cancel ${currentConversation.conversation_id}; conversation is ${currentConversation.status}`);
5202
+ }
5203
+ const currentTakeover = isRecord(currentConversation.native_session_takeover)
5204
+ ? currentConversation.native_session_takeover
5205
+ : undefined;
5206
+ const currentControl = terminalControlFromTakeover(currentTakeover);
5207
+ if (!currentControl ||
5208
+ currentControl.target !== terminalControl.target ||
5209
+ currentControl.socketPath !== terminalControl.socketPath) {
5210
+ throw new Error("terminal control changed while waiting to cancel; refresh status and retry");
5211
+ }
5212
+ const cancellation = await createTerminalAgentBridge(options).cancel(agent, currentControl, {
5213
+ runtime: terminalRuntimeIdentityForConversation(currentConversation, currentControl),
5214
+ scrollbackLines: Number(options.scrollbackLines ?? 120)
5215
+ });
5216
+ if (!cancellation.cancelRequested) {
5217
+ printJson({
5218
+ conversation: currentConversation,
5219
+ cancel_requested: false,
5220
+ reason: cancellation.reason,
5221
+ terminal_control: currentControl,
5222
+ budget: budgetAction(currentConversation)
5223
+ });
5224
+ return;
5225
+ }
5226
+ const now = new Date().toISOString();
5227
+ appendEvent(logPath, {
5228
+ ts: now,
5229
+ conversation_id: currentConversation.conversation_id,
5230
+ event: "terminal_cancel_requested",
5231
+ terminal_control: currentControl,
5232
+ key: cancellation.key,
5233
+ keys: cancellation.keys,
5234
+ denied_approval: cancellation.deniedApproval,
5235
+ request_id: cancellation.requestId
5236
+ });
5237
+ runtimeLog("info", "terminal_cancel_requested", {
5238
+ conversation_id: currentConversation.conversation_id,
5239
+ agent,
5240
+ terminal_target: currentControl.target,
5241
+ key: cancellation.key,
5242
+ keys: cancellation.keys,
5243
+ denied_approval: cancellation.deniedApproval,
5244
+ request_id: cancellation.requestId
5245
+ });
5246
+ const nextConversation = {
5247
+ ...currentConversation,
5248
+ status: "cancelled",
5249
+ cancelled_at: now,
5250
+ terminal_cancel_requested_at: now,
5251
+ updated_at: now
5252
+ };
5253
+ saveState(statePath, nextConversation);
5254
+ releaseClaudeHookLease(nextConversation);
5255
+ printJson({
5256
+ conversation: nextConversation,
5257
+ cancel_requested: true,
5258
+ terminal_control: currentControl,
5259
+ key: cancellation.key,
5260
+ keys: cancellation.keys,
5261
+ denied_approval: cancellation.deniedApproval,
5262
+ request_id: cancellation.requestId,
5263
+ budget: budgetAction(nextConversation)
5264
+ });
5265
+ }
5266
+ finally {
5267
+ try {
5268
+ releaseStateLock?.();
5269
+ }
5270
+ finally {
5271
+ releaseTerminalLock();
5272
+ }
5273
+ }
4052
5274
  }
4053
5275
  function runRecover(options) {
4054
5276
  runRecoveryDecision({ ...options, mode: "recover" });
@@ -4117,7 +5339,7 @@ function runRecoveryDecision(options) {
4117
5339
  const acpxArgs = buildAcpxPromptArgs({ executor, payload, model: executorModel });
4118
5340
  if (options.background) {
4119
5341
  const outputPath = path.join(path.dirname(logPath), `${executor.kind}-${options.mode}-output.log`);
4120
- const outputFd = fs.openSync(outputPath, "a");
5342
+ const outputFd = openPrivateAppendFile(outputPath);
4121
5343
  const child = spawn(acpxPath, acpxArgs, {
4122
5344
  detached: true,
4123
5345
  stdio: ["ignore", outputFd, outputFd],
@@ -4217,36 +5439,57 @@ function formatProtocolHistoryForRecovery(events) {
4217
5439
  });
4218
5440
  return lines.length ? lines.join("\n") : "- No prior protocol messages were recorded.";
4219
5441
  }
4220
- function runClose(options) {
4221
- const { conversation, statePath, logPath } = loadConversationFromOptions(options);
4222
- const now = new Date().toISOString();
4223
- const closed = {
4224
- ...conversation,
4225
- status: "closed",
4226
- closed_at: now,
4227
- close_reason: options.reason ?? "closed by request",
4228
- updated_at: now
4229
- };
4230
- saveState(statePath, closed);
4231
- releaseClaudeHookLease(closed);
4232
- appendEvent(logPath, {
4233
- ts: now,
4234
- conversation_id: conversation.conversation_id,
4235
- event: "conversation_closed",
4236
- status: "closed",
4237
- reason: closed.close_reason
4238
- });
4239
- runtimeLog("info", "conversation_closed", {
4240
- conversation_id: conversation.conversation_id,
4241
- status: "closed",
4242
- reason: closed.close_reason,
4243
- state_path: statePath,
4244
- event_log_path: logPath
4245
- });
4246
- printJson({
4247
- conversation: closed,
4248
- closed: true
4249
- });
5442
+ async function runClose(options) {
5443
+ const loaded = loadConversationFromOptions(options);
5444
+ const { statePath, logPath } = loaded;
5445
+ const nativeTakeover = isRecord(loaded.conversation.native_session_takeover)
5446
+ ? loaded.conversation.native_session_takeover
5447
+ : undefined;
5448
+ const terminalControl = terminalControlFromTakeover(nativeTakeover);
5449
+ const releaseTerminalLock = terminalControl
5450
+ ? acquireFileLock(terminalBridgeSendLockPath(storeDirFromOptions(options), terminalControl), { timeoutMs: 30000 })
5451
+ : () => { };
5452
+ let releaseStateLock;
5453
+ try {
5454
+ releaseStateLock = acquireFileLock(`${statePath}.lock`);
5455
+ const conversation = loadState(statePath);
5456
+ const now = new Date().toISOString();
5457
+ const closed = {
5458
+ ...conversation,
5459
+ status: "closed",
5460
+ closed_at: now,
5461
+ close_reason: options.reason ?? "closed by request",
5462
+ updated_at: now
5463
+ };
5464
+ saveState(statePath, closed);
5465
+ releaseClaudeHookLease(closed);
5466
+ appendEvent(logPath, {
5467
+ ts: now,
5468
+ conversation_id: conversation.conversation_id,
5469
+ event: "conversation_closed",
5470
+ status: "closed",
5471
+ reason: closed.close_reason
5472
+ });
5473
+ runtimeLog("info", "conversation_closed", {
5474
+ conversation_id: conversation.conversation_id,
5475
+ status: "closed",
5476
+ reason: closed.close_reason,
5477
+ state_path: statePath,
5478
+ event_log_path: logPath
5479
+ });
5480
+ printJson({
5481
+ conversation: closed,
5482
+ closed: true
5483
+ });
5484
+ }
5485
+ finally {
5486
+ try {
5487
+ releaseStateLock?.();
5488
+ }
5489
+ finally {
5490
+ releaseTerminalLock();
5491
+ }
5492
+ }
4250
5493
  }
4251
5494
  async function runMonitor(options) {
4252
5495
  if (options.callbackRetry) {
@@ -4383,45 +5626,66 @@ async function runMonitor(options) {
4383
5626
  sleepSync(pollIntervalMs);
4384
5627
  }
4385
5628
  }
4386
- function startCallbackRetryMonitor({ statePath }) {
5629
+ function startCallbackRetryMonitor({ statePath, delayMs = CALLBACK_RETRY_DELAYS_MS[0] }) {
5630
+ const normalizedDelayMs = Math.max(0, Number.isFinite(Number(delayMs)) ? Number(delayMs) : CALLBACK_RETRY_DELAYS_MS[0]);
4387
5631
  const child = spawn(process.execPath, [
4388
5632
  new URL(import.meta.url).pathname,
4389
5633
  "monitor",
4390
5634
  "--callback-retry",
4391
5635
  "--state",
4392
- statePath
5636
+ statePath,
5637
+ "--callback-retry-delay-ms",
5638
+ String(normalizedDelayMs)
4393
5639
  ], {
4394
5640
  detached: true,
4395
5641
  stdio: "ignore",
4396
5642
  cwd: process.cwd(),
4397
- env: process.env
5643
+ env: environmentWithoutGatewayTokens()
4398
5644
  });
4399
5645
  child.unref();
4400
5646
  return child;
4401
5647
  }
4402
5648
  function runCallbackRetryMonitor(options) {
4403
5649
  const statePath = expandHome(required(options.state, "--state is required"));
5650
+ const initialDelayMs = Math.max(0, Number.isFinite(Number(options.callbackRetryDelayMs))
5651
+ ? Number(options.callbackRetryDelayMs)
5652
+ : CALLBACK_RETRY_DELAYS_MS[0]);
5653
+ sleepSync(initialDelayMs);
4404
5654
  while (true) {
4405
5655
  const conversation = loadState(statePath);
4406
5656
  const callbackDelivery = isRecord(conversation.callback_delivery)
4407
5657
  ? conversation.callback_delivery
4408
5658
  : undefined;
4409
5659
  const attempts = Number(callbackDelivery?.attempts ?? 0);
4410
- if (conversation.status !== "callback_failed" || !isRecord(callbackDelivery?.message)) {
5660
+ if (!["callback_pending", "callback_failed"].includes(conversation.status) ||
5661
+ !["pending", "failed"].includes(String(callbackDelivery?.status ?? "")) ||
5662
+ !isRecord(callbackDelivery?.message)) {
4411
5663
  return;
4412
5664
  }
4413
5665
  if (attempts > CALLBACK_RETRY_DELAYS_MS.length) {
4414
5666
  return;
4415
5667
  }
4416
- const delayMs = CALLBACK_RETRY_DELAYS_MS[Math.max(0, attempts - 1)];
4417
- sleepSync(delayMs);
4418
- const releaseLock = acquireFileLock(`${statePath}.lock`);
5668
+ let releaseLock;
5669
+ try {
5670
+ releaseLock = acquireFileLock(`${statePath}.lock`);
5671
+ }
5672
+ catch (error) {
5673
+ if (isRecord(error) && error.code === "LOCK_TIMEOUT") {
5674
+ sleepSync(1000);
5675
+ continue;
5676
+ }
5677
+ throw error;
5678
+ }
4419
5679
  try {
4420
5680
  const current = loadState(statePath);
4421
5681
  const currentDelivery = isRecord(current.callback_delivery)
4422
5682
  ? current.callback_delivery
4423
5683
  : undefined;
4424
- if (current.status !== "callback_failed" || !isRecord(currentDelivery?.message)) {
5684
+ const currentAttempts = Number(currentDelivery?.attempts ?? 0);
5685
+ if (!["callback_pending", "callback_failed"].includes(current.status) ||
5686
+ !["pending", "failed"].includes(String(currentDelivery?.status ?? "")) ||
5687
+ !isRecord(currentDelivery?.message) ||
5688
+ currentAttempts > CALLBACK_RETRY_DELAYS_MS.length) {
4425
5689
  return;
4426
5690
  }
4427
5691
  try {
@@ -4447,13 +5711,62 @@ function runCallbackRetryMonitor(options) {
4447
5711
  finally {
4448
5712
  releaseLock();
4449
5713
  }
5714
+ const latest = loadState(statePath);
5715
+ const latestDelivery = isRecord(latest.callback_delivery)
5716
+ ? latest.callback_delivery
5717
+ : undefined;
5718
+ const latestAttempts = Number(latestDelivery?.attempts ?? 0);
5719
+ if (!["callback_pending", "callback_failed"].includes(latest.status) ||
5720
+ !["pending", "failed"].includes(String(latestDelivery?.status ?? "")) ||
5721
+ !isRecord(latestDelivery?.message) ||
5722
+ latestAttempts > CALLBACK_RETRY_DELAYS_MS.length) {
5723
+ return;
5724
+ }
5725
+ const delayMs = CALLBACK_RETRY_DELAYS_MS[Math.max(0, latestAttempts - 1)];
5726
+ sleepSync(delayMs);
4450
5727
  }
4451
5728
  }
4452
5729
  async function runTerminalBridgeMonitor(options) {
5730
+ const statePath = expandHome(required(options.state, "--state is required"));
5731
+ const conversation = loadState(statePath);
5732
+ const nativeTakeover = isRecord(conversation.native_session_takeover)
5733
+ ? conversation.native_session_takeover
5734
+ : undefined;
5735
+ const terminalMessageId = stringValue(nativeTakeover?.terminal_bridge_message_id) ?? "missing-message-id";
5736
+ const monitorLock = tryAcquireTerminalBridgeMonitorLock(statePath, terminalMessageId);
5737
+ if (!monitorLock.acquired) {
5738
+ runtimeLog("info", "terminal_bridge_monitor_already_running", {
5739
+ conversation_id: conversation.conversation_id,
5740
+ terminal_bridge_message_id: terminalMessageId,
5741
+ monitor_owner_pid: monitorLock.ownerPid
5742
+ });
5743
+ printJson({
5744
+ conversation,
5745
+ monitored: false,
5746
+ terminal_bridge: true,
5747
+ already_running: true,
5748
+ reason: "terminal_bridge_monitor_already_running",
5749
+ monitor_owner_pid: monitorLock.ownerPid ?? null
5750
+ });
5751
+ return;
5752
+ }
5753
+ try {
5754
+ await runTerminalBridgeMonitorWithLock(options);
5755
+ }
5756
+ finally {
5757
+ monitorLock.release();
5758
+ }
5759
+ }
5760
+ async function runTerminalBridgeMonitorWithLock(options) {
4453
5761
  const statePath = expandHome(required(options.state, "--state is required"));
4454
5762
  const logPath = expandHome(options.log ?? logPathForStatePath(statePath));
4455
5763
  const pollIntervalMs = Math.max(50, Number(options.pollIntervalMs ?? DEFAULT_MONITOR_POLL_INTERVAL_MS));
4456
- let conversation = loadState(statePath);
5764
+ let conversation = await migrateLegacyTerminalAgentIdentity({
5765
+ conversation: loadState(statePath),
5766
+ statePath,
5767
+ logPath,
5768
+ options
5769
+ });
4457
5770
  const initialNativeTakeover = isRecord(conversation.native_session_takeover)
4458
5771
  ? conversation.native_session_takeover
4459
5772
  : undefined;
@@ -4587,6 +5900,25 @@ async function runTerminalBridgeMonitor(options) {
4587
5900
  });
4588
5901
  const terminalStatus = poll.status;
4589
5902
  const approval = terminalStatus.approval_state;
5903
+ const currentScreenFingerprint = stringValue(terminalStatus?.screen?.digest) ??
5904
+ terminalBridgeScreenFingerprint(terminalStatus?.screen?.excerpt);
5905
+ const currentScreenChangedSinceSend = preSendScreenFingerprint !== undefined &&
5906
+ currentScreenFingerprint !== undefined &&
5907
+ currentScreenFingerprint !== preSendScreenFingerprint;
5908
+ if (executor.kind === "claude" &&
5909
+ isRecord(approval) &&
5910
+ approval.approvable === true &&
5911
+ approval.decision_mode === "keys" &&
5912
+ !currentScreenChangedSinceSend) {
5913
+ previousScreenFingerprint = currentScreenFingerprint;
5914
+ runtimeLog("warn", "claude_screen_approval_not_new", {
5915
+ conversation_id: conversation.conversation_id,
5916
+ terminal_target: terminalControl.target,
5917
+ reason: "permission screen is not proven to have changed since the managed send"
5918
+ });
5919
+ sleepSync(pollIntervalMs);
5920
+ continue;
5921
+ }
4590
5922
  if (isRecord(approval) && approval.blocked === true && approval.approvable !== true) {
4591
5923
  const approvalReason = stringValue(approval.reason) ??
4592
5924
  "Claude Code permission state cannot be safely resolved through AKK";
@@ -4608,8 +5940,67 @@ async function runTerminalBridgeMonitor(options) {
4608
5940
  logPath,
4609
5941
  terminalControl,
4610
5942
  terminalStatus,
4611
- fingerprint
5943
+ fingerprint,
5944
+ expectedConversation: {
5945
+ conversationId: conversation.conversation_id,
5946
+ status: conversation.status,
5947
+ updatedAt: conversation.updated_at,
5948
+ messageId: currentMessageId
5949
+ },
5950
+ onRecorded: (notificationConversation) => {
5951
+ const callbackMessage = createMessage({
5952
+ conversation: notificationConversation,
5953
+ from: executor.actor,
5954
+ to: "openclaw",
5955
+ type: "blocked",
5956
+ requiresResponse: true,
5957
+ body: [
5958
+ `${executor.display_name} is waiting at a permission state that AKK cannot safely approve.`,
5959
+ approvalReason,
5960
+ "",
5961
+ `Conversation: ${notificationConversation.conversation_id}`,
5962
+ `Terminal: ${terminalControl.target}`,
5963
+ "Review and resolve this dialog in the terminal manually. AKK intentionally sends no key when the request identity cannot be revalidated."
5964
+ ].join("\n"),
5965
+ metadata: {
5966
+ source: "terminal_bridge",
5967
+ reason: "approval_not_approvable",
5968
+ terminal_control: terminalControl,
5969
+ terminal_status: terminalStatus,
5970
+ approval_fingerprint: fingerprint
5971
+ }
5972
+ });
5973
+ if (notificationConversation.gateway_method) {
5974
+ runLockedCallback({
5975
+ ...options,
5976
+ statePath,
5977
+ log: logPath,
5978
+ messageJson: JSON.stringify(callbackMessage),
5979
+ gatewayMethod: notificationConversation.gateway_method,
5980
+ gatewaySession: notificationConversation.gateway_session,
5981
+ openclawSession: notificationConversation.openclaw_session,
5982
+ openclawBin: notificationConversation.openclaw_bin,
5983
+ gatewayUrl: stringValue(notificationConversation.gateway_token)
5984
+ ? notificationConversation.gateway_url
5985
+ : undefined,
5986
+ token: stringValue(notificationConversation.gateway_token)
5987
+ });
5988
+ return {
5989
+ callbackMessage,
5990
+ delivered: true
5991
+ };
5992
+ }
5993
+ return {
5994
+ callbackMessage,
5995
+ delivered: false
5996
+ };
5997
+ }
4612
5998
  });
5999
+ if (notification.stale) {
6000
+ previousScreenFingerprint = currentScreenFingerprint;
6001
+ sleepSync(pollIntervalMs);
6002
+ continue;
6003
+ }
4613
6004
  if (notification.duplicate) {
4614
6005
  printJson({
4615
6006
  conversation: notification.conversation,
@@ -4624,43 +6015,7 @@ async function runTerminalBridgeMonitor(options) {
4624
6015
  });
4625
6016
  return;
4626
6017
  }
4627
- const callbackMessage = createMessage({
4628
- conversation: notification.conversation,
4629
- from: executor.actor,
4630
- to: "openclaw",
4631
- type: "blocked",
4632
- requiresResponse: true,
4633
- body: [
4634
- `${executor.display_name} is waiting at a permission state that AKK cannot safely approve.`,
4635
- approvalReason,
4636
- "",
4637
- `Conversation: ${notification.conversation.conversation_id}`,
4638
- `Terminal: ${terminalControl.target}`,
4639
- "Review and resolve this dialog in the terminal manually. AKK intentionally sends no key when the request identity cannot be revalidated."
4640
- ].join("\n"),
4641
- metadata: {
4642
- source: "terminal_bridge",
4643
- reason: "approval_not_approvable",
4644
- terminal_control: terminalControl,
4645
- terminal_status: terminalStatus,
4646
- approval_fingerprint: fingerprint
4647
- }
4648
- });
4649
- if (notification.conversation.gateway_method) {
4650
- runLockedCallback({
4651
- ...options,
4652
- statePath,
4653
- log: logPath,
4654
- messageJson: JSON.stringify(callbackMessage),
4655
- gatewayMethod: notification.conversation.gateway_method,
4656
- gatewaySession: notification.conversation.gateway_session,
4657
- openclawSession: notification.conversation.openclaw_session,
4658
- openclawBin: notification.conversation.openclaw_bin,
4659
- gatewayUrl: stringValue(notification.conversation.gateway_token)
4660
- ? notification.conversation.gateway_url
4661
- : undefined,
4662
- token: stringValue(notification.conversation.gateway_token)
4663
- });
6018
+ if (notification.recorded?.delivered) {
4664
6019
  return;
4665
6020
  }
4666
6021
  printJson({
@@ -4670,7 +6025,7 @@ async function runTerminalBridgeMonitor(options) {
4670
6025
  awaiting_approval: true,
4671
6026
  approvable: false,
4672
6027
  delivered: false,
4673
- message: callbackMessage,
6028
+ message: notification.recorded?.callbackMessage,
4674
6029
  reason: "gateway_method_missing",
4675
6030
  terminal_control: terminalControl,
4676
6031
  terminal_status: terminalStatus
@@ -4693,8 +6048,74 @@ async function runTerminalBridgeMonitor(options) {
4693
6048
  logPath,
4694
6049
  terminalControl,
4695
6050
  terminalStatus,
4696
- fingerprint
6051
+ fingerprint,
6052
+ expectedConversation: {
6053
+ conversationId: conversation.conversation_id,
6054
+ status: conversation.status,
6055
+ updatedAt: conversation.updated_at,
6056
+ messageId: currentMessageId
6057
+ },
6058
+ onRecorded: (notificationConversation) => {
6059
+ const callbackMessage = createMessage({
6060
+ conversation: notificationConversation,
6061
+ from: executor.actor,
6062
+ to: "openclaw",
6063
+ type: "question",
6064
+ requiresResponse: true,
6065
+ body: terminalBridgeApprovalInstructions({
6066
+ conversation: notificationConversation,
6067
+ terminalControl,
6068
+ terminalStatus
6069
+ }),
6070
+ metadata: {
6071
+ source: "terminal_bridge",
6072
+ reason: "approval_required",
6073
+ terminal_control: terminalControl,
6074
+ terminal_status: terminalStatus,
6075
+ approval_fingerprint: fingerprint,
6076
+ approval_candidate: terminalBridgeApprovalCandidate({
6077
+ executor,
6078
+ terminalControl,
6079
+ terminalStatus,
6080
+ fingerprint
6081
+ }),
6082
+ approve_command: `AKK approve ${notificationConversation.conversation_id} --expected-approval-fingerprint ${fingerprint}`,
6083
+ deny_command: `AKK cancel ${notificationConversation.conversation_id}`,
6084
+ approve_tool: "agent_knock_knock_approve",
6085
+ deny_tool: "agent_knock_knock_cancel"
6086
+ }
6087
+ });
6088
+ if (notificationConversation.gateway_method) {
6089
+ runLockedCallback({
6090
+ ...options,
6091
+ statePath,
6092
+ log: logPath,
6093
+ messageJson: JSON.stringify(callbackMessage),
6094
+ gatewayMethod: notificationConversation.gateway_method,
6095
+ gatewaySession: notificationConversation.gateway_session,
6096
+ openclawSession: notificationConversation.openclaw_session,
6097
+ openclawBin: notificationConversation.openclaw_bin,
6098
+ gatewayUrl: stringValue(notificationConversation.gateway_token)
6099
+ ? notificationConversation.gateway_url
6100
+ : undefined,
6101
+ token: stringValue(notificationConversation.gateway_token)
6102
+ });
6103
+ return {
6104
+ callbackMessage,
6105
+ delivered: true
6106
+ };
6107
+ }
6108
+ return {
6109
+ callbackMessage,
6110
+ delivered: false
6111
+ };
6112
+ }
4697
6113
  });
6114
+ if (notification.stale) {
6115
+ previousScreenFingerprint = currentScreenFingerprint;
6116
+ sleepSync(pollIntervalMs);
6117
+ continue;
6118
+ }
4698
6119
  if (notification.duplicate) {
4699
6120
  printJson({
4700
6121
  conversation: notification.conversation,
@@ -4707,48 +6128,7 @@ async function runTerminalBridgeMonitor(options) {
4707
6128
  });
4708
6129
  return;
4709
6130
  }
4710
- const callbackMessage = createMessage({
4711
- conversation: notification.conversation,
4712
- from: executor.actor,
4713
- to: "openclaw",
4714
- type: "question",
4715
- requiresResponse: true,
4716
- body: terminalBridgeApprovalInstructions({
4717
- conversation: notification.conversation,
4718
- terminalControl,
4719
- terminalStatus
4720
- }),
4721
- metadata: {
4722
- source: "terminal_bridge",
4723
- reason: "approval_required",
4724
- terminal_control: terminalControl,
4725
- terminal_status: terminalStatus,
4726
- approval_fingerprint: fingerprint,
4727
- approval_candidate: terminalBridgeApprovalCandidate({
4728
- executor,
4729
- terminalControl,
4730
- terminalStatus,
4731
- fingerprint
4732
- }),
4733
- approve_command: `AKK approve ${notification.conversation.conversation_id} --expected-approval-fingerprint ${fingerprint}`,
4734
- deny_command: `AKK cancel ${notification.conversation.conversation_id}`,
4735
- approve_tool: "agent_knock_knock_approve",
4736
- deny_tool: "agent_knock_knock_cancel"
4737
- }
4738
- });
4739
- if (notification.conversation.gateway_method) {
4740
- runLockedCallback({
4741
- ...options,
4742
- statePath,
4743
- log: logPath,
4744
- messageJson: JSON.stringify(callbackMessage),
4745
- gatewayMethod: notification.conversation.gateway_method,
4746
- gatewaySession: notification.conversation.gateway_session,
4747
- openclawSession: notification.conversation.openclaw_session,
4748
- openclawBin: notification.conversation.openclaw_bin,
4749
- gatewayUrl: stringValue(notification.conversation.gateway_token) ? notification.conversation.gateway_url : undefined,
4750
- token: stringValue(notification.conversation.gateway_token)
4751
- });
6131
+ if (notification.recorded?.delivered) {
4752
6132
  return;
4753
6133
  }
4754
6134
  printJson({
@@ -4757,14 +6137,14 @@ async function runTerminalBridgeMonitor(options) {
4757
6137
  terminal_bridge: true,
4758
6138
  awaiting_approval: true,
4759
6139
  delivered: false,
4760
- message: callbackMessage,
6140
+ message: notification.recorded?.callbackMessage,
4761
6141
  reason: "gateway_method_missing",
4762
6142
  terminal_control: terminalControl,
4763
6143
  terminal_status: terminalStatus
4764
6144
  });
4765
6145
  return;
4766
6146
  }
4767
- const screenFingerprint = terminalBridgeScreenFingerprint(terminalStatus?.screen?.excerpt);
6147
+ const screenFingerprint = currentScreenFingerprint;
4768
6148
  const screenChanged = previousScreenFingerprint !== undefined &&
4769
6149
  screenFingerprint !== undefined &&
4770
6150
  screenFingerprint !== previousScreenFingerprint;
@@ -5009,7 +6389,48 @@ function terminalBridgeScreenFingerprint(value) {
5009
6389
  ? createHash("sha256").update(value).digest("hex")
5010
6390
  : undefined;
5011
6391
  }
6392
+ function terminalBridgeMonitorLockPath(statePath, terminalMessageId) {
6393
+ const messageKey = createHash("sha256")
6394
+ .update(terminalMessageId)
6395
+ .digest("hex")
6396
+ .slice(0, 20);
6397
+ return `${statePath}.terminal-bridge-monitor-${messageKey}.lock`;
6398
+ }
6399
+ function fileLockOwnerPid(lockPath) {
6400
+ return readFileLockOwner(lockPath).pid;
6401
+ }
6402
+ function activeTerminalBridgeMonitorOwner(statePath, terminalMessageId) {
6403
+ const lockPath = terminalBridgeMonitorLockPath(statePath, terminalMessageId);
6404
+ if (!fs.existsSync(lockPath) || staleFileLock(lockPath)) {
6405
+ return undefined;
6406
+ }
6407
+ return {
6408
+ lockPath,
6409
+ ownerPid: fileLockOwnerPid(lockPath)
6410
+ };
6411
+ }
6412
+ function tryAcquireTerminalBridgeMonitorLock(statePath, terminalMessageId) {
6413
+ const lockPath = terminalBridgeMonitorLockPath(statePath, terminalMessageId);
6414
+ try {
6415
+ return {
6416
+ acquired: true,
6417
+ lockPath,
6418
+ release: acquireFileLock(lockPath, { timeoutMs: 0 })
6419
+ };
6420
+ }
6421
+ catch (error) {
6422
+ if (isRecord(error) && error.code === "LOCK_TIMEOUT") {
6423
+ return {
6424
+ acquired: false,
6425
+ lockPath,
6426
+ ownerPid: fileLockOwnerPid(lockPath)
6427
+ };
6428
+ }
6429
+ throw error;
6430
+ }
6431
+ }
5012
6432
  function terminalBridgeSendLockPath(storeDir, terminalControl) {
6433
+ ensureDir(storeDir);
5013
6434
  const terminalKey = createHash("sha256")
5014
6435
  .update(JSON.stringify({
5015
6436
  target: terminalControl.target,
@@ -5216,7 +6637,7 @@ function terminalBridgeApprovalCandidate({ executor, terminalControl, terminalSt
5216
6637
  command: stringValue(approval.command),
5217
6638
  tool_name: stringValue(approval.tool_name),
5218
6639
  request_detail: stringValue(approval.request_detail),
5219
- cwd: terminalControl.currentPath,
6640
+ cwd: stringValue(approval.cwd) ?? terminalControl.currentPath,
5220
6641
  fingerprint,
5221
6642
  terminal_target: terminalControl.target,
5222
6643
  decision_mode: stringValue(approval.decision_mode)
@@ -5318,6 +6739,11 @@ function resolveOptionalExecutable(command) {
5318
6739
  function packageRootDir() {
5319
6740
  return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
5320
6741
  }
6742
+ function printVersion() {
6743
+ const packageJsonPath = path.join(packageRootDir(), "package.json");
6744
+ const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
6745
+ process.stdout.write(`${packageJson.version}\n`);
6746
+ }
5321
6747
  function runCheckedCommand(command, args, { label }) {
5322
6748
  const result = spawnSync(command, args, {
5323
6749
  encoding: "utf8",
@@ -5356,18 +6782,18 @@ function buildCallbackCommand({ statePath, gatewayUrl, token, openclawSession, g
5356
6782
  "--state",
5357
6783
  shellQuote(statePath)
5358
6784
  ];
5359
- if (token) {
5360
- parts.push("--gateway-url", shellQuote(gatewayUrl), "--token", shellQuote(token), "--openclaw-session", shellQuote(openclawSession));
5361
- }
5362
- else if (!gatewayMethod) {
5363
- parts.push("--record-only");
5364
- }
5365
6785
  if (gatewayMethod) {
5366
6786
  parts.push("--gateway-method", shellQuote(gatewayMethod), "--gateway-session", shellQuote(gatewaySession ?? openclawSession));
5367
6787
  if (openclawBin) {
5368
6788
  parts.push("--openclaw-bin", shellQuote(openclawBin));
5369
6789
  }
5370
6790
  }
6791
+ else if (token) {
6792
+ parts.push("--gateway-url", shellQuote(gatewayUrl), "--token", shellQuote(token), "--openclaw-session", shellQuote(openclawSession));
6793
+ }
6794
+ else {
6795
+ parts.push("--record-only");
6796
+ }
5371
6797
  parts.push("--message-json", "'<structured-message-json>'");
5372
6798
  return parts.join(" ");
5373
6799
  }
@@ -5490,6 +6916,18 @@ function runLockedCallback(options) {
5490
6916
  CONVERSATION_STATUSES.has(storedFinalStatus)
5491
6917
  ? storedFinalStatus
5492
6918
  : nextConversation.status;
6919
+ const callbackRetryDelayMs = CALLBACK_RETRY_DELAYS_MS[Math.min(CALLBACK_RETRY_DELAYS_MS.length - 1, Math.max(0, deliveryAttempt - 1))];
6920
+ const callbackWatchdog = trackCallbackDelivery &&
6921
+ requiresDelivery &&
6922
+ options.recordOnly !== true &&
6923
+ !retryingPending &&
6924
+ options.disableCallbackRetry !== true &&
6925
+ deliveryAttempt <= CALLBACK_RETRY_DELAYS_MS.length
6926
+ ? startCallbackRetryMonitor({
6927
+ statePath: options.statePath,
6928
+ delayMs: callbackRetryDelayMs
6929
+ })
6930
+ : undefined;
5493
6931
  if (!retryingPending && !recoveringTerminalCompletion) {
5494
6932
  appendEvent(logPath, messageEvent(message));
5495
6933
  }
@@ -5510,7 +6948,13 @@ function runLockedCallback(options) {
5510
6948
  openclaw_bin: options.openclawBin ?? conversation.openclaw_bin,
5511
6949
  close_terminal_bridge_on_done: closeTerminalBridgeOnDone,
5512
6950
  track_delivery: true,
5513
- final_status: finalStatus
6951
+ final_status: finalStatus,
6952
+ ...(callbackWatchdog
6953
+ ? {
6954
+ retry_monitor_pid: callbackWatchdog.pid ?? null,
6955
+ next_attempt_at: new Date(Date.now() + callbackRetryDelayMs).toISOString()
6956
+ }
6957
+ : {})
5514
6958
  },
5515
6959
  updated_at: now
5516
6960
  };
@@ -5524,6 +6968,18 @@ function runLockedCallback(options) {
5524
6968
  message_id: message.id,
5525
6969
  attempt: deliveryAttempt
5526
6970
  });
6971
+ if (callbackWatchdog) {
6972
+ appendEvent(logPath, {
6973
+ ts: new Date().toISOString(),
6974
+ conversation_id: conversation.conversation_id,
6975
+ event: "callback_retry_monitor_launched",
6976
+ message_id: message.id,
6977
+ pid: callbackWatchdog.pid ?? null,
6978
+ next_attempt_at: isRecord(nextConversation.callback_delivery)
6979
+ ? nextConversation.callback_delivery.next_attempt_at
6980
+ : undefined
6981
+ });
6982
+ }
5527
6983
  }
5528
6984
  saveState(options.statePath, nextConversation);
5529
6985
  runtimeLog("info", "callback_received", {
@@ -5758,8 +7214,8 @@ function recordCallbackProcessDelivery({ logPath, conversation, message, event,
5758
7214
  round: message.round,
5759
7215
  ...detail,
5760
7216
  status: delivery.status,
5761
- stdout: delivery.stdout,
5762
- stderr: delivery.stderr
7217
+ stdout: redactString(delivery.stdout),
7218
+ stderr: redactString(delivery.stderr)
5763
7219
  });
5764
7220
  runtimeLog("info", runtimeEvent, {
5765
7221
  conversation_id: conversation.conversation_id,
@@ -5772,26 +7228,37 @@ function recordCallbackProcessDelivery({ logPath, conversation, message, event,
5772
7228
  }
5773
7229
  function acquireFileLock(lockPath, { timeoutMs = 5000, retryMs = 50 } = {}) {
5774
7230
  const started = Date.now();
7231
+ const token = randomUUID();
5775
7232
  while (true) {
7233
+ let fd;
5776
7234
  try {
5777
- const fd = fs.openSync(lockPath, "wx");
5778
- fs.writeFileSync(fd, `${process.pid}\n`, "utf8");
7235
+ fd = fs.openSync(lockPath, fs.constants.O_CREAT |
7236
+ fs.constants.O_EXCL |
7237
+ fs.constants.O_WRONLY |
7238
+ NO_FOLLOW_FLAG, PRIVATE_LOCK_FILE_MODE);
7239
+ fs.fchmodSync(fd, PRIVATE_LOCK_FILE_MODE);
7240
+ fs.writeFileSync(fd, `${JSON.stringify({
7241
+ pid: process.pid,
7242
+ token,
7243
+ created_at: new Date().toISOString()
7244
+ })}\n`, "utf8");
5779
7245
  fs.fsyncSync(fd);
5780
7246
  fs.closeSync(fd);
5781
- return () => {
5782
- fs.rmSync(lockPath, { force: true });
5783
- };
7247
+ fd = undefined;
7248
+ return () => releaseFileLock(lockPath, token);
5784
7249
  }
5785
7250
  catch (error) {
5786
- if (error.code !== "EEXIST") {
7251
+ if (fd !== undefined) {
7252
+ fs.closeSync(fd);
7253
+ }
7254
+ if (!isRecord(error) || error.code !== "EEXIST") {
5787
7255
  throw error;
5788
7256
  }
5789
- if (staleFileLock(lockPath)) {
5790
- fs.rmSync(lockPath, { force: true });
7257
+ if (reclaimStaleFileLock(lockPath)) {
5791
7258
  continue;
5792
7259
  }
5793
7260
  if (Date.now() - started >= timeoutMs) {
5794
- throw new Error(`timed out waiting for file lock: ${lockPath}`);
7261
+ throw Object.assign(new Error(`timed out waiting for file lock: ${lockPath}`), { code: "LOCK_TIMEOUT" });
5795
7262
  }
5796
7263
  sleepSync(retryMs);
5797
7264
  }
@@ -5799,12 +7266,14 @@ function acquireFileLock(lockPath, { timeoutMs = 5000, retryMs = 50 } = {}) {
5799
7266
  }
5800
7267
  function staleFileLock(lockPath) {
5801
7268
  try {
5802
- const stat = fs.statSync(lockPath);
5803
- const ownerText = fs.readFileSync(lockPath, "utf8").trim();
5804
- const ownerPid = Number(ownerText);
5805
- if (Number.isSafeInteger(ownerPid) && ownerPid > 1) {
7269
+ const stat = fs.lstatSync(lockPath);
7270
+ if (stat.isSymbolicLink() || !stat.isFile()) {
7271
+ throw new Error(`file lock must be a regular file, not a symlink: ${lockPath}`);
7272
+ }
7273
+ const owner = readFileLockOwner(lockPath);
7274
+ if (owner.pid !== undefined) {
5806
7275
  try {
5807
- process.kill(ownerPid, 0);
7276
+ process.kill(owner.pid, 0);
5808
7277
  return false;
5809
7278
  }
5810
7279
  catch (error) {
@@ -5817,6 +7286,91 @@ function staleFileLock(lockPath) {
5817
7286
  return isRecord(error) && error.code === "ENOENT";
5818
7287
  }
5819
7288
  }
7289
+ function reclaimStaleFileLock(lockPath) {
7290
+ const reclaimPath = `${lockPath}.reclaim`;
7291
+ let reclaimFd;
7292
+ try {
7293
+ reclaimFd = fs.openSync(reclaimPath, fs.constants.O_CREAT |
7294
+ fs.constants.O_EXCL |
7295
+ fs.constants.O_WRONLY |
7296
+ NO_FOLLOW_FLAG, PRIVATE_LOCK_FILE_MODE);
7297
+ fs.fchmodSync(reclaimFd, PRIVATE_LOCK_FILE_MODE);
7298
+ fs.writeFileSync(reclaimFd, `${process.pid}\n`, "utf8");
7299
+ fs.fsyncSync(reclaimFd);
7300
+ }
7301
+ catch (error) {
7302
+ if (reclaimFd !== undefined) {
7303
+ fs.closeSync(reclaimFd);
7304
+ }
7305
+ if (isRecord(error) && error.code === "EEXIST") {
7306
+ return false;
7307
+ }
7308
+ throw error;
7309
+ }
7310
+ try {
7311
+ if (!staleFileLock(lockPath)) {
7312
+ return false;
7313
+ }
7314
+ try {
7315
+ fs.unlinkSync(lockPath);
7316
+ return true;
7317
+ }
7318
+ catch (error) {
7319
+ return isRecord(error) && error.code === "ENOENT";
7320
+ }
7321
+ }
7322
+ finally {
7323
+ fs.closeSync(reclaimFd);
7324
+ try {
7325
+ fs.unlinkSync(reclaimPath);
7326
+ }
7327
+ catch (error) {
7328
+ if (!isRecord(error) || error.code !== "ENOENT") {
7329
+ throw error;
7330
+ }
7331
+ }
7332
+ }
7333
+ }
7334
+ function releaseFileLock(lockPath, token) {
7335
+ try {
7336
+ if (readFileLockOwner(lockPath).token !== token) {
7337
+ return;
7338
+ }
7339
+ fs.unlinkSync(lockPath);
7340
+ }
7341
+ catch (error) {
7342
+ if (!isRecord(error) || error.code !== "ENOENT") {
7343
+ throw error;
7344
+ }
7345
+ }
7346
+ }
7347
+ function readFileLockOwner(lockPath) {
7348
+ try {
7349
+ const text = fs.readFileSync(lockPath, "utf8").trim();
7350
+ try {
7351
+ const owner = JSON.parse(text);
7352
+ if (isRecord(owner)) {
7353
+ const pid = Number(owner.pid);
7354
+ return {
7355
+ pid: Number.isSafeInteger(pid) && pid > 1 ? pid : undefined,
7356
+ token: stringValue(owner.token)
7357
+ };
7358
+ }
7359
+ }
7360
+ catch {
7361
+ // Legacy locks contained only the owner PID.
7362
+ }
7363
+ const legacyPid = Number(text);
7364
+ return {
7365
+ pid: Number.isSafeInteger(legacyPid) && legacyPid > 1
7366
+ ? legacyPid
7367
+ : undefined
7368
+ };
7369
+ }
7370
+ catch {
7371
+ return {};
7372
+ }
7373
+ }
5820
7374
  function sleepSync(ms) {
5821
7375
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
5822
7376
  }
@@ -6594,8 +8148,8 @@ function deliverStalledNotification({ statePath, logPath, conversation, message,
6594
8148
  method: conversation.gateway_method,
6595
8149
  message_id: message.id,
6596
8150
  status: delivery.status,
6597
- stdout: delivery.stdout,
6598
- stderr: delivery.stderr
8151
+ stdout: redactString(delivery.stdout),
8152
+ stderr: redactString(delivery.stderr)
6599
8153
  });
6600
8154
  runtimeLog("info", `${eventPrefix}_gateway_method_delivery`, {
6601
8155
  conversation_id: conversation.conversation_id,
@@ -6626,8 +8180,8 @@ function deliverStalledNotification({ statePath, logPath, conversation, message,
6626
8180
  event: `${eventPrefix}_chat_send_delivery`,
6627
8181
  message_id: message.id,
6628
8182
  status: chatSendDelivery.status,
6629
- stdout: chatSendDelivery.stdout,
6630
- stderr: chatSendDelivery.stderr
8183
+ stdout: redactString(chatSendDelivery.stdout),
8184
+ stderr: redactString(chatSendDelivery.stderr)
6631
8185
  });
6632
8186
  runtimeLog("info", `${eventPrefix}_chat_send_delivery`, {
6633
8187
  conversation_id: conversation.conversation_id,
@@ -6653,54 +8207,84 @@ function cleanupIdleConversations(storeDir, options = {}, now = new Date()) {
6653
8207
  }
6654
8208
  const conversations = listConversations(storeDir);
6655
8209
  let closed = 0;
6656
- for (const conversation of conversations) {
6657
- if (conversation.status !== "idle" || !conversation.idle_since) {
8210
+ for (const listedConversation of conversations) {
8211
+ if (listedConversation.status !== "idle" || !listedConversation.idle_since) {
6658
8212
  continue;
6659
8213
  }
6660
- const idleSinceMs = Date.parse(conversation.idle_since);
6661
- if (!Number.isFinite(idleSinceMs)) {
8214
+ const listedIdleSinceMs = Date.parse(listedConversation.idle_since);
8215
+ if (!Number.isFinite(listedIdleSinceMs)) {
6662
8216
  continue;
6663
8217
  }
6664
- const terminalBridge = terminalBridgeEnabled(conversation) &&
6665
- isRecord(conversation.native_session_takeover) &&
6666
- typeof conversation.native_session_takeover.terminal_bridge_message_id === "string";
6667
- if (!terminalBridge && now.getTime() - idleSinceMs < timeoutMinutes * 60 * 1000) {
8218
+ const listedTerminalBridge = terminalBridgeEnabled(listedConversation) &&
8219
+ isRecord(listedConversation.native_session_takeover) &&
8220
+ typeof listedConversation.native_session_takeover.terminal_bridge_message_id === "string";
8221
+ if (!listedTerminalBridge && now.getTime() - listedIdleSinceMs < timeoutMinutes * 60 * 1000) {
6668
8222
  continue;
6669
8223
  }
6670
- const statePath = conversation.state_path ?? statePathForConversationId(conversation.conversation_id, storeDir);
6671
- const logPath = conversation.event_log_path ?? logPathForStatePath(statePath);
6672
- const closeReason = terminalBridge
6673
- ? "terminal bridge task completed"
6674
- : `idle timeout after ${timeoutMinutes} minutes`;
6675
- const closedConversation = {
6676
- ...conversation,
6677
- status: "closed",
6678
- closed_at: now.toISOString(),
6679
- close_reason: closeReason,
6680
- updated_at: now.toISOString()
6681
- };
6682
- delete closedConversation.idle_since;
6683
- saveState(statePath, closedConversation);
6684
- appendEvent(logPath, {
6685
- ts: now.toISOString(),
6686
- conversation_id: conversation.conversation_id,
6687
- event: "conversation_closed",
6688
- status: "closed",
6689
- reason: closedConversation.close_reason,
6690
- idle_timeout_minutes: timeoutMinutes,
6691
- terminal_bridge: terminalBridge
6692
- });
6693
- runtimeLog("info", "idle_conversation_closed", {
6694
- conversation_id: conversation.conversation_id,
6695
- agent: executorForConversation(conversation).kind,
6696
- executor_session: executorForConversation(conversation).session,
6697
- state_path: statePath,
6698
- event_log_path: logPath,
6699
- idle_since: conversation.idle_since,
6700
- idle_timeout_minutes: timeoutMinutes,
6701
- reason: closedConversation.close_reason
6702
- });
6703
- closed += 1;
8224
+ const statePath = listedConversation.state_path ??
8225
+ statePathForConversationId(listedConversation.conversation_id, storeDir);
8226
+ let releaseStateLock;
8227
+ try {
8228
+ releaseStateLock = acquireFileLock(`${statePath}.lock`);
8229
+ }
8230
+ catch (error) {
8231
+ if (isRecord(error) && error.code === "LOCK_TIMEOUT") {
8232
+ continue;
8233
+ }
8234
+ throw error;
8235
+ }
8236
+ try {
8237
+ const conversation = loadState(statePath);
8238
+ if (conversation.status !== "idle" || !conversation.idle_since) {
8239
+ continue;
8240
+ }
8241
+ const idleSinceMs = Date.parse(conversation.idle_since);
8242
+ if (!Number.isFinite(idleSinceMs)) {
8243
+ continue;
8244
+ }
8245
+ const terminalBridge = terminalBridgeEnabled(conversation) &&
8246
+ isRecord(conversation.native_session_takeover) &&
8247
+ typeof conversation.native_session_takeover.terminal_bridge_message_id === "string";
8248
+ if (!terminalBridge && now.getTime() - idleSinceMs < timeoutMinutes * 60 * 1000) {
8249
+ continue;
8250
+ }
8251
+ const logPath = conversation.event_log_path ?? logPathForStatePath(statePath);
8252
+ const closeReason = terminalBridge
8253
+ ? "terminal bridge task completed"
8254
+ : `idle timeout after ${timeoutMinutes} minutes`;
8255
+ const closedConversation = {
8256
+ ...conversation,
8257
+ status: "closed",
8258
+ closed_at: now.toISOString(),
8259
+ close_reason: closeReason,
8260
+ updated_at: now.toISOString()
8261
+ };
8262
+ delete closedConversation.idle_since;
8263
+ saveState(statePath, closedConversation);
8264
+ appendEvent(logPath, {
8265
+ ts: now.toISOString(),
8266
+ conversation_id: conversation.conversation_id,
8267
+ event: "conversation_closed",
8268
+ status: "closed",
8269
+ reason: closedConversation.close_reason,
8270
+ idle_timeout_minutes: timeoutMinutes,
8271
+ terminal_bridge: terminalBridge
8272
+ });
8273
+ runtimeLog("info", "idle_conversation_closed", {
8274
+ conversation_id: conversation.conversation_id,
8275
+ agent: executorForConversation(conversation).kind,
8276
+ executor_session: executorForConversation(conversation).session,
8277
+ state_path: statePath,
8278
+ event_log_path: logPath,
8279
+ idle_since: conversation.idle_since,
8280
+ idle_timeout_minutes: timeoutMinutes,
8281
+ reason: closedConversation.close_reason
8282
+ });
8283
+ closed += 1;
8284
+ }
8285
+ finally {
8286
+ releaseStateLock();
8287
+ }
6704
8288
  }
6705
8289
  return {
6706
8290
  checked: conversations.length,
@@ -6731,10 +8315,13 @@ function messageFingerprint(message) {
6731
8315
  });
6732
8316
  }
6733
8317
  function deliverToOpenClaw({ gatewayUrl, token, openclawSession, message }) {
6734
- const agent = `openclaw acp --url ${gatewayUrl} --token ${token} --session ${openclawSession}`;
8318
+ const agent = `openclaw acp --url ${gatewayUrl} --session ${openclawSession}`;
6735
8319
  const result = spawnSync("acpx", ["--agent", agent, JSON.stringify(message)], {
6736
8320
  encoding: "utf8",
6737
- maxBuffer: 1024 * 1024 * 10
8321
+ maxBuffer: 1024 * 1024 * 10,
8322
+ timeout: CALLBACK_DELIVERY_TIMEOUT_MS,
8323
+ killSignal: "SIGKILL",
8324
+ env: openClawGatewayEnvironment(token)
6738
8325
  });
6739
8326
  if (result.error) {
6740
8327
  return {
@@ -6759,7 +8346,7 @@ function deliverToGatewayMethod({ method, openclawBin, gatewayUrl, token, sessio
6759
8346
  sessionKey,
6760
8347
  statePath,
6761
8348
  logPath,
6762
- conversation,
8349
+ conversation: redactCliOutput(conversation),
6763
8350
  message
6764
8351
  }),
6765
8352
  "--json"
@@ -6767,12 +8354,12 @@ function deliverToGatewayMethod({ method, openclawBin, gatewayUrl, token, sessio
6767
8354
  if (gatewayUrl) {
6768
8355
  args.push("--url", gatewayUrl);
6769
8356
  }
6770
- if (token && token !== "<token>") {
6771
- args.push("--token", token);
6772
- }
6773
8357
  const result = spawnSync(openclawBin ?? "openclaw", args, {
6774
8358
  encoding: "utf8",
6775
- maxBuffer: 1024 * 1024 * 10
8359
+ maxBuffer: 1024 * 1024 * 10,
8360
+ timeout: CALLBACK_DELIVERY_TIMEOUT_MS,
8361
+ killSignal: "SIGKILL",
8362
+ env: openClawGatewayEnvironment(token)
6776
8363
  });
6777
8364
  if (result.error) {
6778
8365
  return {
@@ -6799,12 +8386,12 @@ function deliverToSessionSend({ openclawBin, gatewayUrl, token, params }) {
6799
8386
  if (gatewayUrl) {
6800
8387
  args.push("--url", gatewayUrl);
6801
8388
  }
6802
- if (token && token !== "<token>") {
6803
- args.push("--token", token);
6804
- }
6805
8389
  const result = spawnSync(openclawBin ?? "openclaw", args, {
6806
8390
  encoding: "utf8",
6807
- maxBuffer: 1024 * 1024 * 10
8391
+ maxBuffer: 1024 * 1024 * 10,
8392
+ timeout: CALLBACK_DELIVERY_TIMEOUT_MS,
8393
+ killSignal: "SIGKILL",
8394
+ env: openClawGatewayEnvironment(token)
6808
8395
  });
6809
8396
  if (result.error) {
6810
8397
  return {
@@ -6831,12 +8418,12 @@ function deliverToChatSend({ openclawBin, gatewayUrl, token, params }) {
6831
8418
  if (gatewayUrl) {
6832
8419
  args.push("--url", gatewayUrl);
6833
8420
  }
6834
- if (token && token !== "<token>") {
6835
- args.push("--token", token);
6836
- }
6837
8421
  const result = spawnSync(openclawBin ?? "openclaw", args, {
6838
8422
  encoding: "utf8",
6839
- maxBuffer: 1024 * 1024 * 10
8423
+ maxBuffer: 1024 * 1024 * 10,
8424
+ timeout: CALLBACK_DELIVERY_TIMEOUT_MS,
8425
+ killSignal: "SIGKILL",
8426
+ env: openClawGatewayEnvironment(token)
6840
8427
  });
6841
8428
  if (result.error) {
6842
8429
  return {
@@ -6851,6 +8438,15 @@ function deliverToChatSend({ openclawBin, gatewayUrl, token, params }) {
6851
8438
  stderr: result.stderr ?? ""
6852
8439
  };
6853
8440
  }
8441
+ function openClawGatewayEnvironment(token) {
8442
+ if (!token || token === "<token>") {
8443
+ return process.env;
8444
+ }
8445
+ return {
8446
+ ...process.env,
8447
+ OPENCLAW_GATEWAY_TOKEN: token
8448
+ };
8449
+ }
6854
8450
  function captureJson(argv) {
6855
8451
  const result = spawnSync(process.execPath, [new URL(import.meta.url).pathname, ...argv], {
6856
8452
  encoding: "utf8"
@@ -6938,7 +8534,30 @@ function expandHome(filePath) {
6938
8534
  return filePath;
6939
8535
  }
6940
8536
  function printJson(value) {
6941
- process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
8537
+ process.stdout.write(`${JSON.stringify(redactCliOutput(value), null, 2)}\n`);
8538
+ }
8539
+ function redactCliOutput(value) {
8540
+ if (Array.isArray(value)) {
8541
+ return value.map((item) => redactCliOutput(item));
8542
+ }
8543
+ if (isRecord(value)) {
8544
+ return Object.fromEntries(Object.entries(value).flatMap(([key, item]) => {
8545
+ if (key === "gateway_token" || key === "gatewayToken") {
8546
+ return [];
8547
+ }
8548
+ if (key === "claude_transcript_anchor" ||
8549
+ key === "claudeTranscriptAnchor" ||
8550
+ key === "claude_home" ||
8551
+ key === "claudeHome") {
8552
+ return [];
8553
+ }
8554
+ if ((key === "callback_command" || key === "callbackCommand") && typeof item === "string") {
8555
+ return [[key, redactString(item)]];
8556
+ }
8557
+ return [[key, redactCliOutput(item)]];
8558
+ }));
8559
+ }
8560
+ return value;
6942
8561
  }
6943
8562
  function cleanProcessText(text) {
6944
8563
  const value = String(text ?? "").trim();
@@ -7004,6 +8623,24 @@ function readOutputTail(outputPath, maxBytes = 65536) {
7004
8623
  return "";
7005
8624
  }
7006
8625
  }
8626
+ function openPrivateAppendFile(filePath) {
8627
+ if (fs.existsSync(filePath) && fs.lstatSync(filePath).isSymbolicLink()) {
8628
+ throw new Error(`refusing agent output symlink: ${filePath}`);
8629
+ }
8630
+ const noFollow = typeof fs.constants.O_NOFOLLOW === "number"
8631
+ ? fs.constants.O_NOFOLLOW
8632
+ : 0;
8633
+ const descriptor = fs.openSync(filePath, fs.constants.O_CREAT |
8634
+ fs.constants.O_APPEND |
8635
+ fs.constants.O_WRONLY |
8636
+ noFollow, 0o600);
8637
+ if (!fs.fstatSync(descriptor).isFile()) {
8638
+ fs.closeSync(descriptor);
8639
+ throw new Error(`agent output must be a regular file: ${filePath}`);
8640
+ }
8641
+ fs.fchmodSync(descriptor, 0o600);
8642
+ return descriptor;
8643
+ }
7007
8644
  function detectModelSelectionError(text) {
7008
8645
  const cleaned = cleanProcessText(text);
7009
8646
  if (!cleaned) {
@@ -7077,6 +8714,8 @@ function withStoragePaths(conversation, paths) {
7077
8714
  function usage() {
7078
8715
  const agentList = EXECUTOR_KINDS.join("|");
7079
8716
  process.stdout.write(`Usage:
8717
+ agent-knock-knock --help
8718
+ agent-knock-knock --version
7080
8719
  agent-knock-knock new --request <text> [--agent ${agentList}] [--workspace <path>] [--store-dir <dir>]
7081
8720
  agent-knock-knock record --state <file> --message-json <json>
7082
8721
  agent-knock-knock bootstrap-prompt --callback-command <command> [--agent ${agentList}]
@@ -7088,11 +8727,11 @@ function usage() {
7088
8727
  agent-knock-knock approve --conversation <id>
7089
8728
  agent-knock-knock cancel --conversation <id> [--all-proxy <url>]
7090
8729
  agent-knock-knock renew --conversation <id> [--minutes <inactivity-minutes>]
8730
+ agent-knock-knock reconcile-monitors [--store-dir <dir>]
7091
8731
  agent-knock-knock retry-callback --conversation <id> [--store-dir <dir>]
7092
8732
  agent-knock-knock recover --conversation <id> [--session <name>] [--all-proxy <url>]
7093
8733
  agent-knock-knock close --conversation <id> [--reason <text>]
7094
8734
  agent-knock-knock install-openclaw [--openclaw-bin <path>] [--skill-path <path>] [--skill-only] [--no-restart]
7095
- agent-knock-knock install-claude-hooks [--settings-path <path>] [--executable-path <path>] [--dry-run]
7096
8735
  agent-knock-knock doctor
7097
8736
  agent-knock-knock agent takeover --agent codex --session-id <id> --strategy terminate_then_resume|terminal_control|fork [--create-conversation]
7098
8737
  agent-knock-knock callback --state <file> --message-json <json> [--record-only]