@scotthuang/agent-knock-knock 0.2.48 → 0.2.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +77 -133
  3. package/dist/src/approval-policy.js +2 -2
  4. package/dist/src/approval-policy.js.map +1 -1
  5. package/dist/src/claude-hook-protocol.js.map +1 -1
  6. package/dist/src/claude-hook-store.js.map +1 -1
  7. package/dist/src/claude-local-transcript-provider.d.ts +40 -0
  8. package/dist/src/claude-local-transcript-provider.js +804 -0
  9. package/dist/src/claude-local-transcript-provider.js.map +1 -0
  10. package/dist/src/claude-terminal-agent-adapter.d.ts +3 -0
  11. package/dist/src/claude-terminal-agent-adapter.js +130 -46
  12. package/dist/src/claude-terminal-agent-adapter.js.map +1 -1
  13. package/dist/src/cli.js +1576 -802
  14. package/dist/src/cli.js.map +1 -1
  15. package/dist/src/codex-session-provider.js.map +1 -1
  16. package/dist/src/codex-store-adapter.js.map +1 -1
  17. package/dist/src/codex-terminal-agent-adapter.js.map +1 -1
  18. package/dist/src/doctor-capabilities.d.ts +22 -0
  19. package/dist/src/doctor-capabilities.js +29 -0
  20. package/dist/src/doctor-capabilities.js.map +1 -0
  21. package/dist/src/executors.js.map +1 -1
  22. package/dist/src/openclaw-plugin-helpers.js.map +1 -1
  23. package/dist/src/openclaw-plugin.js +2 -2
  24. package/dist/src/openclaw-plugin.js.map +1 -1
  25. package/dist/src/runtime-log.js.map +1 -1
  26. package/dist/src/terminal-agent-adapter.js.map +1 -1
  27. package/dist/src/terminal-agent-bridge.d.ts +19 -1
  28. package/dist/src/terminal-agent-bridge.js +90 -11
  29. package/dist/src/terminal-agent-bridge.js.map +1 -1
  30. package/dist/src/terminal-agent-registry.js.map +1 -1
  31. package/dist/src/terminal-control-provider.js.map +1 -1
  32. package/dist/src/terminal-process-source.js.map +1 -1
  33. package/dist/src/transcript.js.map +1 -1
  34. package/openclaw.plugin.json +2 -2
  35. package/package.json +3 -3
  36. package/templates/openclaw-skills/agent-knock-knock/SKILL.md +7 -7
package/dist/src/cli.js CHANGED
@@ -7,9 +7,10 @@ import process from "node:process";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { createCodexTerminalAgentAdapter, detectCodexDurableCompletion } from "./codex-terminal-agent-adapter.js";
9
9
  import { createClaudeTerminalAgentAdapter } from "./claude-terminal-agent-adapter.js";
10
+ import { captureClaudeTranscriptAnchor, defaultClaudeHome, detectClaudeTranscriptCompletion } from "./claude-local-transcript-provider.js";
10
11
  import { ClaudeHookStore, ClaudeHookStoreError, defaultClaudeHookStoreDir } from "./claude-hook-store.js";
11
12
  import { claudePermissionHookOutput } from "./claude-hook-protocol.js";
12
- import { defaultClaudeSettingsPath, installClaudeHooks, loadTrustedClaudeTokenjuiceLaunchers } from "./claude-hook-installer.js";
13
+ import { defaultClaudeSettingsPath, loadTrustedClaudeTokenjuiceLaunchers } from "./claude-hook-installer.js";
13
14
  import { CodexLocalSessionProvider } from "./codex-local-session-provider.js";
14
15
  import { CodexStoreAdapter } from "./codex-store-adapter.js";
15
16
  import { applyMessageToConversation, budgetAction, createConversation, createMessage, executorForConversation, extractStructuredMessage, parseMessageJson, resolveExecutor } from "./protocol.js";
@@ -17,7 +18,7 @@ import { EXECUTOR_KINDS, acpxCommandForExecutor, executorDefinitionForKind, mode
17
18
  import { executorBootstrapPrompt } from "./bootstrap.js";
18
19
  import { redactString, writeRuntimeLog } from "./runtime-log.js";
19
20
  import { formatTranscript, readNdjsonLog } from "./transcript.js";
20
- import { appendEvent, defaultStoreDir, listConversations, logPathForStatePath, loadConversationById, loadState, messageEvent, pathsForConversation, pathsForConversationDir, saveState, statePathForConversationId } from "./store.js";
21
+ import { appendEvent, defaultStoreDir, ensureDir, listConversations, logPathForStatePath, loadConversationById, loadState, messageEvent, pathsForConversation, pathsForConversationDir, saveState, statePathForConversationId } from "./store.js";
21
22
  import { planFork, planTakeover } from "./session-takeover-planner.js";
22
23
  import { StaticTerminalControlProvider, TmuxTerminalControlProvider, terminalPaneContainsProcess } from "./terminal-control-provider.js";
23
24
  import { parseTerminalConversationId } from "./terminal-agent-adapter.js";
@@ -25,10 +26,13 @@ import { createProductionTerminalAgentRegistry } from "./terminal-agent-registry
25
26
  import { StaticTerminalProcessSource, SystemTerminalProcessSource } from "./terminal-process-source.js";
26
27
  import { TerminalAgentBridge } from "./terminal-agent-bridge.js";
27
28
  import { evaluateApprovalPolicy } from "./approval-policy.js";
29
+ import { evaluateDoctorCapabilities } from "./doctor-capabilities.js";
28
30
  const DEFAULT_IDLE_TIMEOUT_MINUTES = 10080;
29
31
  const DEFAULT_AGENT_TIMEOUT_MINUTES = 60;
30
32
  const DEFAULT_AGENT_HARD_TIMEOUT_MINUTES = 720;
31
33
  const DEFAULT_MONITOR_POLL_INTERVAL_MS = 5000;
34
+ const CLAUDE_SCREEN_APPROVAL_TTL_MS = 10 * 60 * 1000;
35
+ const CALLBACK_DELIVERY_TIMEOUT_MS = 30_000;
32
36
  const CALLBACK_RETRY_DELAYS_MS = [5000, 15000, 60000, 60000];
33
37
  const TERMINAL_BRIDGE_MONITOR_LOCK_VERSION = 1;
34
38
  const MINIMUM_NODE_VERSION = "22.14.0";
@@ -155,7 +159,7 @@ async function runCommand(commandName, options) {
155
159
  runRecover(options);
156
160
  }
157
161
  else if (commandName === "close") {
158
- runClose(options);
162
+ await runClose(options);
159
163
  }
160
164
  else if (commandName === "transcript") {
161
165
  runTranscript(options);
@@ -164,7 +168,7 @@ async function runCommand(commandName, options) {
164
168
  runInstallOpenClaw(options);
165
169
  }
166
170
  else if (commandName === "install-claude-hooks") {
167
- runInstallClaudeHooks(options);
171
+ throw new Error("install-claude-hooks is no longer supported; Claude tmux control now works without modifying Claude Code settings");
168
172
  }
169
173
  else if (commandName === "claude-hook") {
170
174
  await runClaudeHook(options);
@@ -239,34 +243,6 @@ function runInstallOpenClaw(options) {
239
243
  : "Agent Knock Knock is installed. Try: AKK list"
240
244
  });
241
245
  }
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
246
  async function runClaudeHook(options) {
271
247
  const rawInput = fs.readFileSync(0, "utf8");
272
248
  let input;
@@ -345,7 +321,7 @@ function installOpenClawPlugin(openclawBin, root) {
345
321
  return { mode: "replaced" };
346
322
  }
347
323
  function runDoctor(options) {
348
- const commands = ["node", "openclaw", "acpx", "codex", "claude", "cursor"];
324
+ const commands = ["node", "openclaw", "tmux", "acpx", "codex", "claude", "cursor"];
349
325
  const checks = commands.map((commandName) => {
350
326
  const check = executableCheck(commandName);
351
327
  return commandName === "node"
@@ -370,24 +346,23 @@ function runDoctor(options) {
370
346
  exists: fs.existsSync(filePath)
371
347
  };
372
348
  });
373
- const requiredOk = checks
374
- .filter((check) => ["node", "openclaw", "acpx"].includes(check.command))
375
- .every((check) => check.available &&
376
- (check.command !== "node" ||
377
- ("version_supported" in check && check.version_supported === true)));
378
- const agentOk = checks
379
- .filter((check) => ["codex", "claude", "cursor"].includes(check.command))
380
- .some((check) => check.available);
349
+ const capabilities = evaluateDoctorCapabilities(checks);
381
350
  const filesOk = packageFiles.every((check) => check.exists);
382
- const ok = requiredOk && agentOk && filesOk;
351
+ const ok = capabilities.coreOk && filesOk && capabilities.transportOk;
383
352
  printJson({
384
353
  ok,
385
354
  package_root: root,
386
355
  checks,
387
356
  package_files: packageFiles,
357
+ capabilities: {
358
+ tmux: capabilities.tmux,
359
+ acp: capabilities.acp
360
+ },
388
361
  notes: [
389
- `Node.js ${MINIMUM_NODE_VERSION}+, openclaw, and acpx are required.`,
390
- "At least one local coding agent command should be available: codex, claude, or cursor."
362
+ `Node.js ${MINIMUM_NODE_VERSION}+ and OpenClaw are required.`,
363
+ "Choose tmux (recommended), ACPX/ACP, or install both.",
364
+ "tmux supports Codex and Claude Code; ACPX supports Codex, Claude Code, and Cursor.",
365
+ "Claude tmux completion is hook-free and fails closed unless the local transcript schema is verified."
391
366
  ],
392
367
  options
393
368
  });
@@ -741,6 +716,11 @@ function createClaudeHookStore(options = {}) {
741
716
  rootDir: expandHome(configuredRoot ?? defaultClaudeHookStoreDir())
742
717
  });
743
718
  }
719
+ function createConfiguredClaudeHookStore(options = {}) {
720
+ return stringValue(options.claudeHookStoreDir)
721
+ ? createClaudeHookStore(options)
722
+ : undefined;
723
+ }
744
724
  function loadClaudeAgentRows(options = {}) {
745
725
  let value;
746
726
  if (options.claudeAgentsJson !== undefined) {
@@ -793,6 +773,9 @@ function loadClaudeAgentRows(options = {}) {
793
773
  ...(stringValue(row.cwd) ? { cwd: stringValue(row.cwd) } : {}),
794
774
  ...(stringValue(row.kind) ? { kind: stringValue(row.kind) } : {}),
795
775
  ...(stringValue(row.sessionId) ? { sessionId: stringValue(row.sessionId) } : {}),
776
+ ...(Number.isSafeInteger(Number(row.startedAt)) && Number(row.startedAt) > 0
777
+ ? { startedAt: Number(row.startedAt) }
778
+ : {}),
796
779
  ...(stringValue(row.status) ? { status: stringValue(row.status) } : {})
797
780
  }];
798
781
  });
@@ -833,6 +816,7 @@ function isClaudeProcessCommand(commandText) {
833
816
  /[\\/]\.local[\\/]share[\\/]claude[\\/]versions[\\/][^\\/\s]+$/u.test(executable);
834
817
  }
