@scotthuang/agent-knock-knock 0.10.1 → 0.10.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/src/cli.js CHANGED
@@ -7,14 +7,15 @@ import process from "node:process";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { createCodexTerminalAgentAdapter, detectCodexDurableCompletion } from "./codex-terminal-agent-adapter.js";
9
9
  import { createClaudeTerminalAgentAdapter } from "./claude-terminal-agent-adapter.js";
10
- import { captureClaudeTranscriptAnchor, defaultClaudeHome, detectClaudeTranscriptCompletion, detectClaudeTranscriptPendingApproval, listClaudeThreadLifecycleCandidates, revalidateClaudeThreadLifecycleCandidate } from "./claude-local-transcript-provider.js";
10
+ import { captureClaudeTranscriptAnchor, defaultClaudeHome, detectClaudeTranscriptAcceptance, detectClaudeTranscriptCompletion, detectClaudeTranscriptPendingApproval, listClaudeThreadLifecycleCandidates, revalidateClaudeThreadLifecycleCandidate } from "./claude-local-transcript-provider.js";
11
+ import { captureCodexRolloutAcceptanceAnchor, detectCodexBoundRolloutCompletion, detectCodexRolloutAcceptance, terminalSubmissionReplayReceipt, validateTerminalSubmissionAcceptanceEvidence } from "./terminal-submission-acceptance.js";
11
12
  import { CodexLocalSessionProvider } from "./codex-local-session-provider.js";
12
13
  import { CodexStoreAdapter } from "./codex-store-adapter.js";
13
14
  import { applyMessageToConversation, budgetAction, createConversation, createMessage, effectiveTurnStatus, executorForConversation, extractStructuredMessage, isTurnPhaseStatus, normalizeLegacyCallbackStatus, parseMessageJson, resolveExecutor, sessionIdForConversation, turnIdForConversation } from "./protocol.js";
14
15
  import { EXECUTOR_KINDS, executorDefinitionForKind, isExecutorKind } from "./executors.js";
15
16
  import { redactString, writeRuntimeLog } from "./runtime-log.js";
16
17
  import { formatTranscript, readNdjsonLog } from "./transcript.js";
17
- import { appendEvent, assertStoreWriterCompatible, defaultStoreDir, ensureDir, ensureStoreWritable, inspectStoreCompatibility, listConversations, logPathForStatePath, loadConversationById, loadState, messageEvent, pathsForConversation, pathsForConversationDir, saveState, statePathForConversationId, withStoreWriterLeaseAsync } from "./store.js";
18
+ import { appendEvent, assertStoreWriterCompatible, defaultStoreDir, ensureDir, ensureStoreWritable, inspectStoreCompatibility, listConversations, logPathForStatePath, loadConversationById, loadState, messageEvent, pathsForConversation, pathsForConversationDir, saveState, StoreLockTimeoutError, statePathForConversationId, withStoreWriterLease, withStoreWriterLeaseAsync } from "./store.js";
18
19
  import { createManagedSessionId, createNativeThreadTransitionId, isExactNativeThreadId, managedSessionBindingToken, nativeThreadCommandFingerprint, terminalBindingFrom, unmanagedTerminalBindingToken } from "./managed-session.js";
19
20
  import { classifyCodexLifecyclePostcondition, evaluateResumeCandidateAvailability, hasStrongCodexLifecycleIdentity, isFreshCodexPostProbeScreen } from "./native-thread-lifecycle-policy.js";
20
21
  import { listManagedSessions, loadManagedSession, loadNativeThreadTransition, saveManagedSession, saveNativeThreadTransition, tryLoadManagedSession } from "./session-store.js";
@@ -31,6 +32,8 @@ const DEFAULT_IDLE_TIMEOUT_MINUTES = 10080;
31
32
  const DEFAULT_AGENT_TIMEOUT_MINUTES = 60;
32
33
  const DEFAULT_AGENT_HARD_TIMEOUT_MINUTES = 720;
33
34
  const DEFAULT_MONITOR_POLL_INTERVAL_MS = 5000;
35
+ const DEFAULT_TERMINAL_ACCEPTANCE_TIMEOUT_MS = 5000;
36
+ const DEFAULT_TERMINAL_ACCEPTANCE_POLL_INTERVAL_MS = 50;
34
37
  const CLAUDE_SCREEN_APPROVAL_TTL_MS = 10 * 60 * 1000;
35
38
  const CALLBACK_DELIVERY_TIMEOUT_MS = 30_000;
36
39
  const CALLBACK_AGENT_WAIT_TIMEOUT_MS = 20_000;
@@ -38,6 +41,32 @@ const CALLBACK_AGENT_WAIT_CLI_TIMEOUT_MS = 25_000;
38
41
  const CALLBACK_AGENT_WAIT_PROCESS_TIMEOUT_MS = 30_000;
39
42
  const CALLBACK_ATTEMPT_LEASE_MS = 2 * 60 * 1000;
40
43
  const CALLBACK_RETRY_DELAYS_MS = [5000, 15000, 60000, 60000];
