@scotthuang/agent-knock-knock 0.12.8 → 0.12.11

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.
@@ -8,7 +8,7 @@ import { fileURLToPath } from "node:url";
8
8
  import { createCodexTerminalAgentAdapter, detectCodexDurableCompletion } from "./codex-terminal-agent-adapter.js";
9
9
  import { codexLifecycleBehaviorProfile } from "./codex-lifecycle-compatibility.js";
10
10
  import { createClaudeTerminalAgentAdapter } from "./claude-terminal-agent-adapter.js";
11
- import { captureClaudeTranscriptAnchor, defaultClaudeHome, detectClaudeTranscriptAcceptance, detectClaudeTranscriptCompletion, detectClaudeTranscriptPendingApproval, listClaudeThreadLifecycleCandidates, revalidateClaudeThreadLifecycleCandidate } from "./claude-local-transcript-provider.js";
11
+ import { captureClaudeTranscriptAnchor, defaultClaudeHome, detectClaudeTranscriptAcceptance, detectClaudeTranscriptCompletion, detectClaudeTranscriptPendingApproval, listClaudeThreadLifecycleCandidates, observeClaudeDeadProcessTranscriptCompletion, revalidateClaudeThreadLifecycleCandidate } from "./claude-local-transcript-provider.js";
12
12
  import { captureCodexCandidateSetRolloutAcceptanceAnchor, captureCodexRolloutAcceptanceAnchor, detectCodexBoundRolloutCompletion, detectCodexCandidateSetRolloutAcceptance, detectCodexRolloutAcceptance, terminalSubmissionReplayReceipt, validateCodexRolloutAcceptanceAnchor, validateTerminalSubmissionAcceptanceEvidence } from "./terminal-submission-acceptance.js";
13
13
  import { CodexLocalSessionProvider } from "./codex-local-session-provider.js";
14
14
  import { CodexStoreAdapter } from "./codex-store-adapter.js";
@@ -5853,7 +5853,7 @@ function managedListApprovalState(conversation) {
5853
5853
  }
