@scotthuang/agent-knock-knock 0.2.46 → 0.2.48

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 (44) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +93 -55
  3. package/dist/src/approval-policy.d.ts +3 -1
  4. package/dist/src/approval-policy.js +18 -4
  5. package/dist/src/approval-policy.js.map +1 -1
  6. package/dist/src/claude-hook-installer.d.ts +65 -0
  7. package/dist/src/claude-hook-installer.js +410 -0
  8. package/dist/src/claude-hook-installer.js.map +1 -0
  9. package/dist/src/claude-hook-protocol.d.ts +89 -0
  10. package/dist/src/claude-hook-protocol.js +243 -0
  11. package/dist/src/claude-hook-protocol.js.map +1 -0
  12. package/dist/src/claude-hook-store.d.ts +247 -0
  13. package/dist/src/claude-hook-store.js +1075 -0
  14. package/dist/src/claude-hook-store.js.map +1 -0
  15. package/dist/src/claude-terminal-agent-adapter.d.ts +47 -0
  16. package/dist/src/claude-terminal-agent-adapter.js +935 -0
  17. package/dist/src/claude-terminal-agent-adapter.js.map +1 -0
  18. package/dist/src/cli.js +2065 -316
  19. package/dist/src/cli.js.map +1 -1
  20. package/dist/src/openclaw-plugin-helpers.d.ts +49 -0
  21. package/dist/src/openclaw-plugin-helpers.js +246 -0
  22. package/dist/src/openclaw-plugin-helpers.js.map +1 -0
  23. package/dist/src/openclaw-plugin.js +98 -275
  24. package/dist/src/openclaw-plugin.js.map +1 -1
  25. package/dist/src/runtime-log.js +54 -2
  26. package/dist/src/runtime-log.js.map +1 -1
  27. package/dist/src/store.js +432 -12
  28. package/dist/src/store.js.map +1 -1
  29. package/dist/src/terminal-agent-adapter.d.ts +37 -0
  30. package/dist/src/terminal-agent-adapter.js.map +1 -1
  31. package/dist/src/terminal-agent-bridge.d.ts +51 -4
  32. package/dist/src/terminal-agent-bridge.js +326 -43
  33. package/dist/src/terminal-agent-bridge.js.map +1 -1
  34. package/dist/src/terminal-agent-registry.js +3 -1
  35. package/dist/src/terminal-agent-registry.js.map +1 -1
  36. package/dist/src/terminal-control-provider.d.ts +3 -1
  37. package/dist/src/terminal-control-provider.js +41 -37
  38. package/dist/src/terminal-control-provider.js.map +1 -1
  39. package/dist/src/terminal-process-source.d.ts +12 -3
  40. package/dist/src/terminal-process-source.js +37 -6
  41. package/dist/src/terminal-process-source.js.map +1 -1
  42. package/openclaw.plugin.json +5 -5
  43. package/package.json +10 -5
  44. package/templates/openclaw-skills/agent-knock-knock/SKILL.md +19 -30
package/dist/src/cli.js CHANGED
@@ -6,26 +6,53 @@ import path from "node:path";
6
6
  import process from "node:process";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { createCodexTerminalAgentAdapter, detectCodexDurableCompletion } from "./codex-terminal-agent-adapter.js";
9
+ import { createClaudeTerminalAgentAdapter } from "./claude-terminal-agent-adapter.js";
10
+ import { ClaudeHookStore, ClaudeHookStoreError, defaultClaudeHookStoreDir } from "./claude-hook-store.js";
11
+ import { claudePermissionHookOutput } from "./claude-hook-protocol.js";
12
+ import { defaultClaudeSettingsPath, installClaudeHooks, loadTrustedClaudeTokenjuiceLaunchers } from "./claude-hook-installer.js";
9
13
  import { CodexLocalSessionProvider } from "./codex-local-session-provider.js";
10
14
  import { CodexStoreAdapter } from "./codex-store-adapter.js";
11
15
  import { applyMessageToConversation, budgetAction, createConversation, createMessage, executorForConversation, extractStructuredMessage, parseMessageJson, resolveExecutor } from "./protocol.js";
12
16
  import { EXECUTOR_KINDS, acpxCommandForExecutor, executorDefinitionForKind, modelEnvForExecutor, normalizeModelForExecutor, proxyEnvForExecutor } from "./executors.js";
13
17
  import { executorBootstrapPrompt } from "./bootstrap.js";
14
- import { writeRuntimeLog } from "./runtime-log.js";
18
+ import { redactString, writeRuntimeLog } from "./runtime-log.js";
15
19
  import { formatTranscript, readNdjsonLog } from "./transcript.js";
16
20
  import { appendEvent, defaultStoreDir, listConversations, logPathForStatePath, loadConversationById, loadState, messageEvent, pathsForConversation, pathsForConversationDir, saveState, statePathForConversationId } from "./store.js";
17
21
  import { planFork, planTakeover } from "./session-takeover-planner.js";
18
- import { StaticTerminalControlProvider, TmuxTerminalControlProvider } from "./terminal-control-provider.js";
22
+ import { StaticTerminalControlProvider, TmuxTerminalControlProvider, terminalPaneContainsProcess } from "./terminal-control-provider.js";
19
23
  import { parseTerminalConversationId } from "./terminal-agent-adapter.js";
20
24
  import { createProductionTerminalAgentRegistry } from "./terminal-agent-registry.js";
21
25
  import { StaticTerminalProcessSource, SystemTerminalProcessSource } from "./terminal-process-source.js";
22
26
  import { TerminalAgentBridge } from "./terminal-agent-bridge.js";
27
+ import { evaluateApprovalPolicy } from "./approval-policy.js";
23
28
  const DEFAULT_IDLE_TIMEOUT_MINUTES = 10080;
24
29
  const DEFAULT_AGENT_TIMEOUT_MINUTES = 60;
25
30
  const DEFAULT_AGENT_HARD_TIMEOUT_MINUTES = 720;
26
31
  const DEFAULT_MONITOR_POLL_INTERVAL_MS = 5000;
27
32
  const CALLBACK_RETRY_DELAYS_MS = [5000, 15000, 60000, 60000];
