@scotthuang/agent-knock-knock 0.2.49 → 0.2.51

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.
package/dist/src/cli.js CHANGED
@@ -7,7 +7,7 @@ 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
+ import { captureClaudeTranscriptAnchor, defaultClaudeHome, detectClaudeTranscriptCompletion, detectClaudeTranscriptPendingApproval } from "./claude-local-transcript-provider.js";
11
11
  import { ClaudeHookStore, ClaudeHookStoreError, defaultClaudeHookStoreDir } from "./claude-hook-store.js";
12
12
  import { claudePermissionHookOutput } from "./claude-hook-protocol.js";
13
13
  import { defaultClaudeSettingsPath, loadTrustedClaudeTokenjuiceLaunchers } from "./claude-hook-installer.js";
@@ -856,6 +856,12 @@ function createRuntimeTerminalAgentRegistry(options) {
856
856
  }),
857
857
  createClaudeTerminalAgentAdapter({
858
858
  agentRows: loadClaudeAgentRows(options),
859
+ detectPendingApproval(request) {
860
+ return detectClaudeTranscriptPendingApproval(request, {
861
+ claudeHome: expandHome(options.claudeHome),
862
+ agentRows: loadClaudeAgentRows(options)
863
+ });
864
+ },
859
865
  async detectDurableCompletion(request) {
860
866
  return detectClaudeTranscriptCompletion(request, {
861
867
  claudeHome: expandHome(options.claudeHome),
@@ -997,6 +1003,25 @@ function terminalRuntimeIdentityForConversation(conversation, terminalControl) {
997
1003
  terminalTarget: terminalControl.target
998
1004
  };
999
1005
  }
1006
+ function terminalDurableRequestForConversation(conversation, terminalControl) {
1007
+ const nativeTakeover = isRecord(conversation?.native_session_takeover)
1008
+ ? conversation.native_session_takeover
1009
+ : undefined;
1010
+ const runtime = terminalRuntimeIdentityForConversation(conversation, terminalControl);
1011
+ const requestText = String(nativeTakeover?.terminal_bridge_request_text ?? conversation?.user_request ?? "");
1012
+ return {
1013
+ sessionId: runtime.sessionId,
1014
+ cwd: stringValue(nativeTakeover?.source_cwd),
1015
+ requestText,
1016
+ requestHash: stringValue(nativeTakeover?.terminal_bridge_request_hash),
1017
+ startedAt: stringValue(nativeTakeover?.terminal_bridge_started_at),
1018
+ context: {
1019
+ conversation,
1020
+ nativeTakeover,
1021
+ ...runtime
1022
+ }
1023
+ };
1024
+ }
1000
1025
  async function migrateLegacyTerminalAgentIdentity({ conversation, statePath, logPath, options }) {
1001
1026
  const nativeTakeover = isRecord(conversation?.native_session_takeover)
1002
1027
  ? conversation.native_session_takeover
@@ -1714,6 +1739,151 @@ function startTerminalBridgeMonitorForConversation({ conversation, statePath, lo
1714
1739
  claudeHookStoreDir: options.claudeHookStoreDir ?? nativeTakeover?.["claude_hook_store_dir"]
1715
1740
  });
1716
1741
  }
1742
+ function startTerminalBridgeMonitorHandoffWatchdog({ conversation, statePath, logPath, options }) {
1743
+ if (options.disableTerminalBridgeMonitor === true ||
1744
+ !terminalBridgeEnabled(conversation)) {
1745
+ return undefined;
1746
+ }
1747
+ const nativeTakeover = isRecord(conversation.native_session_takeover)
1748
+ ? conversation.native_session_takeover
1749
+ : undefined;
1750
+ const terminalMessageId = stringValue(nativeTakeover?.terminal_bridge_message_id);
1751
+ if (!terminalMessageId) {
1752
+ return undefined;
1753
+ }
1754
+ const args = [
1755
+ new URL(import.meta.url).pathname,
1756
+ "monitor",
1757
+ "--terminal-bridge-handoff",
1758
+ "--state",
1759
+ statePath,
1760
+ "--log",
1761
+ logPath,
1762
+ "--expected-terminal-message-id",
1763
+ terminalMessageId
1764
+ ];
1765
+ const monitorPollIntervalMs = Number(options.monitorPollIntervalMs);
1766
+ if (Number.isFinite(monitorPollIntervalMs) && monitorPollIntervalMs > 0) {
1767
+ args.push("--monitor-poll-interval-ms", String(monitorPollIntervalMs));
1768
+ }
1769
+ const handoffPollIntervalMs = Number(options.monitorHandoffPollIntervalMs);
1770
+ if (Number.isFinite(handoffPollIntervalMs) && handoffPollIntervalMs > 0) {
1771
+ args.push("--monitor-handoff-poll-interval-ms", String(handoffPollIntervalMs));
1772
+ }
1773
+ const claudeHome = options.claudeHome ?? nativeTakeover?.["claude_home"];
1774
+ const claudeHookStoreDir = options.claudeHookStoreDir ??
1775
+ nativeTakeover?.["claude_hook_store_dir"];
1776
+ if (options.codexHome) {
1777
+ args.push("--codex-home", options.codexHome);
1778
+ }
1779
+ if (claudeHome) {
1780
+ args.push("--claude-home", claudeHome);
1781
+ }
1782
+ if (claudeHookStoreDir) {
1783
+ args.push("--claude-hook-store-dir", claudeHookStoreDir);
1784
+ }
1785
+ const child = spawn(process.execPath, args, {
1786
+ detached: true,
1787
+ stdio: "ignore",
1788
+ cwd: process.cwd(),
1789
+ env: environmentWithoutGatewayTokens()
1790
+ });
1791
+ child.unref();
1792
+ return child;
1793
+ }
1794
+ function ensureTerminalBridgeMonitorAfterApproval({ conversation, statePath, logPath, terminalControl, options, reason = "approval_resolved" }) {
1795
+ const nativeTakeover = isRecord(conversation.native_session_takeover)
1796
+ ? conversation.native_session_takeover
1797
+ : undefined;
1798
+ const terminalMessageId = stringValue(nativeTakeover?.terminal_bridge_message_id);
1799
+ const activeMonitor = terminalMessageId
1800
+ ? activeTerminalBridgeMonitorOwner(statePath, terminalMessageId)
1801
+ : undefined;
1802
+ const launchedMonitor = activeMonitor
1803
+ ? undefined
1804
+ : startTerminalBridgeMonitorForConversation({
1805
+ conversation,
1806
+ statePath,
1807
+ logPath,
1808
+ options
1809
+ });
1810
+ const handoffWatchdog = activeMonitor
1811
+ ? startTerminalBridgeMonitorHandoffWatchdog({
1812
+ conversation,
1813
+ statePath,
1814
+ logPath,
1815
+ options
1816
+ })
1817
+ : undefined;
1818
+ const monitorPid = activeMonitor?.ownerPid ?? launchedMonitor?.pid;
1819
+ const agentTimeoutMinutes = Number(options.agentTimeoutMinutes ??
1820
+ nativeTakeover?.terminal_bridge_inactivity_timeout_minutes ??
1821
+ DEFAULT_AGENT_TIMEOUT_MINUTES);
1822
+ const agentHardTimeoutMinutes = Number(options.agentHardTimeoutMinutes ??
1823
+ nativeTakeover?.terminal_bridge_hard_timeout_minutes ??
1824
+ DEFAULT_AGENT_HARD_TIMEOUT_MINUTES);
1825
+ if (activeMonitor) {
1826
+ appendEvent(logPath, {
1827
+ ts: new Date().toISOString(),
1828
+ conversation_id: conversation.conversation_id,
1829
+ event: "terminal_bridge_monitor_reused",
1830
+ pid: activeMonitor.ownerPid ?? null,
1831
+ terminal_control: terminalControl,
1832
+ reason,
1833
+ agent_timeout_minutes: agentTimeoutMinutes,
1834
+ agent_hard_timeout_minutes: agentHardTimeoutMinutes
1835
+ });
1836
+ runtimeLog("info", "terminal_bridge_monitor_reused", {
1837
+ conversation_id: conversation.conversation_id,
1838
+ monitor_pid: activeMonitor.ownerPid ?? null,
1839
+ terminal_target: terminalControl.target,
1840
+ reason
1841
+ });
1842
+ if (handoffWatchdog) {
1843
+ appendEvent(logPath, {
1844
+ ts: new Date().toISOString(),
1845
+ conversation_id: conversation.conversation_id,
1846
+ event: "terminal_bridge_monitor_handoff_watchdog_launch",
1847
+ pid: handoffWatchdog.pid ?? null,
1848
+ monitor_owner_pid: activeMonitor.ownerPid ?? null,
1849
+ terminal_bridge_message_id: terminalMessageId,
1850
+ terminal_control: terminalControl,
1851
+ reason
1852
+ });
1853
+ runtimeLog("info", "terminal_bridge_monitor_handoff_watchdog_launch", {
1854
+ conversation_id: conversation.conversation_id,
1855
+ watchdog_pid: handoffWatchdog.pid ?? null,
1856
+ monitor_owner_pid: activeMonitor.ownerPid ?? null,
1857
+ terminal_target: terminalControl.target,
1858
+ reason
1859
+ });
1860
+ }
1861
+ }
1862
+ else if (launchedMonitor) {
1863
+ appendEvent(logPath, {
1864
+ ts: new Date().toISOString(),
1865
+ conversation_id: conversation.conversation_id,
1866
+ event: "terminal_bridge_monitor_launch",
1867
+ pid: launchedMonitor.pid ?? null,
1868
+ terminal_control: terminalControl,
1869
+ reason,
1870
+ agent_timeout_minutes: agentTimeoutMinutes,
1871
+ agent_hard_timeout_minutes: agentHardTimeoutMinutes
1872
+ });
1873
+ runtimeLog("info", "terminal_bridge_monitor_launch", {
1874
+ conversation_id: conversation.conversation_id,
1875
+ monitor_pid: launchedMonitor.pid ?? null,
1876
+ terminal_target: terminalControl.target,
1877
+ reason
1878
+ });
1879
+ }
1880
+ return {
1881
+ activeMonitor,
1882
+ launchedMonitor,
1883
+ handoffWatchdog,
1884
+ monitorPid
1885
+ };
1886
+ }
1717
1887
  function terminalBridgeEnabled(conversation) {
1718
1888
  const nativeTakeover = isRecord(conversation.native_session_takeover)
1719
1889
  ? conversation.native_session_takeover
@@ -2434,6 +2604,24 @@ function terminalBridgeApprovalFingerprint({ terminalControl, terminalStatus })
2434
2604
  }))
2435
2605
  .digest("hex");
2436
2606
  }
2607
+ function claudeTranscriptApprovalIdentity(approvalState) {
2608
+ const policyEvidence = isRecord(approvalState?.policy_evidence)
2609
+ ? approvalState.policy_evidence
2610
+ : undefined;
2611
+ const requestId = stringValue(policyEvidence?.request_id);
2612
+ const evidenceFingerprint = stringValue(policyEvidence?.evidence_fingerprint);
2613
+ if (policyEvidence?.source !== "claude_transcript" ||
2614
+ policyEvidence?.kind !== "run_command" ||
2615
+ !requestId ||
2616
+ !evidenceFingerprint ||
2617
+ !/^[0-9a-f]{64}$/u.test(evidenceFingerprint)) {
2618
+ return undefined;
2619
+ }
2620
+ return {
2621
+ requestId,
2622
+ evidenceFingerprint
2623
+ };
2624
+ }
2437
2625
  function assertSafeClaudeTerminalSend(terminalStatus) {
2438
2626
  const approval = isRecord(terminalStatus?.approval_state)
2439
2627
  ? terminalStatus.approval_state
@@ -2470,6 +2658,8 @@ function terminalBridgeApprovalInstructions({ conversation, terminalControl, ter
2470
2658
  const requestDetail = stringValue(approval.request_detail);
2471
2659
  const requestId = stringValue(approval.request_id);
2472
2660
  const excerpt = stringValue(screen.excerpt) || "(No terminal excerpt was available.)";
2661
+ const requiresDirectTerminalReview = executor.kind === "claude" &&
2662
+ decisionMode === "keys";
2473
2663
  return [
2474
2664
  `${agentName} is waiting for approval in a terminal-controlled AKK session.`,
2475
2665
  "",
@@ -2487,6 +2677,13 @@ function terminalBridgeApprovalInstructions({ conversation, terminalControl, ter
2487
2677
  excerpt,
2488
2678
  "```",
2489
2679
  "",
2680
+ requiresDirectTerminalReview
2681
+ ? `Before asking for approval, have the user personally inspect the live ${terminalControl.kind} pane ${terminalControl.target}.`
2682
+ : undefined,
2683
+ requiresDirectTerminalReview
2684
+ ? "This hookless callback intentionally omits raw command details; do not approve from the hash or summary alone."
2685
+ : undefined,
2686
+ requiresDirectTerminalReview ? "" : undefined,
2490
2687
  `Ask the user whether to approve or deny this ${agentName} request.`,
2491
2688
  "",
2492
2689
  "If the user approves, call `agent_knock_knock_approve` with:",
@@ -2534,13 +2731,86 @@ function recordTerminalBridgeApprovalNotification({ statePath, logPath, terminal
2534
2731
  const nativeTakeover = isRecord(conversation.native_session_takeover)
2535
2732
  ? { ...conversation.native_session_takeover }
2536
2733
  : {};
2734
+ const approvalScreenDigest = stringValue(isRecord(terminalStatus?.screen)
2735
+ ? terminalStatus.screen.digest
2736
+ : undefined);
2537
2737
  const previousApproval = isRecord(nativeTakeover.terminal_bridge_approval)
2538
2738
  ? nativeTakeover.terminal_bridge_approval
2539
2739
  : undefined;
2540
2740
  const previousNotifiedAt = validTimestampMs(previousApproval?.notified_at);
2741
+ const callbackDelivery = isRecord(conversation.callback_delivery)
2742
+ ? conversation.callback_delivery
2743
+ : undefined;
2744
+ const callbackMessage = isRecord(callbackDelivery?.message)
2745
+ ? callbackDelivery.message
2746
+ : undefined;
2747
+ const previousCallbackMessageId = stringValue(previousApproval?.callback_message_id);
2748
+ const matchingApprovalOutbox = callbackDelivery?.kind === "approval_notification" &&
2749
+ previousCallbackMessageId !== undefined &&
2750
+ callbackMessage?.id === previousCallbackMessageId;
2751
+ const callbackDeliveryStatus = stringValue(callbackDelivery?.status);
2752
+ const callbackDeliveryAttempts = Number(callbackDelivery?.attempts ?? 0);
2753
+ const conflictingActiveOutbox = !matchingApprovalOutbox &&
2754
+ (callbackDeliveryStatus === "pending" ||
2755
+ (callbackDeliveryStatus === "failed" &&
2756
+ Number.isFinite(callbackDeliveryAttempts) &&
2757
+ callbackDeliveryAttempts <= CALLBACK_RETRY_DELAYS_MS.length));
2541
2758
  if (previousApproval?.fingerprint === fingerprint &&
2542
2759
  previousNotifiedAt !== undefined &&
2543
2760
  Date.now() - previousNotifiedAt <= CLAUDE_SCREEN_APPROVAL_TTL_MS) {
2761
+ if (conflictingActiveOutbox) {
2762
+ return {
2763
+ conversation,
2764
+ duplicate: false,
2765
+ stale: true,
2766
+ deferred: true,
2767
+ previousApproval,
2768
+ recorded: undefined
2769
+ };
2770
+ }
2771
+ if (!matchingApprovalOutbox && !conflictingActiveOutbox) {
2772
+ const recoveryMessageId = previousCallbackMessageId ?? `msg-${randomUUID()}`;
2773
+ const recoveryMessageTs = stringValue(previousApproval?.callback_message_ts) ??
2774
+ stringValue(previousApproval?.notified_at) ??
2775
+ new Date().toISOString();
2776
+ const recoveryConversation = previousCallbackMessageId
2777
+ ? conversation
2778
+ : {
2779
+ ...conversation,
2780
+ native_session_takeover: {
2781
+ ...nativeTakeover,
2782
+ terminal_bridge_approval: {
2783
+ ...previousApproval,
2784
+ callback_message_id: recoveryMessageId,
2785
+ callback_message_ts: recoveryMessageTs
2786
+ }
2787
+ }
2788
+ };
2789
+ if (!previousCallbackMessageId) {
2790
+ saveState(statePath, recoveryConversation);
2791
+ }
2792
+ const recorded = onRecorded?.(recoveryConversation, {
2793
+ recoverMissingOutbox: true
2794
+ });
2795
+ appendEvent(logPath, {
2796
+ ts: new Date().toISOString(),
2797
+ conversation_id: recoveryConversation.conversation_id,
2798
+ event: "terminal_bridge_approval_notification_outbox_recovered",
2799
+ terminal_control: terminalControl,
2800
+ fingerprint,
2801
+ callback_message_id: recoveryMessageId
2802
+ });
2803
+ return {
2804
+ conversation: isRecord(recorded) && isRecord(recorded.prepared)
2805
+ ? recorded.prepared.conversation
2806
+ : recoveryConversation,
2807
+ duplicate: false,
2808
+ recovered: true,
2809
+ stale: false,
2810
+ previousApproval,
2811
+ recorded
2812
+ };
2813
+ }
2544
2814
  return {
2545
2815
  conversation,
2546
2816
  duplicate: true,
@@ -2550,38 +2820,113 @@ function recordTerminalBridgeApprovalNotification({ statePath, logPath, terminal
2550
2820
  };
2551
2821
  }
2552
2822
  const now = new Date().toISOString();
2823
+ const callbackMessageId = `msg-${randomUUID()}`;
2553
2824
  const nextConversation = {
2554
2825
  ...conversation,
2555
2826
  native_session_takeover: {
2556
2827
  ...nativeTakeover,
2557
2828
  terminal_bridge_approval: {
2558
2829
  fingerprint,
2830
+ screen_digest: approvalScreenDigest,
2559
2831
  notified_at: now,
2560
2832
  terminal_control: terminalControl,
2561
- approval_state: terminalStatus.approval_state
2833
+ approval_state: terminalStatus.approval_state,
2834
+ callback_message_id: callbackMessageId,
2835
+ callback_message_ts: now
2562
2836
  }
2563
2837
  },
2564
2838
  updated_at: now
2565
2839
  };
2840
+ // Persist the stable callback identity before any message/outbox event. If
2841
+ // the process exits during callback preparation, recovery can recreate the
2842
+ // exact same message and safely finish the outbox transaction.
2566
2843
  saveState(statePath, nextConversation);
2567
2844
  appendEvent(logPath, {
2568
2845
  ts: now,
2569
2846
  conversation_id: conversation.conversation_id,
2570
2847
  event: "terminal_bridge_approval_notification_recorded",
2571
2848
  terminal_control: terminalControl,
2572
- fingerprint
2849
+ fingerprint,
2850
+ screen_digest: approvalScreenDigest
2573
2851
  });
2852
+ const recorded = onRecorded?.(nextConversation);
2574
2853
  return {
2575
- conversation: nextConversation,
2854
+ conversation: isRecord(recorded) && isRecord(recorded.prepared)
2855
+ ? recorded.prepared.conversation
2856
+ : nextConversation,
2576
2857
  duplicate: false,
2577
2858
  stale: false,
2578
- recorded: onRecorded?.(nextConversation)
2859
+ recorded
2860
+ };
2861
+ }
2862
+ finally {
2863
+ releaseLock();
2864
+ }
2865
+ }
2866
+ function markTerminalBridgeApprovalPromptCleared({ statePath, logPath, expectedConversationId, expectedMessageId }) {
2867
+ const releaseLock = acquireFileLock(`${statePath}.lock`);
2868
+ try {
2869
+ const conversation = loadState(statePath);
2870
+ const nativeTakeover = isRecord(conversation.native_session_takeover)
2871
+ ? conversation.native_session_takeover
2872
+ : undefined;
2873
+ if (conversation.conversation_id !== expectedConversationId ||
2874
+ conversation.status !== "waiting_for_agent" ||
2875
+ nativeTakeover?.terminal_bridge !== true ||
2876
+ stringValue(nativeTakeover.terminal_bridge_message_id) !==
2877
+ expectedMessageId ||
2878
+ stringValue(nativeTakeover.terminal_bridge_last_approval_message_id) !==
2879
+ expectedMessageId ||
2880
+ validTimestampMs(nativeTakeover.terminal_bridge_approval_resolved_at) === undefined ||
2881
+ validTimestampMs(nativeTakeover.terminal_bridge_last_approval_prompt_cleared_at) !== undefined) {
2882
+ return {
2883
+ conversation,
2884
+ marked: false
2885
+ };
2886
+ }
2887
+ const clearedAt = new Date().toISOString();
2888
+ const nextConversation = {
2889
+ ...conversation,
2890
+ native_session_takeover: {
2891
+ ...nativeTakeover,
2892
+ terminal_bridge_last_approval_prompt_cleared_at: clearedAt
2893
+ },
2894
+ updated_at: clearedAt
2895
+ };
2896
+ saveState(statePath, nextConversation);
2897
+ appendEvent(logPath, {
2898
+ ts: clearedAt,
2899
+ conversation_id: conversation.conversation_id,
2900
+ event: "terminal_bridge_approval_prompt_cleared",
2901
+ terminal_bridge_message_id: expectedMessageId
2902
+ });
2903
+ return {
2904
+ conversation: nextConversation,
2905
+ marked: true
2579
2906
  };
2580
2907
  }
2581
2908
  finally {
2582
2909
  releaseLock();
2583
2910
  }
2584
2911
  }
2912
+ function terminalBridgeApprovalCallbackIdentity(conversation) {
2913
+ const nativeTakeover = isRecord(conversation?.native_session_takeover)
2914
+ ? conversation.native_session_takeover
2915
+ : undefined;
2916
+ const approval = isRecord(nativeTakeover?.terminal_bridge_approval)
2917
+ ? nativeTakeover.terminal_bridge_approval
2918
+ : undefined;
2919
+ const id = stringValue(approval?.callback_message_id);
2920
+ const timestamp = stringValue(approval?.callback_message_ts);
2921
+ const timestampMs = validTimestampMs(timestamp);
2922
+ if (!id || timestampMs === undefined) {
2923
+ throw new Error("terminal approval notification has no stable callback identity");
2924
+ }
2925
+ return {
2926
+ id,
2927
+ now: new Date(timestampMs)
2928
+ };
2929
+ }
2585
2930
  function prepareManagedSend({ options, statePath, logPath, messageBody, stateLockHeld = false, persist = true, rejectTerminalControl = false }) {
2586
2931
  if (!stateLockHeld) {
2587
2932
  const releaseLock = acquireFileLock(`${statePath}.lock`);
@@ -3129,6 +3474,7 @@ async function runApprove(options) {
3129
3474
  const suppliedExpectedFingerprint = stringValue(options.expectedApprovalFingerprint);
3130
3475
  const expectedFingerprint = suppliedExpectedFingerprint ??
3131
3476
  stringValue(monitoredApproval?.fingerprint);
3477
+ const autoApproved = options.autoApproved === true;
3132
3478
  const hooklessClaudeScreenApproval = executor.kind === "claude" &&
3133
3479
  nativeTakeover?.claude_hook_mode !== "enabled";
3134
3480
  if (hooklessClaudeScreenApproval) {
@@ -3138,6 +3484,39 @@ async function runApprove(options) {
3138
3484
  const pendingDispatch = isRecord(nativeTakeover?.terminal_bridge_approval_dispatch)
3139
3485
  ? nativeTakeover.terminal_bridge_approval_dispatch
3140
3486
  : undefined;
3487
+ const lastApprovalFingerprint = stringValue(nativeTakeover?.terminal_bridge_last_approval_fingerprint);
3488
+ const lastApprovalMessageId = stringValue(nativeTakeover?.terminal_bridge_last_approval_message_id);
3489
+ const currentMessageId = stringValue(nativeTakeover?.terminal_bridge_message_id);
3490
+ const approvalResolvedAt = validTimestampMs(nativeTakeover?.terminal_bridge_approval_resolved_at);
3491
+ if (autoApproved &&
3492
+ monitoredApproval === undefined &&
3493
+ pendingDispatch === undefined &&
3494
+ conversation.status === "waiting_for_agent" &&
3495
+ suppliedExpectedFingerprint !== undefined &&
3496
+ suppliedExpectedFingerprint === lastApprovalFingerprint &&
3497
+ lastApprovalMessageId !== undefined &&
3498
+ lastApprovalMessageId === currentMessageId &&
3499
+ approvalResolvedAt !== undefined) {
3500
+ const monitor = ensureTerminalBridgeMonitorAfterApproval({
3501
+ conversation,
3502
+ statePath,
3503
+ logPath,
3504
+ terminalControl,
3505
+ options,
3506
+ reason: "approval_already_resolved"
3507
+ });
3508
+ printJson({
3509
+ conversation,
3510
+ approved: false,
3511
+ already_approved: true,
3512
+ blocked: false,
3513
+ reason: "Claude screen approval fingerprint was already consumed",
3514
+ terminal_control: terminalControl,
3515
+ monitor_pid: monitor.monitorPid ?? null,
3516
+ monitor_handoff_pid: monitor.handoffWatchdog?.pid ?? null
3517
+ });
3518
+ return;
3519
+ }
3141
3520
  const notifiedAt = validTimestampMs(monitoredApproval?.notified_at);
3142
3521
  if (conversation.status !== "waiting_for_openclaw" ||
3143
3522
  monitoredState?.decision_mode !== "keys" ||
@@ -3197,7 +3576,6 @@ async function runApprove(options) {
3197
3576
  return;
3198
3577
  }
3199
3578
  }
3200
- const autoApproved = options.autoApproved === true;
3201
3579
  const policyRuleId = stringValue(options.policyRuleId);
3202
3580
  const policyFingerprint = stringValue(options.policyFingerprint);
3203
3581
  const autoApprovalPolicy = autoApproved
@@ -3205,6 +3583,28 @@ async function runApprove(options) {
3205
3583
  : undefined;
3206
3584
  const runtimeIdentity = terminalRuntimeIdentityForConversation(conversation, terminalControl);
3207
3585
  let executorPolicyDecision;
3586
+ const policyCandidateForInspection = ({ agent, currentTerminalControl, inspection, fingerprint }) => {
3587
+ const evidence = inspection.approval.approvable
3588
+ ? inspection.approval.policyEvidence
3589
+ : undefined;
3590
+ return {
3591
+ agent,
3592
+ kind: evidence?.kind ?? inspection.approval.promptKind ?? "unknown",
3593
+ decisionMode: inspection.approval.approvable
3594
+ ? inspection.approval.action.mode ?? "keys"
3595
+ : undefined,
3596
+ command: evidence?.command ?? inspection.approval.command,
3597
+ cwd: evidence?.cwd ?? inspection.approval.cwd ?? currentTerminalControl.currentPath,
3598
+ fingerprint: fingerprint ?? "",
3599
+ terminalTarget: currentTerminalControl.target,
3600
+ ...(evidence?.source === "claude_transcript"
3601
+ ? {
3602
+ evidenceSource: "claude_transcript",
3603
+ evidenceFingerprint: evidence.evidenceFingerprint
3604
+ }
3605
+ : {})
3606
+ };
3607
+ };
3208
3608
  const releaseTerminalLock = acquireFileLock(terminalBridgeSendLockPath(storeDirFromOptions(options), terminalControl), { timeoutMs: 30000 });
3209
3609
  let terminalLockReleased = false;
3210
3610
  const releaseApprovalTerminalLock = () => {
@@ -3245,8 +3645,11 @@ async function runApprove(options) {
3245
3645
  expectedFingerprint,
3246
3646
  scrollbackLines: Number(options.scrollbackLines ?? 120),
3247
3647
  runtime: runtimeIdentity,
3648
+ managedRequest: terminalDurableRequestForConversation(currentConversation, terminalControl),
3248
3649
  requiredDecisionMode: autoApproved && executor.kind === "claude"
3249
- ? "structured"
3650
+ ? hooklessClaudeScreenApproval
3651
+ ? "keys"
3652
+ : "structured"
3250
3653
  : undefined,
3251
3654
  authorize: autoApproved
3252
3655
  ? ({ agent, terminalControl: currentTerminalControl, inspection, fingerprint }) => {
@@ -3256,17 +3659,12 @@ async function runApprove(options) {
3256
3659
  reason: "automatic approval requires an executor-side policy"
3257
3660
  };
3258
3661
  }
3259
- const candidate = {
3662
+ const candidate = policyCandidateForInspection({
3260
3663
  agent,
3261
- kind: inspection.approval.promptKind ?? "unknown",
3262
- decisionMode: inspection.approval.approvable
3263
- ? inspection.approval.action.mode ?? "keys"
3264
- : undefined,
3265
- command: inspection.approval.command,
3266
- cwd: inspection.approval.cwd ?? currentTerminalControl.currentPath,
3267
- fingerprint: fingerprint ?? "",
3268
- terminalTarget: currentTerminalControl.target
3269
- };
3664
+ currentTerminalControl,
3665
+ inspection,
3666
+ fingerprint
3667
+ });
3270
3668
  executorPolicyDecision = evaluateApprovalPolicy({
3271
3669
  policy: autoApprovalPolicy,
3272
3670
  candidate
@@ -3294,7 +3692,36 @@ async function runApprove(options) {
3294
3692
  }
3295
3693
  : undefined,
3296
3694
  beforeKeyDispatch: hooklessClaudeScreenApproval
3297
- ? ({ fingerprint, terminalControl: dispatchControl, keys }) => {
3695
+ ? ({ fingerprint, terminalControl: dispatchControl, inspection, keys }) => {
3696
+ if (autoApproved) {
3697
+ if (!autoApprovalPolicy) {
3698
+ throw new Error("automatic approval requires an executor-side policy before dispatch");
3699
+ }
3700
+ const freshPolicyDecision = evaluateApprovalPolicy({
3701
+ policy: autoApprovalPolicy,
3702
+ candidate: policyCandidateForInspection({
3703
+ agent: executor.kind,
3704
+ currentTerminalControl: dispatchControl,
3705
+ inspection,
3706
+ fingerprint
3707
+ })
3708
+ });
3709
+ if (freshPolicyDecision.action !== "approve") {
3710
+ throw new Error(`executor-side auto-approval policy rejected the recaptured request: ${freshPolicyDecision.reason}`);
3711
+ }
3712
+ if (executorPolicyDecision?.ruleId &&
3713
+ freshPolicyDecision.ruleId !== executorPolicyDecision.ruleId) {
3714
+ throw new Error("executor-side auto-approval rule changed after recapture");
3715
+ }
3716
+ if (policyRuleId && freshPolicyDecision.ruleId !== policyRuleId) {
3717
+ throw new Error("executor-side auto-approval rule changed before dispatch");
3718
+ }
3719
+ if (policyFingerprint &&
3720
+ freshPolicyDecision.policyFingerprint !== policyFingerprint) {
3721
+ throw new Error("executor-side auto-approval policy changed before dispatch");
3722
+ }
3723
+ executorPolicyDecision = freshPolicyDecision;
3724
+ }
3298
3725
  if (releaseStateLock) {
3299
3726
  throw new Error("Claude approval dispatch was already reserved");
3300
3727
  }
@@ -3308,6 +3735,15 @@ async function runApprove(options) {
3308
3735
  ? latestTakeover.terminal_bridge_approval
3309
3736
  : undefined;
3310
3737
  const latestNotifiedAt = validTimestampMs(latestApproval?.notified_at);
3738
+ const latestApprovalState = isRecord(latestApproval?.approval_state)
3739
+ ? latestApproval.approval_state
3740
+ : undefined;
3741
+ const latestPolicyEvidence = isRecord(latestApprovalState?.policy_evidence)
3742
+ ? latestApprovalState.policy_evidence
3743
+ : undefined;
3744
+ const recapturedPolicyEvidence = inspection.approval.approvable
3745
+ ? inspection.approval.policyEvidence
3746
+ : undefined;
3311
3747
  const latestDispatch = isRecord(latestTakeover?.terminal_bridge_approval_dispatch)
3312
3748
  ? latestTakeover.terminal_bridge_approval_dispatch
3313
3749
  : undefined;
@@ -3320,7 +3756,11 @@ async function runApprove(options) {
3320
3756
  Date.now() - latestNotifiedAt > CLAUDE_SCREEN_APPROVAL_TTL_MS ||
3321
3757
  expectedFingerprint !== fingerprint ||
3322
3758
  latestControl?.target !== dispatchControl.target ||
3323
- latestControl?.socketPath !== dispatchControl.socketPath) {
3759
+ latestControl?.socketPath !== dispatchControl.socketPath ||
3760
+ (autoApproved &&
3761
+ (latestPolicyEvidence?.source !== "claude_transcript" ||
3762
+ latestPolicyEvidence.evidence_fingerprint !==
3763
+ recapturedPolicyEvidence?.evidenceFingerprint))) {
3324
3764
  throw new Error("approval state changed before terminal dispatch; refresh status and retry");
3325
3765
  }
3326
3766
  if (latestDispatch?.state === "reserved" &&
@@ -3425,6 +3865,14 @@ async function runApprove(options) {
3425
3865
  const nativeTakeoverForUpdate = isRecord(lockedConversation.native_session_takeover)
3426
3866
  ? { ...lockedConversation.native_session_takeover }
3427
3867
  : {};
3868
+ const resolvedApproval = isRecord(nativeTakeoverForUpdate.terminal_bridge_approval)
3869
+ ? nativeTakeoverForUpdate.terminal_bridge_approval
3870
+ : undefined;
3871
+ const resolvedApprovalScreenDigest = stringValue(resolvedApproval?.screen_digest);
3872
+ const resolvedApprovalState = isRecord(resolvedApproval?.approval_state)
3873
+ ? resolvedApproval.approval_state
3874
+ : undefined;
3875
+ const resolvedTranscriptIdentity = claudeTranscriptApprovalIdentity(resolvedApprovalState);
3428
3876
  const approvalResolvedAt = new Date().toISOString();
3429
3877
  const agentTimeoutMinutes = Number(options.agentTimeoutMinutes ??
3430
3878
  nativeTakeoverForUpdate.terminal_bridge_inactivity_timeout_minutes ??
@@ -3438,7 +3886,12 @@ async function runApprove(options) {
3438
3886
  terminal_bridge_approval_dispatch: undefined,
3439
3887
  terminal_bridge_approval_resolved_at: approvalResolvedAt,
3440
3888
  terminal_bridge_last_approval_fingerprint: actualFingerprint,
3889
+ terminal_bridge_last_approval_screen_digest: resolvedApprovalScreenDigest,
3890
+ terminal_bridge_last_approval_request_id: resolvedTranscriptIdentity?.requestId,
3891
+ terminal_bridge_last_approval_evidence_fingerprint: resolvedTranscriptIdentity?.evidenceFingerprint,
3892
+ terminal_bridge_last_approval_prompt_cleared_at: undefined,
3441
3893
  terminal_bridge_last_approval_at: approvalResolvedAt,
3894
+ terminal_bridge_last_approval_message_id: nativeTakeoverForUpdate.terminal_bridge_message_id,
3442
3895
  terminal_bridge_monitor_lock_version: TERMINAL_BRIDGE_MONITOR_LOCK_VERSION,
3443
3896
  terminal_bridge_monitor_started_at: approvalResolvedAt,
3444
3897
  terminal_bridge_last_activity_at: approvalResolvedAt,
@@ -3450,6 +3903,7 @@ async function runApprove(options) {
3450
3903
  };
3451
3904
  delete nextNativeTakeover.terminal_bridge_approval;
3452
3905
  delete nextNativeTakeover.terminal_bridge_approval_dispatch;
3906
+ delete nextNativeTakeover.terminal_bridge_last_approval_prompt_cleared_at;
3453
3907
  let nextConversation = {
3454
3908
  ...lockedConversation,
3455
3909
  status: terminalBridgeEnabled(lockedConversation)
@@ -3477,30 +3931,13 @@ async function runApprove(options) {
3477
3931
  saveState(statePath, nextConversation);
3478
3932
  releaseApprovalStateLock();
3479
3933
  releaseApprovalTerminalLock();
3480
- const bridgeMonitor = startTerminalBridgeMonitorForConversation({
3934
+ const bridgeMonitor = ensureTerminalBridgeMonitorAfterApproval({
3481
3935
  conversation: nextConversation,
3482
3936
  statePath,
3483
3937
  logPath,
3938
+ terminalControl,
3484
3939
  options
3485
3940
  });
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
3941
  printJson({
3505
3942
  conversation: nextConversation,
3506
3943
  approved: true,
@@ -3513,7 +3950,9 @@ async function runApprove(options) {
3513
3950
  approval_fingerprint: actualFingerprint,
3514
3951
  auto_approved: autoApproved,
3515
3952
  policy_rule_id: effectivePolicyRuleId,
3516
- monitor_pid: bridgeMonitor?.pid ?? null
3953
+ policy_fingerprint: effectivePolicyFingerprint,
3954
+ monitor_pid: bridgeMonitor.monitorPid ?? null,
3955
+ monitor_handoff_pid: bridgeMonitor.handoffWatchdog?.pid ?? null
3517
3956
  });
3518
3957
  }
3519
3958
  finally {
@@ -4836,7 +5275,9 @@ function prepareCallbackDeliveryReconciliation({ statePath, logPath, delayMs })
4836
5275
  const callbackDelivery = isRecord(conversation.callback_delivery)
4837
5276
  ? conversation.callback_delivery
4838
5277
  : undefined;
4839
- if (!["callback_pending", "callback_failed"].includes(conversation.status)) {
5278
+ const preservesConversationStatus = callbackDelivery?.preserve_conversation_status === true;
5279
+ if (!preservesConversationStatus &&
5280
+ !["callback_pending", "callback_failed"].includes(conversation.status)) {
4840
5281
  return {
4841
5282
  handled: false
4842
5283
  };
@@ -4852,6 +5293,14 @@ function prepareCallbackDeliveryReconciliation({ statePath, logPath, delayMs })
4852
5293
  reason: "callback_delivery_metadata_missing"
4853
5294
  };
4854
5295
  }
5296
+ if (!isRetryableCallbackDelivery(conversation, callbackDelivery)) {
5297
+ return {
5298
+ handled: true,
5299
+ conversationId,
5300
+ status: "skipped",
5301
+ reason: "callback_delivery_in_flight"
5302
+ };
5303
+ }
4855
5304
  if (!Number.isSafeInteger(attempts) || attempts < 1 ||
4856
5305
  attempts > CALLBACK_RETRY_DELAYS_MS.length) {
4857
5306
  return {
@@ -4971,10 +5420,18 @@ function latestTerminalBridgeMonitorLaunchPid(logPath) {
4971
5420
  const pid = Number(launch?.pid);
4972
5421
  return Number.isSafeInteger(pid) && pid > 1 ? pid : undefined;
4973
5422
  }
4974
- function prepareTerminalBridgeMonitorReconciliation({ statePath, expectedMessageId }) {
5423
+ function prepareTerminalBridgeMonitorReconciliation({ statePath, expectedMessageId, requireWaitingForAgentStatus = false }) {
4975
5424
  const releaseStateLock = acquireFileLock(`${statePath}.lock`);
4976
5425
  try {
4977
5426
  const conversation = loadState(statePath);
5427
+ if (requireWaitingForAgentStatus &&
5428
+ conversation.status !== "waiting_for_agent") {
5429
+ return {
5430
+ prepared: false,
5431
+ alreadyRunning: false,
5432
+ reason: `conversation_status_${String(conversation.status ?? "missing")}`
5433
+ };
5434
+ }
4978
5435
  const eligibility = terminalBridgeReconciliationEligibility(conversation);
4979
5436
  if (!eligibility.eligible) {
4980
5437
  return {
@@ -5495,6 +5952,9 @@ async function runMonitor(options) {
5495
5952
  if (options.callbackRetry) {
5496
5953
  return runCallbackRetryMonitor(options);
5497
5954
  }
5955
+ if (options.terminalBridgeHandoff) {
5956
+ return runTerminalBridgeMonitorHandoff(options);
5957
+ }
5498
5958
  if (options.terminalBridge) {
5499
5959
  return await runTerminalBridgeMonitor(options);
5500
5960
  }
@@ -5657,68 +6117,54 @@ function runCallbackRetryMonitor(options) {
5657
6117
  ? conversation.callback_delivery
5658
6118
  : undefined;
5659
6119
  const attempts = Number(callbackDelivery?.attempts ?? 0);
5660
- if (!["callback_pending", "callback_failed"].includes(conversation.status) ||
5661
- !["pending", "failed"].includes(String(callbackDelivery?.status ?? "")) ||
5662
- !isRecord(callbackDelivery?.message)) {
6120
+ if (!callbackDelivery ||
6121
+ !isRecord(callbackDelivery.message) ||
6122
+ !["pending", "failed"].includes(String(callbackDelivery.status ?? ""))) {
5663
6123
  return;
5664
6124
  }
5665
6125
  if (attempts > CALLBACK_RETRY_DELAYS_MS.length) {
5666
6126
  return;
5667
6127
  }
5668
- let releaseLock;
5669
- try {
5670
- releaseLock = acquireFileLock(`${statePath}.lock`);
5671
- }
5672
- catch (error) {
5673
- if (isRecord(error) && error.code === "LOCK_TIMEOUT") {
6128
+ if (!isRetryableCallbackDelivery(conversation, callbackDelivery)) {
6129
+ const attemptPidValue = Number(callbackDelivery.attempt_pid);
6130
+ const attemptPid = Number.isSafeInteger(attemptPidValue) && attemptPidValue > 0
6131
+ ? attemptPidValue
6132
+ : undefined;
6133
+ if (callbackDelivery.status === "pending" &&
6134
+ attemptPid !== undefined &&
6135
+ isProcessAlive(attemptPid)) {
5674
6136
  sleepSync(1000);
5675
6137
  continue;
5676
6138
  }
5677
- throw error;
6139
+ return;
5678
6140
  }
5679
6141
  try {
5680
- const current = loadState(statePath);
5681
- const currentDelivery = isRecord(current.callback_delivery)
5682
- ? current.callback_delivery
5683
- : undefined;
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) {
5689
- return;
5690
- }
5691
- try {
5692
- runLockedCallback({
5693
- statePath,
5694
- messageJson: JSON.stringify(currentDelivery.message),
5695
- gatewayMethod: stringValue(currentDelivery.gateway_method) ?? current.gateway_method,
5696
- gatewaySession: stringValue(currentDelivery.gateway_session) ?? current.gateway_session,
5697
- openclawSession: current.openclaw_session,
5698
- openclawBin: stringValue(currentDelivery.openclaw_bin) ?? current.openclaw_bin,
5699
- gatewayUrl: stringValue(currentDelivery.gateway_url) ?? current.gateway_url,
5700
- token: current.gateway_token,
5701
- closeTerminalBridgeOnDone: currentDelivery.close_terminal_bridge_on_done === true,
5702
- retryPending: true,
5703
- disableCallbackRetry: true
5704
- });
5705
- return;
5706
- }
5707
- catch {
5708
- // The failed attempt is persisted by runLockedCallback; continue with bounded backoff.
5709
- }
6142
+ runCallbackTransaction({
6143
+ statePath,
6144
+ messageJson: JSON.stringify(callbackDelivery.message),
6145
+ gatewayMethod: stringValue(callbackDelivery.gateway_method) ?? conversation.gateway_method,
6146
+ gatewaySession: stringValue(callbackDelivery.gateway_session) ?? conversation.gateway_session,
6147
+ openclawSession: conversation.openclaw_session,
6148
+ openclawBin: stringValue(callbackDelivery.openclaw_bin) ?? conversation.openclaw_bin,
6149
+ gatewayUrl: stringValue(callbackDelivery.gateway_url) ?? conversation.gateway_url,
6150
+ token: conversation.gateway_token,
6151
+ closeTerminalBridgeOnDone: callbackDelivery.close_terminal_bridge_on_done === true,
6152
+ retryPending: true,
6153
+ disableCallbackRetry: true
6154
+ });
6155
+ return;
5710
6156
  }
5711
- finally {
5712
- releaseLock();
6157
+ catch {
6158
+ // The failed attempt is persisted before the next bounded retry.
5713
6159
  }
5714
6160
  const latest = loadState(statePath);
5715
6161
  const latestDelivery = isRecord(latest.callback_delivery)
5716
6162
  ? latest.callback_delivery
5717
6163
  : undefined;
5718
6164
  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) ||
6165
+ if (!latestDelivery ||
6166
+ !isRecord(latestDelivery.message) ||
6167
+ !isRetryableCallbackDelivery(latest, latestDelivery) ||
5722
6168
  latestAttempts > CALLBACK_RETRY_DELAYS_MS.length) {
5723
6169
  return;
5724
6170
  }
@@ -5726,6 +6172,157 @@ function runCallbackRetryMonitor(options) {
5726
6172
  sleepSync(delayMs);
5727
6173
  }
5728
6174
  }
6175
+ function runTerminalBridgeMonitorHandoff(options) {
6176
+ const statePath = expandHome(required(options.state, "--state is required"));
6177
+ const logPath = expandHome(options.log ?? logPathForStatePath(statePath));
6178
+ const expectedMessageId = required(options.expectedTerminalMessageId, "--expected-terminal-message-id is required");
6179
+ const configuredPollIntervalMs = Number(options.monitorHandoffPollIntervalMs);
6180
+ const pollIntervalMs = Math.max(50, Number.isFinite(configuredPollIntervalMs)
6181
+ ? configuredPollIntervalMs
6182
+ : 100);
6183
+ const handoffLockPath = terminalBridgeMonitorHandoffLockPath(statePath, expectedMessageId);
6184
+ let releaseHandoffLock;
6185
+ try {
6186
+ releaseHandoffLock = acquireFileLock(handoffLockPath, { timeoutMs: 0 });
6187
+ }
6188
+ catch (error) {
6189
+ if (!isRecord(error) || error.code !== "LOCK_TIMEOUT") {
6190
+ throw error;
6191
+ }
6192
+ printJson({
6193
+ monitored: false,
6194
+ terminal_bridge: true,
6195
+ handoff_watchdog: false,
6196
+ already_running: true,
6197
+ reason: "terminal_bridge_monitor_handoff_watchdog_already_running"
6198
+ });
6199
+ return;
6200
+ }
6201
+ try {
6202
+ const startedConversation = loadState(statePath);
6203
+ appendEvent(logPath, {
6204
+ ts: new Date().toISOString(),
6205
+ conversation_id: startedConversation.conversation_id,
6206
+ event: "terminal_bridge_monitor_handoff_watchdog_started",
6207
+ terminal_bridge_message_id: expectedMessageId
6208
+ });
6209
+ while (true) {
6210
+ const conversation = loadState(statePath);
6211
+ const nativeTakeover = isRecord(conversation.native_session_takeover)
6212
+ ? conversation.native_session_takeover
6213
+ : undefined;
6214
+ const currentMessageId = stringValue(nativeTakeover?.terminal_bridge_message_id);
6215
+ if (currentMessageId !== expectedMessageId) {
6216
+ appendEvent(logPath, {
6217
+ ts: new Date().toISOString(),
6218
+ conversation_id: conversation.conversation_id,
6219
+ event: "terminal_bridge_monitor_handoff_watchdog_finished",
6220
+ terminal_bridge_message_id: expectedMessageId,
6221
+ current_terminal_bridge_message_id: currentMessageId,
6222
+ reason: "terminal_bridge_task_replaced"
6223
+ });
6224
+ return;
6225
+ }
6226
+ if (conversation.status === "waiting_for_openclaw") {
6227
+ sleepSync(pollIntervalMs);
6228
+ continue;
6229
+ }
6230
+ if (conversation.status !== "waiting_for_agent") {
6231
+ appendEvent(logPath, {
6232
+ ts: new Date().toISOString(),
6233
+ conversation_id: conversation.conversation_id,
6234
+ event: "terminal_bridge_monitor_handoff_watchdog_finished",
6235
+ terminal_bridge_message_id: expectedMessageId,
6236
+ status: conversation.status,
6237
+ reason: "conversation_no_longer_waiting_for_agent"
6238
+ });
6239
+ return;
6240
+ }
6241
+ const eligibility = terminalBridgeReconciliationEligibility(conversation);
6242
+ if (!eligibility.eligible) {
6243
+ appendEvent(logPath, {
6244
+ ts: new Date().toISOString(),
6245
+ conversation_id: conversation.conversation_id,
6246
+ event: "terminal_bridge_monitor_handoff_watchdog_finished",
6247
+ terminal_bridge_message_id: expectedMessageId,
6248
+ reason: eligibility.reason
6249
+ });
6250
+ return;
6251
+ }
6252
+ const activeOwner = activeTerminalBridgeMonitorOwner(statePath, expectedMessageId);
6253
+ if (activeOwner) {
6254
+ sleepSync(pollIntervalMs);
6255
+ continue;
6256
+ }
6257
+ const prepared = prepareTerminalBridgeMonitorReconciliation({
6258
+ statePath,
6259
+ expectedMessageId,
6260
+ requireWaitingForAgentStatus: true
6261
+ });
6262
+ if (!prepared.prepared) {
6263
+ if (prepared.alreadyRunning) {
6264
+ sleepSync(pollIntervalMs);
6265
+ continue;
6266
+ }
6267
+ appendEvent(logPath, {
6268
+ ts: new Date().toISOString(),
6269
+ conversation_id: conversation.conversation_id,
6270
+ event: "terminal_bridge_monitor_handoff_watchdog_finished",
6271
+ terminal_bridge_message_id: expectedMessageId,
6272
+ reason: prepared.reason
6273
+ });
6274
+ return;
6275
+ }
6276
+ const monitor = startTerminalBridgeMonitorForConversation({
6277
+ conversation: prepared.conversation,
6278
+ statePath,
6279
+ logPath,
6280
+ options
6281
+ });
6282
+ if (!monitor) {
6283
+ appendEvent(logPath, {
6284
+ ts: new Date().toISOString(),
6285
+ conversation_id: prepared.conversation.conversation_id,
6286
+ event: "terminal_bridge_monitor_handoff_watchdog_finished",
6287
+ terminal_bridge_message_id: expectedMessageId,
6288
+ reason: "terminal_bridge_monitor_launch_disabled"
6289
+ });
6290
+ return;
6291
+ }
6292
+ const launchedAt = new Date().toISOString();
6293
+ appendEvent(logPath, {
6294
+ ts: launchedAt,
6295
+ conversation_id: prepared.conversation.conversation_id,
6296
+ event: "terminal_bridge_monitor_launch",
6297
+ pid: monitor.pid ?? null,
6298
+ terminal_control: prepared.terminalControl,
6299
+ terminal_bridge_message_id: expectedMessageId,
6300
+ reason: "approval_handoff_reconciliation",
6301
+ agent_timeout_minutes: prepared.inactivityTimeoutMinutes,
6302
+ agent_hard_timeout_minutes: prepared.hardTimeoutMinutes
6303
+ });
6304
+ runtimeLog("info", "terminal_bridge_monitor_handoff_reconciled", {
6305
+ conversation_id: prepared.conversation.conversation_id,
6306
+ monitor_pid: monitor.pid ?? null,
6307
+ terminal_target: prepared.terminalControl.target,
6308
+ terminal_bridge_message_id: expectedMessageId
6309
+ });
6310
+ printJson({
6311
+ conversation: prepared.conversation,
6312
+ monitored: true,
6313
+ terminal_bridge: true,
6314
+ handoff_watchdog: true,
6315
+ launched: true,
6316
+ monitor_pid: monitor.pid ?? null,
6317
+ reason: "approval_handoff_reconciliation"
6318
+ });
6319
+ return;
6320
+ }
6321
+ }
6322
+ finally {
6323
+ releaseHandoffLock();
6324
+ }
6325
+ }
5729
6326
  async function runTerminalBridgeMonitor(options) {
5730
6327
  const statePath = expandHome(required(options.state, "--state is required"));
5731
6328
  const conversation = loadState(statePath);
@@ -5830,7 +6427,7 @@ async function runTerminalBridgeMonitorWithLock(options) {
5830
6427
  });
5831
6428
  return;
5832
6429
  }
5833
- const nativeTakeover = isRecord(conversation.native_session_takeover)
6430
+ let nativeTakeover = isRecord(conversation.native_session_takeover)
5834
6431
  ? conversation.native_session_takeover
5835
6432
  : undefined;
5836
6433
  const currentMessageId = stringValue(nativeTakeover?.["terminal_bridge_message_id"]);
@@ -5871,7 +6468,6 @@ async function runTerminalBridgeMonitorWithLock(options) {
5871
6468
  return;
5872
6469
  }
5873
6470
  const requestText = String(nativeTakeover?.["terminal_bridge_request_text"] ?? conversation.user_request ?? "");
5874
- const startedAt = stringValue(nativeTakeover?.["terminal_bridge_started_at"]);
5875
6471
  const terminalRuntime = terminalRuntimeIdentityForConversation(conversation, terminalControl);
5876
6472
  const screenChangedSinceSend = preSendScreenFingerprint !== undefined &&
5877
6473
  previousScreenFingerprint !== undefined &&
@@ -5885,18 +6481,7 @@ async function runTerminalBridgeMonitorWithLock(options) {
5885
6481
  screenChangedSinceSend,
5886
6482
  runtime: terminalRuntime
5887
6483
  },
5888
- durableRequest: {
5889
- sessionId: terminalRuntime.sessionId,
5890
- cwd: stringValue(nativeTakeover?.["source_cwd"]),
5891
- requestText,
5892
- requestHash: stringValue(nativeTakeover?.["terminal_bridge_request_hash"]),
5893
- startedAt,
5894
- context: {
5895
- conversation,
5896
- nativeTakeover,
5897
- ...terminalRuntime
5898
- }
5899
- }
6484
+ durableRequest: terminalDurableRequestForConversation(conversation, terminalControl)
5900
6485
  });
5901
6486
  const terminalStatus = poll.status;
5902
6487
  const approval = terminalStatus.approval_state;
@@ -5905,21 +6490,107 @@ async function runTerminalBridgeMonitorWithLock(options) {
5905
6490
  const currentScreenChangedSinceSend = preSendScreenFingerprint !== undefined &&
5906
6491
  currentScreenFingerprint !== undefined &&
5907
6492
  currentScreenFingerprint !== preSendScreenFingerprint;
6493
+ const currentClaudePermissionVisible = executor.kind === "claude" &&
6494
+ isRecord(approval) &&
6495
+ approval.blocked === true &&
6496
+ approval.prompt_kind === "claude_permission";
6497
+ const lastApprovalMessageMatches = currentMessageId !== undefined &&
6498
+ currentMessageId ===
6499
+ stringValue(nativeTakeover?.terminal_bridge_last_approval_message_id);
6500
+ const lastApprovalPromptCleared = validTimestampMs(nativeTakeover?.terminal_bridge_last_approval_prompt_cleared_at) !== undefined;
6501
+ if (executor.kind === "claude" &&
6502
+ terminalStatus.reachable === true &&
6503
+ approval.scanned === true &&
6504
+ !currentClaudePermissionVisible &&
6505
+ lastApprovalMessageMatches &&
6506
+ !lastApprovalPromptCleared &&
6507
+ validTimestampMs(nativeTakeover?.terminal_bridge_approval_resolved_at) !== undefined) {
6508
+ const cleared = markTerminalBridgeApprovalPromptCleared({
6509
+ statePath,
6510
+ logPath,
6511
+ expectedConversationId: conversation.conversation_id,
6512
+ expectedMessageId: currentMessageId
6513
+ });
6514
+ if (cleared.marked) {
6515
+ conversation = cleared.conversation;
6516
+ nativeTakeover = isRecord(conversation.native_session_takeover)
6517
+ ? conversation.native_session_takeover
6518
+ : undefined;
6519
+ }
6520
+ }
6521
+ let suppressApprovalNotification = false;
5908
6522
  if (executor.kind === "claude" &&
5909
6523
  isRecord(approval) &&
5910
6524
  approval.approvable === true &&
5911
6525
  approval.decision_mode === "keys" &&
5912
6526
  !currentScreenChangedSinceSend) {
5913
6527
  previousScreenFingerprint = currentScreenFingerprint;
6528
+ suppressApprovalNotification = true;
5914
6529
  runtimeLog("warn", "claude_screen_approval_not_new", {
5915
6530
  conversation_id: conversation.conversation_id,
5916
6531
  terminal_target: terminalControl.target,
5917
6532
  reason: "permission screen is not proven to have changed since the managed send"
5918
6533
  });
5919
- sleepSync(pollIntervalMs);
5920
- continue;
5921
6534
  }
5922
- if (isRecord(approval) && approval.blocked === true && approval.approvable !== true) {
6535
+ if (currentClaudePermissionVisible) {
6536
+ const observedFingerprint = terminalBridgeApprovalFingerprint({
6537
+ terminalControl,
6538
+ terminalStatus
6539
+ });
6540
+ const currentTranscriptIdentity = claudeTranscriptApprovalIdentity(approval);
6541
+ const lastApprovalRequestId = stringValue(nativeTakeover?.terminal_bridge_last_approval_request_id);
6542
+ const lastApprovalEvidenceFingerprint = stringValue(nativeTakeover
6543
+ ?.terminal_bridge_last_approval_evidence_fingerprint);
6544
+ const lastApprovalScreenDigest = stringValue(nativeTakeover?.terminal_bridge_last_approval_screen_digest);
6545
+ const sameConsumedTranscriptRequest = lastApprovalMessageMatches &&
6546
+ currentTranscriptIdentity !== undefined &&
6547
+ ((lastApprovalRequestId !== undefined &&
6548
+ currentTranscriptIdentity.requestId === lastApprovalRequestId) ||
6549
+ (lastApprovalEvidenceFingerprint !== undefined &&
6550
+ currentTranscriptIdentity.evidenceFingerprint ===
6551
+ lastApprovalEvidenceFingerprint));
6552
+ const promptClearWasObserved = validTimestampMs(nativeTakeover?.terminal_bridge_last_approval_prompt_cleared_at) !== undefined;
6553
+ const sameUnrepaintedConsumedScreen = lastApprovalMessageMatches &&
6554
+ currentTranscriptIdentity === undefined &&
6555
+ !promptClearWasObserved &&
6556
+ lastApprovalScreenDigest !== undefined &&
6557
+ currentScreenFingerprint === lastApprovalScreenDigest;
6558
+ const consumedPromptWithoutEvidenceBeforeClear = lastApprovalMessageMatches &&
6559
+ currentTranscriptIdentity === undefined &&
6560
+ !promptClearWasObserved &&
6561
+ stringValue(nativeTakeover?.terminal_bridge_last_approval_fingerprint) !== undefined;
6562
+ const legacySameConsumedApproval = lastApprovalMessageMatches &&
6563
+ !lastApprovalRequestId &&
6564
+ !lastApprovalEvidenceFingerprint &&
6565
+ !promptClearWasObserved &&
6566
+ (sameUnrepaintedConsumedScreen ||
6567
+ observedFingerprint ===
6568
+ stringValue(nativeTakeover?.terminal_bridge_last_approval_fingerprint));
6569
+ if (sameConsumedTranscriptRequest ||
6570
+ sameUnrepaintedConsumedScreen ||
6571
+ consumedPromptWithoutEvidenceBeforeClear ||
6572
+ legacySameConsumedApproval) {
6573
+ previousScreenFingerprint = currentScreenFingerprint;
6574
+ suppressApprovalNotification = true;
6575
+ runtimeLog("info", "claude_consumed_approval_screen_still_visible", {
6576
+ conversation_id: conversation.conversation_id,
6577
+ terminal_target: terminalControl.target,
6578
+ fingerprint: observedFingerprint,
6579
+ screen_digest: currentScreenFingerprint,
6580
+ reason: sameConsumedTranscriptRequest
6581
+ ? "same_transcript_request"
6582
+ : sameUnrepaintedConsumedScreen
6583
+ ? "same_unrepainted_screen"
6584
+ : legacySameConsumedApproval
6585
+ ? "legacy_consumed_approval"
6586
+ : "prompt_not_observed_cleared"
6587
+ });
6588
+ }
6589
+ }
6590
+ if (!suppressApprovalNotification &&
6591
+ isRecord(approval) &&
6592
+ approval.blocked === true &&
6593
+ approval.approvable !== true) {
5923
6594
  const approvalReason = stringValue(approval.reason) ??
5924
6595
  "Claude Code permission state cannot be safely resolved through AKK";
5925
6596
  appendEvent(logPath, {
@@ -5947,9 +6618,11 @@ async function runTerminalBridgeMonitorWithLock(options) {
5947
6618
  updatedAt: conversation.updated_at,
5948
6619
  messageId: currentMessageId
5949
6620
  },
5950
- onRecorded: (notificationConversation) => {
6621
+ onRecorded: (notificationConversation, notificationContext) => {
6622
+ const callbackIdentity = terminalBridgeApprovalCallbackIdentity(notificationConversation);
5951
6623
  const callbackMessage = createMessage({
5952
6624
  conversation: notificationConversation,
6625
+ id: callbackIdentity.id,
5953
6626
  from: executor.actor,
5954
6627
  to: "openclaw",
5955
6628
  type: "blocked",
@@ -5968,26 +6641,32 @@ async function runTerminalBridgeMonitorWithLock(options) {
5968
6641
  terminal_control: terminalControl,
5969
6642
  terminal_status: terminalStatus,
5970
6643
  approval_fingerprint: fingerprint
5971
- }
6644
+ },
6645
+ now: callbackIdentity.now
5972
6646
  });
5973
6647
  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
6648
  return {
5989
6649
  callbackMessage,
5990
- delivered: true
6650
+ prepared: prepareLockedCallback({
6651
+ ...options,
6652
+ statePath,
6653
+ log: logPath,
6654
+ messageJson: JSON.stringify(callbackMessage),
6655
+ gatewayMethod: notificationConversation.gateway_method,
6656
+ gatewaySession: notificationConversation.gateway_session,
6657
+ openclawSession: notificationConversation.openclaw_session,
6658
+ openclawBin: notificationConversation.openclaw_bin,
6659
+ gatewayUrl: stringValue(notificationConversation.gateway_token)
6660
+ ? notificationConversation.gateway_url
6661
+ : undefined,
6662
+ token: stringValue(notificationConversation.gateway_token),
6663
+ preserveMessageId: true,
6664
+ trackCallbackDelivery: true,
6665
+ preserveCallbackStatus: true,
6666
+ callbackDeliveryKind: "approval_notification",
6667
+ recoverMissingOutbox: notificationContext?.recoverMissingOutbox === true,
6668
+ conversationOverride: notificationConversation
6669
+ })
5991
6670
  };
5992
6671
  }
5993
6672
  return {
@@ -6015,7 +6694,8 @@ async function runTerminalBridgeMonitorWithLock(options) {
6015
6694
  });
6016
6695
  return;
6017
6696
  }
6018
- if (notification.recorded?.delivered) {
6697
+ if (notification.recorded?.prepared) {
6698
+ runPreparedCallback(notification.recorded.prepared);
6019
6699
  return;
6020
6700
  }
6021
6701
  printJson({
@@ -6032,7 +6712,9 @@ async function runTerminalBridgeMonitorWithLock(options) {
6032
6712
  });
6033
6713
  return;
6034
6714
  }
6035
- if (isRecord(approval) && approval.blocked === true) {
6715
+ if (!suppressApprovalNotification &&
6716
+ isRecord(approval) &&
6717
+ approval.blocked === true) {
6036
6718
  const fingerprint = terminalBridgeApprovalFingerprint({ terminalControl, terminalStatus });
6037
6719
  appendEvent(logPath, {
6038
6720
  ts: new Date().toISOString(),
@@ -6055,9 +6737,11 @@ async function runTerminalBridgeMonitorWithLock(options) {
6055
6737
  updatedAt: conversation.updated_at,
6056
6738
  messageId: currentMessageId
6057
6739
  },
6058
- onRecorded: (notificationConversation) => {
6740
+ onRecorded: (notificationConversation, notificationContext) => {
6741
+ const callbackIdentity = terminalBridgeApprovalCallbackIdentity(notificationConversation);
6059
6742
  const callbackMessage = createMessage({
6060
6743
  conversation: notificationConversation,
6744
+ id: callbackIdentity.id,
6061
6745
  from: executor.actor,
6062
6746
  to: "openclaw",
6063
6747
  type: "question",
@@ -6083,26 +6767,32 @@ async function runTerminalBridgeMonitorWithLock(options) {
6083
6767
  deny_command: `AKK cancel ${notificationConversation.conversation_id}`,
6084
6768
  approve_tool: "agent_knock_knock_approve",
6085
6769
  deny_tool: "agent_knock_knock_cancel"
6086
- }
6770
+ },
6771
+ now: callbackIdentity.now
6087
6772
  });
6088
6773
  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
6774
  return {
6104
6775
  callbackMessage,
6105
- delivered: true
6776
+ prepared: prepareLockedCallback({
6777
+ ...options,
6778
+ statePath,
6779
+ log: logPath,
6780
+ messageJson: JSON.stringify(callbackMessage),
6781
+ gatewayMethod: notificationConversation.gateway_method,
6782
+ gatewaySession: notificationConversation.gateway_session,
6783
+ openclawSession: notificationConversation.openclaw_session,
6784
+ openclawBin: notificationConversation.openclaw_bin,
6785
+ gatewayUrl: stringValue(notificationConversation.gateway_token)
6786
+ ? notificationConversation.gateway_url
6787
+ : undefined,
6788
+ token: stringValue(notificationConversation.gateway_token),
6789
+ preserveMessageId: true,
6790
+ trackCallbackDelivery: true,
6791
+ preserveCallbackStatus: true,
6792
+ callbackDeliveryKind: "approval_notification",
6793
+ recoverMissingOutbox: notificationContext?.recoverMissingOutbox === true,
6794
+ conversationOverride: notificationConversation
6795
+ })
6106
6796
  };
6107
6797
  }
6108
6798
  return {
@@ -6128,7 +6818,50 @@ async function runTerminalBridgeMonitorWithLock(options) {
6128
6818
  });
6129
6819
  return;
6130
6820
  }
6131
- if (notification.recorded?.delivered) {
6821
+ if (notification.recorded?.prepared) {
6822
+ const callbackResult = runPreparedCallback(notification.recorded.prepared, { emit: false });
6823
+ if (callbackResult.delivered === true &&
6824
+ process.env
6825
+ .AKK_TEST_EXIT_AFTER_APPROVAL_CALLBACK_DELIVERED === "1") {
6826
+ appendEvent(logPath, {
6827
+ ts: new Date().toISOString(),
6828
+ conversation_id: conversation.conversation_id,
6829
+ event: "terminal_bridge_test_exit_after_approval_callback_delivered",
6830
+ terminal_control: terminalControl,
6831
+ fingerprint
6832
+ });
6833
+ process.exit(86);
6834
+ }
6835
+ const afterCallback = loadState(statePath);
6836
+ const afterTakeover = isRecord(afterCallback.native_session_takeover)
6837
+ ? afterCallback.native_session_takeover
6838
+ : undefined;
6839
+ const approvalWasConsumed = isWaitingForAgent(afterCallback.status) &&
6840
+ afterTakeover?.terminal_bridge_approval === undefined &&
6841
+ stringValue(afterTakeover?.terminal_bridge_message_id) ===
6842
+ currentMessageId &&
6843
+ stringValue(afterTakeover?.terminal_bridge_last_approval_message_id) === currentMessageId &&
6844
+ stringValue(afterTakeover?.terminal_bridge_last_approval_fingerprint) === fingerprint;
6845
+ if (approvalWasConsumed) {
6846
+ conversation = afterCallback;
6847
+ previousScreenFingerprint = currentScreenFingerprint;
6848
+ previousDurableFingerprint = undefined;
6849
+ idleCompletionFingerprint = undefined;
6850
+ lastActivityAtMs =
6851
+ validTimestampMs(afterTakeover?.terminal_bridge_last_activity_at) ?? Date.now();
6852
+ lastPersistedActivityAtMs = lastActivityAtMs;
6853
+ persistedActivityReason = stringValue(afterTakeover?.terminal_bridge_last_activity_reason);
6854
+ appendEvent(logPath, {
6855
+ ts: new Date().toISOString(),
6856
+ conversation_id: conversation.conversation_id,
6857
+ event: "terminal_bridge_monitor_continued_after_approval",
6858
+ terminal_control: terminalControl,
6859
+ fingerprint
6860
+ });
6861
+ sleepSync(pollIntervalMs);
6862
+ continue;
6863
+ }
6864
+ emitPreparedCallbackResult(callbackResult);
6132
6865
  return;
6133
6866
  }
6134
6867
  printJson({
@@ -6236,6 +6969,7 @@ async function runTerminalBridgeMonitorWithLock(options) {
6236
6969
  });
6237
6970
  return;
6238
6971
  }
6972
+ let preparedCallback;
6239
6973
  try {
6240
6974
  conversation = claim.conversation;
6241
6975
  appendEvent(logPath, {
@@ -6279,7 +7013,7 @@ async function runTerminalBridgeMonitorWithLock(options) {
6279
7013
  }),
6280
7014
  id: callbackMessageId
6281
7015
  };
6282
- runLockedCallback({
7016
+ preparedCallback = prepareLockedCallback({
6283
7017
  ...options,
6284
7018
  statePath,
6285
7019
  log: logPath,
@@ -6300,6 +7034,7 @@ async function runTerminalBridgeMonitorWithLock(options) {
6300
7034
  releaseClaudeHookLease(conversation);
6301
7035
  claim.release();
6302
7036
  }
7037
+ runPreparedCallback(preparedCallback);
6303
7038
  return;
6304
7039
  }
6305
7040
  // A concrete approval or completion observed on this poll wins over a timeout boundary.
@@ -6396,6 +7131,13 @@ function terminalBridgeMonitorLockPath(statePath, terminalMessageId) {
6396
7131
  .slice(0, 20);
6397
7132
  return `${statePath}.terminal-bridge-monitor-${messageKey}.lock`;
6398
7133
  }
7134
+ function terminalBridgeMonitorHandoffLockPath(statePath, terminalMessageId) {
7135
+ const messageKey = createHash("sha256")
7136
+ .update(terminalMessageId)
7137
+ .digest("hex")
7138
+ .slice(0, 20);
7139
+ return `${statePath}.terminal-bridge-monitor-handoff-${messageKey}.lock`;
7140
+ }
6399
7141
  function fileLockOwnerPid(lockPath) {
6400
7142
  return readFileLockOwner(lockPath).pid;
6401
7143
  }
@@ -6631,16 +7373,36 @@ function terminalBridgeApprovalCandidate({ executor, terminalControl, terminalSt
6631
7373
  if (approval.approvable !== true) {
6632
7374
  return undefined;
6633
7375
  }
7376
+ const policyEvidence = isRecord(approval.policy_evidence)
7377
+ ? approval.policy_evidence
7378
+ : undefined;
7379
+ const localClaudeEvidence = executor.kind === "claude" &&
7380
+ policyEvidence?.source === "claude_transcript" &&
7381
+ policyEvidence?.kind === "run_command";
6634
7382
  return {
6635
7383
  agent: executor.kind,
6636
- kind: stringValue(approval.prompt_kind) ?? "unknown",
6637
- command: stringValue(approval.command),
7384
+ kind: localClaudeEvidence
7385
+ ? "run_command"
7386
+ : stringValue(approval.prompt_kind) ?? "unknown",
7387
+ command: localClaudeEvidence ? undefined : stringValue(approval.command),
6638
7388
  tool_name: stringValue(approval.tool_name),
6639
7389
  request_detail: stringValue(approval.request_detail),
6640
7390
  cwd: stringValue(approval.cwd) ?? terminalControl.currentPath,
6641
7391
  fingerprint,
6642
7392
  terminal_target: terminalControl.target,
6643
- decision_mode: stringValue(approval.decision_mode)
7393
+ decision_mode: stringValue(approval.decision_mode),
7394
+ ...(localClaudeEvidence
7395
+ ? {
7396
+ command_source: "executor_local",
7397
+ policy_evidence: {
7398
+ source: "claude_transcript",
7399
+ kind: "run_command",
7400
+ command_sha256: stringValue(policyEvidence.command_sha256),
7401
+ evidence_fingerprint: stringValue(policyEvidence.evidence_fingerprint),
7402
+ request_id: stringValue(policyEvidence.request_id)
7403
+ }
7404
+ }
7405
+ : {})
6644
7406
  };
6645
7407
  }
6646
7408
  async function loadCodexTerminalContext({ conversation, nativeTakeover, options }) {
@@ -6814,49 +7576,50 @@ function runTranscript(options) {
6814
7576
  }
6815
7577
  function runCallback(options) {
6816
7578
  const statePath = expandHome(required(options.state, "--state is required"));
6817
- const releaseLock = acquireFileLock(`${statePath}.lock`);
6818
- try {
6819
- runLockedCallback({ ...options, statePath });
6820
- }
6821
- finally {
6822
- releaseLock();
6823
- }
7579
+ runCallbackTransaction({ ...options, statePath });
6824
7580
  }
6825
7581
  function runRetryCallback(options) {
6826
7582
  const { conversation, statePath } = loadConversationFromOptions(options);
6827
7583
  const callbackDelivery = isRecord(conversation.callback_delivery)
6828
7584
  ? conversation.callback_delivery
6829
7585
  : undefined;
6830
- if (!["callback_pending", "callback_failed"].includes(conversation.status)) {
6831
- throw new Error(`cannot retry callback for ${conversation.conversation_id}; conversation is ${conversation.status}`);
7586
+ if (!isRetryableCallbackDelivery(conversation, callbackDelivery)) {
7587
+ throw new Error(`cannot retry callback for ${conversation.conversation_id}; no retryable callback delivery is available`);
6832
7588
  }
6833
7589
  if (!callbackDelivery || !isRecord(callbackDelivery.message)) {
6834
7590
  throw new Error(`cannot retry callback for ${conversation.conversation_id}; pending callback is missing`);
6835
7591
  }
6836
- const releaseLock = acquireFileLock(`${statePath}.lock`);
7592
+ runCallbackTransaction({
7593
+ ...options,
7594
+ statePath,
7595
+ messageJson: JSON.stringify(callbackDelivery.message),
7596
+ gatewayMethod: stringValue(callbackDelivery.gateway_method) ?? conversation.gateway_method,
7597
+ gatewaySession: stringValue(callbackDelivery.gateway_session) ?? conversation.gateway_session,
7598
+ openclawSession: conversation.openclaw_session,
7599
+ openclawBin: stringValue(callbackDelivery.openclaw_bin) ?? conversation.openclaw_bin,
7600
+ gatewayUrl: stringValue(callbackDelivery.gateway_url) ?? conversation.gateway_url,
7601
+ token: stringValue(callbackDelivery.gateway_token) ?? conversation.gateway_token,
7602
+ closeTerminalBridgeOnDone: callbackDelivery.close_terminal_bridge_on_done === true,
7603
+ retryPending: true
7604
+ });
7605
+ }
7606
+ function runCallbackTransaction(options) {
7607
+ const releaseLock = acquireFileLock(`${options.statePath}.lock`);
7608
+ let prepared;
6837
7609
  try {
6838
- runLockedCallback({
6839
- ...options,
6840
- statePath,
6841
- messageJson: JSON.stringify(callbackDelivery.message),
6842
- gatewayMethod: stringValue(callbackDelivery.gateway_method) ?? conversation.gateway_method,
6843
- gatewaySession: stringValue(callbackDelivery.gateway_session) ?? conversation.gateway_session,
6844
- openclawSession: conversation.openclaw_session,
6845
- openclawBin: stringValue(callbackDelivery.openclaw_bin) ?? conversation.openclaw_bin,
6846
- gatewayUrl: stringValue(callbackDelivery.gateway_url) ?? conversation.gateway_url,
6847
- token: stringValue(callbackDelivery.gateway_token) ?? conversation.gateway_token,
6848
- closeTerminalBridgeOnDone: callbackDelivery.close_terminal_bridge_on_done === true,
6849
- retryPending: true
6850
- });
7610
+ prepared = prepareLockedCallback(options);
6851
7611
  }
6852
7612
  finally {
6853
7613
  releaseLock();
6854
7614
  }
7615
+ return runPreparedCallback(prepared);
6855
7616
  }
6856
- function runLockedCallback(options) {
7617
+ function prepareLockedCallback(options) {
6857
7618
  const messageInput = required(options.messageJson, "--message-json is required");
6858
7619
  const logPath = expandHome(options.log ?? logPathForStatePath(options.statePath));
6859
- const conversation = loadState(options.statePath);
7620
+ const conversation = isRecord(options.conversationOverride)
7621
+ ? options.conversationOverride
7622
+ : loadState(options.statePath);
6860
7623
  const executor = executorForConversation(conversation);
6861
7624
  const message = options.retryPending === true || options.preserveMessageId === true
6862
7625
  ? parseMessageJson(messageInput)
@@ -6873,15 +7636,28 @@ function runLockedCallback(options) {
6873
7636
  const callbackDelivery = isRecord(conversation.callback_delivery)
6874
7637
  ? conversation.callback_delivery
6875
7638
  : undefined;
7639
+ const sameDeliveryMessage = isRecord(callbackDelivery?.message) &&
7640
+ callbackDelivery.message.id === message.id;
7641
+ const inheritedDelivery = sameDeliveryMessage
7642
+ ? callbackDelivery
7643
+ : undefined;
6876
7644
  const retryingPending = options.retryPending === true &&
6877
- isRecord(callbackDelivery?.message) &&
6878
- callbackDelivery.message.id === message.id &&
6879
- ["pending", "failed"].includes(String(callbackDelivery.status ?? ""));
7645
+ sameDeliveryMessage &&
7646
+ isRetryableCallbackDelivery(conversation, inheritedDelivery);
6880
7647
  const duplicateMessage = isDuplicateMessage(existingEvents, message);
7648
+ const recoveryMessageAlreadyLogged = options.recoverMissingOutbox === true
7649
+ ? exactLoggedMessageForRecovery(existingEvents, message)
7650
+ : false;
7651
+ const recoveringMissingOutbox = options.recoverMissingOutbox === true &&
7652
+ (!isRecord(callbackDelivery?.message) ||
7653
+ callbackDelivery.message.id !== message.id);
6881
7654
  const recoveringTerminalCompletion = options.recoverTerminalCompletion === true &&
6882
7655
  duplicateMessage &&
6883
7656
  isWaitingForAgent(conversation.status);
6884
- if (duplicateMessage && !retryingPending && !recoveringTerminalCompletion) {
7657
+ if (duplicateMessage &&
7658
+ !retryingPending &&
7659
+ !recoveringTerminalCompletion &&
7660
+ !recoveringMissingOutbox) {
6885
7661
  runtimeLog("info", "callback_duplicate", {
6886
7662
  conversation_id: conversation.conversation_id,
6887
7663
  agent: executor.kind,
@@ -6892,26 +7668,28 @@ function runLockedCallback(options) {
6892
7668
  state_path: options.statePath,
6893
7669
  event_log_path: logPath
6894
7670
  });
6895
- printJson({
7671
+ return {
7672
+ outcome: "duplicate",
6896
7673
  conversation,
6897
7674
  message,
6898
- budget: budgetAction(conversation),
6899
- delivered: false,
6900
- duplicate: true
6901
- });
6902
- return;
7675
+ logPath
7676
+ };
6903
7677
  }
6904
7678
  const closeTerminalBridgeOnDone = message.type === "done" &&
6905
7679
  options.closeTerminalBridgeOnDone === true;
7680
+ const preserveConversationStatus = options.preserveCallbackStatus === true ||
7681
+ inheritedDelivery?.preserve_conversation_status === true;
6906
7682
  const trackCallbackDelivery = closeTerminalBridgeOnDone ||
6907
7683
  options.trackCallbackDelivery === true ||
6908
- callbackDelivery?.track_delivery === true;
7684
+ inheritedDelivery?.track_delivery === true ||
7685
+ preserveConversationStatus;
6909
7686
  const requiresDelivery = Boolean(options.gatewayMethod) || options.recordOnly !== true;
6910
- const deliveryAttempt = Number(callbackDelivery?.attempts ?? 0) + 1;
7687
+ const deliveryAttempt = Number(inheritedDelivery?.attempts ?? 0) + 1;
7688
+ const deliveryAttemptId = randomUUID();
6911
7689
  let nextConversation = retryingPending
6912
7690
  ? conversation
6913
7691
  : applyMessageToConversation(conversation, message);
6914
- const storedFinalStatus = stringValue(callbackDelivery?.final_status);
7692
+ const storedFinalStatus = stringValue(inheritedDelivery?.final_status);
6915
7693
  const finalStatus = storedFinalStatus &&
6916
7694
  CONVERSATION_STATUSES.has(storedFinalStatus)
6917
7695
  ? storedFinalStatus
@@ -6928,19 +7706,25 @@ function runLockedCallback(options) {
6928
7706
  delayMs: callbackRetryDelayMs
6929
7707
  })
6930
7708
  : undefined;
6931
- if (!retryingPending && !recoveringTerminalCompletion) {
7709
+ if (!retryingPending &&
7710
+ !recoveringTerminalCompletion &&
7711
+ !(recoveringMissingOutbox && recoveryMessageAlreadyLogged)) {
6932
7712
  appendEvent(logPath, messageEvent(message));
6933
7713
  }
6934
7714
  if (trackCallbackDelivery && requiresDelivery) {
6935
7715
  const now = new Date().toISOString();
6936
7716
  nextConversation = {
6937
7717
  ...nextConversation,
6938
- status: "callback_pending",
7718
+ status: preserveConversationStatus
7719
+ ? nextConversation.status
7720
+ : "callback_pending",
6939
7721
  callback_delivery: {
6940
7722
  status: "pending",
6941
7723
  message,
6942
7724
  attempts: deliveryAttempt,
6943
- created_at: stringValue(callbackDelivery?.created_at) ?? now,
7725
+ attempt_id: deliveryAttemptId,
7726
+ attempt_pid: process.pid,
7727
+ created_at: stringValue(inheritedDelivery?.created_at) ?? now,
6944
7728
  last_attempt_at: now,
6945
7729
  gateway_method: options.gatewayMethod,
6946
7730
  gateway_session: options.gatewaySession ?? options.openclawSession ?? conversation.openclaw_session,
@@ -6949,6 +7733,9 @@ function runLockedCallback(options) {
6949
7733
  close_terminal_bridge_on_done: closeTerminalBridgeOnDone,
6950
7734
  track_delivery: true,
6951
7735
  final_status: finalStatus,
7736
+ preserve_conversation_status: preserveConversationStatus,
7737
+ kind: stringValue(options.callbackDeliveryKind) ??
7738
+ stringValue(inheritedDelivery?.kind),
6952
7739
  ...(callbackWatchdog
6953
7740
  ? {
6954
7741
  retry_monitor_pid: callbackWatchdog.pid ?? null,
@@ -7000,113 +7787,233 @@ function runLockedCallback(options) {
7000
7787
  conversation_id: conversation.conversation_id,
7001
7788
  status: nextConversation.status
7002
7789
  });
7003
- printJson({
7790
+ return {
7791
+ outcome: "record_only",
7004
7792
  conversation: nextConversation,
7005
7793
  message,
7006
- budget: budgetAction(nextConversation),
7794
+ logPath
7795
+ };
7796
+ }
7797
+ return {
7798
+ outcome: "deliver",
7799
+ options,
7800
+ statePath: options.statePath,
7801
+ logPath,
7802
+ conversation: nextConversation,
7803
+ message,
7804
+ trackCallbackDelivery,
7805
+ preserveConversationStatus,
7806
+ closeTerminalBridgeOnDone,
7807
+ finalStatus,
7808
+ deliveryAttempt,
7809
+ deliveryAttemptId
7810
+ };
7811
+ }
7812
+ function emitPreparedCallbackResult(result) {
7813
+ printJson({
7814
+ conversation: result.conversation,
7815
+ message: result.message,
7816
+ budget: budgetAction(result.conversation),
7817
+ delivered: result.delivered,
7818
+ duplicate: result.duplicate,
7819
+ ...(result.delivery === undefined ? {} : { delivery: result.delivery })
7820
+ });
7821
+ }
7822
+ function runPreparedCallback(prepared, { emit = true } = {}) {
7823
+ if (prepared.outcome === "duplicate") {
7824
+ const result = {
7007
7825
  delivered: false,
7008
- duplicate: false
7009
- });
7010
- return;
7826
+ duplicate: true,
7827
+ conversation: prepared.conversation,
7828
+ message: prepared.message
7829
+ };
7830
+ if (emit) {
7831
+ emitPreparedCallbackResult(result);
7832
+ }
7833
+ return result;
7834
+ }
7835
+ if (prepared.outcome === "record_only") {
7836
+ const result = {
7837
+ delivered: false,
7838
+ duplicate: false,
7839
+ conversation: prepared.conversation,
7840
+ message: prepared.message
7841
+ };
7842
+ if (emit) {
7843
+ emitPreparedCallbackResult(result);
7844
+ }
7845
+ return result;
7011
7846
  }
7012
7847
  try {
7013
7848
  const deliveryKind = deliverCallbackToOpenClaw({
7014
- options,
7015
- statePath: options.statePath,
7016
- logPath,
7017
- conversation: nextConversation,
7018
- message
7019
- });
7020
- const deliveredAt = new Date().toISOString();
7021
- let deliveredConversation = nextConversation;
7022
- if (trackCallbackDelivery) {
7023
- const deliveredStatus = closeTerminalBridgeOnDone ? "closed" : finalStatus;
7024
- deliveredConversation = {
7025
- ...nextConversation,
7026
- status: deliveredStatus,
7027
- ...(closeTerminalBridgeOnDone
7849
+ options: prepared.options,
7850
+ statePath: prepared.statePath,
7851
+ logPath: prepared.logPath,
7852
+ conversation: prepared.conversation,
7853
+ message: prepared.message
7854
+ });
7855
+ const deliveredConversation = prepared.trackCallbackDelivery
7856
+ ? settlePreparedCallbackDelivery(prepared, { delivered: true })
7857
+ : loadState(prepared.statePath);
7858
+ const result = {
7859
+ delivered: true,
7860
+ duplicate: false,
7861
+ conversation: deliveredConversation,
7862
+ message: prepared.message,
7863
+ delivery: deliveryKind
7864
+ };
7865
+ if (emit) {
7866
+ emitPreparedCallbackResult(result);
7867
+ }
7868
+ return result;
7869
+ }
7870
+ catch (error) {
7871
+ if (prepared.trackCallbackDelivery) {
7872
+ settlePreparedCallbackDelivery(prepared, { delivered: false, error });
7873
+ }
7874
+ throw error;
7875
+ }
7876
+ }
7877
+ function settlePreparedCallbackDelivery(prepared, result) {
7878
+ const releaseLock = acquireFileLock(`${prepared.statePath}.lock`);
7879
+ try {
7880
+ const current = loadState(prepared.statePath);
7881
+ const currentDelivery = isRecord(current.callback_delivery)
7882
+ ? current.callback_delivery
7883
+ : undefined;
7884
+ if (!currentDelivery ||
7885
+ !isRecord(currentDelivery.message) ||
7886
+ currentDelivery.message.id !== prepared.message.id ||
7887
+ currentDelivery.attempt_id !== prepared.deliveryAttemptId ||
7888
+ Number(currentDelivery.attempts) !== prepared.deliveryAttempt ||
7889
+ currentDelivery.status !== "pending") {
7890
+ appendEvent(prepared.logPath, {
7891
+ ts: new Date().toISOString(),
7892
+ conversation_id: current.conversation_id,
7893
+ event: "callback_delivery_settle_skipped",
7894
+ message_id: prepared.message.id,
7895
+ attempt: prepared.deliveryAttempt,
7896
+ result: result.delivered ? "delivered" : "failed",
7897
+ reason: "callback delivery claim changed before settlement",
7898
+ current_status: current.status
7899
+ });
7900
+ return current;
7901
+ }
7902
+ if (result.delivered) {
7903
+ const deliveredAt = new Date().toISOString();
7904
+ const ownsConversationStatus = currentDelivery.preserve_conversation_status !== true &&
7905
+ ["callback_pending", "callback_failed"].includes(current.status);
7906
+ const deliveredStatus = prepared.closeTerminalBridgeOnDone
7907
+ ? "closed"
7908
+ : prepared.finalStatus;
7909
+ const nextConversation = {
7910
+ ...current,
7911
+ status: ownsConversationStatus ? deliveredStatus : current.status,
7912
+ ...(ownsConversationStatus && prepared.closeTerminalBridgeOnDone
7028
7913
  ? {
7029
7914
  closed_at: deliveredAt,
7030
7915
  close_reason: "terminal bridge task completed"
7031
7916
  }
7032
7917
  : {}),
7033
7918
  callback_delivery: {
7034
- ...(isRecord(nextConversation.callback_delivery) ? nextConversation.callback_delivery : {}),
7919
+ ...currentDelivery,
7035
7920
  status: "delivered",
7036
7921
  delivered_at: deliveredAt,
7037
7922
  last_error: undefined
7038
7923
  },
7039
- updated_at: deliveredAt
7924
+ updated_at: ownsConversationStatus ? deliveredAt : current.updated_at
7040
7925
  };
7041
- delete deliveredConversation.idle_since;
7042
- saveState(options.statePath, deliveredConversation);
7043
- appendEvent(logPath, {
7926
+ if (ownsConversationStatus) {
7927
+ delete nextConversation.idle_since;
7928
+ }
7929
+ saveState(prepared.statePath, nextConversation);
7930
+ appendEvent(prepared.logPath, {
7044
7931
  ts: deliveredAt,
7045
- conversation_id: conversation.conversation_id,
7932
+ conversation_id: current.conversation_id,
7046
7933
  event: "callback_delivery_succeeded",
7047
- message_id: message.id,
7048
- attempt: deliveryAttempt,
7049
- status: deliveredStatus
7934
+ message_id: prepared.message.id,
7935
+ attempt: prepared.deliveryAttempt,
7936
+ status: nextConversation.status,
7937
+ state_preserved: !ownsConversationStatus
7050
7938
  });
7051
- }
7052
- printJson({
7053
- conversation: deliveredConversation,
7054
- message,
7055
- budget: budgetAction(deliveredConversation),
7056
- delivered: true,
7057
- duplicate: false,
7058
- delivery: deliveryKind
7059
- });
7060
- }
7061
- catch (error) {
7062
- if (trackCallbackDelivery) {
7063
- const failedAt = new Date().toISOString();
7064
- const failedConversation = {
7065
- ...nextConversation,
7066
- status: "callback_failed",
7067
- callback_delivery: {
7068
- ...(isRecord(nextConversation.callback_delivery) ? nextConversation.callback_delivery : {}),
7069
- status: "failed",
7070
- failed_at: failedAt,
7071
- last_error: error instanceof Error ? error.message : String(error)
7072
- },
7073
- updated_at: failedAt
7074
- };
7075
- saveState(options.statePath, failedConversation);
7076
- appendEvent(logPath, {
7077
- ts: failedAt,
7078
- conversation_id: conversation.conversation_id,
7079
- event: "callback_delivery_failed",
7080
- message_id: message.id,
7081
- attempt: deliveryAttempt,
7082
- error: failedConversation.callback_delivery.last_error
7083
- });
7084
- if (options.retryPending !== true &&
7085
- options.disableCallbackRetry !== true &&
7086
- deliveryAttempt <= CALLBACK_RETRY_DELAYS_MS.length) {
7087
- const retryMonitor = startCallbackRetryMonitor({ statePath: options.statePath });
7088
- const retryDelayMs = CALLBACK_RETRY_DELAYS_MS[Math.max(0, deliveryAttempt - 1)];
7089
- const retryState = {
7090
- ...failedConversation,
7091
- callback_delivery: {
7092
- ...failedConversation.callback_delivery,
7939
+ return nextConversation;
7940
+ }
7941
+ const failedAt = new Date().toISOString();
7942
+ const lastError = result.error instanceof Error
7943
+ ? result.error.message
7944
+ : String(result.error);
7945
+ const ownsConversationStatus = currentDelivery.preserve_conversation_status !== true &&
7946
+ current.status === "callback_pending";
7947
+ const shouldLaunchRetry = prepared.options.retryPending !== true &&
7948
+ prepared.options.disableCallbackRetry !== true &&
7949
+ prepared.deliveryAttempt <= CALLBACK_RETRY_DELAYS_MS.length;
7950
+ const retryDelayMs = CALLBACK_RETRY_DELAYS_MS[Math.max(0, prepared.deliveryAttempt - 1)];
7951
+ const retryMonitor = shouldLaunchRetry
7952
+ ? startCallbackRetryMonitor({ statePath: prepared.statePath })
7953
+ : undefined;
7954
+ const nextAttemptAt = retryMonitor
7955
+ ? new Date(Date.now() + retryDelayMs).toISOString()
7956
+ : undefined;
7957
+ const failedConversation = {
7958
+ ...current,
7959
+ status: ownsConversationStatus ? "callback_failed" : current.status,
7960
+ callback_delivery: {
7961
+ ...currentDelivery,
7962
+ status: "failed",
7963
+ failed_at: failedAt,
7964
+ last_error: lastError,
7965
+ ...(retryMonitor
7966
+ ? {
7093
7967
  retry_monitor_pid: retryMonitor.pid ?? null,
7094
- next_attempt_at: new Date(Date.now() + retryDelayMs).toISOString()
7968
+ next_attempt_at: nextAttemptAt
7095
7969
  }
7096
- };
7097
- saveState(options.statePath, retryState);
7098
- appendEvent(logPath, {
7099
- ts: new Date().toISOString(),
7100
- conversation_id: conversation.conversation_id,
7101
- event: "callback_retry_monitor_launched",
7102
- message_id: message.id,
7103
- pid: retryMonitor.pid ?? null,
7104
- next_attempt_at: retryState.callback_delivery.next_attempt_at
7105
- });
7106
- }
7970
+ : {})
7971
+ },
7972
+ updated_at: ownsConversationStatus ? failedAt : current.updated_at
7973
+ };
7974
+ saveState(prepared.statePath, failedConversation);
7975
+ appendEvent(prepared.logPath, {
7976
+ ts: failedAt,
7977
+ conversation_id: current.conversation_id,
7978
+ event: "callback_delivery_failed",
7979
+ message_id: prepared.message.id,
7980
+ attempt: prepared.deliveryAttempt,
7981
+ error: lastError,
7982
+ state_preserved: !ownsConversationStatus
7983
+ });
7984
+ if (retryMonitor) {
7985
+ appendEvent(prepared.logPath, {
7986
+ ts: new Date().toISOString(),
7987
+ conversation_id: current.conversation_id,
7988
+ event: "callback_retry_monitor_launched",
7989
+ message_id: prepared.message.id,
7990
+ pid: retryMonitor.pid ?? null,
7991
+ next_attempt_at: nextAttemptAt
7992
+ });
7107
7993
  }
7108
- throw error;
7994
+ return failedConversation;
7995
+ }
7996
+ finally {
7997
+ releaseLock();
7998
+ }
7999
+ }
8000
+ function isRetryableCallbackDelivery(conversation, callbackDelivery) {
8001
+ if (!isRecord(callbackDelivery) ||
8002
+ !isRecord(callbackDelivery.message) ||
8003
+ !["pending", "failed"].includes(String(callbackDelivery.status ?? ""))) {
8004
+ return false;
7109
8005
  }
8006
+ if (callbackDelivery.preserve_conversation_status !== true &&
8007
+ !["callback_pending", "callback_failed"].includes(conversation.status)) {
8008
+ return false;
8009
+ }
8010
+ const attemptPidValue = Number(callbackDelivery.attempt_pid);
8011
+ const attemptPid = Number.isSafeInteger(attemptPidValue) && attemptPidValue > 0
8012
+ ? attemptPidValue
8013
+ : undefined;
8014
+ return callbackDelivery.status === "failed" ||
8015
+ attemptPid === undefined ||
8016
+ !isProcessAlive(attemptPid);
7110
8017
  }
7111
8018
  function deliverCallbackToOpenClaw({ options, statePath, logPath, conversation, message }) {
7112
8019
  if (options.gatewayMethod) {
@@ -8304,6 +9211,32 @@ function isDuplicateMessage(events, message) {
8304
9211
  return messageFingerprint(existing) === messageFingerprint(message);
8305
9212
  });
8306
9213
  }
9214
+ function exactLoggedMessageForRecovery(events, message) {
9215
+ const matchingId = events
9216
+ .filter((event) => event.event === "message")
9217
+ .map((event) => event.message ?? event)
9218
+ .filter((existing) => existing.id === message.id);
9219
+ if (matchingId.length === 0) {
9220
+ return false;
9221
+ }
9222
+ if (matchingId.length !== 1 ||
9223
+ canonicalJson(matchingId[0]) !== canonicalJson(message)) {
9224
+ throw new Error(`callback recovery message ${message.id} conflicts with its logged payload`);
9225
+ }
9226
+ return true;
9227
+ }
9228
+ function canonicalJson(value) {
9229
+ if (Array.isArray(value)) {
9230
+ return `[${value.map(canonicalJson).join(",")}]`;
9231
+ }
9232
+ if (isRecord(value)) {
9233
+ return `{${Object.keys(value)
9234
+ .sort()
9235
+ .map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`)
9236
+ .join(",")}}`;
9237
+ }
9238
+ return JSON.stringify(value) ?? "undefined";
9239
+ }
8307
9240
  function messageFingerprint(message) {
8308
9241
  return JSON.stringify({
8309
9242
  conversation_id: message.conversation_id,