5854
5854
  function listActionContracts() {
5855
5855
  return {
5856
- version: 15,
5856
+ version: 16,
5857
5857
  instructions: [
5858
5858
  "Treat terminals[] as the primary resource and use only actions present in available_actions, except the snapshot-bound terminals[].handoff_decision.choices.take_over_current.action and an exact terminals[].blocking_turns[].recovery_action. Either nested action requires explicit user confirmation; after it succeeds, refresh list before any follow-current send.",
5859
5859
  "The session_exact scope uses session_id only when it is prefilled by the listed send action. A rollout-backed managed Codex pane instead uses the terminal_follow_current scope with its exact selector plus expected_terminal_token because even one materialized rollout does not prove the current TUI foreground thread. A turn id is never an ordinary send target.",
@@ -5864,6 +5864,7 @@ function listActionContracts() {
5864
5864
  "List resumable threads before resume; use only a complete native_thread_id and the action returned for that candidate.",
5865
5865
  "Use a terminal selector only when explicitly named by the user or prefilled by that terminal row's send action. A handoff action also carries expected_terminal_token; never infer, guess, or reuse either value.",
5866
5866
  "Use respond only for an in-flight turn that is explicitly waiting for OpenClaw.",
5867
+ "Approval fingerprint v2 binds stable terminal, process, and action fields plus an unredacted exact prompt-region digest. Whole-screen digests and scrollback excerpts are diagnostic only and never authorize approval. A keys-mode prompt without exact prompt-region evidence is not approvable and exposes no approve action.",
5867
5868
  "Managed controls target turn_id. A raw terminal may be controlled only through its own list-prefilled conversation_id action. When a Codex managed prompt has no usable AKK Turn owner, list may expose one manual terminal-scoped approve carrying expected_terminal_token; copy the complete action after explicit human confirmation. It does not mutate the Turn or Session binding, has no durable dispatch receipt, and is never eligible for automatic approval. If its result is interrupted, refresh status and inspect the live prompt instead of retrying blindly. Never construct, guess, or reuse either selector or token.",
5868
5869
  "Start with the action's prefilled arguments, supply every missing_required field, and consult the top-level action's optional fields only when needed.",
5869
5870
  "Authoritative full IDs are prefilled; short_ref is for display and human input.",
@@ -6023,6 +6024,9 @@ function listActionContracts() {
6023
6024
  required: ["turn_id", "expected_approval_fingerprint"],
6024
6025
  optional: ["expected_terminal_token"],
6025
6026
  terminal_scoped_scope: "Only the latest list-prefilled Codex conversation_id/token action may approve a managed current prompt without a usable Turn owner. It remains human-confirmed, revalidates exact terminal/process/Session/dispatch/prompt state before keys, never mutates managed state, has no durable dispatch receipt, cannot be auto-approved, and must not be blindly retried after an interrupted result.",
6027
+ fingerprint_contract: "v2_prompt_region",
6028
+ fingerprint_authority: "Stable terminal, process, and action fields plus the unredacted exact prompt-region digest; whole-screen digests and scrollback excerpts are diagnostic only.",
6029
+ missing_prompt_region_evidence: "not_approvable",
6026
6030
  requires_explicit_user_confirmation: true,
6027
6031
  requires_fresh_status: true
6028
6032
  },
@@ -6195,7 +6199,8 @@ function availableListActions(entry, { conversation } = {}) {
6195
6199
  }
6196
6200
  if (terminalBridgeReady &&
6197
6201
  entry.status === "stalled" &&
6198
- terminalBridgeSubmission(conversation)?.status !== "uncertain") {
6202
+ terminalBridgeSubmission(conversation)?.status !== "uncertain" &&
6203
+ !isVerifiedDeadTerminalAgentProcess(conversation ?? {})) {
6199
6204
  actions.renew = {
6200
6205
  tool: "agent_knock_knock_renew",
6201
6206
  arguments: targetArguments
@@ -6592,6 +6597,245 @@ function codexProcessIncarnationForPid(pid) {
6592
6597
  evidence: "codex_process_birth"
6593
6598
  };
6594
6599
  }
6600
+ function observeExactCodexDeadProcessRolloutCompletion({ options, conversation, terminalControl }) {
6601
+ const takeover = isRecord(conversation.native_session_takeover)
6602
+ ? conversation.native_session_takeover
6603
+ : undefined;
6604
+ const submission = terminalBridgeSubmission(conversation);
6605
+ const acceptanceEvidence = isRecord(submission?.acceptance_evidence)
6606
+ ? submission.acceptance_evidence
6607
+ : undefined;
6608
+ const anchor = isRecord(takeover?.codex_rollout_acceptance_anchor)
6609
+ ? takeover.codex_rollout_acceptance_anchor
6610
+ : undefined;
6611
+ if (submission?.status !== "agent_accepted" ||
6612
+ !requiresExactBoundCodexCompletion(conversation, options) ||
6613
+ acceptanceEvidence?.source !== "codex_rollout" ||
6614
+ !anchor) {
6615
+ return {
6616
+ status: "unverifiable",
6617
+ reason: "Codex has no exact accepted rollout authority for the dead process"
6618
+ };
6619
+ }
6620
+ const runtime = terminalRuntimeIdentityForConversation(conversation, terminalControl);
6621
+ const nativeRollout = isRecord(runtime.nativeRollout)
6622
+ ? runtime.nativeRollout
6623
+ : undefined;
6624
+ const request = terminalDurableRequestForConversation(conversation, terminalControl);
6625
+ const result = detectCodexBoundRolloutCompletion({
6626
+ anchor: anchor,
6627
+ acceptanceEvidence: acceptanceEvidence,
6628
+ currentIdentity: {
6629
+ sessionId: runtime.nativeSessionId ?? runtime.sessionId ?? "",
6630
+ processUuid: runtime.nativeProcessUuid,
6631
+ processBirth: runtime.nativeProcessBirth,
6632
+ ...(nativeRollout
6633
+ ? {
6634
+ rollout: {
6635
+ fd: String(nativeRollout.fd ?? ""),
6636
+ device: String(nativeRollout.device ?? ""),
6637
+ inode: String(nativeRollout.inode ?? ""),
6638
+ path: String(nativeRollout.path ?? "")
6639
+ }
6640
+ }
6641
+ : {})
6642
+ },
6643
+ requestHash: stringValue(request.requestHash) ??
6644
+ stringValue(takeover?.terminal_bridge_request_hash) ??
6645
+ ""
6646
+ });
6647
+ if (result.status === "completed") {
6648
+ return {
6649
+ status: "present",
6650
+ completion: {
6651
+ ...result.completion,
6652
+ metadata: {
6653
+ ...result.completion.metadata,
6654
+ context_match: "exact_bound_rollout",
6655
+ detector_code: result.diagnostics.code
6656
+ }
6657
+ }
6658
+ };
6659
+ }
6660
+ if (result.status === "pending" &&
6661
+ result.diagnostics.code === "exact_turn_not_complete") {
6662
+ return { status: "absent" };
6663
+ }
6664
+ return {
6665
+ status: "unverifiable",
6666
+ reason: `[codex_exact_bound_rollout:${result.diagnostics.code}] ` +
6667
+ (result.diagnostics.detail ??
6668
+ "the exact bound rollout is not safely inspectable")
6669
+ };
6670
+ }
6671
+ async function observeDurableCompletionBeforeDeadStall({ options, conversation, terminalControl }) {
6672
+ try {
6673
+ const executor = executorForConversation(conversation);
6674
+ const takeover = isRecord(conversation.native_session_takeover)
6675
+ ? conversation.native_session_takeover
6676
+ : undefined;
6677
+ if (executor.kind === "claude") {
6678
+ const anchor = isRecord(takeover?.claude_transcript_anchor)
6679
+ ? takeover.claude_transcript_anchor
6680
+ : undefined;
6681
+ const pid = Number(anchor?.pid);
6682
+ const startedAt = Number(anchor?.agent_started_at_ms);
6683
+ const sessionId = stringValue(anchor?.session_id);
6684
+ const cwd = stringValue(anchor?.cwd);
6685
+ if (!Number.isSafeInteger(pid) ||
6686
+ pid <= 1 ||
6687
+ !Number.isSafeInteger(startedAt) ||
6688
+ startedAt <= 0 ||
6689
+ !sessionId ||
6690
+ !cwd ||
6691
+ pid !== Number(takeover?.terminal_agent_pid) ||
6692
+ sessionId !== stringValue(takeover?.terminal_agent_session_id) ||
6693
+ path.resolve(cwd) !== path.resolve(conversation.workspace)) {
6694
+ return {
6695
+ status: "unverifiable",
6696
+ reason: "Claude has no exact immutable transcript anchor for the dead process"
6697
+ };
6698
+ }
6699
+ const processUuid = stringValue(takeover?.terminal_agent_process_uuid);
6700
+ if (processUuid !== `claude-pid:${pid}:started:${startedAt}`) {
6701
+ return {
6702
+ status: "unverifiable",
6703
+ reason: "Claude transcript anchor does not match the exact process incarnation"
6704
+ };
6705
+ }
6706
+ const submission = terminalBridgeSubmission(conversation);
6707
+ const observation = observeClaudeDeadProcessTranscriptCompletion(terminalDurableRequestForConversation(conversation, terminalControl), {
6708
+ claudeHome: expandHome(options.claudeHome),
6709
+ // Process death freezes this exact historical incarnation. The
6710
+ // transcript detector still revalidates the immutable PID/session/
6711
+ // cwd/start anchor and the no-follow file boundary before reading.
6712
+ agentRows: [{
6713
+ pid,
6714
+ startedAt,
6715
+ sessionId,
6716
+ cwd,
6717
+ kind: "interactive",
6718
+ status: "idle"
6719
+ }],
6720
+ acceptanceEvidence: submission?.acceptance_evidence
6721
+ });
6722
+ return observation;
6723
+ }
6724
+ return observeExactCodexDeadProcessRolloutCompletion({
6725
+ options,
6726
+ conversation,
6727
+ terminalControl
6728
+ });
6729
+ }
6730
+ catch (error) {
6731
+ return {
6732
+ status: "unverifiable",
6733
+ reason: error instanceof Error ? error.message : String(error)
6734
+ };
6735
+ }
6736
+ }
6737
+ async function observeBoundTerminalAgentProcess({ options, conversation, terminalControl }) {
6738
+ try {
6739
+ assertTurnBindingCurrent(conversation, "verify the bound agent process for");
6740
+ const storeDir = managedSessionStoreDirForConversation(conversation);
6741
+ if (!storeDir) {
6742
+ return {
6743
+ status: "unverifiable",
6744
+ reason: "the managed Store is unavailable"
6745
+ };
6746
+ }
6747
+ const session = loadManagedSession(storeDir, sessionIdForConversation(conversation));
6748
+ const binding = session.binding;
6749
+ const takeover = isRecord(conversation.native_session_takeover)
6750
+ ? conversation.native_session_takeover
6751
+ : undefined;
6752
+ const pid = Number(takeover?.terminal_agent_pid);
6753
+ const bindingId = stringValue(conversation.terminal_binding_id);
6754
+ const bindingGeneration = Number(conversation.terminal_binding_generation);
6755
+ const processUuid = stringValue(takeover?.terminal_agent_process_uuid);
6756
+ const processBirth = stringValue(takeover?.terminal_agent_process_birth);
6757
+ const messageId = stringValue(takeover?.terminal_bridge_message_id);
6758
+ const submission = terminalBridgeSubmission(conversation);
6759
+ if (session.status !== "bound" ||
6760
+ !binding ||
6761
+ !Number.isSafeInteger(pid) ||
6762
+ pid <= 1 ||
6763
+ !bindingId ||
6764
+ !Number.isSafeInteger(bindingGeneration) ||
6765
+ bindingGeneration < 1 ||
6766
+ !processUuid ||
6767
+ !messageId ||
6768
+ !hasCanonicalTerminalEndpoint(terminalControl) ||
6769
+ binding.native_process.pid !== pid ||
6770
+ !terminalControlsShareIncarnation(binding.terminal_control, terminalControl) ||
6771
+ binding.binding_id !== bindingId ||
6772
+ binding.generation !== bindingGeneration ||
6773
+ session.agent !== executorForConversation(conversation).kind ||
6774
+ path.resolve(session.workspace) !== path.resolve(conversation.workspace) ||
6775
+ binding.native_thread_id !==
6776
+ (stringValue(conversation.native_thread_id) ??
6777
+ stringValue(takeover?.terminal_agent_session_id)) ||
6778
+ binding.native_process.process_uuid !== processUuid ||
6779
+ stringValue(takeover?.terminal_binding_id) !== bindingId ||
6780
+ Number(takeover?.terminal_binding_generation) !== bindingGeneration ||
6781
+ submission?.status !== "agent_accepted" ||
6782
+ stringValue(submission.session_id) !==
6783
+ sessionIdForConversation(conversation) ||
6784
+ stringValue(submission.turn_id) !== turnIdForConversation(conversation) ||
6785
+ stringValue(submission.message_id) !== messageId ||
6786
+ stringValue(submission.binding_id) !== bindingId ||
6787
+ Number(submission.binding_generation) !== bindingGeneration ||
6788
+ (binding.native_process.process_birth ?? undefined) !== processBirth) {
6789
+ return {
6790
+ status: "unverifiable",
6791
+ reason: "the Turn and Session no longer share one exact process binding"
6792
+ };
6793
+ }
6794
+ const processSource = createTerminalProcessSource(options);
6795
+ if (processSource.completeInventoryAuthority !== true) {
6796
+ return {
6797
+ status: "unverifiable",
6798
+ reason: "the configured process source is not complete process-death authority"
6799
+ };
6800
+ }
6801
+ const snapshots = await processSource.listProcessSnapshots((snapshot) => snapshot.pid === pid, { includeCwd: false, includeAncestors: false });
6802
+ const exact = snapshots.filter((snapshot) => snapshot.pid === pid);
6803
+ if (exact.length === 1) {
6804
+ return { status: "alive", pid };
6805
+ }
6806
+ if (exact.length !== 0) {
6807
+ return {
6808
+ status: "unverifiable",
6809
+ reason: `the process inventory returned ${exact.length} rows for pid ${pid}`
6810
+ };
6811
+ }
6812
+ return {
6813
+ status: "verified_dead",
6814
+ proof: {
6815
+ kind: "exact_pid_absent_from_complete_process_inventory",
6816
+ agent: session.agent,
6817
+ pid,
6818
+ process_uuid: processUuid,
6819
+ process_birth: binding.native_process.process_birth,
6820
+ conversation_id: conversation.conversation_id,
6821
+ session_id: sessionIdForConversation(conversation),
6822
+ turn_id: turnIdForConversation(conversation),
6823
+ terminal_control: terminalControl,
6824
+ terminal_endpoint: terminalControlEvidence(terminalControl),
6825
+ binding_id: binding.binding_id,
6826
+ binding_generation: binding.generation,
6827
+ message_id: messageId,
6828
+ observed_at: cliNow().toISOString()
6829
+ }
6830
+ };
6831
+ }
6832
+ catch (error) {
6833
+ return {
6834
+ status: "unverifiable",
6835
+ reason: error instanceof Error ? error.message : String(error)
6836
+ };
6837
+ }
6838
+ }
6595
6839
  function processIncarnationRelationship({ binding, livePid, liveProcessUuid, liveProcessBirth }) {
6596
6840
  if (binding.native_process.pid !== livePid) {
6597
6841
  return "different";
@@ -12427,6 +12671,10 @@ function terminalBridgeApprovalFingerprint({ terminalControl, terminalStatus })
12427
12671
  if (adapterFingerprint) {
12428
12672
  return adapterFingerprint;
12429
12673
  }
12674
+ if (approval.approvable === true &&
12675
+ (stringValue(approval.decision_mode) ?? "keys") === "keys") {
12676
+ return undefined;
12677
+ }
12430
12678
  const endpoint = terminalEndpointFromControlRef(terminalControl);
12431
12679
  const processAnchorPid = Number(endpoint.processAnchorPid);
12432
12680
  if (!Number.isSafeInteger(processAnchorPid) || processAnchorPid <= 1) {
@@ -13808,331 +14056,337 @@ async function runApprove(options) {
13808
14056
  release();
13809
14057
  }
13810
14058
  };
13811
- releaseStateLock = acquireFileLock(`${statePath}.lock`);
13812
14059
  const writerStoreDir = pathsForConversationDir(path.dirname(statePath)).storeDir;
13813
- try {
13814
- return await withStoreWriterLeaseAsync(writerStoreDir, async () => {
13815
- let approval;
13816
- let lockedConversation = conversation;
13817
- const currentConversation = loadState(statePath);
13818
- const currentTakeover = isRecord(currentConversation.native_session_takeover)
13819
- ? currentConversation.native_session_takeover
13820
- : undefined;
13821
- const currentControl = terminalControlFromTakeover(currentTakeover);
13822
- const currentApproval = isRecord(currentTakeover?.terminal_bridge_approval)
13823
- ? currentTakeover.terminal_bridge_approval
13824
- : undefined;
13825
- if (currentConversation.status !== conversation.status ||
13826
- currentTakeover?.terminal_bridge_message_id !== nativeTakeover?.terminal_bridge_message_id ||
13827
- !currentControl ||
13828
- !terminalControlsShareIncarnation(currentControl, terminalControl) ||
13829
- (claudeScreenApproval &&
13830
- currentApproval?.fingerprint !== monitoredApproval?.fingerprint)) {
13831
- throw new Error("approval state changed while waiting for terminal control; refresh status and retry");
13832
- }
13833
- assertManagedTerminalDispatchOwner({
13834
- storeDir: writerStoreDir,
13835
- conversation: currentConversation,
13836
- terminalControl: currentControl,
13837
- action: "approve"
13838
- });
13839
- lockedConversation = currentConversation;
13840
- const currentRuntimeIdentity = terminalRuntimeIdentityForConversation(currentConversation, currentControl);
13841
- approval = await createTerminalAgentBridge(options).approve(executor.kind, currentControl, {
13842
- expectedFingerprint,
13843
- scrollbackLines: Number(options.scrollbackLines ?? 120),
13844
- runtime: currentRuntimeIdentity,
13845
- managedRequest: terminalDurableRequestForConversation(currentConversation, currentControl),
13846
- requiredDecisionMode: autoApproved && executor.kind === "claude" ? "keys" : undefined,
13847
- authorize: autoApproved
13848
- ? ({ agent, terminalControl: currentTerminalControl, inspection, fingerprint }) => {
14060
+ const runApprovalWithStateLock = async () => {
14061
+ let approval;
14062
+ let lockedConversation = conversation;
14063
+ const currentConversation = loadState(statePath);
14064
+ const currentTakeover = isRecord(currentConversation.native_session_takeover)
14065
+ ? currentConversation.native_session_takeover
14066
+ : undefined;
14067
+ const currentControl = terminalControlFromTakeover(currentTakeover);
14068
+ const currentApproval = isRecord(currentTakeover?.terminal_bridge_approval)
14069
+ ? currentTakeover.terminal_bridge_approval
14070
+ : undefined;
14071
+ if (currentConversation.status !== conversation.status ||
14072
+ currentTakeover?.terminal_bridge_message_id !== nativeTakeover?.terminal_bridge_message_id ||
14073
+ !currentControl ||
14074
+ !terminalControlsShareIncarnation(currentControl, terminalControl) ||
14075
+ (claudeScreenApproval &&
14076
+ currentApproval?.fingerprint !== monitoredApproval?.fingerprint)) {
14077
+ throw new Error("approval state changed while waiting for terminal control; refresh status and retry");
14078
+ }
14079
+ assertManagedTerminalDispatchOwner({
14080
+ storeDir: writerStoreDir,
14081
+ conversation: currentConversation,
14082
+ terminalControl: currentControl,
14083
+ action: "approve"
14084
+ });
14085
+ lockedConversation = currentConversation;
14086
+ const currentRuntimeIdentity = terminalRuntimeIdentityForConversation(currentConversation, currentControl);
14087
+ approval = await createTerminalAgentBridge(options).approve(executor.kind, currentControl, {
14088
+ expectedFingerprint,
14089
+ scrollbackLines: Number(options.scrollbackLines ?? 120),
14090
+ runtime: currentRuntimeIdentity,
14091
+ managedRequest: terminalDurableRequestForConversation(currentConversation, currentControl),
14092
+ requiredDecisionMode: autoApproved && executor.kind === "claude" ? "keys" : undefined,
14093
+ authorize: autoApproved
14094
+ ? ({ agent, terminalControl: currentTerminalControl, inspection, fingerprint }) => {
14095
+ if (!autoApprovalPolicy) {
14096
+ return {
14097
+ approved: false,
14098
+ reason: "automatic approval requires an executor-side policy"
14099
+ };
14100
+ }
14101
+ const candidate = policyCandidateForInspection({
14102
+ agent,
14103
+ currentTerminalControl,
14104
+ inspection,
14105
+ fingerprint
14106
+ });
14107
+ executorPolicyDecision = evaluateApprovalPolicy({
14108
+ policy: autoApprovalPolicy,
14109
+ candidate
14110
+ });
14111
+ if (executorPolicyDecision.action !== "approve") {
14112
+ return {
14113
+ approved: false,
14114
+ reason: `executor-side auto-approval policy rejected the current request: ${executorPolicyDecision.reason}`
14115
+ };
14116
+ }
14117
+ if (policyRuleId && executorPolicyDecision.ruleId !== policyRuleId) {
14118
+ return {
14119
+ approved: false,
14120
+ reason: "executor-side auto-approval rule changed before execution"
14121
+ };
14122
+ }
14123
+ if (policyFingerprint &&
14124
+ executorPolicyDecision.policyFingerprint !== policyFingerprint) {
14125
+ return {
14126
+ approved: false,
14127
+ reason: "executor-side auto-approval policy changed before execution"
14128
+ };
14129
+ }
14130
+ return { approved: true };
14131
+ }
14132
+ : undefined,
14133
+ beforeKeyDispatch: claudeScreenApproval
14134
+ ? ({ fingerprint, terminalControl: dispatchControl, inspection, keys }) => {
14135
+ if (autoApproved) {
13849
14136
  if (!autoApprovalPolicy) {
13850
- return {
13851
- approved: false,
13852
- reason: "automatic approval requires an executor-side policy"
13853
- };
14137
+ throw new Error("automatic approval requires an executor-side policy before dispatch");
13854
14138
  }
13855
- const candidate = policyCandidateForInspection({
13856
- agent,
13857
- currentTerminalControl,
13858
- inspection,
13859
- fingerprint
13860
- });
13861
- executorPolicyDecision = evaluateApprovalPolicy({
14139
+ const freshPolicyDecision = evaluateApprovalPolicy({
13862
14140
  policy: autoApprovalPolicy,
13863
- candidate
14141
+ candidate: policyCandidateForInspection({
14142
+ agent: executor.kind,
14143
+ currentTerminalControl: dispatchControl,
14144
+ inspection,
14145
+ fingerprint
14146
+ })
13864
14147
  });
13865
- if (executorPolicyDecision.action !== "approve") {
13866
- return {
13867
- approved: false,
13868
- reason: `executor-side auto-approval policy rejected the current request: ${executorPolicyDecision.reason}`
13869
- };
14148
+ if (freshPolicyDecision.action !== "approve") {
14149
+ throw new Error(`executor-side auto-approval policy rejected the recaptured request: ${freshPolicyDecision.reason}`);
13870
14150
  }
13871
- if (policyRuleId && executorPolicyDecision.ruleId !== policyRuleId) {
13872
- return {
13873
- approved: false,
13874
- reason: "executor-side auto-approval rule changed before execution"
13875
- };
14151
+ if (executorPolicyDecision?.ruleId &&
14152
+ freshPolicyDecision.ruleId !== executorPolicyDecision.ruleId) {
14153
+ throw new Error("executor-side auto-approval rule changed after recapture");
14154
+ }
14155
+ if (policyRuleId && freshPolicyDecision.ruleId !== policyRuleId) {
14156
+ throw new Error("executor-side auto-approval rule changed before dispatch");
13876
14157
  }
13877
14158
  if (policyFingerprint &&
13878
- executorPolicyDecision.policyFingerprint !== policyFingerprint) {
13879
- return {
13880
- approved: false,
13881
- reason: "executor-side auto-approval policy changed before execution"
13882
- };
14159
+ freshPolicyDecision.policyFingerprint !== policyFingerprint) {
14160
+ throw new Error("executor-side auto-approval policy changed before dispatch");
13883
14161
  }
13884
- return { approved: true };
14162
+ executorPolicyDecision = freshPolicyDecision;
13885
14163
  }
13886
- : undefined,
13887
- beforeKeyDispatch: claudeScreenApproval
13888
- ? ({ fingerprint, terminalControl: dispatchControl, inspection, keys }) => {
13889
- if (autoApproved) {
13890
- if (!autoApprovalPolicy) {
13891
- throw new Error("automatic approval requires an executor-side policy before dispatch");
13892
- }
13893
- const freshPolicyDecision = evaluateApprovalPolicy({
13894
- policy: autoApprovalPolicy,
13895
- candidate: policyCandidateForInspection({
13896
- agent: executor.kind,
13897
- currentTerminalControl: dispatchControl,
13898
- inspection,
13899
- fingerprint
13900
- })
13901
- });
13902
- if (freshPolicyDecision.action !== "approve") {
13903
- throw new Error(`executor-side auto-approval policy rejected the recaptured request: ${freshPolicyDecision.reason}`);
13904
- }
13905
- if (executorPolicyDecision?.ruleId &&
13906
- freshPolicyDecision.ruleId !== executorPolicyDecision.ruleId) {
13907
- throw new Error("executor-side auto-approval rule changed after recapture");
13908
- }
13909
- if (policyRuleId && freshPolicyDecision.ruleId !== policyRuleId) {
13910
- throw new Error("executor-side auto-approval rule changed before dispatch");
13911
- }
13912
- if (policyFingerprint &&
13913
- freshPolicyDecision.policyFingerprint !== policyFingerprint) {
13914
- throw new Error("executor-side auto-approval policy changed before dispatch");
13915
- }
13916
- executorPolicyDecision = freshPolicyDecision;
13917
- }
13918
- if (approvalDispatchReserved) {
13919
- throw new Error("Claude approval dispatch was already reserved");
13920
- }
13921
- approvalDispatchReserved = true;
13922
- if (!releaseStateLock) {
13923
- throw new Error("approval state lock was released before terminal dispatch");
13924
- }
13925
- const latestConversation = loadState(statePath);
13926
- const latestTakeover = isRecord(latestConversation.native_session_takeover)
13927
- ? latestConversation.native_session_takeover
13928
- : undefined;
13929
- const latestControl = terminalControlFromTakeover(latestTakeover);
13930
- const latestApproval = isRecord(latestTakeover?.terminal_bridge_approval)
13931
- ? latestTakeover.terminal_bridge_approval
13932
- : undefined;
13933
- const latestNotifiedAt = validTimestampMs(latestApproval?.notified_at);
13934
- const latestApprovalState = isRecord(latestApproval?.approval_state)
13935
- ? latestApproval.approval_state
13936
- : undefined;
13937
- const latestPolicyEvidence = isRecord(latestApprovalState?.policy_evidence)
13938
- ? latestApprovalState.policy_evidence
13939
- : undefined;
13940
- const recapturedPolicyEvidence = inspection.approval.approvable
13941
- ? inspection.approval.policyEvidence
13942
- : undefined;
13943
- const latestDispatch = isRecord(latestTakeover?.terminal_bridge_approval_dispatch)
13944
- ? latestTakeover.terminal_bridge_approval_dispatch
13945
- : undefined;
13946
- if (!latestTakeover ||
13947
- latestConversation.status !== "waiting_for_openclaw" ||
13948
- latestTakeover.terminal_bridge_message_id !==
13949
- nativeTakeover?.terminal_bridge_message_id ||
13950
- latestApproval?.fingerprint !== fingerprint ||
13951
- latestNotifiedAt === undefined ||
13952
- cliNowMs() - latestNotifiedAt > CLAUDE_SCREEN_APPROVAL_TTL_MS ||
13953
- expectedFingerprint !== fingerprint ||
13954
- !terminalControlsShareIncarnation(latestControl, dispatchControl) ||
13955
- (autoApproved &&
13956
- (latestPolicyEvidence?.source !== "claude_transcript" ||
13957
- latestPolicyEvidence.evidence_fingerprint !==
13958
- recapturedPolicyEvidence?.evidenceFingerprint))) {
13959
- throw new Error("approval state changed before terminal dispatch; refresh status and retry");
13960
- }
13961
- if (latestDispatch?.state === "reserved" &&
13962
- latestDispatch.terminal_bridge_message_id ===
13963
- latestTakeover.terminal_bridge_message_id) {
13964
- throw new Error("a previous Claude approval dispatch has an uncertain outcome; inspect and resolve the terminal manually");
13965
- }
13966
- const reservedAt = cliNow().toISOString();
13967
- const reservedConversation = {
13968
- ...latestConversation,
13969
- native_session_takeover: {
13970
- ...latestTakeover,
13971
- terminal_bridge_approval_dispatch: {
13972
- state: "reserved",
13973
- attempt_id: randomUUID(),
13974
- fingerprint,
13975
- keys,
13976
- terminal_target: dispatchControl.target,
13977
- terminal_bridge_message_id: latestTakeover.terminal_bridge_message_id,
13978
- reserved_at: reservedAt
13979
- }
13980
- },
13981
- updated_at: reservedAt
13982
- };
13983
- saveState(statePath, reservedConversation);
13984
- lockedConversation = reservedConversation;
14164
+ if (approvalDispatchReserved) {
14165
+ throw new Error("Claude approval dispatch was already reserved");
13985
14166
  }
13986
- : undefined
13987
- });
13988
- const actualFingerprint = approval.fingerprint;
13989
- const effectivePolicyRuleId = executorPolicyDecision?.ruleId ?? policyRuleId;
13990
- const effectivePolicyFingerprint = executorPolicyDecision?.policyFingerprint ?? policyFingerprint;
13991
- if (!approval.approved) {
13992
- releaseApprovalStateLock();
13993
- releaseApprovalTerminalLock();
13994
- if (autoApproved) {
13995
- appendEvent(logPath, {
13996
- ts: cliNow().toISOString(),
13997
- conversation_id: conversation.conversation_id,
13998
- event: "terminal_auto_approval_decision",
13999
- action: "rejected",
14000
- reason: approval.reason,
14001
- terminal_control: terminalControl,
14002
- expected_fingerprint: expectedFingerprint,
14003
- actual_fingerprint: actualFingerprint,
14004
- policy_rule_id: effectivePolicyRuleId,
14005
- policy_fingerprint: effectivePolicyFingerprint
14006
- });
14167
+ approvalDispatchReserved = true;
14168
+ if (!releaseStateLock) {
14169
+ throw new Error("approval state lock was released before terminal dispatch");
14170
+ }
14171
+ const latestConversation = loadState(statePath);
14172
+ const latestTakeover = isRecord(latestConversation.native_session_takeover)
14173
+ ? latestConversation.native_session_takeover
14174
+ : undefined;
14175
+ const latestControl = terminalControlFromTakeover(latestTakeover);
14176
+ const latestApproval = isRecord(latestTakeover?.terminal_bridge_approval)
14177
+ ? latestTakeover.terminal_bridge_approval
14178
+ : undefined;
14179
+ const latestNotifiedAt = validTimestampMs(latestApproval?.notified_at);
14180
+ const latestApprovalState = isRecord(latestApproval?.approval_state)
14181
+ ? latestApproval.approval_state
14182
+ : undefined;
14183
+ const latestPolicyEvidence = isRecord(latestApprovalState?.policy_evidence)
14184
+ ? latestApprovalState.policy_evidence
14185
+ : undefined;
14186
+ const recapturedPolicyEvidence = inspection.approval.approvable
14187
+ ? inspection.approval.policyEvidence
14188
+ : undefined;
14189
+ const latestDispatch = isRecord(latestTakeover?.terminal_bridge_approval_dispatch)
14190
+ ? latestTakeover.terminal_bridge_approval_dispatch
14191
+ : undefined;
14192
+ if (!latestTakeover ||
14193
+ latestConversation.status !== "waiting_for_openclaw" ||
14194
+ latestTakeover.terminal_bridge_message_id !==
14195
+ nativeTakeover?.terminal_bridge_message_id ||
14196
+ latestApproval?.fingerprint !== fingerprint ||
14197
+ latestNotifiedAt === undefined ||
14198
+ cliNowMs() - latestNotifiedAt > CLAUDE_SCREEN_APPROVAL_TTL_MS ||
14199
+ expectedFingerprint !== fingerprint ||
14200
+ !terminalControlsShareIncarnation(latestControl, dispatchControl) ||
14201
+ (autoApproved &&
14202
+ (latestPolicyEvidence?.source !== "claude_transcript" ||
14203
+ latestPolicyEvidence.evidence_fingerprint !==
14204
+ recapturedPolicyEvidence?.evidenceFingerprint))) {
14205
+ throw new Error("approval state changed before terminal dispatch; refresh status and retry");
14206
+ }
14207
+ if (latestDispatch?.state === "reserved" &&
14208
+ latestDispatch.terminal_bridge_message_id ===
14209
+ latestTakeover.terminal_bridge_message_id) {
14210
+ throw new Error("a previous Claude approval dispatch has an uncertain outcome; inspect and resolve the terminal manually");
14211
+ }
14212
+ const reservedAt = cliNow().toISOString();
14213
+ const reservedConversation = {
14214
+ ...latestConversation,
14215
+ native_session_takeover: {
14216
+ ...latestTakeover,
14217
+ terminal_bridge_approval_dispatch: {
14218
+ state: "reserved",
14219
+ attempt_id: randomUUID(),
14220
+ fingerprint,
14221
+ keys,
14222
+ terminal_target: dispatchControl.target,
14223
+ terminal_bridge_message_id: latestTakeover.terminal_bridge_message_id,
14224
+ reserved_at: reservedAt
14225
+ }
14226
+ },
14227
+ updated_at: reservedAt
14228
+ };
14229
+ saveState(statePath, reservedConversation);
14230
+ lockedConversation = reservedConversation;
14007
14231
  }
14008
- printJson({
14009
- conversation,
14010
- approved: false,
14011
- blocked: approval.blocked,
14012
- reason: approval.reason,
14013
- terminal_control: terminalControl,
14014
- expected_approval_fingerprint: expectedFingerprint,
14015
- actual_approval_fingerprint: actualFingerprint,
14016
- screen_excerpt: approval.screenExcerpt
14017
- });
14018
- return;
14019
- }
14020
- appendEvent(logPath, {
14021
- ts: cliNow().toISOString(),
14022
- conversation_id: conversation.conversation_id,
14023
- event: "terminal_approval_send",
14024
- terminal_control: terminalControl,
14025
- key: approval.key,
14026
- keys: approval.keys,
14027
- label: approval.label,
14028
- decision_mode: approval.decisionMode,
14029
- request_id: approval.requestId,
14030
- approval_fingerprint: actualFingerprint,
14031
- auto_approved: autoApproved,
14032
- policy_rule_id: effectivePolicyRuleId,
14033
- policy_fingerprint: effectivePolicyFingerprint
14034
- });
14232
+ : undefined
14233
+ });
14234
+ const actualFingerprint = approval.fingerprint;
14235
+ const effectivePolicyRuleId = executorPolicyDecision?.ruleId ?? policyRuleId;
14236
+ const effectivePolicyFingerprint = executorPolicyDecision?.policyFingerprint ?? policyFingerprint;
14237
+ if (!approval.approved) {
14238
+ releaseApprovalStateLock();
14035
14239
  if (autoApproved) {
14036
14240
  appendEvent(logPath, {
14037
14241
  ts: cliNow().toISOString(),
14038
14242
  conversation_id: conversation.conversation_id,
14039
14243
  event: "terminal_auto_approval_decision",
14040
- action: "approved",
14244
+ action: "rejected",
14245
+ reason: approval.reason,
14041
14246
  terminal_control: terminalControl,
14042
- approval_fingerprint: actualFingerprint,
14247
+ expected_fingerprint: expectedFingerprint,
14248
+ actual_fingerprint: actualFingerprint,
14043
14249
  policy_rule_id: effectivePolicyRuleId,
14044
14250
  policy_fingerprint: effectivePolicyFingerprint
14045
14251
  });
14046
14252
  }
14047
- runtimeLog("info", "terminal_approval_send", {
14253
+ printJson({
14254
+ conversation,
14255
+ approved: false,
14256
+ blocked: approval.blocked,
14257
+ reason: approval.reason,
14258
+ terminal_control: terminalControl,
14259
+ expected_approval_fingerprint: expectedFingerprint,
14260
+ actual_approval_fingerprint: actualFingerprint,
14261
+ screen_excerpt: approval.screenExcerpt
14262
+ });
14263
+ return;
14264
+ }
14265
+ appendEvent(logPath, {
14266
+ ts: cliNow().toISOString(),
14267
+ conversation_id: conversation.conversation_id,
14268
+ event: "terminal_approval_send",
14269
+ terminal_control: terminalControl,
14270
+ key: approval.key,
14271
+ keys: approval.keys,
14272
+ label: approval.label,
14273
+ decision_mode: approval.decisionMode,
14274
+ request_id: approval.requestId,
14275
+ approval_fingerprint: actualFingerprint,
14276
+ auto_approved: autoApproved,
14277
+ policy_rule_id: effectivePolicyRuleId,
14278
+ policy_fingerprint: effectivePolicyFingerprint
14279
+ });
14280
+ if (autoApproved) {
14281
+ appendEvent(logPath, {
14282
+ ts: cliNow().toISOString(),
14048
14283
  conversation_id: conversation.conversation_id,
14049
- terminal_target: terminalControl.target,
14050
- key: approval.key,
14051
- keys: approval.keys,
14052
- label: approval.label,
14053
- decision_mode: approval.decisionMode,
14054
- request_id: approval.requestId,
14284
+ event: "terminal_auto_approval_decision",
14285
+ action: "approved",
14286
+ terminal_control: terminalControl,
14055
14287
  approval_fingerprint: actualFingerprint,
14056
- auto_approved: autoApproved,
14057
14288
  policy_rule_id: effectivePolicyRuleId,
14058
14289
  policy_fingerprint: effectivePolicyFingerprint
14059
14290
  });
14060
- const nativeTakeoverForUpdate = isRecord(lockedConversation.native_session_takeover)
14061
- ? { ...lockedConversation.native_session_takeover }
14062
- : {};
14063
- const resolvedApproval = isRecord(nativeTakeoverForUpdate.terminal_bridge_approval)
14064
- ? nativeTakeoverForUpdate.terminal_bridge_approval
14065
- : undefined;
14066
- const resolvedApprovalScreenDigest = stringValue(resolvedApproval?.screen_digest);
14067
- const resolvedApprovalState = isRecord(resolvedApproval?.approval_state)
14068
- ? resolvedApproval.approval_state
14069
- : undefined;
14070
- const resolvedTranscriptIdentity = claudeTranscriptApprovalIdentity(resolvedApprovalState);
14071
- const approvalResolvedAt = cliNow().toISOString();
14072
- const agentTimeoutMinutes = Number(options.agentTimeoutMinutes ??
14073
- nativeTakeoverForUpdate.terminal_bridge_inactivity_timeout_minutes ??
14074
- DEFAULT_AGENT_TIMEOUT_MINUTES);
14075
- const agentHardTimeoutMinutes = positiveMinutes(options.agentHardTimeoutMinutes ??
14076
- nativeTakeoverForUpdate.terminal_bridge_hard_timeout_minutes ??
14077
- DEFAULT_AGENT_HARD_TIMEOUT_MINUTES, "--agent-hard-timeout-minutes");
14078
- const nextNativeTakeover = {
14079
- ...nativeTakeoverForUpdate,
14080
- terminal_bridge_approval: undefined,
14081
- terminal_bridge_approval_dispatch: undefined,
14082
- terminal_bridge_approval_resolved_at: approvalResolvedAt,
14083
- terminal_bridge_last_approval_fingerprint: actualFingerprint,
14084
- terminal_bridge_last_approval_screen_digest: resolvedApprovalScreenDigest,
14085
- terminal_bridge_last_approval_request_id: resolvedTranscriptIdentity?.requestId,
14086
- terminal_bridge_last_approval_evidence_fingerprint: resolvedTranscriptIdentity?.evidenceFingerprint,
14087
- terminal_bridge_last_approval_prompt_cleared_at: undefined,
14088
- terminal_bridge_last_approval_at: approvalResolvedAt,
14089
- terminal_bridge_last_approval_message_id: nativeTakeoverForUpdate.terminal_bridge_message_id,
14090
- terminal_bridge_monitor_lock_version: TERMINAL_BRIDGE_MONITOR_LOCK_VERSION,
14091
- terminal_bridge_monitor_started_at: approvalResolvedAt,
14092
- terminal_bridge_last_activity_at: approvalResolvedAt,
14093
- terminal_bridge_last_activity_reason: "approval resolved",
14094
- terminal_bridge_inactivity_timeout_minutes: agentTimeoutMinutes,
14095
- terminal_bridge_hard_timeout_minutes: agentHardTimeoutMinutes,
14096
- terminal_bridge_inactivity_deadline_at: deadlineAt(approvalResolvedAt, agentTimeoutMinutes),
14097
- terminal_bridge_hard_deadline_at: deadlineAt(stringValue(nativeTakeoverForUpdate.terminal_bridge_started_at) ?? approvalResolvedAt, agentHardTimeoutMinutes)
14098
- };
14099
- delete nextNativeTakeover.terminal_bridge_approval;
14100
- delete nextNativeTakeover.terminal_bridge_approval_dispatch;
14101
- delete nextNativeTakeover.terminal_bridge_last_approval_prompt_cleared_at;
14102
- const nextConversation = {
14103
- ...lockedConversation,
14104
- status: terminalBridgeEnabled(lockedConversation)
14105
- ? "waiting_for_agent"
14106
- : lockedConversation.status,
14107
- native_session_takeover: nextNativeTakeover,
14108
- updated_at: approvalResolvedAt
14109
- };
14110
- saveState(statePath, nextConversation);
14111
- releaseApprovalStateLock();
14112
- releaseApprovalTerminalLock();
14113
- const bridgeMonitor = ensureTerminalBridgeMonitorAfterApproval({
14114
- conversation: nextConversation,
14115
- statePath,
14116
- logPath,
14117
- terminalControl,
14118
- options
14119
- });
14120
- printJson({
14121
- conversation: nextConversation,
14122
- approved: true,
14123
- terminal_control: terminalControl,
14124
- key: approval.key,
14125
- keys: approval.keys,
14126
- label: approval.label,
14127
- decision_mode: approval.decisionMode,
14128
- request_id: approval.requestId,
14129
- approval_fingerprint: actualFingerprint,
14130
- auto_approved: autoApproved,
14131
- policy_rule_id: effectivePolicyRuleId,
14132
- policy_fingerprint: effectivePolicyFingerprint,
14133
- monitor_pid: bridgeMonitor.monitorPid ?? null,
14134
- monitor_handoff_pid: bridgeMonitor.handoffWatchdog?.pid ?? null
14135
- });
14291
+ }
14292
+ runtimeLog("info", "terminal_approval_send", {
14293
+ conversation_id: conversation.conversation_id,
14294
+ terminal_target: terminalControl.target,
14295
+ key: approval.key,
14296
+ keys: approval.keys,
14297
+ label: approval.label,
14298
+ decision_mode: approval.decisionMode,
14299
+ request_id: approval.requestId,
14300
+ approval_fingerprint: actualFingerprint,
14301
+ auto_approved: autoApproved,
14302
+ policy_rule_id: effectivePolicyRuleId,
14303
+ policy_fingerprint: effectivePolicyFingerprint
14304
+ });
14305
+ const nativeTakeoverForUpdate = isRecord(lockedConversation.native_session_takeover)
14306
+ ? { ...lockedConversation.native_session_takeover }
14307
+ : {};
14308
+ const resolvedApproval = isRecord(nativeTakeoverForUpdate.terminal_bridge_approval)
14309
+ ? nativeTakeoverForUpdate.terminal_bridge_approval
14310
+ : undefined;
14311
+ const resolvedApprovalScreenDigest = stringValue(resolvedApproval?.screen_digest);
14312
+ const resolvedApprovalState = isRecord(resolvedApproval?.approval_state)
14313
+ ? resolvedApproval.approval_state
14314
+ : undefined;
14315
+ const resolvedTranscriptIdentity = claudeTranscriptApprovalIdentity(resolvedApprovalState);
14316
+ const approvalResolvedAt = cliNow().toISOString();
14317
+ const agentTimeoutMinutes = Number(options.agentTimeoutMinutes ??
14318
+ nativeTakeoverForUpdate.terminal_bridge_inactivity_timeout_minutes ??
14319
+ DEFAULT_AGENT_TIMEOUT_MINUTES);
14320
+ const agentHardTimeoutMinutes = positiveMinutes(options.agentHardTimeoutMinutes ??
14321
+ nativeTakeoverForUpdate.terminal_bridge_hard_timeout_minutes ??
14322
+ DEFAULT_AGENT_HARD_TIMEOUT_MINUTES, "--agent-hard-timeout-minutes");
14323
+ const nextNativeTakeover = {
14324
+ ...nativeTakeoverForUpdate,
14325
+ terminal_bridge_approval: undefined,
14326
+ terminal_bridge_approval_dispatch: undefined,
14327
+ terminal_bridge_approval_resolved_at: approvalResolvedAt,
14328
+ terminal_bridge_last_approval_fingerprint: actualFingerprint,
14329
+ terminal_bridge_last_approval_screen_digest: resolvedApprovalScreenDigest,
14330
+ terminal_bridge_last_approval_request_id: resolvedTranscriptIdentity?.requestId,
14331
+ terminal_bridge_last_approval_evidence_fingerprint: resolvedTranscriptIdentity?.evidenceFingerprint,
14332
+ terminal_bridge_last_approval_prompt_cleared_at: undefined,
14333
+ terminal_bridge_last_approval_at: approvalResolvedAt,
14334
+ terminal_bridge_last_approval_message_id: nativeTakeoverForUpdate.terminal_bridge_message_id,
14335
+ terminal_bridge_monitor_lock_version: TERMINAL_BRIDGE_MONITOR_LOCK_VERSION,
14336
+ terminal_bridge_monitor_started_at: approvalResolvedAt,
14337
+ terminal_bridge_last_activity_at: approvalResolvedAt,
14338
+ terminal_bridge_last_activity_reason: "approval resolved",
14339
+ terminal_bridge_inactivity_timeout_minutes: agentTimeoutMinutes,
14340
+ terminal_bridge_hard_timeout_minutes: agentHardTimeoutMinutes,
14341
+ terminal_bridge_inactivity_deadline_at: deadlineAt(approvalResolvedAt, agentTimeoutMinutes),
14342
+ terminal_bridge_hard_deadline_at: deadlineAt(stringValue(nativeTakeoverForUpdate.terminal_bridge_started_at) ?? approvalResolvedAt, agentHardTimeoutMinutes)
14343
+ };
14344
+ delete nextNativeTakeover.terminal_bridge_approval;
14345
+ delete nextNativeTakeover.terminal_bridge_approval_dispatch;
14346
+ delete nextNativeTakeover.terminal_bridge_last_approval_prompt_cleared_at;
14347
+ const nextConversation = {
14348
+ ...lockedConversation,
14349
+ status: terminalBridgeEnabled(lockedConversation)
14350
+ ? "waiting_for_agent"
14351
+ : lockedConversation.status,
14352
+ native_session_takeover: nextNativeTakeover,
14353
+ updated_at: approvalResolvedAt
14354
+ };
14355
+ saveState(statePath, nextConversation);
14356
+ releaseApprovalStateLock();
14357
+ const bridgeMonitor = ensureTerminalBridgeMonitorAfterApproval({
14358
+ conversation: nextConversation,
14359
+ statePath,
14360
+ logPath,
14361
+ terminalControl,
14362
+ options
14363
+ });
14364
+ printJson({
14365
+ conversation: nextConversation,
14366
+ approved: true,
14367
+ terminal_control: terminalControl,
14368
+ key: approval.key,
14369
+ keys: approval.keys,
14370
+ label: approval.label,
14371
+ decision_mode: approval.decisionMode,
14372
+ request_id: approval.requestId,
14373
+ approval_fingerprint: actualFingerprint,
14374
+ auto_approved: autoApproved,
14375
+ policy_rule_id: effectivePolicyRuleId,
14376
+ policy_fingerprint: effectivePolicyFingerprint,
14377
+ monitor_pid: bridgeMonitor.monitorPid ?? null,
14378
+ monitor_handoff_pid: bridgeMonitor.handoffWatchdog?.pid ?? null
14379
+ });
14380
+ };
14381
+ try {
14382
+ return await withStoreWriterLeaseAsync(writerStoreDir, async () => {
14383
+ releaseStateLock = acquireFileLock(`${statePath}.lock`);
14384
+ try {
14385
+ return await runApprovalWithStateLock();
14386
+ }
14387
+ finally {
14388
+ releaseApprovalStateLock();
14389
+ }
14136
14390
  });
14137
14391
  }
14138
14392
  finally {
@@ -17525,6 +17779,10 @@ async function runRenew(options) {
17525
17779
  if (conversation.status !== "stalled") {
17526
17780
  throw new Error(`cannot renew ${conversation.conversation_id}; conversation is ${conversation.status}, not stalled`);
17527
17781
  }
17782
+ if (isVerifiedDeadTerminalAgentProcess(conversation)) {
17783
+ throw new Error(`cannot renew ${conversation.conversation_id}; its bound terminal agent ` +
17784
+ "process is verified dead. Close this orphaned Turn instead.");
17785
+ }
17528
17786
  if (terminalBridgeSubmission(conversation)?.status === "uncertain") {
17529
17787
  throw new Error(`cannot renew ${conversation.conversation_id}; its terminal submission ` +
17530
17788
  "is uncertain and cannot be attributed by monitoring. Inspect the pane " +
@@ -17556,6 +17814,10 @@ async function runRenew(options) {
17556
17814
  if (current.status !== "stalled") {
17557
17815
  throw new Error(`cannot renew ${current.conversation_id}; conversation is ${current.status}, not stalled`);
17558
17816
  }
17817
+ if (isVerifiedDeadTerminalAgentProcess(current)) {
17818
+ throw new Error(`cannot renew ${current.conversation_id}; its bound terminal agent ` +
17819
+ "process is verified dead");
17820
+ }
17559
17821
  if (terminalBridgeSubmission(current)?.status === "uncertain") {
17560
17822
  throw new Error(`cannot renew ${current.conversation_id}; its terminal submission is uncertain`);
17561
17823
  }
@@ -17766,6 +18028,47 @@ async function reconcileMonitors(options, { includeCallbackRecovery, reason, con
17766
18028
  logPath,
17767
18029
  options
17768
18030
  });
18031
+ const deadProcessStall = await stallAcceptedTurnForVerifiedDeadAgent({
18032
+ options,
18033
+ storeDir,
18034
+ statePath,
18035
+ logPath,
18036
+ expectedConversationId: initialConversation.conversation_id,
18037
+ expectedMessageId: stringValue(isRecord(initialConversation.native_session_takeover)
18038
+ ? initialConversation.native_session_takeover
18039
+ .terminal_bridge_message_id
18040
+ : undefined)
18041
+ });
18042
+ if (deadProcessStall.completionPreparation) {
18043
+ const completionPreparation = deadProcessStall.completionPreparation;
18044
+ skipped += 1;
18045
+ if (!completionPreparation.claimed) {
18046
+ items.push({
18047
+ conversation_id: initialConversation.conversation_id,
18048
+ status: "skipped",
18049
+ reason: completionPreparation.reason
18050
+ });
18051
+ }
18052
+ else {
18053
+ const completionResult = runPreparedCallback(completionPreparation.prepared, { emit: false });
18054
+ items.push({
18055
+ conversation_id: initialConversation.conversation_id,
18056
+ status: "recovered",
18057
+ reason: "bound_agent_process_dead_completion_recovered",
18058
+ delivered: completionResult.delivered
18059
+ });
18060
+ }
18061
+ continue;
18062
+ }
18063
+ if (deadProcessStall.stalled) {
18064
+ skipped += 1;
18065
+ items.push({
18066
+ conversation_id: initialConversation.conversation_id,
18067
+ status: "stalled",
18068
+ reason: deadProcessStall.reason
18069
+ });
18070
+ continue;
18071
+ }
17769
18072
  const initialTakeover = isRecord(initialConversation.native_session_takeover)
17770
18073
  ? initialConversation.native_session_takeover
17771
18074
  : undefined;
@@ -18256,6 +18559,600 @@ function terminalBridgeReconciliationEligibility(conversation) {
18256
18559
  hardDeadlineAtMs
18257
18560
  };
18258
18561
  }
18562
+ function terminalAgentProcessDisposition(conversation) {
18563
+ return isRecord(conversation.terminal_agent_process_disposition)
18564
+ ? conversation.terminal_agent_process_disposition
18565
+ : undefined;
18566
+ }
18567
+ function isVerifiedDeadTerminalAgentProcess(conversation) {
18568
+ return terminalAgentProcessDisposition(conversation)?.status ===
18569
+ "verified_dead";
18570
+ }
18571
+ function verifiedDeadTerminalAgentProcessEvidenceId(proof) {
18572
+ return createHash("sha256")
18573
+ .update(canonicalJson({
18574
+ kind: proof.kind,
18575
+ agent: proof.agent,
18576
+ pid: proof.pid,
18577
+ process_uuid: proof.process_uuid,
18578
+ process_birth: proof.process_birth ?? null,
18579
+ conversation_id: proof.conversation_id,
18580
+ session_id: proof.session_id,
18581
+ turn_id: proof.turn_id,
18582
+ terminal_endpoint: proof.terminal_endpoint,
18583
+ binding_id: proof.binding_id,
18584
+ binding_generation: proof.binding_generation,
18585
+ message_id: proof.message_id
18586
+ }))
18587
+ .digest("hex");
18588
+ }
18589
+ function storedVerifiedDeadTerminalAgentProcessProof({ conversation, storeDir, terminalControl }) {
18590
+ const disposition = terminalAgentProcessDisposition(conversation);
18591
+ if (disposition?.status !== "verified_dead") {
18592
+ return { status: "absent" };
18593
+ }
18594
+ const proof = isRecord(disposition.proof)
18595
+ ? disposition.proof
18596
+ : undefined;
18597
+ const evidenceId = stringValue(disposition.evidence_id);
18598
+ const recordedAt = stringValue(disposition.recorded_at);
18599
+ const takeover = isRecord(conversation.native_session_takeover)
18600
+ ? conversation.native_session_takeover
18601
+ : undefined;
18602
+ const submission = terminalBridgeSubmission(conversation);
18603
+ const session = tryLoadManagedSession(storeDir, sessionIdForConversation(conversation));
18604
+ const binding = session?.binding;
18605
+ const candidate = proof;
18606
+ if (!candidate ||
18607
+ candidate.kind !==
18608
+ "exact_pid_absent_from_complete_process_inventory" ||
18609
+ !session ||
18610
+ session.status !== "bound" ||
18611
+ !binding ||
18612
+ candidate.agent !== session.agent ||
18613
+ session.agent !== executorForConversation(conversation).kind ||
18614
+ path.resolve(session.workspace) !== path.resolve(conversation.workspace) ||
18615
+ candidate.pid !== Number(takeover?.terminal_agent_pid) ||
18616
+ candidate.pid !== binding.native_process.pid ||
18617
+ candidate.process_uuid !==
18618
+ stringValue(takeover?.terminal_agent_process_uuid) ||
18619
+ candidate.process_uuid !== binding.native_process.process_uuid ||
18620
+ (candidate.process_birth ?? undefined) !==
18621
+ (stringValue(takeover?.terminal_agent_process_birth) ?? undefined) ||
18622
+ (candidate.process_birth ?? undefined) !==
18623
+ (binding.native_process.process_birth ?? undefined) ||
18624
+ candidate.conversation_id !== conversation.conversation_id ||
18625
+ candidate.session_id !== sessionIdForConversation(conversation) ||
18626
+ candidate.turn_id !== turnIdForConversation(conversation) ||
18627
+ candidate.binding_id !== stringValue(conversation.terminal_binding_id) ||
18628
+ candidate.binding_id !== binding.binding_id ||
18629
+ candidate.binding_generation !==
18630
+ Number(conversation.terminal_binding_generation) ||
18631
+ candidate.binding_generation !== binding.generation ||
18632
+ !terminalControlsShareIncarnation(binding.terminal_control, terminalControl) ||
18633
+ candidate.binding_id !== stringValue(takeover?.terminal_binding_id) ||
18634
+ candidate.binding_generation !==
18635
+ Number(takeover?.terminal_binding_generation) ||
18636
+ candidate.message_id !==
18637
+ stringValue(takeover?.terminal_bridge_message_id) ||
18638
+ candidate.message_id !== stringValue(submission?.message_id) ||
18639
+ submission?.status !== "agent_accepted" ||
18640
+ stringValue(submission?.session_id) !== candidate.session_id ||
18641
+ stringValue(submission?.turn_id) !== candidate.turn_id ||
18642
+ stringValue(submission?.binding_id) !== candidate.binding_id ||
18643
+ Number(submission?.binding_generation) !==
18644
+ candidate.binding_generation ||
18645
+ !isRecord(candidate.terminal_control) ||
18646
+ !terminalControlsShareIncarnation(candidate.terminal_control, terminalControl) ||
18647
+ !terminalControlEvidenceMatches(candidate.terminal_endpoint, terminalControl, { requireProcessAnchor: true }) ||
18648
+ Number(candidate.terminal_endpoint.process_anchor_pid) !==
18649
+ terminalEndpointFromControlRef(terminalControl).processAnchorPid ||
18650
+ validTimestampMs(candidate.observed_at) === undefined ||
18651
+ !recordedAt ||
18652
+ validTimestampMs(recordedAt) === undefined ||
18653
+ recordedAt !== candidate.observed_at ||
18654
+ !evidenceId ||
18655
+ evidenceId !== verifiedDeadTerminalAgentProcessEvidenceId(candidate)) {
18656
+ return {
18657
+ status: "invalid",
18658
+ reason: "the persisted process-death proof no longer matches the exact Turn, Session, terminal, or submission binding"
18659
+ };
18660
+ }
18661
+ return {
18662
+ status: "valid",
18663
+ proof: candidate,
18664
+ evidenceId,
18665
+ recordedAt
18666
+ };
18667
+ }
18668
+ function eventVerifiedDeadTerminalAgentProcessProof({ conversation, storeDir, terminalControl, logPath }) {
18669
+ const candidates = readExistingEvents(logPath).filter((event) => event.event === "terminal_agent_process_verified_dead" &&
18670
+ event.conversation_id === conversation.conversation_id);
18671
+ if (candidates.length === 0) {
18672
+ return { status: "absent" };
18673
+ }
18674
+ if (candidates.length !== 1) {
18675
+ return {
18676
+ status: "invalid",
18677
+ reason: "the process-death event history is ambiguous"
18678
+ };
18679
+ }
18680
+ const event = candidates[0];
18681
+ const proof = isRecord(event.proof)
18682
+ ? event.proof
18683
+ : undefined;
18684
+ const evidenceId = stringValue(event.evidence_id);
18685
+ const recordedAt = stringValue(event.ts);
18686
+ if (!proof ||
18687
+ !evidenceId ||
18688
+ !recordedAt ||
18689
+ proof.observed_at !== recordedAt ||
18690
+ verifiedDeadTerminalAgentProcessEvidenceId(proof) !== evidenceId) {
18691
+ return {
18692
+ status: "invalid",
18693
+ reason: "the process-death event proof is malformed"
18694
+ };
18695
+ }
18696
+ const synthetic = {
18697
+ ...conversation,
18698
+ terminal_agent_process_disposition: {
18699
+ status: "verified_dead",
18700
+ proof,
18701
+ evidence_id: evidenceId,
18702
+ recorded_at: recordedAt
18703
+ }
18704
+ };
18705
+ return storedVerifiedDeadTerminalAgentProcessProof({
18706
+ conversation: synthetic,
18707
+ storeDir,
18708
+ terminalControl
18709
+ });
18710
+ }
18711
+ function exactVerifiedDeadTerminalAgentProcessAuthority({ conversation, storeDir, terminalControl, logPath }) {
18712
+ const stored = storedVerifiedDeadTerminalAgentProcessProof({
18713
+ conversation,
18714
+ storeDir,
18715
+ terminalControl
18716
+ });
18717
+ if (stored.status === "invalid") {
18718
+ return stored;
18719
+ }
18720
+ const event = eventVerifiedDeadTerminalAgentProcessProof({
18721
+ conversation,
18722
+ storeDir,
18723
+ terminalControl,
18724
+ logPath
18725
+ });
18726
+ if (stored.status === "absent") {
18727
+ return event;
18728
+ }
18729
+ if (event.status !== "valid" ||
18730
+ event.evidenceId !== stored.evidenceId ||
18731
+ canonicalJson(event.proof) !== canonicalJson(stored.proof)) {
18732
+ return {
18733
+ status: "invalid",
18734
+ reason: "the persisted process-death disposition has no exact append-only event"
18735
+ };
18736
+ }
18737
+ return stored;
18738
+ }
18739
+ function ensureVerifiedDeadTerminalAgentProcessEvent({ logPath, proof, action }) {
18740
+ const evidenceId = verifiedDeadTerminalAgentProcessEvidenceId(proof);
18741
+ const existing = readExistingEvents(logPath).find((event) => event.event === "terminal_agent_process_verified_dead" &&
18742
+ event.conversation_id === proof.conversation_id &&
18743
+ event.evidence_id === evidenceId);
18744
+ if (existing) {
18745
+ const existingProof = isRecord(existing.proof)
18746
+ ? existing.proof
18747
+ : undefined;
18748
+ const existingAt = stringValue(existing.ts);
18749
+ if (!existingProof ||
18750
+ !existingAt ||
18751
+ validTimestampMs(existingAt) === undefined ||
18752
+ existingProof.observed_at !== existingAt ||
18753
+ verifiedDeadTerminalAgentProcessEvidenceId(existingProof) !== evidenceId) {
18754
+ throw new Error(`verified-dead process event ${evidenceId} is inconsistent`);
18755
+ }
18756
+ return {
18757
+ proof: existingProof,
18758
+ evidenceId,
18759
+ recordedAt: existingAt
18760
+ };
18761
+ }
18762
+ appendEvent(logPath, {
18763
+ ts: proof.observed_at,
18764
+ conversation_id: proof.conversation_id,
18765
+ event: "terminal_agent_process_verified_dead",
18766
+ evidence_id: evidenceId,
18767
+ status: "verified_dead",
18768
+ proof,
18769
+ action
18770
+ });
18771
+ return {
18772
+ proof,
18773
+ evidenceId,
18774
+ recordedAt: proof.observed_at
18775
+ };
18776
+ }
18777
+ function ensureVerifiedDeadConversationStalledEvent({ logPath, proof, evidenceId, reason, terminalControl, completionObservation }) {
18778
+ const existing = readExistingEvents(logPath).find((event) => event.event === "conversation_stalled" &&
18779
+ event.conversation_id === proof.conversation_id &&
18780
+ event.evidence_id === evidenceId);
18781
+ if (existing) {
18782
+ if (existing.ts !== proof.observed_at ||
18783
+ existing.reason !== reason ||
18784
+ existing.disposition !== "verified_dead_agent_process" ||
18785
+ existing.completion_observation !== completionObservation) {
18786
+ throw new Error(`verified-dead stalled event ${evidenceId} is inconsistent`);
18787
+ }
18788
+ return;
18789
+ }
18790
+ appendEvent(logPath, {
18791
+ ts: proof.observed_at,
18792
+ conversation_id: proof.conversation_id,
18793
+ event: "conversation_stalled",
18794
+ evidence_id: evidenceId,
18795
+ status: "stalled",
18796
+ reason,
18797
+ terminal_bridge: true,
18798
+ terminal_control: terminalControl,
18799
+ disposition: "verified_dead_agent_process",
18800
+ completion_observation: completionObservation
18801
+ });
18802
+ }
18803
+ function verifiedDeadConversationStalledEventDecision({ logPath, proof, evidenceId, reason }) {
18804
+ const candidates = readExistingEvents(logPath).filter((event) => event.event === "conversation_stalled" &&
18805
+ event.conversation_id === proof.conversation_id &&
18806
+ event.evidence_id === evidenceId);
18807
+ if (candidates.length === 0) {
18808
+ return { status: "absent" };
18809
+ }
18810
+ if (candidates.length !== 1) {
18811
+ return {
18812
+ status: "invalid",
18813
+ reason: "the verified-dead stalled event history is ambiguous"
18814
+ };
18815
+ }
18816
+ const event = candidates[0];
18817
+ const completionObservation = stringValue(event.completion_observation);
18818
+ if (event.ts !== proof.observed_at ||
18819
+ event.status !== "stalled" ||
18820
+ event.reason !== reason ||
18821
+ event.disposition !== "verified_dead_agent_process" ||
18822
+ !["absent", "unverifiable"].includes(completionObservation ?? "")) {
18823
+ return {
18824
+ status: "invalid",
18825
+ reason: "the verified-dead stalled event decision is inconsistent"
18826
+ };
18827
+ }
18828
+ return {
18829
+ status: "valid",
18830
+ completionObservation: completionObservation
18831
+ };
18832
+ }
18833
+ function ensureVerifiedDeadConversationClosedEvent({ logPath, conversation, evidenceId }) {
18834
+ const closedAt = required(stringValue(conversation.closed_at), "verified-dead closed Turn has no closed_at timestamp");
18835
+ const reason = required(stringValue(conversation.close_reason), "verified-dead closed Turn has no close reason");
18836
+ if (conversation.status !== "closed" ||
18837
+ validTimestampMs(closedAt) === undefined) {
18838
+ throw new Error("verified-dead closed Turn state is inconsistent");
18839
+ }
18840
+ const existing = readExistingEvents(logPath).find((event) => event.event === "conversation_closed" &&
18841
+ event.conversation_id === conversation.conversation_id &&
18842
+ event.evidence_id === evidenceId);
18843
+ if (existing) {
18844
+ if (existing.ts !== closedAt ||
18845
+ existing.status !== "closed" ||
18846
+ existing.reason !== reason ||
18847
+ existing.disposition !== "verified_dead_agent_process") {
18848
+ throw new Error(`verified-dead close event ${evidenceId} is inconsistent`);
18849
+ }
18850
+ return;
18851
+ }
18852
+ appendEvent(logPath, {
18853
+ ts: closedAt,
18854
+ conversation_id: conversation.conversation_id,
18855
+ event: "conversation_closed",
18856
+ evidence_id: evidenceId,
18857
+ status: "closed",
18858
+ reason,
18859
+ disposition: "verified_dead_agent_process"
18860
+ });
18861
+ }
18862
+ function acceptedTurnCanBeStalledForDeadAgent({ storeDir, conversation }) {
18863
+ if (conversation.status !== "waiting_for_agent") {
18864
+ return false;
18865
+ }
18866
+ const takeover = isRecord(conversation.native_session_takeover)
18867
+ ? conversation.native_session_takeover
18868
+ : undefined;
18869
+ const messageId = stringValue(takeover?.terminal_bridge_message_id);
18870
+ const submission = terminalBridgeSubmission(conversation);
18871
+ if (takeover?.terminal_bridge !== true ||
18872
+ !messageId ||
18873
+ submission?.status !== "agent_accepted" ||
18874
+ stringValue(submission.message_id) !== messageId) {
18875
+ return false;
18876
+ }
18877
+ const transferId = stringValue(takeover.deferred_foreground_transfer_id);
18878
+ if (!transferId) {
18879
+ return true;
18880
+ }
18881
+ const transfer = loadDeferredForegroundTransfer(storeDir, transferId);
18882
+ return transfer.status === "resolved";
18883
+ }
18884
+ async function stallAcceptedTurnForVerifiedDeadAgent({ options, storeDir, statePath, logPath, expectedConversationId, expectedMessageId }) {
18885
+ const canonicalStoreDir = pathsForConversationDir(path.dirname(statePath)).storeDir;
18886
+ if (path.resolve(storeDir) !== path.resolve(canonicalStoreDir)) {
18887
+ const conversation = loadState(statePath);
18888
+ return {
18889
+ stalled: false,
18890
+ conversation,
18891
+ reason: "dead_process_stall_store_mismatch"
18892
+ };
18893
+ }
18894
+ const initial = loadState(statePath);
18895
+ const initialTakeover = isRecord(initial.native_session_takeover)
18896
+ ? initial.native_session_takeover
18897
+ : undefined;
18898
+ const terminalControl = terminalControlFromTakeover(initialTakeover);
18899
+ if (initial.conversation_id !== expectedConversationId ||
18900
+ !terminalControl ||
18901
+ !acceptedTurnCanBeStalledForDeadAgent({ storeDir, conversation: initial })) {
18902
+ return {
18903
+ stalled: false,
18904
+ conversation: initial,
18905
+ reason: "dead_process_stall_not_applicable"
18906
+ };
18907
+ }
18908
+ const releaseTerminalLock = acquireTerminalBridgeSendLock(storeDir, terminalControl, { timeoutMs: 30000 });
18909
+ try {
18910
+ return await withStoreWriterLeaseAsync(storeDir, async () => {
18911
+ const releaseStateLock = acquireFileLock(`${statePath}.lock`);
18912
+ let stateLockReleased = false;
18913
+ try {
18914
+ const current = loadState(statePath);
18915
+ const takeover = isRecord(current.native_session_takeover)
18916
+ ? current.native_session_takeover
18917
+ : undefined;
18918
+ const currentControl = terminalControlFromTakeover(takeover);
18919
+ const messageId = stringValue(takeover?.terminal_bridge_message_id);
18920
+ if (current.conversation_id !== expectedConversationId ||
18921
+ (expectedMessageId !== undefined && messageId !== expectedMessageId) ||
18922
+ !currentControl ||
18923
+ !terminalControlsShareIncarnation(currentControl, terminalControl) ||
18924
+ !acceptedTurnCanBeStalledForDeadAgent({
18925
+ storeDir,
18926
+ conversation: current
18927
+ })) {
18928
+ return {
18929
+ stalled: false,
18930
+ conversation: current,
18931
+ reason: "dead_process_stall_generation_changed"
18932
+ };
18933
+ }
18934
+ assertTurnBindingCurrent(current, "stall a verified-dead agent Turn for");
18935
+ const ledger = loadTerminalBridgeDispatchLedger(currentControl);
18936
+ if (!ledger ||
18937
+ stringValue(ledger.status) !== "agent_accepted" ||
18938
+ !sameCanonicalStatePath(ledger.state_path, statePath) ||
18939
+ path.resolve(stringValue(ledger.store_dir) ?? "") !==
18940
+ path.resolve(storeDir) ||
18941
+ path.resolve(stringValue(ledger.event_log_path) ?? "") !==
18942
+ path.resolve(logPath) ||
18943
+ stringValue(ledger.conversation_id) !== current.conversation_id ||
18944
+ stringValue(ledger.session_id) !== sessionIdForConversation(current) ||
18945
+ stringValue(ledger.turn_id) !== turnIdForConversation(current) ||
18946
+ stringValue(ledger.message_id) !== messageId ||
18947
+ !terminalDispatchRecordMatchesControl(ledger, currentControl, {
18948
+ requireProcessAnchor: true
18949
+ }) ||
18950
+ terminalDispatchRecordProcessAnchor(ledger) !==
18951
+ terminalEndpointFromControlRef(currentControl).processAnchorPid ||
18952
+ stringValue(ledger.binding_id) !==
18953
+ stringValue(current.terminal_binding_id) ||
18954
+ Number(ledger.binding_generation) !==
18955
+ Number(current.terminal_binding_generation)) {
18956
+ return {
18957
+ stalled: false,
18958
+ conversation: current,
18959
+ reason: "dead_process_stall_dispatch_changed"
18960
+ };
18961
+ }
18962
+ try {
18963
+ assertVerifiedDeadTerminalBridgeDispatchAuthority({
18964
+ terminalControl: currentControl,
18965
+ conversation: current,
18966
+ storeDir,
18967
+ statePath,
18968
+ logPath,
18969
+ expectedMessageId: required(messageId, "verified-dead stall has no terminal message id")
18970
+ });
18971
+ }
18972
+ catch (error) {
18973
+ return {
18974
+ stalled: false,
18975
+ conversation: current,
18976
+ reason: "dead_process_stall_dispatch_changed: " +
18977
+ (error instanceof Error ? error.message : String(error))
18978
+ };
18979
+ }
18980
+ const persistedAuthority = exactVerifiedDeadTerminalAgentProcessAuthority({
18981
+ conversation: current,
18982
+ storeDir,
18983
+ terminalControl: currentControl,
18984
+ logPath
18985
+ });
18986
+ if (persistedAuthority.status === "invalid") {
18987
+ return {
18988
+ stalled: false,
18989
+ conversation: current,
18990
+ reason: `bound_agent_process_evidence_invalid: ${persistedAuthority.reason}`
18991
+ };
18992
+ }
18993
+ let proof;
18994
+ if (persistedAuthority.status === "valid") {
18995
+ proof = persistedAuthority.proof;
18996
+ }
18997
+ else {
18998
+ const observation = await observeBoundTerminalAgentProcess({
18999
+ options,
19000
+ conversation: current,
19001
+ terminalControl: currentControl
19002
+ });
19003
+ if (observation.status !== "verified_dead") {
19004
+ return {
19005
+ stalled: false,
19006
+ conversation: current,
19007
+ reason: observation.status === "alive"
19008
+ ? "bound_agent_process_alive"
19009
+ : `bound_agent_process_unverifiable: ${observation.reason}`
19010
+ };
19011
+ }
19012
+ proof = observation.proof;
19013
+ }
19014
+ const stalledReason = "bound terminal agent process is verified dead";
19015
+ const priorStalledDecision = verifiedDeadConversationStalledEventDecision({
19016
+ logPath,
19017
+ proof,
19018
+ evidenceId: verifiedDeadTerminalAgentProcessEvidenceId(proof),
19019
+ reason: stalledReason
19020
+ });
19021
+ if (priorStalledDecision.status === "invalid") {
19022
+ return {
19023
+ stalled: false,
19024
+ conversation: current,
19025
+ reason: `bound_agent_process_evidence_invalid: ${priorStalledDecision.reason}`
19026
+ };
19027
+ }
19028
+ if (priorStalledDecision.status === "valid") {
19029
+ if (persistedAuthority.status !== "valid") {
19030
+ return {
19031
+ stalled: false,
19032
+ conversation: current,
19033
+ reason: "bound_agent_process_evidence_invalid: the stalled decision has no exact death event"
19034
+ };
19035
+ }
19036
+ const stalled = {
19037
+ ...current,
19038
+ status: "stalled",
19039
+ stalled_at: persistedAuthority.recordedAt,
19040
+ stalled_reason: stalledReason,
19041
+ terminal_agent_process_disposition: {
19042
+ status: "verified_dead",
19043
+ proof: persistedAuthority.proof,
19044
+ evidence_id: persistedAuthority.evidenceId,
19045
+ recorded_at: persistedAuthority.recordedAt,
19046
+ completion_observation: {
19047
+ status: priorStalledDecision.completionObservation
19048
+ }
19049
+ },
19050
+ updated_at: persistedAuthority.recordedAt
19051
+ };
19052
+ saveState(statePath, stalled);
19053
+ return {
19054
+ stalled: true,
19055
+ conversation: stalled,
19056
+ reason: priorStalledDecision.completionObservation === "unverifiable"
19057
+ ? "bound_agent_process_verified_dead_completion_unverifiable"
19058
+ : "bound_agent_process_verified_dead"
19059
+ };
19060
+ }
19061
+ const durableCompletion = await observeDurableCompletionBeforeDeadStall({
19062
+ options,
19063
+ conversation: current,
19064
+ terminalControl: currentControl
19065
+ });
19066
+ if (durableCompletion.status === "present") {
19067
+ // Completion claiming re-enters the Turn state lock. Keep the
19068
+ // canonical terminal and Store-writer locks, but release the state
19069
+ // lock before preparing the callback/local completion atomically.
19070
+ releaseStateLock();
19071
+ stateLockReleased = true;
19072
+ const completionPreparation = prepareTerminalBridgeCompletionCallbackWithLocksHeld({
19073
+ options,
19074
+ statePath,
19075
+ logPath,
19076
+ conversation: current,
19077
+ executor: executorForConversation(current),
19078
+ terminalControl: currentControl,
19079
+ terminalMessageId: required(messageId, "verified-dead completion has no terminal message id"),
19080
+ completion: durableCompletion.completion
19081
+ });
19082
+ return {
19083
+ stalled: false,
19084
+ conversation: completionPreparation.conversation,
19085
+ reason: completionPreparation.claimed
19086
+ ? "bound_agent_process_dead_completion_prepared"
19087
+ : `bound_agent_process_dead_completion_${completionPreparation.reason}`,
19088
+ completionPreparation
19089
+ };
19090
+ }
19091
+ const completionUnverifiable = durableCompletion.status === "unverifiable";
19092
+ // Keep the durable lifecycle reason independent of a transient
19093
+ // completion-read outcome. If the process crashes between the
19094
+ // append-only events and state save, a later reconciliation must be
19095
+ // able to reuse the same death evidence even when the transcript or
19096
+ // rollout has become readable in the meantime.
19097
+ const reason = stalledReason;
19098
+ const audit = ensureVerifiedDeadTerminalAgentProcessEvent({
19099
+ logPath,
19100
+ proof,
19101
+ action: "monitor_reconciliation"
19102
+ });
19103
+ ensureVerifiedDeadConversationStalledEvent({
19104
+ logPath,
19105
+ proof: audit.proof,
19106
+ evidenceId: audit.evidenceId,
19107
+ reason,
19108
+ terminalControl: currentControl,
19109
+ completionObservation: completionUnverifiable ? "unverifiable" : "absent"
19110
+ });
19111
+ if (cliEnv().AKK_TEST_EXIT_AFTER_VERIFIED_DEAD_STALL_EVENTS === "1") {
19112
+ cliExit(86);
19113
+ }
19114
+ const stalled = {
19115
+ ...current,
19116
+ status: "stalled",
19117
+ stalled_at: audit.recordedAt,
19118
+ stalled_reason: reason,
19119
+ terminal_agent_process_disposition: {
19120
+ status: "verified_dead",
19121
+ proof: audit.proof,
19122
+ evidence_id: audit.evidenceId,
19123
+ recorded_at: audit.recordedAt,
19124
+ completion_observation: {
19125
+ status: completionUnverifiable ? "unverifiable" : "absent"
19126
+ }
19127
+ },
19128
+ updated_at: audit.recordedAt
19129
+ };
19130
+ saveState(statePath, stalled);
19131
+ runtimeLog("warn", "terminal_agent_process_verified_dead", {
19132
+ conversation_id: current.conversation_id,
19133
+ terminal_target: currentControl.target,
19134
+ pid: audit.proof.pid,
19135
+ reason
19136
+ });
19137
+ return {
19138
+ stalled: true,
19139
+ conversation: stalled,
19140
+ reason: completionUnverifiable
19141
+ ? "bound_agent_process_verified_dead_completion_unverifiable"
19142
+ : "bound_agent_process_verified_dead"
19143
+ };
19144
+ }
19145
+ finally {
19146
+ if (!stateLockReleased) {
19147
+ releaseStateLock();
19148
+ }
19149
+ }
19150
+ });
19151
+ }
19152
+ finally {
19153
+ releaseTerminalLock();
19154
+ }
19155
+ }
18259
19156
  function settleLocalTerminalBridgeCompletionClaim({ storeDir, statePath, logPath }) {
18260
19157
  const initial = loadState(statePath);
18261
19158
  const initialTakeover = isRecord(initial.native_session_takeover)
@@ -18899,6 +19796,7 @@ async function runClose(options) {
18899
19796
  const releaseStateLock = acquireFileLock(`${statePath}.lock`);
18900
19797
  try {
18901
19798
  const conversation = loadState(statePath);
19799
+ let verifiedDeadProcess;
18902
19800
  assertConversationHasNoNonterminalDeferredForegroundTransfer({
18903
19801
  storeDir: closeStoreDir,
18904
19802
  conversation,
@@ -18912,34 +19810,120 @@ async function runClose(options) {
18912
19810
  !terminalControlsShareIncarnation(currentTerminalControl, terminalControl)) {
18913
19811
  throw new Error("terminal control changed after the close action was listed; refresh AKK list");
18914
19812
  }
18915
- if (terminalControl && currentTerminalControl) {
18916
- await assertGenericCloseDoesNotBypassObservedHandoff({
18917
- options,
18918
- storeDir: closeStoreDir,
19813
+ if (conversation.status === "closed" && currentTerminalControl) {
19814
+ const recoveredAuthority = exactVerifiedDeadTerminalAgentProcessAuthority({
18919
19815
  conversation,
18920
- terminalControl: currentTerminalControl
19816
+ storeDir: closeStoreDir,
19817
+ terminalControl: currentTerminalControl,
19818
+ logPath
18921
19819
  });
19820
+ if (recoveredAuthority.status === "invalid") {
19821
+ throw new Error(`cannot finish close recovery for ${conversation.conversation_id}; ` +
19822
+ recoveredAuthority.reason);
19823
+ }
19824
+ if (recoveredAuthority.status === "valid") {
19825
+ const audit = ensureVerifiedDeadTerminalAgentProcessEvent({
19826
+ logPath,
19827
+ proof: recoveredAuthority.proof,
19828
+ action: "managed_close"
19829
+ });
19830
+ const expectedMessageId = required(stringValue(currentTakeover?.terminal_bridge_message_id), "verified-dead close recovery has no terminal message id");
19831
+ const dispatchLedgerResolved = resolveVerifiedDeadTerminalBridgeDispatchLedger({
19832
+ terminalControl: currentTerminalControl,
19833
+ conversation,
19834
+ storeDir: closeStoreDir,
19835
+ statePath,
19836
+ logPath,
19837
+ expectedMessageId,
19838
+ reason: "conversation explicitly closed by request"
19839
+ });
19840
+ ensureVerifiedDeadConversationClosedEvent({
19841
+ logPath,
19842
+ conversation,
19843
+ evidenceId: audit.evidenceId
19844
+ });
19845
+ printJson({
19846
+ conversation,
19847
+ closed: true,
19848
+ recovered: true,
19849
+ terminal_dispatch_resolved: dispatchLedgerResolved
19850
+ });
19851
+ return;
19852
+ }
19853
+ }
19854
+ if (terminalControl && currentTerminalControl) {
19855
+ verifiedDeadProcess =
19856
+ await assertGenericCloseDoesNotBypassObservedHandoff({
19857
+ options,
19858
+ storeDir: closeStoreDir,
19859
+ conversation,
19860
+ terminalControl: currentTerminalControl
19861
+ });
18922
19862
  }
18923
19863
  const now = cliNow().toISOString();
18924
19864
  const closeReason = options.reason ?? "closed by request";
19865
+ const verifiedDeadAudit = verifiedDeadProcess
19866
+ ? ensureVerifiedDeadTerminalAgentProcessEvent({
19867
+ logPath,
19868
+ proof: verifiedDeadProcess,
19869
+ action: "managed_close"
19870
+ })
19871
+ : undefined;
19872
+ const verifiedDeadMessageId = verifiedDeadAudit
19873
+ ? required(stringValue(currentTakeover?.terminal_bridge_message_id), "verified-dead close has no terminal message id")
19874
+ : undefined;
19875
+ if (verifiedDeadAudit && currentTerminalControl) {
19876
+ assertVerifiedDeadTerminalBridgeDispatchAuthority({
19877
+ terminalControl: currentTerminalControl,
19878
+ conversation,
19879
+ storeDir: closeStoreDir,
19880
+ statePath,
19881
+ logPath,
19882
+ expectedMessageId: verifiedDeadMessageId
19883
+ });
19884
+ }
18925
19885
  const closed = {
18926
19886
  ...conversation,
18927
19887
  status: "closed",
18928
19888
  closed_at: now,
18929
19889
  close_reason: closeReason,
19890
+ ...(verifiedDeadAudit
19891
+ ? {
19892
+ terminal_agent_process_disposition: {
19893
+ status: "verified_dead",
19894
+ proof: verifiedDeadAudit.proof,
19895
+ evidence_id: verifiedDeadAudit.evidenceId,
19896
+ recorded_at: verifiedDeadAudit.recordedAt
19897
+ }
19898
+ }
19899
+ : {}),
18930
19900
  updated_at: now
18931
19901
  };
18932
19902
  saveState(statePath, closed);
19903
+ if (verifiedDeadAudit &&
19904
+ cliEnv().AKK_TEST_EXIT_AFTER_VERIFIED_DEAD_CLOSE_STATE === "1") {
19905
+ cliExit(86);
19906
+ }
18933
19907
  let dispatchLedgerResolved = false;
18934
19908
  let dispatchLedgerWarning;
18935
19909
  if (currentTerminalControl) {
18936
19910
  try {
18937
- dispatchLedgerResolved = resolveTerminalBridgeDispatchLedger({
18938
- terminalControl: currentTerminalControl,
18939
- conversation: closed,
18940
- expectedMessageId: stringValue(currentTakeover?.terminal_bridge_message_id),
18941
- reason: "conversation explicitly closed by request"
18942
- });
19911
+ dispatchLedgerResolved = verifiedDeadAudit
19912
+ ? resolveVerifiedDeadTerminalBridgeDispatchLedger({
19913
+ terminalControl: currentTerminalControl,
19914
+ conversation: closed,
19915
+ storeDir: closeStoreDir,
19916
+ statePath,
19917
+ logPath,
19918
+ expectedMessageId: verifiedDeadMessageId,
19919
+ reason: "conversation explicitly closed by request"
19920
+ })
19921
+ : resolveTerminalBridgeDispatchLedger({
19922
+ terminalControl: currentTerminalControl,
19923
+ conversation: closed,
19924
+ expectedMessageId: stringValue(currentTakeover?.terminal_bridge_message_id),
19925
+ reason: "conversation explicitly closed by request"
19926
+ });
18943
19927
  }
18944
19928
  catch (error) {
18945
19929
  dispatchLedgerWarning =
@@ -18951,13 +19935,22 @@ async function runClose(options) {
18951
19935
  });
18952
19936
  }
18953
19937
  }
18954
- appendEvent(logPath, {
18955
- ts: now,
18956
- conversation_id: conversation.conversation_id,
18957
- event: "conversation_closed",
18958
- status: "closed",
18959
- reason: closed.close_reason
18960
- });
19938
+ if (verifiedDeadAudit) {
19939
+ ensureVerifiedDeadConversationClosedEvent({
19940
+ logPath,
19941
+ conversation: closed,
19942
+ evidenceId: verifiedDeadAudit.evidenceId
19943
+ });
19944
+ }
19945
+ else {
19946
+ appendEvent(logPath, {
19947
+ ts: now,
19948
+ conversation_id: conversation.conversation_id,
19949
+ event: "conversation_closed",
19950
+ status: "closed",
19951
+ reason: closed.close_reason
19952
+ });
19953
+ }
18961
19954
  runtimeLog("info", "conversation_closed", {
18962
19955
  conversation_id: conversation.conversation_id,
18963
19956
  status: "closed",
@@ -19003,6 +19996,19 @@ async function assertGenericCloseDoesNotBypassObservedHandoff({ options, storeDi
19003
19996
  !terminalControlsShareIncarnation(sourceSession.binding.terminal_control, terminalControl)) {
19004
19997
  return;
19005
19998
  }
19999
+ const storedProof = exactVerifiedDeadTerminalAgentProcessAuthority({
20000
+ conversation,
20001
+ storeDir,
20002
+ terminalControl,
20003
+ logPath: stringValue(conversation.event_log_path) ??
20004
+ logPathForStatePath(required(stringValue(conversation.state_path), "managed Turn state path is unavailable"))
20005
+ });
20006
+ if (storedProof.status === "valid") {
20007
+ return storedProof.proof;
20008
+ }
20009
+ if (storedProof.status === "invalid") {
20010
+ throw new Error(`cannot close ${conversation.conversation_id}; ${storedProof.reason}`);
20011
+ }
19006
20012
  const takeover = isRecord(conversation.native_session_takeover)
19007
20013
  ? conversation.native_session_takeover
19008
20014
  : undefined;
@@ -19016,12 +20022,24 @@ async function assertGenericCloseDoesNotBypassObservedHandoff({ options, storeDi
19016
20022
  terminal = await bridge.resolveStoredTerminal(sourceSession.agent, pid, terminalControl, { pid });
19017
20023
  }
19018
20024
  catch (error) {
19019
- if (!(error instanceof TerminalControlUnavailableError)) {
19020
- throw error;
20025
+ if (error instanceof TerminalControlUnavailableError) {
20026
+ // An unavailable terminal cannot prove a live A -> B handoff. Preserve the
20027
+ // explicit Store-only close path used to retire unavailable Turn history.
20028
+ return;
19021
20029
  }
19022
- // An unavailable terminal cannot prove a live A -> B handoff. Preserve the
19023
- // explicit Store-only close path used to retire unavailable Turn history.
19024
- return;
20030
+ const processObservation = await observeBoundTerminalAgentProcess({
20031
+ options,
20032
+ conversation,
20033
+ terminalControl
20034
+ });
20035
+ if (processObservation.status === "verified_dead") {
20036
+ return processObservation.proof;
20037
+ }
20038
+ if (processObservation.status === "unverifiable") {
20039
+ throw new Error(`cannot close ${conversation.conversation_id}; the bound agent process ` +
20040
+ `death is unverifiable: ${processObservation.reason}`);
20041
+ }
20042
+ throw error;
19025
20043
  }
19026
20044
  const companions = sourceSession.agent === "codex"
19027
20045
  ? codexAllowedCompanionSetForManagedSession({
@@ -20341,12 +21359,20 @@ async function runTerminalBridgeMonitorWithLock(options, lifecycle, expectedTerm
20341
21359
  });
20342
21360
  }
20343
21361
  }
21362
+ const approvalPromptEvidenceUnavailable = isRecord(approval) &&
21363
+ approval.blocked === true &&
21364
+ approval.approvable === true &&
21365
+ (stringValue(approval.decision_mode) ?? "keys") === "keys" &&
21366
+ !stringValue(approval.fingerprint);
20344
21367
  if (!suppressApprovalNotification &&
20345
21368
  isRecord(approval) &&
20346
21369
  approval.blocked === true &&
20347
- approval.approvable !== true) {
20348
- const approvalReason = stringValue(approval.reason) ??
20349
- "Claude Code permission state cannot be safely resolved through AKK";
21370
+ (approval.approvable !== true ||
21371
+ approvalPromptEvidenceUnavailable)) {
21372
+ const approvalReason = approvalPromptEvidenceUnavailable
21373
+ ? `${executor.display_name} approval prompt lacks exact prompt-region evidence`
21374
+ : stringValue(approval.reason) ??
21375
+ "Claude Code permission state cannot be safely resolved through AKK";
20350
21376
  appendEvent(logPath, {
20351
21377
  ts: cliNow().toISOString(),
20352
21378
  conversation_id: conversation.conversation_id,
@@ -20717,6 +21743,46 @@ async function runTerminalBridgeMonitorWithLock(options, lifecycle, expectedTerm
20717
21743
  runPreparedCallback(preparedCompletion.prepared);
20718
21744
  return;
20719
21745
  }
21746
+ // Durable completion wins over process-death cleanup. A coding-agent
21747
+ // process may exit immediately after committing its final transcript, so
21748
+ // only classify the Turn as orphaned after this poll (and an independent
21749
+ // durable probe under the mutation locks) both find no completion.
21750
+ if (!completion) {
21751
+ const deadProcessStall = await stallAcceptedTurnForVerifiedDeadAgent({
21752
+ options,
21753
+ storeDir: pathsForConversationDir(path.dirname(statePath)).storeDir,
21754
+ statePath,
21755
+ logPath,
21756
+ expectedConversationId: conversation.conversation_id,
21757
+ expectedMessageId: currentMessageId
21758
+ });
21759
+ if (deadProcessStall.completionPreparation) {
21760
+ const completionPreparation = deadProcessStall.completionPreparation;
21761
+ if (!completionPreparation.claimed) {
21762
+ printJson({
21763
+ conversation: completionPreparation.conversation,
21764
+ monitored: true,
21765
+ terminal_bridge: true,
21766
+ completed: false,
21767
+ duplicate: true,
21768
+ reason: completionPreparation.reason
21769
+ });
21770
+ return;
21771
+ }
21772
+ runPreparedCallback(completionPreparation.prepared);
21773
+ return;
21774
+ }
21775
+ if (deadProcessStall.stalled) {
21776
+ printJson({
21777
+ conversation: deadProcessStall.conversation,
21778
+ monitored: true,
21779
+ terminal_bridge: true,
21780
+ stalled: true,
21781
+ reason: deadProcessStall.reason
21782
+ });
21783
+ return;
21784
+ }
21785
+ }
20720
21786
  // A concrete approval or completion observed on this poll wins over a timeout boundary.
20721
21787
  const nowMs = cliNowMs();
20722
21788
  if (Number.isFinite(hardTimeoutMinutes) &&
@@ -24439,6 +25505,144 @@ function resolveTerminalBridgeDispatchLedger({ terminalControl, conversation, ex
24439
25505
  });
24440
25506
  return true;
24441
25507
  }
25508
+ function assertVerifiedDeadTerminalBridgeDispatchAuthority({ terminalControl, conversation, storeDir, statePath, logPath, expectedMessageId }) {
25509
+ assertTurnBindingCurrent(conversation, "resolve a verified-dead agent dispatch for");
25510
+ const takeover = isRecord(conversation.native_session_takeover)
25511
+ ? conversation.native_session_takeover
25512
+ : undefined;
25513
+ const submission = terminalBridgeSubmission(conversation);
25514
+ const rawStateReceiptHistory = takeover?.terminal_bridge_submission_receipts;
25515
+ const rawStateReceipts = Array.isArray(rawStateReceiptHistory)
25516
+ ? rawStateReceiptHistory.filter((receipt) => isRecord(receipt) &&
25517
+ stringValue(receipt.message_id) === expectedMessageId)
25518
+ : [];
25519
+ const validatedStateReceipts = terminalBridgeSubmissionReceipts(conversation).filter((receipt) => stringValue(receipt.message_id) === expectedMessageId);
25520
+ const requestText = String(takeover?.terminal_bridge_request_text ?? conversation.user_request ?? "");
25521
+ const requestHash = terminalBridgeRequestFingerprint(requestText);
25522
+ const sessionId = sessionIdForConversation(conversation);
25523
+ const turnId = turnIdForConversation(conversation);
25524
+ const bindingId = stringValue(conversation.terminal_binding_id);
25525
+ const bindingGeneration = Number(conversation.terminal_binding_generation);
25526
+ const nativeThreadId = stringValue(conversation.native_thread_id) ??
25527
+ stringValue(takeover?.terminal_agent_session_id);
25528
+ const endpointAnchor = terminalEndpointFromControlRef(terminalControl).processAnchorPid;
25529
+ if (!expectedMessageId ||
25530
+ !Array.isArray(rawStateReceiptHistory) ||
25531
+ rawStateReceipts.length !== 1 ||
25532
+ validatedStateReceipts.length !== 1 ||
25533
+ canonicalJson(rawStateReceipts[0]) !== canonicalJson(submission) ||
25534
+ canonicalJson(validatedStateReceipts[0]) !== canonicalJson(submission) ||
25535
+ stringValue(takeover?.terminal_bridge_message_id) !== expectedMessageId ||
25536
+ submission?.status !== "agent_accepted" ||
25537
+ stringValue(submission.message_id) !== expectedMessageId ||
25538
+ stringValue(submission.session_id) !== sessionId ||
25539
+ stringValue(submission.turn_id) !== turnId ||
25540
+ stringValue(submission.binding_id) !== bindingId ||
25541
+ Number(submission.binding_generation) !== bindingGeneration ||
25542
+ !requestHash ||
25543
+ stringValue(submission.request_hash) !== requestHash ||
25544
+ !bindingId ||
25545
+ !Number.isSafeInteger(bindingGeneration) ||
25546
+ bindingGeneration < 1 ||
25547
+ !nativeThreadId ||
25548
+ !Number.isSafeInteger(endpointAnchor) ||
25549
+ Number(endpointAnchor) < 1 ||
25550
+ !sameCanonicalStatePath(conversation.state_path, statePath) ||
25551
+ path.resolve(stringValue(conversation.event_log_path) ?? "") !==
25552
+ path.resolve(logPath) ||
25553
+ path.resolve(managedSessionStoreDirForConversation(conversation) ?? "") !==
25554
+ path.resolve(storeDir)) {
25555
+ throw new Error(`verified-dead Turn ${conversation.conversation_id} has no exact accepted submission authority`);
25556
+ }
25557
+ const ledger = loadTerminalBridgeDispatchLedger(terminalControl);
25558
+ const rawReceiptHistory = ledger?.terminal_submission_receipts;
25559
+ const receipts = Array.isArray(rawReceiptHistory)
25560
+ ? rawReceiptHistory.filter((receipt) => isRecord(receipt) &&
25561
+ stringValue(receipt.message_id) === expectedMessageId)
25562
+ : [];
25563
+ // Parse the entire append-only history as well as selecting the one exact
25564
+ // receipt. A synthesized top-level candidate is not sufficient cleanup
25565
+ // authority for a verified-dead process.
25566
+ const validatedReceiptHistory = terminalLedgerReceiptHistory(ledger);
25567
+ const validatedReceipts = validatedReceiptHistory.filter((receipt) => stringValue(receipt.message_id) === expectedMessageId);
25568
+ const receipt = receipts[0];
25569
+ const recordMatches = (record, expectedStatus) => Boolean(record &&
25570
+ record.status === expectedStatus &&
25571
+ stringValue(record.generation_id) === expectedMessageId &&
25572
+ stringValue(record.conversation_id) === conversation.conversation_id &&
25573
+ stringValue(record.session_id) === sessionId &&
25574
+ stringValue(record.turn_id) === turnId &&
25575
+ stringValue(record.message_id) === expectedMessageId &&
25576
+ stringValue(record.request_hash) === requestHash &&
25577
+ sameCanonicalStatePath(record.state_path, statePath) &&
25578
+ path.resolve(stringValue(record.store_dir) ?? "") ===
25579
+ path.resolve(storeDir) &&
25580
+ path.resolve(stringValue(record.event_log_path) ?? "") ===
25581
+ path.resolve(logPath) &&
25582
+ stringValue(record.binding_id) === bindingId &&
25583
+ Number(record.binding_generation) === bindingGeneration &&
25584
+ stringValue(record.native_thread_id) === nativeThreadId &&
25585
+ stringValue(record.executor_kind) ===
25586
+ executorForConversation(conversation).kind &&
25587
+ (stringValue(record.openclaw_session) ?? undefined) ===
25588
+ (stringValue(conversation.openclaw_session) ?? undefined) &&
25589
+ Boolean(record.callback_expected) === Boolean(conversation.gateway_method) &&
25590
+ (stringValue(record.message_type) ?? undefined) ===
25591
+ (stringValue(submission.message_type) ?? undefined) &&
25592
+ (stringValue(record.message_body_hash) ?? undefined) ===
25593
+ (stringValue(submission.message_body_hash) ?? undefined) &&
25594
+ isRecord(record.terminal_endpoint) &&
25595
+ terminalDispatchRecordMatchesControl(record, terminalControl, {
25596
+ requireProcessAnchor: true
25597
+ }) &&
25598
+ terminalDispatchRecordProcessAnchor(record) === endpointAnchor);
25599
+ if (!ledger ||
25600
+ terminalDispatchLedgerLooksLifecycle(ledger) ||
25601
+ !["agent_accepted", "resolved"].includes(String(ledger.status)) ||
25602
+ !recordMatches(ledger, ledger.status === "resolved" ? "resolved" : "agent_accepted") ||
25603
+ receipts.length !== 1 ||
25604
+ validatedReceipts.length !== 1 ||
25605
+ canonicalJson(receipts[0]) !== canonicalJson(validatedReceipts[0]) ||
25606
+ !recordMatches(receipt, "agent_accepted") ||
25607
+ (ledger.status === "resolved" &&
25608
+ validTimestampMs(ledger.resolved_at) === undefined)) {
25609
+ throw new Error(`verified-dead Turn ${conversation.conversation_id} no longer owns one exact terminal dispatch receipt`);
25610
+ }
25611
+ let stateAcceptance;
25612
+ let ledgerAcceptance;
25613
+ let receiptAcceptance;
25614
+ try {
25615
+ stateAcceptance = terminalAcceptanceEvidenceForConversation(conversation, requestText, submission.acceptance_evidence);
25616
+ ledgerAcceptance = terminalAcceptanceEvidenceForConversation(conversation, requestText, ledger.acceptance_evidence);
25617
+ receiptAcceptance = terminalAcceptanceEvidenceForConversation(conversation, requestText, receipt.acceptance_evidence);
25618
+ }
25619
+ catch (error) {
25620
+ throw new Error(`verified-dead Turn ${conversation.conversation_id} has invalid native acceptance evidence: ` +
25621
+ (error instanceof Error ? error.message : String(error)));
25622
+ }
25623
+ if (canonicalJson(stateAcceptance) !== canonicalJson(ledgerAcceptance) ||
25624
+ canonicalJson(stateAcceptance) !== canonicalJson(receiptAcceptance)) {
25625
+ throw new Error(`verified-dead Turn ${conversation.conversation_id} has conflicting native acceptance receipts`);
25626
+ }
25627
+ return { ledger, resolved: ledger.status === "resolved" };
25628
+ }
25629
+ function resolveVerifiedDeadTerminalBridgeDispatchLedger(options) {
25630
+ const authority = assertVerifiedDeadTerminalBridgeDispatchAuthority(options);
25631
+ if (authority.resolved) {
25632
+ return true;
25633
+ }
25634
+ saveTerminalBridgeDispatchLedger(options.terminalControl, {
25635
+ ...authority.ledger,
25636
+ status: "resolved",
25637
+ resolved_at: cliNow().toISOString(),
25638
+ reason: options.reason
25639
+ });
25640
+ const resolved = assertVerifiedDeadTerminalBridgeDispatchAuthority(options);
25641
+ if (!resolved.resolved) {
25642
+ throw new Error(`verified-dead Turn ${options.conversation.conversation_id} dispatch did not resolve`);
25643
+ }
25644
+ return true;
25645
+ }
24442
25646
  function terminalBridgeRequestFingerprint(value) {
24443
25647
  const text = String(value ?? "");
24444
25648
  return text ? createHash("sha256").update(text).digest("hex") : undefined;