28
- const DEFAULT_CODEX_ACPX_AGENT_COMMAND = "npx -y @agentclientprotocol/codex-acp@^1.1.0";
33
+ const TERMINAL_BRIDGE_MONITOR_LOCK_VERSION = 1;
34
+ const MINIMUM_NODE_VERSION = "22.14.0";
35
+ const PRIVATE_LOCK_FILE_MODE = 0o600;
36
+ const NO_FOLLOW_FLAG = typeof fs.constants.O_NOFOLLOW === "number"
37
+ ? fs.constants.O_NOFOLLOW
38
+ : 0;
39
+ const DEFAULT_CODEX_ACPX_AGENT_COMMAND = "npx -y @agentclientprotocol/codex-acp@1.1.7";
40
+ const CONVERSATION_STATUSES = new Set([
41
+ "created",
42
+ "running",
43
+ "waiting_for_agent",
44
+ "waiting_for_openclaw",
45
+ "idle",
46
+ "stalled",
47
+ "needs_recovery",
48
+ "needs_model_selection",
49
+ "callback_pending",
50
+ "callback_failed",
51
+ "failed",
52
+ "closed",
53
+ "cancelled",
54
+ "cancelling"
55
+ ]);
29
56
  class InlineCodexSessionAdapter {
30
57
  threads;
31
58
  processes;
@@ -82,7 +109,13 @@ catch (error) {
82
109
  process.exit(1);
83
110
  }
84
111
  async function runCommand(commandName, options) {
85
- if (commandName === "new") {
112
+ if (commandName === "help" || commandName === "--help" || commandName === "-h") {
113
+ usage();
114
+ }
115
+ else if (commandName === "version" || commandName === "--version" || commandName === "-v") {
116
+ printVersion();
117
+ }
118
+ else if (commandName === "new") {
86
119
  runNew(options);
87
120
  }
88
121
  else if (commandName === "record") {
@@ -115,6 +148,9 @@ async function runCommand(commandName, options) {
115
148
  else if (commandName === "renew") {
116
149
  await runRenew(options);
117
150
  }
151
+ else if (commandName === "reconcile-monitors") {
152
+ await runReconcileMonitors(options);
153
+ }
118
154
  else if (commandName === "recover") {
119
155
  runRecover(options);
120
156
  }
@@ -127,6 +163,12 @@ async function runCommand(commandName, options) {
127
163
  else if (commandName === "install-openclaw") {
128
164
  runInstallOpenClaw(options);
129
165
  }
166
+ else if (commandName === "install-claude-hooks") {
167
+ runInstallClaudeHooks(options);
168
+ }
169
+ else if (commandName === "claude-hook") {
170
+ await runClaudeHook(options);
171
+ }
130
172
  else if (commandName === "doctor") {
131
173
  runDoctor(options);
132
174
  }
@@ -197,6 +239,88 @@ function runInstallOpenClaw(options) {
197
239
  : "Agent Knock Knock is installed. Try: AKK list"
198
240
  });
199
241
  }
242
+ function runInstallClaudeHooks(options) {
243
+ const configuredExecutable = stringValue(options.executablePath ?? options.binPath);
244
+ let executablePath;
245
+ if (configuredExecutable) {
246
+ executablePath = path.resolve(expandHome(configuredExecutable));
247
+ }
248
+ else {
249
+ try {
250
+ executablePath = resolveExecutable("agent-knock-knock");
251
+ }
252
+ catch {
253
+ executablePath = path.resolve(process.argv[1]);
254
+ }
255
+ }
256
+ const result = installClaudeHooks({
257
+ executablePath,
258
+ settingsPath: expandHome(options.settingsPath ?? options.settings),
259
+ dryRun: options.dryRun === true
260
+ });
261
+ printJson({
262
+ installed: result.written || !result.changed,
263
+ ...result,
264
+ executablePath,
265
+ next: result.dryRun
266
+ ? "Run again without --dry-run to install the Claude Code hooks."
267
+ : "Claude Code hooks are configured. Existing sessions pick them up through settings reload; start a new session if needed."
268
+ });
269
+ }
270
+ async function runClaudeHook(options) {
271
+ const rawInput = fs.readFileSync(0, "utf8");
272
+ let input;
273
+ try {
274
+ input = JSON.parse(rawInput);
275
+ }
276
+ catch {
277
+ throw new Error("Claude hook input must be valid JSON");
278
+ }
279
+ const agentRows = loadClaudeAgentRows(options);
280
+ const claudePid = inferClaudeAncestorPid(agentRows);
281
+ const store = createClaudeHookStore(options);
282
+ const record = store.record(input, {
283
+ ...(claudePid === undefined ? {} : { claudePid })
284
+ });
285
+ if (record.event.input.hook_event_name !== "PermissionRequest" || !record.permission) {
286
+ return;
287
+ }
288
+ const permission = record.permission;
289
+ const requestedTimeout = options.permissionWaitTimeoutMs ?? options.timeoutMs;
290
+ const timeoutMs = requestedTimeout === undefined
291
+ ? undefined
292
+ : Math.max(0, Number(requestedTimeout));
293
+ try {
294
+ const decision = await store.waitForPermissionDecision({
295
+ sessionId: permission.sessionId,
296
+ requestId: permission.requestId,
297
+ fingerprint: permission.fingerprint,
298
+ conversationId: permission.conversationId,
299
+ messageId: permission.messageId,
300
+ ...(timeoutMs === undefined ? {} : { timeoutMs })
301
+ });
302
+ process.stdout.write(`${JSON.stringify(decision?.hookOutput ?? claudePermissionHookOutput({
303
+ behavior: "deny",
304
+ interrupt: false,
305
+ message: "Agent Knock Knock approval timed out. Review the request and retry the task."
306
+ }))}\n`);
307
+ }
308
+ catch (error) {
309
+ if (error instanceof ClaudeHookStoreError && [
310
+ "PERMISSION_EXPIRED",
311
+ "PERMISSION_CONSUMED",
312
+ "PERMISSION_ALREADY_DECIDED"
313
+ ].includes(error.code)) {
314
+ process.stdout.write(`${JSON.stringify(claudePermissionHookOutput({
315
+ behavior: "deny",
316
+ interrupt: false,
317
+ message: "Agent Knock Knock approval expired before a safe decision was received."
318
+ }))}\n`);
319
+ return;
320
+ }
321
+ throw error;
322
+ }
323
+ }
200
324
  function installOpenClawPlugin(openclawBin, root) {
201
325
  const linked = spawnSync(openclawBin, ["plugins", "install", "--link", root], {
202
326
  encoding: "utf8",
@@ -222,7 +346,17 @@ function installOpenClawPlugin(openclawBin, root) {
222
346
  }
223
347
  function runDoctor(options) {
224
348
  const commands = ["node", "openclaw", "acpx", "codex", "claude", "cursor"];
225
- const checks = commands.map((commandName) => executableCheck(commandName));
349
+ const checks = commands.map((commandName) => {
350
+ const check = executableCheck(commandName);
351
+ return commandName === "node"
352
+ ? {
353
+ ...check,
354
+ version: process.versions.node,
355
+ version_supported: versionAtLeast(process.versions.node, MINIMUM_NODE_VERSION),
356
+ minimum_version: MINIMUM_NODE_VERSION
357
+ }
358
+ : check;
359
+ });
226
360
  const root = packageRootDir();
227
361
  const packageFiles = [
228
362
  "dist/src/cli.js",
@@ -238,22 +372,43 @@ function runDoctor(options) {
238
372
  });
239
373
  const requiredOk = checks
240
374
  .filter((check) => ["node", "openclaw", "acpx"].includes(check.command))
241
- .every((check) => check.available);
375
+ .every((check) => check.available &&
376
+ (check.command !== "node" ||
377
+ ("version_supported" in check && check.version_supported === true)));
242
378
  const agentOk = checks
243
379
  .filter((check) => ["codex", "claude", "cursor"].includes(check.command))
244
380
  .some((check) => check.available);
245
381
  const filesOk = packageFiles.every((check) => check.exists);
382
+ const ok = requiredOk && agentOk && filesOk;
246
383
  printJson({
247
- ok: requiredOk && agentOk && filesOk,
384
+ ok,
248
385
  package_root: root,
249
386
  checks,
250
387
  package_files: packageFiles,
251
388
  notes: [
252
- "node, openclaw, and acpx are required.",
389
+ `Node.js ${MINIMUM_NODE_VERSION}+, openclaw, and acpx are required.`,
253
390
  "At least one local coding agent command should be available: codex, claude, or cursor."
254
391
  ],
255
392
  options
256
393
  });
394
+ if (!ok) {
395
+ process.exitCode = 1;
396
+ }
397
+ }
398
+ function versionAtLeast(version, minimum) {
399
+ const parsed = version.split(".").slice(0, 3).map((part) => Number.parseInt(part, 10));
400
+ const required = minimum.split(".").slice(0, 3).map((part) => Number.parseInt(part, 10));
401
+ if (parsed.length !== 3 ||
402
+ required.length !== 3 ||
403
+ [...parsed, ...required].some((part) => !Number.isInteger(part) || part < 0)) {
404
+ return false;
405
+ }
406
+ for (let index = 0; index < 3; index += 1) {
407
+ if (parsed[index] !== required[index]) {
408
+ return parsed[index] > required[index];
409
+ }
410
+ }
411
+ return true;
257
412
  }
258
413
  async function runAgent(options) {
259
414
  const agentCommand = required(options.agentCommand, "agent subcommand is required: takeover");
@@ -397,6 +552,7 @@ async function runAgentTakeover(options) {
397
552
  options,
398
553
  takeoverMatchKind: "terminal_control",
399
554
  terminalControl: target.terminalControl,
555
+ terminalAgentPid: target.pid,
400
556
  needsBootstrap: false
401
557
  });
402
558
  return {
@@ -558,7 +714,11 @@ function selectTerminateTarget({ plan, session, activeSessions, expectedPid, all
558
714
  }
559
715
  async function listActiveSessionsWithTerminalControl(provider, options, terminalProvider = createTerminalControlProvider(options)) {
560
716
  const activeSessions = await provider.listActiveSessions();
561
- return createTerminalAgentBridge(options, terminalProvider).attachProcesses(provider.agent, activeSessions);
717
+ const activePids = new Set(activeSessions.map((session) => session.pid));
718
+ const processTree = activePids.size > 0
719
+ ? await createTerminalProcessSource(options).listProcessSnapshots((snapshot) => activePids.has(snapshot.pid), { includeCwd: false, includeAncestors: true })
720
+ : [];
721
+ return createTerminalAgentBridge(options, terminalProvider).attachProcesses(provider.agent, activeSessions, { processTree });
562
722
  }
563
723
  function createTerminalControlProvider(options) {
564
724
  if (options.terminalsJson || options.terminalScreensJson || options.processesJson) {
@@ -575,9 +735,107 @@ function createTerminalProcessSource(options) {
575
735
  }
576
736
  return new SystemTerminalProcessSource();
577
737
  }
738
+ function createClaudeHookStore(options = {}) {
739
+ const configuredRoot = stringValue(options.claudeHookStoreDir);
740
+ return new ClaudeHookStore({
741
+ rootDir: expandHome(configuredRoot ?? defaultClaudeHookStoreDir())
742
+ });
743
+ }
744
+ function loadClaudeAgentRows(options = {}) {
745
+ let value;
746
+ if (options.claudeAgentsJson !== undefined) {
747
+ value = typeof options.claudeAgentsJson === "string"
748
+ ? parseJsonOption(options.claudeAgentsJson, "--claude-agents-json")
749
+ : options.claudeAgentsJson;
750
+ }
751
+ else if (options.processesJson || options.terminalsJson || options.terminalScreensJson) {
752
+ return [];
753
+ }
754
+ else {
755
+ const claudeExecutable = resolveOptionalExecutable("claude");
756
+ if (!claudeExecutable) {
757
+ return [];
758
+ }
759
+ const result = spawnSync(claudeExecutable, ["agents", "--json", "--all"], {
760
+ encoding: "utf8",
761
+ maxBuffer: 1024 * 1024 * 10,
762
+ timeout: 10_000
763
+ });
764
+ if (result.error || result.status !== 0) {
765
+ runtimeLog("warn", "claude_agents_list_failed", {
766
+ status: result.status ?? null,
767
+ error: result.error?.message,
768
+ stderr: textSummary(cleanProcessText(result.stderr))
769
+ });
770
+ return [];
771
+ }
772
+ try {
773
+ value = JSON.parse(result.stdout);
774
+ }
775
+ catch {
776
+ runtimeLog("warn", "claude_agents_list_invalid_json", {
777
+ stdout: textSummary(result.stdout)
778
+ });
779
+ return [];
780
+ }
781
+ }
782
+ const rows = Array.isArray(value)
783
+ ? value
784
+ : isRecord(value) && Array.isArray(value.agents)
785
+ ? value.agents
786
+ : [];
787
+ return rows.flatMap((row) => {
788
+ if (!isRecord(row) || !Number.isInteger(Number(row.pid))) {
789
+ return [];
790
+ }
791
+ return [{
792
+ pid: Number(row.pid),
793
+ ...(stringValue(row.cwd) ? { cwd: stringValue(row.cwd) } : {}),
794
+ ...(stringValue(row.kind) ? { kind: stringValue(row.kind) } : {}),
795
+ ...(stringValue(row.sessionId) ? { sessionId: stringValue(row.sessionId) } : {}),
796
+ ...(stringValue(row.status) ? { status: stringValue(row.status) } : {})
797
+ }];
798
+ });
799
+ }
800
+ function inferClaudeAncestorPid(agentRows, startingPid = process.ppid) {
801
+ const interactivePids = new Set(agentRows
802
+ .filter((row) => row.kind === undefined || row.kind === "interactive")
803
+ .map((row) => row.pid)
804
+ .filter((pid) => Number.isInteger(pid)));
805
+ let pid = startingPid;
806
+ const visited = new Set();
807
+ for (let depth = 0; depth < 16 && Number.isInteger(pid) && pid > 1 && !visited.has(pid); depth += 1) {
808
+ visited.add(pid);
809
+ const processRow = spawnSync("ps", ["-p", String(pid), "-o", "pid=,ppid=,command="], {
810
+ encoding: "utf8",
811
+ timeout: 2_000
812
+ });
813
+ if (processRow.error || processRow.status !== 0) {
814
+ return undefined;
815
+ }
816
+ const match = /^\s*(\d+)\s+(\d+)\s+(.+)$/u.exec(String(processRow.stdout).trim());
817
+ if (!match) {
818
+ return undefined;
819
+ }
820
+ const currentPid = Number(match[1]);
821
+ const parentPid = Number(match[2]);
822
+ const commandText = match[3];
823
+ if (interactivePids.has(currentPid) || isClaudeProcessCommand(commandText)) {
824
+ return currentPid;
825
+ }
826
+ pid = parentPid;
827
+ }
828
+ return undefined;
829
+ }
830
+ function isClaudeProcessCommand(commandText) {
831
+ const executable = commandText.trim().split(/\s+/u, 1)[0];
832
+ return path.basename(executable) === "claude" ||
833
+ /[\\/]\.local[\\/]share[\\/]claude[\\/]versions[\\/][^\\/\s]+$/u.test(executable);
834
+ }
578
835
  function createRuntimeTerminalAgentRegistry(options) {
579
836
  return createProductionTerminalAgentRegistry({
580
- overrides: [createCodexTerminalAgentAdapter({
837
+ overrides: [
838
+ createCodexTerminalAgentAdapter({
581
839
  async detectDurableCompletion(request) {
582
840
  const runtime = isRecord(request.context) ? request.context : undefined;
583
841
  const conversation = runtime?.conversation;
@@ -611,13 +869,44 @@ function createRuntimeTerminalAgentRegistry(options) {
611
869
  }
612
870
  : undefined;
613
871
  }
614
- })]
872
+ }),
873
+ createClaudeTerminalAgentAdapter({
874
+ agentRows: loadClaudeAgentRows(options),
875
+ hookStore: createClaudeHookStore(options),
876
+ trustedTokenjuiceLaunchers: loadTrustedClaudeTokenjuiceLaunchers(expandHome(options.claudeSettingsPath ?? defaultClaudeSettingsPath()))
877
+ })
878
+ ]
615
879
  });
616
880
  }
617
- function createTerminalAgentBridge(options, terminalProvider = createTerminalControlProvider(options)) {
881
+ function createTerminalAgentBridge(options, terminalProvider = createTerminalControlProvider(options), registry = createRuntimeTerminalAgentRegistry(options)) {
882
+ const processSource = createTerminalProcessSource(options);
618
883
  return new TerminalAgentBridge({
619
- registry: createRuntimeTerminalAgentRegistry(options),
620
- terminalProvider
884
+ registry,
885
+ terminalProvider,
886
+ async verifyIdentity({ agent, pid, terminalControl }) {
887
+ const adapter = registry.require(agent);
888
+ const snapshots = await processSource.listProcessSnapshots(undefined, { includeCwd: false });
889
+ const snapshot = snapshots.find((candidate) => candidate.pid === pid);
890
+ if (!snapshot || !adapter.classifyProcess(snapshot)) {
891
+ throw new Error(`terminal conversation agent ${agent} with pid ${pid} is no longer active`);
892
+ }
893
+ const panes = await terminalProvider.listPanes();
894
+ const pane = panes.find((candidate) => candidate.kind === terminalControl.kind &&
895
+ candidate.target === terminalControl.target &&
896
+ candidate.panePid === terminalControl.panePid);
897
+ if (!pane || !terminalPaneContainsProcess(snapshot, pane, snapshots)) {
898
+ throw new Error(`terminal conversation agent ${agent} with pid ${pid} no longer belongs to pane ${terminalControl.target}`);
899
+ }
900
+ return {
901
+ terminalControl: {
902
+ ...terminalControl,
903
+ socketPath: pane.socketPath,
904
+ panePid: pane.panePid,
905
+ currentCommand: pane.currentCommand,
906
+ currentPath: pane.currentPath
907
+ }
908
+ };
909
+ }
621
910
  });
622
911
  }
623
912
  function planTerminalControlTakeover(session, activeSessions) {
@@ -695,6 +984,134 @@ function terminalControlFromTakeover(nativeTakeover) {
695
984
  ]
696
985
  };
697
986
  }
987
+ function terminalRuntimeIdentityForConversation(conversation, terminalControl) {
988
+ const nativeTakeover = isRecord(conversation?.native_session_takeover)
989
+ ? conversation.native_session_takeover
990
+ : undefined;
991
+ const nativeSessionId = stringValue(nativeTakeover?.native_session_id);
992
+ const terminalIdentity = parseTerminalConversationId(nativeSessionId);
993
+ const explicitSessionId = stringValue(nativeTakeover?.terminal_agent_session_id) ??
994
+ (terminalIdentity ? undefined : nativeSessionId);
995
+ return {
996
+ pid: Number.isInteger(Number(nativeTakeover?.terminal_agent_pid))
997
+ ? Number(nativeTakeover?.terminal_agent_pid)
998
+ : terminalIdentity?.pid,
999
+ sessionId: explicitSessionId,
1000
+ cwd: stringValue(nativeTakeover?.source_cwd) ?? terminalControl.currentPath,
1001
+ conversationId: stringValue(conversation?.conversation_id),
1002
+ messageId: stringValue(nativeTakeover?.terminal_bridge_message_id),
1003
+ terminalTarget: terminalControl.target
1004
+ };
1005
+ }
1006
+ async function migrateLegacyTerminalAgentIdentity({ conversation, statePath, logPath, options }) {
1007
+ const nativeTakeover = isRecord(conversation?.native_session_takeover)
1008
+ ? conversation.native_session_takeover
1009
+ : undefined;
1010
+ const terminalControl = terminalControlFromTakeover(nativeTakeover);
1011
+ if (!nativeTakeover || !terminalControl) {
1012
+ return conversation;
1013
+ }
1014
+ const runtime = terminalRuntimeIdentityForConversation(conversation, terminalControl);
1015
+ if (Number.isInteger(runtime.pid) && Number(runtime.pid) > 0) {
1016
+ return conversation;
1017
+ }
1018
+ const executor = executorForConversation(conversation);
1019
+ const nativeSessionId = stringValue(nativeTakeover.native_session_id);
1020
+ if (executor.kind !== "codex" ||
1021
+ !nativeSessionId ||
1022
+ parseTerminalConversationId(nativeSessionId)) {
1023
+ return conversation;
1024
+ }
1025
+ let matchedProcess;
1026
+ try {
1027
+ const registry = createRuntimeTerminalAgentRegistry(options);
1028
+ const adapter = registry.require("codex");
1029
+ const snapshots = await createTerminalProcessSource(options).listProcessSnapshots((snapshot) => adapter.classifyProcess(snapshot) !== undefined, { includeAncestors: true });
1030
+ const panes = await createTerminalControlProvider(options).listPanes();
1031
+ const matchingPanes = panes.filter((pane) => pane.kind === terminalControl.kind &&
1032
+ pane.target === terminalControl.target &&
1033
+ pane.panePid === terminalControl.panePid);
1034
+ if (matchingPanes.length !== 1) {
1035
+ return conversation;
1036
+ }
1037
+ const candidates = snapshots.flatMap((snapshot) => {
1038
+ const classified = adapter.classifyProcess(snapshot);
1039
+ return classified ? [{ ...classified, agent: "codex" }] : [];
1040
+ });
1041
+ const matches = candidates.filter((candidate) => candidate.sessionId === nativeSessionId &&
1042
+ terminalPaneContainsProcess(candidate, matchingPanes[0], snapshots));
1043
+ if (matches.length !== 1) {
1044
+ return conversation;
1045
+ }
1046
+ matchedProcess = matches[0];
1047
+ }
1048
+ catch (error) {
1049
+ runtimeLog("warn", "legacy_terminal_agent_identity_migration_failed", {
1050
+ conversation_id: conversation.conversation_id,
1051
+ terminal_target: terminalControl.target,
1052
+ reason: error instanceof Error ? error.message : String(error)
1053
+ });
1054
+ return conversation;
1055
+ }
1056
+ if (!matchedProcess) {
1057
+ return conversation;
1058
+ }
1059
+ const releaseLock = acquireFileLock(`${statePath}.lock`);
1060
+ let migratedConversation = conversation;
1061
+ let migrated = false;
1062
+ try {
1063
+ const current = loadState(statePath);
1064
+ const currentTakeover = isRecord(current.native_session_takeover)
1065
+ ? current.native_session_takeover
1066
+ : undefined;
1067
+ const currentControl = terminalControlFromTakeover(currentTakeover);
1068
+ if (!currentTakeover || !currentControl) {
1069
+ return current;
1070
+ }
1071
+ const currentRuntime = terminalRuntimeIdentityForConversation(current, currentControl);
1072
+ if (Number.isInteger(currentRuntime.pid) && Number(currentRuntime.pid) > 0) {
1073
+ return current;
1074
+ }
1075
+ if (currentTakeover.native_session_id !== nativeSessionId ||
1076
+ currentControl.target !== terminalControl.target ||
1077
+ currentControl.socketPath !== terminalControl.socketPath ||
1078
+ currentControl.panePid !== terminalControl.panePid) {
1079
+ return current;
1080
+ }
1081
+ const migratedAt = new Date().toISOString();
1082
+ migratedConversation = {
1083
+ ...current,
1084
+ native_session_takeover: {
1085
+ ...currentTakeover,
1086
+ terminal_agent_pid: matchedProcess.pid,
1087
+ terminal_agent_session_id: matchedProcess.sessionId,
1088
+ terminal_agent_identity_migrated_at: migratedAt
1089
+ },
1090
+ updated_at: migratedAt
1091
+ };
1092
+ saveState(statePath, migratedConversation);
1093
+ migrated = true;
1094
+ }
1095
+ finally {
1096
+ releaseLock();
1097
+ }
1098
+ if (migrated) {
1099
+ appendEvent(logPath, {
1100
+ ts: new Date().toISOString(),
1101
+ conversation_id: migratedConversation.conversation_id,
1102
+ event: "terminal_agent_identity_migrated",
1103
+ terminal_target: terminalControl.target,
1104
+ terminal_agent_pid: matchedProcess.pid,
1105
+ native_session_id: nativeSessionId
1106
+ });
1107
+ runtimeLog("info", "terminal_agent_identity_migrated", {
1108
+ conversation_id: migratedConversation.conversation_id,
1109
+ terminal_target: terminalControl.target,
1110
+ terminal_agent_pid: matchedProcess.pid
1111
+ });
1112
+ }
1113
+ return migratedConversation;
1114
+ }
698
1115
  function isTerminalControlCapability(value) {
699
1116
  return typeof value === "string" && [
700
1117
  "screen_status",
@@ -798,7 +1215,7 @@ function createForkConversation({ agent, strategy, session, contextPackage, fork
798
1215
  next: `Use AKK send ${forkedConversation.conversation_id}: <message> to start the forked ${agent} session with the approved summary.`
799
1216
  };
800
1217
  }
801
- function createNativeSessionConversation({ agent, strategy, session, modelInfo, options, takeoverMatchKind = strategy, terminalControl = undefined, needsBootstrap = true }) {
1218
+ function createNativeSessionConversation({ agent, strategy, session, modelInfo, options, takeoverMatchKind = strategy, terminalControl = undefined, terminalAgentPid = undefined, needsBootstrap = true }) {
802
1219
  const workspace = session.cwd;
803
1220
  const storeDir = expandHome(options.storeDir ?? options.logDir ?? defaultStoreDir(workspace));
804
1221
  cleanupIdleConversations(storeDir, options);
@@ -847,6 +1264,7 @@ function createNativeSessionConversation({ agent, strategy, session, modelInfo,
847
1264
  native_session_takeover: {
848
1265
  agent,
849
1266
  native_session_id: session.id,
1267
+ terminal_agent_pid: terminalAgentPid,
850
1268
  source_cwd: session.cwd,
851
1269
  source_title: session.title,
852
1270
  strategy,
@@ -1094,7 +1512,7 @@ function runDelegate(options) {
1094
1512
  throw new Error(cleanProcessText(ensureSession.stderr || ensureSession.stdout || `acpx ${executor.kind} sessions ensure exited with status ${ensureSession.status}`));
1095
1513
  }
1096
1514
  const outputPath = path.join(newResult.paths.conversationDir, `${executor.kind}-output.log`);
1097
- const outputFd = fs.openSync(outputPath, "a");
1515
+ const outputFd = openPrivateAppendFile(outputPath);
1098
1516
  const child = spawn(acpxPath, acpxArgs, {
1099
1517
  detached: true,
1100
1518
  stdio: ["ignore", outputFd, outputFd],
@@ -1241,12 +1659,12 @@ function startExecutorMonitor({ statePath, logPath, pid, outputPath, agentTimeou
1241
1659
  detached: true,
1242
1660
  stdio: "ignore",
1243
1661
  cwd: process.cwd(),
1244
- env: process.env
1662
+ env: environmentWithoutGatewayTokens()
1245
1663
  });
1246
1664
  child.unref();
1247
1665
  return child;
1248
1666
  }
1249
- function startTerminalBridgeMonitor({ statePath, logPath, agentTimeoutMinutes, agentHardTimeoutMinutes, pollIntervalMs, codexHome }) {
1667
+ function startTerminalBridgeMonitor({ statePath, logPath, agentTimeoutMinutes, agentHardTimeoutMinutes, pollIntervalMs, codexHome, claudeHookStoreDir }) {
1250
1668
  const args = [
1251
1669
  new URL(import.meta.url).pathname,
1252
1670
  "monitor",
@@ -1265,11 +1683,14 @@ function startTerminalBridgeMonitor({ statePath, logPath, agentTimeoutMinutes, a
1265
1683
  if (codexHome) {
1266
1684
  args.push("--codex-home", codexHome);
1267
1685
  }
1686
+ if (claudeHookStoreDir) {
1687
+ args.push("--claude-hook-store-dir", claudeHookStoreDir);
1688
+ }
1268
1689
  const child = spawn(process.execPath, args, {
1269
1690
  detached: true,
1270
1691
  stdio: "ignore",
1271
1692
  cwd: process.cwd(),
1272
- env: process.env
1693
+ env: environmentWithoutGatewayTokens()
1273
1694
  });
1274
1695
  child.unref();
1275
1696
  return child;
@@ -1291,7 +1712,8 @@ function startTerminalBridgeMonitorForConversation({ conversation, statePath, lo
1291
1712
  nativeTakeover?.["terminal_bridge_hard_timeout_minutes"] ??
1292
1713
  DEFAULT_AGENT_HARD_TIMEOUT_MINUTES),
1293
1714
  pollIntervalMs: Number(options.monitorPollIntervalMs ?? DEFAULT_MONITOR_POLL_INTERVAL_MS),
1294
- codexHome: options.codexHome
1715
+ codexHome: options.codexHome,
1716
+ claudeHookStoreDir: options.claudeHookStoreDir ?? nativeTakeover?.["claude_hook_store_dir"]
1295
1717
  });
1296
1718
  }
1297
1719
  function terminalBridgeEnabled(conversation) {
@@ -1314,6 +1736,8 @@ function withTerminalBridgeState({ conversation, message, startedAt, agentTimeou
1314
1736
  terminal_bridge_request_text: String(message.body ?? ""),
1315
1737
  terminal_bridge_request_hash: terminalBridgeRequestFingerprint(message.body),
1316
1738
  terminal_bridge_pre_send_screen_fingerprint: preSendScreenFingerprint,
1739
+ terminal_bridge_completion_claim: undefined,
1740
+ terminal_bridge_monitor_lock_version: TERMINAL_BRIDGE_MONITOR_LOCK_VERSION,
1317
1741
  terminal_bridge_monitor_started_at: startedAt,
1318
1742
  terminal_bridge_last_activity_at: startedAt,
1319
1743
  terminal_bridge_inactivity_timeout_minutes: agentTimeoutMinutes,
@@ -1324,6 +1748,165 @@ function withTerminalBridgeState({ conversation, message, startedAt, agentTimeou
1324
1748
  updated_at: startedAt
1325
1749
  };
1326
1750
  }
1751
+ function activateClaudeHookLease({ options, conversation, message, terminalControl, expiresAt }) {
1752
+ const runtime = terminalRuntimeIdentityForConversation(conversation, terminalControl);
1753
+ const pid = runtime.pid;
1754
+ const agentRow = pid === undefined
1755
+ ? undefined
1756
+ : loadClaudeAgentRows(options).find((row) => row.pid === pid && (row.kind === undefined || row.kind === "interactive"));
1757
+ const sessionId = agentRow?.sessionId ?? runtime.sessionId;
1758
+ const cwd = agentRow?.cwd ?? runtime.cwd ?? terminalControl.currentPath;
1759
+ if (!cwd || (pid === undefined && !sessionId)) {
1760
+ runtimeLog("warn", "claude_hook_lease_unavailable", {
1761
+ conversation_id: conversation.conversation_id,
1762
+ terminal_target: terminalControl.target,
1763
+ reason: "exact Claude pid/session identity is unavailable"
1764
+ });
1765
+ return undefined;
1766
+ }
1767
+ const store = createClaudeHookStore(options);
1768
+ try {
1769
+ const previous = store.resolveLease({
1770
+ ...(sessionId === undefined ? {} : { sessionId }),
1771
+ ...(pid === undefined ? {} : { pid }),
1772
+ cwd,
1773
+ requireUnique: true
1774
+ });
1775
+ if (previous && (previous.lease.conversationId !== conversation.conversation_id ||
1776
+ previous.lease.messageId !== message.id)) {
1777
+ store.releaseLease({ leaseId: previous.lease.id });
1778
+ }
1779
+ const lease = store.activateLease({
1780
+ ...(sessionId === undefined ? {} : { sessionId }),
1781
+ ...(pid === undefined ? {} : { pid }),
1782
+ cwd,
1783
+ conversationId: conversation.conversation_id,
1784
+ messageId: message.id,
1785
+ terminalTarget: terminalControl.target,
1786
+ expiresAt
1787
+ });
1788
+ runtimeLog("info", "claude_hook_lease_activated", {
1789
+ conversation_id: conversation.conversation_id,
1790
+ message_id: message.id,
1791
+ terminal_target: terminalControl.target,
1792
+ lease_id: lease.id,
1793
+ pid,
1794
+ session_id: sessionId,
1795
+ expires_at: lease.expiresAt
1796
+ });
1797
+ return {
1798
+ lease,
1799
+ storeDir: store.rootDir,
1800
+ ...(pid === undefined ? {} : { pid }),
1801
+ ...(sessionId === undefined ? {} : { sessionId })
1802
+ };
1803
+ }
1804
+ catch (error) {
1805
+ runtimeLog("warn", "claude_hook_lease_unavailable", {
1806
+ conversation_id: conversation.conversation_id,
1807
+ terminal_target: terminalControl.target,
1808
+ reason: error instanceof Error ? error.message : String(error)
1809
+ });
1810
+ return undefined;
1811
+ }
1812
+ }
1813
+ function withClaudeHookLeaseState(conversation, state) {
1814
+ const nativeTakeover = isRecord(conversation.native_session_takeover)
1815
+ ? conversation.native_session_takeover
1816
+ : {};
1817
+ return {
1818
+ ...conversation,
1819
+ native_session_takeover: {
1820
+ ...nativeTakeover,
1821
+ claude_hook_mode: "enabled",
1822
+ claude_hook_store_dir: state.storeDir,
1823
+ claude_hook_lease_id: state.lease.id,
1824
+ terminal_agent_pid: state.pid,
1825
+ terminal_agent_session_id: state.sessionId
1826
+ }
1827
+ };
1828
+ }
1829
+ function releaseClaudeHookLease(conversation) {
1830
+ const nativeTakeover = isRecord(conversation?.native_session_takeover)
1831
+ ? conversation.native_session_takeover
1832
+ : undefined;
1833
+ const leaseId = stringValue(nativeTakeover?.claude_hook_lease_id);
1834
+ if (!leaseId || nativeTakeover?.agent !== "claude") {
1835
+ return;
1836
+ }
1837
+ const storeDir = stringValue(nativeTakeover.claude_hook_store_dir) ?? defaultClaudeHookStoreDir();
1838
+ try {
1839
+ const released = new ClaudeHookStore({ rootDir: storeDir }).releaseLease({ leaseId });
1840
+ runtimeLog("info", "claude_hook_lease_released", {
1841
+ conversation_id: conversation.conversation_id,
1842
+ message_id: released?.messageId,
1843
+ lease_id: leaseId
1844
+ });
1845
+ }
1846
+ catch (error) {
1847
+ runtimeLog("warn", "claude_hook_lease_release_failed", {
1848
+ conversation_id: conversation.conversation_id,
1849
+ lease_id: leaseId,
1850
+ reason: error instanceof Error ? error.message : String(error)
1851
+ });
1852
+ }
1853
+ }
1854
+ function renewClaudeHookLease(conversation, expiresAt) {
1855
+ const nativeTakeover = isRecord(conversation?.native_session_takeover)
1856
+ ? conversation.native_session_takeover
1857
+ : undefined;
1858
+ if (nativeTakeover?.agent !== "claude") {
1859
+ return undefined;
1860
+ }
1861
+ const conversationId = stringValue(conversation.conversation_id);
1862
+ const messageId = stringValue(nativeTakeover.terminal_bridge_message_id);
1863
+ const terminalControl = terminalControlFromTakeover(nativeTakeover);
1864
+ const cwd = stringValue(nativeTakeover.source_cwd) ?? terminalControl?.currentPath;
1865
+ const pid = Number.isInteger(Number(nativeTakeover.terminal_agent_pid))
1866
+ ? Number(nativeTakeover.terminal_agent_pid)
1867
+ : undefined;
1868
+ const sessionId = stringValue(nativeTakeover.terminal_agent_session_id);
1869
+ if (!conversationId || !messageId || !terminalControl || !cwd || (pid === undefined && !sessionId)) {
1870
+ return undefined;
1871
+ }
1872
+ const storeDir = stringValue(nativeTakeover.claude_hook_store_dir) ?? defaultClaudeHookStoreDir();
1873
+ try {
1874
+ return new ClaudeHookStore({ rootDir: storeDir }).activateLease({
1875
+ ...(sessionId === undefined ? {} : { sessionId }),
1876
+ ...(pid === undefined ? {} : { pid }),
1877
+ cwd,
1878
+ conversationId,
1879
+ messageId,
1880
+ terminalTarget: terminalControl.target,
1881
+ expiresAt
1882
+ });
1883
+ }
1884
+ catch (error) {
1885
+ runtimeLog("warn", "claude_hook_lease_renew_failed", {
1886
+ conversation_id: conversationId,
1887
+ message_id: messageId,
1888
+ reason: error instanceof Error ? error.message : String(error)
1889
+ });
1890
+ return undefined;
1891
+ }
1892
+ }
1893
+ function releaseClaudeHookLeasesForTerminal({ storeDir, terminalControl, replacementConversationId }) {
1894
+ for (const candidate of listConversations(storeDir)) {
1895
+ if (candidate.conversation_id === replacementConversationId) {
1896
+ continue;
1897
+ }
1898
+ const nativeTakeover = isRecord(candidate.native_session_takeover)
1899
+ ? candidate.native_session_takeover
1900
+ : undefined;
1901
+ const candidateControl = terminalControlFromTakeover(nativeTakeover);
1902
+ if (nativeTakeover?.agent === "claude" &&
1903
+ nativeTakeover?.terminal_bridge === true &&
1904
+ candidateControl?.target === terminalControl.target &&
1905
+ candidateControl?.socketPath === terminalControl.socketPath) {
1906
+ releaseClaudeHookLease(candidate);
1907
+ }
1908
+ }
1909
+ }
1327
1910
  function uniqueDelegateSessionName(kind) {
1328
1911
  const { sessionPrefix } = executorDefinitionForKind(kind || "claude");
1329
1912
  const timestamp = new Date().toISOString().replace(/\D/g, "").slice(0, 14);
@@ -1378,16 +1961,23 @@ function modelForExecutor(executor, options = {}) {
1378
1961
  return normalizeModelForExecutor(executor, modelEnvForExecutor(executor, process.env));
1379
1962
  }
1380
1963
  function environmentForExecutor(executor, options = {}) {
1964
+ const environment = environmentWithoutGatewayTokens();
1381
1965
  const proxy = proxyForExecutor(executor, options);
1382
1966
  if (!proxy) {
1383
- return process.env;
1967
+ return environment;
1384
1968
  }
1385
1969
  return {
1386
- ...process.env,
1970
+ ...environment,
1387
1971
  ALL_PROXY: proxy,
1388
1972
  all_proxy: proxy
1389
1973
  };
1390
1974
  }
1975
+ function environmentWithoutGatewayTokens() {
1976
+ const environment = { ...process.env };
1977
+ delete environment.AKK_GATEWAY_TOKEN;
1978
+ delete environment.OPENCLAW_GATEWAY_TOKEN;
1979
+ return environment;
1980
+ }
1391
1981
  async function runList(options) {
1392
1982
  const storeDir = expandHome(options.storeDir ?? options.logDir ?? defaultStoreDir(process.cwd()));
1393
1983
  const cleanup = cleanupIdleConversations(storeDir, options);
@@ -1460,7 +2050,7 @@ async function buildNativeListGroups({ options, agentFilter, statusFilter }) {
1460
2050
  };
1461
2051
  }
1462
2052
  const terminalProvider = createTerminalControlProvider(options);
1463
- const bridge = new TerminalAgentBridge({ registry, terminalProvider });
2053
+ const bridge = createTerminalAgentBridge(options, terminalProvider, registry);
1464
2054
  const terminalScan = options.terminalDebug ? await terminalControlDiagnostics(terminalProvider) : undefined;
1465
2055
  const terminalControlled = [];
1466
2056
  const native = [];
@@ -1468,7 +2058,7 @@ async function buildNativeListGroups({ options, agentFilter, statusFilter }) {
1468
2058
  const errors = [];
1469
2059
  try {
1470
2060
  const processSource = createTerminalProcessSource(options);
1471
- const snapshots = await processSource.listProcessSnapshots((snapshot) => adapters.some((adapter) => adapter.capabilities.processDiscovery && adapter.classifyProcess(snapshot) !== undefined));
2061
+ const snapshots = await processSource.listProcessSnapshots((snapshot) => adapters.some((adapter) => adapter.capabilities.processDiscovery && adapter.classifyProcess(snapshot) !== undefined), { includeAncestors: true });
1472
2062
  const activeSessions = await bridge.listProcesses(snapshots, adapters.map((adapter) => adapter.agent));
1473
2063
  activeCount = activeSessions.length;
1474
2064
  const rootSessions = rootActiveProcesses(activeSessions);
@@ -1554,7 +2144,12 @@ async function terminalControlledListEntry(session, activeSessions, options, bri
1554
2144
  if (!terminalControl) {
1555
2145
  throw new Error(`process ${session.pid} is not terminal-controlled`);
1556
2146
  }
1557
- const terminalState = await listStateForTerminal(session.agent, terminalControl, options, bridge);
2147
+ const terminalState = await listStateForTerminal(session.agent, terminalControl, options, bridge, {
2148
+ pid: session.pid,
2149
+ sessionId: session.sessionId,
2150
+ cwd: session.cwd,
2151
+ terminalTarget: terminalControl.target
2152
+ });
1558
2153
  return {
1559
2154
  id: bridge.terminalConversationId(session),
1560
2155
  source: "terminal_control",
@@ -1583,7 +2178,7 @@ async function terminalControlledListEntry(session, activeSessions, options, bri
1583
2178
  }
1584
2179
  };
1585
2180
  }
1586
- async function listStateForTerminal(agent, terminalControl, options, bridge = createTerminalAgentBridge(options)) {
2181
+ async function listStateForTerminal(agent, terminalControl, options, bridge = createTerminalAgentBridge(options), runtime) {
1587
2182
  if (options.noApprovalScan) {
1588
2183
  return {
1589
2184
  approval_state: {
@@ -1598,7 +2193,8 @@ async function listStateForTerminal(agent, terminalControl, options, bridge = cr
1598
2193
  }
1599
2194
  try {
1600
2195
  const status = await bridge.status(agent, terminalControl, {
1601
- scrollbackLines: Number(options.scrollbackLines ?? 120)
2196
+ scrollbackLines: Number(options.scrollbackLines ?? 120),
2197
+ runtime
1602
2198
  });
1603
2199
  return {
1604
2200
  approval_state: {
@@ -1629,7 +2225,7 @@ function rootActiveProcesses(processes) {
1629
2225
  const seenTerminalTargets = new Set();
1630
2226
  return roots.filter((process) => {
1631
2227
  const terminalTarget = process.terminalControl?.target
1632
- ? `${process.agent}:${process.terminalControl.target}`
2228
+ ? `${process.agent}:${process.terminalControl.target}:${process.terminalControl.panePid}`
1633
2229
  : undefined;
1634
2230
  if (!terminalTarget) {
1635
2231
  return true;
@@ -1671,7 +2267,11 @@ async function runStatus(options) {
1671
2267
  cleanupIdleConversations(storeDirFromOptions(options), options);
1672
2268
  const terminalConversation = await resolveTerminalConversationFromOptions(options);
1673
2269
  if (terminalConversation) {
1674
- const terminalStatus = await terminalStatusForControl(terminalConversation.agent, terminalConversation.terminalControl, options);
2270
+ const terminalStatus = await terminalStatusForControl(terminalConversation.agent, terminalConversation.terminalControl, options, {
2271
+ pid: terminalConversation.pid,
2272
+ cwd: terminalConversation.terminalControl.currentPath,
2273
+ terminalTarget: terminalConversation.terminalControl.target
2274
+ });
1675
2275
  printJson({
1676
2276
  conversation_id: terminalConversation.conversationId,
1677
2277
  source: "terminal_control",
@@ -1686,7 +2286,12 @@ async function runStatus(options) {
1686
2286
  });
1687
2287
  return;
1688
2288
  }
1689
- const { conversation, statePath, logPath } = loadConversationFromOptions(options);
2289
+ const loaded = loadConversationFromOptions(options);
2290
+ const { statePath, logPath } = loaded;
2291
+ const conversation = await migrateLegacyTerminalAgentIdentity({
2292
+ ...loaded,
2293
+ options
2294
+ });
1690
2295
  const events = readExistingEvents(logPath);
1691
2296
  const result = {
1692
2297
  conversation,
@@ -1703,7 +2308,7 @@ async function runStatus(options) {
1703
2308
  if (terminalControl) {
1704
2309
  const executor = executorForConversation(conversation);
1705
2310
  result.terminal_control = terminalControl;
1706
- result.terminal_status = await terminalStatusForControl(executor.kind, terminalControl, options);
2311
+ result.terminal_status = await terminalStatusForControl(executor.kind, terminalControl, options, terminalRuntimeIdentityForConversation(conversation, terminalControl));
1707
2312
  result.terminal_screen = result.terminal_status.screen;
1708
2313
  }
1709
2314
  printJson(result);
@@ -1721,7 +2326,11 @@ async function runDescribe(options) {
1721
2326
  const conversationId = required(options.conversation ?? options.conversationId, "--conversation is required");
1722
2327
  const terminalConversation = await resolveTerminalConversationFromOptions(options);
1723
2328
  if (terminalConversation) {
1724
- const terminalStatus = await terminalStatusForControl(terminalConversation.agent, terminalConversation.terminalControl, options);
2329
+ const terminalStatus = await terminalStatusForControl(terminalConversation.agent, terminalConversation.terminalControl, options, {
2330
+ pid: terminalConversation.pid,
2331
+ cwd: terminalConversation.terminalControl.currentPath,
2332
+ terminalTarget: terminalConversation.terminalControl.target
2333
+ });
1725
2334
  if (terminalConversation.agent !== "codex") {
1726
2335
  const adapter = createRuntimeTerminalAgentRegistry(options).require(terminalConversation.agent);
1727
2336
  printJson({
@@ -1763,11 +2372,16 @@ async function runDescribe(options) {
1763
2372
  }));
1764
2373
  return;
1765
2374
  }
1766
- const { conversation, statePath, logPath } = loadConversationFromOptions(options);
2375
+ const loaded = loadConversationFromOptions(options);
2376
+ const { statePath, logPath } = loaded;
2377
+ const conversation = await migrateLegacyTerminalAgentIdentity({
2378
+ ...loaded,
2379
+ options
2380
+ });
1767
2381
  const events = readExistingEvents(logPath);
1768
2382
  const terminalControl = terminalControlFromTakeover(isRecord(conversation.native_session_takeover) ? conversation.native_session_takeover : undefined);
1769
2383
  const terminalStatus = terminalControl
1770
- ? await terminalStatusForControl(executorForConversation(conversation).kind, terminalControl, options)
2384
+ ? await terminalStatusForControl(executorForConversation(conversation).kind, terminalControl, options, terminalRuntimeIdentityForConversation(conversation, terminalControl))
1771
2385
  : undefined;
1772
2386
  printJson({
1773
2387
  conversation_id: conversation.conversation_id,
@@ -1786,9 +2400,10 @@ async function runDescribe(options) {
1786
2400
  event_log_path: logPath
1787
2401
  });
1788
2402
  }
1789
- async function terminalStatusForControl(agent, terminalControl, options) {
2403
+ async function terminalStatusForControl(agent, terminalControl, options, runtime) {
1790
2404
  return createTerminalAgentBridge(options).status(agent, terminalControl, {
1791
- scrollbackLines: Number(options.scrollbackLines ?? 120)
2405
+ scrollbackLines: Number(options.scrollbackLines ?? 120),
2406
+ runtime
1792
2407
  });
1793
2408
  }
1794
2409
  function terminalBridgeApprovalFingerprint({ terminalControl, terminalStatus }) {
@@ -1805,10 +2420,26 @@ function terminalBridgeApprovalFingerprint({ terminalControl, terminalStatus })
1805
2420
  label: approval.label,
1806
2421
  prompt_kind: approval.prompt_kind,
1807
2422
  command: approval.command,
2423
+ tool_name: approval.tool_name,
2424
+ request_detail: approval.request_detail,
1808
2425
  excerpt: screen.excerpt
1809
2426
  }))
1810
2427
  .digest("hex");
1811
2428
  }
2429
+ function assertSafeClaudeTerminalSend(terminalStatus) {
2430
+ const approval = isRecord(terminalStatus?.approval_state)
2431
+ ? terminalStatus.approval_state
2432
+ : undefined;
2433
+ if (terminalStatus?.reachable !== true) {
2434
+ throw new Error("Claude Code terminal status is unavailable");
2435
+ }
2436
+ if (approval?.blocked === true) {
2437
+ throw new Error(stringValue(approval.reason) ?? "Claude Code is waiting at a permission dialog");
2438
+ }
2439
+ if (terminalStatus.activity_state !== "idle") {
2440
+ throw new Error(`Claude Code terminal is ${stringValue(terminalStatus.activity_state) ?? "unknown"}, not idle`);
2441
+ }
2442
+ }
1812
2443
  function terminalBridgeApprovalInstructions({ conversation, terminalControl, terminalStatus }) {
1813
2444
  const approval = isRecord(terminalStatus?.approval_state) ? terminalStatus.approval_state : {};
1814
2445
  const screen = isRecord(terminalStatus?.screen) ? terminalStatus.screen : {};
@@ -1818,10 +2449,18 @@ function terminalBridgeApprovalInstructions({ conversation, terminalControl, ter
1818
2449
  const keys = Array.isArray(approval.keys)
1819
2450
  ? approval.keys.filter((value) => typeof value === "string")
1820
2451
  : [];
1821
- const keyDescription = keys.length > 0
1822
- ? keys.join(" then ")
1823
- : stringValue(approval.key) || "the detected approve key sequence";
2452
+ const decisionMode = stringValue(approval.decision_mode);
2453
+ const keyDescription = decisionMode === "structured"
2454
+ ? "structured one-time Hook decision"
2455
+ : keys.length > 0
2456
+ ? keys.join(" then ")
2457
+ : stringValue(approval.key) || "the detected approve key sequence";
1824
2458
  const fingerprint = stringValue(approval.fingerprint);
2459
+ const promptKind = stringValue(approval.prompt_kind);
2460
+ const command = stringValue(approval.command);
2461
+ const toolName = stringValue(approval.tool_name);
2462
+ const requestDetail = stringValue(approval.request_detail);
2463
+ const requestId = stringValue(approval.request_id);
1825
2464
  const excerpt = stringValue(screen.excerpt) || "(No terminal excerpt was available.)";
1826
2465
  return [
1827
2466
  `${agentName} is waiting for approval in a terminal-controlled AKK session.`,
@@ -1829,6 +2468,11 @@ function terminalBridgeApprovalInstructions({ conversation, terminalControl, ter
1829
2468
  `Conversation: ${conversation.conversation_id}`,
1830
2469
  `Terminal: ${terminalControl.kind}:${terminalControl.target}`,
1831
2470
  `Approval option: ${label} (${keyDescription})`,
2471
+ promptKind ? `Request kind: ${promptKind}` : undefined,
2472
+ toolName ? `Tool: ${toolName}` : undefined,
2473
+ requestDetail ? `Request: ${requestDetail}` : undefined,
2474
+ command ? `Command: ${command}` : undefined,
2475
+ requestId ? `Structured request id: ${requestId}` : undefined,
1832
2476
  "",
1833
2477
  "Safe terminal excerpt:",
1834
2478
  "```text",
@@ -1850,7 +2494,7 @@ function terminalBridgeApprovalInstructions({ conversation, terminalControl, ter
1850
2494
  "Equivalent user command: `AKK cancel " + conversation.conversation_id + "`",
1851
2495
  "",
1852
2496
  "Do not use raw tmux, shell, or manual key presses for this approval. Do not approve without explicit user confirmation."
1853
- ].join("\n");
2497
+ ].filter((line) => line !== undefined).join("\n");
1854
2498
  }
1855
2499
  function recordTerminalBridgeApprovalNotification({ statePath, logPath, terminalControl, terminalStatus, fingerprint }) {
1856
2500
  const releaseLock = acquireFileLock(`${statePath}.lock`);
@@ -1900,102 +2544,138 @@ function recordTerminalBridgeApprovalNotification({ statePath, logPath, terminal
1900
2544
  releaseLock();
1901
2545
  }
1902
2546
  }
1903
- async function runSend(options) {
1904
- const messageBody = required(options.message ?? options.request, "--message is required");
1905
- if (options.agentHardTimeoutMinutes !== undefined) {
1906
- positiveMinutes(options.agentHardTimeoutMinutes, "--agent-hard-timeout-minutes");
1907
- }
1908
- cleanupIdleConversations(storeDirFromOptions(options), options);
1909
- const terminalConversation = await resolveTerminalConversationFromOptions(options);
1910
- if (terminalConversation) {
1911
- if (options.background) {
1912
- const managed = createManagedTerminalConversationFromRawId({
1913
- options,
1914
- conversationId: terminalConversation.conversationId,
1915
- agent: terminalConversation.agent,
1916
- messageBody,
1917
- terminalControl: terminalConversation.terminalControl
1918
- });
1919
- await runTerminalControlSend({
1920
- options,
1921
- conversation: managed.conversation,
1922
- nextConversation: managed.nextConversation,
1923
- statePath: managed.statePath,
1924
- logPath: managed.logPath,
1925
- executor: managed.executor,
1926
- message: managed.message,
1927
- terminalControl: terminalConversation.terminalControl,
1928
- needsNativeTakeoverBootstrap: true
1929
- });
1930
- return;
2547
+ function prepareManagedSend({ options, statePath, logPath, messageBody }) {
2548
+ const releaseLock = acquireFileLock(`${statePath}.lock`);
2549
+ try {
2550
+ const conversation = loadState(statePath);
2551
+ if (["done", "failed", "closed", "cancelled"].includes(conversation.status)) {
2552
+ throw new Error(`cannot send to ${conversation.conversation_id}; conversation is ${conversation.status}`);
2553
+ }
2554
+ if (conversation.status === "needs_recovery") {
2555
+ throw new Error(`cannot send to ${conversation.conversation_id}; choose recover, close, or delegate a new task first`);
2556
+ }
2557
+ if (conversation.status === "needs_model_selection" && !options.model) {
2558
+ throw new Error(`cannot send to ${conversation.conversation_id}; choose a supported model with --model first`);
2559
+ }
2560
+ const executor = executorForConversation(conversation);
2561
+ const type = options.type ??
2562
+ (conversation.status === "waiting_for_openclaw" ? "answer" : "task");
2563
+ const nativeTakeoverForSend = isRecord(conversation.native_session_takeover)
2564
+ ? conversation.native_session_takeover
2565
+ : undefined;
2566
+ const forkTakeoverForSend = isRecord(conversation.fork_context_takeover)
2567
+ ? conversation.fork_context_takeover
2568
+ : undefined;
2569
+ const needsNativeTakeoverBootstrap = nativeTakeoverForSend?.["needs_bootstrap"] === true;
2570
+ const needsForkTakeoverBootstrap = forkTakeoverForSend?.["needs_bootstrap"] === true;
2571
+ const message = createMessage({
2572
+ conversation,
2573
+ from: "openclaw",
2574
+ to: executor.actor,
2575
+ type,
2576
+ body: messageBody,
2577
+ metadata: {
2578
+ executor_kind: executor.kind,
2579
+ executor_session: executor.session
2580
+ }
2581
+ });
2582
+ const previousModelSelection = isRecord(conversation.model_selection)
2583
+ ? conversation.model_selection
2584
+ : {};
2585
+ const nextConversation = {
2586
+ ...applyMessageToConversation(conversation, message),
2587
+ executor,
2588
+ claude_session: executor.kind === "claude"
2589
+ ? executor.session
2590
+ : conversation.claude_session,
2591
+ executor_model: options.model ?? conversation.executor_model,
2592
+ model_selection: conversation.status === "needs_model_selection"
2593
+ ? {
2594
+ ...previousModelSelection,
2595
+ resolved_at: new Date().toISOString(),
2596
+ selected_model: options.model
2597
+ }
2598
+ : conversation.model_selection
2599
+ };
2600
+ saveState(statePath, nextConversation);
2601
+ appendEvent(logPath, messageEvent(message));
2602
+ runtimeLog("info", "message_created", {
2603
+ conversation_id: conversation.conversation_id,
2604
+ agent: executor.kind,
2605
+ executor_session: executor.session,
2606
+ message_type: type,
2607
+ state_path: statePath,
2608
+ event_log_path: logPath,
2609
+ message: textSummary(messageBody)
2610
+ });
2611
+ return {
2612
+ conversation,
2613
+ executor,
2614
+ nativeTakeoverForSend,
2615
+ forkTakeoverForSend,
2616
+ needsNativeTakeoverBootstrap,
2617
+ needsForkTakeoverBootstrap,
2618
+ message,
2619
+ nextConversation
2620
+ };
2621
+ }
2622
+ finally {
2623
+ releaseLock();
2624
+ }
2625
+ }
2626
+ async function runSend(options) {
2627
+ const messageBody = required(options.message ?? options.request, "--message is required");
2628
+ if (options.agentHardTimeoutMinutes !== undefined) {
2629
+ positiveMinutes(options.agentHardTimeoutMinutes, "--agent-hard-timeout-minutes");
2630
+ }
2631
+ cleanupIdleConversations(storeDirFromOptions(options), options);
2632
+ const terminalConversation = await resolveTerminalConversationFromOptions(options);
2633
+ if (terminalConversation) {
2634
+ if (options.background) {
2635
+ const managed = createManagedTerminalConversationFromRawId({
2636
+ options,
2637
+ conversationId: terminalConversation.conversationId,
2638
+ agent: terminalConversation.agent,
2639
+ pid: terminalConversation.pid,
2640
+ messageBody,
2641
+ terminalControl: terminalConversation.terminalControl
2642
+ });
2643
+ await runTerminalControlSend({
2644
+ options,
2645
+ conversation: managed.conversation,
2646
+ nextConversation: managed.nextConversation,
2647
+ statePath: managed.statePath,
2648
+ logPath: managed.logPath,
2649
+ executor: managed.executor,
2650
+ message: managed.message,
2651
+ terminalControl: terminalConversation.terminalControl,
2652
+ needsNativeTakeoverBootstrap: true
2653
+ });
2654
+ return;
1931
2655
  }
1932
2656
  await runTerminalConversationSend({
1933
2657
  options,
1934
2658
  conversationId: terminalConversation.conversationId,
1935
2659
  agent: terminalConversation.agent,
2660
+ pid: terminalConversation.pid,
1936
2661
  messageBody,
1937
2662
  terminalControl: terminalConversation.terminalControl
1938
2663
  });
1939
2664
  return;
1940
2665
  }
1941
- const { conversation, statePath, logPath } = loadConversationFromOptions(options);
1942
- if (["done", "failed", "closed", "cancelled"].includes(conversation.status)) {
1943
- throw new Error(`cannot send to ${conversation.conversation_id}; conversation is ${conversation.status}`);
1944
- }
1945
- if (conversation.status === "needs_recovery") {
1946
- throw new Error(`cannot send to ${conversation.conversation_id}; choose recover, close, or delegate a new task first`);
1947
- }
1948
- if (conversation.status === "needs_model_selection" && !options.model) {
1949
- throw new Error(`cannot send to ${conversation.conversation_id}; choose a supported model with --model first`);
1950
- }
1951
- const executor = executorForConversation(conversation);
1952
- const type = options.type ?? (conversation.status === "waiting_for_openclaw" ? "answer" : "task");
1953
- const nativeTakeoverForSend = isRecord(conversation.native_session_takeover)
1954
- ? conversation.native_session_takeover
1955
- : undefined;
1956
- const forkTakeoverForSend = isRecord(conversation.fork_context_takeover)
1957
- ? conversation.fork_context_takeover
1958
- : undefined;
1959
- const needsNativeTakeoverBootstrap = nativeTakeoverForSend?.["needs_bootstrap"] === true;
1960
- const needsForkTakeoverBootstrap = forkTakeoverForSend?.["needs_bootstrap"] === true;
1961
- const message = createMessage({
1962
- conversation,
1963
- from: "openclaw",
1964
- to: executor.actor,
1965
- type,
1966
- body: messageBody,
1967
- metadata: {
1968
- executor_kind: executor.kind,
1969
- executor_session: executor.session
1970
- }
2666
+ const loaded = loadConversationFromOptions(options);
2667
+ const { statePath, logPath } = loaded;
2668
+ await migrateLegacyTerminalAgentIdentity({
2669
+ ...loaded,
2670
+ options
1971
2671
  });
1972
- const previousModelSelection = isRecord(conversation.model_selection)
1973
- ? conversation.model_selection
1974
- : {};
1975
- const nextConversation = {
1976
- ...applyMessageToConversation(conversation, message),
1977
- executor,
1978
- claude_session: executor.kind === "claude" ? executor.session : conversation.claude_session,
1979
- executor_model: options.model ?? conversation.executor_model,
1980
- model_selection: conversation.status === "needs_model_selection"
1981
- ? {
1982
- ...previousModelSelection,
1983
- resolved_at: new Date().toISOString(),
1984
- selected_model: options.model
1985
- }
1986
- : conversation.model_selection
1987
- };
1988
- saveState(statePath, nextConversation);
1989
- appendEvent(logPath, messageEvent(message));
1990
- runtimeLog("info", "message_created", {
1991
- conversation_id: conversation.conversation_id,
1992
- agent: executor.kind,
1993
- executor_session: executor.session,
1994
- message_type: type,
1995
- state_path: statePath,
1996
- event_log_path: logPath,
1997
- message: textSummary(messageBody)
2672
+ const prepared = prepareManagedSend({
2673
+ options,
2674
+ statePath,
2675
+ logPath,
2676
+ messageBody
1998
2677
  });
2678
+ const { conversation, executor, nativeTakeoverForSend, forkTakeoverForSend, needsNativeTakeoverBootstrap, needsForkTakeoverBootstrap, message, nextConversation } = prepared;
1999
2679
  const executorEnv = environmentForExecutor(executor, {
2000
2680
  allProxy: options.allProxy ?? conversation.executor_all_proxy
2001
2681
  });
@@ -2125,7 +2805,7 @@ async function runSend(options) {
2125
2805
  const acpxArgs = buildAcpxPromptArgs({ executor, payload, model: executorModel });
2126
2806
  if (options.background) {
2127
2807
  const outputPath = path.join(path.dirname(logPath), `${executor.kind}-followup-output.log`);
2128
- const outputFd = fs.openSync(outputPath, "a");
2808
+ const outputFd = openPrivateAppendFile(outputPath);
2129
2809
  const child = spawn(acpxPath, acpxArgs, {
2130
2810
  detached: true,
2131
2811
  stdio: ["ignore", outputFd, outputFd],
@@ -2297,11 +2977,17 @@ async function runApprove(options) {
2297
2977
  options,
2298
2978
  conversationId: terminalConversation.conversationId,
2299
2979
  agent: terminalConversation.agent,
2300
- terminalControl: terminalConversation.terminalControl
2980
+ terminalControl: terminalConversation.terminalControl,
2981
+ pid: terminalConversation.pid
2301
2982
  });
2302
2983
  return;
2303
2984
  }
2304
- const { conversation, statePath, logPath } = loadConversationFromOptions(options);
2985
+ const loaded = loadConversationFromOptions(options);
2986
+ const { statePath, logPath } = loaded;
2987
+ const conversation = await migrateLegacyTerminalAgentIdentity({
2988
+ ...loaded,
2989
+ options
2990
+ });
2305
2991
  const nativeTakeover = isRecord(conversation.native_session_takeover)
2306
2992
  ? conversation.native_session_takeover
2307
2993
  : undefined;
@@ -2318,11 +3004,67 @@ async function runApprove(options) {
2318
3004
  const autoApproved = options.autoApproved === true;
2319
3005
  const policyRuleId = stringValue(options.policyRuleId);
2320
3006
  const policyFingerprint = stringValue(options.policyFingerprint);
3007
+ const autoApprovalPolicy = autoApproved
3008
+ ? parseJsonOption(options.autoApprovalPolicyJson, "--auto-approval-policy-json")
3009
+ : undefined;
3010
+ const runtimeIdentity = terminalRuntimeIdentityForConversation(conversation, terminalControl);
3011
+ let executorPolicyDecision;
2321
3012
  const approval = await createTerminalAgentBridge(options).approve(executor.kind, terminalControl, {
2322
3013
  expectedFingerprint,
2323
- scrollbackLines: Number(options.scrollbackLines ?? 120)
3014
+ scrollbackLines: Number(options.scrollbackLines ?? 120),
3015
+ runtime: runtimeIdentity,
3016
+ requiredDecisionMode: autoApproved && executor.kind === "claude"
3017
+ ? "structured"
3018
+ : undefined,
3019
+ authorize: autoApproved
3020
+ ? ({ agent, terminalControl: currentTerminalControl, inspection, fingerprint }) => {
3021
+ if (!autoApprovalPolicy) {
3022
+ return {
3023
+ approved: false,
3024
+ reason: "automatic approval requires an executor-side policy"
3025
+ };
3026
+ }
3027
+ const candidate = {
3028
+ agent,
3029
+ kind: inspection.approval.promptKind ?? "unknown",
3030
+ decisionMode: inspection.approval.approvable
3031
+ ? inspection.approval.action.mode ?? "keys"
3032
+ : undefined,
3033
+ command: inspection.approval.command,
3034
+ cwd: inspection.approval.cwd ?? currentTerminalControl.currentPath,
3035
+ fingerprint: fingerprint ?? "",
3036
+ terminalTarget: currentTerminalControl.target
3037
+ };
3038
+ executorPolicyDecision = evaluateApprovalPolicy({
3039
+ policy: autoApprovalPolicy,
3040
+ candidate
3041
+ });
3042
+ if (executorPolicyDecision.action !== "approve") {
3043
+ return {
3044
+ approved: false,
3045
+ reason: `executor-side auto-approval policy rejected the current request: ${executorPolicyDecision.reason}`
3046
+ };
3047
+ }
3048
+ if (policyRuleId && executorPolicyDecision.ruleId !== policyRuleId) {
3049
+ return {
3050
+ approved: false,
3051
+ reason: "executor-side auto-approval rule changed before execution"
3052
+ };
3053
+ }
3054
+ if (policyFingerprint &&
3055
+ executorPolicyDecision.policyFingerprint !== policyFingerprint) {
3056
+ return {
3057
+ approved: false,
3058
+ reason: "executor-side auto-approval policy changed before execution"
3059
+ };
3060
+ }
3061
+ return { approved: true };
3062
+ }
3063
+ : undefined
2324
3064
  });
2325
3065
  const actualFingerprint = approval.fingerprint;
3066
+ const effectivePolicyRuleId = executorPolicyDecision?.ruleId ?? policyRuleId;
3067
+ const effectivePolicyFingerprint = executorPolicyDecision?.policyFingerprint ?? policyFingerprint;
2326
3068
  if (!approval.approved) {
2327
3069
  if (autoApproved) {
2328
3070
  appendEvent(logPath, {
@@ -2334,8 +3076,8 @@ async function runApprove(options) {
2334
3076
  terminal_control: terminalControl,
2335
3077
  expected_fingerprint: expectedFingerprint,
2336
3078
  actual_fingerprint: actualFingerprint,
2337
- policy_rule_id: policyRuleId,
2338
- policy_fingerprint: policyFingerprint
3079
+ policy_rule_id: effectivePolicyRuleId,
3080
+ policy_fingerprint: effectivePolicyFingerprint
2339
3081
  });
2340
3082
  }
2341
3083
  printJson({
@@ -2358,10 +3100,12 @@ async function runApprove(options) {
2358
3100
  key: approval.key,
2359
3101
  keys: approval.keys,
2360
3102
  label: approval.label,
3103
+ decision_mode: approval.decisionMode,
3104
+ request_id: approval.requestId,
2361
3105
  approval_fingerprint: actualFingerprint,
2362
3106
  auto_approved: autoApproved,
2363
- policy_rule_id: policyRuleId,
2364
- policy_fingerprint: policyFingerprint
3107
+ policy_rule_id: effectivePolicyRuleId,
3108
+ policy_fingerprint: effectivePolicyFingerprint
2365
3109
  });
2366
3110
  if (autoApproved) {
2367
3111
  appendEvent(logPath, {
@@ -2371,8 +3115,8 @@ async function runApprove(options) {
2371
3115
  action: "approved",
2372
3116
  terminal_control: terminalControl,
2373
3117
  approval_fingerprint: actualFingerprint,
2374
- policy_rule_id: policyRuleId,
2375
- policy_fingerprint: policyFingerprint
3118
+ policy_rule_id: effectivePolicyRuleId,
3119
+ policy_fingerprint: effectivePolicyFingerprint
2376
3120
  });
2377
3121
  }
2378
3122
  runtimeLog("info", "terminal_approval_send", {
@@ -2381,10 +3125,12 @@ async function runApprove(options) {
2381
3125
  key: approval.key,
2382
3126
  keys: approval.keys,
2383
3127
  label: approval.label,
3128
+ decision_mode: approval.decisionMode,
3129
+ request_id: approval.requestId,
2384
3130
  approval_fingerprint: actualFingerprint,
2385
3131
  auto_approved: autoApproved,
2386
- policy_rule_id: policyRuleId,
2387
- policy_fingerprint: policyFingerprint
3132
+ policy_rule_id: effectivePolicyRuleId,
3133
+ policy_fingerprint: effectivePolicyFingerprint
2388
3134
  });
2389
3135
  const nativeTakeoverForUpdate = isRecord(conversation.native_session_takeover)
2390
3136
  ? { ...conversation.native_session_takeover }
@@ -2400,6 +3146,7 @@ async function runApprove(options) {
2400
3146
  ...nativeTakeoverForUpdate,
2401
3147
  terminal_bridge_approval: undefined,
2402
3148
  terminal_bridge_approval_resolved_at: approvalResolvedAt,
3149
+ terminal_bridge_monitor_lock_version: TERMINAL_BRIDGE_MONITOR_LOCK_VERSION,
2403
3150
  terminal_bridge_monitor_started_at: approvalResolvedAt,
2404
3151
  terminal_bridge_last_activity_at: approvalResolvedAt,
2405
3152
  terminal_bridge_last_activity_reason: "approval resolved",
@@ -2409,12 +3156,28 @@ async function runApprove(options) {
2409
3156
  terminal_bridge_hard_deadline_at: deadlineAt(stringValue(nativeTakeoverForUpdate.terminal_bridge_started_at) ?? approvalResolvedAt, agentHardTimeoutMinutes)
2410
3157
  };
2411
3158
  delete nextNativeTakeover.terminal_bridge_approval;
2412
- const nextConversation = {
3159
+ let nextConversation = {
2413
3160
  ...conversation,
2414
3161
  status: terminalBridgeEnabled(conversation) ? "waiting_for_agent" : conversation.status,
2415
3162
  native_session_takeover: nextNativeTakeover,
2416
3163
  updated_at: approvalResolvedAt
2417
3164
  };
3165
+ const approvalLeaseDeadlines = [
3166
+ stringValue(nextNativeTakeover.terminal_bridge_inactivity_deadline_at),
3167
+ stringValue(nextNativeTakeover.terminal_bridge_hard_deadline_at)
3168
+ ].filter((value) => Boolean(value));
3169
+ if (approvalLeaseDeadlines.length > 0) {
3170
+ const renewedLease = renewClaudeHookLease(nextConversation, new Date(Math.min(...approvalLeaseDeadlines.map((value) => Date.parse(value)))).toISOString());
3171
+ if (renewedLease) {
3172
+ nextConversation = {
3173
+ ...nextConversation,
3174
+ native_session_takeover: {
3175
+ ...nextNativeTakeover,
3176
+ claude_hook_lease_id: renewedLease.id
3177
+ }
3178
+ };
3179
+ }
3180
+ }
2418
3181
  saveState(statePath, nextConversation);
2419
3182
  const bridgeMonitor = startTerminalBridgeMonitorForConversation({
2420
3183
  conversation: nextConversation,
@@ -2447,16 +3210,23 @@ async function runApprove(options) {
2447
3210
  key: approval.key,
2448
3211
  keys: approval.keys,
2449
3212
  label: approval.label,
3213
+ decision_mode: approval.decisionMode,
3214
+ request_id: approval.requestId,
2450
3215
  approval_fingerprint: actualFingerprint,
2451
3216
  auto_approved: autoApproved,
2452
- policy_rule_id: policyRuleId,
3217
+ policy_rule_id: effectivePolicyRuleId,
2453
3218
  monitor_pid: bridgeMonitor?.pid ?? null
2454
3219
  });
2455
3220
  }
2456
- async function runTerminalConversationApprove({ options, conversationId, agent, terminalControl }) {
3221
+ async function runTerminalConversationApprove({ options, conversationId, agent, terminalControl, pid }) {
2457
3222
  const approval = await createTerminalAgentBridge(options).approve(agent, terminalControl, {
2458
3223
  expectedFingerprint: stringValue(options.expectedApprovalFingerprint),
2459
- scrollbackLines: Number(options.scrollbackLines ?? 120)
3224
+ scrollbackLines: Number(options.scrollbackLines ?? 120),
3225
+ runtime: {
3226
+ pid,
3227
+ cwd: terminalControl.currentPath,
3228
+ terminalTarget: terminalControl.target
3229
+ }
2460
3230
  });
2461
3231
  if (!approval.approved) {
2462
3232
  printJson({
@@ -2476,7 +3246,9 @@ async function runTerminalConversationApprove({ options, conversationId, agent,
2476
3246
  terminal_target: terminalControl.target,
2477
3247
  key: approval.key,
2478
3248
  keys: approval.keys,
2479
- label: approval.label
3249
+ label: approval.label,
3250
+ decision_mode: approval.decisionMode,
3251
+ request_id: approval.requestId
2480
3252
  });
2481
3253
  printJson({
2482
3254
  conversation_id: conversationId,
@@ -2486,12 +3258,32 @@ async function runTerminalConversationApprove({ options, conversationId, agent,
2486
3258
  key: approval.key,
2487
3259
  keys: approval.keys,
2488
3260
  label: approval.label,
2489
- approval_fingerprint: approval.fingerprint
3261
+ approval_fingerprint: approval.fingerprint,
3262
+ decision_mode: approval.decisionMode,
3263
+ request_id: approval.requestId
2490
3264
  });
2491
3265
  }
2492
- async function runTerminalConversationSend({ options, conversationId, agent, messageBody, terminalControl }) {
3266
+ async function runTerminalConversationSend({ options, conversationId, agent, pid, messageBody, terminalControl }) {
2493
3267
  const terminalPayload = terminalSubmissionPayload(String(messageBody));
2494
- await createTerminalAgentBridge(options).send(agent, terminalControl, terminalPayload);
3268
+ const terminalBridge = createTerminalAgentBridge(options);
3269
+ if (agent === "claude") {
3270
+ const status = await terminalBridge.status(agent, terminalControl, {
3271
+ scrollbackLines: Number(options.scrollbackLines ?? 120),
3272
+ runtime: {
3273
+ pid,
3274
+ cwd: terminalControl.currentPath,
3275
+ terminalTarget: terminalControl.target
3276
+ }
3277
+ });
3278
+ assertSafeClaudeTerminalSend(status);
3279
+ }
3280
+ await terminalBridge.send(agent, terminalControl, terminalPayload, {
3281
+ runtime: {
3282
+ pid,
3283
+ cwd: terminalControl.currentPath,
3284
+ terminalTarget: terminalControl.target
3285
+ }
3286
+ });
2495
3287
  runtimeLog("info", "terminal_message_send", {
2496
3288
  conversation_id: conversationId,
2497
3289
  agent,
@@ -2552,19 +3344,62 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
2552
3344
  forkTakeover: undefined
2553
3345
  }))
2554
3346
  : terminalSubmissionPayload(String(message.body ?? ""));
3347
+ const preSendRuntime = {
3348
+ ...terminalRuntimeIdentityForConversation(nextConversation, terminalControl),
3349
+ messageId: message.id
3350
+ };
2555
3351
  let preSendScreenFingerprint;
2556
3352
  if (bridge) {
2557
3353
  try {
2558
3354
  const status = await terminalBridge.status(executor.kind, terminalControl, {
2559
- scrollbackLines: Number(options.scrollbackLines ?? 120)
3355
+ scrollbackLines: Number(options.scrollbackLines ?? 120),
3356
+ runtime: preSendRuntime
2560
3357
  });
3358
+ if (executor.kind === "claude") {
3359
+ assertSafeClaudeTerminalSend(status);
3360
+ }
2561
3361
  preSendScreenFingerprint = terminalBridgeScreenFingerprint(status.screen.excerpt);
2562
3362
  }
2563
- catch {
2564
- // Delivery can still succeed when capture is unavailable; prompt matching remains the fallback boundary.
3363
+ catch (error) {
3364
+ if (executor.kind === "claude") {
3365
+ throw new Error(`refusing to send to Claude Code without a verified idle terminal: ${error instanceof Error ? error.message : String(error)}`);
3366
+ }
3367
+ // Codex delivery can still use its established fallback when capture is unavailable.
3368
+ }
3369
+ }
3370
+ if (bridge && executor.kind === "claude") {
3371
+ releaseClaudeHookLeasesForTerminal({
3372
+ storeDir: storeDirFromOptions(options),
3373
+ terminalControl,
3374
+ replacementConversationId: conversation.conversation_id
3375
+ });
3376
+ releaseClaudeHookLease(nextConversation);
3377
+ }
3378
+ const claudeHookLeaseState = bridge && executor.kind === "claude"
3379
+ ? activateClaudeHookLease({
3380
+ options,
3381
+ conversation: nextConversation,
3382
+ message,
3383
+ terminalControl,
3384
+ expiresAt: deadlineAt(bridgeStartedAt, agentTimeoutMinutes > 0
3385
+ ? Math.min(agentTimeoutMinutes, agentHardTimeoutMinutes)
3386
+ : agentHardTimeoutMinutes)
3387
+ })
3388
+ : undefined;
3389
+ const conversationWithHookLease = claudeHookLeaseState
3390
+ ? withClaudeHookLeaseState(nextConversation, claudeHookLeaseState)
3391
+ : nextConversation;
3392
+ try {
3393
+ await terminalBridge.send(executor.kind, terminalControl, terminalPayload, {
3394
+ runtime: preSendRuntime
3395
+ });
3396
+ }
3397
+ catch (error) {
3398
+ if (claudeHookLeaseState) {
3399
+ releaseClaudeHookLease(conversationWithHookLease);
2565
3400
  }
3401
+ throw error;
2566
3402
  }
2567
- await terminalBridge.send(executor.kind, terminalControl, terminalPayload);
2568
3403
  const supersededConversationIds = bridge
2569
3404
  ? supersedeTerminalBridgeConversations({
2570
3405
  storeDir: storeDirFromOptions(options),
@@ -2592,7 +3427,7 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
2592
3427
  });
2593
3428
  const bridgeConversation = bridge
2594
3429
  ? withTerminalBridgeState({
2595
- conversation: nextConversation,
3430
+ conversation: conversationWithHookLease,
2596
3431
  message,
2597
3432
  startedAt: bridgeStartedAt,
2598
3433
  agentTimeoutMinutes,
@@ -2654,7 +3489,7 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
2654
3489
  function terminalSubmissionPayload(payload) {
2655
3490
  return payload.replace(/[\r\n]+$/u, "");
2656
3491
  }
2657
- function createManagedTerminalConversationFromRawId({ options, conversationId, agent, messageBody, terminalControl }) {
3492
+ function createManagedTerminalConversationFromRawId({ options, conversationId, agent, pid, messageBody, terminalControl }) {
2658
3493
  const workspace = terminalControl.currentPath ?? process.cwd();
2659
3494
  const storeDir = expandHome(options.storeDir ?? options.logDir ?? defaultStoreDir(workspace));
2660
3495
  cleanupIdleConversations(storeDir, options);
@@ -2685,6 +3520,9 @@ function createManagedTerminalConversationFromRawId({ options, conversationId, a
2685
3520
  gatewaySession: options.gatewaySession,
2686
3521
  openclawBin: options.openclawBin ?? resolveOptionalExecutable("openclaw")
2687
3522
  });
3523
+ const claudeAgent = agent === "claude"
3524
+ ? loadClaudeAgentRows(options).find((row) => row.pid === pid)
3525
+ : undefined;
2688
3526
  const attachedConversation = withStoragePaths({
2689
3527
  ...conversation,
2690
3528
  executor,
@@ -2701,6 +3539,8 @@ function createManagedTerminalConversationFromRawId({ options, conversationId, a
2701
3539
  native_session_takeover: {
2702
3540
  agent,
2703
3541
  native_session_id: conversationId,
3542
+ terminal_agent_pid: pid,
3543
+ terminal_agent_session_id: claudeAgent?.sessionId,
2704
3544
  source_cwd: workspace,
2705
3545
  source_title: `Terminal-controlled ${executor.display_name} ${terminalControl.target}`,
2706
3546
  strategy: "terminal_control",
@@ -2805,14 +3645,16 @@ function supersedeTerminalBridgeConversations({ storeDir, terminalControl, repla
2805
3645
  continue;
2806
3646
  }
2807
3647
  const now = new Date().toISOString();
2808
- saveState(candidateStatePath, {
3648
+ const closedConversation = {
2809
3649
  ...current,
2810
3650
  status: "closed",
2811
3651
  closed_at: now,
2812
3652
  close_reason: "terminal bridge superseded by a newer task on the same terminal",
2813
3653
  superseded_by_conversation_id: replacementConversationId,
2814
3654
  updated_at: now
2815
- });
3655
+ };
3656
+ saveState(candidateStatePath, closedConversation);
3657
+ releaseClaudeHookLease(closedConversation);
2816
3658
  appendEvent(logPathForStatePath(candidateStatePath), {
2817
3659
  ts: now,
2818
3660
  conversation_id: current.conversation_id,
@@ -2854,7 +3696,7 @@ function runNativeCodexResumeSend({ options, conversation, nextConversation, sta
2854
3696
  });
2855
3697
  if (options.background) {
2856
3698
  const outputPath = path.join(path.dirname(logPath), `${executor.kind}-native-resume-output.log`);
2857
- const outputFd = fs.openSync(outputPath, "a");
3699
+ const outputFd = openPrivateAppendFile(outputPath);
2858
3700
  const child = spawn(codexPath, codexArgs, {
2859
3701
  detached: true,
2860
3702
  stdio: ["ignore", outputFd, outputFd],
@@ -3073,28 +3915,36 @@ function markTakeoverBootstrapped({ conversation, statePath, logPath, executor,
3073
3915
  return nextConversation;
3074
3916
  }
3075
3917
  function markNativeSessionBootstrapped({ conversation, statePath, logPath, executor }) {
3076
- const nativeTakeover = isRecord(conversation.native_session_takeover)
3077
- ? conversation.native_session_takeover
3078
- : {};
3079
3918
  const now = new Date().toISOString();
3080
- const nextConversation = {
3081
- ...conversation,
3082
- native_session_takeover: {
3083
- ...nativeTakeover,
3084
- needs_bootstrap: false,
3085
- bootstrapped_at: now
3086
- },
3087
- updated_at: now
3088
- };
3089
- saveState(statePath, nextConversation);
3919
+ const releaseLock = acquireFileLock(`${statePath}.lock`);
3920
+ let nextConversation = conversation;
3921
+ try {
3922
+ const current = loadState(statePath);
3923
+ const nativeTakeover = isRecord(current.native_session_takeover)
3924
+ ? current.native_session_takeover
3925
+ : {};
3926
+ nextConversation = {
3927
+ ...current,
3928
+ native_session_takeover: {
3929
+ ...nativeTakeover,
3930
+ needs_bootstrap: false,
3931
+ bootstrapped_at: now
3932
+ },
3933
+ updated_at: now
3934
+ };
3935
+ saveState(statePath, nextConversation);
3936
+ }
3937
+ finally {
3938
+ releaseLock();
3939
+ }
3090
3940
  appendEvent(logPath, {
3091
3941
  ts: now,
3092
- conversation_id: conversation.conversation_id,
3942
+ conversation_id: nextConversation.conversation_id,
3093
3943
  event: "native_session_bootstrapped",
3094
3944
  executor
3095
3945
  });
3096
3946
  runtimeLog("info", "native_session_bootstrapped", {
3097
- conversation_id: conversation.conversation_id,
3947
+ conversation_id: nextConversation.conversation_id,
3098
3948
  agent: executor.kind,
3099
3949
  executor_session: executor.session,
3100
3950
  state_path: statePath
@@ -3102,28 +3952,36 @@ function markNativeSessionBootstrapped({ conversation, statePath, logPath, execu
3102
3952
  return nextConversation;
3103
3953
  }
3104
3954
  function markForkSessionBootstrapped({ conversation, statePath, logPath, executor }) {
3105
- const forkTakeover = isRecord(conversation.fork_context_takeover)
3106
- ? conversation.fork_context_takeover
3107
- : {};
3108
3955
  const now = new Date().toISOString();
3109
- const nextConversation = {
3110
- ...conversation,
3111
- fork_context_takeover: {
3112
- ...forkTakeover,
3113
- needs_bootstrap: false,
3114
- bootstrapped_at: now
3115
- },
3116
- updated_at: now
3117
- };
3118
- saveState(statePath, nextConversation);
3956
+ const releaseLock = acquireFileLock(`${statePath}.lock`);
3957
+ let nextConversation = conversation;
3958
+ try {
3959
+ const current = loadState(statePath);
3960
+ const forkTakeover = isRecord(current.fork_context_takeover)
3961
+ ? current.fork_context_takeover
3962
+ : {};
3963
+ nextConversation = {
3964
+ ...current,
3965
+ fork_context_takeover: {
3966
+ ...forkTakeover,
3967
+ needs_bootstrap: false,
3968
+ bootstrapped_at: now
3969
+ },
3970
+ updated_at: now
3971
+ };
3972
+ saveState(statePath, nextConversation);
3973
+ }
3974
+ finally {
3975
+ releaseLock();
3976
+ }
3119
3977
  appendEvent(logPath, {
3120
3978
  ts: now,
3121
- conversation_id: conversation.conversation_id,
3979
+ conversation_id: nextConversation.conversation_id,
3122
3980
  event: "fork_session_bootstrapped",
3123
3981
  executor
3124
3982
  });
3125
3983
  runtimeLog("info", "fork_session_bootstrapped", {
3126
- conversation_id: conversation.conversation_id,
3984
+ conversation_id: nextConversation.conversation_id,
3127
3985
  agent: executor.kind,
3128
3986
  executor_session: executor.session,
3129
3987
  state_path: statePath
@@ -3210,7 +4068,12 @@ function markConversationNeedsRecovery({ conversation, statePath, logPath, execu
3210
4068
  };
3211
4069
  }
3212
4070
  async function runRenew(options) {
3213
- const { conversation, statePath, logPath } = loadConversationFromOptions(options);
4071
+ const loaded = loadConversationFromOptions(options);
4072
+ const { statePath, logPath } = loaded;
4073
+ const conversation = await migrateLegacyTerminalAgentIdentity({
4074
+ ...loaded,
4075
+ options
4076
+ });
3214
4077
  if (conversation.status === "closed") {
3215
4078
  throw new Error(`cannot renew ${conversation.conversation_id}; conversation is closed`);
3216
4079
  }
@@ -3242,17 +4105,38 @@ async function runRenew(options) {
3242
4105
  throw new Error(`cannot renew ${conversation.conversation_id}; terminal bridge hard lifetime of ${hardTimeoutMinutes} minutes has elapsed`);
3243
4106
  }
3244
4107
  const now = new Date().toISOString();
4108
+ const currentMessageId = stringValue(nativeTakeover?.terminal_bridge_message_id);
4109
+ const hardDeadline = deadlineAt(startedAt ?? now, hardTimeoutMinutes) ??
4110
+ new Date(Date.now() + hardTimeoutMinutes * 60 * 1000).toISOString();
4111
+ const inactivityDeadline = deadlineAt(now, inactivityTimeoutMinutes) ??
4112
+ new Date(Date.now() + inactivityTimeoutMinutes * 60 * 1000).toISOString();
4113
+ const renewedLease = executorForConversation(conversation).kind === "claude" && currentMessageId
4114
+ ? activateClaudeHookLease({
4115
+ options,
4116
+ conversation,
4117
+ message: { id: currentMessageId },
4118
+ terminalControl,
4119
+ expiresAt: new Date(Math.min(Date.parse(hardDeadline), Date.parse(inactivityDeadline))).toISOString()
4120
+ })
4121
+ : undefined;
4122
+ const renewedBase = renewedLease
4123
+ ? withClaudeHookLeaseState(conversation, renewedLease)
4124
+ : conversation;
4125
+ const renewedNativeTakeover = isRecord(renewedBase.native_session_takeover)
4126
+ ? renewedBase.native_session_takeover
4127
+ : nativeTakeover;
3245
4128
  const renewed = {
3246
- ...conversation,
4129
+ ...renewedBase,
3247
4130
  status: "waiting_for_agent",
3248
4131
  native_session_takeover: {
3249
- ...nativeTakeover,
4132
+ ...renewedNativeTakeover,
4133
+ terminal_bridge_monitor_lock_version: TERMINAL_BRIDGE_MONITOR_LOCK_VERSION,
3250
4134
  terminal_bridge_monitor_started_at: now,
3251
4135
  terminal_bridge_last_activity_at: now,
3252
4136
  terminal_bridge_inactivity_timeout_minutes: inactivityTimeoutMinutes,
3253
4137
  terminal_bridge_hard_timeout_minutes: hardTimeoutMinutes,
3254
- terminal_bridge_inactivity_deadline_at: deadlineAt(now, inactivityTimeoutMinutes),
3255
- terminal_bridge_hard_deadline_at: deadlineAt(startedAt ?? now, hardTimeoutMinutes),
4138
+ terminal_bridge_inactivity_deadline_at: inactivityDeadline,
4139
+ terminal_bridge_hard_deadline_at: hardDeadline,
3256
4140
  terminal_bridge_renewed_at: now
3257
4141
  },
3258
4142
  updated_at: now
@@ -3309,6 +4193,318 @@ async function runRenew(options) {
3309
4193
  monitor_pid: monitor?.pid ?? null
3310
4194
  });
3311
4195
  }
4196
+ async function runReconcileMonitors(options) {
4197
+ const storeDir = storeDirFromOptions(options);
4198
+ const conversations = listConversations(storeDir);
4199
+ const items = [];
4200
+ let ignored = 0;
4201
+ let launched = 0;
4202
+ let alreadyRunning = 0;
4203
+ let skipped = 0;
4204
+ let errors = 0;
4205
+ for (const listedConversation of conversations) {
4206
+ const listedNativeTakeover = isRecord(listedConversation.native_session_takeover)
4207
+ ? listedConversation.native_session_takeover
4208
+ : undefined;
4209
+ if (listedNativeTakeover?.terminal_bridge !== true) {
4210
+ ignored += 1;
4211
+ continue;
4212
+ }
4213
+ const statePath = expandHome(stringValue(listedConversation.state_path) ??
4214
+ statePathForConversationId(listedConversation.conversation_id, storeDir));
4215
+ const logPath = expandHome(stringValue(listedConversation.event_log_path) ??
4216
+ logPathForStatePath(statePath));
4217
+ try {
4218
+ const initialConversation = await migrateLegacyTerminalAgentIdentity({
4219
+ conversation: loadState(statePath),
4220
+ statePath,
4221
+ logPath,
4222
+ options
4223
+ });
4224
+ const initialEligibility = terminalBridgeReconciliationEligibility(initialConversation);
4225
+ if (!initialEligibility.eligible) {
4226
+ skipped += 1;
4227
+ items.push({
4228
+ conversation_id: initialConversation.conversation_id,
4229
+ status: "skipped",
4230
+ reason: initialEligibility.reason
4231
+ });
4232
+ continue;
4233
+ }
4234
+ const activeOwner = activeTerminalBridgeMonitorOwner(statePath, initialEligibility.terminalMessageId);
4235
+ if (activeOwner) {
4236
+ alreadyRunning += 1;
4237
+ items.push({
4238
+ conversation_id: initialConversation.conversation_id,
4239
+ status: "already_running",
4240
+ reason: "monitor_lock_owner_alive",
4241
+ monitor_owner_pid: activeOwner.ownerPid ?? null
4242
+ });
4243
+ continue;
4244
+ }
4245
+ const monitorLockVersion = Number(initialEligibility.nativeTakeover.terminal_bridge_monitor_lock_version);
4246
+ if (monitorLockVersion !== TERMINAL_BRIDGE_MONITOR_LOCK_VERSION) {
4247
+ if (Number.isFinite(monitorLockVersion)) {
4248
+ skipped += 1;
4249
+ items.push({
4250
+ conversation_id: initialConversation.conversation_id,
4251
+ status: "skipped",
4252
+ reason: "monitor_lock_version_unsupported",
4253
+ monitor_lock_version: monitorLockVersion
4254
+ });
4255
+ continue;
4256
+ }
4257
+ const legacyLaunchPid = latestTerminalBridgeMonitorLaunchPid(logPath);
4258
+ if (legacyLaunchPid === undefined) {
4259
+ skipped += 1;
4260
+ items.push({
4261
+ conversation_id: initialConversation.conversation_id,
4262
+ status: "skipped",
4263
+ reason: "legacy_monitor_ownership_unknown"
4264
+ });
4265
+ continue;
4266
+ }
4267
+ if (isProcessAlive(legacyLaunchPid)) {
4268
+ alreadyRunning += 1;
4269
+ items.push({
4270
+ conversation_id: initialConversation.conversation_id,
4271
+ status: "already_running",
4272
+ reason: "legacy_monitor_launch_pid_alive",
4273
+ monitor_owner_pid: legacyLaunchPid
4274
+ });
4275
+ continue;
4276
+ }
4277
+ }
4278
+ const prepared = prepareTerminalBridgeMonitorReconciliation({
4279
+ statePath,
4280
+ expectedMessageId: initialEligibility.terminalMessageId
4281
+ });
4282
+ if (!prepared.prepared) {
4283
+ if (prepared.alreadyRunning) {
4284
+ alreadyRunning += 1;
4285
+ items.push({
4286
+ conversation_id: initialConversation.conversation_id,
4287
+ status: "already_running",
4288
+ reason: prepared.reason,
4289
+ monitor_owner_pid: prepared.ownerPid ?? null
4290
+ });
4291
+ }
4292
+ else {
4293
+ skipped += 1;
4294
+ items.push({
4295
+ conversation_id: initialConversation.conversation_id,
4296
+ status: "skipped",
4297
+ reason: prepared.reason
4298
+ });
4299
+ }
4300
+ continue;
4301
+ }
4302
+ const monitor = startTerminalBridgeMonitorForConversation({
4303
+ conversation: prepared.conversation,
4304
+ statePath,
4305
+ logPath,
4306
+ options
4307
+ });
4308
+ if (!monitor) {
4309
+ skipped += 1;
4310
+ items.push({
4311
+ conversation_id: prepared.conversation.conversation_id,
4312
+ status: "skipped",
4313
+ reason: "terminal_bridge_monitor_launch_disabled"
4314
+ });
4315
+ continue;
4316
+ }
4317
+ const launchedAt = new Date().toISOString();
4318
+ appendEvent(logPath, {
4319
+ ts: launchedAt,
4320
+ conversation_id: prepared.conversation.conversation_id,
4321
+ event: "terminal_bridge_monitor_launch",
4322
+ pid: monitor.pid ?? null,
4323
+ terminal_control: prepared.terminalControl,
4324
+ reason: "startup_reconciliation",
4325
+ agent_timeout_minutes: prepared.inactivityTimeoutMinutes,
4326
+ agent_hard_timeout_minutes: prepared.hardTimeoutMinutes
4327
+ });
4328
+ runtimeLog("info", "terminal_bridge_monitor_reconciled", {
4329
+ conversation_id: prepared.conversation.conversation_id,
4330
+ monitor_pid: monitor.pid ?? null,
4331
+ terminal_target: prepared.terminalControl.target
4332
+ });
4333
+ launched += 1;
4334
+ items.push({
4335
+ conversation_id: prepared.conversation.conversation_id,
4336
+ status: "launched",
4337
+ reason: "startup_reconciliation",
4338
+ monitor_pid: monitor.pid ?? null
4339
+ });
4340
+ }
4341
+ catch (error) {
4342
+ errors += 1;
4343
+ items.push({
4344
+ conversation_id: listedConversation.conversation_id,
4345
+ status: "error",
4346
+ reason: error instanceof Error ? error.message : String(error)
4347
+ });
4348
+ }
4349
+ }
4350
+ printJson({
4351
+ reconciled: true,
4352
+ store_dir: storeDir,
4353
+ checked: conversations.length,
4354
+ ignored,
4355
+ launched,
4356
+ already_running: alreadyRunning,
4357
+ skipped,
4358
+ errors,
4359
+ items
4360
+ });
4361
+ }
4362
+ function terminalBridgeReconciliationEligibility(conversation) {
4363
+ const nativeTakeover = isRecord(conversation?.native_session_takeover)
4364
+ ? conversation.native_session_takeover
4365
+ : undefined;
4366
+ if (nativeTakeover?.terminal_bridge !== true) {
4367
+ return { eligible: false, reason: "not_terminal_bridge" };
4368
+ }
4369
+ if (!conversation.gateway_method) {
4370
+ return { eligible: false, reason: "gateway_method_missing" };
4371
+ }
4372
+ if (!stringValue(conversation.gateway_session) && !stringValue(conversation.openclaw_session)) {
4373
+ return { eligible: false, reason: "gateway_session_missing" };
4374
+ }
4375
+ if (!isWaitingForAgent(conversation.status)) {
4376
+ return {
4377
+ eligible: false,
4378
+ reason: `conversation_status_${String(conversation.status ?? "missing")}`
4379
+ };
4380
+ }
4381
+ const terminalMessageId = stringValue(nativeTakeover.terminal_bridge_message_id);
4382
+ const terminalControl = terminalControlFromTakeover(nativeTakeover);
4383
+ if (!terminalMessageId || !terminalControl) {
4384
+ return { eligible: false, reason: "terminal_bridge_identity_missing" };
4385
+ }
4386
+ const runtime = terminalRuntimeIdentityForConversation(conversation, terminalControl);
4387
+ if (!Number.isInteger(runtime.pid) || Number(runtime.pid) <= 0 || !stringValue(runtime.cwd)) {
4388
+ return { eligible: false, reason: "terminal_agent_identity_missing" };
4389
+ }
4390
+ const inactivityTimeoutMinutes = Number(nativeTakeover.terminal_bridge_inactivity_timeout_minutes);
4391
+ const hardTimeoutMinutes = Number(nativeTakeover.terminal_bridge_hard_timeout_minutes);
4392
+ const startedAtMs = validTimestampMs(nativeTakeover.terminal_bridge_started_at);
4393
+ const lastActivityAtMs = validTimestampMs(nativeTakeover.terminal_bridge_last_activity_at);
4394
+ const inactivityDeadlineAtMs = validTimestampMs(nativeTakeover.terminal_bridge_inactivity_deadline_at);
4395
+ const hardDeadlineAtMs = validTimestampMs(nativeTakeover.terminal_bridge_hard_deadline_at);
4396
+ if (!Number.isFinite(inactivityTimeoutMinutes) ||
4397
+ inactivityTimeoutMinutes <= 0 ||
4398
+ !Number.isFinite(hardTimeoutMinutes) ||
4399
+ hardTimeoutMinutes <= 0 ||
4400
+ startedAtMs === undefined ||
4401
+ lastActivityAtMs === undefined ||
4402
+ inactivityDeadlineAtMs === undefined ||
4403
+ hardDeadlineAtMs === undefined) {
4404
+ return { eligible: false, reason: "terminal_bridge_deadline_metadata_missing" };
4405
+ }
4406
+ return {
4407
+ eligible: true,
4408
+ nativeTakeover,
4409
+ terminalMessageId,
4410
+ terminalControl,
4411
+ runtime,
4412
+ inactivityTimeoutMinutes,
4413
+ hardTimeoutMinutes,
4414
+ inactivityDeadlineAtMs,
4415
+ hardDeadlineAtMs
4416
+ };
4417
+ }
4418
+ function latestTerminalBridgeMonitorLaunchPid(logPath) {
4419
+ let events;
4420
+ try {
4421
+ events = readExistingEvents(logPath);
4422
+ }
4423
+ catch {
4424
+ return undefined;
4425
+ }
4426
+ const launch = [...events].reverse().find((event) => event.event === "terminal_bridge_monitor_launch");
4427
+ const pid = Number(launch?.pid);
4428
+ return Number.isSafeInteger(pid) && pid > 1 ? pid : undefined;
4429
+ }
4430
+ function prepareTerminalBridgeMonitorReconciliation({ statePath, expectedMessageId }) {
4431
+ const releaseStateLock = acquireFileLock(`${statePath}.lock`);
4432
+ try {
4433
+ const conversation = loadState(statePath);
4434
+ const eligibility = terminalBridgeReconciliationEligibility(conversation);
4435
+ if (!eligibility.eligible) {
4436
+ return {
4437
+ prepared: false,
4438
+ alreadyRunning: false,
4439
+ reason: eligibility.reason
4440
+ };
4441
+ }
4442
+ if (eligibility.terminalMessageId !== expectedMessageId) {
4443
+ return {
4444
+ prepared: false,
4445
+ alreadyRunning: false,
4446
+ reason: "terminal_bridge_task_replaced"
4447
+ };
4448
+ }
4449
+ const activeOwner = activeTerminalBridgeMonitorOwner(statePath, eligibility.terminalMessageId);
4450
+ if (activeOwner) {
4451
+ return {
4452
+ prepared: false,
4453
+ alreadyRunning: true,
4454
+ reason: "monitor_lock_owner_alive",
4455
+ ownerPid: activeOwner.ownerPid
4456
+ };
4457
+ }
4458
+ let renewedLease;
4459
+ if (executorForConversation(conversation).kind === "claude") {
4460
+ const leaseDeadlineAtMs = Math.min(eligibility.inactivityDeadlineAtMs, eligibility.hardDeadlineAtMs);
4461
+ if (leaseDeadlineAtMs <= Date.now()) {
4462
+ return {
4463
+ prepared: false,
4464
+ alreadyRunning: false,
4465
+ reason: "claude_hook_lease_deadline_elapsed"
4466
+ };
4467
+ }
4468
+ renewedLease = renewClaudeHookLease(conversation, new Date(leaseDeadlineAtMs).toISOString());
4469
+ if (!renewedLease) {
4470
+ return {
4471
+ prepared: false,
4472
+ alreadyRunning: false,
4473
+ reason: "claude_hook_lease_refresh_failed"
4474
+ };
4475
+ }
4476
+ }
4477
+ const nextNativeTakeover = {
4478
+ ...eligibility.nativeTakeover,
4479
+ terminal_bridge_monitor_lock_version: TERMINAL_BRIDGE_MONITOR_LOCK_VERSION,
4480
+ ...(renewedLease ? { claude_hook_lease_id: renewedLease.id } : {})
4481
+ };
4482
+ const needsSave = eligibility.nativeTakeover.terminal_bridge_monitor_lock_version !==
4483
+ TERMINAL_BRIDGE_MONITOR_LOCK_VERSION ||
4484
+ (renewedLease !== undefined &&
4485
+ eligibility.nativeTakeover.claude_hook_lease_id !== renewedLease.id);
4486
+ const preparedConversation = needsSave
4487
+ ? {
4488
+ ...conversation,
4489
+ native_session_takeover: nextNativeTakeover,
4490
+ updated_at: new Date().toISOString()
4491
+ }
4492
+ : conversation;
4493
+ if (needsSave) {
4494
+ saveState(statePath, preparedConversation);
4495
+ }
4496
+ return {
4497
+ prepared: true,
4498
+ conversation: preparedConversation,
4499
+ terminalControl: eligibility.terminalControl,
4500
+ inactivityTimeoutMinutes: eligibility.inactivityTimeoutMinutes,
4501
+ hardTimeoutMinutes: eligibility.hardTimeoutMinutes
4502
+ };
4503
+ }
4504
+ finally {
4505
+ releaseStateLock();
4506
+ }
4507
+ }
3312
4508
  function positiveMinutes(value, optionName) {
3313
4509
  const parsed = Number(value);
3314
4510
  if (!Number.isFinite(parsed) || parsed <= 0) {
@@ -3324,11 +4520,17 @@ async function runCancel(options) {
3324
4520
  options,
3325
4521
  conversationId: terminalConversation.conversationId,
3326
4522
  agent: terminalConversation.agent,
3327
- terminalControl: terminalConversation.terminalControl
4523
+ terminalControl: terminalConversation.terminalControl,
4524
+ pid: terminalConversation.pid
3328
4525
  });
3329
4526
  return;
3330
4527
  }
3331
- const { conversation, statePath, logPath } = loadConversationFromOptions(options);
4528
+ const loaded = loadConversationFromOptions(options);
4529
+ const { statePath, logPath } = loaded;
4530
+ const conversation = await migrateLegacyTerminalAgentIdentity({
4531
+ ...loaded,
4532
+ options
4533
+ });
3332
4534
  if (["closed", "cancelled"].includes(conversation.status)) {
3333
4535
  throw new Error(`cannot cancel ${conversation.conversation_id}; conversation is ${conversation.status}`);
3334
4536
  }
@@ -3405,14 +4607,23 @@ async function runCancel(options) {
3405
4607
  budget: budgetAction(nextConversation)
3406
4608
  });
3407
4609
  }
3408
- async function runTerminalConversationCancel({ options, conversationId, agent, terminalControl }) {
3409
- const cancellation = await createTerminalAgentBridge(options).cancel(agent, terminalControl);
4610
+ async function runTerminalConversationCancel({ options, conversationId, agent, terminalControl, pid }) {
4611
+ const cancellation = await createTerminalAgentBridge(options).cancel(agent, terminalControl, {
4612
+ runtime: {
4613
+ pid,
4614
+ cwd: terminalControl.currentPath,
4615
+ terminalTarget: terminalControl.target
4616
+ },
4617
+ scrollbackLines: Number(options.scrollbackLines ?? 120)
4618
+ });
3410
4619
  runtimeLog("info", "terminal_cancel_requested", {
3411
4620
  conversation_id: conversationId,
3412
4621
  agent,
3413
4622
  terminal_target: terminalControl.target,
3414
4623
  key: cancellation.key,
3415
4624
  keys: cancellation.keys,
4625
+ denied_approval: cancellation.deniedApproval,
4626
+ request_id: cancellation.requestId,
3416
4627
  cancel_requested: cancellation.cancelRequested,
3417
4628
  reason: cancellation.reason
3418
4629
  });
@@ -3423,11 +4634,16 @@ async function runTerminalConversationCancel({ options, conversationId, agent, t
3423
4634
  reason: cancellation.reason,
3424
4635
  terminal_control: terminalControl,
3425
4636
  key: cancellation.key,
3426
- keys: cancellation.keys
4637
+ keys: cancellation.keys,
4638
+ denied_approval: cancellation.deniedApproval,
4639
+ request_id: cancellation.requestId
3427
4640
  });
3428
4641
  }
3429
4642
  async function runTerminalControlCancel({ options, conversation, statePath, logPath, agent, terminalControl }) {
3430
- const cancellation = await createTerminalAgentBridge(options).cancel(agent, terminalControl);
4643
+ const cancellation = await createTerminalAgentBridge(options).cancel(agent, terminalControl, {
4644
+ runtime: terminalRuntimeIdentityForConversation(conversation, terminalControl),
4645
+ scrollbackLines: Number(options.scrollbackLines ?? 120)
4646
+ });
3431
4647
  if (!cancellation.cancelRequested) {
3432
4648
  printJson({
3433
4649
  conversation,
@@ -3445,27 +4661,36 @@ async function runTerminalControlCancel({ options, conversation, statePath, logP
3445
4661
  event: "terminal_cancel_requested",
3446
4662
  terminal_control: terminalControl,
3447
4663
  key: cancellation.key,
3448
- keys: cancellation.keys
4664
+ keys: cancellation.keys,
4665
+ denied_approval: cancellation.deniedApproval,
4666
+ request_id: cancellation.requestId
3449
4667
  });
3450
4668
  runtimeLog("info", "terminal_cancel_requested", {
3451
4669
  conversation_id: conversation.conversation_id,
3452
4670
  agent,
3453
4671
  terminal_target: terminalControl.target,
3454
4672
  key: cancellation.key,
3455
- keys: cancellation.keys
4673
+ keys: cancellation.keys,
4674
+ denied_approval: cancellation.deniedApproval,
4675
+ request_id: cancellation.requestId
3456
4676
  });
3457
4677
  const nextConversation = {
3458
4678
  ...conversation,
4679
+ status: "cancelled",
4680
+ cancelled_at: now,
3459
4681
  terminal_cancel_requested_at: now,
3460
4682
  updated_at: now
3461
4683
  };
3462
4684
  saveState(statePath, nextConversation);
4685
+ releaseClaudeHookLease(nextConversation);
3463
4686
  printJson({
3464
4687
  conversation: nextConversation,
3465
4688
  cancel_requested: true,
3466
4689
  terminal_control: terminalControl,
3467
4690
  key: cancellation.key,
3468
4691
  keys: cancellation.keys,
4692
+ denied_approval: cancellation.deniedApproval,
4693
+ request_id: cancellation.requestId,
3469
4694
  budget: budgetAction(nextConversation)
3470
4695
  });
3471
4696
  }
@@ -3536,7 +4761,7 @@ function runRecoveryDecision(options) {
3536
4761
  const acpxArgs = buildAcpxPromptArgs({ executor, payload, model: executorModel });
3537
4762
  if (options.background) {
3538
4763
  const outputPath = path.join(path.dirname(logPath), `${executor.kind}-${options.mode}-output.log`);
3539
- const outputFd = fs.openSync(outputPath, "a");
4764
+ const outputFd = openPrivateAppendFile(outputPath);
3540
4765
  const child = spawn(acpxPath, acpxArgs, {
3541
4766
  detached: true,
3542
4767
  stdio: ["ignore", outputFd, outputFd],
@@ -3647,6 +4872,7 @@ function runClose(options) {
3647
4872
  updated_at: now
3648
4873
  };
3649
4874
  saveState(statePath, closed);
4875
+ releaseClaudeHookLease(closed);
3650
4876
  appendEvent(logPath, {
3651
4877
  ts: now,
3652
4878
  conversation_id: conversation.conversation_id,
@@ -3812,7 +5038,7 @@ function startCallbackRetryMonitor({ statePath }) {
3812
5038
  detached: true,
3813
5039
  stdio: "ignore",
3814
5040
  cwd: process.cwd(),
3815
- env: process.env
5041
+ env: environmentWithoutGatewayTokens()
3816
5042
  });
3817
5043
  child.unref();
3818
5044
  return child;
@@ -3868,10 +5094,46 @@ function runCallbackRetryMonitor(options) {
3868
5094
  }
3869
5095
  }
3870
5096
  async function runTerminalBridgeMonitor(options) {
5097
+ const statePath = expandHome(required(options.state, "--state is required"));
5098
+ const conversation = loadState(statePath);
5099
+ const nativeTakeover = isRecord(conversation.native_session_takeover)
5100
+ ? conversation.native_session_takeover
5101
+ : undefined;
5102
+ const terminalMessageId = stringValue(nativeTakeover?.terminal_bridge_message_id) ?? "missing-message-id";
5103
+ const monitorLock = tryAcquireTerminalBridgeMonitorLock(statePath, terminalMessageId);
5104
+ if (!monitorLock.acquired) {
5105
+ runtimeLog("info", "terminal_bridge_monitor_already_running", {
5106
+ conversation_id: conversation.conversation_id,
5107
+ terminal_bridge_message_id: terminalMessageId,
5108
+ monitor_owner_pid: monitorLock.ownerPid
5109
+ });
5110
+ printJson({
5111
+ conversation,
5112
+ monitored: false,
5113
+ terminal_bridge: true,
5114
+ already_running: true,
5115
+ reason: "terminal_bridge_monitor_already_running",
5116
+ monitor_owner_pid: monitorLock.ownerPid ?? null
5117
+ });
5118
+ return;
5119
+ }
5120
+ try {
5121
+ await runTerminalBridgeMonitorWithLock(options);
5122
+ }
5123
+ finally {
5124
+ monitorLock.release();
5125
+ }
5126
+ }
5127
+ async function runTerminalBridgeMonitorWithLock(options) {
3871
5128
  const statePath = expandHome(required(options.state, "--state is required"));
3872
5129
  const logPath = expandHome(options.log ?? logPathForStatePath(statePath));
3873
5130
  const pollIntervalMs = Math.max(50, Number(options.pollIntervalMs ?? DEFAULT_MONITOR_POLL_INTERVAL_MS));
3874
- let conversation = loadState(statePath);
5131
+ let conversation = await migrateLegacyTerminalAgentIdentity({
5132
+ conversation: loadState(statePath),
5133
+ statePath,
5134
+ logPath,
5135
+ options
5136
+ });
3875
5137
  const initialNativeTakeover = isRecord(conversation.native_session_takeover)
3876
5138
  ? conversation.native_session_takeover
3877
5139
  : undefined;
@@ -3967,38 +5229,134 @@ async function runTerminalBridgeMonitor(options) {
3967
5229
  }
3968
5230
  });
3969
5231
  printJson({
3970
- conversation: stalledConversation,
5232
+ conversation: stalledConversation,
5233
+ monitored: true,
5234
+ terminal_bridge: true,
5235
+ stalled: true,
5236
+ reason: stalledConversation?.stalled_reason
5237
+ });
5238
+ return;
5239
+ }
5240
+ const requestText = String(nativeTakeover?.["terminal_bridge_request_text"] ?? conversation.user_request ?? "");
5241
+ const startedAt = stringValue(nativeTakeover?.["terminal_bridge_started_at"]);
5242
+ const terminalRuntime = terminalRuntimeIdentityForConversation(conversation, terminalControl);
5243
+ const screenChangedSinceSend = preSendScreenFingerprint !== undefined &&
5244
+ previousScreenFingerprint !== undefined &&
5245
+ previousScreenFingerprint !== preSendScreenFingerprint;
5246
+ const poll = await terminalBridge.monitorPoll({
5247
+ agent: executor.kind,
5248
+ terminalControl,
5249
+ screenOptions: {
5250
+ scrollbackLines: Number(options.scrollbackLines ?? 120),
5251
+ requestText,
5252
+ screenChangedSinceSend,
5253
+ runtime: terminalRuntime
5254
+ },
5255
+ durableRequest: {
5256
+ sessionId: terminalRuntime.sessionId,
5257
+ cwd: stringValue(nativeTakeover?.["source_cwd"]),
5258
+ requestText,
5259
+ requestHash: stringValue(nativeTakeover?.["terminal_bridge_request_hash"]),
5260
+ startedAt,
5261
+ context: {
5262
+ conversation,
5263
+ nativeTakeover,
5264
+ ...terminalRuntime
5265
+ }
5266
+ }
5267
+ });
5268
+ const terminalStatus = poll.status;
5269
+ const approval = terminalStatus.approval_state;
5270
+ if (isRecord(approval) && approval.blocked === true && approval.approvable !== true) {
5271
+ const approvalReason = stringValue(approval.reason) ??
5272
+ "Claude Code permission state cannot be safely resolved through AKK";
5273
+ appendEvent(logPath, {
5274
+ ts: new Date().toISOString(),
5275
+ conversation_id: conversation.conversation_id,
5276
+ event: "terminal_bridge_approval_not_approvable",
5277
+ terminal_control: terminalControl,
5278
+ activity_state: terminalStatus.activity_state,
5279
+ reason: approvalReason
5280
+ });
5281
+ if (/waiting for hook consumption/iu.test(approvalReason)) {
5282
+ sleepSync(pollIntervalMs);
5283
+ continue;
5284
+ }
5285
+ const fingerprint = terminalBridgeApprovalFingerprint({ terminalControl, terminalStatus });
5286
+ const notification = recordTerminalBridgeApprovalNotification({
5287
+ statePath,
5288
+ logPath,
5289
+ terminalControl,
5290
+ terminalStatus,
5291
+ fingerprint
5292
+ });
5293
+ if (notification.duplicate) {
5294
+ printJson({
5295
+ conversation: notification.conversation,
5296
+ monitored: true,
5297
+ terminal_bridge: true,
5298
+ awaiting_approval: true,
5299
+ approvable: false,
5300
+ duplicate: true,
5301
+ reason: approvalReason,
5302
+ terminal_control: terminalControl,
5303
+ terminal_status: terminalStatus
5304
+ });
5305
+ return;
5306
+ }
5307
+ const callbackMessage = createMessage({
5308
+ conversation: notification.conversation,
5309
+ from: executor.actor,
5310
+ to: "openclaw",
5311
+ type: "blocked",
5312
+ requiresResponse: true,
5313
+ body: [
5314
+ `${executor.display_name} is waiting at a permission state that AKK cannot safely approve.`,
5315
+ approvalReason,
5316
+ "",
5317
+ `Conversation: ${notification.conversation.conversation_id}`,
5318
+ `Terminal: ${terminalControl.target}`,
5319
+ "Review and resolve this dialog in the terminal manually. AKK intentionally sends no key when the request identity cannot be revalidated."
5320
+ ].join("\n"),
5321
+ metadata: {
5322
+ source: "terminal_bridge",
5323
+ reason: "approval_not_approvable",
5324
+ terminal_control: terminalControl,
5325
+ terminal_status: terminalStatus,
5326
+ approval_fingerprint: fingerprint
5327
+ }
5328
+ });
5329
+ if (notification.conversation.gateway_method) {
5330
+ runLockedCallback({
5331
+ ...options,
5332
+ statePath,
5333
+ log: logPath,
5334
+ messageJson: JSON.stringify(callbackMessage),
5335
+ gatewayMethod: notification.conversation.gateway_method,
5336
+ gatewaySession: notification.conversation.gateway_session,
5337
+ openclawSession: notification.conversation.openclaw_session,
5338
+ openclawBin: notification.conversation.openclaw_bin,
5339
+ gatewayUrl: stringValue(notification.conversation.gateway_token)
5340
+ ? notification.conversation.gateway_url
5341
+ : undefined,
5342
+ token: stringValue(notification.conversation.gateway_token)
5343
+ });
5344
+ return;
5345
+ }
5346
+ printJson({
5347
+ conversation: notification.conversation,
3971
5348
  monitored: true,
3972
5349
  terminal_bridge: true,
3973
- stalled: true,
3974
- reason: stalledConversation?.stalled_reason
5350
+ awaiting_approval: true,
5351
+ approvable: false,
5352
+ delivered: false,
5353
+ message: callbackMessage,
5354
+ reason: "gateway_method_missing",
5355
+ terminal_control: terminalControl,
5356
+ terminal_status: terminalStatus
3975
5357
  });
3976
5358
  return;
3977
5359
  }
3978
- const requestText = String(nativeTakeover?.["terminal_bridge_request_text"] ?? conversation.user_request ?? "");
3979
- const startedAt = stringValue(nativeTakeover?.["terminal_bridge_started_at"]);
3980
- const screenChangedSinceSend = preSendScreenFingerprint !== undefined &&
3981
- previousScreenFingerprint !== undefined &&
3982
- previousScreenFingerprint !== preSendScreenFingerprint;
3983
- const poll = await terminalBridge.monitorPoll({
3984
- agent: executor.kind,
3985
- terminalControl,
3986
- screenOptions: {
3987
- scrollbackLines: Number(options.scrollbackLines ?? 120),
3988
- requestText,
3989
- screenChangedSinceSend
3990
- },
3991
- durableRequest: {
3992
- sessionId: stringValue(nativeTakeover?.["native_session_id"]),
3993
- cwd: stringValue(nativeTakeover?.["source_cwd"]),
3994
- requestText,
3995
- requestHash: stringValue(nativeTakeover?.["terminal_bridge_request_hash"]),
3996
- startedAt,
3997
- context: { conversation, nativeTakeover }
3998
- }
3999
- });
4000
- const terminalStatus = poll.status;
4001
- const approval = terminalStatus.approval_state;
4002
5360
  if (isRecord(approval) && approval.blocked === true) {
4003
5361
  const fingerprint = terminalBridgeApprovalFingerprint({ terminalControl, terminalStatus });
4004
5362
  appendEvent(logPath, {
@@ -4150,55 +5508,98 @@ async function runTerminalBridgeMonitor(options) {
4150
5508
  : undefined;
4151
5509
  const completionStable = completionFingerprint !== undefined && completionFingerprint === idleCompletionFingerprint;
4152
5510
  idleCompletionFingerprint = completionFingerprint;
4153
- if (completion && completionStable) {
4154
- appendEvent(logPath, {
4155
- ts: new Date().toISOString(),
4156
- conversation_id: conversation.conversation_id,
4157
- event: "terminal_bridge_completion_detected",
4158
- terminal_control: terminalControl,
4159
- match: completionMatch,
4160
- completion_source: completion.source,
4161
- completion_id: completion.id,
4162
- terminal_session: completionMetadata.session,
4163
- context_match: completionMetadata.context_match,
4164
- assistant_timestamp: completion?.timestamp,
4165
- rollout_turn_id: completion.source === "durable" ? completion.id : undefined,
4166
- terminal_bridge_message_id: currentMessageId
5511
+ if (completion && completionStable && completionFingerprint) {
5512
+ const completionOutcome = completion.outcome === "failure" ? "failure" : "success";
5513
+ const callbackMessageId = deterministicTerminalCallbackMessageId({
5514
+ conversationId: conversation.conversation_id,
5515
+ terminalMessageId: currentMessageId,
5516
+ completionFingerprint,
5517
+ outcome: completionOutcome
4167
5518
  });
4168
- const callbackMessage = createMessage({
4169
- conversation,
4170
- from: executor.actor,
4171
- to: "openclaw",
4172
- type: "done",
4173
- requiresResponse: false,
4174
- body: completion?.text ?? "",
4175
- metadata: {
4176
- source: "terminal_bridge",
5519
+ const claim = claimTerminalBridgeCompletion({
5520
+ statePath,
5521
+ logPath,
5522
+ terminalMessageId: currentMessageId,
5523
+ completionFingerprint,
5524
+ completionId: completion.id,
5525
+ callbackMessageId,
5526
+ outcome: completionOutcome
5527
+ });
5528
+ if (!claim.claimed) {
5529
+ printJson({
5530
+ conversation: claim.conversation,
5531
+ monitored: true,
5532
+ terminal_bridge: true,
5533
+ completed: false,
5534
+ duplicate: true,
5535
+ reason: claim.reason
5536
+ });
5537
+ return;
5538
+ }
5539
+ try {
5540
+ conversation = claim.conversation;
5541
+ appendEvent(logPath, {
5542
+ ts: new Date().toISOString(),
5543
+ conversation_id: conversation.conversation_id,
5544
+ event: "terminal_bridge_completion_detected",
4177
5545
  terminal_control: terminalControl,
4178
- ...completionMetadata,
5546
+ match: completionMatch,
4179
5547
  completion_source: completion.source,
5548
+ completion_outcome: completionOutcome,
4180
5549
  completion_id: completion.id,
4181
5550
  terminal_session: completionMetadata.session,
4182
- confidence: completion.confidence,
4183
- match: completionMatch,
5551
+ context_match: completionMetadata.context_match,
4184
5552
  assistant_timestamp: completion?.timestamp,
4185
5553
  rollout_turn_id: completion.source === "durable" ? completion.id : undefined,
4186
- terminal_bridge_message_id: currentMessageId
4187
- }
4188
- });
4189
- runLockedCallback({
4190
- ...options,
4191
- statePath,
4192
- log: logPath,
4193
- closeTerminalBridgeOnDone: true,
4194
- messageJson: JSON.stringify(callbackMessage),
4195
- gatewayMethod: conversation.gateway_method,
4196
- gatewaySession: conversation.gateway_session,
4197
- openclawSession: conversation.openclaw_session,
4198
- openclawBin: conversation.openclaw_bin,
4199
- gatewayUrl: stringValue(conversation.gateway_token) ? conversation.gateway_url : undefined,
4200
- token: stringValue(conversation.gateway_token)
4201
- });
5554
+ terminal_bridge_message_id: currentMessageId,
5555
+ callback_message_id: callbackMessageId
5556
+ });
5557
+ const callbackMessage = {
5558
+ ...createMessage({
5559
+ conversation,
5560
+ from: executor.actor,
5561
+ to: "openclaw",
5562
+ type: completionOutcome === "failure" ? "error" : "done",
5563
+ requiresResponse: false,
5564
+ body: completion.text,
5565
+ metadata: {
5566
+ source: "terminal_bridge",
5567
+ terminal_control: terminalControl,
5568
+ ...completionMetadata,
5569
+ completion_source: completion.source,
5570
+ completion_outcome: completionOutcome,
5571
+ completion_id: completion.id,
5572
+ terminal_session: completionMetadata.session,
5573
+ confidence: completion.confidence,
5574
+ match: completionMatch,
5575
+ assistant_timestamp: completion?.timestamp,
5576
+ rollout_turn_id: completion.source === "durable" ? completion.id : undefined,
5577
+ terminal_bridge_message_id: currentMessageId
5578
+ }
5579
+ }),
5580
+ id: callbackMessageId
5581
+ };
5582
+ runLockedCallback({
5583
+ ...options,
5584
+ statePath,
5585
+ log: logPath,
5586
+ closeTerminalBridgeOnDone: completionOutcome === "success",
5587
+ trackCallbackDelivery: true,
5588
+ recoverTerminalCompletion: claim.resumed === true,
5589
+ preserveMessageId: true,
5590
+ messageJson: JSON.stringify(callbackMessage),
5591
+ gatewayMethod: conversation.gateway_method,
5592
+ gatewaySession: conversation.gateway_session,
5593
+ openclawSession: conversation.openclaw_session,
5594
+ openclawBin: conversation.openclaw_bin,
5595
+ gatewayUrl: stringValue(conversation.gateway_token) ? conversation.gateway_url : undefined,
5596
+ token: stringValue(conversation.gateway_token)
5597
+ });
5598
+ }
5599
+ finally {
5600
+ releaseClaudeHookLease(conversation);
5601
+ claim.release();
5602
+ }
4202
5603
  return;
4203
5604
  }
4204
5605
  // A concrete approval or completion observed on this poll wins over a timeout boundary.
@@ -4288,6 +5689,46 @@ function terminalBridgeScreenFingerprint(value) {
4288
5689
  ? createHash("sha256").update(value).digest("hex")
4289
5690
  : undefined;
4290
5691
  }
5692
+ function terminalBridgeMonitorLockPath(statePath, terminalMessageId) {
5693
+ const messageKey = createHash("sha256")
5694
+ .update(terminalMessageId)
5695
+ .digest("hex")
5696
+ .slice(0, 20);
5697
+ return `${statePath}.terminal-bridge-monitor-${messageKey}.lock`;
5698
+ }
5699
+ function fileLockOwnerPid(lockPath) {
5700
+ return readFileLockOwner(lockPath).pid;
5701
+ }
5702
+ function activeTerminalBridgeMonitorOwner(statePath, terminalMessageId) {
5703
+ const lockPath = terminalBridgeMonitorLockPath(statePath, terminalMessageId);
5704
+ if (!fs.existsSync(lockPath) || staleFileLock(lockPath)) {
5705
+ return undefined;
5706
+ }
5707
+ return {
5708
+ lockPath,
5709
+ ownerPid: fileLockOwnerPid(lockPath)
5710
+ };
5711
+ }
5712
+ function tryAcquireTerminalBridgeMonitorLock(statePath, terminalMessageId) {
5713
+ const lockPath = terminalBridgeMonitorLockPath(statePath, terminalMessageId);
5714
+ try {
5715
+ return {
5716
+ acquired: true,
5717
+ lockPath,
5718
+ release: acquireFileLock(lockPath, { timeoutMs: 0 })
5719
+ };
5720
+ }
5721
+ catch (error) {
5722
+ if (isRecord(error) && error.code === "LOCK_TIMEOUT") {
5723
+ return {
5724
+ acquired: false,
5725
+ lockPath,
5726
+ ownerPid: fileLockOwnerPid(lockPath)
5727
+ };
5728
+ }
5729
+ throw error;
5730
+ }
5731
+ }
4291
5732
  function terminalBridgeSendLockPath(storeDir, terminalControl) {
4292
5733
  const terminalKey = createHash("sha256")
4293
5734
  .update(JSON.stringify({
@@ -4302,6 +5743,111 @@ function terminalBridgeRequestFingerprint(value) {
4302
5743
  const text = String(value ?? "").replace(/\s+/gu, " ").trim();
4303
5744
  return text ? createHash("sha256").update(text).digest("hex") : undefined;
4304
5745
  }
5746
+ function deterministicTerminalCallbackMessageId({ conversationId, terminalMessageId, completionFingerprint, outcome }) {
5747
+ const digest = createHash("sha256")
5748
+ .update(JSON.stringify({
5749
+ conversation_id: conversationId,
5750
+ terminal_message_id: terminalMessageId,
5751
+ completion_fingerprint: completionFingerprint,
5752
+ outcome
5753
+ }))
5754
+ .digest("hex")
5755
+ .slice(0, 32);
5756
+ return `msg-terminal-${digest}`;
5757
+ }
5758
+ function claimTerminalBridgeCompletion({ statePath, logPath, terminalMessageId, completionFingerprint, completionId, callbackMessageId, outcome }) {
5759
+ const release = acquireFileLock(`${statePath}.lock`);
5760
+ try {
5761
+ const conversation = loadState(statePath);
5762
+ const nativeTakeover = isRecord(conversation.native_session_takeover)
5763
+ ? conversation.native_session_takeover
5764
+ : {};
5765
+ if (!isWaitingForAgent(conversation.status)) {
5766
+ release();
5767
+ return {
5768
+ claimed: false,
5769
+ conversation,
5770
+ reason: "conversation_no_longer_waiting"
5771
+ };
5772
+ }
5773
+ if (stringValue(nativeTakeover.terminal_bridge_message_id) !== terminalMessageId) {
5774
+ release();
5775
+ return {
5776
+ claimed: false,
5777
+ conversation,
5778
+ reason: "terminal_bridge_task_replaced"
5779
+ };
5780
+ }
5781
+ const existing = isRecord(nativeTakeover.terminal_bridge_completion_claim)
5782
+ ? nativeTakeover.terminal_bridge_completion_claim
5783
+ : undefined;
5784
+ if (existing) {
5785
+ if (existing.callback_message_id === callbackMessageId &&
5786
+ existing.terminal_bridge_message_id === terminalMessageId &&
5787
+ existing.completion_fingerprint === completionFingerprint &&
5788
+ existing.outcome === outcome) {
5789
+ appendEvent(logPath, {
5790
+ ts: new Date().toISOString(),
5791
+ conversation_id: conversation.conversation_id,
5792
+ event: "terminal_bridge_completion_claim_resumed",
5793
+ terminal_bridge_message_id: terminalMessageId,
5794
+ completion_fingerprint: completionFingerprint,
5795
+ callback_message_id: callbackMessageId,
5796
+ outcome
5797
+ });
5798
+ return {
5799
+ claimed: true,
5800
+ resumed: true,
5801
+ conversation,
5802
+ release
5803
+ };
5804
+ }
5805
+ release();
5806
+ return {
5807
+ claimed: false,
5808
+ conversation,
5809
+ reason: "terminal_bridge_completion_claim_conflict"
5810
+ };
5811
+ }
5812
+ const claimedAt = new Date().toISOString();
5813
+ const claimedConversation = {
5814
+ ...conversation,
5815
+ native_session_takeover: {
5816
+ ...nativeTakeover,
5817
+ terminal_bridge_completion_claim: {
5818
+ terminal_bridge_message_id: terminalMessageId,
5819
+ completion_fingerprint: completionFingerprint,
5820
+ completion_id: completionId,
5821
+ callback_message_id: callbackMessageId,
5822
+ outcome,
5823
+ claimed_at: claimedAt
5824
+ }
5825
+ },
5826
+ updated_at: claimedAt
5827
+ };
5828
+ saveState(statePath, claimedConversation);
5829
+ appendEvent(logPath, {
5830
+ ts: claimedAt,
5831
+ conversation_id: conversation.conversation_id,
5832
+ event: "terminal_bridge_completion_claimed",
5833
+ terminal_bridge_message_id: terminalMessageId,
5834
+ completion_fingerprint: completionFingerprint,
5835
+ completion_id: completionId,
5836
+ callback_message_id: callbackMessageId,
5837
+ outcome
5838
+ });
5839
+ return {
5840
+ claimed: true,
5841
+ resumed: false,
5842
+ conversation: claimedConversation,
5843
+ release
5844
+ };
5845
+ }
5846
+ catch (error) {
5847
+ release();
5848
+ throw error;
5849
+ }
5850
+ }
4305
5851
  function terminalBridgeActivityPersistIntervalMs(timeoutMinutes, pollIntervalMs) {
4306
5852
  if (!Number.isFinite(timeoutMinutes) || timeoutMinutes <= 0) {
4307
5853
  return 5 * 60 * 1000;
@@ -4330,6 +5876,13 @@ function persistTerminalBridgeActivity({ conversation, statePath, logPath, obser
4330
5876
  const inactivityDeadlineAt = Number.isFinite(timeoutMinutes) && timeoutMinutes > 0
4331
5877
  ? new Date(observedAtMs + timeoutMinutes * 60 * 1000).toISOString()
4332
5878
  : undefined;
5879
+ const hardDeadlineAt = stringValue(nativeTakeover.terminal_bridge_hard_deadline_at);
5880
+ const leaseDeadlineAt = inactivityDeadlineAt && hardDeadlineAt
5881
+ ? new Date(Math.min(Date.parse(inactivityDeadlineAt), Date.parse(hardDeadlineAt))).toISOString()
5882
+ : inactivityDeadlineAt ?? hardDeadlineAt;
5883
+ const renewedLease = leaseDeadlineAt
5884
+ ? renewClaudeHookLease(currentConversation, leaseDeadlineAt)
5885
+ : undefined;
4333
5886
  const nextConversation = {
4334
5887
  ...currentConversation,
4335
5888
  native_session_takeover: {
@@ -4338,7 +5891,8 @@ function persistTerminalBridgeActivity({ conversation, statePath, logPath, obser
4338
5891
  terminal_bridge_last_activity_reason: reason,
4339
5892
  terminal_bridge_inactivity_deadline_at: inactivityDeadlineAt,
4340
5893
  terminal_bridge_inactivity_timeout_minutes: timeoutMinutes,
4341
- terminal_bridge_hard_timeout_minutes: hardTimeoutMinutes
5894
+ terminal_bridge_hard_timeout_minutes: hardTimeoutMinutes,
5895
+ claude_hook_lease_id: renewedLease?.id ?? nativeTakeover.claude_hook_lease_id
4342
5896
  },
4343
5897
  updated_at: observedAt
4344
5898
  };
@@ -4380,9 +5934,12 @@ function terminalBridgeApprovalCandidate({ executor, terminalControl, terminalSt
4380
5934
  agent: executor.kind,
4381
5935
  kind: stringValue(approval.prompt_kind) ?? "unknown",
4382
5936
  command: stringValue(approval.command),
4383
- cwd: terminalControl.currentPath,
5937
+ tool_name: stringValue(approval.tool_name),
5938
+ request_detail: stringValue(approval.request_detail),
5939
+ cwd: stringValue(approval.cwd) ?? terminalControl.currentPath,
4384
5940
  fingerprint,
4385
- terminal_target: terminalControl.target
5941
+ terminal_target: terminalControl.target,
5942
+ decision_mode: stringValue(approval.decision_mode)
4386
5943
  };
4387
5944
  }
4388
5945
  async function loadCodexTerminalContext({ conversation, nativeTakeover, options }) {
@@ -4481,6 +6038,11 @@ function resolveOptionalExecutable(command) {
4481
6038
  function packageRootDir() {
4482
6039
  return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
4483
6040
  }
6041
+ function printVersion() {
6042
+ const packageJsonPath = path.join(packageRootDir(), "package.json");
6043
+ const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
6044
+ process.stdout.write(`${packageJson.version}\n`);
6045
+ }
4484
6046
  function runCheckedCommand(command, args, { label }) {
4485
6047
  const result = spawnSync(command, args, {
4486
6048
  encoding: "utf8",
@@ -4519,18 +6081,18 @@ function buildCallbackCommand({ statePath, gatewayUrl, token, openclawSession, g
4519
6081
  "--state",
4520
6082
  shellQuote(statePath)
4521
6083
  ];
4522
- if (token) {
4523
- parts.push("--gateway-url", shellQuote(gatewayUrl), "--token", shellQuote(token), "--openclaw-session", shellQuote(openclawSession));
4524
- }
4525
- else if (!gatewayMethod) {
4526
- parts.push("--record-only");
4527
- }
4528
6084
  if (gatewayMethod) {
4529
6085
  parts.push("--gateway-method", shellQuote(gatewayMethod), "--gateway-session", shellQuote(gatewaySession ?? openclawSession));
4530
6086
  if (openclawBin) {
4531
6087
  parts.push("--openclaw-bin", shellQuote(openclawBin));
4532
6088
  }
4533
6089
  }
6090
+ else if (token) {
6091
+ parts.push("--gateway-url", shellQuote(gatewayUrl), "--token", shellQuote(token), "--openclaw-session", shellQuote(openclawSession));
6092
+ }
6093
+ else {
6094
+ parts.push("--record-only");
6095
+ }
4534
6096
  parts.push("--message-json", "'<structured-message-json>'");
4535
6097
  return parts.join(" ");
4536
6098
  }
@@ -4595,7 +6157,7 @@ function runLockedCallback(options) {
4595
6157
  const logPath = expandHome(options.log ?? logPathForStatePath(options.statePath));
4596
6158
  const conversation = loadState(options.statePath);
4597
6159
  const executor = executorForConversation(conversation);
4598
- const message = options.retryPending === true
6160
+ const message = options.retryPending === true || options.preserveMessageId === true
4599
6161
  ? parseMessageJson(messageInput)
4600
6162
  : extractStructuredMessage({
4601
6163
  conversation,
@@ -4614,7 +6176,11 @@ function runLockedCallback(options) {
4614
6176
  isRecord(callbackDelivery?.message) &&
4615
6177
  callbackDelivery.message.id === message.id &&
4616
6178
  ["pending", "failed"].includes(String(callbackDelivery.status ?? ""));
4617
- if (isDuplicateMessage(existingEvents, message) && !retryingPending) {
6179
+ const duplicateMessage = isDuplicateMessage(existingEvents, message);
6180
+ const recoveringTerminalCompletion = options.recoverTerminalCompletion === true &&
6181
+ duplicateMessage &&
6182
+ isWaitingForAgent(conversation.status);
6183
+ if (duplicateMessage && !retryingPending && !recoveringTerminalCompletion) {
4618
6184
  runtimeLog("info", "callback_duplicate", {
4619
6185
  conversation_id: conversation.conversation_id,
4620
6186
  agent: executor.kind,
@@ -4636,15 +6202,23 @@ function runLockedCallback(options) {
4636
6202
  }
4637
6203
  const closeTerminalBridgeOnDone = message.type === "done" &&
4638
6204
  options.closeTerminalBridgeOnDone === true;
6205
+ const trackCallbackDelivery = closeTerminalBridgeOnDone ||
6206
+ options.trackCallbackDelivery === true ||
6207
+ callbackDelivery?.track_delivery === true;
4639
6208
  const requiresDelivery = Boolean(options.gatewayMethod) || options.recordOnly !== true;
4640
6209
  const deliveryAttempt = Number(callbackDelivery?.attempts ?? 0) + 1;
4641
6210
  let nextConversation = retryingPending
4642
6211
  ? conversation
4643
6212
  : applyMessageToConversation(conversation, message);
4644
- if (!retryingPending) {
6213
+ const storedFinalStatus = stringValue(callbackDelivery?.final_status);
6214
+ const finalStatus = storedFinalStatus &&
6215
+ CONVERSATION_STATUSES.has(storedFinalStatus)
6216
+ ? storedFinalStatus
6217
+ : nextConversation.status;
6218
+ if (!retryingPending && !recoveringTerminalCompletion) {
4645
6219
  appendEvent(logPath, messageEvent(message));
4646
6220
  }
4647
- if (closeTerminalBridgeOnDone && requiresDelivery) {
6221
+ if (trackCallbackDelivery && requiresDelivery) {
4648
6222
  const now = new Date().toISOString();
4649
6223
  nextConversation = {
4650
6224
  ...nextConversation,
@@ -4659,7 +6233,9 @@ function runLockedCallback(options) {
4659
6233
  gateway_session: options.gatewaySession ?? options.openclawSession ?? conversation.openclaw_session,
4660
6234
  gateway_url: options.gatewayUrl ?? conversation.gateway_url,
4661
6235
  openclaw_bin: options.openclawBin ?? conversation.openclaw_bin,
4662
- close_terminal_bridge_on_done: true
6236
+ close_terminal_bridge_on_done: closeTerminalBridgeOnDone,
6237
+ track_delivery: true,
6238
+ final_status: finalStatus
4663
6239
  },
4664
6240
  updated_at: now
4665
6241
  };
@@ -4712,12 +6288,17 @@ function runLockedCallback(options) {
4712
6288
  });
4713
6289
  const deliveredAt = new Date().toISOString();
4714
6290
  let deliveredConversation = nextConversation;
4715
- if (closeTerminalBridgeOnDone) {
6291
+ if (trackCallbackDelivery) {
6292
+ const deliveredStatus = closeTerminalBridgeOnDone ? "closed" : finalStatus;
4716
6293
  deliveredConversation = {
4717
6294
  ...nextConversation,
4718
- status: "closed",
4719
- closed_at: deliveredAt,
4720
- close_reason: "terminal bridge task completed",
6295
+ status: deliveredStatus,
6296
+ ...(closeTerminalBridgeOnDone
6297
+ ? {
6298
+ closed_at: deliveredAt,
6299
+ close_reason: "terminal bridge task completed"
6300
+ }
6301
+ : {}),
4721
6302
  callback_delivery: {
4722
6303
  ...(isRecord(nextConversation.callback_delivery) ? nextConversation.callback_delivery : {}),
4723
6304
  status: "delivered",
@@ -4734,7 +6315,7 @@ function runLockedCallback(options) {
4734
6315
  event: "callback_delivery_succeeded",
4735
6316
  message_id: message.id,
4736
6317
  attempt: deliveryAttempt,
4737
- status: "closed"
6318
+ status: deliveredStatus
4738
6319
  });
4739
6320
  }
4740
6321
  printJson({
@@ -4747,7 +6328,7 @@ function runLockedCallback(options) {
4747
6328
  });
4748
6329
  }
4749
6330
  catch (error) {
4750
- if (closeTerminalBridgeOnDone) {
6331
+ if (trackCallbackDelivery) {
4751
6332
  const failedAt = new Date().toISOString();
4752
6333
  const failedConversation = {
4753
6334
  ...nextConversation,
@@ -4902,8 +6483,8 @@ function recordCallbackProcessDelivery({ logPath, conversation, message, event,
4902
6483
  round: message.round,
4903
6484
  ...detail,
4904
6485
  status: delivery.status,
4905
- stdout: delivery.stdout,
4906
- stderr: delivery.stderr
6486
+ stdout: redactString(delivery.stdout),
6487
+ stderr: redactString(delivery.stderr)
4907
6488
  });
4908
6489
  runtimeLog("info", runtimeEvent, {
4909
6490
  conversation_id: conversation.conversation_id,
@@ -4916,25 +6497,149 @@ function recordCallbackProcessDelivery({ logPath, conversation, message, event,
4916
6497
  }
4917
6498
  function acquireFileLock(lockPath, { timeoutMs = 5000, retryMs = 50 } = {}) {
4918
6499
  const started = Date.now();
6500
+ const token = randomUUID();
4919
6501
  while (true) {
6502
+ let fd;
4920
6503
  try {
4921
- const fd = fs.openSync(lockPath, "wx");
6504
+ fd = fs.openSync(lockPath, fs.constants.O_CREAT |
6505
+ fs.constants.O_EXCL |
6506
+ fs.constants.O_WRONLY |
6507
+ NO_FOLLOW_FLAG, PRIVATE_LOCK_FILE_MODE);
6508
+ fs.fchmodSync(fd, PRIVATE_LOCK_FILE_MODE);
6509
+ fs.writeFileSync(fd, `${JSON.stringify({
6510
+ pid: process.pid,
6511
+ token,
6512
+ created_at: new Date().toISOString()
6513
+ })}\n`, "utf8");
6514
+ fs.fsyncSync(fd);
4922
6515
  fs.closeSync(fd);
4923
- return () => {
4924
- fs.rmSync(lockPath, { force: true });
4925
- };
6516
+ fd = undefined;
6517
+ return () => releaseFileLock(lockPath, token);
4926
6518
  }
4927
6519
  catch (error) {
4928
- if (error.code !== "EEXIST") {
6520
+ if (fd !== undefined) {
6521
+ fs.closeSync(fd);
6522
+ }
6523
+ if (!isRecord(error) || error.code !== "EEXIST") {
4929
6524
  throw error;
4930
6525
  }
6526
+ if (reclaimStaleFileLock(lockPath)) {
6527
+ continue;
6528
+ }
4931
6529
  if (Date.now() - started >= timeoutMs) {
4932
- throw new Error(`timed out waiting for file lock: ${lockPath}`);
6530
+ throw Object.assign(new Error(`timed out waiting for file lock: ${lockPath}`), { code: "LOCK_TIMEOUT" });
4933
6531
  }
4934
6532
  sleepSync(retryMs);
4935
6533
  }
4936
6534
  }
4937
6535
  }
6536
+ function staleFileLock(lockPath) {
6537
+ try {
6538
+ const stat = fs.lstatSync(lockPath);
6539
+ if (stat.isSymbolicLink() || !stat.isFile()) {
6540
+ throw new Error(`file lock must be a regular file, not a symlink: ${lockPath}`);
6541
+ }
6542
+ const owner = readFileLockOwner(lockPath);
6543
+ if (owner.pid !== undefined) {
6544
+ try {
6545
+ process.kill(owner.pid, 0);
6546
+ return false;
6547
+ }
6548
+ catch (error) {
6549
+ return isRecord(error) && error.code === "ESRCH";
6550
+ }
6551
+ }
6552
+ return Date.now() - stat.mtimeMs > 30_000;
6553
+ }
6554
+ catch (error) {
6555
+ return isRecord(error) && error.code === "ENOENT";
6556
+ }
6557
+ }
6558
+ function reclaimStaleFileLock(lockPath) {
6559
+ const reclaimPath = `${lockPath}.reclaim`;
6560
+ let reclaimFd;
6561
+ try {
6562
+ reclaimFd = fs.openSync(reclaimPath, fs.constants.O_CREAT |
6563
+ fs.constants.O_EXCL |
6564
+ fs.constants.O_WRONLY |
6565
+ NO_FOLLOW_FLAG, PRIVATE_LOCK_FILE_MODE);
6566
+ fs.fchmodSync(reclaimFd, PRIVATE_LOCK_FILE_MODE);
6567
+ fs.writeFileSync(reclaimFd, `${process.pid}\n`, "utf8");
6568
+ fs.fsyncSync(reclaimFd);
6569
+ }
6570
+ catch (error) {
6571
+ if (reclaimFd !== undefined) {
6572
+ fs.closeSync(reclaimFd);
6573
+ }
6574
+ if (isRecord(error) && error.code === "EEXIST") {
6575
+ return false;
6576
+ }
6577
+ throw error;
6578
+ }
6579
+ try {
6580
+ if (!staleFileLock(lockPath)) {
6581
+ return false;
6582
+ }
6583
+ try {
6584
+ fs.unlinkSync(lockPath);
6585
+ return true;
6586
+ }
6587
+ catch (error) {
6588
+ return isRecord(error) && error.code === "ENOENT";
6589
+ }
6590
+ }
6591
+ finally {
6592
+ fs.closeSync(reclaimFd);
6593
+ try {
6594
+ fs.unlinkSync(reclaimPath);
6595
+ }
6596
+ catch (error) {
6597
+ if (!isRecord(error) || error.code !== "ENOENT") {
6598
+ throw error;
6599
+ }
6600
+ }
6601
+ }
6602
+ }
6603
+ function releaseFileLock(lockPath, token) {
6604
+ try {
6605
+ if (readFileLockOwner(lockPath).token !== token) {
6606
+ return;
6607
+ }
6608
+ fs.unlinkSync(lockPath);
6609
+ }
6610
+ catch (error) {
6611
+ if (!isRecord(error) || error.code !== "ENOENT") {
6612
+ throw error;
6613
+ }
6614
+ }
6615
+ }
6616
+ function readFileLockOwner(lockPath) {
6617
+ try {
6618
+ const text = fs.readFileSync(lockPath, "utf8").trim();
6619
+ try {
6620
+ const owner = JSON.parse(text);
6621
+ if (isRecord(owner)) {
6622
+ const pid = Number(owner.pid);
6623
+ return {
6624
+ pid: Number.isSafeInteger(pid) && pid > 1 ? pid : undefined,
6625
+ token: stringValue(owner.token)
6626
+ };
6627
+ }
6628
+ }
6629
+ catch {
6630
+ // Legacy locks contained only the owner PID.
6631
+ }
6632
+ const legacyPid = Number(text);
6633
+ return {
6634
+ pid: Number.isSafeInteger(legacyPid) && legacyPid > 1
6635
+ ? legacyPid
6636
+ : undefined
6637
+ };
6638
+ }
6639
+ catch {
6640
+ return {};
6641
+ }
6642
+ }
4938
6643
  function sleepSync(ms) {
4939
6644
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
4940
6645
  }
@@ -5497,6 +7202,7 @@ function markConversationStalled({ statePath, logPath, reason, detail = {} }) {
5497
7202
  updated_at: now
5498
7203
  };
5499
7204
  saveState(statePath, stalledConversation);
7205
+ releaseClaudeHookLease(stalledConversation);
5500
7206
  appendEvent(logPath, {
5501
7207
  ts: now,
5502
7208
  conversation_id: conversation.conversation_id,
@@ -5711,8 +7417,8 @@ function deliverStalledNotification({ statePath, logPath, conversation, message,
5711
7417
  method: conversation.gateway_method,
5712
7418
  message_id: message.id,
5713
7419
  status: delivery.status,
5714
- stdout: delivery.stdout,
5715
- stderr: delivery.stderr
7420
+ stdout: redactString(delivery.stdout),
7421
+ stderr: redactString(delivery.stderr)
5716
7422
  });
5717
7423
  runtimeLog("info", `${eventPrefix}_gateway_method_delivery`, {
5718
7424
  conversation_id: conversation.conversation_id,
@@ -5743,8 +7449,8 @@ function deliverStalledNotification({ statePath, logPath, conversation, message,
5743
7449
  event: `${eventPrefix}_chat_send_delivery`,
5744
7450
  message_id: message.id,
5745
7451
  status: chatSendDelivery.status,
5746
- stdout: chatSendDelivery.stdout,
5747
- stderr: chatSendDelivery.stderr
7452
+ stdout: redactString(chatSendDelivery.stdout),
7453
+ stderr: redactString(chatSendDelivery.stderr)
5748
7454
  });
5749
7455
  runtimeLog("info", `${eventPrefix}_chat_send_delivery`, {
5750
7456
  conversation_id: conversation.conversation_id,
@@ -5848,10 +7554,11 @@ function messageFingerprint(message) {
5848
7554
  });
5849
7555
  }
5850
7556
  function deliverToOpenClaw({ gatewayUrl, token, openclawSession, message }) {
5851
- const agent = `openclaw acp --url ${gatewayUrl} --token ${token} --session ${openclawSession}`;
7557
+ const agent = `openclaw acp --url ${gatewayUrl} --session ${openclawSession}`;
5852
7558
  const result = spawnSync("acpx", ["--agent", agent, JSON.stringify(message)], {
5853
7559
  encoding: "utf8",
5854
- maxBuffer: 1024 * 1024 * 10
7560
+ maxBuffer: 1024 * 1024 * 10,
7561
+ env: openClawGatewayEnvironment(token)
5855
7562
  });
5856
7563
  if (result.error) {
5857
7564
  return {
@@ -5876,7 +7583,7 @@ function deliverToGatewayMethod({ method, openclawBin, gatewayUrl, token, sessio
5876
7583
  sessionKey,
5877
7584
  statePath,
5878
7585
  logPath,
5879
- conversation,
7586
+ conversation: redactCliOutput(conversation),
5880
7587
  message
5881
7588
  }),
5882
7589
  "--json"
@@ -5884,12 +7591,10 @@ function deliverToGatewayMethod({ method, openclawBin, gatewayUrl, token, sessio
5884
7591
  if (gatewayUrl) {
5885
7592
  args.push("--url", gatewayUrl);
5886
7593
  }
5887
- if (token && token !== "<token>") {
5888
- args.push("--token", token);
5889
- }
5890
7594
  const result = spawnSync(openclawBin ?? "openclaw", args, {
5891
7595
  encoding: "utf8",
5892
- maxBuffer: 1024 * 1024 * 10
7596
+ maxBuffer: 1024 * 1024 * 10,
7597
+ env: openClawGatewayEnvironment(token)
5893
7598
  });
5894
7599
  if (result.error) {
5895
7600
  return {
@@ -5916,12 +7621,10 @@ function deliverToSessionSend({ openclawBin, gatewayUrl, token, params }) {
5916
7621
  if (gatewayUrl) {
5917
7622
  args.push("--url", gatewayUrl);
5918
7623
  }
5919
- if (token && token !== "<token>") {
5920
- args.push("--token", token);
5921
- }
5922
7624
  const result = spawnSync(openclawBin ?? "openclaw", args, {
5923
7625
  encoding: "utf8",
5924
- maxBuffer: 1024 * 1024 * 10
7626
+ maxBuffer: 1024 * 1024 * 10,
7627
+ env: openClawGatewayEnvironment(token)
5925
7628
  });
5926
7629
  if (result.error) {
5927
7630
  return {
@@ -5948,12 +7651,10 @@ function deliverToChatSend({ openclawBin, gatewayUrl, token, params }) {
5948
7651
  if (gatewayUrl) {
5949
7652
  args.push("--url", gatewayUrl);
5950
7653
  }
5951
- if (token && token !== "<token>") {
5952
- args.push("--token", token);
5953
- }
5954
7654
  const result = spawnSync(openclawBin ?? "openclaw", args, {
5955
7655
  encoding: "utf8",
5956
- maxBuffer: 1024 * 1024 * 10
7656
+ maxBuffer: 1024 * 1024 * 10,
7657
+ env: openClawGatewayEnvironment(token)
5957
7658
  });
5958
7659
  if (result.error) {
5959
7660
  return {
@@ -5968,6 +7669,15 @@ function deliverToChatSend({ openclawBin, gatewayUrl, token, params }) {
5968
7669
  stderr: result.stderr ?? ""
5969
7670
  };
5970
7671
  }
7672
+ function openClawGatewayEnvironment(token) {
7673
+ if (!token || token === "<token>") {
7674
+ return process.env;
7675
+ }
7676
+ return {
7677
+ ...process.env,
7678
+ OPENCLAW_GATEWAY_TOKEN: token
7679
+ };
7680
+ }
5971
7681
  function captureJson(argv) {
5972
7682
  const result = spawnSync(process.execPath, [new URL(import.meta.url).pathname, ...argv], {
5973
7683
  encoding: "utf8"
@@ -6055,7 +7765,24 @@ function expandHome(filePath) {
6055
7765
  return filePath;
6056
7766
  }
6057
7767
  function printJson(value) {
6058
- process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
7768
+ process.stdout.write(`${JSON.stringify(redactCliOutput(value), null, 2)}\n`);
7769
+ }
7770
+ function redactCliOutput(value) {
7771
+ if (Array.isArray(value)) {
7772
+ return value.map((item) => redactCliOutput(item));
7773
+ }
7774
+ if (isRecord(value)) {
7775
+ return Object.fromEntries(Object.entries(value).flatMap(([key, item]) => {
7776
+ if (key === "gateway_token" || key === "gatewayToken") {
7777
+ return [];
7778
+ }
7779
+ if ((key === "callback_command" || key === "callbackCommand") && typeof item === "string") {
7780
+ return [[key, redactString(item)]];
7781
+ }
7782
+ return [[key, redactCliOutput(item)]];
7783
+ }));
7784
+ }
7785
+ return value;
6059
7786
  }
6060
7787
  function cleanProcessText(text) {
6061
7788
  const value = String(text ?? "").trim();
@@ -6121,6 +7848,24 @@ function readOutputTail(outputPath, maxBytes = 65536) {
6121
7848
  return "";
6122
7849
  }
6123
7850
  }
7851
+ function openPrivateAppendFile(filePath) {
7852
+ if (fs.existsSync(filePath) && fs.lstatSync(filePath).isSymbolicLink()) {
7853
+ throw new Error(`refusing agent output symlink: ${filePath}`);
7854
+ }
7855
+ const noFollow = typeof fs.constants.O_NOFOLLOW === "number"
7856
+ ? fs.constants.O_NOFOLLOW
7857
+ : 0;
7858
+ const descriptor = fs.openSync(filePath, fs.constants.O_CREAT |
7859
+ fs.constants.O_APPEND |
7860
+ fs.constants.O_WRONLY |
7861
+ noFollow, 0o600);
7862
+ if (!fs.fstatSync(descriptor).isFile()) {
7863
+ fs.closeSync(descriptor);
7864
+ throw new Error(`agent output must be a regular file: ${filePath}`);
7865
+ }
7866
+ fs.fchmodSync(descriptor, 0o600);
7867
+ return descriptor;
7868
+ }
6124
7869
  function detectModelSelectionError(text) {
6125
7870
  const cleaned = cleanProcessText(text);
6126
7871
  if (!cleaned) {
@@ -6194,6 +7939,8 @@ function withStoragePaths(conversation, paths) {
6194
7939
  function usage() {
6195
7940
  const agentList = EXECUTOR_KINDS.join("|");
6196
7941
  process.stdout.write(`Usage:
7942
+ agent-knock-knock --help
7943
+ agent-knock-knock --version
6197
7944
  agent-knock-knock new --request <text> [--agent ${agentList}] [--workspace <path>] [--store-dir <dir>]
6198
7945
  agent-knock-knock record --state <file> --message-json <json>
6199
7946
  agent-knock-knock bootstrap-prompt --callback-command <command> [--agent ${agentList}]
@@ -6205,10 +7952,12 @@ function usage() {
6205
7952
  agent-knock-knock approve --conversation <id>
6206
7953
  agent-knock-knock cancel --conversation <id> [--all-proxy <url>]
6207
7954
  agent-knock-knock renew --conversation <id> [--minutes <inactivity-minutes>]
7955
+ agent-knock-knock reconcile-monitors [--store-dir <dir>]
6208
7956
  agent-knock-knock retry-callback --conversation <id> [--store-dir <dir>]
6209
7957
  agent-knock-knock recover --conversation <id> [--session <name>] [--all-proxy <url>]
6210
7958
  agent-knock-knock close --conversation <id> [--reason <text>]
6211
7959
  agent-knock-knock install-openclaw [--openclaw-bin <path>] [--skill-path <path>] [--skill-only] [--no-restart]
7960
+ agent-knock-knock install-claude-hooks [--settings-path <path>] [--executable-path <path>] [--dry-run]
6212
7961
  agent-knock-knock doctor
6213
7962
  agent-knock-knock agent takeover --agent codex --session-id <id> --strategy terminate_then_resume|terminal_control|fork [--create-conversation]
6214
7963
  agent-knock-knock callback --state <file> --message-json <json> [--record-only]