835
818
  function createRuntimeTerminalAgentRegistry(options) {
819
+ const claudeHookStore = createConfiguredClaudeHookStore(options);
836
820
  return createProductionTerminalAgentRegistry({
837
821
  overrides: [
838
822
  createCodexTerminalAgentAdapter({
@@ -872,8 +856,18 @@ function createRuntimeTerminalAgentRegistry(options) {
872
856
  }),
873
857
  createClaudeTerminalAgentAdapter({
874
858
  agentRows: loadClaudeAgentRows(options),
875
- hookStore: createClaudeHookStore(options),
876
- trustedTokenjuiceLaunchers: loadTrustedClaudeTokenjuiceLaunchers(expandHome(options.claudeSettingsPath ?? defaultClaudeSettingsPath()))
859
+ async detectDurableCompletion(request) {
860
+ return detectClaudeTranscriptCompletion(request, {
861
+ claudeHome: expandHome(options.claudeHome),
862
+ agentRows: loadClaudeAgentRows(options)
863
+ });
864
+ },
865
+ ...(claudeHookStore
866
+ ? {
867
+ hookStore: claudeHookStore,
868
+ trustedTokenjuiceLaunchers: loadTrustedClaudeTokenjuiceLaunchers(expandHome(options.claudeSettingsPath ?? defaultClaudeSettingsPath()))
869
+ }
870
+ : {})
877
871
  })
878
872
  ]
879
873
  });
@@ -1664,7 +1658,7 @@ function startExecutorMonitor({ statePath, logPath, pid, outputPath, agentTimeou
1664
1658
  child.unref();
1665
1659
  return child;
1666
1660
  }
1667
- function startTerminalBridgeMonitor({ statePath, logPath, agentTimeoutMinutes, agentHardTimeoutMinutes, pollIntervalMs, codexHome, claudeHookStoreDir }) {
1661
+ function startTerminalBridgeMonitor({ statePath, logPath, agentTimeoutMinutes, agentHardTimeoutMinutes, pollIntervalMs, codexHome, claudeHome, claudeHookStoreDir }) {
1668
1662
  const args = [
1669
1663
  new URL(import.meta.url).pathname,
1670
1664
  "monitor",
@@ -1683,6 +1677,9 @@ function startTerminalBridgeMonitor({ statePath, logPath, agentTimeoutMinutes, a
1683
1677
  if (codexHome) {
1684
1678
  args.push("--codex-home", codexHome);
1685
1679
  }
1680
+ if (claudeHome) {
1681
+ args.push("--claude-home", claudeHome);
1682
+ }
1686
1683
  if (claudeHookStoreDir) {
1687
1684
  args.push("--claude-hook-store-dir", claudeHookStoreDir);
1688
1685
  }
@@ -1713,6 +1710,7 @@ function startTerminalBridgeMonitorForConversation({ conversation, statePath, lo
1713
1710
  DEFAULT_AGENT_HARD_TIMEOUT_MINUTES),
1714
1711
  pollIntervalMs: Number(options.monitorPollIntervalMs ?? DEFAULT_MONITOR_POLL_INTERVAL_MS),
1715
1712
  codexHome: options.codexHome,
1713
+ claudeHome: options.claudeHome ?? nativeTakeover?.["claude_home"],
1716
1714
  claudeHookStoreDir: options.claudeHookStoreDir ?? nativeTakeover?.["claude_hook_store_dir"]
1717
1715
  });
1718
1716
  }
@@ -1722,7 +1720,7 @@ function terminalBridgeEnabled(conversation) {
1722
1720
  : undefined;
1723
1721
  return nativeTakeover?.["terminal_bridge"] === true;
1724
1722
  }
1725
- function withTerminalBridgeState({ conversation, message, startedAt, agentTimeoutMinutes, agentHardTimeoutMinutes, preSendScreenFingerprint }) {
1723
+ function withTerminalBridgeState({ conversation, message, requestText, startedAt, agentTimeoutMinutes, agentHardTimeoutMinutes, preSendScreenFingerprint, claudeTranscriptAnchor, claudeHome }) {
1726
1724
  const nativeTakeover = isRecord(conversation.native_session_takeover)
1727
1725
  ? conversation.native_session_takeover
1728
1726
  : {};
@@ -1733,10 +1731,13 @@ function withTerminalBridgeState({ conversation, message, startedAt, agentTimeou
1733
1731
  terminal_bridge: true,
1734
1732
  terminal_bridge_started_at: startedAt,
1735
1733
  terminal_bridge_message_id: message.id,
1736
- terminal_bridge_request_text: String(message.body ?? ""),
1737
- terminal_bridge_request_hash: terminalBridgeRequestFingerprint(message.body),
1734
+ terminal_bridge_request_text: requestText,
1735
+ terminal_bridge_request_hash: terminalBridgeRequestFingerprint(requestText),
1738
1736
  terminal_bridge_pre_send_screen_fingerprint: preSendScreenFingerprint,
1737
+ claude_transcript_anchor: claudeTranscriptAnchor,
1738
+ claude_home: claudeHome,
1739
1739
  terminal_bridge_completion_claim: undefined,
1740
+ terminal_bridge_approval_dispatch: undefined,
1740
1741
  terminal_bridge_monitor_lock_version: TERMINAL_BRIDGE_MONITOR_LOCK_VERSION,
1741
1742
  terminal_bridge_monitor_started_at: startedAt,
1742
1743
  terminal_bridge_last_activity_at: startedAt,
@@ -1749,6 +1750,9 @@ function withTerminalBridgeState({ conversation, message, startedAt, agentTimeou
1749
1750
  };
1750
1751
  }
1751
1752
  function activateClaudeHookLease({ options, conversation, message, terminalControl, expiresAt }) {
1753
+ if (!stringValue(options.claudeHookStoreDir)) {
1754
+ return undefined;
1755
+ }
1752
1756
  const runtime = terminalRuntimeIdentityForConversation(conversation, terminalControl);
1753
1757
  const pid = runtime.pid;
1754
1758
  const agentRow = pid === undefined
@@ -1855,7 +1859,9 @@ function renewClaudeHookLease(conversation, expiresAt) {
1855
1859
  const nativeTakeover = isRecord(conversation?.native_session_takeover)
1856
1860
  ? conversation.native_session_takeover
1857
1861
  : undefined;
1858
- if (nativeTakeover?.agent !== "claude") {
1862
+ if (nativeTakeover?.agent !== "claude" ||
1863
+ nativeTakeover.claude_hook_mode !== "enabled" ||
1864
+ !stringValue(nativeTakeover.claude_hook_store_dir)) {
1859
1865
  return undefined;
1860
1866
  }
1861
1867
  const conversationId = stringValue(conversation.conversation_id);
@@ -2270,6 +2276,7 @@ async function runStatus(options) {
2270
2276
  const terminalStatus = await terminalStatusForControl(terminalConversation.agent, terminalConversation.terminalControl, options, {
2271
2277
  pid: terminalConversation.pid,
2272
2278
  cwd: terminalConversation.terminalControl.currentPath,
2279
+ conversationId: terminalConversation.conversationId,
2273
2280
  terminalTarget: terminalConversation.terminalControl.target
2274
2281
  });
2275
2282
  printJson({
@@ -2329,6 +2336,7 @@ async function runDescribe(options) {
2329
2336
  const terminalStatus = await terminalStatusForControl(terminalConversation.agent, terminalConversation.terminalControl, options, {
2330
2337
  pid: terminalConversation.pid,
2331
2338
  cwd: terminalConversation.terminalControl.currentPath,
2339
+ conversationId: terminalConversation.conversationId,
2332
2340
  terminalTarget: terminalConversation.terminalControl.target
2333
2341
  });
2334
2342
  if (terminalConversation.agent !== "codex") {
@@ -2496,21 +2504,49 @@ function terminalBridgeApprovalInstructions({ conversation, terminalControl, ter
2496
2504
  "Do not use raw tmux, shell, or manual key presses for this approval. Do not approve without explicit user confirmation."
2497
2505
  ].filter((line) => line !== undefined).join("\n");
2498
2506
  }
2499
- function recordTerminalBridgeApprovalNotification({ statePath, logPath, terminalControl, terminalStatus, fingerprint }) {
2507
+ function recordTerminalBridgeApprovalNotification({ statePath, logPath, terminalControl, terminalStatus, fingerprint, expectedConversation, onRecorded }) {
2500
2508
  const releaseLock = acquireFileLock(`${statePath}.lock`);
2501
2509
  try {
2502
2510
  const conversation = loadState(statePath);
2511
+ const currentNativeTakeover = isRecord(conversation.native_session_takeover)
2512
+ ? conversation.native_session_takeover
2513
+ : undefined;
2514
+ const currentTerminalControl = terminalControlFromTakeover(currentNativeTakeover);
2515
+ if (!isWaitingForAgent(conversation.status) ||
2516
+ conversation.conversation_id !== expectedConversation.conversationId ||
2517
+ conversation.status !== expectedConversation.status ||
2518
+ conversation.updated_at !== expectedConversation.updatedAt ||
2519
+ currentNativeTakeover?.terminal_bridge !== true ||
2520
+ stringValue(currentNativeTakeover.terminal_bridge_message_id) !==
2521
+ expectedConversation.messageId ||
2522
+ !currentTerminalControl ||
2523
+ currentTerminalControl.kind !== terminalControl.kind ||
2524
+ currentTerminalControl.target !== terminalControl.target ||
2525
+ currentTerminalControl.socketPath !== terminalControl.socketPath ||
2526
+ currentTerminalControl.panePid !== terminalControl.panePid) {
2527
+ return {
2528
+ conversation,
2529
+ duplicate: false,
2530
+ stale: true,
2531
+ recorded: undefined
2532
+ };
2533
+ }
2503
2534
  const nativeTakeover = isRecord(conversation.native_session_takeover)
2504
2535
  ? { ...conversation.native_session_takeover }
2505
2536
  : {};
2506
2537
  const previousApproval = isRecord(nativeTakeover.terminal_bridge_approval)
2507
2538
  ? nativeTakeover.terminal_bridge_approval
2508
2539
  : undefined;
2509
- if (previousApproval?.fingerprint === fingerprint && previousApproval?.notified_at) {
2540
+ const previousNotifiedAt = validTimestampMs(previousApproval?.notified_at);
2541
+ if (previousApproval?.fingerprint === fingerprint &&
2542
+ previousNotifiedAt !== undefined &&
2543
+ Date.now() - previousNotifiedAt <= CLAUDE_SCREEN_APPROVAL_TTL_MS) {
2510
2544
  return {
2511
2545
  conversation,
2512
2546
  duplicate: true,
2513
- previousApproval
2547
+ stale: false,
2548
+ previousApproval,
2549
+ recorded: undefined
2514
2550
  };
2515
2551
  }
2516
2552
  const now = new Date().toISOString();
@@ -2537,66 +2573,88 @@ function recordTerminalBridgeApprovalNotification({ statePath, logPath, terminal
2537
2573
  });
2538
2574
  return {
2539
2575
  conversation: nextConversation,
2540
- duplicate: false
2576
+ duplicate: false,
2577
+ stale: false,
2578
+ recorded: onRecorded?.(nextConversation)
2541
2579
  };
2542
2580
  }
2543
2581
  finally {
2544
2582
  releaseLock();
2545
2583
  }
2546
2584
  }
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}`);
2585
+ function prepareManagedSend({ options, statePath, logPath, messageBody, stateLockHeld = false, persist = true, rejectTerminalControl = false }) {
2586
+ if (!stateLockHeld) {
2587
+ const releaseLock = acquireFileLock(`${statePath}.lock`);
2588
+ try {
2589
+ return prepareManagedSend({
2590
+ options,
2591
+ statePath,
2592
+ logPath,
2593
+ messageBody,
2594
+ stateLockHeld: true,
2595
+ persist,
2596
+ rejectTerminalControl
2597
+ });
2553
2598
  }
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`);
2599
+ finally {
2600
+ releaseLock();
2556
2601
  }
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`);
2602
+ }
2603
+ const conversation = loadState(statePath);
2604
+ if (["done", "failed", "closed", "cancelled"].includes(conversation.status)) {
2605
+ throw new Error(`cannot send to ${conversation.conversation_id}; conversation is ${conversation.status}`);
2606
+ }
2607
+ if (conversation.status === "needs_recovery") {
2608
+ throw new Error(`cannot send to ${conversation.conversation_id}; choose recover, close, or delegate a new task first`);
2609
+ }
2610
+ if (conversation.status === "needs_model_selection" && !options.model) {
2611
+ throw new Error(`cannot send to ${conversation.conversation_id}; choose a supported model with --model first`);
2612
+ }
2613
+ const executor = executorForConversation(conversation);
2614
+ const type = options.type ??
2615
+ (conversation.status === "waiting_for_openclaw" ? "answer" : "task");
2616
+ const nativeTakeoverForSend = isRecord(conversation.native_session_takeover)
2617
+ ? conversation.native_session_takeover
2618
+ : undefined;
2619
+ if (rejectTerminalControl &&
2620
+ terminalControlFromTakeover(nativeTakeoverForSend)) {
2621
+ throw new Error("terminal control changed while waiting to send; refresh status and retry");
2622
+ }
2623
+ const forkTakeoverForSend = isRecord(conversation.fork_context_takeover)
2624
+ ? conversation.fork_context_takeover
2625
+ : undefined;
2626
+ const needsNativeTakeoverBootstrap = nativeTakeoverForSend?.["needs_bootstrap"] === true;
2627
+ const needsForkTakeoverBootstrap = forkTakeoverForSend?.["needs_bootstrap"] === true;
2628
+ const message = createMessage({
2629
+ conversation,
2630
+ from: "openclaw",
2631
+ to: executor.actor,
2632
+ type,
2633
+ body: messageBody,
2634
+ metadata: {
2635
+ executor_kind: executor.kind,
2636
+ executor_session: executor.session
2559
2637
  }
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
2638
+ });
2639
+ const previousModelSelection = isRecord(conversation.model_selection)
2640
+ ? conversation.model_selection
2641
+ : {};
2642
+ const nextConversation = {
2643
+ ...applyMessageToConversation(conversation, message),
2644
+ executor,
2645
+ claude_session: executor.kind === "claude"
2646
+ ? executor.session
2647
+ : conversation.claude_session,
2648
+ executor_model: options.model ?? conversation.executor_model,
2649
+ model_selection: conversation.status === "needs_model_selection"
2650
+ ? {
2651
+ ...previousModelSelection,
2652
+ resolved_at: new Date().toISOString(),
2653
+ selected_model: options.model
2580
2654
  }
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
- };
2655
+ : conversation.model_selection
2656
+ };
2657
+ if (persist) {
2600
2658
  saveState(statePath, nextConversation);
2601
2659
  appendEvent(logPath, messageEvent(message));
2602
2660
  runtimeLog("info", "message_created", {
@@ -2608,20 +2666,17 @@ function prepareManagedSend({ options, statePath, logPath, messageBody }) {
2608
2666
  event_log_path: logPath,
2609
2667
  message: textSummary(messageBody)
2610
2668
  });
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
2669
  }
2670
+ return {
2671
+ conversation,
2672
+ executor,
2673
+ nativeTakeoverForSend,
2674
+ forkTakeoverForSend,
2675
+ needsNativeTakeoverBootstrap,
2676
+ needsForkTakeoverBootstrap,
2677
+ message,
2678
+ nextConversation
2679
+ };
2625
2680
  }
2626
2681
  async function runSend(options) {
2627
2682
  const messageBody = required(options.message ?? options.request, "--message is required");
@@ -2632,25 +2687,43 @@ async function runSend(options) {
2632
2687
  const terminalConversation = await resolveTerminalConversationFromOptions(options);
2633
2688
  if (terminalConversation) {
2634
2689
  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
- });
2690
+ const releaseTerminalLock = acquireFileLock(terminalBridgeSendLockPath(storeDirFromOptions(options), terminalConversation.terminalControl), { timeoutMs: 30000 });
2691
+ let releaseStateLock;
2692
+ try {
2693
+ const managed = createManagedTerminalConversationFromRawId({
2694
+ options,
2695
+ conversationId: terminalConversation.conversationId,
2696
+ agent: terminalConversation.agent,
2697
+ pid: terminalConversation.pid,
2698
+ messageBody,
2699
+ terminalControl: terminalConversation.terminalControl
2700
+ });
2701
+ ensureDir(path.dirname(managed.statePath));
2702
+ releaseStateLock = acquireFileLock(`${managed.statePath}.lock`);
2703
+ await runTerminalControlSend({
2704
+ options,
2705
+ conversation: managed.conversation,
2706
+ nextConversation: managed.nextConversation,
2707
+ statePath: managed.statePath,
2708
+ logPath: managed.logPath,
2709
+ executor: managed.executor,
2710
+ message: managed.message,
2711
+ terminalControl: terminalConversation.terminalControl,
2712
+ needsNativeTakeoverBootstrap: true,
2713
+ terminalSendLockHeld: true,
2714
+ terminalStateLockHeld: true,
2715
+ recordMessageAfterSend: true,
2716
+ recordRawAttachmentAfterSend: true
2717
+ });
2718
+ }
2719
+ finally {
2720
+ try {
2721
+ releaseStateLock?.();
2722
+ }
2723
+ finally {
2724
+ releaseTerminalLock();
2725
+ }
2726
+ }
2654
2727
  return;
2655
2728
  }
2656
2729
  await runTerminalConversationSend({
@@ -2665,15 +2738,66 @@ async function runSend(options) {
2665
2738
  }
2666
2739
  const loaded = loadConversationFromOptions(options);
2667
2740
  const { statePath, logPath } = loaded;
2668
- await migrateLegacyTerminalAgentIdentity({
2741
+ const migratedConversation = await migrateLegacyTerminalAgentIdentity({
2669
2742
  ...loaded,
2670
2743
  options
2671
2744
  });
2745
+ const migratedTakeover = isRecord(migratedConversation.native_session_takeover)
2746
+ ? migratedConversation.native_session_takeover
2747
+ : undefined;
2748
+ const migratedTerminalControl = terminalControlFromTakeover(migratedTakeover);
2749
+ if (migratedTerminalControl) {
2750
+ const releaseTerminalLock = acquireFileLock(terminalBridgeSendLockPath(storeDirFromOptions(options), migratedTerminalControl), { timeoutMs: 30000 });
2751
+ let releaseStateLock;
2752
+ try {
2753
+ releaseStateLock = acquireFileLock(`${statePath}.lock`);
2754
+ const prepared = prepareManagedSend({
2755
+ options,
2756
+ statePath,
2757
+ logPath,
2758
+ messageBody,
2759
+ stateLockHeld: true,
2760
+ persist: false
2761
+ });
2762
+ const currentTerminalControl = terminalControlFromTakeover(prepared.nativeTakeoverForSend);
2763
+ if (!currentTerminalControl ||
2764
+ currentTerminalControl.kind !== migratedTerminalControl.kind ||
2765
+ currentTerminalControl.target !== migratedTerminalControl.target ||
2766
+ currentTerminalControl.socketPath !== migratedTerminalControl.socketPath ||
2767
+ currentTerminalControl.panePid !== migratedTerminalControl.panePid) {
2768
+ throw new Error("terminal control changed while waiting to send; refresh status and retry");
2769
+ }
2770
+ await runTerminalControlSend({
2771
+ options,
2772
+ conversation: prepared.conversation,
2773
+ nextConversation: prepared.nextConversation,
2774
+ statePath,
2775
+ logPath,
2776
+ executor: prepared.executor,
2777
+ message: prepared.message,
2778
+ terminalControl: currentTerminalControl,
2779
+ needsNativeTakeoverBootstrap: prepared.needsNativeTakeoverBootstrap,
2780
+ terminalSendLockHeld: true,
2781
+ terminalStateLockHeld: true,
2782
+ recordMessageAfterSend: true
2783
+ });
2784
+ }
2785
+ finally {
2786
+ try {
2787
+ releaseStateLock?.();
2788
+ }
2789
+ finally {
2790
+ releaseTerminalLock();
2791
+ }
2792
+ }
2793
+ return;
2794
+ }
2672
2795
  const prepared = prepareManagedSend({
2673
2796
  options,
2674
2797
  statePath,
2675
2798
  logPath,
2676
- messageBody
2799
+ messageBody,
2800
+ rejectTerminalControl: true
2677
2801
  });
2678
2802
  const { conversation, executor, nativeTakeoverForSend, forkTakeoverForSend, needsNativeTakeoverBootstrap, needsForkTakeoverBootstrap, message, nextConversation } = prepared;
2679
2803
  const executorEnv = environmentForExecutor(executor, {
@@ -2995,12 +3119,84 @@ async function runApprove(options) {
2995
3119
  if (!terminalControl) {
2996
3120
  throw new Error(`conversation ${conversation.conversation_id} is not controlled through a terminal`);
2997
3121
  }
3122
+ if (["done", "failed", "closed", "cancelled"].includes(conversation.status)) {
3123
+ throw new Error(`cannot approve ${conversation.conversation_id}; conversation is ${conversation.status}`);
3124
+ }
2998
3125
  const executor = executorForConversation(conversation);
2999
3126
  const monitoredApproval = isRecord(nativeTakeover?.["terminal_bridge_approval"])
3000
3127
  ? nativeTakeover.terminal_bridge_approval
3001
3128
  : undefined;
3002
- const expectedFingerprint = stringValue(options.expectedApprovalFingerprint) ??
3129
+ const suppliedExpectedFingerprint = stringValue(options.expectedApprovalFingerprint);
3130
+ const expectedFingerprint = suppliedExpectedFingerprint ??
3003
3131
  stringValue(monitoredApproval?.fingerprint);
3132
+ const hooklessClaudeScreenApproval = executor.kind === "claude" &&
3133
+ nativeTakeover?.claude_hook_mode !== "enabled";
3134
+ if (hooklessClaudeScreenApproval) {
3135
+ const monitoredState = isRecord(monitoredApproval?.approval_state)
3136
+ ? monitoredApproval.approval_state
3137
+ : undefined;
3138
+ const pendingDispatch = isRecord(nativeTakeover?.terminal_bridge_approval_dispatch)
3139
+ ? nativeTakeover.terminal_bridge_approval_dispatch
3140
+ : undefined;
3141
+ const notifiedAt = validTimestampMs(monitoredApproval?.notified_at);
3142
+ if (conversation.status !== "waiting_for_openclaw" ||
3143
+ monitoredState?.decision_mode !== "keys" ||
3144
+ !stringValue(monitoredApproval?.fingerprint)) {
3145
+ printJson({
3146
+ conversation,
3147
+ approved: false,
3148
+ blocked: true,
3149
+ reason: "Claude screen approval requires a current managed-turn approval notification",
3150
+ terminal_control: terminalControl
3151
+ });
3152
+ return;
3153
+ }
3154
+ if (notifiedAt === undefined ||
3155
+ Date.now() - notifiedAt > CLAUDE_SCREEN_APPROVAL_TTL_MS) {
3156
+ printJson({
3157
+ conversation,
3158
+ approved: false,
3159
+ blocked: true,
3160
+ reason: "Claude screen approval expired; inspect and resolve the terminal manually",
3161
+ terminal_control: terminalControl
3162
+ });
3163
+ return;
3164
+ }
3165
+ if (!suppliedExpectedFingerprint ||
3166
+ expectedFingerprint !== monitoredApproval?.fingerprint) {
3167
+ printJson({
3168
+ conversation,
3169
+ approved: false,
3170
+ blocked: true,
3171
+ reason: "Claude screen approval requires the latest notified fingerprint",
3172
+ terminal_control: terminalControl
3173
+ });
3174
+ return;
3175
+ }
3176
+ if (pendingDispatch?.state === "reserved" &&
3177
+ pendingDispatch.terminal_bridge_message_id ===
3178
+ nativeTakeover?.terminal_bridge_message_id) {
3179
+ printJson({
3180
+ conversation,
3181
+ approved: false,
3182
+ blocked: true,
3183
+ reason: "a previous Claude approval dispatch has an uncertain outcome; inspect and resolve the terminal manually",
3184
+ terminal_control: terminalControl
3185
+ });
3186
+ return;
3187
+ }
3188
+ if (expectedFingerprint ===
3189
+ stringValue(nativeTakeover?.terminal_bridge_last_approval_fingerprint)) {
3190
+ printJson({
3191
+ conversation,
3192
+ approved: false,
3193
+ blocked: true,
3194
+ reason: "Claude screen approval fingerprint was already consumed",
3195
+ terminal_control: terminalControl
3196
+ });
3197
+ return;
3198
+ }
3199
+ }
3004
3200
  const autoApproved = options.autoApproved === true;
3005
3201
  const policyRuleId = stringValue(options.policyRuleId);
3006
3202
  const policyFingerprint = stringValue(options.policyFingerprint);
@@ -3009,308 +3205,443 @@ async function runApprove(options) {
3009
3205
  : undefined;
3010
3206
  const runtimeIdentity = terminalRuntimeIdentityForConversation(conversation, terminalControl);
3011
3207
  let executorPolicyDecision;
3012
- const approval = await createTerminalAgentBridge(options).approve(executor.kind, terminalControl, {
3013
- expectedFingerprint,
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"
3208
+ const releaseTerminalLock = acquireFileLock(terminalBridgeSendLockPath(storeDirFromOptions(options), terminalControl), { timeoutMs: 30000 });
3209
+ let terminalLockReleased = false;
3210
+ const releaseApprovalTerminalLock = () => {
3211
+ if (!terminalLockReleased) {
3212
+ terminalLockReleased = true;
3213
+ releaseTerminalLock();
3214
+ }
3215
+ };
3216
+ let releaseStateLock;
3217
+ const releaseApprovalStateLock = () => {
3218
+ if (releaseStateLock) {
3219
+ const release = releaseStateLock;
3220
+ releaseStateLock = undefined;
3221
+ release();
3222
+ }
3223
+ };
3224
+ try {
3225
+ let approval;
3226
+ let lockedConversation = conversation;
3227
+ const currentConversation = loadState(statePath);
3228
+ const currentTakeover = isRecord(currentConversation.native_session_takeover)
3229
+ ? currentConversation.native_session_takeover
3230
+ : undefined;
3231
+ const currentControl = terminalControlFromTakeover(currentTakeover);
3232
+ const currentApproval = isRecord(currentTakeover?.terminal_bridge_approval)
3233
+ ? currentTakeover.terminal_bridge_approval
3234
+ : undefined;
3235
+ if (currentConversation.status !== conversation.status ||
3236
+ currentTakeover?.terminal_bridge_message_id !== nativeTakeover?.terminal_bridge_message_id ||
3237
+ currentControl?.target !== terminalControl.target ||
3238
+ currentControl?.socketPath !== terminalControl.socketPath ||
3239
+ (hooklessClaudeScreenApproval &&
3240
+ currentApproval?.fingerprint !== monitoredApproval?.fingerprint)) {
3241
+ throw new Error("approval state changed while waiting for terminal control; refresh status and retry");
3242
+ }
3243
+ lockedConversation = currentConversation;
3244
+ approval = await createTerminalAgentBridge(options).approve(executor.kind, terminalControl, {
3245
+ expectedFingerprint,
3246
+ scrollbackLines: Number(options.scrollbackLines ?? 120),
3247
+ runtime: runtimeIdentity,
3248
+ requiredDecisionMode: autoApproved && executor.kind === "claude"
3249
+ ? "structured"
3250
+ : undefined,
3251
+ authorize: autoApproved
3252
+ ? ({ agent, terminalControl: currentTerminalControl, inspection, fingerprint }) => {
3253
+ if (!autoApprovalPolicy) {
3254
+ return {
3255
+ approved: false,
3256
+ reason: "automatic approval requires an executor-side policy"
3257
+ };
3258
+ }
3259
+ const candidate = {
3260
+ agent,
3261
+ kind: inspection.approval.promptKind ?? "unknown",
3262
+ decisionMode: inspection.approval.approvable
3263
+ ? inspection.approval.action.mode ?? "keys"
3264
+ : undefined,
3265
+ command: inspection.approval.command,
3266
+ cwd: inspection.approval.cwd ?? currentTerminalControl.currentPath,
3267
+ fingerprint: fingerprint ?? "",
3268
+ terminalTarget: currentTerminalControl.target
3052
3269
  };
3270
+ executorPolicyDecision = evaluateApprovalPolicy({
3271
+ policy: autoApprovalPolicy,
3272
+ candidate
3273
+ });
3274
+ if (executorPolicyDecision.action !== "approve") {
3275
+ return {
3276
+ approved: false,
3277
+ reason: `executor-side auto-approval policy rejected the current request: ${executorPolicyDecision.reason}`
3278
+ };
3279
+ }
3280
+ if (policyRuleId && executorPolicyDecision.ruleId !== policyRuleId) {
3281
+ return {
3282
+ approved: false,
3283
+ reason: "executor-side auto-approval rule changed before execution"
3284
+ };
3285
+ }
3286
+ if (policyFingerprint &&
3287
+ executorPolicyDecision.policyFingerprint !== policyFingerprint) {
3288
+ return {
3289
+ approved: false,
3290
+ reason: "executor-side auto-approval policy changed before execution"
3291
+ };
3292
+ }
3293
+ return { approved: true };
3053
3294
  }
3054
- if (policyFingerprint &&
3055
- executorPolicyDecision.policyFingerprint !== policyFingerprint) {
3056
- return {
3057
- approved: false,
3058
- reason: "executor-side auto-approval policy changed before execution"
3295
+ : undefined,
3296
+ beforeKeyDispatch: hooklessClaudeScreenApproval
3297
+ ? ({ fingerprint, terminalControl: dispatchControl, keys }) => {
3298
+ if (releaseStateLock) {
3299
+ throw new Error("Claude approval dispatch was already reserved");
3300
+ }
3301
+ releaseStateLock = acquireFileLock(`${statePath}.lock`);
3302
+ const latestConversation = loadState(statePath);
3303
+ const latestTakeover = isRecord(latestConversation.native_session_takeover)
3304
+ ? latestConversation.native_session_takeover
3305
+ : undefined;
3306
+ const latestControl = terminalControlFromTakeover(latestTakeover);
3307
+ const latestApproval = isRecord(latestTakeover?.terminal_bridge_approval)
3308
+ ? latestTakeover.terminal_bridge_approval
3309
+ : undefined;
3310
+ const latestNotifiedAt = validTimestampMs(latestApproval?.notified_at);
3311
+ const latestDispatch = isRecord(latestTakeover?.terminal_bridge_approval_dispatch)
3312
+ ? latestTakeover.terminal_bridge_approval_dispatch
3313
+ : undefined;
3314
+ if (!latestTakeover ||
3315
+ latestConversation.status !== "waiting_for_openclaw" ||
3316
+ latestTakeover.terminal_bridge_message_id !==
3317
+ nativeTakeover?.terminal_bridge_message_id ||
3318
+ latestApproval?.fingerprint !== fingerprint ||
3319
+ latestNotifiedAt === undefined ||
3320
+ Date.now() - latestNotifiedAt > CLAUDE_SCREEN_APPROVAL_TTL_MS ||
3321
+ expectedFingerprint !== fingerprint ||
3322
+ latestControl?.target !== dispatchControl.target ||
3323
+ latestControl?.socketPath !== dispatchControl.socketPath) {
3324
+ throw new Error("approval state changed before terminal dispatch; refresh status and retry");
3325
+ }
3326
+ if (latestDispatch?.state === "reserved" &&
3327
+ latestDispatch.terminal_bridge_message_id ===
3328
+ latestTakeover.terminal_bridge_message_id) {
3329
+ throw new Error("a previous Claude approval dispatch has an uncertain outcome; inspect and resolve the terminal manually");
3330
+ }
3331
+ const reservedAt = new Date().toISOString();
3332
+ const reservedConversation = {
3333
+ ...latestConversation,
3334
+ native_session_takeover: {
3335
+ ...latestTakeover,
3336
+ terminal_bridge_approval_dispatch: {
3337
+ state: "reserved",
3338
+ attempt_id: randomUUID(),
3339
+ fingerprint,
3340
+ keys,
3341
+ terminal_target: dispatchControl.target,
3342
+ terminal_bridge_message_id: latestTakeover.terminal_bridge_message_id,
3343
+ reserved_at: reservedAt
3344
+ }
3345
+ },
3346
+ updated_at: reservedAt
3059
3347
  };
3348
+ saveState(statePath, reservedConversation);
3349
+ lockedConversation = reservedConversation;
3060
3350
  }
3061
- return { approved: true };
3351
+ : undefined
3352
+ });
3353
+ const actualFingerprint = approval.fingerprint;
3354
+ const effectivePolicyRuleId = executorPolicyDecision?.ruleId ?? policyRuleId;
3355
+ const effectivePolicyFingerprint = executorPolicyDecision?.policyFingerprint ?? policyFingerprint;
3356
+ if (!approval.approved) {
3357
+ releaseApprovalStateLock();
3358
+ releaseApprovalTerminalLock();
3359
+ if (autoApproved) {
3360
+ appendEvent(logPath, {
3361
+ ts: new Date().toISOString(),
3362
+ conversation_id: conversation.conversation_id,
3363
+ event: "terminal_auto_approval_decision",
3364
+ action: "rejected",
3365
+ reason: approval.reason,
3366
+ terminal_control: terminalControl,
3367
+ expected_fingerprint: expectedFingerprint,
3368
+ actual_fingerprint: actualFingerprint,
3369
+ policy_rule_id: effectivePolicyRuleId,
3370
+ policy_fingerprint: effectivePolicyFingerprint
3371
+ });
3062
3372
  }
3063
- : undefined
3064
- });
3065
- const actualFingerprint = approval.fingerprint;
3066
- const effectivePolicyRuleId = executorPolicyDecision?.ruleId ?? policyRuleId;
3067
- const effectivePolicyFingerprint = executorPolicyDecision?.policyFingerprint ?? policyFingerprint;
3068
- if (!approval.approved) {
3373
+ printJson({
3374
+ conversation,
3375
+ approved: false,
3376
+ blocked: approval.blocked,
3377
+ reason: approval.reason,
3378
+ terminal_control: terminalControl,
3379
+ expected_approval_fingerprint: expectedFingerprint,
3380
+ actual_approval_fingerprint: actualFingerprint,
3381
+ screen_excerpt: approval.screenExcerpt
3382
+ });
3383
+ return;
3384
+ }
3385
+ appendEvent(logPath, {
3386
+ ts: new Date().toISOString(),
3387
+ conversation_id: conversation.conversation_id,
3388
+ event: "terminal_approval_send",
3389
+ terminal_control: terminalControl,
3390
+ key: approval.key,
3391
+ keys: approval.keys,
3392
+ label: approval.label,
3393
+ decision_mode: approval.decisionMode,
3394
+ request_id: approval.requestId,
3395
+ approval_fingerprint: actualFingerprint,
3396
+ auto_approved: autoApproved,
3397
+ policy_rule_id: effectivePolicyRuleId,
3398
+ policy_fingerprint: effectivePolicyFingerprint
3399
+ });
3069
3400
  if (autoApproved) {
3070
3401
  appendEvent(logPath, {
3071
3402
  ts: new Date().toISOString(),
3072
3403
  conversation_id: conversation.conversation_id,
3073
3404
  event: "terminal_auto_approval_decision",
3074
- action: "rejected",
3075
- reason: approval.reason,
3405
+ action: "approved",
3076
3406
  terminal_control: terminalControl,
3077
- expected_fingerprint: expectedFingerprint,
3078
- actual_fingerprint: actualFingerprint,
3407
+ approval_fingerprint: actualFingerprint,
3079
3408
  policy_rule_id: effectivePolicyRuleId,
3080
3409
  policy_fingerprint: effectivePolicyFingerprint
3081
3410
  });
3082
3411
  }
3083
- printJson({
3084
- conversation,
3085
- approved: false,
3086
- blocked: approval.blocked,
3087
- reason: approval.reason,
3088
- terminal_control: terminalControl,
3089
- expected_approval_fingerprint: expectedFingerprint,
3090
- actual_approval_fingerprint: actualFingerprint,
3091
- screen_excerpt: approval.screenExcerpt
3092
- });
3093
- return;
3094
- }
3095
- appendEvent(logPath, {
3096
- ts: new Date().toISOString(),
3097
- conversation_id: conversation.conversation_id,
3098
- event: "terminal_approval_send",
3099
- terminal_control: terminalControl,
3100
- key: approval.key,
3101
- keys: approval.keys,
3102
- label: approval.label,
3103
- decision_mode: approval.decisionMode,
3104
- request_id: approval.requestId,
3105
- approval_fingerprint: actualFingerprint,
3106
- auto_approved: autoApproved,
3107
- policy_rule_id: effectivePolicyRuleId,
3108
- policy_fingerprint: effectivePolicyFingerprint
3109
- });
3110
- if (autoApproved) {
3111
- appendEvent(logPath, {
3112
- ts: new Date().toISOString(),
3412
+ runtimeLog("info", "terminal_approval_send", {
3113
3413
  conversation_id: conversation.conversation_id,
3114
- event: "terminal_auto_approval_decision",
3115
- action: "approved",
3116
- terminal_control: terminalControl,
3414
+ terminal_target: terminalControl.target,
3415
+ key: approval.key,
3416
+ keys: approval.keys,
3417
+ label: approval.label,
3418
+ decision_mode: approval.decisionMode,
3419
+ request_id: approval.requestId,
3117
3420
  approval_fingerprint: actualFingerprint,
3421
+ auto_approved: autoApproved,
3118
3422
  policy_rule_id: effectivePolicyRuleId,
3119
3423
  policy_fingerprint: effectivePolicyFingerprint
3120
3424
  });
3121
- }
3122
- runtimeLog("info", "terminal_approval_send", {
3123
- conversation_id: conversation.conversation_id,
3124
- terminal_target: terminalControl.target,
3125
- key: approval.key,
3126
- keys: approval.keys,
3127
- label: approval.label,
3128
- decision_mode: approval.decisionMode,
3129
- request_id: approval.requestId,
3130
- approval_fingerprint: actualFingerprint,
3131
- auto_approved: autoApproved,
3132
- policy_rule_id: effectivePolicyRuleId,
3133
- policy_fingerprint: effectivePolicyFingerprint
3134
- });
3135
- const nativeTakeoverForUpdate = isRecord(conversation.native_session_takeover)
3136
- ? { ...conversation.native_session_takeover }
3137
- : {};
3138
- const approvalResolvedAt = new Date().toISOString();
3139
- const agentTimeoutMinutes = Number(options.agentTimeoutMinutes ??
3140
- nativeTakeoverForUpdate.terminal_bridge_inactivity_timeout_minutes ??
3141
- DEFAULT_AGENT_TIMEOUT_MINUTES);
3142
- const agentHardTimeoutMinutes = positiveMinutes(options.agentHardTimeoutMinutes ??
3143
- nativeTakeoverForUpdate.terminal_bridge_hard_timeout_minutes ??
3144
- DEFAULT_AGENT_HARD_TIMEOUT_MINUTES, "--agent-hard-timeout-minutes");
3145
- const nextNativeTakeover = {
3146
- ...nativeTakeoverForUpdate,
3147
- terminal_bridge_approval: undefined,
3148
- terminal_bridge_approval_resolved_at: approvalResolvedAt,
3149
- terminal_bridge_monitor_lock_version: TERMINAL_BRIDGE_MONITOR_LOCK_VERSION,
3150
- terminal_bridge_monitor_started_at: approvalResolvedAt,
3151
- terminal_bridge_last_activity_at: approvalResolvedAt,
3152
- terminal_bridge_last_activity_reason: "approval resolved",
3153
- terminal_bridge_inactivity_timeout_minutes: agentTimeoutMinutes,
3154
- terminal_bridge_hard_timeout_minutes: agentHardTimeoutMinutes,
3155
- terminal_bridge_inactivity_deadline_at: deadlineAt(approvalResolvedAt, agentTimeoutMinutes),
3156
- terminal_bridge_hard_deadline_at: deadlineAt(stringValue(nativeTakeoverForUpdate.terminal_bridge_started_at) ?? approvalResolvedAt, agentHardTimeoutMinutes)
3157
- };
3158
- delete nextNativeTakeover.terminal_bridge_approval;
3159
- let nextConversation = {
3160
- ...conversation,
3161
- status: terminalBridgeEnabled(conversation) ? "waiting_for_agent" : conversation.status,
3162
- native_session_takeover: nextNativeTakeover,
3163
- updated_at: approvalResolvedAt
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
- }
3181
- saveState(statePath, nextConversation);
3182
- const bridgeMonitor = startTerminalBridgeMonitorForConversation({
3183
- conversation: nextConversation,
3184
- statePath,
3185
- logPath,
3186
- options
3187
- });
3188
- if (bridgeMonitor) {
3189
- appendEvent(logPath, {
3190
- ts: new Date().toISOString(),
3191
- conversation_id: conversation.conversation_id,
3192
- event: "terminal_bridge_monitor_launch",
3193
- pid: bridgeMonitor.pid ?? null,
3194
- terminal_control: terminalControl,
3195
- reason: "approval_resolved",
3196
- agent_timeout_minutes: agentTimeoutMinutes,
3197
- agent_hard_timeout_minutes: agentHardTimeoutMinutes
3425
+ const nativeTakeoverForUpdate = isRecord(lockedConversation.native_session_takeover)
3426
+ ? { ...lockedConversation.native_session_takeover }
3427
+ : {};
3428
+ const approvalResolvedAt = new Date().toISOString();
3429
+ const agentTimeoutMinutes = Number(options.agentTimeoutMinutes ??
3430
+ nativeTakeoverForUpdate.terminal_bridge_inactivity_timeout_minutes ??
3431
+ DEFAULT_AGENT_TIMEOUT_MINUTES);
3432
+ const agentHardTimeoutMinutes = positiveMinutes(options.agentHardTimeoutMinutes ??
3433
+ nativeTakeoverForUpdate.terminal_bridge_hard_timeout_minutes ??
3434
+ DEFAULT_AGENT_HARD_TIMEOUT_MINUTES, "--agent-hard-timeout-minutes");
3435
+ const nextNativeTakeover = {
3436
+ ...nativeTakeoverForUpdate,
3437
+ terminal_bridge_approval: undefined,
3438
+ terminal_bridge_approval_dispatch: undefined,
3439
+ terminal_bridge_approval_resolved_at: approvalResolvedAt,
3440
+ terminal_bridge_last_approval_fingerprint: actualFingerprint,
3441
+ terminal_bridge_last_approval_at: approvalResolvedAt,
3442
+ terminal_bridge_monitor_lock_version: TERMINAL_BRIDGE_MONITOR_LOCK_VERSION,
3443
+ terminal_bridge_monitor_started_at: approvalResolvedAt,
3444
+ terminal_bridge_last_activity_at: approvalResolvedAt,
3445
+ terminal_bridge_last_activity_reason: "approval resolved",
3446
+ terminal_bridge_inactivity_timeout_minutes: agentTimeoutMinutes,
3447
+ terminal_bridge_hard_timeout_minutes: agentHardTimeoutMinutes,
3448
+ terminal_bridge_inactivity_deadline_at: deadlineAt(approvalResolvedAt, agentTimeoutMinutes),
3449
+ terminal_bridge_hard_deadline_at: deadlineAt(stringValue(nativeTakeoverForUpdate.terminal_bridge_started_at) ?? approvalResolvedAt, agentHardTimeoutMinutes)
3450
+ };
3451
+ delete nextNativeTakeover.terminal_bridge_approval;
3452
+ delete nextNativeTakeover.terminal_bridge_approval_dispatch;
3453
+ let nextConversation = {
3454
+ ...lockedConversation,
3455
+ status: terminalBridgeEnabled(lockedConversation)
3456
+ ? "waiting_for_agent"
3457
+ : lockedConversation.status,
3458
+ native_session_takeover: nextNativeTakeover,
3459
+ updated_at: approvalResolvedAt
3460
+ };
3461
+ const approvalLeaseDeadlines = [
3462
+ stringValue(nextNativeTakeover.terminal_bridge_inactivity_deadline_at),
3463
+ stringValue(nextNativeTakeover.terminal_bridge_hard_deadline_at)
3464
+ ].filter((value) => Boolean(value));
3465
+ if (approvalLeaseDeadlines.length > 0) {
3466
+ const renewedLease = renewClaudeHookLease(nextConversation, new Date(Math.min(...approvalLeaseDeadlines.map((value) => Date.parse(value)))).toISOString());
3467
+ if (renewedLease) {
3468
+ nextConversation = {
3469
+ ...nextConversation,
3470
+ native_session_takeover: {
3471
+ ...nextNativeTakeover,
3472
+ claude_hook_lease_id: renewedLease.id
3473
+ }
3474
+ };
3475
+ }
3476
+ }
3477
+ saveState(statePath, nextConversation);
3478
+ releaseApprovalStateLock();
3479
+ releaseApprovalTerminalLock();
3480
+ const bridgeMonitor = startTerminalBridgeMonitorForConversation({
3481
+ conversation: nextConversation,
3482
+ statePath,
3483
+ logPath,
3484
+ options
3198
3485
  });
3199
- runtimeLog("info", "terminal_bridge_monitor_launch", {
3200
- conversation_id: conversation.conversation_id,
3201
- monitor_pid: bridgeMonitor.pid ?? null,
3202
- terminal_target: terminalControl.target,
3203
- reason: "approval_resolved"
3486
+ if (bridgeMonitor) {
3487
+ appendEvent(logPath, {
3488
+ ts: new Date().toISOString(),
3489
+ conversation_id: conversation.conversation_id,
3490
+ event: "terminal_bridge_monitor_launch",
3491
+ pid: bridgeMonitor.pid ?? null,
3492
+ terminal_control: terminalControl,
3493
+ reason: "approval_resolved",
3494
+ agent_timeout_minutes: agentTimeoutMinutes,
3495
+ agent_hard_timeout_minutes: agentHardTimeoutMinutes
3496
+ });
3497
+ runtimeLog("info", "terminal_bridge_monitor_launch", {
3498
+ conversation_id: conversation.conversation_id,
3499
+ monitor_pid: bridgeMonitor.pid ?? null,
3500
+ terminal_target: terminalControl.target,
3501
+ reason: "approval_resolved"
3502
+ });
3503
+ }
3504
+ printJson({
3505
+ conversation: nextConversation,
3506
+ approved: true,
3507
+ terminal_control: terminalControl,
3508
+ key: approval.key,
3509
+ keys: approval.keys,
3510
+ label: approval.label,
3511
+ decision_mode: approval.decisionMode,
3512
+ request_id: approval.requestId,
3513
+ approval_fingerprint: actualFingerprint,
3514
+ auto_approved: autoApproved,
3515
+ policy_rule_id: effectivePolicyRuleId,
3516
+ monitor_pid: bridgeMonitor?.pid ?? null
3204
3517
  });
3205
3518
  }
3206
- printJson({
3207
- conversation: nextConversation,
3208
- approved: true,
3209
- terminal_control: terminalControl,
3210
- key: approval.key,
3211
- keys: approval.keys,
3212
- label: approval.label,
3213
- decision_mode: approval.decisionMode,
3214
- request_id: approval.requestId,
3215
- approval_fingerprint: actualFingerprint,
3216
- auto_approved: autoApproved,
3217
- policy_rule_id: effectivePolicyRuleId,
3218
- monitor_pid: bridgeMonitor?.pid ?? null
3219
- });
3519
+ finally {
3520
+ try {
3521
+ releaseApprovalStateLock();
3522
+ }
3523
+ finally {
3524
+ releaseApprovalTerminalLock();
3525
+ }
3526
+ }
3220
3527
  }
3221
3528
  async function runTerminalConversationApprove({ options, conversationId, agent, terminalControl, pid }) {
3222
- const approval = await createTerminalAgentBridge(options).approve(agent, terminalControl, {
3223
- expectedFingerprint: stringValue(options.expectedApprovalFingerprint),
3224
- scrollbackLines: Number(options.scrollbackLines ?? 120),
3225
- runtime: {
3226
- pid,
3227
- cwd: terminalControl.currentPath,
3228
- terminalTarget: terminalControl.target
3529
+ const releaseTerminalLock = acquireFileLock(terminalBridgeSendLockPath(storeDirFromOptions(options), terminalControl), { timeoutMs: 30000 });
3530
+ try {
3531
+ if (agent === "claude") {
3532
+ printJson({
3533
+ conversation_id: conversationId,
3534
+ source: "terminal_control",
3535
+ approved: false,
3536
+ blocked: true,
3537
+ reason: "Claude screen approval requires `send --background` so AKK can bind it to an active managed turn",
3538
+ terminal_control: terminalControl
3539
+ });
3540
+ return;
3229
3541
  }
3230
- });
3231
- if (!approval.approved) {
3542
+ const approval = await createTerminalAgentBridge(options).approve(agent, terminalControl, {
3543
+ expectedFingerprint: stringValue(options.expectedApprovalFingerprint),
3544
+ scrollbackLines: Number(options.scrollbackLines ?? 120),
3545
+ runtime: {
3546
+ pid,
3547
+ cwd: terminalControl.currentPath,
3548
+ conversationId,
3549
+ terminalTarget: terminalControl.target
3550
+ }
3551
+ });
3552
+ if (!approval.approved) {
3553
+ printJson({
3554
+ conversation_id: conversationId,
3555
+ source: "terminal_control",
3556
+ approved: false,
3557
+ blocked: approval.blocked,
3558
+ reason: approval.reason,
3559
+ terminal_control: terminalControl,
3560
+ screen_excerpt: approval.screenExcerpt
3561
+ });
3562
+ return;
3563
+ }
3564
+ runtimeLog("info", "terminal_approval_send", {
3565
+ conversation_id: conversationId,
3566
+ agent,
3567
+ terminal_target: terminalControl.target,
3568
+ key: approval.key,
3569
+ keys: approval.keys,
3570
+ label: approval.label,
3571
+ decision_mode: approval.decisionMode,
3572
+ request_id: approval.requestId
3573
+ });
3232
3574
  printJson({
3233
3575
  conversation_id: conversationId,
3234
3576
  source: "terminal_control",
3235
- approved: false,
3236
- blocked: approval.blocked,
3237
- reason: approval.reason,
3577
+ approved: true,
3238
3578
  terminal_control: terminalControl,
3239
- screen_excerpt: approval.screenExcerpt
3579
+ key: approval.key,
3580
+ keys: approval.keys,
3581
+ label: approval.label,
3582
+ approval_fingerprint: approval.fingerprint,
3583
+ decision_mode: approval.decisionMode,
3584
+ request_id: approval.requestId
3240
3585
  });
3241
- return;
3242
3586
  }
3243
- runtimeLog("info", "terminal_approval_send", {
3244
- conversation_id: conversationId,
3245
- agent,
3246
- terminal_target: terminalControl.target,
3247
- key: approval.key,
3248
- keys: approval.keys,
3249
- label: approval.label,
3250
- decision_mode: approval.decisionMode,
3251
- request_id: approval.requestId
3252
- });
3253
- printJson({
3254
- conversation_id: conversationId,
3255
- source: "terminal_control",
3256
- approved: true,
3257
- terminal_control: terminalControl,
3258
- key: approval.key,
3259
- keys: approval.keys,
3260
- label: approval.label,
3261
- approval_fingerprint: approval.fingerprint,
3262
- decision_mode: approval.decisionMode,
3263
- request_id: approval.requestId
3264
- });
3587
+ finally {
3588
+ releaseTerminalLock();
3589
+ }
3265
3590
  }
3266
3591
  async function runTerminalConversationSend({ options, conversationId, agent, pid, messageBody, terminalControl }) {
3267
- const terminalPayload = terminalSubmissionPayload(String(messageBody));
3268
- const terminalBridge = createTerminalAgentBridge(options);
3269
- if (agent === "claude") {
3270
- const status = await terminalBridge.status(agent, terminalControl, {
3271
- scrollbackLines: Number(options.scrollbackLines ?? 120),
3592
+ const releaseTerminalLock = acquireFileLock(terminalBridgeSendLockPath(storeDirFromOptions(options), terminalControl), { timeoutMs: 30000 });
3593
+ try {
3594
+ const terminalPayload = terminalSubmissionPayload(String(messageBody));
3595
+ const terminalBridge = createTerminalAgentBridge(options);
3596
+ if (agent === "claude") {
3597
+ const status = await terminalBridge.status(agent, terminalControl, {
3598
+ scrollbackLines: Number(options.scrollbackLines ?? 120),
3599
+ runtime: {
3600
+ pid,
3601
+ cwd: terminalControl.currentPath,
3602
+ terminalTarget: terminalControl.target
3603
+ }
3604
+ });
3605
+ assertSafeClaudeTerminalSend(status);
3606
+ }
3607
+ await terminalBridge.send(agent, terminalControl, terminalPayload, {
3272
3608
  runtime: {
3273
3609
  pid,
3274
3610
  cwd: terminalControl.currentPath,
3275
3611
  terminalTarget: terminalControl.target
3276
3612
  }
3277
3613
  });
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
- });
3287
- runtimeLog("info", "terminal_message_send", {
3288
- conversation_id: conversationId,
3289
- agent,
3290
- terminal_target: terminalControl.target,
3291
- message: textSummary(messageBody)
3292
- });
3293
- printJson({
3294
- conversation_id: conversationId,
3295
- source: "terminal_control",
3296
- delivered: true,
3297
- status: "async_pending",
3298
- background: true,
3299
- callback_expected: false,
3300
- terminal_control: terminalControl,
3301
- message: {
3302
- body: messageBody
3303
- },
3304
- openclaw_next_action: openClawYieldNextAction({
3305
- conversationId,
3614
+ runtimeLog("info", "terminal_message_send", {
3615
+ conversation_id: conversationId,
3616
+ agent,
3617
+ terminal_target: terminalControl.target,
3618
+ message: textSummary(messageBody)
3619
+ });
3620
+ printJson({
3621
+ conversation_id: conversationId,
3306
3622
  source: "terminal_control",
3307
- callbackExpected: false
3308
- })
3309
- });
3623
+ delivered: true,
3624
+ status: "async_pending",
3625
+ background: true,
3626
+ callback_expected: false,
3627
+ terminal_control: terminalControl,
3628
+ message: {
3629
+ body: messageBody
3630
+ },
3631
+ openclaw_next_action: openClawYieldNextAction({
3632
+ conversationId,
3633
+ source: "terminal_control",
3634
+ callbackExpected: false
3635
+ })
3636
+ });
3637
+ }
3638
+ finally {
3639
+ releaseTerminalLock();
3640
+ }
3310
3641
  }
3311
- async function runTerminalControlSend({ options, conversation, nextConversation, statePath, logPath, executor, message, terminalControl, needsNativeTakeoverBootstrap, terminalSendLockHeld = false }) {
3642
+ async function runTerminalControlSend({ options, conversation, nextConversation, statePath, logPath, executor, message, terminalControl, needsNativeTakeoverBootstrap, terminalSendLockHeld = false, terminalStateLockHeld = false, recordMessageAfterSend = false, recordRawAttachmentAfterSend = false }) {
3312
3643
  const bridge = terminalBridgeEnabled(conversation);
3313
- if (bridge && !terminalSendLockHeld) {
3644
+ if (!terminalSendLockHeld) {
3314
3645
  const releaseTerminalLock = acquireFileLock(terminalBridgeSendLockPath(storeDirFromOptions(options), terminalControl), { timeoutMs: 30000 });
3315
3646
  try {
3316
3647
  return await runTerminalControlSend({
@@ -3323,13 +3654,52 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
3323
3654
  message,
3324
3655
  terminalControl,
3325
3656
  needsNativeTakeoverBootstrap,
3326
- terminalSendLockHeld: true
3657
+ terminalSendLockHeld: true,
3658
+ terminalStateLockHeld,
3659
+ recordMessageAfterSend,
3660
+ recordRawAttachmentAfterSend
3327
3661
  });
3328
3662
  }
3329
3663
  finally {
3330
3664
  releaseTerminalLock();
3331
3665
  }
3332
3666
  }
3667
+ if (bridge && !terminalStateLockHeld) {
3668
+ const releaseStateLock = acquireFileLock(`${statePath}.lock`);
3669
+ try {
3670
+ const currentConversation = loadState(statePath);
3671
+ const currentTakeover = isRecord(currentConversation.native_session_takeover)
3672
+ ? currentConversation.native_session_takeover
3673
+ : undefined;
3674
+ const currentControl = terminalControlFromTakeover(currentTakeover);
3675
+ if (currentConversation.conversation_id !== nextConversation.conversation_id ||
3676
+ currentConversation.updated_at !== nextConversation.updated_at ||
3677
+ currentConversation.status !== nextConversation.status ||
3678
+ currentConversation.response_rounds_used !== nextConversation.response_rounds_used ||
3679
+ currentControl?.target !== terminalControl.target ||
3680
+ currentControl?.socketPath !== terminalControl.socketPath) {
3681
+ throw new Error("conversation changed while waiting to send to the terminal; refresh status and retry");
3682
+ }
3683
+ return await runTerminalControlSend({
3684
+ options,
3685
+ conversation: currentConversation,
3686
+ nextConversation: currentConversation,
3687
+ statePath,
3688
+ logPath,
3689
+ executor,
3690
+ message,
3691
+ terminalControl,
3692
+ needsNativeTakeoverBootstrap,
3693
+ terminalSendLockHeld: true,
3694
+ terminalStateLockHeld: true,
3695
+ recordMessageAfterSend,
3696
+ recordRawAttachmentAfterSend
3697
+ });
3698
+ }
3699
+ finally {
3700
+ releaseStateLock();
3701
+ }
3702
+ }
3333
3703
  const terminalBridge = createTerminalAgentBridge(options);
3334
3704
  const bridgeStartedAt = new Date().toISOString();
3335
3705
  const agentTimeoutMinutes = Number(options.agentTimeoutMinutes ?? DEFAULT_AGENT_TIMEOUT_MINUTES);
@@ -3349,6 +3719,10 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
3349
3719
  messageId: message.id
3350
3720
  };
3351
3721
  let preSendScreenFingerprint;
3722
+ let claudeTranscriptAnchor;
3723
+ const claudeHome = executor.kind === "claude"
3724
+ ? path.resolve(expandHome(options.claudeHome) ?? defaultClaudeHome())
3725
+ : undefined;
3352
3726
  if (bridge) {
3353
3727
  try {
3354
3728
  const status = await terminalBridge.status(executor.kind, terminalControl, {
@@ -3358,7 +3732,21 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
3358
3732
  if (executor.kind === "claude") {
3359
3733
  assertSafeClaudeTerminalSend(status);
3360
3734
  }
3361
- preSendScreenFingerprint = terminalBridgeScreenFingerprint(status.screen.excerpt);
3735
+ preSendScreenFingerprint = stringValue(status.screen.digest) ??
3736
+ terminalBridgeScreenFingerprint(status.screen.excerpt);
3737
+ if (executor.kind === "claude") {
3738
+ claudeTranscriptAnchor = captureClaudeTranscriptAnchor({
3739
+ sessionId: preSendRuntime.sessionId,
3740
+ cwd: preSendRuntime.cwd,
3741
+ pid: preSendRuntime.pid,
3742
+ claudeHome,
3743
+ agentRows: loadClaudeAgentRows(options)
3744
+ });
3745
+ if (!claudeTranscriptAnchor &&
3746
+ !stringValue(options.claudeHookStoreDir)) {
3747
+ throw new Error("the hook-free completion monitor could not bind an owner-private Claude transcript boundary");
3748
+ }
3749
+ }
3362
3750
  }
3363
3751
  catch (error) {
3364
3752
  if (executor.kind === "claude") {
@@ -3400,6 +3788,29 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
3400
3788
  }
3401
3789
  throw error;
3402
3790
  }
3791
+ const bridgeConversation = bridge
3792
+ ? withTerminalBridgeState({
3793
+ conversation: conversationWithHookLease,
3794
+ message,
3795
+ requestText: terminalPayload,
3796
+ startedAt: bridgeStartedAt,
3797
+ agentTimeoutMinutes,
3798
+ agentHardTimeoutMinutes,
3799
+ preSendScreenFingerprint,
3800
+ claudeTranscriptAnchor,
3801
+ claudeHome
3802
+ })
3803
+ : nextConversation;
3804
+ const deliveredConversation = markTakeoverBootstrapped({
3805
+ conversation: bridgeConversation,
3806
+ statePath,
3807
+ logPath,
3808
+ executor,
3809
+ native: needsNativeTakeoverBootstrap && !bridge,
3810
+ fork: false,
3811
+ stateLockHeld: terminalStateLockHeld
3812
+ });
3813
+ saveState(statePath, deliveredConversation);
3403
3814
  const supersededConversationIds = bridge
3404
3815
  ? supersedeTerminalBridgeConversations({
3405
3816
  storeDir: storeDirFromOptions(options),
@@ -3407,6 +3818,39 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
3407
3818
  replacementConversationId: conversation.conversation_id
3408
3819
  })
3409
3820
  : [];
3821
+ if (recordRawAttachmentAfterSend) {
3822
+ const sourceConversationId = stringValue(isRecord(conversation.native_session_takeover)
3823
+ ? conversation.native_session_takeover.native_session_id
3824
+ : undefined);
3825
+ appendEvent(logPath, {
3826
+ ts: new Date().toISOString(),
3827
+ conversation_id: conversation.conversation_id,
3828
+ event: "raw_terminal_session_attached",
3829
+ source_conversation_id: sourceConversationId,
3830
+ agent: executor.kind,
3831
+ terminal_control: terminalControl,
3832
+ executor
3833
+ });
3834
+ runtimeLog("info", "raw_terminal_session_attached", {
3835
+ conversation_id: conversation.conversation_id,
3836
+ source_conversation_id: sourceConversationId,
3837
+ terminal_target: terminalControl.target,
3838
+ state_path: statePath,
3839
+ event_log_path: logPath
3840
+ });
3841
+ }
3842
+ if (recordMessageAfterSend) {
3843
+ appendEvent(logPath, messageEvent(message));
3844
+ runtimeLog("info", "message_created", {
3845
+ conversation_id: conversation.conversation_id,
3846
+ agent: executor.kind,
3847
+ executor_session: executor.session,
3848
+ message_type: message.type,
3849
+ state_path: statePath,
3850
+ event_log_path: logPath,
3851
+ message: textSummary(message.body)
3852
+ });
3853
+ }
3410
3854
  appendEvent(logPath, {
3411
3855
  ts: new Date().toISOString(),
3412
3856
  conversation_id: conversation.conversation_id,
@@ -3425,25 +3869,6 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
3425
3869
  payload: textSummary(terminalPayload),
3426
3870
  superseded_conversation_ids: supersededConversationIds
3427
3871
  });
3428
- const bridgeConversation = bridge
3429
- ? withTerminalBridgeState({
3430
- conversation: conversationWithHookLease,
3431
- message,
3432
- startedAt: bridgeStartedAt,
3433
- agentTimeoutMinutes,
3434
- agentHardTimeoutMinutes,
3435
- preSendScreenFingerprint
3436
- })
3437
- : nextConversation;
3438
- const deliveredConversation = markTakeoverBootstrapped({
3439
- conversation: bridgeConversation,
3440
- statePath,
3441
- logPath,
3442
- executor,
3443
- native: needsNativeTakeoverBootstrap && !bridge,
3444
- fork: false
3445
- });
3446
- saveState(statePath, deliveredConversation);
3447
3872
  const bridgeMonitor = bridge
3448
3873
  ? startTerminalBridgeMonitorForConversation({
3449
3874
  conversation: deliveredConversation,
@@ -3487,7 +3912,7 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
3487
3912
  });
3488
3913
  }
3489
3914
  function terminalSubmissionPayload(payload) {
3490
- return payload.replace(/[\r\n]+$/u, "");
3915
+ return payload.trimEnd();
3491
3916
  }
3492
3917
  function createManagedTerminalConversationFromRawId({ options, conversationId, agent, pid, messageBody, terminalControl }) {
3493
3918
  const workspace = terminalControl.currentPath ?? process.cwd();
@@ -3551,23 +3976,6 @@ function createManagedTerminalConversationFromRawId({ options, conversationId, a
3551
3976
  terminal_bridge: true
3552
3977
  }
3553
3978
  }, paths);
3554
- saveState(paths.statePath, attachedConversation);
3555
- appendEvent(paths.logPath, {
3556
- ts: now.toISOString(),
3557
- conversation_id: attachedConversation.conversation_id,
3558
- event: "raw_terminal_session_attached",
3559
- source_conversation_id: conversationId,
3560
- agent,
3561
- terminal_control: terminalControl,
3562
- executor
3563
- });
3564
- runtimeLog("info", "raw_terminal_session_attached", {
3565
- conversation_id: attachedConversation.conversation_id,
3566
- source_conversation_id: conversationId,
3567
- terminal_target: terminalControl.target,
3568
- state_path: paths.statePath,
3569
- event_log_path: paths.logPath
3570
- });
3571
3979
  const message = createMessage({
3572
3980
  conversation: attachedConversation,
3573
3981
  from: "openclaw",
@@ -3581,18 +3989,6 @@ function createManagedTerminalConversationFromRawId({ options, conversationId, a
3581
3989
  }
3582
3990
  });
3583
3991
  const nextConversation = applyMessageToConversation(attachedConversation, message);
3584
- saveState(paths.statePath, nextConversation);
3585
- appendEvent(paths.logPath, messageEvent(message));
3586
- runtimeLog("info", "message_created", {
3587
- conversation_id: nextConversation.conversation_id,
3588
- source_conversation_id: conversationId,
3589
- agent: executor.kind,
3590
- executor_session: executor.session,
3591
- message_type: message.type,
3592
- state_path: paths.statePath,
3593
- event_log_path: paths.logPath,
3594
- message: textSummary(messageBody)
3595
- });
3596
3992
  return {
3597
3993
  conversation: attachedConversation,
3598
3994
  nextConversation,
@@ -3904,22 +4300,36 @@ function buildAgentSendPayload({ conversation, executor, message, includeNativeT
3904
4300
  function forkTakeoverSummaryText(forkTakeover) {
3905
4301
  return String(isRecord(forkTakeover) ? forkTakeover.summary ?? "" : "").trim();
3906
4302
  }
3907
- function markTakeoverBootstrapped({ conversation, statePath, logPath, executor, native, fork }) {
4303
+ function markTakeoverBootstrapped({ conversation, statePath, logPath, executor, native, fork, stateLockHeld = false }) {
3908
4304
  let nextConversation = conversation;
3909
4305
  if (native) {
3910
- nextConversation = markNativeSessionBootstrapped({ conversation: nextConversation, statePath, logPath, executor });
4306
+ nextConversation = markNativeSessionBootstrapped({
4307
+ conversation: nextConversation,
4308
+ statePath,
4309
+ logPath,
4310
+ executor,
4311
+ stateLockHeld
4312
+ });
3911
4313
  }
3912
4314
  if (fork) {
3913
- nextConversation = markForkSessionBootstrapped({ conversation: nextConversation, statePath, logPath, executor });
4315
+ nextConversation = markForkSessionBootstrapped({
4316
+ conversation: nextConversation,
4317
+ statePath,
4318
+ logPath,
4319
+ executor,
4320
+ stateLockHeld
4321
+ });
3914
4322
  }
3915
4323
  return nextConversation;
3916
4324
  }
3917
- function markNativeSessionBootstrapped({ conversation, statePath, logPath, executor }) {
4325
+ function markNativeSessionBootstrapped({ conversation, statePath, logPath, executor, stateLockHeld = false }) {
3918
4326
  const now = new Date().toISOString();
3919
- const releaseLock = acquireFileLock(`${statePath}.lock`);
4327
+ const releaseLock = stateLockHeld
4328
+ ? undefined
4329
+ : acquireFileLock(`${statePath}.lock`);
3920
4330
  let nextConversation = conversation;
3921
4331
  try {
3922
- const current = loadState(statePath);
4332
+ const current = stateLockHeld ? conversation : loadState(statePath);
3923
4333
  const nativeTakeover = isRecord(current.native_session_takeover)
3924
4334
  ? current.native_session_takeover
3925
4335
  : {};
@@ -3935,7 +4345,7 @@ function markNativeSessionBootstrapped({ conversation, statePath, logPath, execu
3935
4345
  saveState(statePath, nextConversation);
3936
4346
  }
3937
4347
  finally {
3938
- releaseLock();
4348
+ releaseLock?.();
3939
4349
  }
3940
4350
  appendEvent(logPath, {
3941
4351
  ts: now,
@@ -3951,12 +4361,14 @@ function markNativeSessionBootstrapped({ conversation, statePath, logPath, execu
3951
4361
  });
3952
4362
  return nextConversation;
3953
4363
  }
3954
- function markForkSessionBootstrapped({ conversation, statePath, logPath, executor }) {
4364
+ function markForkSessionBootstrapped({ conversation, statePath, logPath, executor, stateLockHeld = false }) {
3955
4365
  const now = new Date().toISOString();
3956
- const releaseLock = acquireFileLock(`${statePath}.lock`);
4366
+ const releaseLock = stateLockHeld
4367
+ ? undefined
4368
+ : acquireFileLock(`${statePath}.lock`);
3957
4369
  let nextConversation = conversation;
3958
4370
  try {
3959
- const current = loadState(statePath);
4371
+ const current = stateLockHeld ? conversation : loadState(statePath);
3960
4372
  const forkTakeover = isRecord(current.fork_context_takeover)
3961
4373
  ? current.fork_context_takeover
3962
4374
  : {};
@@ -3972,7 +4384,7 @@ function markForkSessionBootstrapped({ conversation, statePath, logPath, executo
3972
4384
  saveState(statePath, nextConversation);
3973
4385
  }
3974
4386
  finally {
3975
- releaseLock();
4387
+ releaseLock?.();
3976
4388
  }
3977
4389
  appendEvent(logPath, {
3978
4390
  ts: now,
@@ -4093,75 +4505,108 @@ async function runRenew(options) {
4093
4505
  if (!terminalExists) {
4094
4506
  throw new Error(`cannot renew ${conversation.conversation_id}; terminal ${terminalControl.target} is no longer available`);
4095
4507
  }
4096
- const inactivityTimeoutMinutes = positiveMinutes(options.minutes ??
4097
- options.agentTimeoutMinutes ??
4098
- nativeTakeover?.["terminal_bridge_inactivity_timeout_minutes"] ??
4099
- DEFAULT_AGENT_TIMEOUT_MINUTES, "--minutes");
4100
- const hardTimeoutMinutes = positiveMinutes(nativeTakeover?.["terminal_bridge_hard_timeout_minutes"] ??
4101
- DEFAULT_AGENT_HARD_TIMEOUT_MINUTES, "--agent-hard-timeout-minutes");
4102
- const startedAt = stringValue(nativeTakeover?.["terminal_bridge_started_at"]);
4103
- const startedAtMs = startedAt ? Date.parse(startedAt) : NaN;
4104
- if (Number.isFinite(startedAtMs) && Date.now() - startedAtMs >= hardTimeoutMinutes * 60 * 1000) {
4105
- throw new Error(`cannot renew ${conversation.conversation_id}; terminal bridge hard lifetime of ${hardTimeoutMinutes} minutes has elapsed`);
4508
+ const expectedMessageId = stringValue(nativeTakeover?.terminal_bridge_message_id);
4509
+ const expectedStartedAt = stringValue(nativeTakeover?.terminal_bridge_started_at);
4510
+ let renewed = conversation;
4511
+ let renewedTerminalControl = terminalControl;
4512
+ let inactivityTimeoutMinutes = 0;
4513
+ let hardTimeoutMinutes = 0;
4514
+ const releaseStateLock = acquireFileLock(`${statePath}.lock`);
4515
+ try {
4516
+ const current = loadState(statePath);
4517
+ if (current.status !== "stalled") {
4518
+ throw new Error(`cannot renew ${current.conversation_id}; conversation is ${current.status}, not stalled`);
4519
+ }
4520
+ const currentTakeover = isRecord(current.native_session_takeover)
4521
+ ? current.native_session_takeover
4522
+ : undefined;
4523
+ const currentControl = terminalControlFromTakeover(currentTakeover);
4524
+ if (!currentControl || currentTakeover?.terminal_bridge !== true) {
4525
+ throw new Error(`cannot renew ${current.conversation_id}; conversation is not a terminal bridge task`);
4526
+ }
4527
+ if (current.conversation_id !== conversation.conversation_id ||
4528
+ currentControl.target !== terminalControl.target ||
4529
+ currentControl.socketPath !== terminalControl.socketPath ||
4530
+ currentControl.panePid !== terminalControl.panePid ||
4531
+ stringValue(currentTakeover.terminal_bridge_message_id) !== expectedMessageId ||
4532
+ stringValue(currentTakeover.terminal_bridge_started_at) !== expectedStartedAt) {
4533
+ throw new Error("conversation changed while waiting to renew; refresh status and retry");
4534
+ }
4535
+ renewedTerminalControl = currentControl;
4536
+ inactivityTimeoutMinutes = positiveMinutes(options.minutes ??
4537
+ options.agentTimeoutMinutes ??
4538
+ currentTakeover.terminal_bridge_inactivity_timeout_minutes ??
4539
+ DEFAULT_AGENT_TIMEOUT_MINUTES, "--minutes");
4540
+ hardTimeoutMinutes = positiveMinutes(currentTakeover.terminal_bridge_hard_timeout_minutes ??
4541
+ DEFAULT_AGENT_HARD_TIMEOUT_MINUTES, "--agent-hard-timeout-minutes");
4542
+ const startedAt = stringValue(currentTakeover.terminal_bridge_started_at);
4543
+ const startedAtMs = startedAt ? Date.parse(startedAt) : NaN;
4544
+ if (Number.isFinite(startedAtMs) &&
4545
+ Date.now() - startedAtMs >= hardTimeoutMinutes * 60 * 1000) {
4546
+ throw new Error(`cannot renew ${current.conversation_id}; terminal bridge hard lifetime of ${hardTimeoutMinutes} minutes has elapsed`);
4547
+ }
4548
+ const now = new Date().toISOString();
4549
+ const currentMessageId = stringValue(currentTakeover.terminal_bridge_message_id);
4550
+ const hardDeadline = deadlineAt(startedAt ?? now, hardTimeoutMinutes) ??
4551
+ new Date(Date.now() + hardTimeoutMinutes * 60 * 1000).toISOString();
4552
+ const inactivityDeadline = deadlineAt(now, inactivityTimeoutMinutes) ??
4553
+ new Date(Date.now() + inactivityTimeoutMinutes * 60 * 1000).toISOString();
4554
+ const renewedLease = executorForConversation(current).kind === "claude" && currentMessageId
4555
+ ? activateClaudeHookLease({
4556
+ options,
4557
+ conversation: current,
4558
+ message: { id: currentMessageId },
4559
+ terminalControl: currentControl,
4560
+ expiresAt: new Date(Math.min(Date.parse(hardDeadline), Date.parse(inactivityDeadline))).toISOString()
4561
+ })
4562
+ : undefined;
4563
+ const renewedBase = renewedLease
4564
+ ? withClaudeHookLeaseState(current, renewedLease)
4565
+ : current;
4566
+ const renewedNativeTakeover = isRecord(renewedBase.native_session_takeover)
4567
+ ? renewedBase.native_session_takeover
4568
+ : currentTakeover;
4569
+ renewed = {
4570
+ ...renewedBase,
4571
+ status: "waiting_for_agent",
4572
+ native_session_takeover: {
4573
+ ...renewedNativeTakeover,
4574
+ terminal_bridge_monitor_lock_version: TERMINAL_BRIDGE_MONITOR_LOCK_VERSION,
4575
+ terminal_bridge_monitor_started_at: now,
4576
+ terminal_bridge_last_activity_at: now,
4577
+ terminal_bridge_inactivity_timeout_minutes: inactivityTimeoutMinutes,
4578
+ terminal_bridge_hard_timeout_minutes: hardTimeoutMinutes,
4579
+ terminal_bridge_inactivity_deadline_at: inactivityDeadline,
4580
+ terminal_bridge_hard_deadline_at: hardDeadline,
4581
+ terminal_bridge_renewed_at: now
4582
+ },
4583
+ updated_at: now
4584
+ };
4585
+ Reflect.deleteProperty(renewed, "stalled_at");
4586
+ Reflect.deleteProperty(renewed, "stalled_reason");
4587
+ Reflect.deleteProperty(renewed, "stalled_notification_sent_at");
4588
+ Reflect.deleteProperty(renewed, "stalled_notification_message_id");
4589
+ saveState(statePath, renewed);
4590
+ appendEvent(logPath, {
4591
+ ts: now,
4592
+ conversation_id: current.conversation_id,
4593
+ event: "terminal_bridge_renewed",
4594
+ previous_status: current.status,
4595
+ terminal_control: currentControl,
4596
+ agent_timeout_minutes: inactivityTimeoutMinutes,
4597
+ agent_hard_timeout_minutes: hardTimeoutMinutes,
4598
+ last_activity_at: now
4599
+ });
4600
+ runtimeLog("info", "terminal_bridge_renewed", {
4601
+ conversation_id: current.conversation_id,
4602
+ terminal_target: currentControl.target,
4603
+ agent_timeout_minutes: inactivityTimeoutMinutes,
4604
+ agent_hard_timeout_minutes: hardTimeoutMinutes
4605
+ });
4606
+ }
4607
+ finally {
4608
+ releaseStateLock();
4106
4609
  }
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;
4128
- const renewed = {
4129
- ...renewedBase,
4130
- status: "waiting_for_agent",
4131
- native_session_takeover: {
4132
- ...renewedNativeTakeover,
4133
- terminal_bridge_monitor_lock_version: TERMINAL_BRIDGE_MONITOR_LOCK_VERSION,
4134
- terminal_bridge_monitor_started_at: now,
4135
- terminal_bridge_last_activity_at: now,
4136
- terminal_bridge_inactivity_timeout_minutes: inactivityTimeoutMinutes,
4137
- terminal_bridge_hard_timeout_minutes: hardTimeoutMinutes,
4138
- terminal_bridge_inactivity_deadline_at: inactivityDeadline,
4139
- terminal_bridge_hard_deadline_at: hardDeadline,
4140
- terminal_bridge_renewed_at: now
4141
- },
4142
- updated_at: now
4143
- };
4144
- Reflect.deleteProperty(renewed, "stalled_at");
4145
- Reflect.deleteProperty(renewed, "stalled_reason");
4146
- Reflect.deleteProperty(renewed, "stalled_notification_sent_at");
4147
- Reflect.deleteProperty(renewed, "stalled_notification_message_id");
4148
- saveState(statePath, renewed);
4149
- appendEvent(logPath, {
4150
- ts: now,
4151
- conversation_id: conversation.conversation_id,
4152
- event: "terminal_bridge_renewed",
4153
- previous_status: conversation.status,
4154
- terminal_control: terminalControl,
4155
- agent_timeout_minutes: inactivityTimeoutMinutes,
4156
- agent_hard_timeout_minutes: hardTimeoutMinutes,
4157
- last_activity_at: now
4158
- });
4159
- runtimeLog("info", "terminal_bridge_renewed", {
4160
- conversation_id: conversation.conversation_id,
4161
- terminal_target: terminalControl.target,
4162
- agent_timeout_minutes: inactivityTimeoutMinutes,
4163
- agent_hard_timeout_minutes: hardTimeoutMinutes
4164
- });
4165
4610
  const monitor = startTerminalBridgeMonitorForConversation({
4166
4611
  conversation: renewed,
4167
4612
  statePath,
@@ -4175,10 +4620,10 @@ async function runRenew(options) {
4175
4620
  if (monitor) {
4176
4621
  appendEvent(logPath, {
4177
4622
  ts: new Date().toISOString(),
4178
- conversation_id: conversation.conversation_id,
4623
+ conversation_id: renewed.conversation_id,
4179
4624
  event: "terminal_bridge_monitor_launch",
4180
4625
  pid: monitor.pid ?? null,
4181
- terminal_control: terminalControl,
4626
+ terminal_control: renewedTerminalControl,
4182
4627
  reason: "renewal",
4183
4628
  agent_timeout_minutes: inactivityTimeoutMinutes,
4184
4629
  agent_hard_timeout_minutes: hardTimeoutMinutes
@@ -4187,7 +4632,7 @@ async function runRenew(options) {
4187
4632
  printJson({
4188
4633
  conversation: renewed,
4189
4634
  renewed: true,
4190
- terminal_control: terminalControl,
4635
+ terminal_control: renewedTerminalControl,
4191
4636
  agent_timeout_minutes: inactivityTimeoutMinutes,
4192
4637
  agent_hard_timeout_minutes: hardTimeoutMinutes,
4193
4638
  monitor_pid: monitor?.pid ?? null
@@ -4203,18 +4648,43 @@ async function runReconcileMonitors(options) {
4203
4648
  let skipped = 0;
4204
4649
  let errors = 0;
4205
4650
  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
4651
  const statePath = expandHome(stringValue(listedConversation.state_path) ??
4214
4652
  statePathForConversationId(listedConversation.conversation_id, storeDir));
4215
4653
  const logPath = expandHome(stringValue(listedConversation.event_log_path) ??
4216
4654
  logPathForStatePath(statePath));
4217
4655
  try {
4656
+ const callbackRecovery = prepareCallbackDeliveryReconciliation({
4657
+ statePath,
4658
+ logPath,
4659
+ delayMs: options.callbackRetryDelayMs
4660
+ });
4661
+ if (callbackRecovery.handled) {
4662
+ if (callbackRecovery.status === "launched") {
4663
+ launched += 1;
4664
+ }
4665
+ else if (callbackRecovery.status === "already_running") {
4666
+ alreadyRunning += 1;
4667
+ }
4668
+ else {
4669
+ skipped += 1;
4670
+ }
4671
+ items.push({
4672
+ conversation_id: callbackRecovery.conversationId,
4673
+ status: callbackRecovery.status,
4674
+ reason: callbackRecovery.reason,
4675
+ ...(callbackRecovery.monitorPid === undefined
4676
+ ? {}
4677
+ : { monitor_pid: callbackRecovery.monitorPid })
4678
+ });
4679
+ continue;
4680
+ }
4681
+ const listedNativeTakeover = isRecord(listedConversation.native_session_takeover)
4682
+ ? listedConversation.native_session_takeover
4683
+ : undefined;
4684
+ if (listedNativeTakeover?.terminal_bridge !== true) {
4685
+ ignored += 1;
4686
+ continue;
4687
+ }
4218
4688
  const initialConversation = await migrateLegacyTerminalAgentIdentity({
4219
4689
  conversation: loadState(statePath),
4220
4690
  statePath,
@@ -4359,6 +4829,80 @@ async function runReconcileMonitors(options) {
4359
4829
  items
4360
4830
  });
4361
4831
  }
4832
+ function prepareCallbackDeliveryReconciliation({ statePath, logPath, delayMs }) {
4833
+ const releaseStateLock = acquireFileLock(`${statePath}.lock`);
4834
+ try {
4835
+ const conversation = loadState(statePath);
4836
+ const callbackDelivery = isRecord(conversation.callback_delivery)
4837
+ ? conversation.callback_delivery
4838
+ : undefined;
4839
+ if (!["callback_pending", "callback_failed"].includes(conversation.status)) {
4840
+ return {
4841
+ handled: false
4842
+ };
4843
+ }
4844
+ const conversationId = stringValue(conversation.conversation_id) ?? "unknown";
4845
+ const attempts = Number(callbackDelivery?.attempts ?? 0);
4846
+ if (!["pending", "failed"].includes(String(callbackDelivery?.status ?? "")) ||
4847
+ !isRecord(callbackDelivery?.message)) {
4848
+ return {
4849
+ handled: true,
4850
+ conversationId,
4851
+ status: "skipped",
4852
+ reason: "callback_delivery_metadata_missing"
4853
+ };
4854
+ }
4855
+ if (!Number.isSafeInteger(attempts) || attempts < 1 ||
4856
+ attempts > CALLBACK_RETRY_DELAYS_MS.length) {
4857
+ return {
4858
+ handled: true,
4859
+ conversationId,
4860
+ status: "skipped",
4861
+ reason: "callback_delivery_retries_exhausted"
4862
+ };
4863
+ }
4864
+ const configuredDelayMs = Number(delayMs);
4865
+ const retryDelayMs = Number.isFinite(configuredDelayMs) &&
4866
+ configuredDelayMs >= 0
4867
+ ? configuredDelayMs
4868
+ : CALLBACK_RETRY_DELAYS_MS[Math.max(0, attempts - 1)];
4869
+ const retryMonitor = startCallbackRetryMonitor({
4870
+ statePath,
4871
+ delayMs: retryDelayMs
4872
+ });
4873
+ const launchedAt = new Date().toISOString();
4874
+ const nextAttemptAt = new Date(Date.now() + retryDelayMs).toISOString();
4875
+ const nextConversation = {
4876
+ ...conversation,
4877
+ callback_delivery: {
4878
+ ...callbackDelivery,
4879
+ retry_monitor_pid: retryMonitor.pid ?? null,
4880
+ next_attempt_at: nextAttemptAt
4881
+ },
4882
+ updated_at: launchedAt
4883
+ };
4884
+ saveState(statePath, nextConversation);
4885
+ appendEvent(logPath, {
4886
+ ts: launchedAt,
4887
+ conversation_id: conversationId,
4888
+ event: "callback_retry_monitor_launched",
4889
+ message_id: callbackDelivery.message.id,
4890
+ pid: retryMonitor.pid ?? null,
4891
+ next_attempt_at: nextAttemptAt,
4892
+ reason: "startup_reconciliation"
4893
+ });
4894
+ return {
4895
+ handled: true,
4896
+ conversationId,
4897
+ status: "launched",
4898
+ reason: "callback_delivery_reconciliation",
4899
+ monitorPid: retryMonitor.pid
4900
+ };
4901
+ }
4902
+ finally {
4903
+ releaseStateLock();
4904
+ }
4905
+ }
4362
4906
  function terminalBridgeReconciliationEligibility(conversation) {
4363
4907
  const nativeTakeover = isRecord(conversation?.native_session_takeover)
4364
4908
  ? conversation.native_session_takeover
@@ -4456,7 +5000,9 @@ function prepareTerminalBridgeMonitorReconciliation({ statePath, expectedMessage
4456
5000
  };
4457
5001
  }
4458
5002
  let renewedLease;
4459
- if (executorForConversation(conversation).kind === "claude") {
5003
+ const usesClaudeHookLease = executorForConversation(conversation).kind === "claude" &&
5004
+ eligibility.nativeTakeover.claude_hook_mode === "enabled";
5005
+ if (usesClaudeHookLease) {
4460
5006
  const leaseDeadlineAtMs = Math.min(eligibility.inactivityDeadlineAtMs, eligibility.hardDeadlineAtMs);
4461
5007
  if (leaseDeadlineAtMs <= Date.now()) {
4462
5008
  return {
@@ -4585,115 +5131,147 @@ async function runCancel(options) {
4585
5131
  }
4586
5132
  if (cancelResult.status !== 0) {
4587
5133
  throw new Error(cleanProcessText(cancelResult.stderr || cancelResult.stdout || `acpx ${executor.kind} cancel exited with status ${cancelResult.status}`));
4588
- }
4589
- const nextConversation = {
4590
- ...conversation,
4591
- status: "cancelling",
4592
- cancel_requested_at: now,
4593
- updated_at: now
4594
- };
4595
- saveState(statePath, nextConversation);
4596
- runtimeLog("info", "conversation_cancelling", {
4597
- conversation_id: conversation.conversation_id,
4598
- agent: executor.kind,
4599
- executor_session: executor.session,
4600
- state_path: statePath
4601
- });
4602
- printJson({
4603
- conversation: nextConversation,
4604
- cancel_requested: true,
4605
- executor,
4606
- acpx_command: ["acpx", ...cancelArgs],
4607
- budget: budgetAction(nextConversation)
4608
- });
4609
- }
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
- });
4619
- runtimeLog("info", "terminal_cancel_requested", {
4620
- conversation_id: conversationId,
4621
- agent,
4622
- terminal_target: terminalControl.target,
4623
- key: cancellation.key,
4624
- keys: cancellation.keys,
4625
- denied_approval: cancellation.deniedApproval,
4626
- request_id: cancellation.requestId,
4627
- cancel_requested: cancellation.cancelRequested,
4628
- reason: cancellation.reason
4629
- });
4630
- printJson({
4631
- conversation_id: conversationId,
4632
- source: "terminal_control",
4633
- cancel_requested: cancellation.cancelRequested,
4634
- reason: cancellation.reason,
4635
- terminal_control: terminalControl,
4636
- key: cancellation.key,
4637
- keys: cancellation.keys,
4638
- denied_approval: cancellation.deniedApproval,
4639
- request_id: cancellation.requestId
4640
- });
4641
- }
4642
- async function runTerminalControlCancel({ options, conversation, statePath, logPath, agent, terminalControl }) {
4643
- const cancellation = await createTerminalAgentBridge(options).cancel(agent, terminalControl, {
4644
- runtime: terminalRuntimeIdentityForConversation(conversation, terminalControl),
4645
- scrollbackLines: Number(options.scrollbackLines ?? 120)
4646
- });
4647
- if (!cancellation.cancelRequested) {
4648
- printJson({
4649
- conversation,
4650
- cancel_requested: false,
4651
- reason: cancellation.reason,
4652
- terminal_control: terminalControl,
4653
- budget: budgetAction(conversation)
4654
- });
4655
- return;
4656
- }
4657
- const now = new Date().toISOString();
4658
- appendEvent(logPath, {
4659
- ts: now,
4660
- conversation_id: conversation.conversation_id,
4661
- event: "terminal_cancel_requested",
4662
- terminal_control: terminalControl,
4663
- key: cancellation.key,
4664
- keys: cancellation.keys,
4665
- denied_approval: cancellation.deniedApproval,
4666
- request_id: cancellation.requestId
4667
- });
4668
- runtimeLog("info", "terminal_cancel_requested", {
4669
- conversation_id: conversation.conversation_id,
4670
- agent,
4671
- terminal_target: terminalControl.target,
4672
- key: cancellation.key,
4673
- keys: cancellation.keys,
4674
- denied_approval: cancellation.deniedApproval,
4675
- request_id: cancellation.requestId
4676
- });
5134
+ }
4677
5135
  const nextConversation = {
4678
5136
  ...conversation,
4679
- status: "cancelled",
4680
- cancelled_at: now,
4681
- terminal_cancel_requested_at: now,
5137
+ status: "cancelling",
5138
+ cancel_requested_at: now,
4682
5139
  updated_at: now
4683
5140
  };
4684
5141
  saveState(statePath, nextConversation);
4685
- releaseClaudeHookLease(nextConversation);
5142
+ runtimeLog("info", "conversation_cancelling", {
5143
+ conversation_id: conversation.conversation_id,
5144
+ agent: executor.kind,
5145
+ executor_session: executor.session,
5146
+ state_path: statePath
5147
+ });
4686
5148
  printJson({
4687
5149
  conversation: nextConversation,
4688
5150
  cancel_requested: true,
4689
- terminal_control: terminalControl,
4690
- key: cancellation.key,
4691
- keys: cancellation.keys,
4692
- denied_approval: cancellation.deniedApproval,
4693
- request_id: cancellation.requestId,
5151
+ executor,
5152
+ acpx_command: ["acpx", ...cancelArgs],
4694
5153
  budget: budgetAction(nextConversation)
4695
5154
  });
4696
5155
  }
5156
+ async function runTerminalConversationCancel({ options, conversationId, agent, terminalControl, pid }) {
5157
+ const releaseTerminalLock = acquireFileLock(terminalBridgeSendLockPath(storeDirFromOptions(options), terminalControl), { timeoutMs: 30000 });
5158
+ try {
5159
+ const cancellation = await createTerminalAgentBridge(options).cancel(agent, terminalControl, {
5160
+ runtime: {
5161
+ pid,
5162
+ cwd: terminalControl.currentPath,
5163
+ terminalTarget: terminalControl.target
5164
+ },
5165
+ scrollbackLines: Number(options.scrollbackLines ?? 120)
5166
+ });
5167
+ runtimeLog("info", "terminal_cancel_requested", {
5168
+ conversation_id: conversationId,
5169
+ agent,
5170
+ terminal_target: terminalControl.target,
5171
+ key: cancellation.key,
5172
+ keys: cancellation.keys,
5173
+ denied_approval: cancellation.deniedApproval,
5174
+ request_id: cancellation.requestId,
5175
+ cancel_requested: cancellation.cancelRequested,
5176
+ reason: cancellation.reason
5177
+ });
5178
+ printJson({
5179
+ conversation_id: conversationId,
5180
+ source: "terminal_control",
5181
+ cancel_requested: cancellation.cancelRequested,
5182
+ reason: cancellation.reason,
5183
+ terminal_control: terminalControl,
5184
+ key: cancellation.key,
5185
+ keys: cancellation.keys,
5186
+ denied_approval: cancellation.deniedApproval,
5187
+ request_id: cancellation.requestId
5188
+ });
5189
+ }
5190
+ finally {
5191
+ releaseTerminalLock();
5192
+ }
5193
+ }
5194
+ async function runTerminalControlCancel({ options, conversation, statePath, logPath, agent, terminalControl }) {
5195
+ const releaseTerminalLock = acquireFileLock(terminalBridgeSendLockPath(storeDirFromOptions(options), terminalControl), { timeoutMs: 30000 });
5196
+ let releaseStateLock;
5197
+ try {
5198
+ releaseStateLock = acquireFileLock(`${statePath}.lock`);
5199
+ const currentConversation = loadState(statePath);
5200
+ if (["closed", "cancelled"].includes(currentConversation.status)) {
5201
+ throw new Error(`cannot cancel ${currentConversation.conversation_id}; conversation is ${currentConversation.status}`);
5202
+ }
5203
+ const currentTakeover = isRecord(currentConversation.native_session_takeover)
5204
+ ? currentConversation.native_session_takeover
5205
+ : undefined;
5206
+ const currentControl = terminalControlFromTakeover(currentTakeover);
5207
+ if (!currentControl ||
5208
+ currentControl.target !== terminalControl.target ||
5209
+ currentControl.socketPath !== terminalControl.socketPath) {
5210
+ throw new Error("terminal control changed while waiting to cancel; refresh status and retry");
5211
+ }
5212
+ const cancellation = await createTerminalAgentBridge(options).cancel(agent, currentControl, {
5213
+ runtime: terminalRuntimeIdentityForConversation(currentConversation, currentControl),
5214
+ scrollbackLines: Number(options.scrollbackLines ?? 120)
5215
+ });
5216
+ if (!cancellation.cancelRequested) {
5217
+ printJson({
5218
+ conversation: currentConversation,
5219
+ cancel_requested: false,
5220
+ reason: cancellation.reason,
5221
+ terminal_control: currentControl,
5222
+ budget: budgetAction(currentConversation)
5223
+ });
5224
+ return;
5225
+ }
5226
+ const now = new Date().toISOString();
5227
+ appendEvent(logPath, {
5228
+ ts: now,
5229
+ conversation_id: currentConversation.conversation_id,
5230
+ event: "terminal_cancel_requested",
5231
+ terminal_control: currentControl,
5232
+ key: cancellation.key,
5233
+ keys: cancellation.keys,
5234
+ denied_approval: cancellation.deniedApproval,
5235
+ request_id: cancellation.requestId
5236
+ });
5237
+ runtimeLog("info", "terminal_cancel_requested", {
5238
+ conversation_id: currentConversation.conversation_id,
5239
+ agent,
5240
+ terminal_target: currentControl.target,
5241
+ key: cancellation.key,
5242
+ keys: cancellation.keys,
5243
+ denied_approval: cancellation.deniedApproval,
5244
+ request_id: cancellation.requestId
5245
+ });
5246
+ const nextConversation = {
5247
+ ...currentConversation,
5248
+ status: "cancelled",
5249
+ cancelled_at: now,
5250
+ terminal_cancel_requested_at: now,
5251
+ updated_at: now
5252
+ };
5253
+ saveState(statePath, nextConversation);
5254
+ releaseClaudeHookLease(nextConversation);
5255
+ printJson({
5256
+ conversation: nextConversation,
5257
+ cancel_requested: true,
5258
+ terminal_control: currentControl,
5259
+ key: cancellation.key,
5260
+ keys: cancellation.keys,
5261
+ denied_approval: cancellation.deniedApproval,
5262
+ request_id: cancellation.requestId,
5263
+ budget: budgetAction(nextConversation)
5264
+ });
5265
+ }
5266
+ finally {
5267
+ try {
5268
+ releaseStateLock?.();
5269
+ }
5270
+ finally {
5271
+ releaseTerminalLock();
5272
+ }
5273
+ }
5274
+ }
4697
5275
  function runRecover(options) {
4698
5276
  runRecoveryDecision({ ...options, mode: "recover" });
4699
5277
  }
@@ -4861,36 +5439,57 @@ function formatProtocolHistoryForRecovery(events) {
4861
5439
  });
4862
5440
  return lines.length ? lines.join("\n") : "- No prior protocol messages were recorded.";
4863
5441
  }
4864
- function runClose(options) {
4865
- const { conversation, statePath, logPath } = loadConversationFromOptions(options);
4866
- const now = new Date().toISOString();
4867
- const closed = {
4868
- ...conversation,
4869
- status: "closed",
4870
- closed_at: now,
4871
- close_reason: options.reason ?? "closed by request",
4872
- updated_at: now
4873
- };
4874
- saveState(statePath, closed);
4875
- releaseClaudeHookLease(closed);
4876
- appendEvent(logPath, {
4877
- ts: now,
4878
- conversation_id: conversation.conversation_id,
4879
- event: "conversation_closed",
4880
- status: "closed",
4881
- reason: closed.close_reason
4882
- });
4883
- runtimeLog("info", "conversation_closed", {
4884
- conversation_id: conversation.conversation_id,
4885
- status: "closed",
4886
- reason: closed.close_reason,
4887
- state_path: statePath,
4888
- event_log_path: logPath
4889
- });
4890
- printJson({
4891
- conversation: closed,
4892
- closed: true
4893
- });
5442
+ async function runClose(options) {
5443
+ const loaded = loadConversationFromOptions(options);
5444
+ const { statePath, logPath } = loaded;
5445
+ const nativeTakeover = isRecord(loaded.conversation.native_session_takeover)
5446
+ ? loaded.conversation.native_session_takeover
5447
+ : undefined;
5448
+ const terminalControl = terminalControlFromTakeover(nativeTakeover);
5449
+ const releaseTerminalLock = terminalControl
5450
+ ? acquireFileLock(terminalBridgeSendLockPath(storeDirFromOptions(options), terminalControl), { timeoutMs: 30000 })
5451
+ : () => { };
5452
+ let releaseStateLock;
5453
+ try {
5454
+ releaseStateLock = acquireFileLock(`${statePath}.lock`);
5455
+ const conversation = loadState(statePath);
5456
+ const now = new Date().toISOString();
5457
+ const closed = {
5458
+ ...conversation,
5459
+ status: "closed",
5460
+ closed_at: now,
5461
+ close_reason: options.reason ?? "closed by request",
5462
+ updated_at: now
5463
+ };
5464
+ saveState(statePath, closed);
5465
+ releaseClaudeHookLease(closed);
5466
+ appendEvent(logPath, {
5467
+ ts: now,
5468
+ conversation_id: conversation.conversation_id,
5469
+ event: "conversation_closed",
5470
+ status: "closed",
5471
+ reason: closed.close_reason
5472
+ });
5473
+ runtimeLog("info", "conversation_closed", {
5474
+ conversation_id: conversation.conversation_id,
5475
+ status: "closed",
5476
+ reason: closed.close_reason,
5477
+ state_path: statePath,
5478
+ event_log_path: logPath
5479
+ });
5480
+ printJson({
5481
+ conversation: closed,
5482
+ closed: true
5483
+ });
5484
+ }
5485
+ finally {
5486
+ try {
5487
+ releaseStateLock?.();
5488
+ }
5489
+ finally {
5490
+ releaseTerminalLock();
5491
+ }
5492
+ }
4894
5493
  }
4895
5494
  async function runMonitor(options) {
4896
5495
  if (options.callbackRetry) {
@@ -5027,13 +5626,16 @@ async function runMonitor(options) {
5027
5626
  sleepSync(pollIntervalMs);
5028
5627
  }
5029
5628
  }
5030
- function startCallbackRetryMonitor({ statePath }) {
5629
+ function startCallbackRetryMonitor({ statePath, delayMs = CALLBACK_RETRY_DELAYS_MS[0] }) {
5630
+ const normalizedDelayMs = Math.max(0, Number.isFinite(Number(delayMs)) ? Number(delayMs) : CALLBACK_RETRY_DELAYS_MS[0]);
5031
5631
  const child = spawn(process.execPath, [
5032
5632
  new URL(import.meta.url).pathname,
5033
5633
  "monitor",
5034
5634
  "--callback-retry",
5035
5635
  "--state",
5036
- statePath
5636
+ statePath,
5637
+ "--callback-retry-delay-ms",
5638
+ String(normalizedDelayMs)
5037
5639
  ], {
5038
5640
  detached: true,
5039
5641
  stdio: "ignore",
@@ -5045,27 +5647,45 @@ function startCallbackRetryMonitor({ statePath }) {
5045
5647
  }
5046
5648
  function runCallbackRetryMonitor(options) {
5047
5649
  const statePath = expandHome(required(options.state, "--state is required"));
5650
+ const initialDelayMs = Math.max(0, Number.isFinite(Number(options.callbackRetryDelayMs))
5651
+ ? Number(options.callbackRetryDelayMs)
5652
+ : CALLBACK_RETRY_DELAYS_MS[0]);
5653
+ sleepSync(initialDelayMs);
5048
5654
  while (true) {
5049
5655
  const conversation = loadState(statePath);
5050
5656
  const callbackDelivery = isRecord(conversation.callback_delivery)
5051
5657
  ? conversation.callback_delivery
5052
5658
  : undefined;
5053
5659
  const attempts = Number(callbackDelivery?.attempts ?? 0);
5054
- if (conversation.status !== "callback_failed" || !isRecord(callbackDelivery?.message)) {
5660
+ if (!["callback_pending", "callback_failed"].includes(conversation.status) ||
5661
+ !["pending", "failed"].includes(String(callbackDelivery?.status ?? "")) ||
5662
+ !isRecord(callbackDelivery?.message)) {
5055
5663
  return;
5056
5664
  }
5057
5665
  if (attempts > CALLBACK_RETRY_DELAYS_MS.length) {
5058
5666
  return;
5059
5667
  }
5060
- const delayMs = CALLBACK_RETRY_DELAYS_MS[Math.max(0, attempts - 1)];
5061
- sleepSync(delayMs);
5062
- const releaseLock = acquireFileLock(`${statePath}.lock`);
5668
+ let releaseLock;
5669
+ try {
5670
+ releaseLock = acquireFileLock(`${statePath}.lock`);
5671
+ }
5672
+ catch (error) {
5673
+ if (isRecord(error) && error.code === "LOCK_TIMEOUT") {
5674
+ sleepSync(1000);
5675
+ continue;
5676
+ }
5677
+ throw error;
5678
+ }
5063
5679
  try {
5064
5680
  const current = loadState(statePath);
5065
5681
  const currentDelivery = isRecord(current.callback_delivery)
5066
5682
  ? current.callback_delivery
5067
5683
  : undefined;
5068
- if (current.status !== "callback_failed" || !isRecord(currentDelivery?.message)) {
5684
+ const currentAttempts = Number(currentDelivery?.attempts ?? 0);
5685
+ if (!["callback_pending", "callback_failed"].includes(current.status) ||
5686
+ !["pending", "failed"].includes(String(currentDelivery?.status ?? "")) ||
5687
+ !isRecord(currentDelivery?.message) ||
5688
+ currentAttempts > CALLBACK_RETRY_DELAYS_MS.length) {
5069
5689
  return;
5070
5690
  }
5071
5691
  try {
@@ -5091,6 +5711,19 @@ function runCallbackRetryMonitor(options) {
5091
5711
  finally {
5092
5712
  releaseLock();
5093
5713
  }
5714
+ const latest = loadState(statePath);
5715
+ const latestDelivery = isRecord(latest.callback_delivery)
5716
+ ? latest.callback_delivery
5717
+ : undefined;
5718
+ const latestAttempts = Number(latestDelivery?.attempts ?? 0);
5719
+ if (!["callback_pending", "callback_failed"].includes(latest.status) ||
5720
+ !["pending", "failed"].includes(String(latestDelivery?.status ?? "")) ||
5721
+ !isRecord(latestDelivery?.message) ||
5722
+ latestAttempts > CALLBACK_RETRY_DELAYS_MS.length) {
5723
+ return;
5724
+ }
5725
+ const delayMs = CALLBACK_RETRY_DELAYS_MS[Math.max(0, latestAttempts - 1)];
5726
+ sleepSync(delayMs);
5094
5727
  }
5095
5728
  }
5096
5729
  async function runTerminalBridgeMonitor(options) {
@@ -5267,6 +5900,25 @@ async function runTerminalBridgeMonitorWithLock(options) {
5267
5900
  });
5268
5901
  const terminalStatus = poll.status;
5269
5902
  const approval = terminalStatus.approval_state;
5903
+ const currentScreenFingerprint = stringValue(terminalStatus?.screen?.digest) ??
5904
+ terminalBridgeScreenFingerprint(terminalStatus?.screen?.excerpt);
5905
+ const currentScreenChangedSinceSend = preSendScreenFingerprint !== undefined &&
5906
+ currentScreenFingerprint !== undefined &&
5907
+ currentScreenFingerprint !== preSendScreenFingerprint;
5908
+ if (executor.kind === "claude" &&
5909
+ isRecord(approval) &&
5910
+ approval.approvable === true &&
5911
+ approval.decision_mode === "keys" &&
5912
+ !currentScreenChangedSinceSend) {
5913
+ previousScreenFingerprint = currentScreenFingerprint;
5914
+ runtimeLog("warn", "claude_screen_approval_not_new", {
5915
+ conversation_id: conversation.conversation_id,
5916
+ terminal_target: terminalControl.target,
5917
+ reason: "permission screen is not proven to have changed since the managed send"
5918
+ });
5919
+ sleepSync(pollIntervalMs);
5920
+ continue;
5921
+ }
5270
5922
  if (isRecord(approval) && approval.blocked === true && approval.approvable !== true) {
5271
5923
  const approvalReason = stringValue(approval.reason) ??
5272
5924
  "Claude Code permission state cannot be safely resolved through AKK";
@@ -5288,8 +5940,67 @@ async function runTerminalBridgeMonitorWithLock(options) {
5288
5940
  logPath,
5289
5941
  terminalControl,
5290
5942
  terminalStatus,
5291
- fingerprint
5943
+ fingerprint,
5944
+ expectedConversation: {
5945
+ conversationId: conversation.conversation_id,
5946
+ status: conversation.status,
5947
+ updatedAt: conversation.updated_at,
5948
+ messageId: currentMessageId
5949
+ },
5950
+ onRecorded: (notificationConversation) => {
5951
+ const callbackMessage = createMessage({
5952
+ conversation: notificationConversation,
5953
+ from: executor.actor,
5954
+ to: "openclaw",
5955
+ type: "blocked",
5956
+ requiresResponse: true,
5957
+ body: [
5958
+ `${executor.display_name} is waiting at a permission state that AKK cannot safely approve.`,
5959
+ approvalReason,
5960
+ "",
5961
+ `Conversation: ${notificationConversation.conversation_id}`,
5962
+ `Terminal: ${terminalControl.target}`,
5963
+ "Review and resolve this dialog in the terminal manually. AKK intentionally sends no key when the request identity cannot be revalidated."
5964
+ ].join("\n"),
5965
+ metadata: {
5966
+ source: "terminal_bridge",
5967
+ reason: "approval_not_approvable",
5968
+ terminal_control: terminalControl,
5969
+ terminal_status: terminalStatus,
5970
+ approval_fingerprint: fingerprint
5971
+ }
5972
+ });
5973
+ if (notificationConversation.gateway_method) {
5974
+ runLockedCallback({
5975
+ ...options,
5976
+ statePath,
5977
+ log: logPath,
5978
+ messageJson: JSON.stringify(callbackMessage),
5979
+ gatewayMethod: notificationConversation.gateway_method,
5980
+ gatewaySession: notificationConversation.gateway_session,
5981
+ openclawSession: notificationConversation.openclaw_session,
5982
+ openclawBin: notificationConversation.openclaw_bin,
5983
+ gatewayUrl: stringValue(notificationConversation.gateway_token)
5984
+ ? notificationConversation.gateway_url
5985
+ : undefined,
5986
+ token: stringValue(notificationConversation.gateway_token)
5987
+ });
5988
+ return {
5989
+ callbackMessage,
5990
+ delivered: true
5991
+ };
5992
+ }
5993
+ return {
5994
+ callbackMessage,
5995
+ delivered: false
5996
+ };
5997
+ }
5292
5998
  });
5999
+ if (notification.stale) {
6000
+ previousScreenFingerprint = currentScreenFingerprint;
6001
+ sleepSync(pollIntervalMs);
6002
+ continue;
6003
+ }
5293
6004
  if (notification.duplicate) {
5294
6005
  printJson({
5295
6006
  conversation: notification.conversation,
@@ -5304,43 +6015,7 @@ async function runTerminalBridgeMonitorWithLock(options) {
5304
6015
  });
5305
6016
  return;
5306
6017
  }
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
- });
6018
+ if (notification.recorded?.delivered) {
5344
6019
  return;
5345
6020
  }
5346
6021
  printJson({
@@ -5350,7 +6025,7 @@ async function runTerminalBridgeMonitorWithLock(options) {
5350
6025
  awaiting_approval: true,
5351
6026
  approvable: false,
5352
6027
  delivered: false,
5353
- message: callbackMessage,
6028
+ message: notification.recorded?.callbackMessage,
5354
6029
  reason: "gateway_method_missing",
5355
6030
  terminal_control: terminalControl,
5356
6031
  terminal_status: terminalStatus
@@ -5373,8 +6048,74 @@ async function runTerminalBridgeMonitorWithLock(options) {
5373
6048
  logPath,
5374
6049
  terminalControl,
5375
6050
  terminalStatus,
5376
- fingerprint
6051
+ fingerprint,
6052
+ expectedConversation: {
6053
+ conversationId: conversation.conversation_id,
6054
+ status: conversation.status,
6055
+ updatedAt: conversation.updated_at,
6056
+ messageId: currentMessageId
6057
+ },
6058
+ onRecorded: (notificationConversation) => {
6059
+ const callbackMessage = createMessage({
6060
+ conversation: notificationConversation,
6061
+ from: executor.actor,
6062
+ to: "openclaw",
6063
+ type: "question",
6064
+ requiresResponse: true,
6065
+ body: terminalBridgeApprovalInstructions({
6066
+ conversation: notificationConversation,
6067
+ terminalControl,
6068
+ terminalStatus
6069
+ }),
6070
+ metadata: {
6071
+ source: "terminal_bridge",
6072
+ reason: "approval_required",
6073
+ terminal_control: terminalControl,
6074
+ terminal_status: terminalStatus,
6075
+ approval_fingerprint: fingerprint,
6076
+ approval_candidate: terminalBridgeApprovalCandidate({
6077
+ executor,
6078
+ terminalControl,
6079
+ terminalStatus,
6080
+ fingerprint
6081
+ }),
6082
+ approve_command: `AKK approve ${notificationConversation.conversation_id} --expected-approval-fingerprint ${fingerprint}`,
6083
+ deny_command: `AKK cancel ${notificationConversation.conversation_id}`,
6084
+ approve_tool: "agent_knock_knock_approve",
6085
+ deny_tool: "agent_knock_knock_cancel"
6086
+ }
6087
+ });
6088
+ if (notificationConversation.gateway_method) {
6089
+ runLockedCallback({
6090
+ ...options,
6091
+ statePath,
6092
+ log: logPath,
6093
+ messageJson: JSON.stringify(callbackMessage),
6094
+ gatewayMethod: notificationConversation.gateway_method,
6095
+ gatewaySession: notificationConversation.gateway_session,
6096
+ openclawSession: notificationConversation.openclaw_session,
6097
+ openclawBin: notificationConversation.openclaw_bin,
6098
+ gatewayUrl: stringValue(notificationConversation.gateway_token)
6099
+ ? notificationConversation.gateway_url
6100
+ : undefined,
6101
+ token: stringValue(notificationConversation.gateway_token)
6102
+ });
6103
+ return {
6104
+ callbackMessage,
6105
+ delivered: true
6106
+ };
6107
+ }
6108
+ return {
6109
+ callbackMessage,
6110
+ delivered: false
6111
+ };
6112
+ }
5377
6113
  });
6114
+ if (notification.stale) {
6115
+ previousScreenFingerprint = currentScreenFingerprint;
6116
+ sleepSync(pollIntervalMs);
6117
+ continue;
6118
+ }
5378
6119
  if (notification.duplicate) {
5379
6120
  printJson({
5380
6121
  conversation: notification.conversation,
@@ -5387,48 +6128,7 @@ async function runTerminalBridgeMonitorWithLock(options) {
5387
6128
  });
5388
6129
  return;
5389
6130
  }
5390
- const callbackMessage = createMessage({
5391
- conversation: notification.conversation,
5392
- from: executor.actor,
5393
- to: "openclaw",
5394
- type: "question",
5395
- requiresResponse: true,
5396
- body: terminalBridgeApprovalInstructions({
5397
- conversation: notification.conversation,
5398
- terminalControl,
5399
- terminalStatus
5400
- }),
5401
- metadata: {
5402
- source: "terminal_bridge",
5403
- reason: "approval_required",
5404
- terminal_control: terminalControl,
5405
- terminal_status: terminalStatus,
5406
- approval_fingerprint: fingerprint,
5407
- approval_candidate: terminalBridgeApprovalCandidate({
5408
- executor,
5409
- terminalControl,
5410
- terminalStatus,
5411
- fingerprint
5412
- }),
5413
- approve_command: `AKK approve ${notification.conversation.conversation_id} --expected-approval-fingerprint ${fingerprint}`,
5414
- deny_command: `AKK cancel ${notification.conversation.conversation_id}`,
5415
- approve_tool: "agent_knock_knock_approve",
5416
- deny_tool: "agent_knock_knock_cancel"
5417
- }
5418
- });
5419
- if (notification.conversation.gateway_method) {
5420
- runLockedCallback({
5421
- ...options,
5422
- statePath,
5423
- log: logPath,
5424
- messageJson: JSON.stringify(callbackMessage),
5425
- gatewayMethod: notification.conversation.gateway_method,
5426
- gatewaySession: notification.conversation.gateway_session,
5427
- openclawSession: notification.conversation.openclaw_session,
5428
- openclawBin: notification.conversation.openclaw_bin,
5429
- gatewayUrl: stringValue(notification.conversation.gateway_token) ? notification.conversation.gateway_url : undefined,
5430
- token: stringValue(notification.conversation.gateway_token)
5431
- });
6131
+ if (notification.recorded?.delivered) {
5432
6132
  return;
5433
6133
  }
5434
6134
  printJson({
@@ -5437,14 +6137,14 @@ async function runTerminalBridgeMonitorWithLock(options) {
5437
6137
  terminal_bridge: true,
5438
6138
  awaiting_approval: true,
5439
6139
  delivered: false,
5440
- message: callbackMessage,
6140
+ message: notification.recorded?.callbackMessage,
5441
6141
  reason: "gateway_method_missing",
5442
6142
  terminal_control: terminalControl,
5443
6143
  terminal_status: terminalStatus
5444
6144
  });
5445
6145
  return;
5446
6146
  }
5447
- const screenFingerprint = terminalBridgeScreenFingerprint(terminalStatus?.screen?.excerpt);
6147
+ const screenFingerprint = currentScreenFingerprint;
5448
6148
  const screenChanged = previousScreenFingerprint !== undefined &&
5449
6149
  screenFingerprint !== undefined &&
5450
6150
  screenFingerprint !== previousScreenFingerprint;
@@ -5730,6 +6430,7 @@ function tryAcquireTerminalBridgeMonitorLock(statePath, terminalMessageId) {
5730
6430
  }
5731
6431
  }
5732
6432
  function terminalBridgeSendLockPath(storeDir, terminalControl) {
6433
+ ensureDir(storeDir);
5733
6434
  const terminalKey = createHash("sha256")
5734
6435
  .update(JSON.stringify({
5735
6436
  target: terminalControl.target,
@@ -6215,6 +6916,18 @@ function runLockedCallback(options) {
6215
6916
  CONVERSATION_STATUSES.has(storedFinalStatus)
6216
6917
  ? storedFinalStatus
6217
6918
  : nextConversation.status;
6919
+ const callbackRetryDelayMs = CALLBACK_RETRY_DELAYS_MS[Math.min(CALLBACK_RETRY_DELAYS_MS.length - 1, Math.max(0, deliveryAttempt - 1))];
6920
+ const callbackWatchdog = trackCallbackDelivery &&
6921
+ requiresDelivery &&
6922
+ options.recordOnly !== true &&
6923
+ !retryingPending &&
6924
+ options.disableCallbackRetry !== true &&
6925
+ deliveryAttempt <= CALLBACK_RETRY_DELAYS_MS.length
6926
+ ? startCallbackRetryMonitor({
6927
+ statePath: options.statePath,
6928
+ delayMs: callbackRetryDelayMs
6929
+ })
6930
+ : undefined;
6218
6931
  if (!retryingPending && !recoveringTerminalCompletion) {
6219
6932
  appendEvent(logPath, messageEvent(message));
6220
6933
  }
@@ -6235,7 +6948,13 @@ function runLockedCallback(options) {
6235
6948
  openclaw_bin: options.openclawBin ?? conversation.openclaw_bin,
6236
6949
  close_terminal_bridge_on_done: closeTerminalBridgeOnDone,
6237
6950
  track_delivery: true,
6238
- final_status: finalStatus
6951
+ final_status: finalStatus,
6952
+ ...(callbackWatchdog
6953
+ ? {
6954
+ retry_monitor_pid: callbackWatchdog.pid ?? null,
6955
+ next_attempt_at: new Date(Date.now() + callbackRetryDelayMs).toISOString()
6956
+ }
6957
+ : {})
6239
6958
  },
6240
6959
  updated_at: now
6241
6960
  };
@@ -6249,6 +6968,18 @@ function runLockedCallback(options) {
6249
6968
  message_id: message.id,
6250
6969
  attempt: deliveryAttempt
6251
6970
  });
6971
+ if (callbackWatchdog) {
6972
+ appendEvent(logPath, {
6973
+ ts: new Date().toISOString(),
6974
+ conversation_id: conversation.conversation_id,
6975
+ event: "callback_retry_monitor_launched",
6976
+ message_id: message.id,
6977
+ pid: callbackWatchdog.pid ?? null,
6978
+ next_attempt_at: isRecord(nextConversation.callback_delivery)
6979
+ ? nextConversation.callback_delivery.next_attempt_at
6980
+ : undefined
6981
+ });
6982
+ }
6252
6983
  }
6253
6984
  saveState(options.statePath, nextConversation);
6254
6985
  runtimeLog("info", "callback_received", {
@@ -7476,54 +8207,84 @@ function cleanupIdleConversations(storeDir, options = {}, now = new Date()) {
7476
8207
  }
7477
8208
  const conversations = listConversations(storeDir);
7478
8209
  let closed = 0;
7479
- for (const conversation of conversations) {
7480
- if (conversation.status !== "idle" || !conversation.idle_since) {
8210
+ for (const listedConversation of conversations) {
8211
+ if (listedConversation.status !== "idle" || !listedConversation.idle_since) {
7481
8212
  continue;
7482
8213
  }
7483
- const idleSinceMs = Date.parse(conversation.idle_since);
7484
- if (!Number.isFinite(idleSinceMs)) {
8214
+ const listedIdleSinceMs = Date.parse(listedConversation.idle_since);
8215
+ if (!Number.isFinite(listedIdleSinceMs)) {
7485
8216
  continue;
7486
8217
  }
7487
- const terminalBridge = terminalBridgeEnabled(conversation) &&
7488
- isRecord(conversation.native_session_takeover) &&
7489
- typeof conversation.native_session_takeover.terminal_bridge_message_id === "string";
7490
- if (!terminalBridge && now.getTime() - idleSinceMs < timeoutMinutes * 60 * 1000) {
8218
+ const listedTerminalBridge = terminalBridgeEnabled(listedConversation) &&
8219
+ isRecord(listedConversation.native_session_takeover) &&
8220
+ typeof listedConversation.native_session_takeover.terminal_bridge_message_id === "string";
8221
+ if (!listedTerminalBridge && now.getTime() - listedIdleSinceMs < timeoutMinutes * 60 * 1000) {
7491
8222
  continue;
7492
8223
  }
7493
- const statePath = conversation.state_path ?? statePathForConversationId(conversation.conversation_id, storeDir);
7494
- const logPath = conversation.event_log_path ?? logPathForStatePath(statePath);
7495
- const closeReason = terminalBridge
7496
- ? "terminal bridge task completed"
7497
- : `idle timeout after ${timeoutMinutes} minutes`;
7498
- const closedConversation = {
7499
- ...conversation,
7500
- status: "closed",
7501
- closed_at: now.toISOString(),
7502
- close_reason: closeReason,
7503
- updated_at: now.toISOString()
7504
- };
7505
- delete closedConversation.idle_since;
7506
- saveState(statePath, closedConversation);
7507
- appendEvent(logPath, {
7508
- ts: now.toISOString(),
7509
- conversation_id: conversation.conversation_id,
7510
- event: "conversation_closed",
7511
- status: "closed",
7512
- reason: closedConversation.close_reason,
7513
- idle_timeout_minutes: timeoutMinutes,
7514
- terminal_bridge: terminalBridge
7515
- });
7516
- runtimeLog("info", "idle_conversation_closed", {
7517
- conversation_id: conversation.conversation_id,
7518
- agent: executorForConversation(conversation).kind,
7519
- executor_session: executorForConversation(conversation).session,
7520
- state_path: statePath,
7521
- event_log_path: logPath,
7522
- idle_since: conversation.idle_since,
7523
- idle_timeout_minutes: timeoutMinutes,
7524
- reason: closedConversation.close_reason
7525
- });
7526
- closed += 1;
8224
+ const statePath = listedConversation.state_path ??
8225
+ statePathForConversationId(listedConversation.conversation_id, storeDir);
8226
+ let releaseStateLock;
8227
+ try {
8228
+ releaseStateLock = acquireFileLock(`${statePath}.lock`);
8229
+ }
8230
+ catch (error) {
8231
+ if (isRecord(error) && error.code === "LOCK_TIMEOUT") {
8232
+ continue;
8233
+ }
8234
+ throw error;
8235
+ }
8236
+ try {
8237
+ const conversation = loadState(statePath);
8238
+ if (conversation.status !== "idle" || !conversation.idle_since) {
8239
+ continue;
8240
+ }
8241
+ const idleSinceMs = Date.parse(conversation.idle_since);
8242
+ if (!Number.isFinite(idleSinceMs)) {
8243
+ continue;
8244
+ }
8245
+ const terminalBridge = terminalBridgeEnabled(conversation) &&
8246
+ isRecord(conversation.native_session_takeover) &&
8247
+ typeof conversation.native_session_takeover.terminal_bridge_message_id === "string";
8248
+ if (!terminalBridge && now.getTime() - idleSinceMs < timeoutMinutes * 60 * 1000) {
8249
+ continue;
8250
+ }
8251
+ const logPath = conversation.event_log_path ?? logPathForStatePath(statePath);
8252
+ const closeReason = terminalBridge
8253
+ ? "terminal bridge task completed"
8254
+ : `idle timeout after ${timeoutMinutes} minutes`;
8255
+ const closedConversation = {
8256
+ ...conversation,
8257
+ status: "closed",
8258
+ closed_at: now.toISOString(),
8259
+ close_reason: closeReason,
8260
+ updated_at: now.toISOString()
8261
+ };
8262
+ delete closedConversation.idle_since;
8263
+ saveState(statePath, closedConversation);
8264
+ appendEvent(logPath, {
8265
+ ts: now.toISOString(),
8266
+ conversation_id: conversation.conversation_id,
8267
+ event: "conversation_closed",
8268
+ status: "closed",
8269
+ reason: closedConversation.close_reason,
8270
+ idle_timeout_minutes: timeoutMinutes,
8271
+ terminal_bridge: terminalBridge
8272
+ });
8273
+ runtimeLog("info", "idle_conversation_closed", {
8274
+ conversation_id: conversation.conversation_id,
8275
+ agent: executorForConversation(conversation).kind,
8276
+ executor_session: executorForConversation(conversation).session,
8277
+ state_path: statePath,
8278
+ event_log_path: logPath,
8279
+ idle_since: conversation.idle_since,
8280
+ idle_timeout_minutes: timeoutMinutes,
8281
+ reason: closedConversation.close_reason
8282
+ });
8283
+ closed += 1;
8284
+ }
8285
+ finally {
8286
+ releaseStateLock();
8287
+ }
7527
8288
  }
7528
8289
  return {
7529
8290
  checked: conversations.length,
@@ -7558,6 +8319,8 @@ function deliverToOpenClaw({ gatewayUrl, token, openclawSession, message }) {
7558
8319
  const result = spawnSync("acpx", ["--agent", agent, JSON.stringify(message)], {
7559
8320
  encoding: "utf8",
7560
8321
  maxBuffer: 1024 * 1024 * 10,
8322
+ timeout: CALLBACK_DELIVERY_TIMEOUT_MS,
8323
+ killSignal: "SIGKILL",
7561
8324
  env: openClawGatewayEnvironment(token)
7562
8325
  });
7563
8326
  if (result.error) {
@@ -7594,6 +8357,8 @@ function deliverToGatewayMethod({ method, openclawBin, gatewayUrl, token, sessio
7594
8357
  const result = spawnSync(openclawBin ?? "openclaw", args, {
7595
8358
  encoding: "utf8",
7596
8359
  maxBuffer: 1024 * 1024 * 10,
8360
+ timeout: CALLBACK_DELIVERY_TIMEOUT_MS,
8361
+ killSignal: "SIGKILL",
7597
8362
  env: openClawGatewayEnvironment(token)
7598
8363
  });
7599
8364
  if (result.error) {
@@ -7624,6 +8389,8 @@ function deliverToSessionSend({ openclawBin, gatewayUrl, token, params }) {
7624
8389
  const result = spawnSync(openclawBin ?? "openclaw", args, {
7625
8390
  encoding: "utf8",
7626
8391
  maxBuffer: 1024 * 1024 * 10,
8392
+ timeout: CALLBACK_DELIVERY_TIMEOUT_MS,
8393
+ killSignal: "SIGKILL",
7627
8394
  env: openClawGatewayEnvironment(token)
7628
8395
  });
7629
8396
  if (result.error) {
@@ -7654,6 +8421,8 @@ function deliverToChatSend({ openclawBin, gatewayUrl, token, params }) {
7654
8421
  const result = spawnSync(openclawBin ?? "openclaw", args, {
7655
8422
  encoding: "utf8",
7656
8423
  maxBuffer: 1024 * 1024 * 10,
8424
+ timeout: CALLBACK_DELIVERY_TIMEOUT_MS,
8425
+ killSignal: "SIGKILL",
7657
8426
  env: openClawGatewayEnvironment(token)
7658
8427
  });
7659
8428
  if (result.error) {
@@ -7776,6 +8545,12 @@ function redactCliOutput(value) {
7776
8545
  if (key === "gateway_token" || key === "gatewayToken") {
7777
8546
  return [];
7778
8547
  }
8548
+ if (key === "claude_transcript_anchor" ||
8549
+ key === "claudeTranscriptAnchor" ||
8550
+ key === "claude_home" ||
8551
+ key === "claudeHome") {
8552
+ return [];
8553
+ }
7779
8554
  if ((key === "callback_command" || key === "callbackCommand") && typeof item === "string") {
7780
8555
  return [[key, redactString(item)]];
7781
8556
  }
@@ -7957,7 +8732,6 @@ function usage() {
7957
8732
  agent-knock-knock recover --conversation <id> [--session <name>] [--all-proxy <url>]
7958
8733
  agent-knock-knock close --conversation <id> [--reason <text>]
7959
8734
  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]
7961
8735
  agent-knock-knock doctor
7962
8736
  agent-knock-knock agent takeover --agent codex --session-id <id> --strategy terminate_then_resume|terminal_control|fork [--create-conversation]
7963
8737
  agent-knock-knock callback --state <file> --message-json <json> [--record-only]