44
+ const TERMINAL_LEDGER_RECEIPT_STATUSES = new Set([
45
+ "text_injected",
46
+ "enter_dispatched",
47
+ "submitted",
48
+ "agent_accepted",
49
+ "not_accepted",
50
+ "uncertain",
51
+ "aborted"
52
+ ]);
53
+ const TERMINAL_LEDGER_RECEIPT_IMMUTABLE_FIELDS = [
54
+ "binding_id",
55
+ "binding_generation",
56
+ "native_thread_id",
57
+ "store_dir",
58
+ "conversation_id",
59
+ "session_id",
60
+ "turn_id",
61
+ "message_id",
62
+ "message_type",
63
+ "message_body_hash",
64
+ "request_hash",
65
+ "executor_kind",
66
+ "openclaw_session",
67
+ "state_path",
68
+ "event_log_path"
69
+ ];
41
70
  const TERMINAL_BRIDGE_SUPERSEDE_STATUSES = new Set([
42
71
  "created",
43
72
  "running",
@@ -96,6 +125,13 @@ const STORE_MUTATION_COMMANDS = new Set([
96
125
  "clear-thread",
97
126
  "resume-thread"
98
127
  ]);
128
+ class TurnBindingSupersededError extends Error {
129
+ code = "AKK_TURN_BINDING_SUPERSEDED";
130
+ constructor(message) {
131
+ super(message);
132
+ this.name = "TurnBindingSupersededError";
133
+ }
134
+ }
99
135
  class InlineCodexSessionAdapter {
100
136
  threads;
101
137
  processes;
@@ -682,6 +718,16 @@ function createRuntimeTerminalAgentRegistry(options) {
682
718
  if (!isRecord(conversation)) {
683
719
  return undefined;
684
720
  }
721
+ const exactCompletion = detectExactBoundCodexCompletion({
722
+ conversation,
723
+ nativeTakeover,
724
+ request,
725
+ runtime,
726
+ options
727
+ });
728
+ if (exactCompletion.handled) {
729
+ return exactCompletion.completion;
730
+ }
685
731
  const contextMatches = await loadCodexTerminalContexts({
686
732
  nativeTakeover,
687
733
  options
@@ -737,6 +783,89 @@ function createRuntimeTerminalAgentRegistry(options) {
737
783
  ]
738
784
  });
739
785
  }
786
+ function detectExactBoundCodexCompletion({ conversation, nativeTakeover, request, runtime, options }) {
787
+ const submission = terminalBridgeSubmission(conversation);
788
+ const acceptanceEvidence = isRecord(submission?.acceptance_evidence)
789
+ ? submission.acceptance_evidence
790
+ : undefined;
791
+ const anchor = isRecord(nativeTakeover?.codex_rollout_acceptance_anchor)
792
+ ? nativeTakeover.codex_rollout_acceptance_anchor
793
+ : undefined;
794
+ if (submission?.status !== "agent_accepted") {
795
+ return { handled: false };
796
+ }
797
+ const exactRequired = requiresExactBoundCodexCompletion(conversation, options);
798
+ if (!exactRequired) {
799
+ return { handled: false };
800
+ }
801
+ if (acceptanceEvidence?.source !== "codex_rollout") {
802
+ throw new Error("[codex_exact_bound_rollout:invalid_acceptance_evidence] " +
803
+ "the accepted modern Codex Turn has no exact rollout acceptance evidence");
804
+ }
805
+ if (!anchor) {
806
+ throw new Error("[codex_exact_bound_rollout:invalid_anchor] " +
807
+ "the accepted modern Codex Turn has no exact rollout byte anchor");
808
+ }
809
+ const nativeRollout = isRecord(runtime?.nativeRollout)
810
+ ? runtime.nativeRollout
811
+ : undefined;
812
+ const result = detectCodexBoundRolloutCompletion({
813
+ anchor: anchor,
814
+ acceptanceEvidence: acceptanceEvidence,
815
+ currentIdentity: {
816
+ sessionId: stringValue(runtime?.nativeSessionId) ??
817
+ stringValue(runtime?.sessionId) ??
818
+ "",
819
+ processUuid: stringValue(runtime?.nativeProcessUuid),
820
+ processBirth: stringValue(runtime?.nativeProcessBirth),
821
+ ...(nativeRollout
822
+ ? {
823
+ rollout: {
824
+ fd: String(nativeRollout.fd ?? ""),
825
+ device: String(nativeRollout.device ?? ""),
826
+ inode: String(nativeRollout.inode ?? ""),
827
+ path: String(nativeRollout.path ?? "")
828
+ }
829
+ }
830
+ : {})
831
+ },
832
+ requestHash: stringValue(request.requestHash) ??
833
+ stringValue(nativeTakeover?.terminal_bridge_request_hash) ??
834
+ ""
835
+ });
836
+ if (result.status === "failure") {
837
+ throw new Error(`[codex_exact_bound_rollout:${result.diagnostics.code}] ${result.diagnostics.detail ?? "the exact bound rollout is not safely inspectable"}`);
838
+ }
839
+ if (result.status === "pending") {
840
+ return { handled: true };
841
+ }
842
+ return {
843
+ handled: true,
844
+ completion: {
845
+ ...result.completion,
846
+ metadata: {
847
+ ...result.completion.metadata,
848
+ context_match: "exact_bound_rollout",
849
+ detector_code: result.diagnostics.code
850
+ }
851
+ }
852
+ };
853
+ }
854
+ function requiresExactBoundCodexCompletion(conversation, options) {
855
+ const nativeTakeover = isRecord(conversation.native_session_takeover)
856
+ ? conversation.native_session_takeover
857
+ : undefined;
858
+ const submission = terminalBridgeSubmission(conversation);
859
+ if (submission?.status !== "agent_accepted") {
860
+ return false;
861
+ }
862
+ const hasExactArtifacts = isRecord(nativeTakeover?.codex_rollout_acceptance_anchor) &&
863
+ isRecord(submission.acceptance_evidence) &&
864
+ submission.acceptance_evidence.source === "codex_rollout";
865
+ const modernProductionTurn = Number(nativeTakeover?.terminal_agent_identity_protocol) === 1 &&
866
+ !allowsSyntheticTerminalAcceptance(options);
867
+ return hasExactArtifacts || modernProductionTurn;
868
+ }
740
869
  function createTerminalAgentBridge(options, terminalProvider = createTerminalControlProvider(options), registry = createRuntimeTerminalAgentRegistry(options)) {
741
870
  const processSource = createTerminalProcessSource(options);
742
871
  return new TerminalAgentBridge({
@@ -1063,6 +1192,158 @@ function isTerminalControlCapability(value) {
1063
1192
  "terminal_cancel"
1064
1193
  ].includes(value);
1065
1194
  }
1195
+ function assertSafeAbortedTerminalRetryBinding({ owner, receipt, storeDir, terminalControl, messageId }) {
1196
+ if (!(receipt.status === "aborted" && receipt.safe_to_retry === true)) {
1197
+ return;
1198
+ }
1199
+ const sessionId = sessionIdForConversation(owner);
1200
+ const managedSession = tryLoadManagedSession(storeDir, sessionId);
1201
+ const binding = managedSession?.binding;
1202
+ const receiptBindingId = stringValue(receipt.binding_id);
1203
+ const receiptBindingGeneration = Number(receipt.binding_generation);
1204
+ const receiptNativeThreadId = stringValue(receipt.native_thread_id);
1205
+ const ownerTakeover = isRecord(owner.native_session_takeover)
1206
+ ? owner.native_session_takeover
1207
+ : undefined;
1208
+ const ownerControl = terminalControlFromTakeover(ownerTakeover);
1209
+ const ownerNativeThreadId = stringValue(owner.native_thread_id) ??
1210
+ stringValue(ownerTakeover?.terminal_agent_session_id) ??
1211
+ stringValue(ownerTakeover?.terminal_agent_expected_session_id);
1212
+ const ownerAgentPid = Number(ownerTakeover?.terminal_agent_pid);
1213
+ if (!managedSession ||
1214
+ managedSession.status !== "bound" ||
1215
+ !binding ||
1216
+ !receiptBindingId ||
1217
+ !Number.isSafeInteger(receiptBindingGeneration) ||
1218
+ !receiptNativeThreadId ||
1219
+ receiptBindingId !== stringValue(owner.terminal_binding_id) ||
1220
+ receiptBindingGeneration !== Number(owner.terminal_binding_generation) ||
1221
+ receiptNativeThreadId !== ownerNativeThreadId ||
1222
+ binding.binding_id !== receiptBindingId ||
1223
+ binding.generation !== receiptBindingGeneration ||
1224
+ binding.native_thread_id !== receiptNativeThreadId ||
1225
+ !Number.isSafeInteger(ownerAgentPid) ||
1226
+ binding.native_process.pid !== ownerAgentPid ||
1227
+ !ownerControl ||
1228
+ terminalControlSelectorKey(ownerControl) !==
1229
+ terminalControlSelectorKey(terminalControl) ||
1230
+ terminalControlSelectorKey(binding.terminal_control) !==
1231
+ terminalControlSelectorKey(terminalControl)) {
1232
+ throw new Error(`terminal idempotency key ${messageId} belongs to a safe-aborted Turn ` +
1233
+ "whose Session binding is no longer current; no terminal input was sent");
1234
+ }
1235
+ }
1236
+ function stableDelegateTerminalRoute({ options, request, workspace, requestedAgent }) {
1237
+ const messageId = stringValue(options.messageId);
1238
+ if (!messageId) {
1239
+ return undefined;
1240
+ }
1241
+ const storeDir = path.resolve(storeDirFromOptions(options));
1242
+ const requestHash = terminalBridgeRequestFingerprint(terminalSubmissionPayload(request));
1243
+ const bodyHash = createHash("sha256").update(request).digest("hex");
1244
+ const requestedOpenClawSession = stringValue(options.openclawSession);
1245
+ const matches = listConversations(storeDir).flatMap((owner) => terminalBridgeSubmissionReceipts(owner)
1246
+ .filter((receipt) => stringValue(receipt.message_id) === messageId)
1247
+ .map((receipt) => ({ owner, receipt })));
1248
+ if (matches.length === 0) {
1249
+ return undefined;
1250
+ }
1251
+ const routed = matches.map(({ owner, receipt }) => {
1252
+ const ownerStoreDir = managedSessionStoreDirForConversation(owner);
1253
+ const takeover = isRecord(owner.native_session_takeover)
1254
+ ? owner.native_session_takeover
1255
+ : undefined;
1256
+ const terminalControl = terminalControlFromTakeover(takeover);
1257
+ const conversationId = stringValue(takeover?.native_session_id);
1258
+ let eventMessages = [];
1259
+ const eventLogPath = stringValue(owner.event_log_path);
1260
+ if (eventLogPath) {
1261
+ try {
1262
+ eventMessages = readNdjsonLog(eventLogPath)
1263
+ .filter((event) => isRecord(event.message) && event.message.id === messageId)
1264
+ .map((event) => event.message);
1265
+ }
1266
+ catch {
1267
+ eventMessages = [];
1268
+ }
1269
+ }
1270
+ if (eventMessages.length > 1) {
1271
+ throw new Error(`terminal idempotency key ${messageId} has duplicate durable messages`);
1272
+ }
1273
+ const eventMessage = eventMessages[0];
1274
+ const messageType = stringValue(receipt.message_type) ??
1275
+ (isRecord(eventMessage) ? stringValue(eventMessage.type) : undefined);
1276
+ const storedBodyHash = stringValue(receipt.message_body_hash) ??
1277
+ (isRecord(eventMessage) && typeof eventMessage.body === "string"
1278
+ ? createHash("sha256").update(eventMessage.body).digest("hex")
1279
+ : undefined);
1280
+ const ownerWorkspace = canonicalWorkspace(owner.workspace);
1281
+ if (!ownerStoreDir ||
1282
+ path.resolve(ownerStoreDir) !== storeDir ||
1283
+ (stringValue(receipt.store_dir) !== undefined &&
1284
+ path.resolve(String(receipt.store_dir)) !== storeDir) ||
1285
+ !terminalControl ||
1286
+ !conversationId ||
1287
+ stringValue(receipt.request_hash) !== requestHash ||
1288
+ messageType !== "task" ||
1289
+ storedBodyHash !== bodyHash ||
1290
+ (requestedOpenClawSession &&
1291
+ (stringValue(receipt.openclaw_session) ?? owner.openclaw_session) !==
1292
+ requestedOpenClawSession) ||
1293
+ (requestedAgent && executorForConversation(owner).kind !== requestedAgent) ||
1294
+ (workspace && ownerWorkspace !== workspace)) {
1295
+ throw new Error(`terminal idempotency key ${messageId} does not match its original ` +
1296
+ "delegate request boundary; no terminal input was sent");
1297
+ }
1298
+ return {
1299
+ owner,
1300
+ receipt,
1301
+ conversationId,
1302
+ workspace: ownerWorkspace,
1303
+ terminalKey: terminalControlSelectorKey(terminalControl),
1304
+ panePid: terminalControl.panePid
1305
+ };
1306
+ });
1307
+ const authoritative = routed.filter(({ receipt }) => !(receipt.status === "aborted" && receipt.safe_to_retry === true));
1308
+ if (authoritative.length > 1) {
1309
+ throw new Error(`terminal idempotency key ${messageId} has multiple durable delegate receipts`);
1310
+ }
1311
+ const terminalKeys = new Set(routed.map((entry) => `${entry.conversationId}\0${entry.terminalKey}\0${entry.panePid}`));
1312
+ if (terminalKeys.size !== 1) {
1313
+ throw new Error(`terminal idempotency key ${messageId} has conflicting terminal routes`);
1314
+ }
1315
+ const selected = authoritative[0] ?? routed.at(-1);
1316
+ if (!selected) {
1317
+ return undefined;
1318
+ }
1319
+ if (selected.receipt.status === "aborted" &&
1320
+ selected.receipt.safe_to_retry === true) {
1321
+ const sessionId = sessionIdForConversation(selected.owner);
1322
+ const ownerControl = terminalControlFromTakeover(isRecord(selected.owner.native_session_takeover)
1323
+ ? selected.owner.native_session_takeover
1324
+ : undefined);
1325
+ if (!ownerControl) {
1326
+ throw new Error(`terminal idempotency key ${messageId} has no durable terminal route`);
1327
+ }
1328
+ assertSafeAbortedTerminalRetryBinding({
1329
+ owner: selected.owner,
1330
+ receipt: selected.receipt,
1331
+ storeDir,
1332
+ terminalControl: ownerControl,
1333
+ messageId
1334
+ });
1335
+ return {
1336
+ kind: "session",
1337
+ sessionId,
1338
+ workspace: selected.workspace
1339
+ };
1340
+ }
1341
+ return {
1342
+ kind: "terminal",
1343
+ conversationId: selected.conversationId,
1344
+ workspace: selected.workspace
1345
+ };
1346
+ }
1066
1347
  async function runDelegate(options) {
1067
1348
  const request = required(options.request, "--request is required");
1068
1349
  const workspace = options.workspace === undefined
@@ -1071,6 +1352,32 @@ async function runDelegate(options) {
1071
1352
  const requestedAgent = options.agent === undefined
1072
1353
  ? undefined
1073
1354
  : resolveExecutor({ kind: options.agent }).kind;
1355
+ const stableRoute = stableDelegateTerminalRoute({
1356
+ options,
1357
+ request,
1358
+ workspace,
1359
+ requestedAgent
1360
+ });
1361
+ if (stableRoute) {
1362
+ await runSend(stableRoute.kind === "session"
1363
+ ? {
1364
+ ...options,
1365
+ session: stableRoute.sessionId,
1366
+ conversation: undefined,
1367
+ message: request,
1368
+ workspace: stableRoute.workspace,
1369
+ background: true
1370
+ }
1371
+ : {
1372
+ ...options,
1373
+ conversation: stableRoute.conversationId,
1374
+ session: undefined,
1375
+ message: request,
1376
+ workspace: stableRoute.workspace,
1377
+ background: true
1378
+ });
1379
+ return;
1380
+ }
1074
1381
  const scan = await buildTerminalListGroup({
1075
1382
  options: {
1076
1383
  ...options,
@@ -1340,7 +1647,7 @@ function terminalBridgeEnabled(conversation) {
1340
1647
  : undefined;
1341
1648
  return nativeTakeover?.["terminal_bridge"] === true;
1342
1649
  }
1343
- function withTerminalBridgeState({ conversation, message, requestText, startedAt, agentTimeoutMinutes, agentHardTimeoutMinutes, preSendScreenFingerprint, claudeTranscriptAnchor, claudeHome }) {
1650
+ function withTerminalBridgeState({ conversation, message, requestText, startedAt, agentTimeoutMinutes, agentHardTimeoutMinutes, preSendScreenFingerprint, codexRolloutAcceptanceAnchor, claudeTranscriptAnchor, claudeHome }) {
1344
1651
  const nativeTakeover = isRecord(conversation.native_session_takeover)
1345
1652
  ? conversation.native_session_takeover
1346
1653
  : {};
@@ -1354,10 +1661,12 @@ function withTerminalBridgeState({ conversation, message, requestText, startedAt
1354
1661
  terminal_bridge_request_text: requestText,
1355
1662
  terminal_bridge_request_hash: terminalBridgeRequestFingerprint(requestText),
1356
1663
  terminal_bridge_pre_send_screen_fingerprint: preSendScreenFingerprint,
1664
+ codex_rollout_acceptance_anchor: codexRolloutAcceptanceAnchor,
1357
1665
  claude_transcript_anchor: claudeTranscriptAnchor,
1358
1666
  claude_home: claudeHome,
1359
1667
  terminal_bridge_completion_claim: undefined,
1360
1668
  terminal_bridge_approval_dispatch: undefined,
1669
+ terminal_bridge_detector_diagnostic: undefined,
1361
1670
  terminal_bridge_monitor_lock_version: TERMINAL_BRIDGE_MONITOR_LOCK_VERSION,
1362
1671
  terminal_bridge_monitor_started_at: startedAt,
1363
1672
  terminal_bridge_last_activity_at: startedAt,
@@ -1369,40 +1678,653 @@ function withTerminalBridgeState({ conversation, message, requestText, startedAt
1369
1678
  updated_at: startedAt
1370
1679
  };
1371
1680
  }
1372
- function withTerminalBridgeSubmission({ conversation, messageId, requestText, status, preparedAt, submittedAt, uncertainAt, abortedAt, error }) {
1681
+ function withTerminalBridgeSubmission({ conversation, messageId, messageType, messageBody, requestText, status, preparedAt, textInjectedAt, enterDispatchedAt, agentAcceptedAt, notAcceptedAt, submittedAt, uncertainAt, abortedAt, error, acceptanceEvidence, lastProvenStage, safeToRetry }) {
1373
1682
  const nativeTakeover = isRecord(conversation.native_session_takeover)
1374
1683
  ? conversation.native_session_takeover
1375
1684
  : {};
1376
1685
  const previousSubmission = isRecord(nativeTakeover.terminal_bridge_submission)
1377
1686
  ? nativeTakeover.terminal_bridge_submission
1378
1687
  : undefined;
1379
- const previousDispatcherPid = Number(previousSubmission?.dispatcher_pid);
1688
+ const receiptsValue = nativeTakeover.terminal_bridge_submission_receipts;
1689
+ if (receiptsValue !== undefined && !Array.isArray(receiptsValue)) {
1690
+ throw new Error("terminal submission receipt history is malformed");
1691
+ }
1692
+ const storedReceipts = (Array.isArray(receiptsValue) ? receiptsValue : [])
1693
+ .map((value) => {
1694
+ if (!isRecord(value) || !stringValue(value.message_id)) {
1695
+ throw new Error("terminal submission receipt history is malformed");
1696
+ }
1697
+ return value;
1698
+ });
1699
+ const storedReceiptIds = new Set();
1700
+ for (const receipt of storedReceipts) {
1701
+ const receiptId = String(receipt.message_id);
1702
+ if (storedReceiptIds.has(receiptId)) {
1703
+ throw new Error(`terminal submission receipt ${receiptId} is duplicated`);
1704
+ }
1705
+ storedReceiptIds.add(receiptId);
1706
+ }
1707
+ // A v0.10.1 Turn has only the latest singleton receipt. Seed it into the
1708
+ // append-only history before recording a later response so upgrading cannot
1709
+ // erase the only proof that an older message was already dispatched.
1710
+ const previousSubmissionId = stringValue(previousSubmission?.message_id);
1711
+ const previousReceipts = previousSubmissionId &&
1712
+ !storedReceiptIds.has(previousSubmissionId)
1713
+ ? [...storedReceipts, previousSubmission]
1714
+ : storedReceipts;
1715
+ const matchingReceipts = previousReceipts.filter((receipt) => stringValue(receipt.message_id) === messageId);
1716
+ if (matchingReceipts.length > 1) {
1717
+ throw new Error(`terminal submission receipt ${messageId} is duplicated`);
1718
+ }
1719
+ const previousReceipt = matchingReceipts[0];
1720
+ const previousGenerationSubmission = stringValue(previousSubmission?.message_id) === messageId
1721
+ ? previousSubmission
1722
+ : undefined;
1723
+ if (previousReceipt &&
1724
+ previousGenerationSubmission &&
1725
+ canonicalJson(previousReceipt) !== canonicalJson(previousGenerationSubmission)) {
1726
+ throw new Error(`terminal submission receipt ${messageId} conflicts with its current generation`);
1727
+ }
1728
+ const durableMessageType = messageType ??
1729
+ (stringValue(previousReceipt?.message_type) === "answer"
1730
+ ? "answer"
1731
+ : stringValue(previousReceipt?.message_type) === "task"
1732
+ ? "task"
1733
+ : stringValue(previousGenerationSubmission?.message_type) === "answer"
1734
+ ? "answer"
1735
+ : stringValue(previousGenerationSubmission?.message_type) === "task"
1736
+ ? "task"
1737
+ : undefined);
1738
+ const messageBodyHash = messageBody !== undefined
1739
+ ? createHash("sha256").update(messageBody).digest("hex")
1740
+ : stringValue(previousReceipt?.message_body_hash) ??
1741
+ stringValue(previousGenerationSubmission?.message_body_hash);
1742
+ const previousDispatcherPid = Number(previousGenerationSubmission?.dispatcher_pid);
1380
1743
  const dispatcherPid = status === "prepared" ||
1381
1744
  !Number.isSafeInteger(previousDispatcherPid) ||
1382
1745
  previousDispatcherPid <= 1
1383
1746
  ? process.pid
1384
1747
  : previousDispatcherPid;
1748
+ const provenStage = lastProvenStage ?? terminalSubmissionLastProvenStage(status, stringValue(previousGenerationSubmission?.last_proven_stage));
1749
+ const validatedAcceptanceEvidence = status === "agent_accepted"
1750
+ ? terminalAcceptanceEvidenceForConversation(conversation, requestText, acceptanceEvidence)
1751
+ : undefined;
1752
+ const storedControl = terminalControlFromTakeover(nativeTakeover);
1753
+ const requestHash = terminalBridgeRequestFingerprint(requestText);
1754
+ const candidateImmutableReceiptFields = {
1755
+ session_id: sessionIdForConversation(conversation),
1756
+ turn_id: turnIdForConversation(conversation),
1757
+ message_id: messageId,
1758
+ binding_id: stringValue(conversation.terminal_binding_id),
1759
+ binding_generation: Number.isSafeInteger(Number(conversation.terminal_binding_generation))
1760
+ ? Number(conversation.terminal_binding_generation)
1761
+ : undefined,
1762
+ ...(durableMessageType ? { message_type: durableMessageType } : {}),
1763
+ ...(messageBodyHash ? { message_body_hash: messageBodyHash } : {}),
1764
+ request_hash: requestHash,
1765
+ executor_kind: executorForConversation(conversation).kind,
1766
+ openclaw_session: conversation.openclaw_session,
1767
+ store_dir: managedSessionStoreDirForConversation(conversation),
1768
+ native_thread_id: stringValue(conversation.native_thread_id) ??
1769
+ stringValue(nativeTakeover.terminal_agent_session_id) ??
1770
+ stringValue(nativeTakeover.terminal_agent_expected_session_id),
1771
+ terminal_target: storedControl?.target,
1772
+ terminal_socket_path: storedControl?.socketPath ?? null,
1773
+ terminal_pane_pid: storedControl?.panePid
1774
+ };
1775
+ const immutableReceiptFields = Object.fromEntries(Object.entries(candidateImmutableReceiptFields).map(([key, value]) => [
1776
+ key,
1777
+ value === undefined && previousReceipt?.[key] !== undefined
1778
+ ? previousReceipt[key]
1779
+ : value
1780
+ ]));
1781
+ if (previousReceipt) {
1782
+ for (const [key, value] of Object.entries(candidateImmutableReceiptFields)) {
1783
+ if (previousReceipt[key] !== undefined &&
1784
+ value !== undefined &&
1785
+ String(previousReceipt[key]) !== String(value)) {
1786
+ throw new Error(`terminal submission receipt ${messageId} changed immutable ${key}`);
1787
+ }
1788
+ }
1789
+ }
1790
+ const nextSubmission = {
1791
+ status,
1792
+ ...immutableReceiptFields,
1793
+ prepared_at: preparedAt,
1794
+ dispatcher_pid: dispatcherPid,
1795
+ last_proven_stage: provenStage,
1796
+ ...(textInjectedAt ? { text_injected_at: textInjectedAt } : {}),
1797
+ ...(enterDispatchedAt ? { enter_dispatched_at: enterDispatchedAt } : {}),
1798
+ ...(agentAcceptedAt ? { agent_accepted_at: agentAcceptedAt } : {}),
1799
+ ...(notAcceptedAt ? { not_accepted_at: notAcceptedAt } : {}),
1800
+ ...(submittedAt ? { submitted_at: submittedAt } : {}),
1801
+ ...(uncertainAt ? { uncertain_at: uncertainAt } : {}),
1802
+ ...(abortedAt ? { aborted_at: abortedAt } : {}),
1803
+ ...(error ? { error: textSummary(error) } : {}),
1804
+ ...(safeToRetry !== undefined ? { safe_to_retry: safeToRetry } : {}),
1805
+ ...(validatedAcceptanceEvidence
1806
+ ? { acceptance_evidence: validatedAcceptanceEvidence }
1807
+ : {})
1808
+ };
1809
+ const nextReceipts = previousReceipt
1810
+ ? previousReceipts.map((receipt) => stringValue(receipt.message_id) === messageId
1811
+ ? nextSubmission
1812
+ : receipt)
1813
+ : [...previousReceipts, nextSubmission];
1385
1814
  return {
1386
1815
  ...conversation,
1387
1816
  native_session_takeover: {
1388
1817
  ...nativeTakeover,
1389
- terminal_bridge_submission: {
1390
- status,
1391
- session_id: sessionIdForConversation(conversation),
1392
- turn_id: turnIdForConversation(conversation),
1393
- message_id: messageId,
1394
- request_hash: terminalBridgeRequestFingerprint(requestText),
1395
- prepared_at: preparedAt,
1396
- dispatcher_pid: dispatcherPid,
1397
- ...(submittedAt ? { submitted_at: submittedAt } : {}),
1398
- ...(uncertainAt ? { uncertain_at: uncertainAt } : {}),
1399
- ...(abortedAt ? { aborted_at: abortedAt } : {}),
1400
- ...(error ? { error: textSummary(error) } : {})
1401
- }
1818
+ terminal_bridge_submission: nextSubmission,
1819
+ terminal_bridge_submission_receipts: nextReceipts
1402
1820
  },
1403
- updated_at: submittedAt ?? uncertainAt ?? abortedAt ?? preparedAt
1821
+ updated_at: agentAcceptedAt ?? notAcceptedAt ?? uncertainAt ?? abortedAt ??
1822
+ enterDispatchedAt ?? textInjectedAt ?? submittedAt ?? preparedAt
1823
+ };
1824
+ }
1825
+ function terminalAcceptanceEvidenceForConversation(conversation, requestText, evidence) {
1826
+ return validateTerminalSubmissionAcceptanceEvidence(evidence, terminalAcceptanceEvidenceExpectation(conversation, requestText));
1827
+ }
1828
+ function terminalAcceptanceEvidenceExpectation(conversation, requestText) {
1829
+ const nativeTakeover = isRecord(conversation.native_session_takeover)
1830
+ ? conversation.native_session_takeover
1831
+ : undefined;
1832
+ const executor = executorForConversation(conversation).kind;
1833
+ const nativeThreadId = stringValue(conversation.native_thread_id) ??
1834
+ stringValue(nativeTakeover?.terminal_agent_session_id) ??
1835
+ stringValue(nativeTakeover?.terminal_agent_expected_session_id);
1836
+ if (!nativeThreadId) {
1837
+ throw new Error("native acceptance evidence cannot be bound without an exact native thread");
1838
+ }
1839
+ const requestHash = terminalBridgeRequestFingerprint(requestText);
1840
+ if (!requestHash) {
1841
+ throw new Error("native acceptance evidence has no exact request hash");
1842
+ }
1843
+ return {
1844
+ source: executor === "codex" ? "codex_rollout" : "claude_transcript",
1845
+ nativeThreadId,
1846
+ requestHash
1404
1847
  };
1405
1848
  }
1849
+ function replayExactActiveTerminalSubmission({ options, terminalControl, requestText, expectedStoreDir, expectedSessionId, expectedTurnId, expectedMessageType = "task", expectedStatePath }) {
1850
+ const messageId = stringValue(options.messageId);
1851
+ if (!messageId) {
1852
+ return false;
1853
+ }
1854
+ const terminalPayload = terminalSubmissionPayload(requestText);
1855
+ const requestHash = terminalBridgeRequestFingerprint(terminalPayload);
1856
+ if (!requestHash) {
1857
+ return false;
1858
+ }
1859
+ const loadedLedger = loadTerminalBridgeDispatchLedger(terminalControl);
1860
+ const ledgerReceiptMatches = terminalLedgerReceiptHistory(loadedLedger)
1861
+ .filter((receipt) => stringValue(receipt.message_id) === messageId);
1862
+ if (ledgerReceiptMatches.length > 1) {
1863
+ throw new Error(`terminal idempotency key ${messageId} has multiple terminal-ledger receipts`);
1864
+ }
1865
+ const ledgerReceipt = ledgerReceiptMatches[0];
1866
+ const loadedLedgerControl = isRecord(loadedLedger?.terminal_control)
1867
+ ? loadedLedger.terminal_control
1868
+ : undefined;
1869
+ const loadedLedgerMessageId = stringValue(loadedLedger?.message_id);
1870
+ const loadedLedgerStoreDir = stringValue(loadedLedger?.store_dir);
1871
+ const loadedLedgerStatePath = stringValue(loadedLedger?.state_path);
1872
+ const loadedLedgerOwner = loadedLedger && loadedLedgerMessageId === messageId
1873
+ ? loadTerminalDispatchLedgerOwner(loadedLedger)
1874
+ : undefined;
1875
+ const ledgerReceiptControl = isRecord(ledgerReceipt?.terminal_control)
1876
+ ? ledgerReceipt.terminal_control
1877
+ : undefined;
1878
+ const expectedMessageBodyHash = createHash("sha256")
1879
+ .update(requestText)
1880
+ .digest("hex");
1881
+ if (ledgerReceipt &&
1882
+ !(ledgerReceipt.status === "aborted" && ledgerReceipt.safe_to_retry === true) &&
1883
+ (stringValue(ledgerReceiptControl?.target) !== terminalControl.target ||
1884
+ (stringValue(ledgerReceiptControl?.socket_path) ?? undefined) !==
1885
+ terminalControl.socketPath ||
1886
+ Number(ledgerReceiptControl?.pane_pid) !== Number(terminalControl.panePid) ||
1887
+ (stringValue(ledgerReceipt.store_dir) !== undefined &&
1888
+ path.resolve(String(ledgerReceipt.store_dir)) !==
1889
+ path.resolve(expectedStoreDir)) ||
1890
+ (expectedSessionId && stringValue(ledgerReceipt.session_id) !== undefined &&
1891
+ stringValue(ledgerReceipt.session_id) !== expectedSessionId) ||
1892
+ (expectedTurnId && stringValue(ledgerReceipt.turn_id) !== undefined &&
1893
+ stringValue(ledgerReceipt.turn_id) !== expectedTurnId) ||
1894
+ (expectedStatePath && stringValue(ledgerReceipt.state_path) !== undefined &&
1895
+ !sameCanonicalStatePath(ledgerReceipt.state_path, expectedStatePath)) ||
1896
+ (stringValue(ledgerReceipt.message_type) !== undefined &&
1897
+ stringValue(ledgerReceipt.message_type) !== expectedMessageType) ||
1898
+ (stringValue(ledgerReceipt.message_body_hash) !== undefined &&
1899
+ stringValue(ledgerReceipt.message_body_hash) !== expectedMessageBodyHash) ||
1900
+ (stringValue(ledgerReceipt.request_hash) !== undefined &&
1901
+ stringValue(ledgerReceipt.request_hash) !== requestHash) ||
1902
+ (stringValue(options.openclawSession) !== undefined &&
1903
+ stringValue(ledgerReceipt.openclaw_session) !== undefined &&
1904
+ stringValue(ledgerReceipt.openclaw_session) !==
1905
+ stringValue(options.openclawSession)))) {
1906
+ throw new Error(`terminal idempotency key ${messageId} does not match its durable ` +
1907
+ "terminal receipt; no terminal input was sent");
1908
+ }
1909
+ if (loadedLedger &&
1910
+ loadedLedgerMessageId === messageId &&
1911
+ (stringValue(loadedLedgerControl?.target) !== terminalControl.target ||
1912
+ (stringValue(loadedLedgerControl?.socket_path) ?? undefined) !==
1913
+ terminalControl.socketPath ||
1914
+ Number(loadedLedgerControl?.pane_pid) !== Number(terminalControl.panePid) ||
1915
+ (loadedLedgerStoreDir !== undefined &&
1916
+ path.resolve(loadedLedgerStoreDir) !== path.resolve(expectedStoreDir)) ||
1917
+ (expectedSessionId && stringValue(loadedLedger.session_id) !== undefined &&
1918
+ stringValue(loadedLedger.session_id) !== expectedSessionId) ||
1919
+ (expectedTurnId && stringValue(loadedLedger.turn_id) !== undefined &&
1920
+ stringValue(loadedLedger.turn_id) !== expectedTurnId) ||
1921
+ (expectedStatePath && loadedLedgerStatePath !== undefined &&
1922
+ !sameCanonicalStatePath(loadedLedgerStatePath, expectedStatePath)) ||
1923
+ (stringValue(loadedLedger.message_type) !== undefined &&
1924
+ stringValue(loadedLedger.message_type) !== expectedMessageType) ||
1925
+ (stringValue(loadedLedger.request_hash) !== undefined &&
1926
+ stringValue(loadedLedger.request_hash) !== requestHash) ||
1927
+ (stringValue(options.openclawSession) !== undefined &&
1928
+ loadedLedgerOwner !== undefined &&
1929
+ loadedLedgerOwner.openclaw_session !==
1930
+ stringValue(options.openclawSession)))) {
1931
+ throw new Error(`terminal idempotency key ${messageId} does not match its original ` +
1932
+ "Store, Session, Turn, OpenClaw session, message, or tmux pane binding; " +
1933
+ "no terminal input was sent");
1934
+ }
1935
+ if (replayExactStoredTerminalSubmission({
1936
+ options,
1937
+ terminalControl,
1938
+ requestText,
1939
+ requestHash,
1940
+ messageId,
1941
+ expectedStoreDir,
1942
+ expectedSessionId,
1943
+ expectedTurnId,
1944
+ expectedMessageType,
1945
+ expectedStatePath
1946
+ })) {
1947
+ return true;
1948
+ }
1949
+ if (ledgerReceipt &&
1950
+ (loadedLedgerMessageId !== messageId ||
1951
+ !["submitted", "enter_dispatched", "agent_accepted"].includes(String(ledgerReceipt.status)) ||
1952
+ !["submitted", "enter_dispatched", "agent_accepted"].includes(String(loadedLedger?.status)))) {
1953
+ throw new Error(`terminal idempotency key ${messageId} already has durable ` +
1954
+ `${String(ledgerReceipt.status)} proof and cannot be dispatched again; ` +
1955
+ "no terminal input was sent");
1956
+ }
1957
+ const incarnationLedger = resolveTerminalDispatchLedgerPaneIncarnation(terminalControl, loadedLedger);
1958
+ const ledger = reconcilePreparedTerminalDispatchLedger(terminalControl, incarnationLedger);
1959
+ const ledgerControl = isRecord(ledger?.terminal_control)
1960
+ ? ledger.terminal_control
1961
+ : undefined;
1962
+ if (!ledger ||
1963
+ !["submitted", "enter_dispatched", "agent_accepted"].includes(String(ledger.status)) ||
1964
+ stringValue(ledgerControl?.target) !== terminalControl.target ||
1965
+ (stringValue(ledgerControl?.socket_path) ?? undefined) !==
1966
+ terminalControl.socketPath ||
1967
+ Number(ledgerControl?.pane_pid) !== Number(terminalControl.panePid) ||
1968
+ stringValue(ledger.message_id) !== messageId ||
1969
+ (stringValue(ledger.message_type) !== undefined &&
1970
+ stringValue(ledger.message_type) !== expectedMessageType) ||
1971
+ stringValue(ledger.request_hash) !== requestHash) {
1972
+ return false;
1973
+ }
1974
+ const owner = loadTerminalDispatchLedgerOwner(ledger);
1975
+ const ledgerStoreDir = stringValue(ledger.store_dir);
1976
+ const ownerStoreDir = owner
1977
+ ? managedSessionStoreDirForConversation(owner)
1978
+ : undefined;
1979
+ if (!owner ||
1980
+ TERMINAL_DISPATCH_RELEASE_STATUSES.has(owner.status) ||
1981
+ !ledgerStoreDir ||
1982
+ !ownerStoreDir ||
1983
+ path.resolve(ledgerStoreDir) !== path.resolve(expectedStoreDir) ||
1984
+ path.resolve(ownerStoreDir) !== path.resolve(expectedStoreDir) ||
1985
+ (expectedSessionId && sessionIdForConversation(owner) !== expectedSessionId) ||
1986
+ (expectedTurnId && turnIdForConversation(owner) !== expectedTurnId) ||
1987
+ (expectedStatePath && !sameCanonicalStatePath(ledger.state_path, expectedStatePath)) ||
1988
+ (stringValue(options.openclawSession) &&
1989
+ owner.openclaw_session !== stringValue(options.openclawSession))) {
1990
+ return false;
1991
+ }
1992
+ const submission = terminalBridgeSubmission(owner);
1993
+ const nativeTakeover = isRecord(owner.native_session_takeover)
1994
+ ? owner.native_session_takeover
1995
+ : undefined;
1996
+ if (stringValue(nativeTakeover?.terminal_bridge_message_id) !== messageId ||
1997
+ stringValue(submission?.message_id) !== messageId ||
1998
+ (stringValue(submission?.message_type) !== undefined &&
1999
+ stringValue(submission?.message_type) !== expectedMessageType) ||
2000
+ stringValue(submission?.request_hash) !== requestHash) {
2001
+ return false;
2002
+ }
2003
+ const executor = executorForConversation(owner);
2004
+ const nativeThreadId = stringValue(owner.native_thread_id) ??
2005
+ stringValue(nativeTakeover?.terminal_agent_session_id) ??
2006
+ stringValue(nativeTakeover?.terminal_agent_expected_session_id) ??
2007
+ sessionIdForConversation(owner);
2008
+ const proofLevel = String(ledger.status);
2009
+ const replayReceipt = terminalSubmissionReplayReceipt({
2010
+ proofLevel,
2011
+ evidence: ledger.acceptance_evidence,
2012
+ expected: {
2013
+ source: executor.kind === "codex"
2014
+ ? "codex_rollout"
2015
+ : "claude_transcript",
2016
+ nativeThreadId,
2017
+ requestHash
2018
+ }
2019
+ });
2020
+ const logPath = stringValue(ledger.event_log_path) ??
2021
+ stringValue(owner.event_log_path);
2022
+ let loggedMessage;
2023
+ if (logPath) {
2024
+ try {
2025
+ loggedMessage = readNdjsonLog(logPath).find((event) => isRecord(event.message) && event.message.id === messageId)?.message;
2026
+ }
2027
+ catch {
2028
+ loggedMessage = undefined;
2029
+ }
2030
+ }
2031
+ if (isRecord(loggedMessage) &&
2032
+ (loggedMessage.type !== expectedMessageType ||
2033
+ loggedMessage.body !== requestText ||
2034
+ loggedMessage.conversation_id !== owner.conversation_id ||
2035
+ loggedMessage.session_id !== sessionIdForConversation(owner) ||
2036
+ loggedMessage.turn_id !== turnIdForConversation(owner))) {
2037
+ return false;
2038
+ }
2039
+ const durableMessageType = stringValue(ledger.message_type) ??
2040
+ stringValue(submission?.message_type) ??
2041
+ (isRecord(loggedMessage) ? stringValue(loggedMessage.type) : undefined);
2042
+ if (durableMessageType !== expectedMessageType) {
2043
+ return false;
2044
+ }
2045
+ const replayedMessage = isRecord(loggedMessage)
2046
+ ? loggedMessage
2047
+ : createMessage({
2048
+ conversation: owner,
2049
+ id: messageId,
2050
+ from: "openclaw",
2051
+ to: executor.actor,
2052
+ type: expectedMessageType,
2053
+ body: requestText,
2054
+ metadata: {
2055
+ executor_kind: executor.kind,
2056
+ executor_session: executor.session
2057
+ }
2058
+ });
2059
+ const acceptanceInvalid = replayReceipt.submission_outcome === "uncertain";
2060
+ printJson({
2061
+ session_id: sessionIdForConversation(owner),
2062
+ turn_id: turnIdForConversation(owner),
2063
+ conversation: owner,
2064
+ message: replayedMessage,
2065
+ delivered: replayReceipt.delivered,
2066
+ status: replayReceipt.status,
2067
+ submission_outcome: replayReceipt.submission_outcome,
2068
+ background: true,
2069
+ callback_expected: !acceptanceInvalid && Boolean(owner.gateway_method ?? ledger.callback_expected),
2070
+ terminal_control: terminalControl,
2071
+ executor,
2072
+ replayed: replayReceipt.replayed,
2073
+ delivery_receipt: replayReceipt.delivery_receipt,
2074
+ ...(replayReceipt.do_not_retry
2075
+ ? { do_not_retry: replayReceipt.do_not_retry }
2076
+ : {}),
2077
+ reason: replayReceipt.delivered
2078
+ ? "AKK replayed the durable native acceptance receipt and sent no additional tmux input."
2079
+ : acceptanceInvalid
2080
+ ? "AKK replayed an invalid native acceptance receipt as uncertain and sent no additional tmux input."
2081
+ : "AKK replayed the original transport proof without upgrading it and sent no additional tmux input.",
2082
+ openclaw_next_action: replayReceipt.delivered
2083
+ ? openClawYieldNextAction({
2084
+ conversationId: owner.conversation_id,
2085
+ sessionId: sessionIdForConversation(owner),
2086
+ turnId: turnIdForConversation(owner),
2087
+ source: "terminal_control",
2088
+ callbackExpected: Boolean(owner.gateway_method ?? ledger.callback_expected)
2089
+ })
2090
+ : {
2091
+ action: "inspect",
2092
+ conversation_id: owner.conversation_id,
2093
+ session_id: sessionIdForConversation(owner),
2094
+ turn_id: turnIdForConversation(owner),
2095
+ do_not_retry: true,
2096
+ reason: acceptanceInvalid
2097
+ ? "Stored native acceptance evidence is invalid."
2098
+ : "Only terminal transport is proven."
2099
+ }
2100
+ });
2101
+ return true;
2102
+ }
2103
+ function validateStoredTerminalSubmissionMatch({ owner, receipt, options, terminalControl, requestText, requestHash, expectedStoreDir, expectedSessionId, expectedTurnId, expectedMessageType, expectedStatePath }) {
2104
+ const messageId = required(stringValue(receipt.message_id), "stored terminal receipt message id is required");
2105
+ const ownerStoreDir = managedSessionStoreDirForConversation(owner);
2106
+ const nativeTakeover = isRecord(owner.native_session_takeover)
2107
+ ? owner.native_session_takeover
2108
+ : undefined;
2109
+ const storedControl = terminalControlFromTakeover(nativeTakeover);
2110
+ const receiptStoreDir = stringValue(receipt.store_dir) ?? ownerStoreDir;
2111
+ const receiptSessionId = stringValue(receipt.session_id) ??
2112
+ sessionIdForConversation(owner);
2113
+ const receiptTurnId = stringValue(receipt.turn_id) ??
2114
+ turnIdForConversation(owner);
2115
+ const receiptOpenClawSession = stringValue(receipt.openclaw_session) ??
2116
+ owner.openclaw_session;
2117
+ const receiptTarget = stringValue(receipt.terminal_target) ??
2118
+ storedControl?.target;
2119
+ const receiptSocketPath = receipt.terminal_socket_path === null
2120
+ ? undefined
2121
+ : stringValue(receipt.terminal_socket_path) ?? storedControl?.socketPath;
2122
+ const receiptPanePid = Number(receipt.terminal_pane_pid ?? storedControl?.panePid);
2123
+ const requestedOpenClawSession = stringValue(options.openclawSession);
2124
+ if (!ownerStoreDir ||
2125
+ !receiptStoreDir ||
2126
+ path.resolve(ownerStoreDir) !== path.resolve(expectedStoreDir) ||
2127
+ path.resolve(receiptStoreDir) !== path.resolve(expectedStoreDir) ||
2128
+ !storedControl ||
2129
+ receiptTarget !== terminalControl.target ||
2130
+ receiptSocketPath !== terminalControl.socketPath ||
2131
+ receiptPanePid !== Number(terminalControl.panePid) ||
2132
+ receiptSessionId !== sessionIdForConversation(owner) ||
2133
+ receiptTurnId !== turnIdForConversation(owner) ||
2134
+ (expectedSessionId && receiptSessionId !== expectedSessionId) ||
2135
+ (expectedTurnId && receiptTurnId !== expectedTurnId) ||
2136
+ (expectedStatePath && !sameCanonicalStatePath(owner.state_path, expectedStatePath)) ||
2137
+ (requestedOpenClawSession && receiptOpenClawSession !== requestedOpenClawSession) ||
2138
+ stringValue(receipt.request_hash) !== requestHash ||
2139
+ (stringValue(receipt.executor_kind) !== undefined &&
2140
+ stringValue(receipt.executor_kind) !== executorForConversation(owner).kind)) {
2141
+ throw new Error(`terminal idempotency key ${messageId} does not match its original ` +
2142
+ "Store, Session, Turn, OpenClaw session, or tmux pane binding; no terminal input was sent");
2143
+ }
2144
+ const logPath = stringValue(owner.event_log_path);
2145
+ let loggedMessages = [];
2146
+ if (logPath) {
2147
+ try {
2148
+ loggedMessages = readNdjsonLog(logPath)
2149
+ .filter((event) => isRecord(event.message) && event.message.id === messageId)
2150
+ .map((event) => event.message);
2151
+ }
2152
+ catch {
2153
+ loggedMessages = [];
2154
+ }
2155
+ }
2156
+ if (loggedMessages.length > 1) {
2157
+ throw new Error(`terminal idempotency key ${messageId} has duplicate durable messages`);
2158
+ }
2159
+ const loggedMessage = loggedMessages[0];
2160
+ if (isRecord(loggedMessage) &&
2161
+ (loggedMessage.type !== expectedMessageType ||
2162
+ loggedMessage.body !== requestText ||
2163
+ loggedMessage.conversation_id !== owner.conversation_id ||
2164
+ loggedMessage.session_id !== sessionIdForConversation(owner) ||
2165
+ loggedMessage.turn_id !== turnIdForConversation(owner))) {
2166
+ throw new Error(`terminal idempotency key ${messageId} does not match its durable message; no terminal input was sent`);
2167
+ }
2168
+ const durableMessageType = stringValue(receipt.message_type) ??
2169
+ (isRecord(loggedMessage) ? stringValue(loggedMessage.type) : undefined);
2170
+ const durableMessageBodyHash = stringValue(receipt.message_body_hash) ??
2171
+ (isRecord(loggedMessage) && typeof loggedMessage.body === "string"
2172
+ ? createHash("sha256").update(loggedMessage.body).digest("hex")
2173
+ : undefined);
2174
+ const expectedMessageBodyHash = createHash("sha256")
2175
+ .update(requestText)
2176
+ .digest("hex");
2177
+ if (durableMessageType !== expectedMessageType ||
2178
+ durableMessageBodyHash !== expectedMessageBodyHash) {
2179
+ throw new Error(`terminal idempotency key ${messageId} was already used for a different message; no terminal input was sent`);
2180
+ }
2181
+ return loggedMessage;
2182
+ }
2183
+ function replayExactStoredTerminalSubmission({ options, terminalControl, requestText, requestHash, messageId, expectedStoreDir, expectedSessionId, expectedTurnId, expectedMessageType, expectedStatePath }) {
2184
+ const allMatches = listConversations(expectedStoreDir).flatMap((owner) => terminalBridgeSubmissionReceipts(owner)
2185
+ .filter((receipt) => stringValue(receipt.message_id) === messageId)
2186
+ .map((receipt) => ({ owner, receipt })));
2187
+ const validatedMatches = allMatches.map((match) => ({
2188
+ ...match,
2189
+ loggedMessage: validateStoredTerminalSubmissionMatch({
2190
+ ...match,
2191
+ options,
2192
+ terminalControl,
2193
+ requestText,
2194
+ requestHash,
2195
+ expectedStoreDir,
2196
+ expectedSessionId,
2197
+ expectedTurnId,
2198
+ expectedMessageType,
2199
+ expectedStatePath
2200
+ })
2201
+ }));
2202
+ const matches = validatedMatches.filter(({ receipt }) => !(receipt.status === "aborted" && receipt.safe_to_retry === true));
2203
+ if (matches.length > 1) {
2204
+ throw new Error(`terminal idempotency key ${messageId} has multiple durable receipts in one Store`);
2205
+ }
2206
+ const match = matches[0];
2207
+ if (!match) {
2208
+ for (const { owner, receipt } of validatedMatches) {
2209
+ assertSafeAbortedTerminalRetryBinding({
2210
+ owner,
2211
+ receipt,
2212
+ storeDir: expectedStoreDir,
2213
+ terminalControl,
2214
+ messageId
2215
+ });
2216
+ }
2217
+ // A prepared-stage failure with a restored ledger proves that tmux was
2218
+ // untouched. The same exact id may therefore start a fresh attempt; all
2219
+ // immutable boundaries above were still validated before allowing it.
2220
+ return false;
2221
+ }
2222
+ const { owner, receipt, loggedMessage } = match;
2223
+ const nativeTakeover = isRecord(owner.native_session_takeover)
2224
+ ? owner.native_session_takeover
2225
+ : undefined;
2226
+ const currentSubmission = terminalBridgeSubmission(owner);
2227
+ const isCurrentSubmission = stringValue(currentSubmission?.message_id) === messageId;
2228
+ if (isCurrentSubmission &&
2229
+ !TERMINAL_DISPATCH_RELEASE_STATUSES.has(owner.status)) {
2230
+ return false;
2231
+ }
2232
+ if (!["submitted", "enter_dispatched", "agent_accepted"].includes(String(receipt.status))) {
2233
+ throw new Error(`terminal idempotency key ${messageId} has durable ${String(receipt.status)} ` +
2234
+ "proof and must not be retried; no terminal input was sent");
2235
+ }
2236
+ const executor = executorForConversation(owner);
2237
+ const nativeThreadId = stringValue(receipt.native_thread_id) ??
2238
+ stringValue(owner.native_thread_id) ??
2239
+ stringValue(nativeTakeover?.terminal_agent_session_id) ??
2240
+ stringValue(nativeTakeover?.terminal_agent_expected_session_id) ??
2241
+ sessionIdForConversation(owner);
2242
+ const proofLevel = String(receipt.status);
2243
+ const replayReceipt = terminalSubmissionReplayReceipt({
2244
+ proofLevel,
2245
+ evidence: receipt.acceptance_evidence,
2246
+ expected: {
2247
+ source: executor.kind === "codex"
2248
+ ? "codex_rollout"
2249
+ : "claude_transcript",
2250
+ nativeThreadId,
2251
+ requestHash
2252
+ }
2253
+ });
2254
+ const replayedMessage = isRecord(loggedMessage)
2255
+ ? loggedMessage
2256
+ : createMessage({
2257
+ conversation: owner,
2258
+ id: messageId,
2259
+ from: "openclaw",
2260
+ to: executor.actor,
2261
+ type: expectedMessageType,
2262
+ body: requestText,
2263
+ metadata: {
2264
+ executor_kind: executor.kind,
2265
+ executor_session: executor.session
2266
+ }
2267
+ });
2268
+ const acceptanceInvalid = replayReceipt.submission_outcome === "uncertain";
2269
+ const callbackExpected = Boolean(owner.gateway_method);
2270
+ printJson({
2271
+ session_id: sessionIdForConversation(owner),
2272
+ turn_id: turnIdForConversation(owner),
2273
+ conversation: owner,
2274
+ message: replayedMessage,
2275
+ delivered: replayReceipt.delivered,
2276
+ status: replayReceipt.status,
2277
+ submission_outcome: replayReceipt.submission_outcome,
2278
+ background: true,
2279
+ callback_expected: !acceptanceInvalid && callbackExpected,
2280
+ terminal_control: terminalControl,
2281
+ executor,
2282
+ replayed: true,
2283
+ delivery_receipt: replayReceipt.delivery_receipt,
2284
+ ...(replayReceipt.do_not_retry
2285
+ ? { do_not_retry: replayReceipt.do_not_retry }
2286
+ : {}),
2287
+ reason: replayReceipt.delivered
2288
+ ? "AKK replayed the stored durable native acceptance receipt and sent no additional tmux input."
2289
+ : acceptanceInvalid
2290
+ ? "AKK replayed an invalid stored native acceptance receipt as uncertain and sent no additional tmux input."
2291
+ : "AKK replayed the stored transport proof without upgrading it and sent no additional tmux input.",
2292
+ openclaw_next_action: replayReceipt.delivered
2293
+ ? openClawYieldNextAction({
2294
+ conversationId: owner.conversation_id,
2295
+ sessionId: sessionIdForConversation(owner),
2296
+ turnId: turnIdForConversation(owner),
2297
+ source: "terminal_control",
2298
+ callbackExpected
2299
+ })
2300
+ : {
2301
+ action: "inspect",
2302
+ conversation_id: owner.conversation_id,
2303
+ session_id: sessionIdForConversation(owner),
2304
+ turn_id: turnIdForConversation(owner),
2305
+ do_not_retry: true,
2306
+ reason: acceptanceInvalid
2307
+ ? "Stored native acceptance evidence is invalid."
2308
+ : "Only terminal transport is proven."
2309
+ }
2310
+ });
2311
+ return true;
2312
+ }
2313
+ function terminalSubmissionLastProvenStage(status, previous) {
2314
+ if (status === "agent_accepted") {
2315
+ return "agent_accepted";
2316
+ }
2317
+ if (status === "enter_dispatched" ||
2318
+ status === "submitted" ||
2319
+ status === "not_accepted") {
2320
+ return "enter_dispatched";
2321
+ }
2322
+ if (status === "uncertain" &&
2323
+ ["prepared", "text_injected", "enter_dispatched", "agent_accepted"].includes(previous ?? "")) {
2324
+ return previous;
2325
+ }
2326
+ return status === "text_injected" ? "text_injected" : "prepared";
2327
+ }
1406
2328
  function terminalBridgeSubmission(conversation) {
1407
2329
  const nativeTakeover = isRecord(conversation?.native_session_takeover)
1408
2330
  ? conversation.native_session_takeover
@@ -1411,10 +2333,45 @@ function terminalBridgeSubmission(conversation) {
1411
2333
  ? nativeTakeover.terminal_bridge_submission
1412
2334
  : undefined;
1413
2335
  }
2336
+ function terminalBridgeSubmissionReceipts(conversation) {
2337
+ const nativeTakeover = isRecord(conversation.native_session_takeover)
2338
+ ? conversation.native_session_takeover
2339
+ : undefined;
2340
+ const historyValue = nativeTakeover?.terminal_bridge_submission_receipts;
2341
+ if (historyValue !== undefined && !Array.isArray(historyValue)) {
2342
+ throw new Error("terminal submission receipt history is malformed");
2343
+ }
2344
+ const receipts = (Array.isArray(historyValue) ? historyValue : []).map((value) => {
2345
+ if (!isRecord(value) || !stringValue(value.message_id)) {
2346
+ throw new Error("terminal submission receipt history is malformed");
2347
+ }
2348
+ return value;
2349
+ });
2350
+ const ids = new Set();
2351
+ for (const receipt of receipts) {
2352
+ const id = String(receipt.message_id);
2353
+ if (ids.has(id)) {
2354
+ throw new Error(`terminal submission receipt ${id} is duplicated`);
2355
+ }
2356
+ ids.add(id);
2357
+ }
2358
+ const current = terminalBridgeSubmission(conversation);
2359
+ const currentId = stringValue(current?.message_id);
2360
+ if (current && currentId && ids.has(currentId)) {
2361
+ const historical = receipts.find((receipt) => stringValue(receipt.message_id) === currentId);
2362
+ if (!historical || canonicalJson(historical) !== canonicalJson(current)) {
2363
+ throw new Error(`terminal submission receipt ${currentId} conflicts with its current generation`);
2364
+ }
2365
+ }
2366
+ return current && currentId && !ids.has(currentId)
2367
+ ? [...receipts, current]
2368
+ : receipts;
2369
+ }
1414
2370
  function unresolvedTerminalBridgeSubmission(conversation) {
1415
2371
  const submission = terminalBridgeSubmission(conversation);
1416
2372
  return submission &&
1417
- (submission.status === "prepared" || submission.status === "uncertain")
2373
+ ["prepared", "text_injected", "enter_dispatched", "not_accepted", "uncertain"]
2374
+ .includes(String(submission.status))
1418
2375
  ? submission
1419
2376
  : undefined;
1420
2377
  }
@@ -1429,10 +2386,18 @@ function assertNoUnresolvedTerminalBridgeSubmission(storeDir, terminalControl, c
1429
2386
  if (candidate.conversation_id === currentConversationId ||
1430
2387
  !isActiveStatus(candidate.status) ||
1431
2388
  !submission ||
1432
- !["prepared", "submitted", "uncertain"].includes(String(submission.status))) {
2389
+ ![
2390
+ "prepared",
2391
+ "text_injected",
2392
+ "enter_dispatched",
2393
+ "agent_accepted",
2394
+ "submitted",
2395
+ "not_accepted",
2396
+ "uncertain"
2397
+ ].includes(String(submission.status))) {
1433
2398
  continue;
1434
2399
  }
1435
- if (submission.status === "submitted" &&
2400
+ if (["submitted", "agent_accepted"].includes(String(submission.status)) &&
1436
2401
  stringValue(submission.request_hash) !== requestHash) {
1437
2402
  continue;
1438
2403
  }
@@ -2291,8 +3256,12 @@ function terminalDispatchOwnership(terminalControl) {
2291
3256
  }
2292
3257
  if (![
2293
3258
  "prepared",
3259
+ "text_injected",
3260
+ "enter_dispatched",
3261
+ "agent_accepted",
2294
3262
  "dispatching",
2295
3263
  "submitted",
3264
+ "not_accepted",
2296
3265
  "uncertain"
2297
3266
  ].includes(String(ledger.status))) {
2298
3267
  return { state: "none" };
@@ -2320,7 +3289,16 @@ function terminalDispatchOwnership(terminalControl) {
2320
3289
  : undefined;
2321
3290
  const ledgerMessageId = stringValue(ledger.message_id);
2322
3291
  const ownerMessageId = stringValue(ownerTakeover?.terminal_bridge_message_id);
2323
- if (["prepared", "dispatching", "submitted", "uncertain"].includes(String(ledger.status)) &&
3292
+ if ([
3293
+ "prepared",
3294
+ "text_injected",
3295
+ "enter_dispatched",
3296
+ "agent_accepted",
3297
+ "dispatching",
3298
+ "submitted",
3299
+ "not_accepted",
3300
+ "uncertain"
3301
+ ].includes(String(ledger.status)) &&
2324
3302
  ledgerMessageId &&
2325
3303
  ownerMessageId !== ledgerMessageId) {
2326
3304
  return {
@@ -3985,11 +4963,18 @@ function assertTerminalLifecycleReady({ options, terminal, terminalStatus }) {
3985
4963
  `transition ${stringValue(ledger?.transition_id) ?? "with invalid identity"}`);
3986
4964
  }
3987
4965
  if (ledger &&
3988
- ["prepared", "dispatching", "uncertain"].includes(String(ledger.status))) {
4966
+ [
4967
+ "prepared",
4968
+ "text_injected",
4969
+ "enter_dispatched",
4970
+ "dispatching",
4971
+ "not_accepted",
4972
+ "uncertain"
4973
+ ].includes(String(ledger.status))) {
3989
4974
  throw new Error(`terminal ${terminal.terminalControl.target} has an unresolved ` +
3990
4975
  `${String(ledger.kind ?? "turn")} operation (${String(ledger.status)})`);
3991
4976
  }
3992
- if (ledger?.status === "submitted") {
4977
+ if (ledger && ["submitted", "agent_accepted"].includes(String(ledger.status))) {
3993
4978
  const owner = loadTerminalDispatchLedgerOwner(ledger);
3994
4979
  if (!owner || !TERMINAL_DISPATCH_RELEASE_STATUSES.has(owner.status)) {
3995
4980
  throw new Error(`terminal ${terminal.terminalControl.target} still has a submitted operation`);
@@ -6000,6 +6985,7 @@ function prepareManagedSend({ options, statePath, logPath, messageBody, stateLoc
6000
6985
  }
6001
6986
  const message = createMessage({
6002
6987
  conversation,
6988
+ id: stringValue(options.messageId),
6003
6989
  from: "openclaw",
6004
6990
  to: executor.actor,
6005
6991
  type,
@@ -6060,7 +7046,6 @@ async function runSend(options) {
6060
7046
  }
6061
7047
  const rawStoreDir = storeDirFromOptions(options);
6062
7048
  const releaseTerminalLock = acquireFileLock(terminalBridgeSendLockPath(rawStoreDir, terminalConversation.terminalControl), { timeoutMs: 30000 });
6063
- let releaseStateLock;
6064
7049
  try {
6065
7050
  await withStoreWriterLeaseAsync(rawStoreDir, async () => {
6066
7051
  await recoverLifecycleFenceBeforeMutation({
@@ -6071,6 +7056,15 @@ async function runSend(options) {
6071
7056
  // protocol migration, rather than mutable Turn recency, is the only code
6072
7057
  // allowed to materialize Session records from existing Turns.
6073
7058
  ensureStoreWritable(rawStoreDir);
7059
+ if (replayExactActiveTerminalSubmission({
7060
+ options,
7061
+ terminalControl: terminalConversation.terminalControl,
7062
+ requestText: String(messageBody),
7063
+ expectedStoreDir: rawStoreDir,
7064
+ expectedMessageType: "task"
7065
+ })) {
7066
+ return;
7067
+ }
6074
7068
  const claimedSession = soleBoundManagedSessionClaimForTerminal(rawStoreDir, terminalConversation);
6075
7069
  const knownCodexCompanions = claimedSession
6076
7070
  ? codexAllowedCompanionSetForManagedSession({
@@ -6178,33 +7172,33 @@ async function runSend(options) {
6178
7172
  });
6179
7173
  ensureStoreWritable(managed.conversation.store_dir);
6180
7174
  ensureDir(path.dirname(managed.statePath));
6181
- releaseStateLock = acquireFileLock(`${managed.statePath}.lock`);
6182
- await runTerminalControlSend({
6183
- options,
6184
- conversation: managed.conversation,
6185
- nextConversation: managed.nextConversation,
6186
- statePath: managed.statePath,
6187
- logPath: managed.logPath,
6188
- executor: managed.executor,
6189
- message: managed.message,
6190
- terminalControl: terminalConversation.terminalControl,
6191
- terminalSendLockHeld: true,
6192
- terminalStateLockHeld: true,
6193
- storeWriterLeaseHeld: true,
6194
- recordMessageAfterSend: true,
6195
- recordRawAttachmentAfterSend: reusableTurn === undefined,
6196
- allowedPreMaterializationIdentity,
6197
- allowedAdditionalIdentities
6198
- });
7175
+ const releaseStateLock = acquireFileLock(`${managed.statePath}.lock`);
7176
+ try {
7177
+ await runTerminalControlSend({
7178
+ options,
7179
+ conversation: managed.conversation,
7180
+ nextConversation: managed.nextConversation,
7181
+ statePath: managed.statePath,
7182
+ logPath: managed.logPath,
7183
+ executor: managed.executor,
7184
+ message: managed.message,
7185
+ terminalControl: terminalConversation.terminalControl,
7186
+ terminalSendLockHeld: true,
7187
+ terminalStateLockHeld: true,
7188
+ storeWriterLeaseHeld: true,
7189
+ recordMessageAfterSend: true,
7190
+ recordRawAttachmentAfterSend: reusableTurn === undefined,
7191
+ allowedPreMaterializationIdentity,
7192
+ allowedAdditionalIdentities
7193
+ });
7194
+ }
7195
+ finally {
7196
+ releaseStateLock();
7197
+ }
6199
7198
  });
6200
7199
  }
6201
7200
  finally {
6202
- try {
6203
- releaseStateLock?.();
6204
- }
6205
- finally {
6206
- releaseTerminalLock();
6207
- }
7201
+ releaseTerminalLock();
6208
7202
  }
6209
7203
  return;
6210
7204
  }
@@ -6243,7 +7237,6 @@ async function runSend(options) {
6243
7237
  throw new Error(`session ${sessionId} is not attached to a live tmux terminal`);
6244
7238
  }
6245
7239
  const releaseTerminalLock = acquireFileLock(terminalBridgeSendLockPath(storeDir, resolvedTerminal.terminalControl), { timeoutMs: 30000 });
6246
- let releaseStateLock;
6247
7240
  try {
6248
7241
  await withStoreWriterLeaseAsync(storeDir, async () => {
6249
7242
  await recoverLifecycleFenceBeforeMutation({
@@ -6254,6 +7247,16 @@ async function runSend(options) {
6254
7247
  // atomic migration. Once protocol 3 is active, a missing Session is corrupt
6255
7248
  // state and must not be reconstructed from whichever Turn looks newest.
6256
7249
  ensureStoreWritable(storeDir);
7250
+ if (replayExactActiveTerminalSubmission({
7251
+ options,
7252
+ terminalControl: resolvedTerminal.terminalControl,
7253
+ requestText: String(messageBody),
7254
+ expectedStoreDir: storeDir,
7255
+ expectedSessionId: sessionId,
7256
+ expectedMessageType: "task"
7257
+ })) {
7258
+ return;
7259
+ }
6257
7260
  let currentSession = tryLoadManagedSession(storeDir, sessionId);
6258
7261
  const knownCodexCompanions = currentSession
6259
7262
  ? codexAllowedCompanionSetForManagedSession({
@@ -6344,32 +7347,32 @@ async function runSend(options) {
6344
7347
  });
6345
7348
  ensureStoreWritable(managed.conversation.store_dir);
6346
7349
  ensureDir(path.dirname(managed.statePath));
6347
- releaseStateLock = acquireFileLock(`${managed.statePath}.lock`);
6348
- await runTerminalControlSend({
6349
- options,
6350
- conversation: managed.conversation,
6351
- nextConversation: managed.nextConversation,
6352
- statePath: managed.statePath,
6353
- logPath: managed.logPath,
6354
- executor: managed.executor,
6355
- message: managed.message,
6356
- terminalControl: resolvedTerminal.terminalControl,
6357
- terminalSendLockHeld: true,
6358
- terminalStateLockHeld: true,
6359
- storeWriterLeaseHeld: true,
6360
- recordMessageAfterSend: true,
6361
- allowedPreMaterializationIdentity,
6362
- allowedAdditionalIdentities
6363
- });
7350
+ const releaseStateLock = acquireFileLock(`${managed.statePath}.lock`);
7351
+ try {
7352
+ await runTerminalControlSend({
7353
+ options,
7354
+ conversation: managed.conversation,
7355
+ nextConversation: managed.nextConversation,
7356
+ statePath: managed.statePath,
7357
+ logPath: managed.logPath,
7358
+ executor: managed.executor,
7359
+ message: managed.message,
7360
+ terminalControl: resolvedTerminal.terminalControl,
7361
+ terminalSendLockHeld: true,
7362
+ terminalStateLockHeld: true,
7363
+ storeWriterLeaseHeld: true,
7364
+ recordMessageAfterSend: true,
7365
+ allowedPreMaterializationIdentity,
7366
+ allowedAdditionalIdentities
7367
+ });
7368
+ }
7369
+ finally {
7370
+ releaseStateLock();
7371
+ }
6364
7372
  });
6365
7373
  }
6366
7374
  finally {
6367
- try {
6368
- releaseStateLock?.();
6369
- }
6370
- finally {
6371
- releaseTerminalLock();
6372
- }
7375
+ releaseTerminalLock();
6373
7376
  }
6374
7377
  }
6375
7378
  async function runRespond(options) {
@@ -6386,101 +7389,151 @@ async function runRespond(options) {
6386
7389
  async function runTurnResponse({ options, messageBody }) {
6387
7390
  const loaded = loadConversationFromOptions(options);
6388
7391
  const { statePath, logPath } = loaded;
6389
- const conversation = await migrateLegacyTerminalAgentIdentity({
6390
- ...loaded,
6391
- options
6392
- });
6393
- if (conversation.status !== "waiting_for_openclaw") {
6394
- throw new Error(`cannot respond to turn ${turnIdForConversation(conversation)}; ` +
6395
- `turn is ${conversation.status}, not waiting_for_openclaw`);
6396
- }
6397
- const nativeTakeover = isRecord(conversation.native_session_takeover)
6398
- ? conversation.native_session_takeover
7392
+ const initialConversation = loaded.conversation;
7393
+ const requestedOpenClawSession = stringValue(options.openclawSession);
7394
+ if (requestedOpenClawSession &&
7395
+ initialConversation.openclaw_session !== requestedOpenClawSession) {
7396
+ throw new Error(`turn ${turnIdForConversation(initialConversation)} belongs to a ` +
7397
+ "different OpenClaw session; no terminal input was sent");
7398
+ }
7399
+ const nativeTakeover = isRecord(initialConversation.native_session_takeover)
7400
+ ? initialConversation.native_session_takeover
6399
7401
  : undefined;
6400
- const terminalControl = terminalControlFromTakeover(nativeTakeover);
6401
- if (!terminalControl) {
6402
- throw new Error(`turn ${turnIdForConversation(conversation)} is not attached to a live tmux terminal`);
6403
- }
6404
- const releaseTerminalLock = acquireFileLock(terminalBridgeSendLockPath(storeDirFromOptions(options), terminalControl), { timeoutMs: 30000 });
6405
- let releaseStateLock;
7402
+ const storedTerminalControl = terminalControlFromTakeover(nativeTakeover);
7403
+ const nativeTerminalId = stringValue(nativeTakeover?.native_session_id);
7404
+ if (!storedTerminalControl || !nativeTerminalId) {
7405
+ throw new Error(`turn ${turnIdForConversation(initialConversation)} is not attached to a live tmux terminal`);
7406
+ }
7407
+ const liveTerminal = await createTerminalAgentBridge(options)
7408
+ .resolveConversationId(nativeTerminalId);
7409
+ if (!liveTerminal ||
7410
+ liveTerminal.agent !== executorForConversation(initialConversation).kind ||
7411
+ liveTerminal.terminalControl.target !== storedTerminalControl.target ||
7412
+ liveTerminal.terminalControl.socketPath !== storedTerminalControl.socketPath ||
7413
+ liveTerminal.terminalControl.panePid !== storedTerminalControl.panePid) {
7414
+ throw new Error(`turn ${turnIdForConversation(initialConversation)} is not attached to its expected live tmux terminal`);
7415
+ }
7416
+ const terminalControl = liveTerminal.terminalControl;
7417
+ const responseStoreDir = pathsForConversationDir(path.dirname(statePath)).storeDir;
7418
+ const releaseTerminalLock = acquireFileLock(terminalBridgeSendLockPath(responseStoreDir, terminalControl), { timeoutMs: 30000 });
6406
7419
  try {
6407
- releaseStateLock = acquireFileLock(`${statePath}.lock`);
6408
- const prepared = prepareManagedSend({
6409
- options: { ...options, type: "answer" },
6410
- statePath,
6411
- logPath,
6412
- messageBody,
6413
- stateLockHeld: true,
6414
- persist: false
6415
- });
6416
- const preparedTakeover = isRecord(prepared.conversation.native_session_takeover)
6417
- ? prepared.conversation.native_session_takeover
6418
- : undefined;
6419
- const terminalAgentPid = Number(preparedTakeover?.terminal_agent_pid);
6420
- const responseManagedSession = tryLoadManagedSession(storeDirFromOptions(options), sessionIdForConversation(prepared.conversation));
6421
- const responseCodexCompanions = prepared.executor.kind === "codex" && responseManagedSession
6422
- ? codexAllowedCompanionSetForManagedSession({
6423
- storeDir: storeDirFromOptions(options),
6424
- session: responseManagedSession
6425
- })
6426
- : { additional: [] };
6427
- const currentNativeIdentity = await resolveCurrentNativeAgentSessionIdentity({
6428
- options,
6429
- agent: prepared.executor.kind,
6430
- pid: terminalAgentPid,
6431
- cwd: terminalControl.currentPath,
6432
- preferredSessionId: responseCodexCompanions.primary
6433
- ? stringValue(preparedTakeover?.terminal_agent_session_id)
6434
- : undefined,
6435
- allowedCompanionIdentity: responseCodexCompanions.primary,
6436
- allowedAdditionalIdentities: responseCodexCompanions.additional
6437
- });
6438
- assertNativeAgentIdentityForTurn({
6439
- conversation: prepared.conversation,
6440
- currentIdentity: currentNativeIdentity,
6441
- operation: "respond to"
6442
- });
6443
- const currentTerminalControl = terminalControlFromTakeover(prepared.nativeTakeoverForSend);
6444
- if (!currentTerminalControl ||
6445
- currentTerminalControl.target !== terminalControl.target ||
6446
- currentTerminalControl.socketPath !== terminalControl.socketPath ||
6447
- currentTerminalControl.panePid !== terminalControl.panePid) {
6448
- throw new Error("terminal control changed while waiting to respond; refresh status and retry");
6449
- }
6450
- const responseOptions = {
6451
- ...options,
6452
- type: "answer",
6453
- agentTimeoutMinutes: options.agentTimeoutMinutes ??
6454
- preparedTakeover?.terminal_bridge_inactivity_timeout_minutes ??
6455
- DEFAULT_AGENT_TIMEOUT_MINUTES,
6456
- agentHardTimeoutMinutes: options.agentHardTimeoutMinutes ??
6457
- preparedTakeover?.terminal_bridge_hard_timeout_minutes ??
6458
- DEFAULT_AGENT_HARD_TIMEOUT_MINUTES
6459
- };
6460
- await runTerminalControlSend({
6461
- options: responseOptions,
6462
- conversation: prepared.conversation,
6463
- nextConversation: prepared.nextConversation,
6464
- statePath,
6465
- logPath,
6466
- executor: prepared.executor,
6467
- message: prepared.message,
6468
- terminalControl: currentTerminalControl,
6469
- terminalSendLockHeld: true,
6470
- terminalStateLockHeld: true,
6471
- recordMessageAfterSend: true,
6472
- allowedPreMaterializationIdentity: responseCodexCompanions.primary,
6473
- allowedAdditionalIdentities: responseCodexCompanions.additional,
6474
- continuingTurnResponse: true
7420
+ await withStoreWriterLeaseAsync(responseStoreDir, async () => {
7421
+ // Legacy identity migration may write the Turn, so keep it inside the
7422
+ // canonical terminal -> Store writer -> state ordering as well.
7423
+ await migrateLegacyTerminalAgentIdentity({ ...loaded, options });
7424
+ const releaseStateLock = acquireFileLock(`${statePath}.lock`);
7425
+ try {
7426
+ const lockedConversation = loadState(statePath);
7427
+ const lockedTakeover = isRecord(lockedConversation.native_session_takeover)
7428
+ ? lockedConversation.native_session_takeover
7429
+ : undefined;
7430
+ const lockedControl = terminalControlFromTakeover(lockedTakeover);
7431
+ if (stringValue(lockedTakeover?.native_session_id) !== nativeTerminalId ||
7432
+ executorForConversation(lockedConversation).kind !== liveTerminal.agent ||
7433
+ !lockedControl ||
7434
+ lockedControl.target !== terminalControl.target ||
7435
+ lockedControl.socketPath !== terminalControl.socketPath ||
7436
+ lockedControl.panePid !== terminalControl.panePid) {
7437
+ throw new Error("terminal control changed while waiting to respond; refresh status and retry");
7438
+ }
7439
+ if (requestedOpenClawSession &&
7440
+ lockedConversation.openclaw_session !== requestedOpenClawSession) {
7441
+ throw new Error(`turn ${turnIdForConversation(lockedConversation)} belongs to a ` +
7442
+ "different OpenClaw session; no terminal input was sent");
7443
+ }
7444
+ if (replayExactActiveTerminalSubmission({
7445
+ options,
7446
+ terminalControl,
7447
+ requestText: String(messageBody),
7448
+ expectedStoreDir: responseStoreDir,
7449
+ expectedSessionId: sessionIdForConversation(lockedConversation),
7450
+ expectedTurnId: turnIdForConversation(lockedConversation),
7451
+ expectedMessageType: "answer",
7452
+ expectedStatePath: statePath
7453
+ })) {
7454
+ return;
7455
+ }
7456
+ if (lockedConversation.status !== "waiting_for_openclaw") {
7457
+ throw new Error(`cannot respond to turn ${turnIdForConversation(lockedConversation)}; ` +
7458
+ `turn is ${lockedConversation.status}, not waiting_for_openclaw`);
7459
+ }
7460
+ const prepared = prepareManagedSend({
7461
+ options: { ...options, type: "answer" },
7462
+ statePath,
7463
+ logPath,
7464
+ messageBody,
7465
+ stateLockHeld: true,
7466
+ persist: false
7467
+ });
7468
+ const preparedTakeover = isRecord(prepared.conversation.native_session_takeover)
7469
+ ? prepared.conversation.native_session_takeover
7470
+ : undefined;
7471
+ const terminalAgentPid = Number(preparedTakeover?.terminal_agent_pid);
7472
+ const responseManagedSession = tryLoadManagedSession(responseStoreDir, sessionIdForConversation(prepared.conversation));
7473
+ const responseCodexCompanions = prepared.executor.kind === "codex" && responseManagedSession
7474
+ ? codexAllowedCompanionSetForManagedSession({
7475
+ storeDir: responseStoreDir,
7476
+ session: responseManagedSession
7477
+ })
7478
+ : { additional: [] };
7479
+ const currentNativeIdentity = await resolveCurrentNativeAgentSessionIdentity({
7480
+ options,
7481
+ agent: prepared.executor.kind,
7482
+ pid: terminalAgentPid,
7483
+ cwd: terminalControl.currentPath,
7484
+ preferredSessionId: responseCodexCompanions.primary
7485
+ ? stringValue(preparedTakeover?.terminal_agent_session_id)
7486
+ : undefined,
7487
+ allowedCompanionIdentity: responseCodexCompanions.primary,
7488
+ allowedAdditionalIdentities: responseCodexCompanions.additional
7489
+ });
7490
+ assertNativeAgentIdentityForTurn({
7491
+ conversation: prepared.conversation,
7492
+ currentIdentity: currentNativeIdentity,
7493
+ operation: "respond to"
7494
+ });
7495
+ const currentTerminalControl = terminalControlFromTakeover(prepared.nativeTakeoverForSend);
7496
+ if (!currentTerminalControl ||
7497
+ currentTerminalControl.target !== terminalControl.target ||
7498
+ currentTerminalControl.socketPath !== terminalControl.socketPath ||
7499
+ currentTerminalControl.panePid !== terminalControl.panePid) {
7500
+ throw new Error("terminal control changed while waiting to respond; refresh status and retry");
7501
+ }
7502
+ const responseOptions = {
7503
+ ...options,
7504
+ type: "answer",
7505
+ agentTimeoutMinutes: options.agentTimeoutMinutes ??
7506
+ preparedTakeover?.terminal_bridge_inactivity_timeout_minutes ??
7507
+ DEFAULT_AGENT_TIMEOUT_MINUTES,
7508
+ agentHardTimeoutMinutes: options.agentHardTimeoutMinutes ??
7509
+ preparedTakeover?.terminal_bridge_hard_timeout_minutes ??
7510
+ DEFAULT_AGENT_HARD_TIMEOUT_MINUTES
7511
+ };
7512
+ await runTerminalControlSend({
7513
+ options: responseOptions,
7514
+ conversation: prepared.conversation,
7515
+ nextConversation: prepared.nextConversation,
7516
+ statePath,
7517
+ logPath,
7518
+ executor: prepared.executor,
7519
+ message: prepared.message,
7520
+ terminalControl: currentTerminalControl,
7521
+ terminalSendLockHeld: true,
7522
+ terminalStateLockHeld: true,
7523
+ storeWriterLeaseHeld: true,
7524
+ recordMessageAfterSend: true,
7525
+ allowedPreMaterializationIdentity: responseCodexCompanions.primary,
7526
+ allowedAdditionalIdentities: responseCodexCompanions.additional,
7527
+ continuingTurnResponse: true
7528
+ });
7529
+ }
7530
+ finally {
7531
+ releaseStateLock();
7532
+ }
6475
7533
  });
6476
7534
  }
6477
7535
  finally {
6478
- try {
6479
- releaseStateLock?.();
6480
- }
6481
- finally {
6482
- releaseTerminalLock();
6483
- }
7536
+ releaseTerminalLock();
6484
7537
  }
6485
7538
  }
6486
7539
  async function runApprove(options) {
@@ -7167,14 +8220,18 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
7167
8220
  "refresh AKK list and use its exact recovery action");
7168
8221
  }
7169
8222
  if (previousDispatchLedger?.status === "prepared" ||
8223
+ previousDispatchLedger?.status === "text_injected" ||
7170
8224
  previousDispatchLedger?.status === "dispatching" ||
7171
- previousDispatchLedger?.status === "uncertain") {
8225
+ previousDispatchLedger?.status === "uncertain" ||
8226
+ previousDispatchLedger?.status === "not_accepted") {
7172
8227
  throw new Error(`terminal ${terminalControl.target} has a terminal-level ` +
7173
8228
  `${String(previousDispatchLedger.status)} dispatch owned by ` +
7174
8229
  `${stringValue(previousDispatchLedger.conversation_id) ?? "an unknown conversation"}; ` +
7175
8230
  "inspect the shared tmux pane and explicitly close that AKK conversation before retrying");
7176
8231
  }
7177
- if (previousDispatchLedger?.status === "submitted") {
8232
+ if (previousDispatchLedger?.status === "submitted" ||
8233
+ previousDispatchLedger?.status === "enter_dispatched" ||
8234
+ previousDispatchLedger?.status === "agent_accepted") {
7178
8235
  const owner = loadTerminalDispatchLedgerOwner(previousDispatchLedger);
7179
8236
  if (!owner) {
7180
8237
  throw new Error(`terminal ${terminalControl.target} has a submitted dispatch whose ` +
@@ -7194,6 +8251,14 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
7194
8251
  stringValue(previousDispatchLedger.message_id) === message.id &&
7195
8252
  sameCanonicalStatePath(previousDispatchLedger.state_path, statePath));
7196
8253
  if (exactDispatchReplay) {
8254
+ const proofLevel = String(previousDispatchLedger.status);
8255
+ const replayReceipt = terminalSubmissionReplayReceipt({
8256
+ proofLevel,
8257
+ evidence: previousDispatchLedger.acceptance_evidence,
8258
+ expected: terminalAcceptanceEvidenceExpectation(owner, terminalPayload)
8259
+ });
8260
+ const accepted = replayReceipt.delivered;
8261
+ const acceptanceInvalid = replayReceipt.submission_outcome === "uncertain";
7197
8262
  const receiptConversationId = stringValue(previousDispatchLedger.conversation_id) ??
7198
8263
  owner.conversation_id;
7199
8264
  const receiptSessionId = sessionIdForConversation(owner);
@@ -7217,24 +8282,43 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
7217
8282
  turn_id: receiptTurnId
7218
8283
  }
7219
8284
  },
7220
- delivered: true,
7221
- status: "async_pending",
8285
+ delivered: replayReceipt.delivered,
8286
+ status: replayReceipt.status,
8287
+ submission_outcome: replayReceipt.submission_outcome,
7222
8288
  background: true,
7223
- callback_expected: Boolean(owner.gateway_method ??
8289
+ callback_expected: !acceptanceInvalid && Boolean(owner.gateway_method ??
7224
8290
  previousDispatchLedger.callback_expected),
7225
8291
  terminal_control: terminalControl,
7226
8292
  executor,
7227
- replayed: true,
7228
- delivery_receipt: "submitted",
7229
- reason: "AKK replayed the durable receipt for an identical active terminal request and did not send tmux input again.",
7230
- openclaw_next_action: openClawYieldNextAction({
7231
- conversationId: receiptConversationId,
7232
- sessionId: receiptSessionId,
7233
- turnId: receiptTurnId,
7234
- source: "terminal_control",
7235
- callbackExpected: Boolean(owner.gateway_method ??
7236
- previousDispatchLedger.callback_expected)
7237
- })
8293
+ replayed: replayReceipt.replayed,
8294
+ delivery_receipt: replayReceipt.delivery_receipt,
8295
+ ...(replayReceipt.do_not_retry
8296
+ ? { do_not_retry: replayReceipt.do_not_retry }
8297
+ : {}),
8298
+ reason: accepted
8299
+ ? "AKK replayed the durable native acceptance receipt for an identical active terminal request and did not send tmux input again."
8300
+ : acceptanceInvalid
8301
+ ? `AKK refused to replay an invalid native acceptance receipt (${replayReceipt.evidence_error ?? "evidence validation failed"}); no tmux input was sent.`
8302
+ : "AKK replayed the original transport-level receipt without upgrading it to native acceptance and did not send tmux input again.",
8303
+ openclaw_next_action: accepted
8304
+ ? openClawYieldNextAction({
8305
+ conversationId: receiptConversationId,
8306
+ sessionId: receiptSessionId,
8307
+ turnId: receiptTurnId,
8308
+ source: "terminal_control",
8309
+ callbackExpected: Boolean(owner.gateway_method ??
8310
+ previousDispatchLedger.callback_expected)
8311
+ })
8312
+ : {
8313
+ action: "inspect",
8314
+ conversation_id: receiptConversationId,
8315
+ session_id: receiptSessionId,
8316
+ turn_id: receiptTurnId,
8317
+ do_not_retry: true,
8318
+ reason: acceptanceInvalid
8319
+ ? "The stored native acceptance evidence is invalid; inspect and explicitly close this Turn."
8320
+ : "Only terminal transport is proven; wait for native acceptance or inspect the shared pane."
8321
+ }
7238
8322
  });
7239
8323
  return;
7240
8324
  }
@@ -7288,6 +8372,7 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
7288
8372
  messageId: message.id
7289
8373
  };
7290
8374
  let preSendScreenFingerprint;
8375
+ let codexRolloutAcceptanceAnchor;
7291
8376
  let claudeTranscriptAnchor;
7292
8377
  const claudeHome = executor.kind === "claude"
7293
8378
  ? path.resolve(expandHome(options.claudeHome) ?? defaultClaudeHome())
@@ -7322,7 +8407,16 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
7322
8407
  if (bridge) {
7323
8408
  preSendScreenFingerprint = stringValue(status.screen.digest) ??
7324
8409
  terminalBridgeScreenFingerprint(status.screen.excerpt);
7325
- if (executor.kind === "claude") {
8410
+ if (executor.kind === "codex") {
8411
+ codexRolloutAcceptanceAnchor = captureCodexAcceptanceAnchorForSend({
8412
+ options,
8413
+ currentIdentity: currentNativeIdentity,
8414
+ expectedNativeThreadId: expectedManagedNativeThreadId,
8415
+ allowedPreMaterializationIdentity,
8416
+ needsPostSendNativeBinding
8417
+ });
8418
+ }
8419
+ else {
7326
8420
  claudeTranscriptAnchor = captureClaudeTranscriptAnchor({
7327
8421
  sessionId: preSendRuntime.sessionId,
7328
8422
  cwd: preSendRuntime.cwd,
@@ -7348,6 +8442,7 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
7348
8442
  agentTimeoutMinutes,
7349
8443
  agentHardTimeoutMinutes,
7350
8444
  preSendScreenFingerprint,
8445
+ codexRolloutAcceptanceAnchor,
7351
8446
  claudeTranscriptAnchor,
7352
8447
  claudeHome
7353
8448
  })
@@ -7355,6 +8450,8 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
7355
8450
  const preparedConversation = withTerminalBridgeSubmission({
7356
8451
  conversation: bridgeConversation,
7357
8452
  messageId: message.id,
8453
+ messageType: message.type,
8454
+ messageBody: String(message.body),
7358
8455
  requestText: terminalPayload,
7359
8456
  status: "prepared",
7360
8457
  preparedAt: bridgeStartedAt
@@ -7367,6 +8464,7 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
7367
8464
  session_id: sessionIdForConversation(preparedConversation),
7368
8465
  turn_id: turnIdForConversation(preparedConversation),
7369
8466
  message_id: message.id,
8467
+ message_type: message.type,
7370
8468
  request_hash: terminalRequestHash,
7371
8469
  prepared_at: bridgeStartedAt,
7372
8470
  dispatcher_pid: process.pid,
@@ -7435,32 +8533,6 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
7435
8533
  request_hash: terminalBridgeRequestFingerprint(terminalPayload),
7436
8534
  dispatcher_pid: process.pid
7437
8535
  });
7438
- bridgeMonitor = bridge
7439
- ? startTerminalBridgeMonitorForConversation({
7440
- conversation: preparedConversation,
7441
- statePath,
7442
- logPath,
7443
- options
7444
- })
7445
- : undefined;
7446
- if (bridgeMonitor) {
7447
- appendEvent(logPath, {
7448
- ts: new Date().toISOString(),
7449
- conversation_id: preparedConversation.conversation_id,
7450
- event: "terminal_bridge_monitor_launch",
7451
- pid: bridgeMonitor.pid ?? null,
7452
- terminal_control: terminalControl,
7453
- phase: "before_terminal_submit",
7454
- agent_timeout_minutes: agentTimeoutMinutes,
7455
- agent_hard_timeout_minutes: agentHardTimeoutMinutes
7456
- });
7457
- runtimeLog("info", "terminal_bridge_monitor_launch", {
7458
- conversation_id: preparedConversation.conversation_id,
7459
- monitor_pid: bridgeMonitor.pid ?? null,
7460
- terminal_target: terminalControl.target,
7461
- phase: "before_terminal_submit"
7462
- });
7463
- }
7464
8536
  }
7465
8537
  catch (error) {
7466
8538
  const abortedAt = new Date().toISOString();
@@ -7490,18 +8562,44 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
7490
8562
  failed_at: abortedAt,
7491
8563
  failure_reason: "terminal submission setup failed before tmux input"
7492
8564
  }
7493
- : conversation;
8565
+ : {
8566
+ ...preparedConversation,
8567
+ status: conversation.status,
8568
+ ...(conversation.idle_since
8569
+ ? { idle_since: conversation.idle_since }
8570
+ : {})
8571
+ };
7494
8572
  const abortedConversation = withTerminalBridgeSubmission({
7495
8573
  conversation: failureBase,
7496
8574
  messageId: message.id,
8575
+ messageType: message.type,
8576
+ messageBody: String(message.body),
7497
8577
  requestText: terminalPayload,
7498
8578
  status: "aborted",
7499
8579
  preparedAt: bridgeStartedAt,
7500
8580
  abortedAt,
7501
- error: errorMessage
8581
+ error: errorMessage,
8582
+ safeToRetry: dispatchLedgerRestored
7502
8583
  });
8584
+ let abortedStatePersisted = false;
7503
8585
  try {
8586
+ if (process.env.AKK_TEST_ABORTED_STATE_PERSISTENCE_FAILURE === "1") {
8587
+ throw new Error("injected aborted submission state persistence failure");
8588
+ }
7504
8589
  saveState(statePath, abortedConversation);
8590
+ abortedStatePersisted = true;
8591
+ }
8592
+ catch (persistenceError) {
8593
+ runtimeLog("error", "terminal_message_submit_aborted_persist_failed", {
8594
+ conversation_id: abortedConversation.conversation_id,
8595
+ terminal_target: terminalControl.target,
8596
+ error: persistenceError instanceof Error
8597
+ ? persistenceError.message
8598
+ : String(persistenceError)
8599
+ });
8600
+ }
8601
+ const safeToRetry = dispatchLedgerRestored && abortedStatePersisted;
8602
+ try {
7505
8603
  appendEvent(logPath, {
7506
8604
  ts: abortedAt,
7507
8605
  conversation_id: abortedConversation.conversation_id,
@@ -7510,11 +8608,11 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
7510
8608
  executor,
7511
8609
  terminal_control: terminalControl,
7512
8610
  error: textSummary(errorMessage),
7513
- safe_to_retry: dispatchLedgerRestored
8611
+ safe_to_retry: safeToRetry
7514
8612
  });
7515
8613
  }
7516
8614
  catch (persistenceError) {
7517
- runtimeLog("error", "terminal_message_submit_aborted_persist_failed", {
8615
+ runtimeLog("error", "terminal_message_submit_aborted_event_failed", {
7518
8616
  conversation_id: abortedConversation.conversation_id,
7519
8617
  terminal_target: terminalControl.target,
7520
8618
  error: persistenceError instanceof Error
@@ -7522,16 +8620,32 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
7522
8620
  : String(persistenceError)
7523
8621
  });
7524
8622
  }
8623
+ const reportedAbortedConversation = safeToRetry
8624
+ ? abortedConversation
8625
+ : withTerminalBridgeSubmission({
8626
+ conversation: abortedConversation,
8627
+ messageId: message.id,
8628
+ messageType: message.type,
8629
+ messageBody: String(message.body),
8630
+ requestText: terminalPayload,
8631
+ status: "aborted",
8632
+ preparedAt: bridgeStartedAt,
8633
+ abortedAt,
8634
+ error: errorMessage,
8635
+ safeToRetry: false
8636
+ });
7525
8637
  runtimeLog("error", "terminal_message_submit_aborted", {
7526
8638
  conversation_id: abortedConversation.conversation_id,
7527
8639
  terminal_target: terminalControl.target,
7528
8640
  error: errorMessage,
7529
- safe_to_retry: dispatchLedgerRestored
8641
+ safe_to_retry: safeToRetry,
8642
+ dispatch_ledger_restored: dispatchLedgerRestored,
8643
+ aborted_state_persisted: abortedStatePersisted
7530
8644
  });
7531
8645
  printJson({
7532
8646
  session_id: sessionIdForConversation(abortedConversation),
7533
8647
  turn_id: turnIdForConversation(abortedConversation),
7534
- conversation: abortedConversation,
8648
+ conversation: reportedAbortedConversation,
7535
8649
  message,
7536
8650
  delivered: false,
7537
8651
  status: "submission_aborted",
@@ -7541,32 +8655,108 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
7541
8655
  terminal_control: terminalControl,
7542
8656
  monitor_pid: bridgeMonitor?.pid ?? null,
7543
8657
  executor,
7544
- safe_to_retry: dispatchLedgerRestored,
7545
- do_not_retry: !dispatchLedgerRestored,
7546
- reason: dispatchLedgerRestored
8658
+ safe_to_retry: safeToRetry,
8659
+ do_not_retry: !safeToRetry,
8660
+ reason: safeToRetry
7547
8661
  ? "AKK failed before touching tmux; this terminal submission was not sent and may be retried."
7548
- : "AKK failed before tmux input but could not restore the terminal dispatch ledger; inspect and close the conversation before retrying.",
8662
+ : !dispatchLedgerRestored
8663
+ ? "AKK failed before tmux input but could not restore the terminal dispatch ledger; inspect and close the conversation before retrying."
8664
+ : "AKK failed before tmux input but could not persist the aborted receipt; inspect the conversation before retrying.",
7549
8665
  openclaw_next_action: {
7550
- action: dispatchLedgerRestored ? "retry" : "inspect",
8666
+ action: safeToRetry ? "retry" : "inspect",
7551
8667
  conversation_id: abortedConversation.conversation_id,
7552
- session_id: sessionIdForConversation(abortedConversation),
7553
- turn_id: turnIdForConversation(abortedConversation),
7554
- safe_to_retry: dispatchLedgerRestored,
7555
- do_not_retry: !dispatchLedgerRestored,
7556
- reason: dispatchLedgerRestored
8668
+ session_id: sessionIdForConversation(reportedAbortedConversation),
8669
+ turn_id: turnIdForConversation(reportedAbortedConversation),
8670
+ safe_to_retry: safeToRetry,
8671
+ do_not_retry: !safeToRetry,
8672
+ reason: safeToRetry
7557
8673
  ? "The failure occurred before any tmux input."
7558
- : "The terminal ledger could not be restored automatically."
8674
+ : !dispatchLedgerRestored
8675
+ ? "The terminal ledger could not be restored automatically."
8676
+ : "The aborted receipt could not be made durable."
7559
8677
  }
7560
8678
  });
7561
8679
  return;
7562
8680
  }
7563
8681
  let deliveredConversation;
8682
+ let stagedConversation = preparedConversation;
8683
+ let textInjectedAt;
8684
+ let enterDispatchedAt;
8685
+ let acceptanceResult;
8686
+ let bookkeepingWarning;
8687
+ const recordPostTransportBookkeepingFailure = (phase, error) => {
8688
+ const message = error instanceof Error ? error.message : String(error);
8689
+ bookkeepingWarning ??= message;
8690
+ runtimeLog("warn", "terminal_message_post_transport_bookkeeping_failed", {
8691
+ conversation_id: stagedConversation.conversation_id,
8692
+ terminal_target: terminalControl.target,
8693
+ phase,
8694
+ error: message
8695
+ });
8696
+ };
7564
8697
  try {
7565
8698
  await terminalBridge.send(executor.kind, terminalControl, terminalPayload, {
7566
- runtime: preSendRuntime
8699
+ runtime: preSendRuntime,
8700
+ async onTransportStage(event) {
8701
+ const stageAt = new Date().toISOString();
8702
+ if (event.stage === "text_injected") {
8703
+ textInjectedAt = stageAt;
8704
+ }
8705
+ else {
8706
+ enterDispatchedAt = stageAt;
8707
+ }
8708
+ stagedConversation = withTerminalBridgeSubmission({
8709
+ conversation: stagedConversation,
8710
+ messageId: message.id,
8711
+ messageType: message.type,
8712
+ messageBody: String(message.body),
8713
+ requestText: terminalPayload,
8714
+ status: event.stage,
8715
+ preparedAt: bridgeStartedAt,
8716
+ textInjectedAt,
8717
+ enterDispatchedAt
8718
+ });
8719
+ saveState(statePath, stagedConversation);
8720
+ saveTerminalBridgeDispatchLedger(terminalControl, {
8721
+ ...terminalBindingLedgerFields(stagedConversation),
8722
+ status: event.stage,
8723
+ generation_id: message.id,
8724
+ conversation_id: stagedConversation.conversation_id,
8725
+ session_id: sessionIdForConversation(stagedConversation),
8726
+ turn_id: turnIdForConversation(stagedConversation),
8727
+ message_id: message.id,
8728
+ message_type: message.type,
8729
+ request_hash: terminalRequestHash,
8730
+ prepared_at: bridgeStartedAt,
8731
+ ...(textInjectedAt ? { text_injected_at: textInjectedAt } : {}),
8732
+ ...(enterDispatchedAt ? { enter_dispatched_at: enterDispatchedAt } : {}),
8733
+ dispatcher_pid: process.pid,
8734
+ state_path: statePath,
8735
+ event_log_path: logPath,
8736
+ callback_expected: Boolean(stagedConversation.gateway_method),
8737
+ previous_generation_id: stringValue(previousDispatchLedger?.generation_id) ??
8738
+ stringValue(previousDispatchLedger?.message_id)
8739
+ });
8740
+ try {
8741
+ appendEvent(logPath, {
8742
+ ts: stageAt,
8743
+ conversation_id: stagedConversation.conversation_id,
8744
+ event: `terminal_message_${event.stage}`,
8745
+ message_id: message.id,
8746
+ executor,
8747
+ terminal_control: terminalControl,
8748
+ request_hash: terminalRequestHash
8749
+ });
8750
+ }
8751
+ catch (error) {
8752
+ recordPostTransportBookkeepingFailure(event.stage, error);
8753
+ }
8754
+ }
7567
8755
  });
7568
- const submittedAt = new Date().toISOString();
7569
- let submittedBase = preparedConversation;
8756
+ if (!enterDispatchedAt) {
8757
+ throw new Error("terminal bridge returned without an enter_dispatched receipt");
8758
+ }
8759
+ let submittedBase = stagedConversation;
7570
8760
  if (needsPostSendNativeBinding) {
7571
8761
  let boundIdentity;
7572
8762
  let boundConversation;
@@ -7593,7 +8783,7 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
7593
8783
  throw new Error(`native agent created thread ${boundIdentity.sessionId}, expected ` +
7594
8784
  `${expectedNativeThreadId}`);
7595
8785
  }
7596
- boundConversation = withNativeAgentSessionIdentity(preparedConversation, boundIdentity);
8786
+ boundConversation = withNativeAgentSessionIdentity(stagedConversation, boundIdentity);
7597
8787
  const bindingStoreDir = managedSessionStoreDirForConversation(boundConversation);
7598
8788
  if (!bindingStoreDir) {
7599
8789
  throw new Error("managed Session Store is unavailable before native identity commit");
@@ -7633,16 +8823,16 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
7633
8823
  if (!boundIdentity) {
7634
8824
  const bindingFailedAt = new Date().toISOString();
7635
8825
  const bindingReason = bindingError
7636
- ? `AKK delivered the terminal input but could not verify the new native agent session: ${bindingError}`
7637
- : "AKK delivered the terminal input but no exact native agent session appeared within the binding window";
8826
+ ? `AKK dispatched terminal input but could not verify the new native agent session: ${bindingError}`
8827
+ : "AKK dispatched terminal input but no exact native agent session appeared within the binding window";
7638
8828
  const unfencedBase = {
7639
- ...preparedConversation,
8829
+ ...stagedConversation,
7640
8830
  status: "stalled",
7641
8831
  stalled_at: bindingFailedAt,
7642
8832
  stalled_reason: bindingReason,
7643
8833
  native_session_takeover: {
7644
- ...(isRecord(preparedConversation.native_session_takeover)
7645
- ? preparedConversation.native_session_takeover
8834
+ ...(isRecord(stagedConversation.native_session_takeover)
8835
+ ? stagedConversation.native_session_takeover
7646
8836
  : {}),
7647
8837
  terminal_agent_identity_status: "unresolved_after_submit",
7648
8838
  terminal_agent_identity_error: textSummary(bindingReason)
@@ -7652,10 +8842,16 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
7652
8842
  const unfencedConversation = withTerminalBridgeSubmission({
7653
8843
  conversation: unfencedBase,
7654
8844
  messageId: message.id,
8845
+ messageType: message.type,
8846
+ messageBody: String(message.body),
7655
8847
  requestText: terminalPayload,
7656
- status: "submitted",
8848
+ status: "uncertain",
7657
8849
  preparedAt: bridgeStartedAt,
7658
- submittedAt
8850
+ textInjectedAt,
8851
+ enterDispatchedAt,
8852
+ uncertainAt: bindingFailedAt,
8853
+ error: bindingReason,
8854
+ lastProvenStage: "enter_dispatched"
7659
8855
  });
7660
8856
  quarantineManagedSessionBinding({
7661
8857
  conversation: unfencedConversation,
@@ -7663,15 +8859,18 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
7663
8859
  });
7664
8860
  saveTerminalBridgeDispatchLedger(terminalControl, {
7665
8861
  ...terminalBindingLedgerFields(unfencedConversation),
7666
- status: "submitted",
8862
+ status: "uncertain",
7667
8863
  generation_id: message.id,
7668
8864
  conversation_id: unfencedConversation.conversation_id,
7669
8865
  session_id: sessionIdForConversation(unfencedConversation),
7670
8866
  turn_id: turnIdForConversation(unfencedConversation),
7671
8867
  message_id: message.id,
8868
+ message_type: message.type,
7672
8869
  request_hash: terminalRequestHash,
7673
8870
  prepared_at: bridgeStartedAt,
7674
- submitted_at: submittedAt,
8871
+ text_injected_at: textInjectedAt,
8872
+ enter_dispatched_at: enterDispatchedAt,
8873
+ uncertain_at: bindingFailedAt,
7675
8874
  dispatcher_pid: process.pid,
7676
8875
  state_path: statePath,
7677
8876
  event_log_path: logPath,
@@ -7690,7 +8889,7 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
7690
8889
  executor,
7691
8890
  terminal_control: terminalControl,
7692
8891
  error: textSummary(bindingReason),
7693
- delivered: true,
8892
+ delivered: false,
7694
8893
  do_not_retry: true
7695
8894
  });
7696
8895
  runtimeLog("error", "terminal_agent_identity_binding_failed", {
@@ -7698,7 +8897,7 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
7698
8897
  agent: executor.kind,
7699
8898
  terminal_target: terminalControl.target,
7700
8899
  error: bindingReason,
7701
- delivered: true,
8900
+ delivered: false,
7702
8901
  do_not_retry: true
7703
8902
  });
7704
8903
  printJson({
@@ -7706,15 +8905,15 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
7706
8905
  turn_id: turnIdForConversation(unfencedConversation),
7707
8906
  conversation: unfencedConversation,
7708
8907
  message,
7709
- delivered: true,
7710
- status: "delivered_unfenced",
7711
- submission_outcome: "submitted",
8908
+ delivered: false,
8909
+ status: "submission_uncertain",
8910
+ submission_outcome: "uncertain",
7712
8911
  background: true,
7713
8912
  callback_expected: false,
7714
8913
  terminal_control: terminalControl,
7715
8914
  monitor_pid: bridgeMonitor?.pid ?? null,
7716
8915
  executor,
7717
- delivery_receipt: "submitted",
8916
+ delivery_receipt: "enter_dispatched",
7718
8917
  do_not_retry: true,
7719
8918
  reason: bindingReason,
7720
8919
  openclaw_next_action: {
@@ -7730,40 +8929,152 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
7730
8929
  }
7731
8930
  submittedBase = boundConversation;
7732
8931
  }
7733
- deliveredConversation = withTerminalBridgeSubmission({
8932
+ acceptanceResult = await pollTerminalSubmissionAcceptance({
8933
+ options,
8934
+ executor: executor.kind,
7734
8935
  conversation: submittedBase,
8936
+ terminalControl,
8937
+ terminalBridge
8938
+ });
8939
+ const acceptanceResolvedAt = new Date().toISOString();
8940
+ const terminalStatus = acceptanceResult.outcome === "pending_acceptance"
8941
+ ? "enter_dispatched"
8942
+ : acceptanceResult.outcome;
8943
+ const outcomeBase = acceptanceResult.outcome === "not_accepted" ||
8944
+ acceptanceResult.outcome === "uncertain"
8945
+ ? {
8946
+ ...submittedBase,
8947
+ status: "stalled",
8948
+ stalled_at: acceptanceResolvedAt,
8949
+ stalled_reason: acceptanceResult.reason,
8950
+ updated_at: acceptanceResolvedAt
8951
+ }
8952
+ : submittedBase;
8953
+ deliveredConversation = withTerminalBridgeSubmission({
8954
+ conversation: outcomeBase,
7735
8955
  messageId: message.id,
8956
+ messageType: message.type,
8957
+ messageBody: String(message.body),
7736
8958
  requestText: terminalPayload,
7737
- status: "submitted",
8959
+ status: terminalStatus,
7738
8960
  preparedAt: bridgeStartedAt,
7739
- submittedAt
8961
+ textInjectedAt,
8962
+ enterDispatchedAt,
8963
+ ...(acceptanceResult.outcome === "agent_accepted"
8964
+ ? {
8965
+ agentAcceptedAt: acceptanceResolvedAt,
8966
+ acceptanceEvidence: acceptanceResult.evidence
8967
+ }
8968
+ : {}),
8969
+ ...(acceptanceResult.outcome === "not_accepted"
8970
+ ? { notAcceptedAt: acceptanceResolvedAt }
8971
+ : {}),
8972
+ ...(acceptanceResult.outcome === "uncertain"
8973
+ ? {
8974
+ uncertainAt: acceptanceResolvedAt,
8975
+ error: acceptanceResult.reason,
8976
+ lastProvenStage: "enter_dispatched"
8977
+ }
8978
+ : {})
7740
8979
  });
7741
8980
  saveState(statePath, deliveredConversation);
7742
- saveTerminalBridgeDispatchLedger(terminalControl, {
7743
- ...terminalBindingLedgerFields(deliveredConversation),
7744
- status: "submitted",
7745
- generation_id: message.id,
7746
- conversation_id: deliveredConversation.conversation_id,
7747
- session_id: sessionIdForConversation(deliveredConversation),
7748
- turn_id: turnIdForConversation(deliveredConversation),
7749
- message_id: message.id,
7750
- request_hash: terminalRequestHash,
7751
- prepared_at: bridgeStartedAt,
7752
- submitted_at: submittedAt,
7753
- dispatcher_pid: process.pid,
7754
- state_path: statePath,
7755
- event_log_path: logPath,
7756
- callback_expected: Boolean(deliveredConversation.gateway_method),
7757
- previous_generation_id: stringValue(previousDispatchLedger?.generation_id) ??
7758
- stringValue(previousDispatchLedger?.message_id)
7759
- });
8981
+ try {
8982
+ if (process.env.AKK_TEST_FINAL_TERMINAL_LEDGER_FAILURE === "1") {
8983
+ throw new Error("injected final terminal ledger persistence failure");
8984
+ }
8985
+ saveTerminalBridgeDispatchLedger(terminalControl, {
8986
+ ...terminalBindingLedgerFields(deliveredConversation),
8987
+ status: terminalStatus,
8988
+ generation_id: message.id,
8989
+ conversation_id: deliveredConversation.conversation_id,
8990
+ session_id: sessionIdForConversation(deliveredConversation),
8991
+ turn_id: turnIdForConversation(deliveredConversation),
8992
+ message_id: message.id,
8993
+ message_type: message.type,
8994
+ request_hash: terminalRequestHash,
8995
+ prepared_at: bridgeStartedAt,
8996
+ text_injected_at: textInjectedAt,
8997
+ enter_dispatched_at: enterDispatchedAt,
8998
+ ...(acceptanceResult.outcome === "agent_accepted"
8999
+ ? {
9000
+ agent_accepted_at: acceptanceResolvedAt,
9001
+ acceptance_evidence: acceptanceResult.evidence
9002
+ }
9003
+ : {}),
9004
+ ...(acceptanceResult.outcome === "not_accepted"
9005
+ ? { not_accepted_at: acceptanceResolvedAt }
9006
+ : {}),
9007
+ ...(acceptanceResult.outcome === "uncertain"
9008
+ ? {
9009
+ uncertain_at: acceptanceResolvedAt,
9010
+ error: textSummary(acceptanceResult.reason)
9011
+ }
9012
+ : {}),
9013
+ dispatcher_pid: null,
9014
+ state_path: statePath,
9015
+ event_log_path: logPath,
9016
+ callback_expected: Boolean(deliveredConversation.gateway_method),
9017
+ previous_generation_id: stringValue(previousDispatchLedger?.generation_id) ??
9018
+ stringValue(previousDispatchLedger?.message_id)
9019
+ });
9020
+ }
9021
+ catch (error) {
9022
+ // State is the durable proof authority. Once a valid native ACK has
9023
+ // committed there, a lagging ledger is bookkeeping debt and must never
9024
+ // overwrite that strongest proof with `uncertain`.
9025
+ recordPostTransportBookkeepingFailure("final_terminal_ledger", error);
9026
+ }
9027
+ try {
9028
+ appendEvent(logPath, {
9029
+ ts: acceptanceResolvedAt,
9030
+ conversation_id: deliveredConversation.conversation_id,
9031
+ event: acceptanceResult.outcome === "agent_accepted"
9032
+ ? "terminal_message_agent_accepted"
9033
+ : acceptanceResult.outcome === "pending_acceptance"
9034
+ ? "terminal_message_acceptance_pending"
9035
+ : `terminal_message_${acceptanceResult.outcome}`,
9036
+ message_id: message.id,
9037
+ executor,
9038
+ terminal_control: terminalControl,
9039
+ delivery_receipt: terminalStatus,
9040
+ do_not_retry: acceptanceResult.outcome !== "agent_accepted"
9041
+ });
9042
+ }
9043
+ catch (error) {
9044
+ recordPostTransportBookkeepingFailure(terminalStatus, error);
9045
+ }
9046
+ if (bridge &&
9047
+ (acceptanceResult.outcome === "agent_accepted" ||
9048
+ acceptanceResult.outcome === "pending_acceptance")) {
9049
+ try {
9050
+ bridgeMonitor = startTerminalBridgeMonitorForConversation({
9051
+ conversation: deliveredConversation,
9052
+ statePath,
9053
+ logPath,
9054
+ options
9055
+ });
9056
+ if (bridgeMonitor) {
9057
+ appendEvent(logPath, {
9058
+ ts: new Date().toISOString(),
9059
+ conversation_id: deliveredConversation.conversation_id,
9060
+ event: "terminal_bridge_monitor_launch",
9061
+ pid: bridgeMonitor.pid ?? null,
9062
+ terminal_control: terminalControl,
9063
+ phase: acceptanceResult.outcome,
9064
+ agent_timeout_minutes: agentTimeoutMinutes,
9065
+ agent_hard_timeout_minutes: agentHardTimeoutMinutes
9066
+ });
9067
+ }
9068
+ }
9069
+ catch (error) {
9070
+ recordPostTransportBookkeepingFailure("monitor_launch", error);
9071
+ }
9072
+ }
7760
9073
  }
7761
9074
  catch (error) {
7762
9075
  const uncertainAt = new Date().toISOString();
7763
9076
  const errorMessage = error instanceof Error ? error.message : String(error);
7764
- const failureBase = !recordRawAttachmentAfterSend && terminalBridgeEnabled(conversation)
7765
- ? conversation
7766
- : preparedConversation;
9077
+ const failureBase = stagedConversation;
7767
9078
  const stalledFailureBase = {
7768
9079
  ...failureBase,
7769
9080
  status: "stalled",
@@ -7774,11 +9085,20 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
7774
9085
  const uncertainConversation = withTerminalBridgeSubmission({
7775
9086
  conversation: stalledFailureBase,
7776
9087
  messageId: message.id,
9088
+ messageType: message.type,
9089
+ messageBody: String(message.body),
7777
9090
  requestText: terminalPayload,
7778
9091
  status: "uncertain",
7779
9092
  preparedAt: bridgeStartedAt,
9093
+ textInjectedAt,
9094
+ enterDispatchedAt,
7780
9095
  uncertainAt,
7781
- error: errorMessage
9096
+ error: errorMessage,
9097
+ lastProvenStage: enterDispatchedAt
9098
+ ? "enter_dispatched"
9099
+ : textInjectedAt
9100
+ ? "text_injected"
9101
+ : "prepared"
7782
9102
  });
7783
9103
  try {
7784
9104
  saveTerminalBridgeDispatchLedger(terminalControl, {
@@ -7789,8 +9109,11 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
7789
9109
  session_id: sessionIdForConversation(uncertainConversation),
7790
9110
  turn_id: turnIdForConversation(uncertainConversation),
7791
9111
  message_id: message.id,
9112
+ message_type: message.type,
7792
9113
  request_hash: terminalRequestHash,
7793
9114
  prepared_at: bridgeStartedAt,
9115
+ ...(textInjectedAt ? { text_injected_at: textInjectedAt } : {}),
9116
+ ...(enterDispatchedAt ? { enter_dispatched_at: enterDispatchedAt } : {}),
7794
9117
  uncertain_at: uncertainAt,
7795
9118
  dispatcher_pid: process.pid,
7796
9119
  state_path: statePath,
@@ -7850,7 +9173,11 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
7850
9173
  executor,
7851
9174
  do_not_retry: true,
7852
9175
  stalled_conversation_ids: stalledConversationIds,
7853
- reason: "AKK durably recorded the terminal submission, but could not prove whether tmux accepted Enter. Do not retry automatically; inspect this conversation and pane.",
9176
+ reason: enterDispatchedAt
9177
+ ? "AKK dispatched Enter but native acceptance or its exact identity became uncertain. Do not retry automatically; inspect this conversation and pane."
9178
+ : textInjectedAt
9179
+ ? "AKK injected text but could not prove that Enter was dispatched. Do not retry automatically; inspect this conversation and pane."
9180
+ : "AKK could not prove that terminal input remained untouched. Inspect this conversation before retrying.",
7854
9181
  openclaw_next_action: {
7855
9182
  action: "inspect",
7856
9183
  conversation_id: uncertainConversation.conversation_id,
@@ -7862,80 +9189,493 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
7862
9189
  });
7863
9190
  return;
7864
9191
  }
7865
- let bookkeepingWarning;
7866
- try {
7867
- appendEvent(logPath, {
7868
- ts: new Date().toISOString(),
7869
- conversation_id: conversation.conversation_id,
7870
- event: "terminal_message_send",
7871
- executor,
7872
- terminal_control: terminalControl,
7873
- message: textSummary(message.body),
7874
- payload: textSummary(terminalPayload)
7875
- });
7876
- runtimeLog("info", "terminal_message_send", {
7877
- conversation_id: conversation.conversation_id,
7878
- agent: executor.kind,
7879
- terminal_target: terminalControl.target,
7880
- message: textSummary(message.body),
7881
- payload: textSummary(terminalPayload)
7882
- });
9192
+ const nativeAccepted = acceptanceResult?.outcome === "agent_accepted";
9193
+ const acceptancePending = acceptanceResult?.outcome === "pending_acceptance";
9194
+ try {
9195
+ appendEvent(logPath, {
9196
+ ts: new Date().toISOString(),
9197
+ conversation_id: conversation.conversation_id,
9198
+ event: "terminal_message_send",
9199
+ executor,
9200
+ terminal_control: terminalControl,
9201
+ message: textSummary(message.body),
9202
+ payload: textSummary(terminalPayload)
9203
+ });
9204
+ runtimeLog("info", "terminal_message_send", {
9205
+ conversation_id: conversation.conversation_id,
9206
+ agent: executor.kind,
9207
+ terminal_target: terminalControl.target,
9208
+ message: textSummary(message.body),
9209
+ payload: textSummary(terminalPayload)
9210
+ });
9211
+ }
9212
+ catch (error) {
9213
+ bookkeepingWarning =
9214
+ error instanceof Error ? error.message : String(error);
9215
+ runtimeLog("warn", "terminal_message_post_submit_bookkeeping_failed", {
9216
+ conversation_id: deliveredConversation.conversation_id,
9217
+ terminal_target: terminalControl.target,
9218
+ error: bookkeepingWarning,
9219
+ delivered: nativeAccepted
9220
+ });
9221
+ try {
9222
+ appendEvent(logPath, {
9223
+ ts: new Date().toISOString(),
9224
+ conversation_id: deliveredConversation.conversation_id,
9225
+ event: "terminal_message_post_submit_bookkeeping_failed",
9226
+ terminal_control: terminalControl,
9227
+ error: textSummary(bookkeepingWarning),
9228
+ delivered: nativeAccepted
9229
+ });
9230
+ }
9231
+ catch {
9232
+ // The durable submitted receipt remains authoritative even if the event log is unavailable.
9233
+ }
9234
+ }
9235
+ printJson({
9236
+ session_id: sessionIdForConversation(deliveredConversation),
9237
+ turn_id: turnIdForConversation(deliveredConversation),
9238
+ conversation: deliveredConversation,
9239
+ message,
9240
+ delivered: nativeAccepted,
9241
+ status: nativeAccepted
9242
+ ? "async_pending"
9243
+ : acceptancePending
9244
+ ? "submission_pending_acceptance"
9245
+ : acceptanceResult?.outcome === "not_accepted"
9246
+ ? "submission_not_accepted"
9247
+ : "submission_uncertain",
9248
+ submission_outcome: acceptanceResult?.outcome,
9249
+ background: true,
9250
+ callback_expected: Boolean(deliveredConversation.gateway_method &&
9251
+ (nativeAccepted || acceptancePending)),
9252
+ terminal_control: terminalControl,
9253
+ monitor_pid: bridgeMonitor?.pid ?? null,
9254
+ executor,
9255
+ budget: budgetAction(deliveredConversation),
9256
+ delivery_receipt: nativeAccepted
9257
+ ? "agent_accepted"
9258
+ : acceptancePending
9259
+ ? "enter_dispatched"
9260
+ : acceptanceResult?.outcome,
9261
+ ...(!nativeAccepted ? { do_not_retry: true } : {}),
9262
+ ...(bookkeepingWarning
9263
+ ? {
9264
+ bookkeeping_warning: textSummary(bookkeepingWarning)
9265
+ }
9266
+ : {}),
9267
+ openclaw_next_action: nativeAccepted
9268
+ ? openClawYieldNextAction({
9269
+ conversationId: deliveredConversation.conversation_id,
9270
+ sessionId: sessionIdForConversation(deliveredConversation),
9271
+ turnId: turnIdForConversation(deliveredConversation),
9272
+ source: "terminal_control",
9273
+ callbackExpected: Boolean(deliveredConversation.gateway_method)
9274
+ })
9275
+ : {
9276
+ action: acceptancePending ? "wait_for_acceptance" : "inspect",
9277
+ conversation_id: deliveredConversation.conversation_id,
9278
+ session_id: sessionIdForConversation(deliveredConversation),
9279
+ turn_id: turnIdForConversation(deliveredConversation),
9280
+ do_not_retry: true,
9281
+ reason: acceptancePending
9282
+ ? "Terminal transport is proven and the background monitor is still waiting for native acceptance."
9283
+ : acceptanceResult?.outcome === "not_accepted"
9284
+ ? "The exact draft is still present; inspect the composer and do not send a duplicate."
9285
+ : "Native acceptance evidence became uncertain; inspect the shared pane."
9286
+ }
9287
+ });
9288
+ }
9289
+ function terminalSubmissionPayload(payload) {
9290
+ assertOrdinaryTerminalPayloadDoesNotInvokeNativeLifecycle(payload);
9291
+ return payload.trimEnd();
9292
+ }
9293
+ function allowsSyntheticTerminalAcceptance(_options) {
9294
+ return process.env.AKK_TEST_ALLOW_SYNTHETIC_TERMINAL_ACCEPTANCE === "1";
9295
+ }
9296
+ function syntheticTerminalAcceptanceEvidence({ options, executor, conversation, requestHash }) {
9297
+ if (!allowsSyntheticTerminalAcceptance(options)) {
9298
+ return undefined;
9299
+ }
9300
+ const outcome = process.env.AKK_TEST_TERMINAL_ACCEPTANCE_OUTCOME ?? "accepted";
9301
+ if (outcome === "pending" || outcome === "not_accepted") {
9302
+ return undefined;
9303
+ }
9304
+ if (outcome === "identity_drift" || outcome === "uncertain") {
9305
+ throw new Error("injected terminal acceptance identity drift");
9306
+ }
9307
+ if (outcome !== "accepted") {
9308
+ throw new Error(`unsupported injected terminal acceptance outcome ${outcome}`);
9309
+ }
9310
+ const nativeTakeover = isRecord(conversation.native_session_takeover)
9311
+ ? conversation.native_session_takeover
9312
+ : undefined;
9313
+ const nativeThreadId = stringValue(nativeTakeover?.terminal_agent_session_id) ?? stringValue(nativeTakeover?.terminal_agent_expected_session_id) ??
9314
+ sessionIdForConversation(conversation);
9315
+ const evidenceBase = {
9316
+ source: executor === "codex"
9317
+ ? "codex_rollout"
9318
+ : "claude_transcript",
9319
+ kind: "native_user_turn",
9320
+ nativeThreadId,
9321
+ requestHash,
9322
+ acceptanceId: `test-${turnIdForConversation(conversation)}`,
9323
+ anchorFingerprint: createHash("sha256")
9324
+ .update(`test-anchor:${nativeThreadId}:${requestHash}`)
9325
+ .digest("hex")
9326
+ };
9327
+ return {
9328
+ ...evidenceBase,
9329
+ evidenceFingerprint: createHash("sha256")
9330
+ .update(JSON.stringify(evidenceBase))
9331
+ .digest("hex")
9332
+ };
9333
+ }
9334
+ function captureCodexAcceptanceAnchorForSend({ options, currentIdentity, expectedNativeThreadId, allowedPreMaterializationIdentity, needsPostSendNativeBinding }) {
9335
+ if (allowsSyntheticTerminalAcceptance(options)) {
9336
+ return undefined;
9337
+ }
9338
+ const nativeThreadId = needsPostSendNativeBinding
9339
+ ? expectedNativeThreadId
9340
+ : currentIdentity?.sessionId;
9341
+ const processUuid = currentIdentity?.processUuid ??
9342
+ allowedPreMaterializationIdentity?.processUuid;
9343
+ const processBirth = currentIdentity?.processBirth ??
9344
+ allowedPreMaterializationIdentity?.processBirth;
9345
+ if (!nativeThreadId || !processUuid || !processBirth) {
9346
+ throw new Error("Codex native acceptance anchor requires an exact thread and process incarnation");
9347
+ }
9348
+ const base = { nativeThreadId, processUuid, processBirth };
9349
+ if (!needsPostSendNativeBinding && currentIdentity?.rollout) {
9350
+ return captureCodexRolloutAcceptanceAnchor({
9351
+ ...base,
9352
+ mode: "existing",
9353
+ rollout: currentIdentity.rollout
9354
+ });
9355
+ }
9356
+ return captureCodexRolloutAcceptanceAnchor({
9357
+ ...base,
9358
+ mode: "pre_materialization",
9359
+ expectedEmptyNativeSession: true
9360
+ });
9361
+ }
9362
+ async function detectTerminalSubmissionAcceptance({ options, executor, conversation, terminalControl }) {
9363
+ const nativeTakeover = isRecord(conversation.native_session_takeover)
9364
+ ? conversation.native_session_takeover
9365
+ : undefined;
9366
+ const requestHash = stringValue(nativeTakeover?.terminal_bridge_request_hash);
9367
+ if (!requestHash) {
9368
+ throw new Error("terminal acceptance request hash is unavailable");
9369
+ }
9370
+ assertTurnBindingCurrent(conversation, "monitor");
9371
+ const synthetic = syntheticTerminalAcceptanceEvidence({
9372
+ options,
9373
+ executor,
9374
+ conversation,
9375
+ requestHash
9376
+ });
9377
+ if (synthetic) {
9378
+ return synthetic;
9379
+ }
9380
+ if (allowsSyntheticTerminalAcceptance(options)) {
9381
+ return undefined;
9382
+ }
9383
+ if (executor === "codex") {
9384
+ const anchor = nativeTakeover?.codex_rollout_acceptance_anchor;
9385
+ if (!isRecord(anchor)) {
9386
+ throw new Error("Codex rollout acceptance anchor is unavailable");
9387
+ }
9388
+ const pid = Number(nativeTakeover?.terminal_agent_pid);
9389
+ const currentIdentity = await resolveCurrentNativeAgentSessionIdentity({
9390
+ options,
9391
+ agent: "codex",
9392
+ pid,
9393
+ cwd: terminalControl.currentPath,
9394
+ preferredSessionId: stringValue(anchor.native_thread_id)
9395
+ });
9396
+ if (!currentIdentity) {
9397
+ return undefined;
9398
+ }
9399
+ return detectCodexRolloutAcceptance({
9400
+ anchor: anchor,
9401
+ currentIdentity,
9402
+ requestHash
9403
+ });
9404
+ }
9405
+ const claudeEvidence = detectClaudeTranscriptAcceptance(terminalDurableRequestForConversation(conversation, terminalControl), {
9406
+ claudeHome: expandHome(options.claudeHome),
9407
+ agentRows: loadClaudeAgentRows(options)
9408
+ });
9409
+ if (!claudeEvidence) {
9410
+ return undefined;
9411
+ }
9412
+ return claudeEvidence;
9413
+ }
9414
+ async function pollTerminalSubmissionAcceptance({ options, executor, conversation, terminalControl, terminalBridge }) {
9415
+ const timeoutMs = positiveMilliseconds(options.terminalAcceptanceTimeoutMs ??
9416
+ DEFAULT_TERMINAL_ACCEPTANCE_TIMEOUT_MS, "--terminal-acceptance-timeout-ms");
9417
+ const pollIntervalMs = Math.max(10, Math.min(timeoutMs, Number(options.terminalAcceptancePollIntervalMs ??
9418
+ DEFAULT_TERMINAL_ACCEPTANCE_POLL_INTERVAL_MS)));
9419
+ const deadline = Date.now() + timeoutMs;
9420
+ while (true) {
9421
+ try {
9422
+ const evidence = await detectTerminalSubmissionAcceptance({
9423
+ options,
9424
+ executor,
9425
+ conversation,
9426
+ terminalControl
9427
+ });
9428
+ if (evidence) {
9429
+ return { outcome: "agent_accepted", evidence };
9430
+ }
9431
+ }
9432
+ catch (error) {
9433
+ return {
9434
+ outcome: "uncertain",
9435
+ reason: error instanceof Error ? error.message : String(error)
9436
+ };
9437
+ }
9438
+ if (Date.now() >= deadline) {
9439
+ break;
9440
+ }
9441
+ await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
9442
+ }
9443
+ if (allowsSyntheticTerminalAcceptance(options) &&
9444
+ process.env.AKK_TEST_TERMINAL_ACCEPTANCE_OUTCOME === "not_accepted") {
9445
+ return {
9446
+ outcome: "not_accepted",
9447
+ reason: "the exact managed draft remains in the terminal composer"
9448
+ };
9449
+ }
9450
+ try {
9451
+ const requestText = String(isRecord(conversation.native_session_takeover)
9452
+ ? conversation.native_session_takeover.terminal_bridge_request_text ?? ""
9453
+ : "");
9454
+ if (await terminalBridge.proveExactDraftStillPresent(executor, terminalControl, requestText, {
9455
+ scrollbackLines: Number(options.scrollbackLines ?? 240),
9456
+ runtime: terminalRuntimeIdentityForConversation(conversation, terminalControl)
9457
+ })) {
9458
+ return {
9459
+ outcome: "not_accepted",
9460
+ reason: "the exact managed draft remains in the terminal composer"
9461
+ };
9462
+ }
9463
+ }
9464
+ catch {
9465
+ // Native acceptance evidence remains authoritative. A best-effort draft
9466
+ // inspection failure does not turn a bounded no-ACK into an identity claim.
9467
+ }
9468
+ return { outcome: "pending_acceptance" };
9469
+ }
9470
+ async function reconcileTerminalAcceptanceInMonitor({ options, conversation, statePath, logPath, terminalControl, executor, terminalBridge }) {
9471
+ const evidence = await detectTerminalSubmissionAcceptance({
9472
+ options,
9473
+ executor: executor.kind,
9474
+ conversation,
9475
+ terminalControl
9476
+ });
9477
+ const submission = terminalBridgeSubmission(conversation);
9478
+ const nativeTakeover = isRecord(conversation.native_session_takeover)
9479
+ ? conversation.native_session_takeover
9480
+ : undefined;
9481
+ const messageId = stringValue(submission?.message_id);
9482
+ if (!messageId) {
9483
+ throw new Error("terminal acceptance monitor lost its exact message identity");
9484
+ }
9485
+ let notAcceptedReason;
9486
+ if (!evidence) {
9487
+ const enterAt = validTimestampMs(submission?.enter_dispatched_at);
9488
+ if (enterAt !== undefined && Date.now() - enterAt >= 250) {
9489
+ const requestText = String(nativeTakeover?.terminal_bridge_request_text ?? "");
9490
+ if (await terminalBridge.proveExactDraftStillPresent(executor.kind, terminalControl, requestText, {
9491
+ scrollbackLines: Number(options.scrollbackLines ?? 240),
9492
+ runtime: terminalRuntimeIdentityForConversation(conversation, terminalControl)
9493
+ })) {
9494
+ notAcceptedReason =
9495
+ "the exact managed draft remains in the terminal composer";
9496
+ }
9497
+ }
9498
+ if (!notAcceptedReason) {
9499
+ return { outcome: "pending" };
9500
+ }
7883
9501
  }
7884
- catch (error) {
7885
- bookkeepingWarning =
7886
- error instanceof Error ? error.message : String(error);
7887
- runtimeLog("warn", "terminal_message_post_submit_bookkeeping_failed", {
7888
- conversation_id: deliveredConversation.conversation_id,
7889
- terminal_target: terminalControl.target,
7890
- error: bookkeepingWarning,
7891
- delivered: true
7892
- });
9502
+ const resolvedAt = new Date().toISOString();
9503
+ const writerStoreDir = pathsForConversationDir(path.dirname(statePath)).storeDir;
9504
+ return await withStoreWriterLeaseAsync(writerStoreDir, async () => {
9505
+ const releaseStateLock = acquireFileLock(`${statePath}.lock`);
9506
+ try {
9507
+ const current = loadState(statePath);
9508
+ const currentSubmission = terminalBridgeSubmission(current);
9509
+ const currentTakeover = isRecord(current.native_session_takeover)
9510
+ ? current.native_session_takeover
9511
+ : undefined;
9512
+ if (current.status !== conversation.status ||
9513
+ stringValue(currentTakeover?.terminal_bridge_message_id) !== messageId ||
9514
+ stringValue(currentSubmission?.message_id) !== messageId ||
9515
+ currentSubmission?.status !== "enter_dispatched") {
9516
+ throw new Error("terminal acceptance generation changed before monitor reconciliation");
9517
+ }
9518
+ const requestText = String(currentTakeover?.terminal_bridge_request_text ?? current.user_request ?? "");
9519
+ const nextBase = notAcceptedReason
9520
+ ? {
9521
+ ...current,
9522
+ status: "stalled",
9523
+ stalled_at: resolvedAt,
9524
+ stalled_reason: notAcceptedReason,
9525
+ updated_at: resolvedAt
9526
+ }
9527
+ : current;
9528
+ const acceptedConversation = withTerminalBridgeSubmission({
9529
+ conversation: nextBase,
9530
+ messageId,
9531
+ requestText,
9532
+ status: notAcceptedReason ? "not_accepted" : "agent_accepted",
9533
+ preparedAt: stringValue(currentSubmission.prepared_at) ?? resolvedAt,
9534
+ textInjectedAt: stringValue(currentSubmission.text_injected_at),
9535
+ enterDispatchedAt: stringValue(currentSubmission.enter_dispatched_at),
9536
+ ...(notAcceptedReason
9537
+ ? { notAcceptedAt: resolvedAt }
9538
+ : {
9539
+ agentAcceptedAt: resolvedAt,
9540
+ acceptanceEvidence: evidence
9541
+ })
9542
+ });
9543
+ const ledger = reconcilePreparedTerminalDispatchLedger(terminalControl, loadTerminalBridgeDispatchLedger(terminalControl));
9544
+ if (stringValue(ledger?.message_id) !== messageId ||
9545
+ ledger?.status !== "enter_dispatched") {
9546
+ throw new Error("terminal dispatch ledger changed before acceptance reconciliation");
9547
+ }
9548
+ saveState(statePath, acceptedConversation);
9549
+ try {
9550
+ if (process.env.AKK_TEST_MONITOR_FINAL_TERMINAL_LEDGER_FAILURE === "1") {
9551
+ throw new Error("injected monitor final terminal ledger persistence failure");
9552
+ }
9553
+ saveTerminalBridgeDispatchLedger(terminalControl, {
9554
+ ...ledger,
9555
+ ...terminalBindingLedgerFields(acceptedConversation),
9556
+ status: notAcceptedReason ? "not_accepted" : "agent_accepted",
9557
+ ...(notAcceptedReason
9558
+ ? { not_accepted_at: resolvedAt }
9559
+ : {
9560
+ agent_accepted_at: resolvedAt,
9561
+ acceptance_evidence: evidence
9562
+ }),
9563
+ dispatcher_pid: null
9564
+ });
9565
+ }
9566
+ catch (error) {
9567
+ runtimeLog("warn", "terminal_acceptance_monitor_ledger_lagging", {
9568
+ conversation_id: acceptedConversation.conversation_id,
9569
+ message_id: messageId,
9570
+ terminal_target: terminalControl.target,
9571
+ durable_submission_status: notAcceptedReason
9572
+ ? "not_accepted"
9573
+ : "agent_accepted",
9574
+ error: error instanceof Error ? error.message : String(error)
9575
+ });
9576
+ }
9577
+ try {
9578
+ if (process.env.AKK_TEST_MONITOR_FINAL_EVENT_FAILURE === "1") {
9579
+ throw new Error("injected monitor final acceptance event persistence failure");
9580
+ }
9581
+ appendEvent(logPath, {
9582
+ ts: resolvedAt,
9583
+ conversation_id: acceptedConversation.conversation_id,
9584
+ event: notAcceptedReason
9585
+ ? "terminal_message_not_accepted"
9586
+ : "terminal_message_agent_accepted",
9587
+ message_id: messageId,
9588
+ terminal_control: terminalControl,
9589
+ do_not_retry: Boolean(notAcceptedReason)
9590
+ });
9591
+ }
9592
+ catch (error) {
9593
+ runtimeLog("warn", "terminal_acceptance_monitor_event_lagging", {
9594
+ conversation_id: acceptedConversation.conversation_id,
9595
+ message_id: messageId,
9596
+ terminal_target: terminalControl.target,
9597
+ durable_submission_status: notAcceptedReason
9598
+ ? "not_accepted"
9599
+ : "agent_accepted",
9600
+ error: error instanceof Error ? error.message : String(error)
9601
+ });
9602
+ }
9603
+ return notAcceptedReason
9604
+ ? { outcome: "not_accepted", conversation: acceptedConversation }
9605
+ : { outcome: "accepted", conversation: acceptedConversation };
9606
+ }
9607
+ finally {
9608
+ releaseStateLock();
9609
+ }
9610
+ });
9611
+ }
9612
+ function markTerminalAcceptanceUncertain({ conversation, statePath, logPath, terminalControl, reason }) {
9613
+ const messageId = stringValue(terminalBridgeSubmission(conversation)?.message_id);
9614
+ const uncertainAt = new Date().toISOString();
9615
+ const writerStoreDir = pathsForConversationDir(path.dirname(statePath)).storeDir;
9616
+ return withStoreWriterLease(writerStoreDir, () => {
9617
+ const releaseStateLock = acquireFileLock(`${statePath}.lock`);
7893
9618
  try {
9619
+ const current = loadState(statePath);
9620
+ const submission = terminalBridgeSubmission(current);
9621
+ const currentMessageId = stringValue(submission?.message_id);
9622
+ if (!messageId ||
9623
+ currentMessageId !== messageId ||
9624
+ !["text_injected", "enter_dispatched"].includes(String(submission?.status))) {
9625
+ return current;
9626
+ }
9627
+ const takeover = isRecord(current.native_session_takeover)
9628
+ ? current.native_session_takeover
9629
+ : undefined;
9630
+ const requestText = String(takeover?.terminal_bridge_request_text ?? current.user_request ?? "");
9631
+ const uncertain = withTerminalBridgeSubmission({
9632
+ conversation: {
9633
+ ...current,
9634
+ status: "stalled",
9635
+ stalled_at: uncertainAt,
9636
+ stalled_reason: reason,
9637
+ updated_at: uncertainAt
9638
+ },
9639
+ messageId,
9640
+ requestText,
9641
+ status: "uncertain",
9642
+ preparedAt: stringValue(submission?.prepared_at) ?? uncertainAt,
9643
+ textInjectedAt: stringValue(submission?.text_injected_at),
9644
+ enterDispatchedAt: stringValue(submission?.enter_dispatched_at),
9645
+ uncertainAt,
9646
+ error: reason,
9647
+ lastProvenStage: submission?.status === "enter_dispatched"
9648
+ ? "enter_dispatched"
9649
+ : "text_injected"
9650
+ });
9651
+ const ledger = loadTerminalBridgeDispatchLedger(terminalControl);
9652
+ if (stringValue(ledger?.message_id) === messageId &&
9653
+ ["text_injected", "enter_dispatched"].includes(String(ledger?.status))) {
9654
+ saveTerminalBridgeDispatchLedger(terminalControl, {
9655
+ ...ledger,
9656
+ status: "uncertain",
9657
+ uncertain_at: uncertainAt,
9658
+ error: textSummary(reason),
9659
+ dispatcher_pid: null
9660
+ });
9661
+ }
9662
+ saveState(statePath, uncertain);
7894
9663
  appendEvent(logPath, {
7895
- ts: new Date().toISOString(),
7896
- conversation_id: deliveredConversation.conversation_id,
7897
- event: "terminal_message_post_submit_bookkeeping_failed",
9664
+ ts: uncertainAt,
9665
+ conversation_id: uncertain.conversation_id,
9666
+ event: "terminal_message_acceptance_uncertain",
9667
+ message_id: messageId,
7898
9668
  terminal_control: terminalControl,
7899
- error: textSummary(bookkeepingWarning),
7900
- delivered: true
9669
+ error: textSummary(reason),
9670
+ do_not_retry: true
7901
9671
  });
9672
+ return uncertain;
7902
9673
  }
7903
- catch {
7904
- // The durable submitted receipt remains authoritative even if the event log is unavailable.
9674
+ finally {
9675
+ releaseStateLock();
7905
9676
  }
7906
- }
7907
- printJson({
7908
- session_id: sessionIdForConversation(deliveredConversation),
7909
- turn_id: turnIdForConversation(deliveredConversation),
7910
- conversation: deliveredConversation,
7911
- message,
7912
- delivered: true,
7913
- status: "async_pending",
7914
- background: true,
7915
- callback_expected: Boolean(deliveredConversation.gateway_method),
7916
- terminal_control: terminalControl,
7917
- monitor_pid: bridgeMonitor?.pid ?? null,
7918
- executor,
7919
- budget: budgetAction(deliveredConversation),
7920
- delivery_receipt: "submitted",
7921
- ...(bookkeepingWarning
7922
- ? {
7923
- bookkeeping_warning: textSummary(bookkeepingWarning)
7924
- }
7925
- : {}),
7926
- openclaw_next_action: openClawYieldNextAction({
7927
- conversationId: deliveredConversation.conversation_id,
7928
- sessionId: sessionIdForConversation(deliveredConversation),
7929
- turnId: turnIdForConversation(deliveredConversation),
7930
- source: "terminal_control",
7931
- callbackExpected: Boolean(deliveredConversation.gateway_method)
7932
- })
7933
9677
  });
7934
9678
  }
7935
- function terminalSubmissionPayload(payload) {
7936
- assertOrdinaryTerminalPayloadDoesNotInvokeNativeLifecycle(payload);
7937
- return payload.trimEnd();
7938
- }
7939
9679
  function assertOrdinaryTerminalPayloadDoesNotInvokeNativeLifecycle(payload) {
7940
9680
  const firstEffectiveLine = String(payload)
7941
9681
  .split(/\r\n?|\n/u)
@@ -8221,7 +9961,7 @@ function assertTurnBindingCurrent(conversation, operation) {
8221
9961
  turnNativeThreadId
8222
9962
  }));
8223
9963
  if (!exactModernBinding && !compatibleMigratedBinding) {
8224
- throw new Error(`cannot ${operation} Turn ${turnIdForConversation(conversation)}: its ` +
9964
+ throw new TurnBindingSupersededError(`cannot ${operation} Turn ${turnIdForConversation(conversation)}: its ` +
8225
9965
  `Session binding generation is no longer current`);
8226
9966
  }
8227
9967
  }
@@ -8608,6 +10348,7 @@ function createManagedTerminalTurn({ options, conversationId, agent, pid, messag
8608
10348
  }, paths);
8609
10349
  const message = createMessage({
8610
10350
  conversation: attachedConversation,
10351
+ id: stringValue(options.messageId),
8611
10352
  from: "openclaw",
8612
10353
  to: executor.actor,
8613
10354
  type: options.type ?? "task",
@@ -8789,9 +10530,11 @@ async function runRenew(options) {
8789
10530
  });
8790
10531
  }
8791
10532
  async function runReconcileMonitors(options) {
10533
+ const reason = stringValue(options.reason) ?? "startup_reconciliation";
8792
10534
  printJson(await reconcileMonitors(options, {
8793
- includeCallbackRecovery: true,
8794
- reason: "startup_reconciliation",
10535
+ includeCallbackRecovery: options.terminalMonitorsOnly !== true &&
10536
+ reason !== "monitor_supervision",
10537
+ reason,
8795
10538
  conversationId: undefined
8796
10539
  }));
8797
10540
  }
@@ -8877,6 +10620,9 @@ async function reconcileMonitors(options, { includeCallbackRecovery, reason, con
8877
10620
  });
8878
10621
  continue;
8879
10622
  }
10623
+ const previousMonitorPid = latestTerminalBridgeMonitorLaunchPid(logPath);
10624
+ const unexpectedMonitorExit = previousMonitorPid !== undefined &&
10625
+ !isProcessAlive(previousMonitorPid);
8880
10626
  const activeOwner = activeTerminalBridgeMonitorOwner(statePath, initialEligibility.terminalMessageId);
8881
10627
  if (activeOwner) {
8882
10628
  alreadyRunning += 1;
@@ -8961,13 +10707,33 @@ async function reconcileMonitors(options, { includeCallbackRecovery, reason, con
8961
10707
  continue;
8962
10708
  }
8963
10709
  const launchedAt = new Date().toISOString();
10710
+ const launchReason = unexpectedMonitorExit
10711
+ ? "unexpected_exit_recovery"
10712
+ : reason;
10713
+ if (unexpectedMonitorExit) {
10714
+ appendEvent(logPath, {
10715
+ ts: launchedAt,
10716
+ conversation_id: prepared.conversation.conversation_id,
10717
+ event: "terminal_bridge_monitor_exit_observed",
10718
+ previous_monitor_pid: previousMonitorPid,
10719
+ terminal_control: prepared.terminalControl,
10720
+ reason: "monitor_owner_process_missing",
10721
+ observed_by: reason
10722
+ });
10723
+ runtimeLog("warn", "terminal_bridge_monitor_exit_observed", {
10724
+ conversation_id: prepared.conversation.conversation_id,
10725
+ previous_monitor_pid: previousMonitorPid,
10726
+ terminal_target: prepared.terminalControl.target,
10727
+ observed_by: reason
10728
+ });
10729
+ }
8964
10730
  appendEvent(logPath, {
8965
10731
  ts: launchedAt,
8966
10732
  conversation_id: prepared.conversation.conversation_id,
8967
10733
  event: "terminal_bridge_monitor_launch",
8968
10734
  pid: monitor.pid ?? null,
8969
10735
  terminal_control: prepared.terminalControl,
8970
- reason,
10736
+ reason: launchReason,
8971
10737
  agent_timeout_minutes: prepared.inactivityTimeoutMinutes,
8972
10738
  agent_hard_timeout_minutes: prepared.hardTimeoutMinutes
8973
10739
  });
@@ -8980,11 +10746,23 @@ async function reconcileMonitors(options, { includeCallbackRecovery, reason, con
8980
10746
  items.push({
8981
10747
  conversation_id: prepared.conversation.conversation_id,
8982
10748
  status: "launched",
8983
- reason,
8984
- monitor_pid: monitor.pid ?? null
10749
+ reason: launchReason,
10750
+ monitor_pid: monitor.pid ?? null,
10751
+ ...(unexpectedMonitorExit
10752
+ ? { previous_monitor_pid: previousMonitorPid }
10753
+ : {})
8985
10754
  });
8986
10755
  }
8987
10756
  catch (error) {
10757
+ if (error instanceof TurnBindingSupersededError) {
10758
+ skipped += 1;
10759
+ items.push({
10760
+ conversation_id: listedConversation.conversation_id,
10761
+ status: "skipped",
10762
+ reason: "session_binding_superseded"
10763
+ });
10764
+ continue;
10765
+ }
8988
10766
  errors += 1;
8989
10767
  items.push({
8990
10768
  conversation_id: listedConversation.conversation_id,
@@ -9153,15 +10931,22 @@ function terminalBridgeReconciliationEligibility(conversation) {
9153
10931
  const dispatchLedger = loadTerminalBridgeDispatchLedger(terminalControl);
9154
10932
  if (dispatchLedger &&
9155
10933
  (stringValue(dispatchLedger.message_id) !== terminalMessageId ||
9156
- !["prepared", "submitted"].includes(String(dispatchLedger.status)))) {
10934
+ ![
10935
+ "prepared",
10936
+ "text_injected",
10937
+ "enter_dispatched",
10938
+ "agent_accepted",
10939
+ "submitted"
10940
+ ].includes(String(dispatchLedger.status)))) {
9157
10941
  return {
9158
10942
  eligible: false,
9159
10943
  reason: `terminal_dispatch_${String(dispatchLedger.status ?? "generation_replaced")}`
9160
10944
  };
9161
10945
  }
9162
10946
  const submission = terminalBridgeSubmission(conversation);
9163
- if (stringValue(submission?.message_id) === terminalMessageId &&
9164
- (submission?.status === "uncertain" || submission?.status === "aborted")) {
10947
+ if (submission &&
10948
+ stringValue(submission.message_id) === terminalMessageId &&
10949
+ ["not_accepted", "uncertain", "aborted"].includes(String(submission.status))) {
9165
10950
  return {
9166
10951
  eligible: false,
9167
10952
  reason: `terminal_submission_${submission.status}`
@@ -9207,8 +10992,11 @@ function latestTerminalBridgeMonitorLaunchPid(logPath) {
9207
10992
  catch {
9208
10993
  return undefined;
9209
10994
  }
9210
- const launch = [...events].reverse().find((event) => event.event === "terminal_bridge_monitor_launch");
9211
- const pid = Number(launch?.pid);
10995
+ const ownership = [...events].reverse().find((event) => event.event === "terminal_bridge_monitor_launch" ||
10996
+ event.event === "terminal_bridge_monitor_started");
10997
+ const pid = Number(ownership?.event === "terminal_bridge_monitor_started"
10998
+ ? ownership.monitor_pid
10999
+ : ownership?.pid);
9212
11000
  return Number.isSafeInteger(pid) && pid > 1 ? pid : undefined;
9213
11001
  }
9214
11002
  function prepareTerminalBridgeMonitorReconciliation({ statePath, expectedMessageId, requireWaitingForAgentStatus = false }) {
@@ -9544,8 +11332,12 @@ async function runTerminalDispatchClose({ options, terminalConversation }) {
9544
11332
  }
9545
11333
  if (![
9546
11334
  "prepared",
11335
+ "text_injected",
11336
+ "enter_dispatched",
9547
11337
  "dispatching",
11338
+ "agent_accepted",
9548
11339
  "submitted",
11340
+ "not_accepted",
9549
11341
  "uncertain",
9550
11342
  "verified"
9551
11343
  ].includes(ledger.status)) {
@@ -9927,6 +11719,7 @@ function runTerminalBridgeMonitorHandoff(options) {
9927
11719
  }
9928
11720
  async function runTerminalBridgeMonitor(options) {
9929
11721
  const statePath = expandHome(required(options.state, "--state is required"));
11722
+ const logPath = expandHome(options.log ?? logPathForStatePath(statePath));
9930
11723
  const conversation = loadState(statePath);
9931
11724
  const nativeTakeover = isRecord(conversation.native_session_takeover)
9932
11725
  ? conversation.native_session_takeover
@@ -9949,14 +11742,81 @@ async function runTerminalBridgeMonitor(options) {
9949
11742
  });
9950
11743
  return;
9951
11744
  }
11745
+ const lifecycle = { startedRecorded: false };
11746
+ let storeDeferredAttempts = 0;
11747
+ let storeFirstDeferredAt;
9952
11748
  try {
9953
- await runTerminalBridgeMonitorWithLock(options);
11749
+ while (true) {
11750
+ try {
11751
+ if (storeDeferredAttempts > 0) {
11752
+ const resumedAt = new Date().toISOString();
11753
+ const resumedConversation = loadState(statePath);
11754
+ const resumedTakeover = isRecord(resumedConversation.native_session_takeover)
11755
+ ? resumedConversation.native_session_takeover
11756
+ : undefined;
11757
+ if (stringValue(resumedTakeover?.terminal_bridge_message_id) !==
11758
+ terminalMessageId) {
11759
+ runtimeLog("info", "terminal_bridge_monitor_finished", {
11760
+ conversation_id: resumedConversation.conversation_id,
11761
+ terminal_bridge_message_id: terminalMessageId,
11762
+ reason: "terminal_bridge_generation_replaced_during_store_deferral"
11763
+ });
11764
+ printJson({
11765
+ conversation: resumedConversation,
11766
+ monitored: true,
11767
+ terminal_bridge: true,
11768
+ completed: false,
11769
+ reason: "terminal_bridge_generation_replaced"
11770
+ });
11771
+ return;
11772
+ }
11773
+ appendEvent(logPath, {
11774
+ ts: resumedAt,
11775
+ conversation_id: resumedConversation.conversation_id,
11776
+ event: "terminal_bridge_monitor_store_operation_deferred",
11777
+ terminal_bridge_message_id: terminalMessageId,
11778
+ error_code: "AKK_STORE_LOCK_TIMEOUT",
11779
+ first_deferred_at: storeFirstDeferredAt,
11780
+ resumed_at: resumedAt,
11781
+ attempts: storeDeferredAttempts,
11782
+ outcome: "resumed"
11783
+ });
11784
+ runtimeLog("info", "terminal_bridge_monitor_store_operation_resumed", {
11785
+ conversation_id: resumedConversation.conversation_id,
11786
+ terminal_bridge_message_id: terminalMessageId,
11787
+ attempts: storeDeferredAttempts,
11788
+ first_deferred_at: storeFirstDeferredAt
11789
+ });
11790
+ storeDeferredAttempts = 0;
11791
+ storeFirstDeferredAt = undefined;
11792
+ }
11793
+ await runTerminalBridgeMonitorWithLock(options, lifecycle, terminalMessageId);
11794
+ return;
11795
+ }
11796
+ catch (error) {
11797
+ if (!(error instanceof StoreLockTimeoutError)) {
11798
+ throw error;
11799
+ }
11800
+ storeDeferredAttempts += 1;
11801
+ storeFirstDeferredAt ??= new Date().toISOString();
11802
+ const retryInMs = Math.min(5_000, 250 * (2 ** Math.min(5, storeDeferredAttempts - 1)));
11803
+ runtimeLog("warn", "terminal_bridge_monitor_store_operation_deferred", {
11804
+ conversation_id: conversation.conversation_id,
11805
+ terminal_bridge_message_id: terminalMessageId,
11806
+ error_code: error.code,
11807
+ lock_kind: error.lockKind,
11808
+ attempt: storeDeferredAttempts,
11809
+ retry_in_ms: retryInMs
11810
+ });
11811
+ sleepSync(retryInMs);
11812
+ }
11813
+ }
9954
11814
  }
9955
11815
  finally {
9956
11816
  monitorLock.release();
9957
11817
  }
9958
11818
  }
9959
- async function runTerminalBridgeMonitorWithLock(options) {
11819
+ async function runTerminalBridgeMonitorWithLock(options, lifecycle, expectedTerminalMessageId) {
9960
11820
  const statePath = expandHome(required(options.state, "--state is required"));
9961
11821
  const logPath = expandHome(options.log ?? logPathForStatePath(statePath));
9962
11822
  const pollIntervalMs = Math.max(50, Number(options.pollIntervalMs ?? DEFAULT_MONITOR_POLL_INTERVAL_MS));
@@ -9969,6 +11829,22 @@ async function runTerminalBridgeMonitorWithLock(options) {
9969
11829
  const initialNativeTakeover = isRecord(conversation.native_session_takeover)
9970
11830
  ? conversation.native_session_takeover
9971
11831
  : undefined;
11832
+ if (stringValue(initialNativeTakeover?.terminal_bridge_message_id) !==
11833
+ expectedTerminalMessageId) {
11834
+ runtimeLog("info", "terminal_bridge_monitor_finished", {
11835
+ conversation_id: conversation.conversation_id,
11836
+ terminal_bridge_message_id: expectedTerminalMessageId,
11837
+ reason: "terminal_bridge_generation_replaced_before_monitor_restart"
11838
+ });
11839
+ printJson({
11840
+ conversation,
11841
+ monitored: true,
11842
+ terminal_bridge: true,
11843
+ completed: false,
11844
+ reason: "terminal_bridge_generation_replaced"
11845
+ });
11846
+ return;
11847
+ }
9972
11848
  const timeoutMinutes = Number(options.agentTimeoutMinutes ??
9973
11849
  initialNativeTakeover?.["terminal_bridge_inactivity_timeout_minutes"] ??
9974
11850
  DEFAULT_AGENT_TIMEOUT_MINUTES);
@@ -9976,7 +11852,7 @@ async function runTerminalBridgeMonitorWithLock(options) {
9976
11852
  initialNativeTakeover?.["terminal_bridge_hard_timeout_minutes"] ??
9977
11853
  DEFAULT_AGENT_HARD_TIMEOUT_MINUTES, "--agent-hard-timeout-minutes");
9978
11854
  const monitorStartedAtMs = Date.now();
9979
- const monitorMessageId = stringValue(initialNativeTakeover?.["terminal_bridge_message_id"]);
11855
+ const monitorMessageId = expectedTerminalMessageId;
9980
11856
  const taskStartedAtMs = validTimestampMs(initialNativeTakeover?.["terminal_bridge_started_at"]) ?? monitorStartedAtMs;
9981
11857
  let lastActivityAtMs = validTimestampMs(initialNativeTakeover?.["terminal_bridge_last_activity_at"]) ?? taskStartedAtMs;
9982
11858
  let lastPersistedActivityAtMs = lastActivityAtMs;
@@ -9985,33 +11861,45 @@ async function runTerminalBridgeMonitorWithLock(options) {
9985
11861
  let previousScreenFingerprint = preSendScreenFingerprint;
9986
11862
  let previousDurableFingerprint;
9987
11863
  let persistedActivityReason = stringValue(initialNativeTakeover?.["terminal_bridge_last_activity_reason"]);
11864
+ const initialDetectorDiagnostic = isRecord(initialNativeTakeover?.["terminal_bridge_detector_diagnostic"])
11865
+ ? initialNativeTakeover.terminal_bridge_detector_diagnostic
11866
+ : undefined;
11867
+ let persistedDetectorDiagnosticFingerprint = stringValue(initialDetectorDiagnostic?.fingerprint);
11868
+ let persistedDetectorDiagnosticStatus = stringValue(initialDetectorDiagnostic?.status);
9988
11869
  const executor = executorForConversation(conversation);
9989
11870
  const terminalBridge = createTerminalAgentBridge(options);
9990
- appendEvent(logPath, {
9991
- ts: new Date().toISOString(),
9992
- conversation_id: conversation.conversation_id,
9993
- event: "terminal_bridge_monitor_started",
9994
- executor,
9995
- agent_timeout_minutes: timeoutMinutes,
9996
- agent_hard_timeout_minutes: hardTimeoutMinutes,
9997
- poll_interval_ms: pollIntervalMs,
9998
- task_started_at: new Date(taskStartedAtMs).toISOString(),
9999
- last_activity_at: new Date(lastActivityAtMs).toISOString(),
10000
- inactivity_deadline_at: timeoutMinutes > 0
10001
- ? new Date(lastActivityAtMs + timeoutMinutes * 60 * 1000).toISOString()
10002
- : null,
10003
- hard_deadline_at: hardTimeoutMinutes > 0
10004
- ? new Date(taskStartedAtMs + hardTimeoutMinutes * 60 * 1000).toISOString()
10005
- : null
10006
- });
10007
- runtimeLog("info", "terminal_bridge_monitor_started", {
10008
- conversation_id: conversation.conversation_id,
10009
- agent: executor.kind,
10010
- executor_session: executor.session,
10011
- agent_timeout_minutes: timeoutMinutes,
10012
- agent_hard_timeout_minutes: hardTimeoutMinutes
10013
- });
11871
+ if (!lifecycle.startedRecorded) {
11872
+ appendEvent(logPath, {
11873
+ ts: new Date().toISOString(),
11874
+ conversation_id: conversation.conversation_id,
11875
+ event: "terminal_bridge_monitor_started",
11876
+ monitor_pid: process.pid,
11877
+ executor,
11878
+ agent_timeout_minutes: timeoutMinutes,
11879
+ agent_hard_timeout_minutes: hardTimeoutMinutes,
11880
+ poll_interval_ms: pollIntervalMs,
11881
+ task_started_at: new Date(taskStartedAtMs).toISOString(),
11882
+ last_activity_at: new Date(lastActivityAtMs).toISOString(),
11883
+ inactivity_deadline_at: timeoutMinutes > 0
11884
+ ? new Date(lastActivityAtMs + timeoutMinutes * 60 * 1000).toISOString()
11885
+ : null,
11886
+ hard_deadline_at: hardTimeoutMinutes > 0
11887
+ ? new Date(taskStartedAtMs + hardTimeoutMinutes * 60 * 1000).toISOString()
11888
+ : null
11889
+ });
11890
+ runtimeLog("info", "terminal_bridge_monitor_started", {
11891
+ conversation_id: conversation.conversation_id,
11892
+ monitor_pid: process.pid,
11893
+ agent: executor.kind,
11894
+ executor_session: executor.session,
11895
+ agent_timeout_minutes: timeoutMinutes,
11896
+ agent_hard_timeout_minutes: hardTimeoutMinutes
11897
+ });
11898
+ lifecycle.startedRecorded = true;
11899
+ }
10014
11900
  let idleCompletionFingerprint;
11901
+ let bindingCheckDeferredAttempts = 0;
11902
+ let bindingCheckFirstDeferredAt;
10015
11903
  while (true) {
10016
11904
  conversation = loadState(statePath);
10017
11905
  if (!isWaitingForAgent(conversation.status)) {
@@ -10074,6 +11962,91 @@ async function runTerminalBridgeMonitorWithLock(options) {
10074
11962
  submission &&
10075
11963
  stringValue(submission.message_id) === currentMessageId) {
10076
11964
  const submissionStatus = stringValue(submission.status);
11965
+ if (submissionStatus === "text_injected" ||
11966
+ submissionStatus === "enter_dispatched") {
11967
+ const dispatcherPid = Number(submission.dispatcher_pid);
11968
+ if (Number.isSafeInteger(dispatcherPid) &&
11969
+ dispatcherPid > 1 &&
11970
+ isProcessAlive(dispatcherPid)) {
11971
+ sleepSync(pollIntervalMs);
11972
+ continue;
11973
+ }
11974
+ if (submissionStatus === "text_injected") {
11975
+ conversation = markTerminalAcceptanceUncertain({
11976
+ conversation,
11977
+ statePath,
11978
+ logPath,
11979
+ terminalControl,
11980
+ reason: "terminal dispatcher exited after text injection but before Enter dispatch was durably proven"
11981
+ });
11982
+ printJson({
11983
+ conversation,
11984
+ monitored: true,
11985
+ terminal_bridge: true,
11986
+ completed: false,
11987
+ submission_outcome: "uncertain",
11988
+ delivery_receipt: "text_injected",
11989
+ do_not_retry: true,
11990
+ reason: "text was injected but native submission was not proven; inspect the composer"
11991
+ });
11992
+ return;
11993
+ }
11994
+ const releaseTerminalLock = acquireFileLock(terminalBridgeSendLockPath(storeDirFromOptions(options), terminalControl), { timeoutMs: 30000 });
11995
+ try {
11996
+ const reconciliation = await reconcileTerminalAcceptanceInMonitor({
11997
+ options,
11998
+ conversation,
11999
+ statePath,
12000
+ logPath,
12001
+ terminalControl,
12002
+ executor,
12003
+ terminalBridge
12004
+ });
12005
+ if (reconciliation.outcome === "accepted") {
12006
+ conversation = reconciliation.conversation;
12007
+ continue;
12008
+ }
12009
+ if (reconciliation.outcome === "not_accepted") {
12010
+ printJson({
12011
+ conversation: reconciliation.conversation,
12012
+ monitored: true,
12013
+ terminal_bridge: true,
12014
+ completed: false,
12015
+ submission_outcome: "not_accepted",
12016
+ delivery_receipt: "enter_dispatched",
12017
+ do_not_retry: true,
12018
+ reason: "the exact managed draft remains in the terminal composer"
12019
+ });
12020
+ return;
12021
+ }
12022
+ }
12023
+ catch (error) {
12024
+ const reason = error instanceof Error ? error.message : String(error);
12025
+ conversation = markTerminalAcceptanceUncertain({
12026
+ conversation,
12027
+ statePath,
12028
+ logPath,
12029
+ terminalControl,
12030
+ reason
12031
+ });
12032
+ printJson({
12033
+ conversation,
12034
+ monitored: true,
12035
+ terminal_bridge: true,
12036
+ completed: false,
12037
+ submission_outcome: "uncertain",
12038
+ delivery_receipt: "enter_dispatched",
12039
+ do_not_retry: true,
12040
+ reason
12041
+ });
12042
+ return;
12043
+ }
12044
+ finally {
12045
+ releaseTerminalLock();
12046
+ }
12047
+ sleepSync(pollIntervalMs);
12048
+ continue;
12049
+ }
10077
12050
  if (submissionStatus === "prepared") {
10078
12051
  const dispatcherPid = Number(submission.dispatcher_pid);
10079
12052
  if (Number.isSafeInteger(dispatcherPid) &&
@@ -10084,115 +12057,128 @@ async function runTerminalBridgeMonitorWithLock(options) {
10084
12057
  }
10085
12058
  const releaseTerminalLock = acquireFileLock(terminalBridgeSendLockPath(storeDirFromOptions(options), terminalControl), { timeoutMs: 30000 });
10086
12059
  try {
10087
- const dispatchLedger = loadTerminalBridgeDispatchLedger(terminalControl);
10088
- const releaseStateLock = acquireFileLock(`${statePath}.lock`);
10089
- try {
10090
- const current = loadState(statePath);
10091
- const currentSubmission = terminalBridgeSubmission(current);
10092
- const currentTakeover = isRecord(current.native_session_takeover)
10093
- ? current.native_session_takeover
10094
- : undefined;
10095
- const expectedMessageId = stringValue(currentTakeover?.terminal_bridge_message_id);
10096
- if (expectedMessageId === currentMessageId &&
10097
- stringValue(currentSubmission?.message_id) === currentMessageId &&
10098
- currentSubmission?.status === "prepared") {
10099
- const requestText = String(currentTakeover?.terminal_bridge_request_text ??
10100
- current.user_request ??
10101
- "");
10102
- if (dispatchLedger?.status === "submitted" &&
10103
- stringValue(dispatchLedger.message_id) === currentMessageId) {
10104
- const submittedAt = stringValue(dispatchLedger.submitted_at) ??
10105
- new Date().toISOString();
10106
- const submittedConversation = withTerminalBridgeSubmission({
10107
- conversation: current,
10108
- messageId: currentMessageId,
10109
- requestText,
10110
- status: "submitted",
10111
- preparedAt: stringValue(currentSubmission.prepared_at) ??
10112
- submittedAt,
10113
- submittedAt
10114
- });
10115
- saveState(statePath, submittedConversation);
10116
- conversation = submittedConversation;
10117
- }
10118
- else {
10119
- const uncertainAt = new Date().toISOString();
10120
- const uncertainConversation = withTerminalBridgeSubmission({
10121
- conversation: {
10122
- ...current,
10123
- status: "stalled",
10124
- stalled_at: uncertainAt,
10125
- stalled_reason: "terminal dispatcher exited before AKK could prove the tmux submission",
10126
- updated_at: uncertainAt
10127
- },
10128
- messageId: currentMessageId,
10129
- requestText,
10130
- status: "uncertain",
10131
- preparedAt: stringValue(currentSubmission.prepared_at) ??
10132
- uncertainAt,
10133
- uncertainAt,
10134
- error: "the terminal dispatcher exited before AKK could persist a submitted receipt"
10135
- });
10136
- if (!dispatchLedger ||
12060
+ const writerStoreDir = pathsForConversationDir(path.dirname(statePath)).storeDir;
12061
+ await withStoreWriterLeaseAsync(writerStoreDir, async () => {
12062
+ const dispatchLedger = loadTerminalBridgeDispatchLedger(terminalControl);
12063
+ const releaseStateLock = acquireFileLock(`${statePath}.lock`);
12064
+ try {
12065
+ const current = loadState(statePath);
12066
+ const currentSubmission = terminalBridgeSubmission(current);
12067
+ const currentTakeover = isRecord(current.native_session_takeover)
12068
+ ? current.native_session_takeover
12069
+ : undefined;
12070
+ const expectedMessageId = stringValue(currentTakeover?.terminal_bridge_message_id);
12071
+ if (expectedMessageId === currentMessageId &&
12072
+ stringValue(currentSubmission?.message_id) === currentMessageId &&
12073
+ currentSubmission?.status === "prepared") {
12074
+ const requestText = String(currentTakeover?.terminal_bridge_request_text ??
12075
+ current.user_request ??
12076
+ "");
12077
+ if (dispatchLedger &&
12078
+ ["submitted", "agent_accepted"].includes(String(dispatchLedger.status)) &&
10137
12079
  stringValue(dispatchLedger.message_id) === currentMessageId) {
10138
- saveTerminalBridgeDispatchLedger(terminalControl, {
10139
- ...(dispatchLedger ?? {}),
12080
+ const submittedAt = stringValue(dispatchLedger.agent_accepted_at) ??
12081
+ stringValue(dispatchLedger.submitted_at) ??
12082
+ new Date().toISOString();
12083
+ const recoveredStatus = dispatchLedger.status === "agent_accepted"
12084
+ ? "agent_accepted"
12085
+ : "submitted";
12086
+ const submittedConversation = withTerminalBridgeSubmission({
12087
+ conversation: current,
12088
+ messageId: currentMessageId,
12089
+ requestText,
12090
+ status: recoveredStatus,
12091
+ preparedAt: stringValue(currentSubmission.prepared_at) ??
12092
+ submittedAt,
12093
+ ...(recoveredStatus === "agent_accepted"
12094
+ ? {
12095
+ agentAcceptedAt: submittedAt,
12096
+ acceptanceEvidence: dispatchLedger.acceptance_evidence
12097
+ }
12098
+ : { submittedAt })
12099
+ });
12100
+ saveState(statePath, submittedConversation);
12101
+ conversation = submittedConversation;
12102
+ }
12103
+ else {
12104
+ const uncertainAt = new Date().toISOString();
12105
+ const uncertainConversation = withTerminalBridgeSubmission({
12106
+ conversation: {
12107
+ ...current,
12108
+ status: "stalled",
12109
+ stalled_at: uncertainAt,
12110
+ stalled_reason: "terminal dispatcher exited before AKK could prove the tmux submission",
12111
+ updated_at: uncertainAt
12112
+ },
12113
+ messageId: currentMessageId,
12114
+ requestText,
10140
12115
  status: "uncertain",
10141
- generation_id: currentMessageId,
12116
+ preparedAt: stringValue(currentSubmission.prepared_at) ??
12117
+ uncertainAt,
12118
+ uncertainAt,
12119
+ error: "the terminal dispatcher exited before AKK could persist a submitted receipt"
12120
+ });
12121
+ if (!dispatchLedger ||
12122
+ stringValue(dispatchLedger.message_id) === currentMessageId) {
12123
+ saveTerminalBridgeDispatchLedger(terminalControl, {
12124
+ ...(dispatchLedger ?? {}),
12125
+ status: "uncertain",
12126
+ generation_id: currentMessageId,
12127
+ conversation_id: uncertainConversation.conversation_id,
12128
+ message_id: currentMessageId,
12129
+ request_hash: terminalBridgeRequestFingerprint(requestText),
12130
+ prepared_at: stringValue(currentSubmission.prepared_at) ??
12131
+ uncertainAt,
12132
+ uncertain_at: uncertainAt,
12133
+ dispatcher_pid: Number.isSafeInteger(dispatcherPid) &&
12134
+ dispatcherPid > 1
12135
+ ? dispatcherPid
12136
+ : null,
12137
+ state_path: statePath,
12138
+ event_log_path: logPath,
12139
+ callback_expected: Boolean(uncertainConversation.gateway_method),
12140
+ reason: "dispatcher_exited_before_submitted_receipt"
12141
+ });
12142
+ }
12143
+ saveState(statePath, uncertainConversation);
12144
+ appendEvent(logPath, {
12145
+ ts: uncertainAt,
10142
12146
  conversation_id: uncertainConversation.conversation_id,
12147
+ event: "terminal_message_submit_uncertain",
10143
12148
  message_id: currentMessageId,
10144
- request_hash: terminalBridgeRequestFingerprint(requestText),
10145
- prepared_at: stringValue(currentSubmission.prepared_at) ??
10146
- uncertainAt,
10147
- uncertain_at: uncertainAt,
12149
+ reason: "dispatcher_exited_before_submitted_receipt",
10148
12150
  dispatcher_pid: Number.isSafeInteger(dispatcherPid) &&
10149
12151
  dispatcherPid > 1
10150
12152
  ? dispatcherPid
10151
12153
  : null,
10152
- state_path: statePath,
10153
- event_log_path: logPath,
10154
- callback_expected: Boolean(uncertainConversation.gateway_method),
10155
- reason: "dispatcher_exited_before_submitted_receipt"
12154
+ do_not_retry: true
10156
12155
  });
12156
+ conversation = uncertainConversation;
10157
12157
  }
10158
- saveState(statePath, uncertainConversation);
10159
- appendEvent(logPath, {
10160
- ts: uncertainAt,
10161
- conversation_id: uncertainConversation.conversation_id,
10162
- event: "terminal_message_submit_uncertain",
10163
- message_id: currentMessageId,
10164
- reason: "dispatcher_exited_before_submitted_receipt",
10165
- dispatcher_pid: Number.isSafeInteger(dispatcherPid) &&
10166
- dispatcherPid > 1
10167
- ? dispatcherPid
10168
- : null,
10169
- do_not_retry: true
10170
- });
10171
- conversation = uncertainConversation;
12158
+ }
12159
+ else {
12160
+ conversation = current;
10172
12161
  }
10173
12162
  }
10174
- else {
10175
- conversation = current;
12163
+ finally {
12164
+ releaseStateLock();
10176
12165
  }
10177
- }
10178
- finally {
10179
- releaseStateLock();
10180
- }
10181
- if (terminalBridgeSubmission(conversation)?.status === "uncertain") {
10182
- stallOtherTerminalBridgeConversationsForUncertainDispatch({
10183
- storeDir: storeDirFromOptions(options),
10184
- terminalControl,
10185
- currentConversationId: conversation.conversation_id,
10186
- uncertainMessageId: currentMessageId
10187
- });
10188
- }
12166
+ if (terminalBridgeSubmission(conversation)?.status === "uncertain") {
12167
+ stallOtherTerminalBridgeConversationsForUncertainDispatch({
12168
+ storeDir: writerStoreDir,
12169
+ terminalControl,
12170
+ currentConversationId: conversation.conversation_id,
12171
+ uncertainMessageId: currentMessageId
12172
+ });
12173
+ }
12174
+ });
10189
12175
  }
10190
12176
  finally {
10191
12177
  releaseTerminalLock();
10192
12178
  }
10193
12179
  const afterSubmission = terminalBridgeSubmission(conversation);
10194
12180
  if (stringValue(afterSubmission?.message_id) === currentMessageId &&
10195
- afterSubmission?.status === "submitted") {
12181
+ ["submitted", "agent_accepted"].includes(String(afterSubmission?.status))) {
10196
12182
  continue;
10197
12183
  }
10198
12184
  printJson({
@@ -10206,22 +12192,31 @@ async function runTerminalBridgeMonitorWithLock(options) {
10206
12192
  });
10207
12193
  return;
10208
12194
  }
10209
- if (submissionStatus === "uncertain" || submissionStatus === "aborted") {
12195
+ if (submissionStatus === "not_accepted" ||
12196
+ submissionStatus === "uncertain" ||
12197
+ submissionStatus === "aborted") {
12198
+ const abortedSafeToRetry = submissionStatus === "aborted" &&
12199
+ submission.safe_to_retry === true;
10210
12200
  printJson({
10211
12201
  conversation,
10212
12202
  monitored: true,
10213
12203
  terminal_bridge: true,
10214
12204
  completed: false,
10215
12205
  submission_outcome: submissionStatus,
10216
- do_not_retry: submissionStatus === "uncertain",
10217
- safe_to_retry: submissionStatus === "aborted",
10218
- reason: submissionStatus === "uncertain"
10219
- ? "terminal submission outcome is uncertain; automatic completion and approval attribution are disabled"
10220
- : "terminal submission was aborted before tmux input"
12206
+ do_not_retry: !abortedSafeToRetry,
12207
+ safe_to_retry: abortedSafeToRetry,
12208
+ reason: submissionStatus === "not_accepted"
12209
+ ? "the exact terminal draft was not accepted; automatic retry is disabled"
12210
+ : submissionStatus === "uncertain"
12211
+ ? "terminal submission outcome is uncertain; automatic completion and approval attribution are disabled"
12212
+ : abortedSafeToRetry
12213
+ ? "terminal submission was durably aborted before tmux input"
12214
+ : "terminal submission was aborted but safe retry was not durably proven"
10221
12215
  });
10222
12216
  return;
10223
12217
  }
10224
- if (submissionStatus !== "submitted") {
12218
+ if (submissionStatus !== "submitted" &&
12219
+ submissionStatus !== "agent_accepted") {
10225
12220
  printJson({
10226
12221
  conversation,
10227
12222
  monitored: true,
@@ -10237,11 +12232,55 @@ async function runTerminalBridgeMonitorWithLock(options) {
10237
12232
  }
10238
12233
  catch (error) {
10239
12234
  const reason = error instanceof Error ? error.message : String(error);
12235
+ if (error instanceof StoreLockTimeoutError) {
12236
+ bindingCheckDeferredAttempts += 1;
12237
+ bindingCheckFirstDeferredAt ??= new Date().toISOString();
12238
+ const backoffMs = Math.min(5_000, 250 * (2 ** Math.min(5, bindingCheckDeferredAttempts - 1)));
12239
+ runtimeLog("warn", "terminal_bridge_monitor_binding_check_deferred", {
12240
+ conversation_id: conversation.conversation_id,
12241
+ terminal_target: terminalControl.target,
12242
+ error_code: error.code,
12243
+ lock_kind: error.lockKind,
12244
+ attempt: bindingCheckDeferredAttempts,
12245
+ retry_in_ms: backoffMs
12246
+ });
12247
+ sleepSync(backoffMs);
12248
+ continue;
12249
+ }
12250
+ if (!(error instanceof TurnBindingSupersededError)) {
12251
+ runtimeLog("error", "terminal_bridge_monitor_binding_check_failed", {
12252
+ conversation_id: conversation.conversation_id,
12253
+ terminal_target: terminalControl.target,
12254
+ error_code: isRecord(error) ? stringValue(error.code) : undefined,
12255
+ reason
12256
+ });
12257
+ throw error;
12258
+ }
10240
12259
  runtimeLog("warn", "terminal_bridge_monitor_binding_superseded", {
10241
12260
  conversation_id: conversation.conversation_id,
10242
12261
  terminal_target: terminalControl.target,
10243
12262
  reason
10244
12263
  });
12264
+ try {
12265
+ appendEvent(logPath, {
12266
+ ts: new Date().toISOString(),
12267
+ conversation_id: conversation.conversation_id,
12268
+ event: "terminal_bridge_monitor_binding_superseded",
12269
+ terminal_control: terminalControl,
12270
+ error_code: error.code,
12271
+ reason
12272
+ });
12273
+ }
12274
+ catch (diagnosticError) {
12275
+ runtimeLog("warn", "terminal_bridge_monitor_diagnostic_write_failed", {
12276
+ conversation_id: conversation.conversation_id,
12277
+ terminal_target: terminalControl.target,
12278
+ diagnostic_event: "terminal_bridge_monitor_binding_superseded",
12279
+ reason: diagnosticError instanceof Error
12280
+ ? diagnosticError.message
12281
+ : String(diagnosticError)
12282
+ });
12283
+ }
10245
12284
  printJson({
10246
12285
  conversation,
10247
12286
  monitored: true,
@@ -10252,6 +12291,44 @@ async function runTerminalBridgeMonitorWithLock(options) {
10252
12291
  });
10253
12292
  return;
10254
12293
  }
12294
+ if (bindingCheckDeferredAttempts > 0) {
12295
+ const resumedAt = new Date().toISOString();
12296
+ try {
12297
+ appendEvent(logPath, {
12298
+ ts: resumedAt,
12299
+ conversation_id: conversation.conversation_id,
12300
+ event: "terminal_bridge_monitor_binding_check_deferred",
12301
+ terminal_control: terminalControl,
12302
+ error_code: "AKK_STORE_LOCK_TIMEOUT",
12303
+ lock_kind: "writer",
12304
+ first_deferred_at: bindingCheckFirstDeferredAt,
12305
+ resumed_at: resumedAt,
12306
+ attempts: bindingCheckDeferredAttempts,
12307
+ outcome: "resumed"
12308
+ });
12309
+ runtimeLog("info", "terminal_bridge_monitor_binding_check_resumed", {
12310
+ conversation_id: conversation.conversation_id,
12311
+ terminal_target: terminalControl.target,
12312
+ attempts: bindingCheckDeferredAttempts,
12313
+ first_deferred_at: bindingCheckFirstDeferredAt
12314
+ });
12315
+ bindingCheckDeferredAttempts = 0;
12316
+ bindingCheckFirstDeferredAt = undefined;
12317
+ }
12318
+ catch (error) {
12319
+ if (!(error instanceof StoreLockTimeoutError)) {
12320
+ throw error;
12321
+ }
12322
+ runtimeLog("warn", "terminal_bridge_monitor_diagnostic_write_deferred", {
12323
+ conversation_id: conversation.conversation_id,
12324
+ terminal_target: terminalControl.target,
12325
+ error_code: error.code,
12326
+ lock_kind: error.lockKind
12327
+ });
12328
+ sleepSync(Math.max(250, pollIntervalMs));
12329
+ continue;
12330
+ }
12331
+ }
10255
12332
  const screenChangedSinceSend = preSendScreenFingerprint !== undefined &&
10256
12333
  previousScreenFingerprint !== undefined &&
10257
12334
  previousScreenFingerprint !== preSendScreenFingerprint;
@@ -10260,18 +12337,25 @@ async function runTerminalBridgeMonitorWithLock(options) {
10260
12337
  const expectedStatus = conversation.status;
10261
12338
  const releaseTerminalPollLock = acquireFileLock(terminalBridgeSendLockPath(storeDirFromOptions(options), terminalControl), { timeoutMs: 30000 });
10262
12339
  try {
10263
- let dispatchLedger = loadTerminalBridgeDispatchLedger(terminalControl);
12340
+ let dispatchLedger = reconcilePreparedTerminalDispatchLedger(terminalControl, loadTerminalBridgeDispatchLedger(terminalControl));
10264
12341
  const durableSubmission = terminalBridgeSubmission(conversation);
10265
12342
  if (dispatchLedger?.status === "prepared" &&
10266
12343
  stringValue(dispatchLedger.message_id) === currentMessageId &&
10267
- durableSubmission?.status === "submitted" &&
12344
+ durableSubmission &&
12345
+ ["submitted", "agent_accepted"].includes(String(durableSubmission.status)) &&
10268
12346
  stringValue(durableSubmission.message_id) === currentMessageId) {
10269
- const submittedAt = stringValue(durableSubmission.submitted_at) ??
12347
+ const submittedAt = stringValue(durableSubmission.agent_accepted_at) ??
12348
+ stringValue(durableSubmission.submitted_at) ??
10270
12349
  new Date().toISOString();
10271
12350
  saveTerminalBridgeDispatchLedger(terminalControl, {
10272
12351
  ...dispatchLedger,
10273
- status: "submitted",
10274
- submitted_at: submittedAt,
12352
+ status: durableSubmission.status,
12353
+ ...(durableSubmission.status === "agent_accepted"
12354
+ ? {
12355
+ agent_accepted_at: submittedAt,
12356
+ acceptance_evidence: durableSubmission.acceptance_evidence
12357
+ }
12358
+ : { submitted_at: submittedAt }),
10275
12359
  reason: "recovered from the durable conversation submission receipt"
10276
12360
  });
10277
12361
  dispatchLedger =
@@ -10280,7 +12364,7 @@ async function runTerminalBridgeMonitorWithLock(options) {
10280
12364
  if (dispatchLedger) {
10281
12365
  const ledgerStatus = stringValue(dispatchLedger.status);
10282
12366
  const ledgerMessageId = stringValue(dispatchLedger.message_id);
10283
- if (ledgerStatus !== "submitted" ||
12367
+ if (!["submitted", "agent_accepted"].includes(ledgerStatus ?? "") ||
10284
12368
  ledgerMessageId !== currentMessageId) {
10285
12369
  appendEvent(logPath, {
10286
12370
  ts: new Date().toISOString(),
@@ -10342,6 +12426,47 @@ async function runTerminalBridgeMonitorWithLock(options) {
10342
12426
  releaseTerminalPollLock();
10343
12427
  }
10344
12428
  const terminalStatus = poll.status;
12429
+ const detectorLimitation = stringValue(terminalStatus.capability_limitation);
12430
+ const detectorDiagnosticFingerprint = detectorLimitation
12431
+ ? terminalBridgeActivityFingerprint(detectorLimitation)
12432
+ : undefined;
12433
+ const detectorDiagnosticChanged = detectorLimitation
12434
+ ? detectorDiagnosticFingerprint !== persistedDetectorDiagnosticFingerprint ||
12435
+ persistedDetectorDiagnosticStatus !== "limited"
12436
+ : persistedDetectorDiagnosticStatus === "limited";
12437
+ if (detectorDiagnosticChanged) {
12438
+ try {
12439
+ const diagnostic = persistTerminalBridgeDetectorDiagnostic({
12440
+ statePath,
12441
+ logPath,
12442
+ expectedConversationId: conversation.conversation_id,
12443
+ expectedMessageId: currentMessageId,
12444
+ limitation: detectorLimitation,
12445
+ fingerprint: detectorDiagnosticFingerprint
12446
+ });
12447
+ if (diagnostic.diagnostic) {
12448
+ conversation = diagnostic.conversation;
12449
+ nativeTakeover = isRecord(conversation.native_session_takeover)
12450
+ ? conversation.native_session_takeover
12451
+ : undefined;
12452
+ persistedDetectorDiagnosticFingerprint = stringValue(diagnostic.diagnostic?.fingerprint);
12453
+ persistedDetectorDiagnosticStatus = stringValue(diagnostic.diagnostic?.status);
12454
+ }
12455
+ }
12456
+ catch (error) {
12457
+ if (!(error instanceof StoreLockTimeoutError)) {
12458
+ throw error;
12459
+ }
12460
+ runtimeLog("warn", "terminal_bridge_detector_diagnostic_deferred", {
12461
+ conversation_id: conversation.conversation_id,
12462
+ terminal_target: terminalControl.target,
12463
+ error_code: error.code,
12464
+ lock_kind: error.lockKind
12465
+ });
12466
+ sleepSync(Math.max(250, pollIntervalMs));
12467
+ continue;
12468
+ }
12469
+ }
10345
12470
  const approval = terminalStatus.approval_state;
10346
12471
  const currentScreenFingerprint = stringValue(terminalStatus?.screen?.digest) ??
10347
12472
  terminalBridgeScreenFingerprint(terminalStatus?.screen?.excerpt);
@@ -11060,7 +13185,17 @@ function orphanedTerminalDispatchForRecovery(terminalControl) {
11060
13185
  const ledgerPanePid = Number(ledgerControl?.pane_pid);
11061
13186
  const currentPanePid = Number(terminalControl.panePid);
11062
13187
  if (!ledger ||
11063
- !["prepared", "dispatching", "submitted", "uncertain", "verified"]
13188
+ ![
13189
+ "prepared",
13190
+ "text_injected",
13191
+ "enter_dispatched",
13192
+ "dispatching",
13193
+ "agent_accepted",
13194
+ "submitted",
13195
+ "not_accepted",
13196
+ "uncertain",
13197
+ "verified"
13198
+ ]
11064
13199
  .includes(String(ledger.status)) ||
11065
13200
  !recoveryIdentity ||
11066
13201
  (!lifecycle &&
@@ -11155,14 +13290,147 @@ function saveLifecycleTerminalDispatchLedger(terminalControl, ledger, options) {
11155
13290
  }
11156
13291
  saveTerminalBridgeDispatchLedger(terminalControl, ledger);
11157
13292
  }
13293
+ function terminalLedgerReceiptHistory(ledger) {
13294
+ if (!ledger) {
13295
+ return [];
13296
+ }
13297
+ const value = ledger.terminal_submission_receipts;
13298
+ if (value !== undefined && !Array.isArray(value)) {
13299
+ throw new Error("terminal dispatch receipt history is malformed");
13300
+ }
13301
+ const receipts = (Array.isArray(value) ? value : []).map((receipt) => {
13302
+ if (!isRecord(receipt) || !stringValue(receipt.message_id)) {
13303
+ throw new Error("terminal dispatch receipt history is malformed");
13304
+ }
13305
+ return receipt;
13306
+ });
13307
+ const ids = new Set();
13308
+ for (const receipt of receipts) {
13309
+ const id = String(receipt.message_id);
13310
+ if (ids.has(id)) {
13311
+ throw new Error(`terminal dispatch receipt ${id} is duplicated`);
13312
+ }
13313
+ ids.add(id);
13314
+ }
13315
+ const current = terminalLedgerReceiptCandidate(ledger);
13316
+ const currentId = stringValue(current?.message_id);
13317
+ if (!current || !currentId) {
13318
+ return receipts;
13319
+ }
13320
+ const previous = receipts.find((receipt) => stringValue(receipt.message_id) === currentId);
13321
+ if (!previous) {
13322
+ return [...receipts, current];
13323
+ }
13324
+ // A resolved top-level ledger may already point at a replacement tmux pane.
13325
+ // Resolution cannot strengthen or rebind an existing submission receipt, so
13326
+ // its append-only historical copy remains authoritative.
13327
+ if (ledger.status === "resolved") {
13328
+ return receipts;
13329
+ }
13330
+ const merged = mergeTerminalLedgerReceipt(previous, current);
13331
+ return receipts.map((receipt) => stringValue(receipt.message_id) === currentId ? merged : receipt);
13332
+ }
13333
+ function terminalLedgerReceiptCandidate(ledger) {
13334
+ const storedStatus = String(ledger.status);
13335
+ const receiptStatus = TERMINAL_LEDGER_RECEIPT_STATUSES.has(storedStatus)
13336
+ ? storedStatus
13337
+ : storedStatus === "resolved" && ledger.agent_accepted_at
13338
+ ? "agent_accepted"
13339
+ : storedStatus === "resolved" && ledger.uncertain_at
13340
+ ? "uncertain"
13341
+ : storedStatus === "resolved" && ledger.not_accepted_at
13342
+ ? "not_accepted"
13343
+ : storedStatus === "resolved" && ledger.aborted_at
13344
+ ? "aborted"
13345
+ : storedStatus === "resolved" && ledger.enter_dispatched_at
13346
+ ? "enter_dispatched"
13347
+ : storedStatus === "resolved" && ledger.submitted_at
13348
+ ? "submitted"
13349
+ : undefined;
13350
+ if (terminalDispatchLedgerLooksLifecycle(ledger) ||
13351
+ !receiptStatus ||
13352
+ !stringValue(ledger.message_id)) {
13353
+ return undefined;
13354
+ }
13355
+ const { terminal_submission_receipts: _history, terminal_key: _terminalKey, version: _version, ...receipt } = ledger;
13356
+ return { ...receipt, status: receiptStatus };
13357
+ }
13358
+ function mergeTerminalLedgerReceipt(previous, next) {
13359
+ const messageId = required(stringValue(previous.message_id), "terminal dispatch receipt message id is required");
13360
+ for (const field of TERMINAL_LEDGER_RECEIPT_IMMUTABLE_FIELDS) {
13361
+ const previousValue = previous[field];
13362
+ const nextValue = next[field];
13363
+ if (previousValue !== undefined &&
13364
+ nextValue !== undefined &&
13365
+ canonicalJson(previousValue) !== canonicalJson(nextValue)) {
13366
+ throw new Error(`terminal dispatch receipt ${messageId} changed immutable ${field}`);
13367
+ }
13368
+ }
13369
+ const previousControl = isRecord(previous.terminal_control)
13370
+ ? previous.terminal_control
13371
+ : undefined;
13372
+ const nextControl = isRecord(next.terminal_control)
13373
+ ? next.terminal_control
13374
+ : undefined;
13375
+ if (previousControl &&
13376
+ nextControl &&
13377
+ (stringValue(previousControl.target) !== stringValue(nextControl.target) ||
13378
+ (stringValue(previousControl.socket_path) ?? undefined) !==
13379
+ (stringValue(nextControl.socket_path) ?? undefined) ||
13380
+ Number(previousControl.pane_pid) !== Number(nextControl.pane_pid))) {
13381
+ throw new Error(`terminal dispatch receipt ${messageId} changed immutable terminal_control`);
13382
+ }
13383
+ if (previous.status === "agent_accepted" &&
13384
+ next.status !== "agent_accepted") {
13385
+ return previous;
13386
+ }
13387
+ const previousIsTerminalFailure = [
13388
+ "not_accepted",
13389
+ "uncertain",
13390
+ "aborted"
13391
+ ].includes(String(previous.status)) && previous.safe_to_retry !== true;
13392
+ const nextIsTransportOnly = [
13393
+ "text_injected",
13394
+ "enter_dispatched",
13395
+ "submitted"
13396
+ ].includes(String(next.status));
13397
+ if (previousIsTerminalFailure && nextIsTransportOnly) {
13398
+ return previous;
13399
+ }
13400
+ const merged = { ...next };
13401
+ for (const field of TERMINAL_LEDGER_RECEIPT_IMMUTABLE_FIELDS) {
13402
+ if (merged[field] === undefined && previous[field] !== undefined) {
13403
+ merged[field] = previous[field];
13404
+ }
13405
+ }
13406
+ if (!merged.terminal_control && previousControl) {
13407
+ merged.terminal_control = previousControl;
13408
+ }
13409
+ return merged;
13410
+ }
11158
13411
  function saveTerminalBridgeDispatchLedger(terminalControl, ledger) {
11159
13412
  const ledgerPath = terminalBridgeDispatchLedgerPath(terminalControl);
11160
13413
  ensureDir(path.dirname(ledgerPath));
11161
13414
  if (fs.existsSync(ledgerPath) && fs.lstatSync(ledgerPath).isSymbolicLink()) {
11162
13415
  throw new Error(`terminal dispatch ledger is a symlink: ${ledgerPath}`);
11163
13416
  }
11164
- const nextLedger = {
11165
- ...ledger,
13417
+ const previousLedger = fs.existsSync(ledgerPath)
13418
+ ? loadTerminalBridgeDispatchLedger(terminalControl)
13419
+ : undefined;
13420
+ let baseReceiptHistory = terminalLedgerReceiptHistory(previousLedger);
13421
+ for (const incomingReceipt of terminalLedgerReceiptHistory(ledger)) {
13422
+ const incomingId = String(incomingReceipt.message_id);
13423
+ const previousReceipt = baseReceiptHistory.find((receipt) => stringValue(receipt.message_id) === incomingId);
13424
+ const merged = previousReceipt
13425
+ ? mergeTerminalLedgerReceipt(previousReceipt, incomingReceipt)
13426
+ : incomingReceipt;
13427
+ baseReceiptHistory = previousReceipt
13428
+ ? baseReceiptHistory.map((receipt) => stringValue(receipt.message_id) === incomingId ? merged : receipt)
13429
+ : [...baseReceiptHistory, merged];
13430
+ }
13431
+ const { terminal_submission_receipts: _incomingReceiptHistory, ...ledgerWithoutReceiptHistory } = ledger;
13432
+ const nextWithoutHistory = {
13433
+ ...ledgerWithoutReceiptHistory,
11166
13434
  version: 1,
11167
13435
  terminal_key: terminalBridgeRuntimeKey(terminalControl),
11168
13436
  terminal_control: {
@@ -11173,6 +13441,37 @@ function saveTerminalBridgeDispatchLedger(terminalControl, ledger) {
11173
13441
  current_path: terminalControl.currentPath ?? null
11174
13442
  }
11175
13443
  };
13444
+ // The top-level ledger follows the current tmux pane incarnation, while an
13445
+ // append-only receipt must retain the pane that actually accepted its
13446
+ // input. Reconciliation may save an old ledger through a newly-created
13447
+ // pane with the same selector, so derive that receipt from the caller's
13448
+ // original control record instead of silently rebinding its proof.
13449
+ const receiptCandidateLedger = isRecord(ledgerWithoutReceiptHistory.terminal_control)
13450
+ ? {
13451
+ ...nextWithoutHistory,
13452
+ terminal_control: ledgerWithoutReceiptHistory.terminal_control
13453
+ }
13454
+ : nextWithoutHistory;
13455
+ const nextCandidate = terminalLedgerReceiptCandidate(receiptCandidateLedger);
13456
+ let nextReceiptHistory = baseReceiptHistory;
13457
+ if (nextCandidate) {
13458
+ const messageId = String(nextCandidate.message_id);
13459
+ const previousReceipt = baseReceiptHistory.find((receipt) => stringValue(receipt.message_id) === messageId);
13460
+ const nextReceipt = previousReceipt
13461
+ ? mergeTerminalLedgerReceipt(previousReceipt, nextCandidate)
13462
+ : nextCandidate;
13463
+ nextReceiptHistory = previousReceipt
13464
+ ? baseReceiptHistory.map((receipt) => stringValue(receipt.message_id) === messageId
13465
+ ? nextReceipt
13466
+ : receipt)
13467
+ : [...baseReceiptHistory, nextReceipt];
13468
+ }
13469
+ const nextLedger = {
13470
+ ...nextWithoutHistory,
13471
+ ...(nextReceiptHistory.length > 0
13472
+ ? { terminal_submission_receipts: nextReceiptHistory }
13473
+ : {})
13474
+ };
11176
13475
  const tempPath = `${ledgerPath}.${process.pid}.${randomUUID()}.tmp`;
11177
13476
  let tempFd;
11178
13477
  try {
@@ -11271,7 +13570,8 @@ function assertManagedTerminalDispatchOwner({ conversation, terminalControl, act
11271
13570
  const messageId = stringValue(nativeTakeover?.terminal_bridge_message_id);
11272
13571
  const ledger = loadTerminalBridgeDispatchLedger(terminalControl);
11273
13572
  if (!messageId ||
11274
- ledger?.status !== "submitted" ||
13573
+ !ledger ||
13574
+ !["submitted", "agent_accepted"].includes(String(ledger.status)) ||
11275
13575
  stringValue(ledger.conversation_id) !==
11276
13576
  conversation.conversation_id ||
11277
13577
  stringValue(ledger.message_id) !== messageId ||
@@ -11297,13 +13597,22 @@ function terminalBindingLedgerFields(conversation) {
11297
13597
  const nativeThreadId = stringValue(conversation.native_thread_id) ??
11298
13598
  stringValue(takeover?.terminal_agent_session_id);
11299
13599
  const storeDir = managedSessionStoreDirForConversation(conversation);
13600
+ const submission = terminalBridgeSubmission(conversation);
13601
+ const messageType = stringValue(submission?.message_type);
13602
+ const messageBodyHash = stringValue(submission?.message_body_hash);
11300
13603
  return {
11301
13604
  ...(bindingId ? { binding_id: bindingId } : {}),
11302
13605
  ...(Number.isSafeInteger(generation)
11303
13606
  ? { binding_generation: generation }
11304
13607
  : {}),
11305
13608
  ...(nativeThreadId ? { native_thread_id: nativeThreadId } : {}),
11306
- ...(storeDir ? { store_dir: path.resolve(storeDir) } : {})
13609
+ ...(storeDir ? { store_dir: path.resolve(storeDir) } : {}),
13610
+ ...(messageType ? { message_type: messageType } : {}),
13611
+ ...(messageBodyHash ? { message_body_hash: messageBodyHash } : {}),
13612
+ executor_kind: executorForConversation(conversation).kind,
13613
+ ...(conversation.openclaw_session
13614
+ ? { openclaw_session: conversation.openclaw_session }
13615
+ : {})
11307
13616
  };
11308
13617
  }
11309
13618
  function reconcilePreparedTerminalDispatchLedger(terminalControl, ledger) {
@@ -11311,7 +13620,7 @@ function reconcilePreparedTerminalDispatchLedger(terminalControl, ledger) {
11311
13620
  return ledger;
11312
13621
  }
11313
13622
  if (ledger?.status !== "prepared") {
11314
- return ledger;
13623
+ return reconcileLaggingTerminalDispatchLedger(terminalControl, ledger);
11315
13624
  }
11316
13625
  const dispatcherPid = Number(ledger.dispatcher_pid);
11317
13626
  if (Number.isSafeInteger(dispatcherPid) &&
@@ -11358,14 +13667,32 @@ function reconcilePreparedTerminalDispatchLedger(terminalControl, ledger) {
11358
13667
  }
11359
13668
  if (storedMessageId === messageId &&
11360
13669
  stringValue(submission?.message_id) === messageId) {
11361
- if (submission?.status === "submitted") {
11362
- const submittedAt = stringValue(submission.submitted_at) ??
13670
+ if (submission &&
13671
+ ["submitted", "text_injected", "enter_dispatched", "agent_accepted"]
13672
+ .includes(String(submission.status))) {
13673
+ const submittedAt = stringValue(submission.agent_accepted_at) ??
13674
+ stringValue(submission.enter_dispatched_at) ??
13675
+ stringValue(submission.text_injected_at) ??
13676
+ stringValue(submission.submitted_at) ??
11363
13677
  stringValue(conversation.updated_at) ??
11364
13678
  new Date().toISOString();
11365
13679
  saveTerminalBridgeDispatchLedger(terminalControl, {
11366
13680
  ...ledger,
11367
- status: "submitted",
11368
- submitted_at: submittedAt,
13681
+ status: submission.status,
13682
+ ...(submission.text_injected_at
13683
+ ? { text_injected_at: submission.text_injected_at }
13684
+ : {}),
13685
+ ...(submission.enter_dispatched_at
13686
+ ? { enter_dispatched_at: submission.enter_dispatched_at }
13687
+ : {}),
13688
+ ...(submission.status === "agent_accepted"
13689
+ ? {
13690
+ agent_accepted_at: submittedAt,
13691
+ acceptance_evidence: submission.acceptance_evidence
13692
+ }
13693
+ : submission.status === "submitted"
13694
+ ? { submitted_at: submittedAt }
13695
+ : {}),
11369
13696
  reason: "recovered from the durable conversation submission receipt"
11370
13697
  });
11371
13698
  }
@@ -11380,22 +13707,34 @@ function reconcilePreparedTerminalDispatchLedger(terminalControl, ledger) {
11380
13707
  return loadTerminalBridgeDispatchLedger(terminalControl);
11381
13708
  }
11382
13709
  if (storedMessageId &&
11383
- submission?.status === "submitted" &&
13710
+ submission &&
13711
+ ["submitted", "agent_accepted"].includes(String(submission.status)) &&
11384
13712
  stringValue(submission.message_id) === storedMessageId) {
11385
13713
  const requestText = String(nativeTakeover?.terminal_bridge_request_text ??
11386
13714
  conversation.user_request ??
11387
13715
  "");
11388
13716
  saveTerminalBridgeDispatchLedger(terminalControl, {
11389
13717
  ...terminalBindingLedgerFields(conversation),
11390
- status: "submitted",
13718
+ status: submission.status,
11391
13719
  generation_id: storedMessageId,
11392
13720
  conversation_id: conversation.conversation_id,
11393
13721
  message_id: storedMessageId,
13722
+ ...(stringValue(submission.message_type)
13723
+ ? { message_type: stringValue(submission.message_type) }
13724
+ : {}),
11394
13725
  request_hash: terminalBridgeRequestFingerprint(requestText),
11395
13726
  prepared_at: stringValue(submission.prepared_at) ??
11396
13727
  stringValue(conversation.updated_at),
11397
- submitted_at: stringValue(submission.submitted_at) ??
11398
- stringValue(conversation.updated_at),
13728
+ ...(submission.status === "agent_accepted"
13729
+ ? {
13730
+ agent_accepted_at: stringValue(submission.agent_accepted_at) ??
13731
+ stringValue(conversation.updated_at),
13732
+ acceptance_evidence: submission.acceptance_evidence
13733
+ }
13734
+ : {
13735
+ submitted_at: stringValue(submission.submitted_at) ??
13736
+ stringValue(conversation.updated_at)
13737
+ }),
11399
13738
  dispatcher_pid: null,
11400
13739
  state_path: statePath,
11401
13740
  event_log_path: stringValue(ledger.event_log_path) ??
@@ -11414,6 +13753,216 @@ function reconcilePreparedTerminalDispatchLedger(terminalControl, ledger) {
11414
13753
  }
11415
13754
  return loadTerminalBridgeDispatchLedger(terminalControl);
11416
13755
  }
13756
+ function reconcileLaggingTerminalDispatchLedger(terminalControl, ledger) {
13757
+ if (!ledger ||
13758
+ terminalDispatchLedgerLooksLifecycle(ledger) ||
13759
+ ![
13760
+ "text_injected",
13761
+ "enter_dispatched",
13762
+ "submitted",
13763
+ "agent_accepted",
13764
+ "not_accepted",
13765
+ "uncertain"
13766
+ ].includes(String(ledger.status))) {
13767
+ return ledger;
13768
+ }
13769
+ const statePath = stringValue(ledger.state_path);
13770
+ const messageId = stringValue(ledger.message_id);
13771
+ if (!statePath || !messageId) {
13772
+ return ledger;
13773
+ }
13774
+ let conversation;
13775
+ try {
13776
+ conversation = loadState(statePath);
13777
+ }
13778
+ catch {
13779
+ return ledger;
13780
+ }
13781
+ const nativeTakeover = isRecord(conversation.native_session_takeover)
13782
+ ? conversation.native_session_takeover
13783
+ : undefined;
13784
+ const submission = terminalBridgeSubmission(conversation);
13785
+ const storedControl = terminalControlFromTakeover(nativeTakeover);
13786
+ if (conversation.conversation_id !== stringValue(ledger.conversation_id) ||
13787
+ stringValue(nativeTakeover?.terminal_bridge_message_id) !== messageId ||
13788
+ stringValue(submission?.message_id) !== messageId ||
13789
+ !storedControl ||
13790
+ terminalBridgeRuntimeKey(storedControl) !==
13791
+ terminalBridgeRuntimeKey(terminalControl)) {
13792
+ return ledger;
13793
+ }
13794
+ const requestText = String(nativeTakeover?.terminal_bridge_request_text ?? conversation.user_request ?? "");
13795
+ const requestHash = terminalBridgeRequestFingerprint(requestText);
13796
+ if (stringValue(ledger.request_hash) !== requestHash ||
13797
+ stringValue(submission?.request_hash) !== requestHash) {
13798
+ return ledger;
13799
+ }
13800
+ const bindingFields = terminalBindingLedgerFields(conversation);
13801
+ for (const key of [
13802
+ "binding_id",
13803
+ "binding_generation",
13804
+ "native_thread_id",
13805
+ "store_dir"
13806
+ ]) {
13807
+ if (ledger[key] !== undefined &&
13808
+ bindingFields[key] !== undefined &&
13809
+ String(ledger[key]) !== String(bindingFields[key])) {
13810
+ return ledger;
13811
+ }
13812
+ }
13813
+ const stateStatus = String(submission?.status ?? "");
13814
+ let stateAcceptanceEvidence;
13815
+ if (stateStatus === "agent_accepted") {
13816
+ try {
13817
+ stateAcceptanceEvidence = terminalAcceptanceEvidenceForConversation(conversation, requestText, submission?.acceptance_evidence);
13818
+ }
13819
+ catch {
13820
+ stateAcceptanceEvidence = undefined;
13821
+ }
13822
+ }
13823
+ let ledgerAcceptanceEvidence;
13824
+ let ledgerAcceptanceError;
13825
+ if (ledger.status === "agent_accepted") {
13826
+ try {
13827
+ ledgerAcceptanceEvidence = terminalAcceptanceEvidenceForConversation(conversation, requestText, ledger.acceptance_evidence);
13828
+ }
13829
+ catch (error) {
13830
+ ledgerAcceptanceError = error instanceof Error
13831
+ ? error.message
13832
+ : String(error);
13833
+ }
13834
+ }
13835
+ if (ledgerAcceptanceEvidence) {
13836
+ if (!stateAcceptanceEvidence) {
13837
+ const acceptedAt = stringValue(ledger.agent_accepted_at) ?? new Date().toISOString();
13838
+ const acceptedConversation = withTerminalBridgeSubmission({
13839
+ conversation,
13840
+ messageId,
13841
+ requestText,
13842
+ status: "agent_accepted",
13843
+ preparedAt: stringValue(submission?.prepared_at) ??
13844
+ stringValue(ledger.prepared_at) ??
13845
+ acceptedAt,
13846
+ textInjectedAt: stringValue(submission?.text_injected_at) ??
13847
+ stringValue(ledger.text_injected_at),
13848
+ enterDispatchedAt: stringValue(submission?.enter_dispatched_at) ??
13849
+ stringValue(ledger.enter_dispatched_at),
13850
+ agentAcceptedAt: acceptedAt,
13851
+ acceptanceEvidence: ledgerAcceptanceEvidence
13852
+ });
13853
+ saveState(statePath, acceptedConversation);
13854
+ }
13855
+ return ledger;
13856
+ }
13857
+ if (stateAcceptanceEvidence) {
13858
+ const acceptedAt = stringValue(submission?.agent_accepted_at) ?? new Date().toISOString();
13859
+ saveTerminalBridgeDispatchLedger(terminalControl, {
13860
+ ...ledger,
13861
+ ...bindingFields,
13862
+ status: "agent_accepted",
13863
+ text_injected_at: submission?.text_injected_at,
13864
+ enter_dispatched_at: submission?.enter_dispatched_at,
13865
+ agent_accepted_at: acceptedAt,
13866
+ acceptance_evidence: stateAcceptanceEvidence,
13867
+ dispatcher_pid: null,
13868
+ reason: "recovered the strongest durable native acceptance receipt"
13869
+ });
13870
+ return loadTerminalBridgeDispatchLedger(terminalControl);
13871
+ }
13872
+ if (ledger.status === "agent_accepted") {
13873
+ const uncertainAt = new Date().toISOString();
13874
+ const uncertainConversation = withTerminalBridgeSubmission({
13875
+ conversation: {
13876
+ ...conversation,
13877
+ status: "stalled",
13878
+ stalled_at: uncertainAt,
13879
+ stalled_reason: "stored native acceptance evidence is invalid",
13880
+ updated_at: uncertainAt
13881
+ },
13882
+ messageId,
13883
+ requestText,
13884
+ status: "uncertain",
13885
+ preparedAt: stringValue(submission?.prepared_at) ??
13886
+ stringValue(ledger.prepared_at) ??
13887
+ uncertainAt,
13888
+ textInjectedAt: stringValue(submission?.text_injected_at) ??
13889
+ stringValue(ledger.text_injected_at),
13890
+ enterDispatchedAt: stringValue(submission?.enter_dispatched_at) ??
13891
+ stringValue(ledger.enter_dispatched_at),
13892
+ uncertainAt,
13893
+ error: ledgerAcceptanceError ??
13894
+ "stored native acceptance evidence is invalid",
13895
+ lastProvenStage: "enter_dispatched"
13896
+ });
13897
+ saveState(statePath, uncertainConversation);
13898
+ saveTerminalBridgeDispatchLedger(terminalControl, {
13899
+ ...ledger,
13900
+ ...bindingFields,
13901
+ status: "uncertain",
13902
+ uncertain_at: uncertainAt,
13903
+ dispatcher_pid: null,
13904
+ reason: "stored native acceptance evidence is invalid"
13905
+ });
13906
+ return loadTerminalBridgeDispatchLedger(terminalControl);
13907
+ }
13908
+ const stateRank = terminalSubmissionProofRank(stateStatus, stringValue(submission?.last_proven_stage));
13909
+ const ledgerRank = terminalSubmissionProofRank(String(ledger.status), stringValue(ledger.last_proven_stage));
13910
+ const stateIsTerminal = ["not_accepted", "uncertain", "aborted"]
13911
+ .includes(stateStatus);
13912
+ if (!stateIsTerminal && stateRank <= ledgerRank) {
13913
+ return ledger;
13914
+ }
13915
+ if (![
13916
+ "text_injected",
13917
+ "enter_dispatched",
13918
+ "submitted",
13919
+ "not_accepted",
13920
+ "uncertain",
13921
+ "aborted"
13922
+ ].includes(stateStatus)) {
13923
+ return ledger;
13924
+ }
13925
+ saveTerminalBridgeDispatchLedger(terminalControl, {
13926
+ ...ledger,
13927
+ ...bindingFields,
13928
+ status: stateStatus,
13929
+ ...(submission?.text_injected_at
13930
+ ? { text_injected_at: submission.text_injected_at }
13931
+ : {}),
13932
+ ...(submission?.enter_dispatched_at
13933
+ ? { enter_dispatched_at: submission.enter_dispatched_at }
13934
+ : {}),
13935
+ ...(submission?.submitted_at
13936
+ ? { submitted_at: submission.submitted_at }
13937
+ : {}),
13938
+ ...(submission?.not_accepted_at
13939
+ ? { not_accepted_at: submission.not_accepted_at }
13940
+ : {}),
13941
+ ...(submission?.uncertain_at
13942
+ ? { uncertain_at: submission.uncertain_at }
13943
+ : {}),
13944
+ ...(submission?.aborted_at
13945
+ ? { aborted_at: submission.aborted_at }
13946
+ : {}),
13947
+ last_proven_stage: submission?.last_proven_stage,
13948
+ ...(stateIsTerminal ? { dispatcher_pid: null } : {}),
13949
+ reason: "recovered the strongest durable conversation proof level"
13950
+ });
13951
+ return loadTerminalBridgeDispatchLedger(terminalControl);
13952
+ }
13953
+ function terminalSubmissionProofRank(status, lastProven) {
13954
+ if (status === "agent_accepted" || lastProven === "agent_accepted") {
13955
+ return 3;
13956
+ }
13957
+ if (["enter_dispatched", "submitted", "not_accepted"].includes(status) ||
13958
+ lastProven === "enter_dispatched") {
13959
+ return 2;
13960
+ }
13961
+ if (status === "text_injected" || lastProven === "text_injected") {
13962
+ return 1;
13963
+ }
13964
+ return 0;
13965
+ }
11417
13966
  async function recoverLifecycleFenceBeforeMutation({ options, terminal }) {
11418
13967
  const ledger = loadTerminalBridgeDispatchLedger(terminal.terminalControl);
11419
13968
  if (!ledger || ledger.status === "resolved") {
@@ -12797,6 +15346,101 @@ function persistTerminalBridgeActivity({ conversation, statePath, logPath, obser
12797
15346
  releaseLock();
12798
15347
  }
12799
15348
  }
15349
+ function persistTerminalBridgeDetectorDiagnostic({ statePath, logPath, expectedConversationId, expectedMessageId, limitation, fingerprint }) {
15350
+ const storeDir = pathsForConversationDir(path.dirname(statePath)).storeDir;
15351
+ const releaseLock = acquireFileLock(`${statePath}.lock`);
15352
+ try {
15353
+ return withStoreWriterLease(storeDir, () => {
15354
+ const conversation = loadState(statePath);
15355
+ const nativeTakeover = isRecord(conversation.native_session_takeover)
15356
+ ? conversation.native_session_takeover
15357
+ : {};
15358
+ if (conversation.conversation_id !== expectedConversationId ||
15359
+ stringValue(nativeTakeover.terminal_bridge_message_id) !==
15360
+ expectedMessageId) {
15361
+ return {
15362
+ persisted: false,
15363
+ conversation,
15364
+ reason: "terminal_bridge_task_replaced"
15365
+ };
15366
+ }
15367
+ const existing = isRecord(nativeTakeover.terminal_bridge_detector_diagnostic)
15368
+ ? nativeTakeover.terminal_bridge_detector_diagnostic
15369
+ : undefined;
15370
+ const now = new Date().toISOString();
15371
+ const nextDiagnostic = limitation && fingerprint
15372
+ ? {
15373
+ status: "limited",
15374
+ source: "terminal_completion_detector",
15375
+ fingerprint,
15376
+ detail: truncateText(redactString(limitation), 1000),
15377
+ observed_at: now
15378
+ }
15379
+ : existing && stringValue(existing.status) === "limited"
15380
+ ? {
15381
+ ...existing,
15382
+ status: "recovered",
15383
+ recovered_at: now
15384
+ }
15385
+ : undefined;
15386
+ if (!nextDiagnostic) {
15387
+ return {
15388
+ persisted: false,
15389
+ conversation,
15390
+ diagnostic: existing,
15391
+ reason: "detector_diagnostic_unchanged"
15392
+ };
15393
+ }
15394
+ if (stringValue(existing?.status) === stringValue(nextDiagnostic.status) &&
15395
+ stringValue(existing?.fingerprint) ===
15396
+ stringValue(nextDiagnostic.fingerprint)) {
15397
+ return {
15398
+ persisted: false,
15399
+ conversation,
15400
+ diagnostic: existing,
15401
+ reason: "detector_diagnostic_unchanged"
15402
+ };
15403
+ }
15404
+ const nextConversation = {
15405
+ ...conversation,
15406
+ native_session_takeover: {
15407
+ ...nativeTakeover,
15408
+ terminal_bridge_detector_diagnostic: nextDiagnostic
15409
+ },
15410
+ updated_at: now
15411
+ };
15412
+ saveState(statePath, nextConversation);
15413
+ const event = nextDiagnostic.status === "limited"
15414
+ ? "terminal_bridge_completion_detector_limited"
15415
+ : "terminal_bridge_completion_detector_recovered";
15416
+ appendEvent(logPath, {
15417
+ ts: now,
15418
+ conversation_id: conversation.conversation_id,
15419
+ event,
15420
+ terminal_bridge_message_id: expectedMessageId,
15421
+ detector_source: nextDiagnostic.source,
15422
+ diagnostic_fingerprint: nextDiagnostic.fingerprint,
15423
+ detail: nextDiagnostic.status === "limited"
15424
+ ? nextDiagnostic.detail
15425
+ : undefined
15426
+ });
15427
+ runtimeLog(nextDiagnostic.status === "limited" ? "warn" : "info", event, {
15428
+ conversation_id: conversation.conversation_id,
15429
+ terminal_bridge_message_id: expectedMessageId,
15430
+ detector_source: nextDiagnostic.source,
15431
+ diagnostic_fingerprint: nextDiagnostic.fingerprint
15432
+ });
15433
+ return {
15434
+ persisted: true,
15435
+ conversation: nextConversation,
15436
+ diagnostic: nextDiagnostic
15437
+ };
15438
+ });
15439
+ }
15440
+ finally {
15441
+ releaseLock();
15442
+ }
15443
+ }
12800
15444
  function terminalBridgeApprovalCandidate({ executor, terminalControl, terminalStatus, fingerprint }) {
12801
15445
  const approval = isRecord(terminalStatus?.approval_state) ? terminalStatus.approval_state : {};
12802
15446
  if (approval.approvable !== true) {
@@ -15428,6 +18072,8 @@ function redactCliOutput(value) {
15428
18072
  }
15429
18073
  if (key === "claude_transcript_anchor" ||
15430
18074
  key === "claudeTranscriptAnchor" ||
18075
+ key === "codex_rollout_acceptance_anchor" ||
18076
+ key === "codexRolloutAcceptanceAnchor" ||
15431
18077
  key === "claude_home" ||
15432
18078
  key === "claudeHome") {
15433
18079
  return [];