@scotthuang/agent-knock-knock 0.10.1 → 0.10.2

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, 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, 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",
@@ -1063,6 +1092,158 @@ function isTerminalControlCapability(value) {
1063
1092
  "terminal_cancel"
1064
1093
  ].includes(value);
1065
1094
  }
1095
+ function assertSafeAbortedTerminalRetryBinding({ owner, receipt, storeDir, terminalControl, messageId }) {
1096
+ if (!(receipt.status === "aborted" && receipt.safe_to_retry === true)) {
1097
+ return;
1098
+ }
1099
+ const sessionId = sessionIdForConversation(owner);
1100
+ const managedSession = tryLoadManagedSession(storeDir, sessionId);
1101
+ const binding = managedSession?.binding;
1102
+ const receiptBindingId = stringValue(receipt.binding_id);
1103
+ const receiptBindingGeneration = Number(receipt.binding_generation);
1104
+ const receiptNativeThreadId = stringValue(receipt.native_thread_id);
1105
+ const ownerTakeover = isRecord(owner.native_session_takeover)
1106
+ ? owner.native_session_takeover
1107
+ : undefined;
1108
+ const ownerControl = terminalControlFromTakeover(ownerTakeover);
1109
+ const ownerNativeThreadId = stringValue(owner.native_thread_id) ??
1110
+ stringValue(ownerTakeover?.terminal_agent_session_id) ??
1111
+ stringValue(ownerTakeover?.terminal_agent_expected_session_id);
1112
+ const ownerAgentPid = Number(ownerTakeover?.terminal_agent_pid);
1113
+ if (!managedSession ||
1114
+ managedSession.status !== "bound" ||
1115
+ !binding ||
1116
+ !receiptBindingId ||
1117
+ !Number.isSafeInteger(receiptBindingGeneration) ||
1118
+ !receiptNativeThreadId ||
1119
+ receiptBindingId !== stringValue(owner.terminal_binding_id) ||
1120
+ receiptBindingGeneration !== Number(owner.terminal_binding_generation) ||
1121
+ receiptNativeThreadId !== ownerNativeThreadId ||
1122
+ binding.binding_id !== receiptBindingId ||
1123
+ binding.generation !== receiptBindingGeneration ||
1124
+ binding.native_thread_id !== receiptNativeThreadId ||
1125
+ !Number.isSafeInteger(ownerAgentPid) ||
1126
+ binding.native_process.pid !== ownerAgentPid ||
1127
+ !ownerControl ||
1128
+ terminalControlSelectorKey(ownerControl) !==
1129
+ terminalControlSelectorKey(terminalControl) ||
1130
+ terminalControlSelectorKey(binding.terminal_control) !==
1131
+ terminalControlSelectorKey(terminalControl)) {
1132
+ throw new Error(`terminal idempotency key ${messageId} belongs to a safe-aborted Turn ` +
1133
+ "whose Session binding is no longer current; no terminal input was sent");
1134
+ }
1135
+ }
1136
+ function stableDelegateTerminalRoute({ options, request, workspace, requestedAgent }) {
1137
+ const messageId = stringValue(options.messageId);
1138
+ if (!messageId) {
1139
+ return undefined;
1140
+ }
1141
+ const storeDir = path.resolve(storeDirFromOptions(options));
1142
+ const requestHash = terminalBridgeRequestFingerprint(terminalSubmissionPayload(request));
1143
+ const bodyHash = createHash("sha256").update(request).digest("hex");
1144
+ const requestedOpenClawSession = stringValue(options.openclawSession);
1145
+ const matches = listConversations(storeDir).flatMap((owner) => terminalBridgeSubmissionReceipts(owner)
1146
+ .filter((receipt) => stringValue(receipt.message_id) === messageId)
1147
+ .map((receipt) => ({ owner, receipt })));
1148
+ if (matches.length === 0) {
1149
+ return undefined;
1150
+ }
1151
+ const routed = matches.map(({ owner, receipt }) => {
1152
+ const ownerStoreDir = managedSessionStoreDirForConversation(owner);
1153
+ const takeover = isRecord(owner.native_session_takeover)
1154
+ ? owner.native_session_takeover
1155
+ : undefined;
1156
+ const terminalControl = terminalControlFromTakeover(takeover);
1157
+ const conversationId = stringValue(takeover?.native_session_id);
1158
+ let eventMessages = [];
1159
+ const eventLogPath = stringValue(owner.event_log_path);
1160
+ if (eventLogPath) {
1161
+ try {
1162
+ eventMessages = readNdjsonLog(eventLogPath)
1163
+ .filter((event) => isRecord(event.message) && event.message.id === messageId)
1164
+ .map((event) => event.message);
1165
+ }
1166
+ catch {
1167
+ eventMessages = [];
1168
+ }
1169
+ }
1170
+ if (eventMessages.length > 1) {
1171
+ throw new Error(`terminal idempotency key ${messageId} has duplicate durable messages`);
1172
+ }
1173
+ const eventMessage = eventMessages[0];
1174
+ const messageType = stringValue(receipt.message_type) ??
1175
+ (isRecord(eventMessage) ? stringValue(eventMessage.type) : undefined);
1176
+ const storedBodyHash = stringValue(receipt.message_body_hash) ??
1177
+ (isRecord(eventMessage) && typeof eventMessage.body === "string"
1178
+ ? createHash("sha256").update(eventMessage.body).digest("hex")
1179
+ : undefined);
1180
+ const ownerWorkspace = canonicalWorkspace(owner.workspace);
1181
+ if (!ownerStoreDir ||
1182
+ path.resolve(ownerStoreDir) !== storeDir ||
1183
+ (stringValue(receipt.store_dir) !== undefined &&
1184
+ path.resolve(String(receipt.store_dir)) !== storeDir) ||
1185
+ !terminalControl ||
1186
+ !conversationId ||
1187
+ stringValue(receipt.request_hash) !== requestHash ||
1188
+ messageType !== "task" ||
1189
+ storedBodyHash !== bodyHash ||
1190
+ (requestedOpenClawSession &&
1191
+ (stringValue(receipt.openclaw_session) ?? owner.openclaw_session) !==
1192
+ requestedOpenClawSession) ||
1193
+ (requestedAgent && executorForConversation(owner).kind !== requestedAgent) ||
1194
+ (workspace && ownerWorkspace !== workspace)) {
1195
+ throw new Error(`terminal idempotency key ${messageId} does not match its original ` +
1196
+ "delegate request boundary; no terminal input was sent");
1197
+ }
1198
+ return {
1199
+ owner,
1200
+ receipt,
1201
+ conversationId,
1202
+ workspace: ownerWorkspace,
1203
+ terminalKey: terminalControlSelectorKey(terminalControl),
1204
+ panePid: terminalControl.panePid
1205
+ };
1206
+ });
1207
+ const authoritative = routed.filter(({ receipt }) => !(receipt.status === "aborted" && receipt.safe_to_retry === true));
1208
+ if (authoritative.length > 1) {
1209
+ throw new Error(`terminal idempotency key ${messageId} has multiple durable delegate receipts`);
1210
+ }
1211
+ const terminalKeys = new Set(routed.map((entry) => `${entry.conversationId}\0${entry.terminalKey}\0${entry.panePid}`));
1212
+ if (terminalKeys.size !== 1) {
1213
+ throw new Error(`terminal idempotency key ${messageId} has conflicting terminal routes`);
1214
+ }
1215
+ const selected = authoritative[0] ?? routed.at(-1);
1216
+ if (!selected) {
1217
+ return undefined;
1218
+ }
1219
+ if (selected.receipt.status === "aborted" &&
1220
+ selected.receipt.safe_to_retry === true) {
1221
+ const sessionId = sessionIdForConversation(selected.owner);
1222
+ const ownerControl = terminalControlFromTakeover(isRecord(selected.owner.native_session_takeover)
1223
+ ? selected.owner.native_session_takeover
1224
+ : undefined);
1225
+ if (!ownerControl) {
1226
+ throw new Error(`terminal idempotency key ${messageId} has no durable terminal route`);
1227
+ }
1228
+ assertSafeAbortedTerminalRetryBinding({
1229
+ owner: selected.owner,
1230
+ receipt: selected.receipt,
1231
+ storeDir,
1232
+ terminalControl: ownerControl,
1233
+ messageId
1234
+ });
1235
+ return {
1236
+ kind: "session",
1237
+ sessionId,
1238
+ workspace: selected.workspace
1239
+ };
1240
+ }
1241
+ return {
1242
+ kind: "terminal",
1243
+ conversationId: selected.conversationId,
1244
+ workspace: selected.workspace
1245
+ };
1246
+ }
1066
1247
  async function runDelegate(options) {
1067
1248
  const request = required(options.request, "--request is required");
1068
1249
  const workspace = options.workspace === undefined
@@ -1071,6 +1252,32 @@ async function runDelegate(options) {
1071
1252
  const requestedAgent = options.agent === undefined
1072
1253
  ? undefined
1073
1254
  : resolveExecutor({ kind: options.agent }).kind;
1255
+ const stableRoute = stableDelegateTerminalRoute({
1256
+ options,
1257
+ request,
1258
+ workspace,
1259
+ requestedAgent
1260
+ });
1261
+ if (stableRoute) {
1262
+ await runSend(stableRoute.kind === "session"
1263
+ ? {
1264
+ ...options,
1265
+ session: stableRoute.sessionId,
1266
+ conversation: undefined,
1267
+ message: request,
1268
+ workspace: stableRoute.workspace,
1269
+ background: true
1270
+ }
1271
+ : {
1272
+ ...options,
1273
+ conversation: stableRoute.conversationId,
1274
+ session: undefined,
1275
+ message: request,
1276
+ workspace: stableRoute.workspace,
1277
+ background: true
1278
+ });
1279
+ return;
1280
+ }
1074
1281
  const scan = await buildTerminalListGroup({
1075
1282
  options: {
1076
1283
  ...options,
@@ -1340,7 +1547,7 @@ function terminalBridgeEnabled(conversation) {
1340
1547
  : undefined;
1341
1548
  return nativeTakeover?.["terminal_bridge"] === true;
1342
1549
  }
1343
- function withTerminalBridgeState({ conversation, message, requestText, startedAt, agentTimeoutMinutes, agentHardTimeoutMinutes, preSendScreenFingerprint, claudeTranscriptAnchor, claudeHome }) {
1550
+ function withTerminalBridgeState({ conversation, message, requestText, startedAt, agentTimeoutMinutes, agentHardTimeoutMinutes, preSendScreenFingerprint, codexRolloutAcceptanceAnchor, claudeTranscriptAnchor, claudeHome }) {
1344
1551
  const nativeTakeover = isRecord(conversation.native_session_takeover)
1345
1552
  ? conversation.native_session_takeover
1346
1553
  : {};
@@ -1354,6 +1561,7 @@ function withTerminalBridgeState({ conversation, message, requestText, startedAt
1354
1561
  terminal_bridge_request_text: requestText,
1355
1562
  terminal_bridge_request_hash: terminalBridgeRequestFingerprint(requestText),
1356
1563
  terminal_bridge_pre_send_screen_fingerprint: preSendScreenFingerprint,
1564
+ codex_rollout_acceptance_anchor: codexRolloutAcceptanceAnchor,
1357
1565
  claude_transcript_anchor: claudeTranscriptAnchor,
1358
1566
  claude_home: claudeHome,
1359
1567
  terminal_bridge_completion_claim: undefined,
@@ -1369,40 +1577,653 @@ function withTerminalBridgeState({ conversation, message, requestText, startedAt
1369
1577
  updated_at: startedAt
1370
1578
  };
1371
1579
  }
1372
- function withTerminalBridgeSubmission({ conversation, messageId, requestText, status, preparedAt, submittedAt, uncertainAt, abortedAt, error }) {
1580
+ function withTerminalBridgeSubmission({ conversation, messageId, messageType, messageBody, requestText, status, preparedAt, textInjectedAt, enterDispatchedAt, agentAcceptedAt, notAcceptedAt, submittedAt, uncertainAt, abortedAt, error, acceptanceEvidence, lastProvenStage, safeToRetry }) {
1373
1581
  const nativeTakeover = isRecord(conversation.native_session_takeover)
1374
1582
  ? conversation.native_session_takeover
1375
1583
  : {};
1376
1584
  const previousSubmission = isRecord(nativeTakeover.terminal_bridge_submission)
1377
1585
  ? nativeTakeover.terminal_bridge_submission
1378
1586
  : undefined;
1379
- const previousDispatcherPid = Number(previousSubmission?.dispatcher_pid);
1587
+ const receiptsValue = nativeTakeover.terminal_bridge_submission_receipts;
1588
+ if (receiptsValue !== undefined && !Array.isArray(receiptsValue)) {
1589
+ throw new Error("terminal submission receipt history is malformed");
1590
+ }
1591
+ const storedReceipts = (Array.isArray(receiptsValue) ? receiptsValue : [])
1592
+ .map((value) => {
1593
+ if (!isRecord(value) || !stringValue(value.message_id)) {
1594
+ throw new Error("terminal submission receipt history is malformed");
1595
+ }
1596
+ return value;
1597
+ });
1598
+ const storedReceiptIds = new Set();
1599
+ for (const receipt of storedReceipts) {
1600
+ const receiptId = String(receipt.message_id);
1601
+ if (storedReceiptIds.has(receiptId)) {
1602
+ throw new Error(`terminal submission receipt ${receiptId} is duplicated`);
1603
+ }
1604
+ storedReceiptIds.add(receiptId);
1605
+ }
1606
+ // A v0.10.1 Turn has only the latest singleton receipt. Seed it into the
1607
+ // append-only history before recording a later response so upgrading cannot
1608
+ // erase the only proof that an older message was already dispatched.
1609
+ const previousSubmissionId = stringValue(previousSubmission?.message_id);
1610
+ const previousReceipts = previousSubmissionId &&
1611
+ !storedReceiptIds.has(previousSubmissionId)
1612
+ ? [...storedReceipts, previousSubmission]
1613
+ : storedReceipts;
1614
+ const matchingReceipts = previousReceipts.filter((receipt) => stringValue(receipt.message_id) === messageId);
1615
+ if (matchingReceipts.length > 1) {
1616
+ throw new Error(`terminal submission receipt ${messageId} is duplicated`);
1617
+ }
1618
+ const previousReceipt = matchingReceipts[0];
1619
+ const previousGenerationSubmission = stringValue(previousSubmission?.message_id) === messageId
1620
+ ? previousSubmission
1621
+ : undefined;
1622
+ if (previousReceipt &&
1623
+ previousGenerationSubmission &&
1624
+ canonicalJson(previousReceipt) !== canonicalJson(previousGenerationSubmission)) {
1625
+ throw new Error(`terminal submission receipt ${messageId} conflicts with its current generation`);
1626
+ }
1627
+ const durableMessageType = messageType ??
1628
+ (stringValue(previousReceipt?.message_type) === "answer"
1629
+ ? "answer"
1630
+ : stringValue(previousReceipt?.message_type) === "task"
1631
+ ? "task"
1632
+ : stringValue(previousGenerationSubmission?.message_type) === "answer"
1633
+ ? "answer"
1634
+ : stringValue(previousGenerationSubmission?.message_type) === "task"
1635
+ ? "task"
1636
+ : undefined);
1637
+ const messageBodyHash = messageBody !== undefined
1638
+ ? createHash("sha256").update(messageBody).digest("hex")
1639
+ : stringValue(previousReceipt?.message_body_hash) ??
1640
+ stringValue(previousGenerationSubmission?.message_body_hash);
1641
+ const previousDispatcherPid = Number(previousGenerationSubmission?.dispatcher_pid);
1380
1642
  const dispatcherPid = status === "prepared" ||
1381
1643
  !Number.isSafeInteger(previousDispatcherPid) ||
1382
1644
  previousDispatcherPid <= 1
1383
1645
  ? process.pid
1384
1646
  : previousDispatcherPid;
1647
+ const provenStage = lastProvenStage ?? terminalSubmissionLastProvenStage(status, stringValue(previousGenerationSubmission?.last_proven_stage));
1648
+ const validatedAcceptanceEvidence = status === "agent_accepted"
1649
+ ? terminalAcceptanceEvidenceForConversation(conversation, requestText, acceptanceEvidence)
1650
+ : undefined;
1651
+ const storedControl = terminalControlFromTakeover(nativeTakeover);
1652
+ const requestHash = terminalBridgeRequestFingerprint(requestText);
1653
+ const candidateImmutableReceiptFields = {
1654
+ session_id: sessionIdForConversation(conversation),
1655
+ turn_id: turnIdForConversation(conversation),
1656
+ message_id: messageId,
1657
+ binding_id: stringValue(conversation.terminal_binding_id),
1658
+ binding_generation: Number.isSafeInteger(Number(conversation.terminal_binding_generation))
1659
+ ? Number(conversation.terminal_binding_generation)
1660
+ : undefined,
1661
+ ...(durableMessageType ? { message_type: durableMessageType } : {}),
1662
+ ...(messageBodyHash ? { message_body_hash: messageBodyHash } : {}),
1663
+ request_hash: requestHash,
1664
+ executor_kind: executorForConversation(conversation).kind,
1665
+ openclaw_session: conversation.openclaw_session,
1666
+ store_dir: managedSessionStoreDirForConversation(conversation),
1667
+ native_thread_id: stringValue(conversation.native_thread_id) ??
1668
+ stringValue(nativeTakeover.terminal_agent_session_id) ??
1669
+ stringValue(nativeTakeover.terminal_agent_expected_session_id),
1670
+ terminal_target: storedControl?.target,
1671
+ terminal_socket_path: storedControl?.socketPath ?? null,
1672
+ terminal_pane_pid: storedControl?.panePid
1673
+ };
1674
+ const immutableReceiptFields = Object.fromEntries(Object.entries(candidateImmutableReceiptFields).map(([key, value]) => [
1675
+ key,
1676
+ value === undefined && previousReceipt?.[key] !== undefined
1677
+ ? previousReceipt[key]
1678
+ : value
1679
+ ]));
1680
+ if (previousReceipt) {
1681
+ for (const [key, value] of Object.entries(candidateImmutableReceiptFields)) {
1682
+ if (previousReceipt[key] !== undefined &&
1683
+ value !== undefined &&
1684
+ String(previousReceipt[key]) !== String(value)) {
1685
+ throw new Error(`terminal submission receipt ${messageId} changed immutable ${key}`);
1686
+ }
1687
+ }
1688
+ }
1689
+ const nextSubmission = {
1690
+ status,
1691
+ ...immutableReceiptFields,
1692
+ prepared_at: preparedAt,
1693
+ dispatcher_pid: dispatcherPid,
1694
+ last_proven_stage: provenStage,
1695
+ ...(textInjectedAt ? { text_injected_at: textInjectedAt } : {}),
1696
+ ...(enterDispatchedAt ? { enter_dispatched_at: enterDispatchedAt } : {}),
1697
+ ...(agentAcceptedAt ? { agent_accepted_at: agentAcceptedAt } : {}),
1698
+ ...(notAcceptedAt ? { not_accepted_at: notAcceptedAt } : {}),
1699
+ ...(submittedAt ? { submitted_at: submittedAt } : {}),
1700
+ ...(uncertainAt ? { uncertain_at: uncertainAt } : {}),
1701
+ ...(abortedAt ? { aborted_at: abortedAt } : {}),
1702
+ ...(error ? { error: textSummary(error) } : {}),
1703
+ ...(safeToRetry !== undefined ? { safe_to_retry: safeToRetry } : {}),
1704
+ ...(validatedAcceptanceEvidence
1705
+ ? { acceptance_evidence: validatedAcceptanceEvidence }
1706
+ : {})
1707
+ };
1708
+ const nextReceipts = previousReceipt
1709
+ ? previousReceipts.map((receipt) => stringValue(receipt.message_id) === messageId
1710
+ ? nextSubmission
1711
+ : receipt)
1712
+ : [...previousReceipts, nextSubmission];
1385
1713
  return {
1386
1714
  ...conversation,
1387
1715
  native_session_takeover: {
1388
1716
  ...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
- }
1717
+ terminal_bridge_submission: nextSubmission,
1718
+ terminal_bridge_submission_receipts: nextReceipts
1402
1719
  },
1403
- updated_at: submittedAt ?? uncertainAt ?? abortedAt ?? preparedAt
1720
+ updated_at: agentAcceptedAt ?? notAcceptedAt ?? uncertainAt ?? abortedAt ??
1721
+ enterDispatchedAt ?? textInjectedAt ?? submittedAt ?? preparedAt
1722
+ };
1723
+ }
1724
+ function terminalAcceptanceEvidenceForConversation(conversation, requestText, evidence) {
1725
+ return validateTerminalSubmissionAcceptanceEvidence(evidence, terminalAcceptanceEvidenceExpectation(conversation, requestText));
1726
+ }
1727
+ function terminalAcceptanceEvidenceExpectation(conversation, requestText) {
1728
+ const nativeTakeover = isRecord(conversation.native_session_takeover)
1729
+ ? conversation.native_session_takeover
1730
+ : undefined;
1731
+ const executor = executorForConversation(conversation).kind;
1732
+ const nativeThreadId = stringValue(conversation.native_thread_id) ??
1733
+ stringValue(nativeTakeover?.terminal_agent_session_id) ??
1734
+ stringValue(nativeTakeover?.terminal_agent_expected_session_id);
1735
+ if (!nativeThreadId) {
1736
+ throw new Error("native acceptance evidence cannot be bound without an exact native thread");
1737
+ }
1738
+ const requestHash = terminalBridgeRequestFingerprint(requestText);
1739
+ if (!requestHash) {
1740
+ throw new Error("native acceptance evidence has no exact request hash");
1741
+ }
1742
+ return {
1743
+ source: executor === "codex" ? "codex_rollout" : "claude_transcript",
1744
+ nativeThreadId,
1745
+ requestHash
1404
1746
  };
1405
1747
  }
1748
+ function replayExactActiveTerminalSubmission({ options, terminalControl, requestText, expectedStoreDir, expectedSessionId, expectedTurnId, expectedMessageType = "task", expectedStatePath }) {
1749
+ const messageId = stringValue(options.messageId);
1750
+ if (!messageId) {
1751
+ return false;
1752
+ }
1753
+ const terminalPayload = terminalSubmissionPayload(requestText);
1754
+ const requestHash = terminalBridgeRequestFingerprint(terminalPayload);
1755
+ if (!requestHash) {
1756
+ return false;
1757
+ }
1758
+ const loadedLedger = loadTerminalBridgeDispatchLedger(terminalControl);
1759
+ const ledgerReceiptMatches = terminalLedgerReceiptHistory(loadedLedger)
1760
+ .filter((receipt) => stringValue(receipt.message_id) === messageId);
1761
+ if (ledgerReceiptMatches.length > 1) {
1762
+ throw new Error(`terminal idempotency key ${messageId} has multiple terminal-ledger receipts`);
1763
+ }
1764
+ const ledgerReceipt = ledgerReceiptMatches[0];
1765
+ const loadedLedgerControl = isRecord(loadedLedger?.terminal_control)
1766
+ ? loadedLedger.terminal_control
1767
+ : undefined;
1768
+ const loadedLedgerMessageId = stringValue(loadedLedger?.message_id);
1769
+ const loadedLedgerStoreDir = stringValue(loadedLedger?.store_dir);
1770
+ const loadedLedgerStatePath = stringValue(loadedLedger?.state_path);
1771
+ const loadedLedgerOwner = loadedLedger && loadedLedgerMessageId === messageId
1772
+ ? loadTerminalDispatchLedgerOwner(loadedLedger)
1773
+ : undefined;
1774
+ const ledgerReceiptControl = isRecord(ledgerReceipt?.terminal_control)
1775
+ ? ledgerReceipt.terminal_control
1776
+ : undefined;
1777
+ const expectedMessageBodyHash = createHash("sha256")
1778
+ .update(requestText)
1779
+ .digest("hex");
1780
+ if (ledgerReceipt &&
1781
+ !(ledgerReceipt.status === "aborted" && ledgerReceipt.safe_to_retry === true) &&
1782
+ (stringValue(ledgerReceiptControl?.target) !== terminalControl.target ||
1783
+ (stringValue(ledgerReceiptControl?.socket_path) ?? undefined) !==
1784
+ terminalControl.socketPath ||
1785
+ Number(ledgerReceiptControl?.pane_pid) !== Number(terminalControl.panePid) ||
1786
+ (stringValue(ledgerReceipt.store_dir) !== undefined &&
1787
+ path.resolve(String(ledgerReceipt.store_dir)) !==
1788
+ path.resolve(expectedStoreDir)) ||
1789
+ (expectedSessionId && stringValue(ledgerReceipt.session_id) !== undefined &&
1790
+ stringValue(ledgerReceipt.session_id) !== expectedSessionId) ||
1791
+ (expectedTurnId && stringValue(ledgerReceipt.turn_id) !== undefined &&
1792
+ stringValue(ledgerReceipt.turn_id) !== expectedTurnId) ||
1793
+ (expectedStatePath && stringValue(ledgerReceipt.state_path) !== undefined &&
1794
+ !sameCanonicalStatePath(ledgerReceipt.state_path, expectedStatePath)) ||
1795
+ (stringValue(ledgerReceipt.message_type) !== undefined &&
1796
+ stringValue(ledgerReceipt.message_type) !== expectedMessageType) ||
1797
+ (stringValue(ledgerReceipt.message_body_hash) !== undefined &&
1798
+ stringValue(ledgerReceipt.message_body_hash) !== expectedMessageBodyHash) ||
1799
+ (stringValue(ledgerReceipt.request_hash) !== undefined &&
1800
+ stringValue(ledgerReceipt.request_hash) !== requestHash) ||
1801
+ (stringValue(options.openclawSession) !== undefined &&
1802
+ stringValue(ledgerReceipt.openclaw_session) !== undefined &&
1803
+ stringValue(ledgerReceipt.openclaw_session) !==
1804
+ stringValue(options.openclawSession)))) {
1805
+ throw new Error(`terminal idempotency key ${messageId} does not match its durable ` +
1806
+ "terminal receipt; no terminal input was sent");
1807
+ }
1808
+ if (loadedLedger &&
1809
+ loadedLedgerMessageId === messageId &&
1810
+ (stringValue(loadedLedgerControl?.target) !== terminalControl.target ||
1811
+ (stringValue(loadedLedgerControl?.socket_path) ?? undefined) !==
1812
+ terminalControl.socketPath ||
1813
+ Number(loadedLedgerControl?.pane_pid) !== Number(terminalControl.panePid) ||
1814
+ (loadedLedgerStoreDir !== undefined &&
1815
+ path.resolve(loadedLedgerStoreDir) !== path.resolve(expectedStoreDir)) ||
1816
+ (expectedSessionId && stringValue(loadedLedger.session_id) !== undefined &&
1817
+ stringValue(loadedLedger.session_id) !== expectedSessionId) ||
1818
+ (expectedTurnId && stringValue(loadedLedger.turn_id) !== undefined &&
1819
+ stringValue(loadedLedger.turn_id) !== expectedTurnId) ||
1820
+ (expectedStatePath && loadedLedgerStatePath !== undefined &&
1821
+ !sameCanonicalStatePath(loadedLedgerStatePath, expectedStatePath)) ||
1822
+ (stringValue(loadedLedger.message_type) !== undefined &&
1823
+ stringValue(loadedLedger.message_type) !== expectedMessageType) ||
1824
+ (stringValue(loadedLedger.request_hash) !== undefined &&
1825
+ stringValue(loadedLedger.request_hash) !== requestHash) ||
1826
+ (stringValue(options.openclawSession) !== undefined &&
1827
+ loadedLedgerOwner !== undefined &&
1828
+ loadedLedgerOwner.openclaw_session !==
1829
+ stringValue(options.openclawSession)))) {
1830
+ throw new Error(`terminal idempotency key ${messageId} does not match its original ` +
1831
+ "Store, Session, Turn, OpenClaw session, message, or tmux pane binding; " +
1832
+ "no terminal input was sent");
1833
+ }
1834
+ if (replayExactStoredTerminalSubmission({
1835
+ options,
1836
+ terminalControl,
1837
+ requestText,
1838
+ requestHash,
1839
+ messageId,
1840
+ expectedStoreDir,
1841
+ expectedSessionId,
1842
+ expectedTurnId,
1843
+ expectedMessageType,
1844
+ expectedStatePath
1845
+ })) {
1846
+ return true;
1847
+ }
1848
+ if (ledgerReceipt &&
1849
+ (loadedLedgerMessageId !== messageId ||
1850
+ !["submitted", "enter_dispatched", "agent_accepted"].includes(String(ledgerReceipt.status)) ||
1851
+ !["submitted", "enter_dispatched", "agent_accepted"].includes(String(loadedLedger?.status)))) {
1852
+ throw new Error(`terminal idempotency key ${messageId} already has durable ` +
1853
+ `${String(ledgerReceipt.status)} proof and cannot be dispatched again; ` +
1854
+ "no terminal input was sent");
1855
+ }
1856
+ const incarnationLedger = resolveTerminalDispatchLedgerPaneIncarnation(terminalControl, loadedLedger);
1857
+ const ledger = reconcilePreparedTerminalDispatchLedger(terminalControl, incarnationLedger);
1858
+ const ledgerControl = isRecord(ledger?.terminal_control)
1859
+ ? ledger.terminal_control
1860
+ : undefined;
1861
+ if (!ledger ||
1862
+ !["submitted", "enter_dispatched", "agent_accepted"].includes(String(ledger.status)) ||
1863
+ stringValue(ledgerControl?.target) !== terminalControl.target ||
1864
+ (stringValue(ledgerControl?.socket_path) ?? undefined) !==
1865
+ terminalControl.socketPath ||
1866
+ Number(ledgerControl?.pane_pid) !== Number(terminalControl.panePid) ||
1867
+ stringValue(ledger.message_id) !== messageId ||
1868
+ (stringValue(ledger.message_type) !== undefined &&
1869
+ stringValue(ledger.message_type) !== expectedMessageType) ||
1870
+ stringValue(ledger.request_hash) !== requestHash) {
1871
+ return false;
1872
+ }
1873
+ const owner = loadTerminalDispatchLedgerOwner(ledger);
1874
+ const ledgerStoreDir = stringValue(ledger.store_dir);
1875
+ const ownerStoreDir = owner
1876
+ ? managedSessionStoreDirForConversation(owner)
1877
+ : undefined;
1878
+ if (!owner ||
1879
+ TERMINAL_DISPATCH_RELEASE_STATUSES.has(owner.status) ||
1880
+ !ledgerStoreDir ||
1881
+ !ownerStoreDir ||
1882
+ path.resolve(ledgerStoreDir) !== path.resolve(expectedStoreDir) ||
1883
+ path.resolve(ownerStoreDir) !== path.resolve(expectedStoreDir) ||
1884
+ (expectedSessionId && sessionIdForConversation(owner) !== expectedSessionId) ||
1885
+ (expectedTurnId && turnIdForConversation(owner) !== expectedTurnId) ||
1886
+ (expectedStatePath && !sameCanonicalStatePath(ledger.state_path, expectedStatePath)) ||
1887
+ (stringValue(options.openclawSession) &&
1888
+ owner.openclaw_session !== stringValue(options.openclawSession))) {
1889
+ return false;
1890
+ }
1891
+ const submission = terminalBridgeSubmission(owner);
1892
+ const nativeTakeover = isRecord(owner.native_session_takeover)
1893
+ ? owner.native_session_takeover
1894
+ : undefined;
1895
+ if (stringValue(nativeTakeover?.terminal_bridge_message_id) !== messageId ||
1896
+ stringValue(submission?.message_id) !== messageId ||
1897
+ (stringValue(submission?.message_type) !== undefined &&
1898
+ stringValue(submission?.message_type) !== expectedMessageType) ||
1899
+ stringValue(submission?.request_hash) !== requestHash) {
1900
+ return false;
1901
+ }
1902
+ const executor = executorForConversation(owner);
1903
+ const nativeThreadId = stringValue(owner.native_thread_id) ??
1904
+ stringValue(nativeTakeover?.terminal_agent_session_id) ??
1905
+ stringValue(nativeTakeover?.terminal_agent_expected_session_id) ??
1906
+ sessionIdForConversation(owner);
1907
+ const proofLevel = String(ledger.status);
1908
+ const replayReceipt = terminalSubmissionReplayReceipt({
1909
+ proofLevel,
1910
+ evidence: ledger.acceptance_evidence,
1911
+ expected: {
1912
+ source: executor.kind === "codex"
1913
+ ? "codex_rollout"
1914
+ : "claude_transcript",
1915
+ nativeThreadId,
1916
+ requestHash
1917
+ }
1918
+ });
1919
+ const logPath = stringValue(ledger.event_log_path) ??
1920
+ stringValue(owner.event_log_path);
1921
+ let loggedMessage;
1922
+ if (logPath) {
1923
+ try {
1924
+ loggedMessage = readNdjsonLog(logPath).find((event) => isRecord(event.message) && event.message.id === messageId)?.message;
1925
+ }
1926
+ catch {
1927
+ loggedMessage = undefined;
1928
+ }
1929
+ }
1930
+ if (isRecord(loggedMessage) &&
1931
+ (loggedMessage.type !== expectedMessageType ||
1932
+ loggedMessage.body !== requestText ||
1933
+ loggedMessage.conversation_id !== owner.conversation_id ||
1934
+ loggedMessage.session_id !== sessionIdForConversation(owner) ||
1935
+ loggedMessage.turn_id !== turnIdForConversation(owner))) {
1936
+ return false;
1937
+ }
1938
+ const durableMessageType = stringValue(ledger.message_type) ??
1939
+ stringValue(submission?.message_type) ??
1940
+ (isRecord(loggedMessage) ? stringValue(loggedMessage.type) : undefined);
1941
+ if (durableMessageType !== expectedMessageType) {
1942
+ return false;
1943
+ }
1944
+ const replayedMessage = isRecord(loggedMessage)
1945
+ ? loggedMessage
1946
+ : createMessage({
1947
+ conversation: owner,
1948
+ id: messageId,
1949
+ from: "openclaw",
1950
+ to: executor.actor,
1951
+ type: expectedMessageType,
1952
+ body: requestText,
1953
+ metadata: {
1954
+ executor_kind: executor.kind,
1955
+ executor_session: executor.session
1956
+ }
1957
+ });
1958
+ const acceptanceInvalid = replayReceipt.submission_outcome === "uncertain";
1959
+ printJson({
1960
+ session_id: sessionIdForConversation(owner),
1961
+ turn_id: turnIdForConversation(owner),
1962
+ conversation: owner,
1963
+ message: replayedMessage,
1964
+ delivered: replayReceipt.delivered,
1965
+ status: replayReceipt.status,
1966
+ submission_outcome: replayReceipt.submission_outcome,
1967
+ background: true,
1968
+ callback_expected: !acceptanceInvalid && Boolean(owner.gateway_method ?? ledger.callback_expected),
1969
+ terminal_control: terminalControl,
1970
+ executor,
1971
+ replayed: replayReceipt.replayed,
1972
+ delivery_receipt: replayReceipt.delivery_receipt,
1973
+ ...(replayReceipt.do_not_retry
1974
+ ? { do_not_retry: replayReceipt.do_not_retry }
1975
+ : {}),
1976
+ reason: replayReceipt.delivered
1977
+ ? "AKK replayed the durable native acceptance receipt and sent no additional tmux input."
1978
+ : acceptanceInvalid
1979
+ ? "AKK replayed an invalid native acceptance receipt as uncertain and sent no additional tmux input."
1980
+ : "AKK replayed the original transport proof without upgrading it and sent no additional tmux input.",
1981
+ openclaw_next_action: replayReceipt.delivered
1982
+ ? openClawYieldNextAction({
1983
+ conversationId: owner.conversation_id,
1984
+ sessionId: sessionIdForConversation(owner),
1985
+ turnId: turnIdForConversation(owner),
1986
+ source: "terminal_control",
1987
+ callbackExpected: Boolean(owner.gateway_method ?? ledger.callback_expected)
1988
+ })
1989
+ : {
1990
+ action: "inspect",
1991
+ conversation_id: owner.conversation_id,
1992
+ session_id: sessionIdForConversation(owner),
1993
+ turn_id: turnIdForConversation(owner),
1994
+ do_not_retry: true,
1995
+ reason: acceptanceInvalid
1996
+ ? "Stored native acceptance evidence is invalid."
1997
+ : "Only terminal transport is proven."
1998
+ }
1999
+ });
2000
+ return true;
2001
+ }
2002
+ function validateStoredTerminalSubmissionMatch({ owner, receipt, options, terminalControl, requestText, requestHash, expectedStoreDir, expectedSessionId, expectedTurnId, expectedMessageType, expectedStatePath }) {
2003
+ const messageId = required(stringValue(receipt.message_id), "stored terminal receipt message id is required");
2004
+ const ownerStoreDir = managedSessionStoreDirForConversation(owner);
2005
+ const nativeTakeover = isRecord(owner.native_session_takeover)
2006
+ ? owner.native_session_takeover
2007
+ : undefined;
2008
+ const storedControl = terminalControlFromTakeover(nativeTakeover);
2009
+ const receiptStoreDir = stringValue(receipt.store_dir) ?? ownerStoreDir;
2010
+ const receiptSessionId = stringValue(receipt.session_id) ??
2011
+ sessionIdForConversation(owner);
2012
+ const receiptTurnId = stringValue(receipt.turn_id) ??
2013
+ turnIdForConversation(owner);
2014
+ const receiptOpenClawSession = stringValue(receipt.openclaw_session) ??
2015
+ owner.openclaw_session;
2016
+ const receiptTarget = stringValue(receipt.terminal_target) ??
2017
+ storedControl?.target;
2018
+ const receiptSocketPath = receipt.terminal_socket_path === null
2019
+ ? undefined
2020
+ : stringValue(receipt.terminal_socket_path) ?? storedControl?.socketPath;
2021
+ const receiptPanePid = Number(receipt.terminal_pane_pid ?? storedControl?.panePid);
2022
+ const requestedOpenClawSession = stringValue(options.openclawSession);
2023
+ if (!ownerStoreDir ||
2024
+ !receiptStoreDir ||
2025
+ path.resolve(ownerStoreDir) !== path.resolve(expectedStoreDir) ||
2026
+ path.resolve(receiptStoreDir) !== path.resolve(expectedStoreDir) ||
2027
+ !storedControl ||
2028
+ receiptTarget !== terminalControl.target ||
2029
+ receiptSocketPath !== terminalControl.socketPath ||
2030
+ receiptPanePid !== Number(terminalControl.panePid) ||
2031
+ receiptSessionId !== sessionIdForConversation(owner) ||
2032
+ receiptTurnId !== turnIdForConversation(owner) ||
2033
+ (expectedSessionId && receiptSessionId !== expectedSessionId) ||
2034
+ (expectedTurnId && receiptTurnId !== expectedTurnId) ||
2035
+ (expectedStatePath && !sameCanonicalStatePath(owner.state_path, expectedStatePath)) ||
2036
+ (requestedOpenClawSession && receiptOpenClawSession !== requestedOpenClawSession) ||
2037
+ stringValue(receipt.request_hash) !== requestHash ||
2038
+ (stringValue(receipt.executor_kind) !== undefined &&
2039
+ stringValue(receipt.executor_kind) !== executorForConversation(owner).kind)) {
2040
+ throw new Error(`terminal idempotency key ${messageId} does not match its original ` +
2041
+ "Store, Session, Turn, OpenClaw session, or tmux pane binding; no terminal input was sent");
2042
+ }
2043
+ const logPath = stringValue(owner.event_log_path);
2044
+ let loggedMessages = [];
2045
+ if (logPath) {
2046
+ try {
2047
+ loggedMessages = readNdjsonLog(logPath)
2048
+ .filter((event) => isRecord(event.message) && event.message.id === messageId)
2049
+ .map((event) => event.message);
2050
+ }
2051
+ catch {
2052
+ loggedMessages = [];
2053
+ }
2054
+ }
2055
+ if (loggedMessages.length > 1) {
2056
+ throw new Error(`terminal idempotency key ${messageId} has duplicate durable messages`);
2057
+ }
2058
+ const loggedMessage = loggedMessages[0];
2059
+ if (isRecord(loggedMessage) &&
2060
+ (loggedMessage.type !== expectedMessageType ||
2061
+ loggedMessage.body !== requestText ||
2062
+ loggedMessage.conversation_id !== owner.conversation_id ||
2063
+ loggedMessage.session_id !== sessionIdForConversation(owner) ||
2064
+ loggedMessage.turn_id !== turnIdForConversation(owner))) {
2065
+ throw new Error(`terminal idempotency key ${messageId} does not match its durable message; no terminal input was sent`);
2066
+ }
2067
+ const durableMessageType = stringValue(receipt.message_type) ??
2068
+ (isRecord(loggedMessage) ? stringValue(loggedMessage.type) : undefined);
2069
+ const durableMessageBodyHash = stringValue(receipt.message_body_hash) ??
2070
+ (isRecord(loggedMessage) && typeof loggedMessage.body === "string"
2071
+ ? createHash("sha256").update(loggedMessage.body).digest("hex")
2072
+ : undefined);
2073
+ const expectedMessageBodyHash = createHash("sha256")
2074
+ .update(requestText)
2075
+ .digest("hex");
2076
+ if (durableMessageType !== expectedMessageType ||
2077
+ durableMessageBodyHash !== expectedMessageBodyHash) {
2078
+ throw new Error(`terminal idempotency key ${messageId} was already used for a different message; no terminal input was sent`);
2079
+ }
2080
+ return loggedMessage;
2081
+ }
2082
+ function replayExactStoredTerminalSubmission({ options, terminalControl, requestText, requestHash, messageId, expectedStoreDir, expectedSessionId, expectedTurnId, expectedMessageType, expectedStatePath }) {
2083
+ const allMatches = listConversations(expectedStoreDir).flatMap((owner) => terminalBridgeSubmissionReceipts(owner)
2084
+ .filter((receipt) => stringValue(receipt.message_id) === messageId)
2085
+ .map((receipt) => ({ owner, receipt })));
2086
+ const validatedMatches = allMatches.map((match) => ({
2087
+ ...match,
2088
+ loggedMessage: validateStoredTerminalSubmissionMatch({
2089
+ ...match,
2090
+ options,
2091
+ terminalControl,
2092
+ requestText,
2093
+ requestHash,
2094
+ expectedStoreDir,
2095
+ expectedSessionId,
2096
+ expectedTurnId,
2097
+ expectedMessageType,
2098
+ expectedStatePath
2099
+ })
2100
+ }));
2101
+ const matches = validatedMatches.filter(({ receipt }) => !(receipt.status === "aborted" && receipt.safe_to_retry === true));
2102
+ if (matches.length > 1) {
2103
+ throw new Error(`terminal idempotency key ${messageId} has multiple durable receipts in one Store`);
2104
+ }
2105
+ const match = matches[0];
2106
+ if (!match) {
2107
+ for (const { owner, receipt } of validatedMatches) {
2108
+ assertSafeAbortedTerminalRetryBinding({
2109
+ owner,
2110
+ receipt,
2111
+ storeDir: expectedStoreDir,
2112
+ terminalControl,
2113
+ messageId
2114
+ });
2115
+ }
2116
+ // A prepared-stage failure with a restored ledger proves that tmux was
2117
+ // untouched. The same exact id may therefore start a fresh attempt; all
2118
+ // immutable boundaries above were still validated before allowing it.
2119
+ return false;
2120
+ }
2121
+ const { owner, receipt, loggedMessage } = match;
2122
+ const nativeTakeover = isRecord(owner.native_session_takeover)
2123
+ ? owner.native_session_takeover
2124
+ : undefined;
2125
+ const currentSubmission = terminalBridgeSubmission(owner);
2126
+ const isCurrentSubmission = stringValue(currentSubmission?.message_id) === messageId;
2127
+ if (isCurrentSubmission &&
2128
+ !TERMINAL_DISPATCH_RELEASE_STATUSES.has(owner.status)) {
2129
+ return false;
2130
+ }
2131
+ if (!["submitted", "enter_dispatched", "agent_accepted"].includes(String(receipt.status))) {
2132
+ throw new Error(`terminal idempotency key ${messageId} has durable ${String(receipt.status)} ` +
2133
+ "proof and must not be retried; no terminal input was sent");
2134
+ }
2135
+ const executor = executorForConversation(owner);
2136
+ const nativeThreadId = stringValue(receipt.native_thread_id) ??
2137
+ stringValue(owner.native_thread_id) ??
2138
+ stringValue(nativeTakeover?.terminal_agent_session_id) ??
2139
+ stringValue(nativeTakeover?.terminal_agent_expected_session_id) ??
2140
+ sessionIdForConversation(owner);
2141
+ const proofLevel = String(receipt.status);
2142
+ const replayReceipt = terminalSubmissionReplayReceipt({
2143
+ proofLevel,
2144
+ evidence: receipt.acceptance_evidence,
2145
+ expected: {
2146
+ source: executor.kind === "codex"
2147
+ ? "codex_rollout"
2148
+ : "claude_transcript",
2149
+ nativeThreadId,
2150
+ requestHash
2151
+ }
2152
+ });
2153
+ const replayedMessage = isRecord(loggedMessage)
2154
+ ? loggedMessage
2155
+ : createMessage({
2156
+ conversation: owner,
2157
+ id: messageId,
2158
+ from: "openclaw",
2159
+ to: executor.actor,
2160
+ type: expectedMessageType,
2161
+ body: requestText,
2162
+ metadata: {
2163
+ executor_kind: executor.kind,
2164
+ executor_session: executor.session
2165
+ }
2166
+ });
2167
+ const acceptanceInvalid = replayReceipt.submission_outcome === "uncertain";
2168
+ const callbackExpected = Boolean(owner.gateway_method);
2169
+ printJson({
2170
+ session_id: sessionIdForConversation(owner),
2171
+ turn_id: turnIdForConversation(owner),
2172
+ conversation: owner,
2173
+ message: replayedMessage,
2174
+ delivered: replayReceipt.delivered,
2175
+ status: replayReceipt.status,
2176
+ submission_outcome: replayReceipt.submission_outcome,
2177
+ background: true,
2178
+ callback_expected: !acceptanceInvalid && callbackExpected,
2179
+ terminal_control: terminalControl,
2180
+ executor,
2181
+ replayed: true,
2182
+ delivery_receipt: replayReceipt.delivery_receipt,
2183
+ ...(replayReceipt.do_not_retry
2184
+ ? { do_not_retry: replayReceipt.do_not_retry }
2185
+ : {}),
2186
+ reason: replayReceipt.delivered
2187
+ ? "AKK replayed the stored durable native acceptance receipt and sent no additional tmux input."
2188
+ : acceptanceInvalid
2189
+ ? "AKK replayed an invalid stored native acceptance receipt as uncertain and sent no additional tmux input."
2190
+ : "AKK replayed the stored transport proof without upgrading it and sent no additional tmux input.",
2191
+ openclaw_next_action: replayReceipt.delivered
2192
+ ? openClawYieldNextAction({
2193
+ conversationId: owner.conversation_id,
2194
+ sessionId: sessionIdForConversation(owner),
2195
+ turnId: turnIdForConversation(owner),
2196
+ source: "terminal_control",
2197
+ callbackExpected
2198
+ })
2199
+ : {
2200
+ action: "inspect",
2201
+ conversation_id: owner.conversation_id,
2202
+ session_id: sessionIdForConversation(owner),
2203
+ turn_id: turnIdForConversation(owner),
2204
+ do_not_retry: true,
2205
+ reason: acceptanceInvalid
2206
+ ? "Stored native acceptance evidence is invalid."
2207
+ : "Only terminal transport is proven."
2208
+ }
2209
+ });
2210
+ return true;
2211
+ }
2212
+ function terminalSubmissionLastProvenStage(status, previous) {
2213
+ if (status === "agent_accepted") {
2214
+ return "agent_accepted";
2215
+ }
2216
+ if (status === "enter_dispatched" ||
2217
+ status === "submitted" ||
2218
+ status === "not_accepted") {
2219
+ return "enter_dispatched";
2220
+ }
2221
+ if (status === "uncertain" &&
2222
+ ["prepared", "text_injected", "enter_dispatched", "agent_accepted"].includes(previous ?? "")) {
2223
+ return previous;
2224
+ }
2225
+ return status === "text_injected" ? "text_injected" : "prepared";
2226
+ }
1406
2227
  function terminalBridgeSubmission(conversation) {
1407
2228
  const nativeTakeover = isRecord(conversation?.native_session_takeover)
1408
2229
  ? conversation.native_session_takeover
@@ -1411,10 +2232,45 @@ function terminalBridgeSubmission(conversation) {
1411
2232
  ? nativeTakeover.terminal_bridge_submission
1412
2233
  : undefined;
1413
2234
  }
2235
+ function terminalBridgeSubmissionReceipts(conversation) {
2236
+ const nativeTakeover = isRecord(conversation.native_session_takeover)
2237
+ ? conversation.native_session_takeover
2238
+ : undefined;
2239
+ const historyValue = nativeTakeover?.terminal_bridge_submission_receipts;
2240
+ if (historyValue !== undefined && !Array.isArray(historyValue)) {
2241
+ throw new Error("terminal submission receipt history is malformed");
2242
+ }
2243
+ const receipts = (Array.isArray(historyValue) ? historyValue : []).map((value) => {
2244
+ if (!isRecord(value) || !stringValue(value.message_id)) {
2245
+ throw new Error("terminal submission receipt history is malformed");
2246
+ }
2247
+ return value;
2248
+ });
2249
+ const ids = new Set();
2250
+ for (const receipt of receipts) {
2251
+ const id = String(receipt.message_id);
2252
+ if (ids.has(id)) {
2253
+ throw new Error(`terminal submission receipt ${id} is duplicated`);
2254
+ }
2255
+ ids.add(id);
2256
+ }
2257
+ const current = terminalBridgeSubmission(conversation);
2258
+ const currentId = stringValue(current?.message_id);
2259
+ if (current && currentId && ids.has(currentId)) {
2260
+ const historical = receipts.find((receipt) => stringValue(receipt.message_id) === currentId);
2261
+ if (!historical || canonicalJson(historical) !== canonicalJson(current)) {
2262
+ throw new Error(`terminal submission receipt ${currentId} conflicts with its current generation`);
2263
+ }
2264
+ }
2265
+ return current && currentId && !ids.has(currentId)
2266
+ ? [...receipts, current]
2267
+ : receipts;
2268
+ }
1414
2269
  function unresolvedTerminalBridgeSubmission(conversation) {
1415
2270
  const submission = terminalBridgeSubmission(conversation);
1416
2271
  return submission &&
1417
- (submission.status === "prepared" || submission.status === "uncertain")
2272
+ ["prepared", "text_injected", "enter_dispatched", "not_accepted", "uncertain"]
2273
+ .includes(String(submission.status))
1418
2274
  ? submission
1419
2275
  : undefined;
1420
2276
  }
@@ -1429,10 +2285,18 @@ function assertNoUnresolvedTerminalBridgeSubmission(storeDir, terminalControl, c
1429
2285
  if (candidate.conversation_id === currentConversationId ||
1430
2286
  !isActiveStatus(candidate.status) ||
1431
2287
  !submission ||
1432
- !["prepared", "submitted", "uncertain"].includes(String(submission.status))) {
2288
+ ![
2289
+ "prepared",
2290
+ "text_injected",
2291
+ "enter_dispatched",
2292
+ "agent_accepted",
2293
+ "submitted",
2294
+ "not_accepted",
2295
+ "uncertain"
2296
+ ].includes(String(submission.status))) {
1433
2297
  continue;
1434
2298
  }
1435
- if (submission.status === "submitted" &&
2299
+ if (["submitted", "agent_accepted"].includes(String(submission.status)) &&
1436
2300
  stringValue(submission.request_hash) !== requestHash) {
1437
2301
  continue;
1438
2302
  }
@@ -2291,8 +3155,12 @@ function terminalDispatchOwnership(terminalControl) {
2291
3155
  }
2292
3156
  if (![
2293
3157
  "prepared",
3158
+ "text_injected",
3159
+ "enter_dispatched",
3160
+ "agent_accepted",
2294
3161
  "dispatching",
2295
3162
  "submitted",
3163
+ "not_accepted",
2296
3164
  "uncertain"
2297
3165
  ].includes(String(ledger.status))) {
2298
3166
  return { state: "none" };
@@ -2320,7 +3188,16 @@ function terminalDispatchOwnership(terminalControl) {
2320
3188
  : undefined;
2321
3189
  const ledgerMessageId = stringValue(ledger.message_id);
2322
3190
  const ownerMessageId = stringValue(ownerTakeover?.terminal_bridge_message_id);
2323
- if (["prepared", "dispatching", "submitted", "uncertain"].includes(String(ledger.status)) &&
3191
+ if ([
3192
+ "prepared",
3193
+ "text_injected",
3194
+ "enter_dispatched",
3195
+ "agent_accepted",
3196
+ "dispatching",
3197
+ "submitted",
3198
+ "not_accepted",
3199
+ "uncertain"
3200
+ ].includes(String(ledger.status)) &&
2324
3201
  ledgerMessageId &&
2325
3202
  ownerMessageId !== ledgerMessageId) {
2326
3203
  return {
@@ -3985,11 +4862,18 @@ function assertTerminalLifecycleReady({ options, terminal, terminalStatus }) {
3985
4862
  `transition ${stringValue(ledger?.transition_id) ?? "with invalid identity"}`);
3986
4863
  }
3987
4864
  if (ledger &&
3988
- ["prepared", "dispatching", "uncertain"].includes(String(ledger.status))) {
4865
+ [
4866
+ "prepared",
4867
+ "text_injected",
4868
+ "enter_dispatched",
4869
+ "dispatching",
4870
+ "not_accepted",
4871
+ "uncertain"
4872
+ ].includes(String(ledger.status))) {
3989
4873
  throw new Error(`terminal ${terminal.terminalControl.target} has an unresolved ` +
3990
4874
  `${String(ledger.kind ?? "turn")} operation (${String(ledger.status)})`);
3991
4875
  }
3992
- if (ledger?.status === "submitted") {
4876
+ if (ledger && ["submitted", "agent_accepted"].includes(String(ledger.status))) {
3993
4877
  const owner = loadTerminalDispatchLedgerOwner(ledger);
3994
4878
  if (!owner || !TERMINAL_DISPATCH_RELEASE_STATUSES.has(owner.status)) {
3995
4879
  throw new Error(`terminal ${terminal.terminalControl.target} still has a submitted operation`);
@@ -6000,6 +6884,7 @@ function prepareManagedSend({ options, statePath, logPath, messageBody, stateLoc
6000
6884
  }
6001
6885
  const message = createMessage({
6002
6886
  conversation,
6887
+ id: stringValue(options.messageId),
6003
6888
  from: "openclaw",
6004
6889
  to: executor.actor,
6005
6890
  type,
@@ -6060,7 +6945,6 @@ async function runSend(options) {
6060
6945
  }
6061
6946
  const rawStoreDir = storeDirFromOptions(options);
6062
6947
  const releaseTerminalLock = acquireFileLock(terminalBridgeSendLockPath(rawStoreDir, terminalConversation.terminalControl), { timeoutMs: 30000 });
6063
- let releaseStateLock;
6064
6948
  try {
6065
6949
  await withStoreWriterLeaseAsync(rawStoreDir, async () => {
6066
6950
  await recoverLifecycleFenceBeforeMutation({
@@ -6071,6 +6955,15 @@ async function runSend(options) {
6071
6955
  // protocol migration, rather than mutable Turn recency, is the only code
6072
6956
  // allowed to materialize Session records from existing Turns.
6073
6957
  ensureStoreWritable(rawStoreDir);
6958
+ if (replayExactActiveTerminalSubmission({
6959
+ options,
6960
+ terminalControl: terminalConversation.terminalControl,
6961
+ requestText: String(messageBody),
6962
+ expectedStoreDir: rawStoreDir,
6963
+ expectedMessageType: "task"
6964
+ })) {
6965
+ return;
6966
+ }
6074
6967
  const claimedSession = soleBoundManagedSessionClaimForTerminal(rawStoreDir, terminalConversation);
6075
6968
  const knownCodexCompanions = claimedSession
6076
6969
  ? codexAllowedCompanionSetForManagedSession({
@@ -6178,33 +7071,33 @@ async function runSend(options) {
6178
7071
  });
6179
7072
  ensureStoreWritable(managed.conversation.store_dir);
6180
7073
  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
- });
7074
+ const releaseStateLock = acquireFileLock(`${managed.statePath}.lock`);
7075
+ try {
7076
+ await runTerminalControlSend({
7077
+ options,
7078
+ conversation: managed.conversation,
7079
+ nextConversation: managed.nextConversation,
7080
+ statePath: managed.statePath,
7081
+ logPath: managed.logPath,
7082
+ executor: managed.executor,
7083
+ message: managed.message,
7084
+ terminalControl: terminalConversation.terminalControl,
7085
+ terminalSendLockHeld: true,
7086
+ terminalStateLockHeld: true,
7087
+ storeWriterLeaseHeld: true,
7088
+ recordMessageAfterSend: true,
7089
+ recordRawAttachmentAfterSend: reusableTurn === undefined,
7090
+ allowedPreMaterializationIdentity,
7091
+ allowedAdditionalIdentities
7092
+ });
7093
+ }
7094
+ finally {
7095
+ releaseStateLock();
7096
+ }
6199
7097
  });
6200
7098
  }
6201
7099
  finally {
6202
- try {
6203
- releaseStateLock?.();
6204
- }
6205
- finally {
6206
- releaseTerminalLock();
6207
- }
7100
+ releaseTerminalLock();
6208
7101
  }
6209
7102
  return;
6210
7103
  }
@@ -6243,7 +7136,6 @@ async function runSend(options) {
6243
7136
  throw new Error(`session ${sessionId} is not attached to a live tmux terminal`);
6244
7137
  }
6245
7138
  const releaseTerminalLock = acquireFileLock(terminalBridgeSendLockPath(storeDir, resolvedTerminal.terminalControl), { timeoutMs: 30000 });
6246
- let releaseStateLock;
6247
7139
  try {
6248
7140
  await withStoreWriterLeaseAsync(storeDir, async () => {
6249
7141
  await recoverLifecycleFenceBeforeMutation({
@@ -6254,6 +7146,16 @@ async function runSend(options) {
6254
7146
  // atomic migration. Once protocol 3 is active, a missing Session is corrupt
6255
7147
  // state and must not be reconstructed from whichever Turn looks newest.
6256
7148
  ensureStoreWritable(storeDir);
7149
+ if (replayExactActiveTerminalSubmission({
7150
+ options,
7151
+ terminalControl: resolvedTerminal.terminalControl,
7152
+ requestText: String(messageBody),
7153
+ expectedStoreDir: storeDir,
7154
+ expectedSessionId: sessionId,
7155
+ expectedMessageType: "task"
7156
+ })) {
7157
+ return;
7158
+ }
6257
7159
  let currentSession = tryLoadManagedSession(storeDir, sessionId);
6258
7160
  const knownCodexCompanions = currentSession
6259
7161
  ? codexAllowedCompanionSetForManagedSession({
@@ -6344,32 +7246,32 @@ async function runSend(options) {
6344
7246
  });
6345
7247
  ensureStoreWritable(managed.conversation.store_dir);
6346
7248
  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
- });
6364
- });
6365
- }
6366
- finally {
6367
- try {
6368
- releaseStateLock?.();
6369
- }
6370
- finally {
6371
- releaseTerminalLock();
6372
- }
7249
+ const releaseStateLock = acquireFileLock(`${managed.statePath}.lock`);
7250
+ try {
7251
+ await runTerminalControlSend({
7252
+ options,
7253
+ conversation: managed.conversation,
7254
+ nextConversation: managed.nextConversation,
7255
+ statePath: managed.statePath,
7256
+ logPath: managed.logPath,
7257
+ executor: managed.executor,
7258
+ message: managed.message,
7259
+ terminalControl: resolvedTerminal.terminalControl,
7260
+ terminalSendLockHeld: true,
7261
+ terminalStateLockHeld: true,
7262
+ storeWriterLeaseHeld: true,
7263
+ recordMessageAfterSend: true,
7264
+ allowedPreMaterializationIdentity,
7265
+ allowedAdditionalIdentities
7266
+ });
7267
+ }
7268
+ finally {
7269
+ releaseStateLock();
7270
+ }
7271
+ });
7272
+ }
7273
+ finally {
7274
+ releaseTerminalLock();
6373
7275
  }
6374
7276
  }
6375
7277
  async function runRespond(options) {
@@ -6386,101 +7288,151 @@ async function runRespond(options) {
6386
7288
  async function runTurnResponse({ options, messageBody }) {
6387
7289
  const loaded = loadConversationFromOptions(options);
6388
7290
  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
7291
+ const initialConversation = loaded.conversation;
7292
+ const requestedOpenClawSession = stringValue(options.openclawSession);
7293
+ if (requestedOpenClawSession &&
7294
+ initialConversation.openclaw_session !== requestedOpenClawSession) {
7295
+ throw new Error(`turn ${turnIdForConversation(initialConversation)} belongs to a ` +
7296
+ "different OpenClaw session; no terminal input was sent");
7297
+ }
7298
+ const nativeTakeover = isRecord(initialConversation.native_session_takeover)
7299
+ ? initialConversation.native_session_takeover
6399
7300
  : 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;
7301
+ const storedTerminalControl = terminalControlFromTakeover(nativeTakeover);
7302
+ const nativeTerminalId = stringValue(nativeTakeover?.native_session_id);
7303
+ if (!storedTerminalControl || !nativeTerminalId) {
7304
+ throw new Error(`turn ${turnIdForConversation(initialConversation)} is not attached to a live tmux terminal`);
7305
+ }
7306
+ const liveTerminal = await createTerminalAgentBridge(options)
7307
+ .resolveConversationId(nativeTerminalId);
7308
+ if (!liveTerminal ||
7309
+ liveTerminal.agent !== executorForConversation(initialConversation).kind ||
7310
+ liveTerminal.terminalControl.target !== storedTerminalControl.target ||
7311
+ liveTerminal.terminalControl.socketPath !== storedTerminalControl.socketPath ||
7312
+ liveTerminal.terminalControl.panePid !== storedTerminalControl.panePid) {
7313
+ throw new Error(`turn ${turnIdForConversation(initialConversation)} is not attached to its expected live tmux terminal`);
7314
+ }
7315
+ const terminalControl = liveTerminal.terminalControl;
7316
+ const responseStoreDir = pathsForConversationDir(path.dirname(statePath)).storeDir;
7317
+ const releaseTerminalLock = acquireFileLock(terminalBridgeSendLockPath(responseStoreDir, terminalControl), { timeoutMs: 30000 });
6406
7318
  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
7319
+ await withStoreWriterLeaseAsync(responseStoreDir, async () => {
7320
+ // Legacy identity migration may write the Turn, so keep it inside the
7321
+ // canonical terminal -> Store writer -> state ordering as well.
7322
+ await migrateLegacyTerminalAgentIdentity({ ...loaded, options });
7323
+ const releaseStateLock = acquireFileLock(`${statePath}.lock`);
7324
+ try {
7325
+ const lockedConversation = loadState(statePath);
7326
+ const lockedTakeover = isRecord(lockedConversation.native_session_takeover)
7327
+ ? lockedConversation.native_session_takeover
7328
+ : undefined;
7329
+ const lockedControl = terminalControlFromTakeover(lockedTakeover);
7330
+ if (stringValue(lockedTakeover?.native_session_id) !== nativeTerminalId ||
7331
+ executorForConversation(lockedConversation).kind !== liveTerminal.agent ||
7332
+ !lockedControl ||
7333
+ lockedControl.target !== terminalControl.target ||
7334
+ lockedControl.socketPath !== terminalControl.socketPath ||
7335
+ lockedControl.panePid !== terminalControl.panePid) {
7336
+ throw new Error("terminal control changed while waiting to respond; refresh status and retry");
7337
+ }
7338
+ if (requestedOpenClawSession &&
7339
+ lockedConversation.openclaw_session !== requestedOpenClawSession) {
7340
+ throw new Error(`turn ${turnIdForConversation(lockedConversation)} belongs to a ` +
7341
+ "different OpenClaw session; no terminal input was sent");
7342
+ }
7343
+ if (replayExactActiveTerminalSubmission({
7344
+ options,
7345
+ terminalControl,
7346
+ requestText: String(messageBody),
7347
+ expectedStoreDir: responseStoreDir,
7348
+ expectedSessionId: sessionIdForConversation(lockedConversation),
7349
+ expectedTurnId: turnIdForConversation(lockedConversation),
7350
+ expectedMessageType: "answer",
7351
+ expectedStatePath: statePath
7352
+ })) {
7353
+ return;
7354
+ }
7355
+ if (lockedConversation.status !== "waiting_for_openclaw") {
7356
+ throw new Error(`cannot respond to turn ${turnIdForConversation(lockedConversation)}; ` +
7357
+ `turn is ${lockedConversation.status}, not waiting_for_openclaw`);
7358
+ }
7359
+ const prepared = prepareManagedSend({
7360
+ options: { ...options, type: "answer" },
7361
+ statePath,
7362
+ logPath,
7363
+ messageBody,
7364
+ stateLockHeld: true,
7365
+ persist: false
7366
+ });
7367
+ const preparedTakeover = isRecord(prepared.conversation.native_session_takeover)
7368
+ ? prepared.conversation.native_session_takeover
7369
+ : undefined;
7370
+ const terminalAgentPid = Number(preparedTakeover?.terminal_agent_pid);
7371
+ const responseManagedSession = tryLoadManagedSession(responseStoreDir, sessionIdForConversation(prepared.conversation));
7372
+ const responseCodexCompanions = prepared.executor.kind === "codex" && responseManagedSession
7373
+ ? codexAllowedCompanionSetForManagedSession({
7374
+ storeDir: responseStoreDir,
7375
+ session: responseManagedSession
7376
+ })
7377
+ : { additional: [] };
7378
+ const currentNativeIdentity = await resolveCurrentNativeAgentSessionIdentity({
7379
+ options,
7380
+ agent: prepared.executor.kind,
7381
+ pid: terminalAgentPid,
7382
+ cwd: terminalControl.currentPath,
7383
+ preferredSessionId: responseCodexCompanions.primary
7384
+ ? stringValue(preparedTakeover?.terminal_agent_session_id)
7385
+ : undefined,
7386
+ allowedCompanionIdentity: responseCodexCompanions.primary,
7387
+ allowedAdditionalIdentities: responseCodexCompanions.additional
7388
+ });
7389
+ assertNativeAgentIdentityForTurn({
7390
+ conversation: prepared.conversation,
7391
+ currentIdentity: currentNativeIdentity,
7392
+ operation: "respond to"
7393
+ });
7394
+ const currentTerminalControl = terminalControlFromTakeover(prepared.nativeTakeoverForSend);
7395
+ if (!currentTerminalControl ||
7396
+ currentTerminalControl.target !== terminalControl.target ||
7397
+ currentTerminalControl.socketPath !== terminalControl.socketPath ||
7398
+ currentTerminalControl.panePid !== terminalControl.panePid) {
7399
+ throw new Error("terminal control changed while waiting to respond; refresh status and retry");
7400
+ }
7401
+ const responseOptions = {
7402
+ ...options,
7403
+ type: "answer",
7404
+ agentTimeoutMinutes: options.agentTimeoutMinutes ??
7405
+ preparedTakeover?.terminal_bridge_inactivity_timeout_minutes ??
7406
+ DEFAULT_AGENT_TIMEOUT_MINUTES,
7407
+ agentHardTimeoutMinutes: options.agentHardTimeoutMinutes ??
7408
+ preparedTakeover?.terminal_bridge_hard_timeout_minutes ??
7409
+ DEFAULT_AGENT_HARD_TIMEOUT_MINUTES
7410
+ };
7411
+ await runTerminalControlSend({
7412
+ options: responseOptions,
7413
+ conversation: prepared.conversation,
7414
+ nextConversation: prepared.nextConversation,
7415
+ statePath,
7416
+ logPath,
7417
+ executor: prepared.executor,
7418
+ message: prepared.message,
7419
+ terminalControl: currentTerminalControl,
7420
+ terminalSendLockHeld: true,
7421
+ terminalStateLockHeld: true,
7422
+ storeWriterLeaseHeld: true,
7423
+ recordMessageAfterSend: true,
7424
+ allowedPreMaterializationIdentity: responseCodexCompanions.primary,
7425
+ allowedAdditionalIdentities: responseCodexCompanions.additional,
7426
+ continuingTurnResponse: true
7427
+ });
7428
+ }
7429
+ finally {
7430
+ releaseStateLock();
7431
+ }
6475
7432
  });
6476
7433
  }
6477
7434
  finally {
6478
- try {
6479
- releaseStateLock?.();
6480
- }
6481
- finally {
6482
- releaseTerminalLock();
6483
- }
7435
+ releaseTerminalLock();
6484
7436
  }
6485
7437
  }
6486
7438
  async function runApprove(options) {
@@ -7167,14 +8119,18 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
7167
8119
  "refresh AKK list and use its exact recovery action");
7168
8120
  }
7169
8121
  if (previousDispatchLedger?.status === "prepared" ||
8122
+ previousDispatchLedger?.status === "text_injected" ||
7170
8123
  previousDispatchLedger?.status === "dispatching" ||
7171
- previousDispatchLedger?.status === "uncertain") {
8124
+ previousDispatchLedger?.status === "uncertain" ||
8125
+ previousDispatchLedger?.status === "not_accepted") {
7172
8126
  throw new Error(`terminal ${terminalControl.target} has a terminal-level ` +
7173
8127
  `${String(previousDispatchLedger.status)} dispatch owned by ` +
7174
8128
  `${stringValue(previousDispatchLedger.conversation_id) ?? "an unknown conversation"}; ` +
7175
8129
  "inspect the shared tmux pane and explicitly close that AKK conversation before retrying");
7176
8130
  }
7177
- if (previousDispatchLedger?.status === "submitted") {
8131
+ if (previousDispatchLedger?.status === "submitted" ||
8132
+ previousDispatchLedger?.status === "enter_dispatched" ||
8133
+ previousDispatchLedger?.status === "agent_accepted") {
7178
8134
  const owner = loadTerminalDispatchLedgerOwner(previousDispatchLedger);
7179
8135
  if (!owner) {
7180
8136
  throw new Error(`terminal ${terminalControl.target} has a submitted dispatch whose ` +
@@ -7194,6 +8150,14 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
7194
8150
  stringValue(previousDispatchLedger.message_id) === message.id &&
7195
8151
  sameCanonicalStatePath(previousDispatchLedger.state_path, statePath));
7196
8152
  if (exactDispatchReplay) {
8153
+ const proofLevel = String(previousDispatchLedger.status);
8154
+ const replayReceipt = terminalSubmissionReplayReceipt({
8155
+ proofLevel,
8156
+ evidence: previousDispatchLedger.acceptance_evidence,
8157
+ expected: terminalAcceptanceEvidenceExpectation(owner, terminalPayload)
8158
+ });
8159
+ const accepted = replayReceipt.delivered;
8160
+ const acceptanceInvalid = replayReceipt.submission_outcome === "uncertain";
7197
8161
  const receiptConversationId = stringValue(previousDispatchLedger.conversation_id) ??
7198
8162
  owner.conversation_id;
7199
8163
  const receiptSessionId = sessionIdForConversation(owner);
@@ -7217,24 +8181,43 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
7217
8181
  turn_id: receiptTurnId
7218
8182
  }
7219
8183
  },
7220
- delivered: true,
7221
- status: "async_pending",
8184
+ delivered: replayReceipt.delivered,
8185
+ status: replayReceipt.status,
8186
+ submission_outcome: replayReceipt.submission_outcome,
7222
8187
  background: true,
7223
- callback_expected: Boolean(owner.gateway_method ??
8188
+ callback_expected: !acceptanceInvalid && Boolean(owner.gateway_method ??
7224
8189
  previousDispatchLedger.callback_expected),
7225
8190
  terminal_control: terminalControl,
7226
8191
  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
- })
8192
+ replayed: replayReceipt.replayed,
8193
+ delivery_receipt: replayReceipt.delivery_receipt,
8194
+ ...(replayReceipt.do_not_retry
8195
+ ? { do_not_retry: replayReceipt.do_not_retry }
8196
+ : {}),
8197
+ reason: accepted
8198
+ ? "AKK replayed the durable native acceptance receipt for an identical active terminal request and did not send tmux input again."
8199
+ : acceptanceInvalid
8200
+ ? `AKK refused to replay an invalid native acceptance receipt (${replayReceipt.evidence_error ?? "evidence validation failed"}); no tmux input was sent.`
8201
+ : "AKK replayed the original transport-level receipt without upgrading it to native acceptance and did not send tmux input again.",
8202
+ openclaw_next_action: accepted
8203
+ ? openClawYieldNextAction({
8204
+ conversationId: receiptConversationId,
8205
+ sessionId: receiptSessionId,
8206
+ turnId: receiptTurnId,
8207
+ source: "terminal_control",
8208
+ callbackExpected: Boolean(owner.gateway_method ??
8209
+ previousDispatchLedger.callback_expected)
8210
+ })
8211
+ : {
8212
+ action: "inspect",
8213
+ conversation_id: receiptConversationId,
8214
+ session_id: receiptSessionId,
8215
+ turn_id: receiptTurnId,
8216
+ do_not_retry: true,
8217
+ reason: acceptanceInvalid
8218
+ ? "The stored native acceptance evidence is invalid; inspect and explicitly close this Turn."
8219
+ : "Only terminal transport is proven; wait for native acceptance or inspect the shared pane."
8220
+ }
7238
8221
  });
7239
8222
  return;
7240
8223
  }
@@ -7288,6 +8271,7 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
7288
8271
  messageId: message.id
7289
8272
  };
7290
8273
  let preSendScreenFingerprint;
8274
+ let codexRolloutAcceptanceAnchor;
7291
8275
  let claudeTranscriptAnchor;
7292
8276
  const claudeHome = executor.kind === "claude"
7293
8277
  ? path.resolve(expandHome(options.claudeHome) ?? defaultClaudeHome())
@@ -7322,7 +8306,16 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
7322
8306
  if (bridge) {
7323
8307
  preSendScreenFingerprint = stringValue(status.screen.digest) ??
7324
8308
  terminalBridgeScreenFingerprint(status.screen.excerpt);
7325
- if (executor.kind === "claude") {
8309
+ if (executor.kind === "codex") {
8310
+ codexRolloutAcceptanceAnchor = captureCodexAcceptanceAnchorForSend({
8311
+ options,
8312
+ currentIdentity: currentNativeIdentity,
8313
+ expectedNativeThreadId: expectedManagedNativeThreadId,
8314
+ allowedPreMaterializationIdentity,
8315
+ needsPostSendNativeBinding
8316
+ });
8317
+ }
8318
+ else {
7326
8319
  claudeTranscriptAnchor = captureClaudeTranscriptAnchor({
7327
8320
  sessionId: preSendRuntime.sessionId,
7328
8321
  cwd: preSendRuntime.cwd,
@@ -7348,6 +8341,7 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
7348
8341
  agentTimeoutMinutes,
7349
8342
  agentHardTimeoutMinutes,
7350
8343
  preSendScreenFingerprint,
8344
+ codexRolloutAcceptanceAnchor,
7351
8345
  claudeTranscriptAnchor,
7352
8346
  claudeHome
7353
8347
  })
@@ -7355,6 +8349,8 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
7355
8349
  const preparedConversation = withTerminalBridgeSubmission({
7356
8350
  conversation: bridgeConversation,
7357
8351
  messageId: message.id,
8352
+ messageType: message.type,
8353
+ messageBody: String(message.body),
7358
8354
  requestText: terminalPayload,
7359
8355
  status: "prepared",
7360
8356
  preparedAt: bridgeStartedAt
@@ -7367,6 +8363,7 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
7367
8363
  session_id: sessionIdForConversation(preparedConversation),
7368
8364
  turn_id: turnIdForConversation(preparedConversation),
7369
8365
  message_id: message.id,
8366
+ message_type: message.type,
7370
8367
  request_hash: terminalRequestHash,
7371
8368
  prepared_at: bridgeStartedAt,
7372
8369
  dispatcher_pid: process.pid,
@@ -7435,32 +8432,6 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
7435
8432
  request_hash: terminalBridgeRequestFingerprint(terminalPayload),
7436
8433
  dispatcher_pid: process.pid
7437
8434
  });
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
8435
  }
7465
8436
  catch (error) {
7466
8437
  const abortedAt = new Date().toISOString();
@@ -7490,18 +8461,44 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
7490
8461
  failed_at: abortedAt,
7491
8462
  failure_reason: "terminal submission setup failed before tmux input"
7492
8463
  }
7493
- : conversation;
8464
+ : {
8465
+ ...preparedConversation,
8466
+ status: conversation.status,
8467
+ ...(conversation.idle_since
8468
+ ? { idle_since: conversation.idle_since }
8469
+ : {})
8470
+ };
7494
8471
  const abortedConversation = withTerminalBridgeSubmission({
7495
8472
  conversation: failureBase,
7496
8473
  messageId: message.id,
8474
+ messageType: message.type,
8475
+ messageBody: String(message.body),
7497
8476
  requestText: terminalPayload,
7498
8477
  status: "aborted",
7499
8478
  preparedAt: bridgeStartedAt,
7500
8479
  abortedAt,
7501
- error: errorMessage
8480
+ error: errorMessage,
8481
+ safeToRetry: dispatchLedgerRestored
7502
8482
  });
8483
+ let abortedStatePersisted = false;
7503
8484
  try {
8485
+ if (process.env.AKK_TEST_ABORTED_STATE_PERSISTENCE_FAILURE === "1") {
8486
+ throw new Error("injected aborted submission state persistence failure");
8487
+ }
7504
8488
  saveState(statePath, abortedConversation);
8489
+ abortedStatePersisted = true;
8490
+ }
8491
+ catch (persistenceError) {
8492
+ runtimeLog("error", "terminal_message_submit_aborted_persist_failed", {
8493
+ conversation_id: abortedConversation.conversation_id,
8494
+ terminal_target: terminalControl.target,
8495
+ error: persistenceError instanceof Error
8496
+ ? persistenceError.message
8497
+ : String(persistenceError)
8498
+ });
8499
+ }
8500
+ const safeToRetry = dispatchLedgerRestored && abortedStatePersisted;
8501
+ try {
7505
8502
  appendEvent(logPath, {
7506
8503
  ts: abortedAt,
7507
8504
  conversation_id: abortedConversation.conversation_id,
@@ -7510,11 +8507,11 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
7510
8507
  executor,
7511
8508
  terminal_control: terminalControl,
7512
8509
  error: textSummary(errorMessage),
7513
- safe_to_retry: dispatchLedgerRestored
8510
+ safe_to_retry: safeToRetry
7514
8511
  });
7515
8512
  }
7516
8513
  catch (persistenceError) {
7517
- runtimeLog("error", "terminal_message_submit_aborted_persist_failed", {
8514
+ runtimeLog("error", "terminal_message_submit_aborted_event_failed", {
7518
8515
  conversation_id: abortedConversation.conversation_id,
7519
8516
  terminal_target: terminalControl.target,
7520
8517
  error: persistenceError instanceof Error
@@ -7522,16 +8519,32 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
7522
8519
  : String(persistenceError)
7523
8520
  });
7524
8521
  }
8522
+ const reportedAbortedConversation = safeToRetry
8523
+ ? abortedConversation
8524
+ : withTerminalBridgeSubmission({
8525
+ conversation: abortedConversation,
8526
+ messageId: message.id,
8527
+ messageType: message.type,
8528
+ messageBody: String(message.body),
8529
+ requestText: terminalPayload,
8530
+ status: "aborted",
8531
+ preparedAt: bridgeStartedAt,
8532
+ abortedAt,
8533
+ error: errorMessage,
8534
+ safeToRetry: false
8535
+ });
7525
8536
  runtimeLog("error", "terminal_message_submit_aborted", {
7526
8537
  conversation_id: abortedConversation.conversation_id,
7527
8538
  terminal_target: terminalControl.target,
7528
8539
  error: errorMessage,
7529
- safe_to_retry: dispatchLedgerRestored
8540
+ safe_to_retry: safeToRetry,
8541
+ dispatch_ledger_restored: dispatchLedgerRestored,
8542
+ aborted_state_persisted: abortedStatePersisted
7530
8543
  });
7531
8544
  printJson({
7532
8545
  session_id: sessionIdForConversation(abortedConversation),
7533
8546
  turn_id: turnIdForConversation(abortedConversation),
7534
- conversation: abortedConversation,
8547
+ conversation: reportedAbortedConversation,
7535
8548
  message,
7536
8549
  delivered: false,
7537
8550
  status: "submission_aborted",
@@ -7541,32 +8554,108 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
7541
8554
  terminal_control: terminalControl,
7542
8555
  monitor_pid: bridgeMonitor?.pid ?? null,
7543
8556
  executor,
7544
- safe_to_retry: dispatchLedgerRestored,
7545
- do_not_retry: !dispatchLedgerRestored,
7546
- reason: dispatchLedgerRestored
8557
+ safe_to_retry: safeToRetry,
8558
+ do_not_retry: !safeToRetry,
8559
+ reason: safeToRetry
7547
8560
  ? "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.",
8561
+ : !dispatchLedgerRestored
8562
+ ? "AKK failed before tmux input but could not restore the terminal dispatch ledger; inspect and close the conversation before retrying."
8563
+ : "AKK failed before tmux input but could not persist the aborted receipt; inspect the conversation before retrying.",
7549
8564
  openclaw_next_action: {
7550
- action: dispatchLedgerRestored ? "retry" : "inspect",
8565
+ action: safeToRetry ? "retry" : "inspect",
7551
8566
  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
8567
+ session_id: sessionIdForConversation(reportedAbortedConversation),
8568
+ turn_id: turnIdForConversation(reportedAbortedConversation),
8569
+ safe_to_retry: safeToRetry,
8570
+ do_not_retry: !safeToRetry,
8571
+ reason: safeToRetry
7557
8572
  ? "The failure occurred before any tmux input."
7558
- : "The terminal ledger could not be restored automatically."
8573
+ : !dispatchLedgerRestored
8574
+ ? "The terminal ledger could not be restored automatically."
8575
+ : "The aborted receipt could not be made durable."
7559
8576
  }
7560
8577
  });
7561
8578
  return;
7562
8579
  }
7563
8580
  let deliveredConversation;
8581
+ let stagedConversation = preparedConversation;
8582
+ let textInjectedAt;
8583
+ let enterDispatchedAt;
8584
+ let acceptanceResult;
8585
+ let bookkeepingWarning;
8586
+ const recordPostTransportBookkeepingFailure = (phase, error) => {
8587
+ const message = error instanceof Error ? error.message : String(error);
8588
+ bookkeepingWarning ??= message;
8589
+ runtimeLog("warn", "terminal_message_post_transport_bookkeeping_failed", {
8590
+ conversation_id: stagedConversation.conversation_id,
8591
+ terminal_target: terminalControl.target,
8592
+ phase,
8593
+ error: message
8594
+ });
8595
+ };
7564
8596
  try {
7565
8597
  await terminalBridge.send(executor.kind, terminalControl, terminalPayload, {
7566
- runtime: preSendRuntime
8598
+ runtime: preSendRuntime,
8599
+ async onTransportStage(event) {
8600
+ const stageAt = new Date().toISOString();
8601
+ if (event.stage === "text_injected") {
8602
+ textInjectedAt = stageAt;
8603
+ }
8604
+ else {
8605
+ enterDispatchedAt = stageAt;
8606
+ }
8607
+ stagedConversation = withTerminalBridgeSubmission({
8608
+ conversation: stagedConversation,
8609
+ messageId: message.id,
8610
+ messageType: message.type,
8611
+ messageBody: String(message.body),
8612
+ requestText: terminalPayload,
8613
+ status: event.stage,
8614
+ preparedAt: bridgeStartedAt,
8615
+ textInjectedAt,
8616
+ enterDispatchedAt
8617
+ });
8618
+ saveState(statePath, stagedConversation);
8619
+ saveTerminalBridgeDispatchLedger(terminalControl, {
8620
+ ...terminalBindingLedgerFields(stagedConversation),
8621
+ status: event.stage,
8622
+ generation_id: message.id,
8623
+ conversation_id: stagedConversation.conversation_id,
8624
+ session_id: sessionIdForConversation(stagedConversation),
8625
+ turn_id: turnIdForConversation(stagedConversation),
8626
+ message_id: message.id,
8627
+ message_type: message.type,
8628
+ request_hash: terminalRequestHash,
8629
+ prepared_at: bridgeStartedAt,
8630
+ ...(textInjectedAt ? { text_injected_at: textInjectedAt } : {}),
8631
+ ...(enterDispatchedAt ? { enter_dispatched_at: enterDispatchedAt } : {}),
8632
+ dispatcher_pid: process.pid,
8633
+ state_path: statePath,
8634
+ event_log_path: logPath,
8635
+ callback_expected: Boolean(stagedConversation.gateway_method),
8636
+ previous_generation_id: stringValue(previousDispatchLedger?.generation_id) ??
8637
+ stringValue(previousDispatchLedger?.message_id)
8638
+ });
8639
+ try {
8640
+ appendEvent(logPath, {
8641
+ ts: stageAt,
8642
+ conversation_id: stagedConversation.conversation_id,
8643
+ event: `terminal_message_${event.stage}`,
8644
+ message_id: message.id,
8645
+ executor,
8646
+ terminal_control: terminalControl,
8647
+ request_hash: terminalRequestHash
8648
+ });
8649
+ }
8650
+ catch (error) {
8651
+ recordPostTransportBookkeepingFailure(event.stage, error);
8652
+ }
8653
+ }
7567
8654
  });
7568
- const submittedAt = new Date().toISOString();
7569
- let submittedBase = preparedConversation;
8655
+ if (!enterDispatchedAt) {
8656
+ throw new Error("terminal bridge returned without an enter_dispatched receipt");
8657
+ }
8658
+ let submittedBase = stagedConversation;
7570
8659
  if (needsPostSendNativeBinding) {
7571
8660
  let boundIdentity;
7572
8661
  let boundConversation;
@@ -7593,7 +8682,7 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
7593
8682
  throw new Error(`native agent created thread ${boundIdentity.sessionId}, expected ` +
7594
8683
  `${expectedNativeThreadId}`);
7595
8684
  }
7596
- boundConversation = withNativeAgentSessionIdentity(preparedConversation, boundIdentity);
8685
+ boundConversation = withNativeAgentSessionIdentity(stagedConversation, boundIdentity);
7597
8686
  const bindingStoreDir = managedSessionStoreDirForConversation(boundConversation);
7598
8687
  if (!bindingStoreDir) {
7599
8688
  throw new Error("managed Session Store is unavailable before native identity commit");
@@ -7633,16 +8722,16 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
7633
8722
  if (!boundIdentity) {
7634
8723
  const bindingFailedAt = new Date().toISOString();
7635
8724
  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";
8725
+ ? `AKK dispatched terminal input but could not verify the new native agent session: ${bindingError}`
8726
+ : "AKK dispatched terminal input but no exact native agent session appeared within the binding window";
7638
8727
  const unfencedBase = {
7639
- ...preparedConversation,
8728
+ ...stagedConversation,
7640
8729
  status: "stalled",
7641
8730
  stalled_at: bindingFailedAt,
7642
8731
  stalled_reason: bindingReason,
7643
8732
  native_session_takeover: {
7644
- ...(isRecord(preparedConversation.native_session_takeover)
7645
- ? preparedConversation.native_session_takeover
8733
+ ...(isRecord(stagedConversation.native_session_takeover)
8734
+ ? stagedConversation.native_session_takeover
7646
8735
  : {}),
7647
8736
  terminal_agent_identity_status: "unresolved_after_submit",
7648
8737
  terminal_agent_identity_error: textSummary(bindingReason)
@@ -7652,10 +8741,16 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
7652
8741
  const unfencedConversation = withTerminalBridgeSubmission({
7653
8742
  conversation: unfencedBase,
7654
8743
  messageId: message.id,
8744
+ messageType: message.type,
8745
+ messageBody: String(message.body),
7655
8746
  requestText: terminalPayload,
7656
- status: "submitted",
8747
+ status: "uncertain",
7657
8748
  preparedAt: bridgeStartedAt,
7658
- submittedAt
8749
+ textInjectedAt,
8750
+ enterDispatchedAt,
8751
+ uncertainAt: bindingFailedAt,
8752
+ error: bindingReason,
8753
+ lastProvenStage: "enter_dispatched"
7659
8754
  });
7660
8755
  quarantineManagedSessionBinding({
7661
8756
  conversation: unfencedConversation,
@@ -7663,15 +8758,18 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
7663
8758
  });
7664
8759
  saveTerminalBridgeDispatchLedger(terminalControl, {
7665
8760
  ...terminalBindingLedgerFields(unfencedConversation),
7666
- status: "submitted",
8761
+ status: "uncertain",
7667
8762
  generation_id: message.id,
7668
8763
  conversation_id: unfencedConversation.conversation_id,
7669
8764
  session_id: sessionIdForConversation(unfencedConversation),
7670
8765
  turn_id: turnIdForConversation(unfencedConversation),
7671
8766
  message_id: message.id,
8767
+ message_type: message.type,
7672
8768
  request_hash: terminalRequestHash,
7673
8769
  prepared_at: bridgeStartedAt,
7674
- submitted_at: submittedAt,
8770
+ text_injected_at: textInjectedAt,
8771
+ enter_dispatched_at: enterDispatchedAt,
8772
+ uncertain_at: bindingFailedAt,
7675
8773
  dispatcher_pid: process.pid,
7676
8774
  state_path: statePath,
7677
8775
  event_log_path: logPath,
@@ -7690,7 +8788,7 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
7690
8788
  executor,
7691
8789
  terminal_control: terminalControl,
7692
8790
  error: textSummary(bindingReason),
7693
- delivered: true,
8791
+ delivered: false,
7694
8792
  do_not_retry: true
7695
8793
  });
7696
8794
  runtimeLog("error", "terminal_agent_identity_binding_failed", {
@@ -7698,7 +8796,7 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
7698
8796
  agent: executor.kind,
7699
8797
  terminal_target: terminalControl.target,
7700
8798
  error: bindingReason,
7701
- delivered: true,
8799
+ delivered: false,
7702
8800
  do_not_retry: true
7703
8801
  });
7704
8802
  printJson({
@@ -7706,15 +8804,15 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
7706
8804
  turn_id: turnIdForConversation(unfencedConversation),
7707
8805
  conversation: unfencedConversation,
7708
8806
  message,
7709
- delivered: true,
7710
- status: "delivered_unfenced",
7711
- submission_outcome: "submitted",
8807
+ delivered: false,
8808
+ status: "submission_uncertain",
8809
+ submission_outcome: "uncertain",
7712
8810
  background: true,
7713
8811
  callback_expected: false,
7714
8812
  terminal_control: terminalControl,
7715
8813
  monitor_pid: bridgeMonitor?.pid ?? null,
7716
8814
  executor,
7717
- delivery_receipt: "submitted",
8815
+ delivery_receipt: "enter_dispatched",
7718
8816
  do_not_retry: true,
7719
8817
  reason: bindingReason,
7720
8818
  openclaw_next_action: {
@@ -7730,40 +8828,152 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
7730
8828
  }
7731
8829
  submittedBase = boundConversation;
7732
8830
  }
7733
- deliveredConversation = withTerminalBridgeSubmission({
8831
+ acceptanceResult = await pollTerminalSubmissionAcceptance({
8832
+ options,
8833
+ executor: executor.kind,
7734
8834
  conversation: submittedBase,
8835
+ terminalControl,
8836
+ terminalBridge
8837
+ });
8838
+ const acceptanceResolvedAt = new Date().toISOString();
8839
+ const terminalStatus = acceptanceResult.outcome === "pending_acceptance"
8840
+ ? "enter_dispatched"
8841
+ : acceptanceResult.outcome;
8842
+ const outcomeBase = acceptanceResult.outcome === "not_accepted" ||
8843
+ acceptanceResult.outcome === "uncertain"
8844
+ ? {
8845
+ ...submittedBase,
8846
+ status: "stalled",
8847
+ stalled_at: acceptanceResolvedAt,
8848
+ stalled_reason: acceptanceResult.reason,
8849
+ updated_at: acceptanceResolvedAt
8850
+ }
8851
+ : submittedBase;
8852
+ deliveredConversation = withTerminalBridgeSubmission({
8853
+ conversation: outcomeBase,
7735
8854
  messageId: message.id,
8855
+ messageType: message.type,
8856
+ messageBody: String(message.body),
7736
8857
  requestText: terminalPayload,
7737
- status: "submitted",
8858
+ status: terminalStatus,
7738
8859
  preparedAt: bridgeStartedAt,
7739
- submittedAt
8860
+ textInjectedAt,
8861
+ enterDispatchedAt,
8862
+ ...(acceptanceResult.outcome === "agent_accepted"
8863
+ ? {
8864
+ agentAcceptedAt: acceptanceResolvedAt,
8865
+ acceptanceEvidence: acceptanceResult.evidence
8866
+ }
8867
+ : {}),
8868
+ ...(acceptanceResult.outcome === "not_accepted"
8869
+ ? { notAcceptedAt: acceptanceResolvedAt }
8870
+ : {}),
8871
+ ...(acceptanceResult.outcome === "uncertain"
8872
+ ? {
8873
+ uncertainAt: acceptanceResolvedAt,
8874
+ error: acceptanceResult.reason,
8875
+ lastProvenStage: "enter_dispatched"
8876
+ }
8877
+ : {})
7740
8878
  });
7741
8879
  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
- });
8880
+ try {
8881
+ if (process.env.AKK_TEST_FINAL_TERMINAL_LEDGER_FAILURE === "1") {
8882
+ throw new Error("injected final terminal ledger persistence failure");
8883
+ }
8884
+ saveTerminalBridgeDispatchLedger(terminalControl, {
8885
+ ...terminalBindingLedgerFields(deliveredConversation),
8886
+ status: terminalStatus,
8887
+ generation_id: message.id,
8888
+ conversation_id: deliveredConversation.conversation_id,
8889
+ session_id: sessionIdForConversation(deliveredConversation),
8890
+ turn_id: turnIdForConversation(deliveredConversation),
8891
+ message_id: message.id,
8892
+ message_type: message.type,
8893
+ request_hash: terminalRequestHash,
8894
+ prepared_at: bridgeStartedAt,
8895
+ text_injected_at: textInjectedAt,
8896
+ enter_dispatched_at: enterDispatchedAt,
8897
+ ...(acceptanceResult.outcome === "agent_accepted"
8898
+ ? {
8899
+ agent_accepted_at: acceptanceResolvedAt,
8900
+ acceptance_evidence: acceptanceResult.evidence
8901
+ }
8902
+ : {}),
8903
+ ...(acceptanceResult.outcome === "not_accepted"
8904
+ ? { not_accepted_at: acceptanceResolvedAt }
8905
+ : {}),
8906
+ ...(acceptanceResult.outcome === "uncertain"
8907
+ ? {
8908
+ uncertain_at: acceptanceResolvedAt,
8909
+ error: textSummary(acceptanceResult.reason)
8910
+ }
8911
+ : {}),
8912
+ dispatcher_pid: null,
8913
+ state_path: statePath,
8914
+ event_log_path: logPath,
8915
+ callback_expected: Boolean(deliveredConversation.gateway_method),
8916
+ previous_generation_id: stringValue(previousDispatchLedger?.generation_id) ??
8917
+ stringValue(previousDispatchLedger?.message_id)
8918
+ });
8919
+ }
8920
+ catch (error) {
8921
+ // State is the durable proof authority. Once a valid native ACK has
8922
+ // committed there, a lagging ledger is bookkeeping debt and must never
8923
+ // overwrite that strongest proof with `uncertain`.
8924
+ recordPostTransportBookkeepingFailure("final_terminal_ledger", error);
8925
+ }
8926
+ try {
8927
+ appendEvent(logPath, {
8928
+ ts: acceptanceResolvedAt,
8929
+ conversation_id: deliveredConversation.conversation_id,
8930
+ event: acceptanceResult.outcome === "agent_accepted"
8931
+ ? "terminal_message_agent_accepted"
8932
+ : acceptanceResult.outcome === "pending_acceptance"
8933
+ ? "terminal_message_acceptance_pending"
8934
+ : `terminal_message_${acceptanceResult.outcome}`,
8935
+ message_id: message.id,
8936
+ executor,
8937
+ terminal_control: terminalControl,
8938
+ delivery_receipt: terminalStatus,
8939
+ do_not_retry: acceptanceResult.outcome !== "agent_accepted"
8940
+ });
8941
+ }
8942
+ catch (error) {
8943
+ recordPostTransportBookkeepingFailure(terminalStatus, error);
8944
+ }
8945
+ if (bridge &&
8946
+ (acceptanceResult.outcome === "agent_accepted" ||
8947
+ acceptanceResult.outcome === "pending_acceptance")) {
8948
+ try {
8949
+ bridgeMonitor = startTerminalBridgeMonitorForConversation({
8950
+ conversation: deliveredConversation,
8951
+ statePath,
8952
+ logPath,
8953
+ options
8954
+ });
8955
+ if (bridgeMonitor) {
8956
+ appendEvent(logPath, {
8957
+ ts: new Date().toISOString(),
8958
+ conversation_id: deliveredConversation.conversation_id,
8959
+ event: "terminal_bridge_monitor_launch",
8960
+ pid: bridgeMonitor.pid ?? null,
8961
+ terminal_control: terminalControl,
8962
+ phase: acceptanceResult.outcome,
8963
+ agent_timeout_minutes: agentTimeoutMinutes,
8964
+ agent_hard_timeout_minutes: agentHardTimeoutMinutes
8965
+ });
8966
+ }
8967
+ }
8968
+ catch (error) {
8969
+ recordPostTransportBookkeepingFailure("monitor_launch", error);
8970
+ }
8971
+ }
7760
8972
  }
7761
8973
  catch (error) {
7762
8974
  const uncertainAt = new Date().toISOString();
7763
8975
  const errorMessage = error instanceof Error ? error.message : String(error);
7764
- const failureBase = !recordRawAttachmentAfterSend && terminalBridgeEnabled(conversation)
7765
- ? conversation
7766
- : preparedConversation;
8976
+ const failureBase = stagedConversation;
7767
8977
  const stalledFailureBase = {
7768
8978
  ...failureBase,
7769
8979
  status: "stalled",
@@ -7774,11 +8984,20 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
7774
8984
  const uncertainConversation = withTerminalBridgeSubmission({
7775
8985
  conversation: stalledFailureBase,
7776
8986
  messageId: message.id,
8987
+ messageType: message.type,
8988
+ messageBody: String(message.body),
7777
8989
  requestText: terminalPayload,
7778
8990
  status: "uncertain",
7779
8991
  preparedAt: bridgeStartedAt,
8992
+ textInjectedAt,
8993
+ enterDispatchedAt,
7780
8994
  uncertainAt,
7781
- error: errorMessage
8995
+ error: errorMessage,
8996
+ lastProvenStage: enterDispatchedAt
8997
+ ? "enter_dispatched"
8998
+ : textInjectedAt
8999
+ ? "text_injected"
9000
+ : "prepared"
7782
9001
  });
7783
9002
  try {
7784
9003
  saveTerminalBridgeDispatchLedger(terminalControl, {
@@ -7789,8 +9008,11 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
7789
9008
  session_id: sessionIdForConversation(uncertainConversation),
7790
9009
  turn_id: turnIdForConversation(uncertainConversation),
7791
9010
  message_id: message.id,
9011
+ message_type: message.type,
7792
9012
  request_hash: terminalRequestHash,
7793
9013
  prepared_at: bridgeStartedAt,
9014
+ ...(textInjectedAt ? { text_injected_at: textInjectedAt } : {}),
9015
+ ...(enterDispatchedAt ? { enter_dispatched_at: enterDispatchedAt } : {}),
7794
9016
  uncertain_at: uncertainAt,
7795
9017
  dispatcher_pid: process.pid,
7796
9018
  state_path: statePath,
@@ -7850,7 +9072,11 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
7850
9072
  executor,
7851
9073
  do_not_retry: true,
7852
9074
  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.",
9075
+ reason: enterDispatchedAt
9076
+ ? "AKK dispatched Enter but native acceptance or its exact identity became uncertain. Do not retry automatically; inspect this conversation and pane."
9077
+ : textInjectedAt
9078
+ ? "AKK injected text but could not prove that Enter was dispatched. Do not retry automatically; inspect this conversation and pane."
9079
+ : "AKK could not prove that terminal input remained untouched. Inspect this conversation before retrying.",
7854
9080
  openclaw_next_action: {
7855
9081
  action: "inspect",
7856
9082
  conversation_id: uncertainConversation.conversation_id,
@@ -7862,7 +9088,8 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
7862
9088
  });
7863
9089
  return;
7864
9090
  }
7865
- let bookkeepingWarning;
9091
+ const nativeAccepted = acceptanceResult?.outcome === "agent_accepted";
9092
+ const acceptancePending = acceptanceResult?.outcome === "pending_acceptance";
7866
9093
  try {
7867
9094
  appendEvent(logPath, {
7868
9095
  ts: new Date().toISOString(),
@@ -7888,54 +9115,466 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
7888
9115
  conversation_id: deliveredConversation.conversation_id,
7889
9116
  terminal_target: terminalControl.target,
7890
9117
  error: bookkeepingWarning,
7891
- delivered: true
9118
+ delivered: nativeAccepted
9119
+ });
9120
+ try {
9121
+ appendEvent(logPath, {
9122
+ ts: new Date().toISOString(),
9123
+ conversation_id: deliveredConversation.conversation_id,
9124
+ event: "terminal_message_post_submit_bookkeeping_failed",
9125
+ terminal_control: terminalControl,
9126
+ error: textSummary(bookkeepingWarning),
9127
+ delivered: nativeAccepted
9128
+ });
9129
+ }
9130
+ catch {
9131
+ // The durable submitted receipt remains authoritative even if the event log is unavailable.
9132
+ }
9133
+ }
9134
+ printJson({
9135
+ session_id: sessionIdForConversation(deliveredConversation),
9136
+ turn_id: turnIdForConversation(deliveredConversation),
9137
+ conversation: deliveredConversation,
9138
+ message,
9139
+ delivered: nativeAccepted,
9140
+ status: nativeAccepted
9141
+ ? "async_pending"
9142
+ : acceptancePending
9143
+ ? "submission_pending_acceptance"
9144
+ : acceptanceResult?.outcome === "not_accepted"
9145
+ ? "submission_not_accepted"
9146
+ : "submission_uncertain",
9147
+ submission_outcome: acceptanceResult?.outcome,
9148
+ background: true,
9149
+ callback_expected: Boolean(deliveredConversation.gateway_method &&
9150
+ (nativeAccepted || acceptancePending)),
9151
+ terminal_control: terminalControl,
9152
+ monitor_pid: bridgeMonitor?.pid ?? null,
9153
+ executor,
9154
+ budget: budgetAction(deliveredConversation),
9155
+ delivery_receipt: nativeAccepted
9156
+ ? "agent_accepted"
9157
+ : acceptancePending
9158
+ ? "enter_dispatched"
9159
+ : acceptanceResult?.outcome,
9160
+ ...(!nativeAccepted ? { do_not_retry: true } : {}),
9161
+ ...(bookkeepingWarning
9162
+ ? {
9163
+ bookkeeping_warning: textSummary(bookkeepingWarning)
9164
+ }
9165
+ : {}),
9166
+ openclaw_next_action: nativeAccepted
9167
+ ? openClawYieldNextAction({
9168
+ conversationId: deliveredConversation.conversation_id,
9169
+ sessionId: sessionIdForConversation(deliveredConversation),
9170
+ turnId: turnIdForConversation(deliveredConversation),
9171
+ source: "terminal_control",
9172
+ callbackExpected: Boolean(deliveredConversation.gateway_method)
9173
+ })
9174
+ : {
9175
+ action: acceptancePending ? "wait_for_acceptance" : "inspect",
9176
+ conversation_id: deliveredConversation.conversation_id,
9177
+ session_id: sessionIdForConversation(deliveredConversation),
9178
+ turn_id: turnIdForConversation(deliveredConversation),
9179
+ do_not_retry: true,
9180
+ reason: acceptancePending
9181
+ ? "Terminal transport is proven and the background monitor is still waiting for native acceptance."
9182
+ : acceptanceResult?.outcome === "not_accepted"
9183
+ ? "The exact draft is still present; inspect the composer and do not send a duplicate."
9184
+ : "Native acceptance evidence became uncertain; inspect the shared pane."
9185
+ }
9186
+ });
9187
+ }
9188
+ function terminalSubmissionPayload(payload) {
9189
+ assertOrdinaryTerminalPayloadDoesNotInvokeNativeLifecycle(payload);
9190
+ return payload.trimEnd();
9191
+ }
9192
+ function allowsSyntheticTerminalAcceptance(_options) {
9193
+ return process.env.AKK_TEST_ALLOW_SYNTHETIC_TERMINAL_ACCEPTANCE === "1";
9194
+ }
9195
+ function syntheticTerminalAcceptanceEvidence({ options, executor, conversation, requestHash }) {
9196
+ if (!allowsSyntheticTerminalAcceptance(options)) {
9197
+ return undefined;
9198
+ }
9199
+ const outcome = process.env.AKK_TEST_TERMINAL_ACCEPTANCE_OUTCOME ?? "accepted";
9200
+ if (outcome === "pending" || outcome === "not_accepted") {
9201
+ return undefined;
9202
+ }
9203
+ if (outcome === "identity_drift" || outcome === "uncertain") {
9204
+ throw new Error("injected terminal acceptance identity drift");
9205
+ }
9206
+ if (outcome !== "accepted") {
9207
+ throw new Error(`unsupported injected terminal acceptance outcome ${outcome}`);
9208
+ }
9209
+ const nativeTakeover = isRecord(conversation.native_session_takeover)
9210
+ ? conversation.native_session_takeover
9211
+ : undefined;
9212
+ const nativeThreadId = stringValue(nativeTakeover?.terminal_agent_session_id) ?? stringValue(nativeTakeover?.terminal_agent_expected_session_id) ??
9213
+ sessionIdForConversation(conversation);
9214
+ const evidenceBase = {
9215
+ source: executor === "codex"
9216
+ ? "codex_rollout"
9217
+ : "claude_transcript",
9218
+ kind: "native_user_turn",
9219
+ nativeThreadId,
9220
+ requestHash,
9221
+ acceptanceId: `test-${turnIdForConversation(conversation)}`,
9222
+ anchorFingerprint: createHash("sha256")
9223
+ .update(`test-anchor:${nativeThreadId}:${requestHash}`)
9224
+ .digest("hex")
9225
+ };
9226
+ return {
9227
+ ...evidenceBase,
9228
+ evidenceFingerprint: createHash("sha256")
9229
+ .update(JSON.stringify(evidenceBase))
9230
+ .digest("hex")
9231
+ };
9232
+ }
9233
+ function captureCodexAcceptanceAnchorForSend({ options, currentIdentity, expectedNativeThreadId, allowedPreMaterializationIdentity, needsPostSendNativeBinding }) {
9234
+ if (allowsSyntheticTerminalAcceptance(options)) {
9235
+ return undefined;
9236
+ }
9237
+ const nativeThreadId = needsPostSendNativeBinding
9238
+ ? expectedNativeThreadId
9239
+ : currentIdentity?.sessionId;
9240
+ const processUuid = currentIdentity?.processUuid ??
9241
+ allowedPreMaterializationIdentity?.processUuid;
9242
+ const processBirth = currentIdentity?.processBirth ??
9243
+ allowedPreMaterializationIdentity?.processBirth;
9244
+ if (!nativeThreadId || !processUuid || !processBirth) {
9245
+ throw new Error("Codex native acceptance anchor requires an exact thread and process incarnation");
9246
+ }
9247
+ const base = { nativeThreadId, processUuid, processBirth };
9248
+ if (!needsPostSendNativeBinding && currentIdentity?.rollout) {
9249
+ return captureCodexRolloutAcceptanceAnchor({
9250
+ ...base,
9251
+ mode: "existing",
9252
+ rollout: currentIdentity.rollout
9253
+ });
9254
+ }
9255
+ return captureCodexRolloutAcceptanceAnchor({
9256
+ ...base,
9257
+ mode: "pre_materialization",
9258
+ expectedEmptyNativeSession: true
9259
+ });
9260
+ }
9261
+ async function detectTerminalSubmissionAcceptance({ options, executor, conversation, terminalControl }) {
9262
+ const nativeTakeover = isRecord(conversation.native_session_takeover)
9263
+ ? conversation.native_session_takeover
9264
+ : undefined;
9265
+ const requestHash = stringValue(nativeTakeover?.terminal_bridge_request_hash);
9266
+ if (!requestHash) {
9267
+ throw new Error("terminal acceptance request hash is unavailable");
9268
+ }
9269
+ assertTurnBindingCurrent(conversation, "monitor");
9270
+ const synthetic = syntheticTerminalAcceptanceEvidence({
9271
+ options,
9272
+ executor,
9273
+ conversation,
9274
+ requestHash
9275
+ });
9276
+ if (synthetic) {
9277
+ return synthetic;
9278
+ }
9279
+ if (allowsSyntheticTerminalAcceptance(options)) {
9280
+ return undefined;
9281
+ }
9282
+ if (executor === "codex") {
9283
+ const anchor = nativeTakeover?.codex_rollout_acceptance_anchor;
9284
+ if (!isRecord(anchor)) {
9285
+ throw new Error("Codex rollout acceptance anchor is unavailable");
9286
+ }
9287
+ const pid = Number(nativeTakeover?.terminal_agent_pid);
9288
+ const currentIdentity = await resolveCurrentNativeAgentSessionIdentity({
9289
+ options,
9290
+ agent: "codex",
9291
+ pid,
9292
+ cwd: terminalControl.currentPath,
9293
+ preferredSessionId: stringValue(anchor.native_thread_id)
9294
+ });
9295
+ if (!currentIdentity) {
9296
+ return undefined;
9297
+ }
9298
+ return detectCodexRolloutAcceptance({
9299
+ anchor: anchor,
9300
+ currentIdentity,
9301
+ requestHash
7892
9302
  });
9303
+ }
9304
+ const claudeEvidence = detectClaudeTranscriptAcceptance(terminalDurableRequestForConversation(conversation, terminalControl), {
9305
+ claudeHome: expandHome(options.claudeHome),
9306
+ agentRows: loadClaudeAgentRows(options)
9307
+ });
9308
+ if (!claudeEvidence) {
9309
+ return undefined;
9310
+ }
9311
+ return claudeEvidence;
9312
+ }
9313
+ async function pollTerminalSubmissionAcceptance({ options, executor, conversation, terminalControl, terminalBridge }) {
9314
+ const timeoutMs = positiveMilliseconds(options.terminalAcceptanceTimeoutMs ??
9315
+ DEFAULT_TERMINAL_ACCEPTANCE_TIMEOUT_MS, "--terminal-acceptance-timeout-ms");
9316
+ const pollIntervalMs = Math.max(10, Math.min(timeoutMs, Number(options.terminalAcceptancePollIntervalMs ??
9317
+ DEFAULT_TERMINAL_ACCEPTANCE_POLL_INTERVAL_MS)));
9318
+ const deadline = Date.now() + timeoutMs;
9319
+ while (true) {
9320
+ try {
9321
+ const evidence = await detectTerminalSubmissionAcceptance({
9322
+ options,
9323
+ executor,
9324
+ conversation,
9325
+ terminalControl
9326
+ });
9327
+ if (evidence) {
9328
+ return { outcome: "agent_accepted", evidence };
9329
+ }
9330
+ }
9331
+ catch (error) {
9332
+ return {
9333
+ outcome: "uncertain",
9334
+ reason: error instanceof Error ? error.message : String(error)
9335
+ };
9336
+ }
9337
+ if (Date.now() >= deadline) {
9338
+ break;
9339
+ }
9340
+ await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
9341
+ }
9342
+ if (allowsSyntheticTerminalAcceptance(options) &&
9343
+ process.env.AKK_TEST_TERMINAL_ACCEPTANCE_OUTCOME === "not_accepted") {
9344
+ return {
9345
+ outcome: "not_accepted",
9346
+ reason: "the exact managed draft remains in the terminal composer"
9347
+ };
9348
+ }
9349
+ try {
9350
+ const requestText = String(isRecord(conversation.native_session_takeover)
9351
+ ? conversation.native_session_takeover.terminal_bridge_request_text ?? ""
9352
+ : "");
9353
+ if (await terminalBridge.proveExactDraftStillPresent(executor, terminalControl, requestText, {
9354
+ scrollbackLines: Number(options.scrollbackLines ?? 240),
9355
+ runtime: terminalRuntimeIdentityForConversation(conversation, terminalControl)
9356
+ })) {
9357
+ return {
9358
+ outcome: "not_accepted",
9359
+ reason: "the exact managed draft remains in the terminal composer"
9360
+ };
9361
+ }
9362
+ }
9363
+ catch {
9364
+ // Native acceptance evidence remains authoritative. A best-effort draft
9365
+ // inspection failure does not turn a bounded no-ACK into an identity claim.
9366
+ }
9367
+ return { outcome: "pending_acceptance" };
9368
+ }
9369
+ async function reconcileTerminalAcceptanceInMonitor({ options, conversation, statePath, logPath, terminalControl, executor, terminalBridge }) {
9370
+ const evidence = await detectTerminalSubmissionAcceptance({
9371
+ options,
9372
+ executor: executor.kind,
9373
+ conversation,
9374
+ terminalControl
9375
+ });
9376
+ const submission = terminalBridgeSubmission(conversation);
9377
+ const nativeTakeover = isRecord(conversation.native_session_takeover)
9378
+ ? conversation.native_session_takeover
9379
+ : undefined;
9380
+ const messageId = stringValue(submission?.message_id);
9381
+ if (!messageId) {
9382
+ throw new Error("terminal acceptance monitor lost its exact message identity");
9383
+ }
9384
+ let notAcceptedReason;
9385
+ if (!evidence) {
9386
+ const enterAt = validTimestampMs(submission?.enter_dispatched_at);
9387
+ if (enterAt !== undefined && Date.now() - enterAt >= 250) {
9388
+ const requestText = String(nativeTakeover?.terminal_bridge_request_text ?? "");
9389
+ if (await terminalBridge.proveExactDraftStillPresent(executor.kind, terminalControl, requestText, {
9390
+ scrollbackLines: Number(options.scrollbackLines ?? 240),
9391
+ runtime: terminalRuntimeIdentityForConversation(conversation, terminalControl)
9392
+ })) {
9393
+ notAcceptedReason =
9394
+ "the exact managed draft remains in the terminal composer";
9395
+ }
9396
+ }
9397
+ if (!notAcceptedReason) {
9398
+ return { outcome: "pending" };
9399
+ }
9400
+ }
9401
+ const resolvedAt = new Date().toISOString();
9402
+ const writerStoreDir = pathsForConversationDir(path.dirname(statePath)).storeDir;
9403
+ return await withStoreWriterLeaseAsync(writerStoreDir, async () => {
9404
+ const releaseStateLock = acquireFileLock(`${statePath}.lock`);
9405
+ try {
9406
+ const current = loadState(statePath);
9407
+ const currentSubmission = terminalBridgeSubmission(current);
9408
+ const currentTakeover = isRecord(current.native_session_takeover)
9409
+ ? current.native_session_takeover
9410
+ : undefined;
9411
+ if (current.status !== conversation.status ||
9412
+ stringValue(currentTakeover?.terminal_bridge_message_id) !== messageId ||
9413
+ stringValue(currentSubmission?.message_id) !== messageId ||
9414
+ currentSubmission?.status !== "enter_dispatched") {
9415
+ throw new Error("terminal acceptance generation changed before monitor reconciliation");
9416
+ }
9417
+ const requestText = String(currentTakeover?.terminal_bridge_request_text ?? current.user_request ?? "");
9418
+ const nextBase = notAcceptedReason
9419
+ ? {
9420
+ ...current,
9421
+ status: "stalled",
9422
+ stalled_at: resolvedAt,
9423
+ stalled_reason: notAcceptedReason,
9424
+ updated_at: resolvedAt
9425
+ }
9426
+ : current;
9427
+ const acceptedConversation = withTerminalBridgeSubmission({
9428
+ conversation: nextBase,
9429
+ messageId,
9430
+ requestText,
9431
+ status: notAcceptedReason ? "not_accepted" : "agent_accepted",
9432
+ preparedAt: stringValue(currentSubmission.prepared_at) ?? resolvedAt,
9433
+ textInjectedAt: stringValue(currentSubmission.text_injected_at),
9434
+ enterDispatchedAt: stringValue(currentSubmission.enter_dispatched_at),
9435
+ ...(notAcceptedReason
9436
+ ? { notAcceptedAt: resolvedAt }
9437
+ : {
9438
+ agentAcceptedAt: resolvedAt,
9439
+ acceptanceEvidence: evidence
9440
+ })
9441
+ });
9442
+ const ledger = reconcilePreparedTerminalDispatchLedger(terminalControl, loadTerminalBridgeDispatchLedger(terminalControl));
9443
+ if (stringValue(ledger?.message_id) !== messageId ||
9444
+ ledger?.status !== "enter_dispatched") {
9445
+ throw new Error("terminal dispatch ledger changed before acceptance reconciliation");
9446
+ }
9447
+ saveState(statePath, acceptedConversation);
9448
+ try {
9449
+ if (process.env.AKK_TEST_MONITOR_FINAL_TERMINAL_LEDGER_FAILURE === "1") {
9450
+ throw new Error("injected monitor final terminal ledger persistence failure");
9451
+ }
9452
+ saveTerminalBridgeDispatchLedger(terminalControl, {
9453
+ ...ledger,
9454
+ ...terminalBindingLedgerFields(acceptedConversation),
9455
+ status: notAcceptedReason ? "not_accepted" : "agent_accepted",
9456
+ ...(notAcceptedReason
9457
+ ? { not_accepted_at: resolvedAt }
9458
+ : {
9459
+ agent_accepted_at: resolvedAt,
9460
+ acceptance_evidence: evidence
9461
+ }),
9462
+ dispatcher_pid: null
9463
+ });
9464
+ }
9465
+ catch (error) {
9466
+ runtimeLog("warn", "terminal_acceptance_monitor_ledger_lagging", {
9467
+ conversation_id: acceptedConversation.conversation_id,
9468
+ message_id: messageId,
9469
+ terminal_target: terminalControl.target,
9470
+ durable_submission_status: notAcceptedReason
9471
+ ? "not_accepted"
9472
+ : "agent_accepted",
9473
+ error: error instanceof Error ? error.message : String(error)
9474
+ });
9475
+ }
9476
+ try {
9477
+ if (process.env.AKK_TEST_MONITOR_FINAL_EVENT_FAILURE === "1") {
9478
+ throw new Error("injected monitor final acceptance event persistence failure");
9479
+ }
9480
+ appendEvent(logPath, {
9481
+ ts: resolvedAt,
9482
+ conversation_id: acceptedConversation.conversation_id,
9483
+ event: notAcceptedReason
9484
+ ? "terminal_message_not_accepted"
9485
+ : "terminal_message_agent_accepted",
9486
+ message_id: messageId,
9487
+ terminal_control: terminalControl,
9488
+ do_not_retry: Boolean(notAcceptedReason)
9489
+ });
9490
+ }
9491
+ catch (error) {
9492
+ runtimeLog("warn", "terminal_acceptance_monitor_event_lagging", {
9493
+ conversation_id: acceptedConversation.conversation_id,
9494
+ message_id: messageId,
9495
+ terminal_target: terminalControl.target,
9496
+ durable_submission_status: notAcceptedReason
9497
+ ? "not_accepted"
9498
+ : "agent_accepted",
9499
+ error: error instanceof Error ? error.message : String(error)
9500
+ });
9501
+ }
9502
+ return notAcceptedReason
9503
+ ? { outcome: "not_accepted", conversation: acceptedConversation }
9504
+ : { outcome: "accepted", conversation: acceptedConversation };
9505
+ }
9506
+ finally {
9507
+ releaseStateLock();
9508
+ }
9509
+ });
9510
+ }
9511
+ function markTerminalAcceptanceUncertain({ conversation, statePath, logPath, terminalControl, reason }) {
9512
+ const messageId = stringValue(terminalBridgeSubmission(conversation)?.message_id);
9513
+ const uncertainAt = new Date().toISOString();
9514
+ const writerStoreDir = pathsForConversationDir(path.dirname(statePath)).storeDir;
9515
+ return withStoreWriterLease(writerStoreDir, () => {
9516
+ const releaseStateLock = acquireFileLock(`${statePath}.lock`);
7893
9517
  try {
9518
+ const current = loadState(statePath);
9519
+ const submission = terminalBridgeSubmission(current);
9520
+ const currentMessageId = stringValue(submission?.message_id);
9521
+ if (!messageId ||
9522
+ currentMessageId !== messageId ||
9523
+ !["text_injected", "enter_dispatched"].includes(String(submission?.status))) {
9524
+ return current;
9525
+ }
9526
+ const takeover = isRecord(current.native_session_takeover)
9527
+ ? current.native_session_takeover
9528
+ : undefined;
9529
+ const requestText = String(takeover?.terminal_bridge_request_text ?? current.user_request ?? "");
9530
+ const uncertain = withTerminalBridgeSubmission({
9531
+ conversation: {
9532
+ ...current,
9533
+ status: "stalled",
9534
+ stalled_at: uncertainAt,
9535
+ stalled_reason: reason,
9536
+ updated_at: uncertainAt
9537
+ },
9538
+ messageId,
9539
+ requestText,
9540
+ status: "uncertain",
9541
+ preparedAt: stringValue(submission?.prepared_at) ?? uncertainAt,
9542
+ textInjectedAt: stringValue(submission?.text_injected_at),
9543
+ enterDispatchedAt: stringValue(submission?.enter_dispatched_at),
9544
+ uncertainAt,
9545
+ error: reason,
9546
+ lastProvenStage: submission?.status === "enter_dispatched"
9547
+ ? "enter_dispatched"
9548
+ : "text_injected"
9549
+ });
9550
+ const ledger = loadTerminalBridgeDispatchLedger(terminalControl);
9551
+ if (stringValue(ledger?.message_id) === messageId &&
9552
+ ["text_injected", "enter_dispatched"].includes(String(ledger?.status))) {
9553
+ saveTerminalBridgeDispatchLedger(terminalControl, {
9554
+ ...ledger,
9555
+ status: "uncertain",
9556
+ uncertain_at: uncertainAt,
9557
+ error: textSummary(reason),
9558
+ dispatcher_pid: null
9559
+ });
9560
+ }
9561
+ saveState(statePath, uncertain);
7894
9562
  appendEvent(logPath, {
7895
- ts: new Date().toISOString(),
7896
- conversation_id: deliveredConversation.conversation_id,
7897
- event: "terminal_message_post_submit_bookkeeping_failed",
9563
+ ts: uncertainAt,
9564
+ conversation_id: uncertain.conversation_id,
9565
+ event: "terminal_message_acceptance_uncertain",
9566
+ message_id: messageId,
7898
9567
  terminal_control: terminalControl,
7899
- error: textSummary(bookkeepingWarning),
7900
- delivered: true
9568
+ error: textSummary(reason),
9569
+ do_not_retry: true
7901
9570
  });
9571
+ return uncertain;
7902
9572
  }
7903
- catch {
7904
- // The durable submitted receipt remains authoritative even if the event log is unavailable.
9573
+ finally {
9574
+ releaseStateLock();
7905
9575
  }
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
9576
  });
7934
9577
  }
7935
- function terminalSubmissionPayload(payload) {
7936
- assertOrdinaryTerminalPayloadDoesNotInvokeNativeLifecycle(payload);
7937
- return payload.trimEnd();
7938
- }
7939
9578
  function assertOrdinaryTerminalPayloadDoesNotInvokeNativeLifecycle(payload) {
7940
9579
  const firstEffectiveLine = String(payload)
7941
9580
  .split(/\r\n?|\n/u)
@@ -8608,6 +10247,7 @@ function createManagedTerminalTurn({ options, conversationId, agent, pid, messag
8608
10247
  }, paths);
8609
10248
  const message = createMessage({
8610
10249
  conversation: attachedConversation,
10250
+ id: stringValue(options.messageId),
8611
10251
  from: "openclaw",
8612
10252
  to: executor.actor,
8613
10253
  type: options.type ?? "task",
@@ -9153,15 +10793,22 @@ function terminalBridgeReconciliationEligibility(conversation) {
9153
10793
  const dispatchLedger = loadTerminalBridgeDispatchLedger(terminalControl);
9154
10794
  if (dispatchLedger &&
9155
10795
  (stringValue(dispatchLedger.message_id) !== terminalMessageId ||
9156
- !["prepared", "submitted"].includes(String(dispatchLedger.status)))) {
10796
+ ![
10797
+ "prepared",
10798
+ "text_injected",
10799
+ "enter_dispatched",
10800
+ "agent_accepted",
10801
+ "submitted"
10802
+ ].includes(String(dispatchLedger.status)))) {
9157
10803
  return {
9158
10804
  eligible: false,
9159
10805
  reason: `terminal_dispatch_${String(dispatchLedger.status ?? "generation_replaced")}`
9160
10806
  };
9161
10807
  }
9162
10808
  const submission = terminalBridgeSubmission(conversation);
9163
- if (stringValue(submission?.message_id) === terminalMessageId &&
9164
- (submission?.status === "uncertain" || submission?.status === "aborted")) {
10809
+ if (submission &&
10810
+ stringValue(submission.message_id) === terminalMessageId &&
10811
+ ["not_accepted", "uncertain", "aborted"].includes(String(submission.status))) {
9165
10812
  return {
9166
10813
  eligible: false,
9167
10814
  reason: `terminal_submission_${submission.status}`
@@ -9544,8 +11191,12 @@ async function runTerminalDispatchClose({ options, terminalConversation }) {
9544
11191
  }
9545
11192
  if (![
9546
11193
  "prepared",
11194
+ "text_injected",
11195
+ "enter_dispatched",
9547
11196
  "dispatching",
11197
+ "agent_accepted",
9548
11198
  "submitted",
11199
+ "not_accepted",
9549
11200
  "uncertain",
9550
11201
  "verified"
9551
11202
  ].includes(ledger.status)) {
@@ -10074,6 +11725,91 @@ async function runTerminalBridgeMonitorWithLock(options) {
10074
11725
  submission &&
10075
11726
  stringValue(submission.message_id) === currentMessageId) {
10076
11727
  const submissionStatus = stringValue(submission.status);
11728
+ if (submissionStatus === "text_injected" ||
11729
+ submissionStatus === "enter_dispatched") {
11730
+ const dispatcherPid = Number(submission.dispatcher_pid);
11731
+ if (Number.isSafeInteger(dispatcherPid) &&
11732
+ dispatcherPid > 1 &&
11733
+ isProcessAlive(dispatcherPid)) {
11734
+ sleepSync(pollIntervalMs);
11735
+ continue;
11736
+ }
11737
+ if (submissionStatus === "text_injected") {
11738
+ conversation = markTerminalAcceptanceUncertain({
11739
+ conversation,
11740
+ statePath,
11741
+ logPath,
11742
+ terminalControl,
11743
+ reason: "terminal dispatcher exited after text injection but before Enter dispatch was durably proven"
11744
+ });
11745
+ printJson({
11746
+ conversation,
11747
+ monitored: true,
11748
+ terminal_bridge: true,
11749
+ completed: false,
11750
+ submission_outcome: "uncertain",
11751
+ delivery_receipt: "text_injected",
11752
+ do_not_retry: true,
11753
+ reason: "text was injected but native submission was not proven; inspect the composer"
11754
+ });
11755
+ return;
11756
+ }
11757
+ const releaseTerminalLock = acquireFileLock(terminalBridgeSendLockPath(storeDirFromOptions(options), terminalControl), { timeoutMs: 30000 });
11758
+ try {
11759
+ const reconciliation = await reconcileTerminalAcceptanceInMonitor({
11760
+ options,
11761
+ conversation,
11762
+ statePath,
11763
+ logPath,
11764
+ terminalControl,
11765
+ executor,
11766
+ terminalBridge
11767
+ });
11768
+ if (reconciliation.outcome === "accepted") {
11769
+ conversation = reconciliation.conversation;
11770
+ continue;
11771
+ }
11772
+ if (reconciliation.outcome === "not_accepted") {
11773
+ printJson({
11774
+ conversation: reconciliation.conversation,
11775
+ monitored: true,
11776
+ terminal_bridge: true,
11777
+ completed: false,
11778
+ submission_outcome: "not_accepted",
11779
+ delivery_receipt: "enter_dispatched",
11780
+ do_not_retry: true,
11781
+ reason: "the exact managed draft remains in the terminal composer"
11782
+ });
11783
+ return;
11784
+ }
11785
+ }
11786
+ catch (error) {
11787
+ const reason = error instanceof Error ? error.message : String(error);
11788
+ conversation = markTerminalAcceptanceUncertain({
11789
+ conversation,
11790
+ statePath,
11791
+ logPath,
11792
+ terminalControl,
11793
+ reason
11794
+ });
11795
+ printJson({
11796
+ conversation,
11797
+ monitored: true,
11798
+ terminal_bridge: true,
11799
+ completed: false,
11800
+ submission_outcome: "uncertain",
11801
+ delivery_receipt: "enter_dispatched",
11802
+ do_not_retry: true,
11803
+ reason
11804
+ });
11805
+ return;
11806
+ }
11807
+ finally {
11808
+ releaseTerminalLock();
11809
+ }
11810
+ sleepSync(pollIntervalMs);
11811
+ continue;
11812
+ }
10077
11813
  if (submissionStatus === "prepared") {
10078
11814
  const dispatcherPid = Number(submission.dispatcher_pid);
10079
11815
  if (Number.isSafeInteger(dispatcherPid) &&
@@ -10084,115 +11820,128 @@ async function runTerminalBridgeMonitorWithLock(options) {
10084
11820
  }
10085
11821
  const releaseTerminalLock = acquireFileLock(terminalBridgeSendLockPath(storeDirFromOptions(options), terminalControl), { timeoutMs: 30000 });
10086
11822
  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 ||
11823
+ const writerStoreDir = pathsForConversationDir(path.dirname(statePath)).storeDir;
11824
+ await withStoreWriterLeaseAsync(writerStoreDir, async () => {
11825
+ const dispatchLedger = loadTerminalBridgeDispatchLedger(terminalControl);
11826
+ const releaseStateLock = acquireFileLock(`${statePath}.lock`);
11827
+ try {
11828
+ const current = loadState(statePath);
11829
+ const currentSubmission = terminalBridgeSubmission(current);
11830
+ const currentTakeover = isRecord(current.native_session_takeover)
11831
+ ? current.native_session_takeover
11832
+ : undefined;
11833
+ const expectedMessageId = stringValue(currentTakeover?.terminal_bridge_message_id);
11834
+ if (expectedMessageId === currentMessageId &&
11835
+ stringValue(currentSubmission?.message_id) === currentMessageId &&
11836
+ currentSubmission?.status === "prepared") {
11837
+ const requestText = String(currentTakeover?.terminal_bridge_request_text ??
11838
+ current.user_request ??
11839
+ "");
11840
+ if (dispatchLedger &&
11841
+ ["submitted", "agent_accepted"].includes(String(dispatchLedger.status)) &&
10137
11842
  stringValue(dispatchLedger.message_id) === currentMessageId) {
10138
- saveTerminalBridgeDispatchLedger(terminalControl, {
10139
- ...(dispatchLedger ?? {}),
11843
+ const submittedAt = stringValue(dispatchLedger.agent_accepted_at) ??
11844
+ stringValue(dispatchLedger.submitted_at) ??
11845
+ new Date().toISOString();
11846
+ const recoveredStatus = dispatchLedger.status === "agent_accepted"
11847
+ ? "agent_accepted"
11848
+ : "submitted";
11849
+ const submittedConversation = withTerminalBridgeSubmission({
11850
+ conversation: current,
11851
+ messageId: currentMessageId,
11852
+ requestText,
11853
+ status: recoveredStatus,
11854
+ preparedAt: stringValue(currentSubmission.prepared_at) ??
11855
+ submittedAt,
11856
+ ...(recoveredStatus === "agent_accepted"
11857
+ ? {
11858
+ agentAcceptedAt: submittedAt,
11859
+ acceptanceEvidence: dispatchLedger.acceptance_evidence
11860
+ }
11861
+ : { submittedAt })
11862
+ });
11863
+ saveState(statePath, submittedConversation);
11864
+ conversation = submittedConversation;
11865
+ }
11866
+ else {
11867
+ const uncertainAt = new Date().toISOString();
11868
+ const uncertainConversation = withTerminalBridgeSubmission({
11869
+ conversation: {
11870
+ ...current,
11871
+ status: "stalled",
11872
+ stalled_at: uncertainAt,
11873
+ stalled_reason: "terminal dispatcher exited before AKK could prove the tmux submission",
11874
+ updated_at: uncertainAt
11875
+ },
11876
+ messageId: currentMessageId,
11877
+ requestText,
10140
11878
  status: "uncertain",
10141
- generation_id: currentMessageId,
11879
+ preparedAt: stringValue(currentSubmission.prepared_at) ??
11880
+ uncertainAt,
11881
+ uncertainAt,
11882
+ error: "the terminal dispatcher exited before AKK could persist a submitted receipt"
11883
+ });
11884
+ if (!dispatchLedger ||
11885
+ stringValue(dispatchLedger.message_id) === currentMessageId) {
11886
+ saveTerminalBridgeDispatchLedger(terminalControl, {
11887
+ ...(dispatchLedger ?? {}),
11888
+ status: "uncertain",
11889
+ generation_id: currentMessageId,
11890
+ conversation_id: uncertainConversation.conversation_id,
11891
+ message_id: currentMessageId,
11892
+ request_hash: terminalBridgeRequestFingerprint(requestText),
11893
+ prepared_at: stringValue(currentSubmission.prepared_at) ??
11894
+ uncertainAt,
11895
+ uncertain_at: uncertainAt,
11896
+ dispatcher_pid: Number.isSafeInteger(dispatcherPid) &&
11897
+ dispatcherPid > 1
11898
+ ? dispatcherPid
11899
+ : null,
11900
+ state_path: statePath,
11901
+ event_log_path: logPath,
11902
+ callback_expected: Boolean(uncertainConversation.gateway_method),
11903
+ reason: "dispatcher_exited_before_submitted_receipt"
11904
+ });
11905
+ }
11906
+ saveState(statePath, uncertainConversation);
11907
+ appendEvent(logPath, {
11908
+ ts: uncertainAt,
10142
11909
  conversation_id: uncertainConversation.conversation_id,
11910
+ event: "terminal_message_submit_uncertain",
10143
11911
  message_id: currentMessageId,
10144
- request_hash: terminalBridgeRequestFingerprint(requestText),
10145
- prepared_at: stringValue(currentSubmission.prepared_at) ??
10146
- uncertainAt,
10147
- uncertain_at: uncertainAt,
11912
+ reason: "dispatcher_exited_before_submitted_receipt",
10148
11913
  dispatcher_pid: Number.isSafeInteger(dispatcherPid) &&
10149
11914
  dispatcherPid > 1
10150
11915
  ? dispatcherPid
10151
11916
  : null,
10152
- state_path: statePath,
10153
- event_log_path: logPath,
10154
- callback_expected: Boolean(uncertainConversation.gateway_method),
10155
- reason: "dispatcher_exited_before_submitted_receipt"
11917
+ do_not_retry: true
10156
11918
  });
11919
+ conversation = uncertainConversation;
10157
11920
  }
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;
11921
+ }
11922
+ else {
11923
+ conversation = current;
10172
11924
  }
10173
11925
  }
10174
- else {
10175
- conversation = current;
11926
+ finally {
11927
+ releaseStateLock();
10176
11928
  }
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
- }
11929
+ if (terminalBridgeSubmission(conversation)?.status === "uncertain") {
11930
+ stallOtherTerminalBridgeConversationsForUncertainDispatch({
11931
+ storeDir: writerStoreDir,
11932
+ terminalControl,
11933
+ currentConversationId: conversation.conversation_id,
11934
+ uncertainMessageId: currentMessageId
11935
+ });
11936
+ }
11937
+ });
10189
11938
  }
10190
11939
  finally {
10191
11940
  releaseTerminalLock();
10192
11941
  }
10193
11942
  const afterSubmission = terminalBridgeSubmission(conversation);
10194
11943
  if (stringValue(afterSubmission?.message_id) === currentMessageId &&
10195
- afterSubmission?.status === "submitted") {
11944
+ ["submitted", "agent_accepted"].includes(String(afterSubmission?.status))) {
10196
11945
  continue;
10197
11946
  }
10198
11947
  printJson({
@@ -10206,22 +11955,31 @@ async function runTerminalBridgeMonitorWithLock(options) {
10206
11955
  });
10207
11956
  return;
10208
11957
  }
10209
- if (submissionStatus === "uncertain" || submissionStatus === "aborted") {
11958
+ if (submissionStatus === "not_accepted" ||
11959
+ submissionStatus === "uncertain" ||
11960
+ submissionStatus === "aborted") {
11961
+ const abortedSafeToRetry = submissionStatus === "aborted" &&
11962
+ submission.safe_to_retry === true;
10210
11963
  printJson({
10211
11964
  conversation,
10212
11965
  monitored: true,
10213
11966
  terminal_bridge: true,
10214
11967
  completed: false,
10215
11968
  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"
11969
+ do_not_retry: !abortedSafeToRetry,
11970
+ safe_to_retry: abortedSafeToRetry,
11971
+ reason: submissionStatus === "not_accepted"
11972
+ ? "the exact terminal draft was not accepted; automatic retry is disabled"
11973
+ : submissionStatus === "uncertain"
11974
+ ? "terminal submission outcome is uncertain; automatic completion and approval attribution are disabled"
11975
+ : abortedSafeToRetry
11976
+ ? "terminal submission was durably aborted before tmux input"
11977
+ : "terminal submission was aborted but safe retry was not durably proven"
10221
11978
  });
10222
11979
  return;
10223
11980
  }
10224
- if (submissionStatus !== "submitted") {
11981
+ if (submissionStatus !== "submitted" &&
11982
+ submissionStatus !== "agent_accepted") {
10225
11983
  printJson({
10226
11984
  conversation,
10227
11985
  monitored: true,
@@ -10260,18 +12018,25 @@ async function runTerminalBridgeMonitorWithLock(options) {
10260
12018
  const expectedStatus = conversation.status;
10261
12019
  const releaseTerminalPollLock = acquireFileLock(terminalBridgeSendLockPath(storeDirFromOptions(options), terminalControl), { timeoutMs: 30000 });
10262
12020
  try {
10263
- let dispatchLedger = loadTerminalBridgeDispatchLedger(terminalControl);
12021
+ let dispatchLedger = reconcilePreparedTerminalDispatchLedger(terminalControl, loadTerminalBridgeDispatchLedger(terminalControl));
10264
12022
  const durableSubmission = terminalBridgeSubmission(conversation);
10265
12023
  if (dispatchLedger?.status === "prepared" &&
10266
12024
  stringValue(dispatchLedger.message_id) === currentMessageId &&
10267
- durableSubmission?.status === "submitted" &&
12025
+ durableSubmission &&
12026
+ ["submitted", "agent_accepted"].includes(String(durableSubmission.status)) &&
10268
12027
  stringValue(durableSubmission.message_id) === currentMessageId) {
10269
- const submittedAt = stringValue(durableSubmission.submitted_at) ??
12028
+ const submittedAt = stringValue(durableSubmission.agent_accepted_at) ??
12029
+ stringValue(durableSubmission.submitted_at) ??
10270
12030
  new Date().toISOString();
10271
12031
  saveTerminalBridgeDispatchLedger(terminalControl, {
10272
12032
  ...dispatchLedger,
10273
- status: "submitted",
10274
- submitted_at: submittedAt,
12033
+ status: durableSubmission.status,
12034
+ ...(durableSubmission.status === "agent_accepted"
12035
+ ? {
12036
+ agent_accepted_at: submittedAt,
12037
+ acceptance_evidence: durableSubmission.acceptance_evidence
12038
+ }
12039
+ : { submitted_at: submittedAt }),
10275
12040
  reason: "recovered from the durable conversation submission receipt"
10276
12041
  });
10277
12042
  dispatchLedger =
@@ -10280,7 +12045,7 @@ async function runTerminalBridgeMonitorWithLock(options) {
10280
12045
  if (dispatchLedger) {
10281
12046
  const ledgerStatus = stringValue(dispatchLedger.status);
10282
12047
  const ledgerMessageId = stringValue(dispatchLedger.message_id);
10283
- if (ledgerStatus !== "submitted" ||
12048
+ if (!["submitted", "agent_accepted"].includes(ledgerStatus ?? "") ||
10284
12049
  ledgerMessageId !== currentMessageId) {
10285
12050
  appendEvent(logPath, {
10286
12051
  ts: new Date().toISOString(),
@@ -11060,7 +12825,17 @@ function orphanedTerminalDispatchForRecovery(terminalControl) {
11060
12825
  const ledgerPanePid = Number(ledgerControl?.pane_pid);
11061
12826
  const currentPanePid = Number(terminalControl.panePid);
11062
12827
  if (!ledger ||
11063
- !["prepared", "dispatching", "submitted", "uncertain", "verified"]
12828
+ ![
12829
+ "prepared",
12830
+ "text_injected",
12831
+ "enter_dispatched",
12832
+ "dispatching",
12833
+ "agent_accepted",
12834
+ "submitted",
12835
+ "not_accepted",
12836
+ "uncertain",
12837
+ "verified"
12838
+ ]
11064
12839
  .includes(String(ledger.status)) ||
11065
12840
  !recoveryIdentity ||
11066
12841
  (!lifecycle &&
@@ -11155,14 +12930,147 @@ function saveLifecycleTerminalDispatchLedger(terminalControl, ledger, options) {
11155
12930
  }
11156
12931
  saveTerminalBridgeDispatchLedger(terminalControl, ledger);
11157
12932
  }
12933
+ function terminalLedgerReceiptHistory(ledger) {
12934
+ if (!ledger) {
12935
+ return [];
12936
+ }
12937
+ const value = ledger.terminal_submission_receipts;
12938
+ if (value !== undefined && !Array.isArray(value)) {
12939
+ throw new Error("terminal dispatch receipt history is malformed");
12940
+ }
12941
+ const receipts = (Array.isArray(value) ? value : []).map((receipt) => {
12942
+ if (!isRecord(receipt) || !stringValue(receipt.message_id)) {
12943
+ throw new Error("terminal dispatch receipt history is malformed");
12944
+ }
12945
+ return receipt;
12946
+ });
12947
+ const ids = new Set();
12948
+ for (const receipt of receipts) {
12949
+ const id = String(receipt.message_id);
12950
+ if (ids.has(id)) {
12951
+ throw new Error(`terminal dispatch receipt ${id} is duplicated`);
12952
+ }
12953
+ ids.add(id);
12954
+ }
12955
+ const current = terminalLedgerReceiptCandidate(ledger);
12956
+ const currentId = stringValue(current?.message_id);
12957
+ if (!current || !currentId) {
12958
+ return receipts;
12959
+ }
12960
+ const previous = receipts.find((receipt) => stringValue(receipt.message_id) === currentId);
12961
+ if (!previous) {
12962
+ return [...receipts, current];
12963
+ }
12964
+ // A resolved top-level ledger may already point at a replacement tmux pane.
12965
+ // Resolution cannot strengthen or rebind an existing submission receipt, so
12966
+ // its append-only historical copy remains authoritative.
12967
+ if (ledger.status === "resolved") {
12968
+ return receipts;
12969
+ }
12970
+ const merged = mergeTerminalLedgerReceipt(previous, current);
12971
+ return receipts.map((receipt) => stringValue(receipt.message_id) === currentId ? merged : receipt);
12972
+ }
12973
+ function terminalLedgerReceiptCandidate(ledger) {
12974
+ const storedStatus = String(ledger.status);
12975
+ const receiptStatus = TERMINAL_LEDGER_RECEIPT_STATUSES.has(storedStatus)
12976
+ ? storedStatus
12977
+ : storedStatus === "resolved" && ledger.agent_accepted_at
12978
+ ? "agent_accepted"
12979
+ : storedStatus === "resolved" && ledger.uncertain_at
12980
+ ? "uncertain"
12981
+ : storedStatus === "resolved" && ledger.not_accepted_at
12982
+ ? "not_accepted"
12983
+ : storedStatus === "resolved" && ledger.aborted_at
12984
+ ? "aborted"
12985
+ : storedStatus === "resolved" && ledger.enter_dispatched_at
12986
+ ? "enter_dispatched"
12987
+ : storedStatus === "resolved" && ledger.submitted_at
12988
+ ? "submitted"
12989
+ : undefined;
12990
+ if (terminalDispatchLedgerLooksLifecycle(ledger) ||
12991
+ !receiptStatus ||
12992
+ !stringValue(ledger.message_id)) {
12993
+ return undefined;
12994
+ }
12995
+ const { terminal_submission_receipts: _history, terminal_key: _terminalKey, version: _version, ...receipt } = ledger;
12996
+ return { ...receipt, status: receiptStatus };
12997
+ }
12998
+ function mergeTerminalLedgerReceipt(previous, next) {
12999
+ const messageId = required(stringValue(previous.message_id), "terminal dispatch receipt message id is required");
13000
+ for (const field of TERMINAL_LEDGER_RECEIPT_IMMUTABLE_FIELDS) {
13001
+ const previousValue = previous[field];
13002
+ const nextValue = next[field];
13003
+ if (previousValue !== undefined &&
13004
+ nextValue !== undefined &&
13005
+ canonicalJson(previousValue) !== canonicalJson(nextValue)) {
13006
+ throw new Error(`terminal dispatch receipt ${messageId} changed immutable ${field}`);
13007
+ }
13008
+ }
13009
+ const previousControl = isRecord(previous.terminal_control)
13010
+ ? previous.terminal_control
13011
+ : undefined;
13012
+ const nextControl = isRecord(next.terminal_control)
13013
+ ? next.terminal_control
13014
+ : undefined;
13015
+ if (previousControl &&
13016
+ nextControl &&
13017
+ (stringValue(previousControl.target) !== stringValue(nextControl.target) ||
13018
+ (stringValue(previousControl.socket_path) ?? undefined) !==
13019
+ (stringValue(nextControl.socket_path) ?? undefined) ||
13020
+ Number(previousControl.pane_pid) !== Number(nextControl.pane_pid))) {
13021
+ throw new Error(`terminal dispatch receipt ${messageId} changed immutable terminal_control`);
13022
+ }
13023
+ if (previous.status === "agent_accepted" &&
13024
+ next.status !== "agent_accepted") {
13025
+ return previous;
13026
+ }
13027
+ const previousIsTerminalFailure = [
13028
+ "not_accepted",
13029
+ "uncertain",
13030
+ "aborted"
13031
+ ].includes(String(previous.status)) && previous.safe_to_retry !== true;
13032
+ const nextIsTransportOnly = [
13033
+ "text_injected",
13034
+ "enter_dispatched",
13035
+ "submitted"
13036
+ ].includes(String(next.status));
13037
+ if (previousIsTerminalFailure && nextIsTransportOnly) {
13038
+ return previous;
13039
+ }
13040
+ const merged = { ...next };
13041
+ for (const field of TERMINAL_LEDGER_RECEIPT_IMMUTABLE_FIELDS) {
13042
+ if (merged[field] === undefined && previous[field] !== undefined) {
13043
+ merged[field] = previous[field];
13044
+ }
13045
+ }
13046
+ if (!merged.terminal_control && previousControl) {
13047
+ merged.terminal_control = previousControl;
13048
+ }
13049
+ return merged;
13050
+ }
11158
13051
  function saveTerminalBridgeDispatchLedger(terminalControl, ledger) {
11159
13052
  const ledgerPath = terminalBridgeDispatchLedgerPath(terminalControl);
11160
13053
  ensureDir(path.dirname(ledgerPath));
11161
13054
  if (fs.existsSync(ledgerPath) && fs.lstatSync(ledgerPath).isSymbolicLink()) {
11162
13055
  throw new Error(`terminal dispatch ledger is a symlink: ${ledgerPath}`);
11163
13056
  }
11164
- const nextLedger = {
11165
- ...ledger,
13057
+ const previousLedger = fs.existsSync(ledgerPath)
13058
+ ? loadTerminalBridgeDispatchLedger(terminalControl)
13059
+ : undefined;
13060
+ let baseReceiptHistory = terminalLedgerReceiptHistory(previousLedger);
13061
+ for (const incomingReceipt of terminalLedgerReceiptHistory(ledger)) {
13062
+ const incomingId = String(incomingReceipt.message_id);
13063
+ const previousReceipt = baseReceiptHistory.find((receipt) => stringValue(receipt.message_id) === incomingId);
13064
+ const merged = previousReceipt
13065
+ ? mergeTerminalLedgerReceipt(previousReceipt, incomingReceipt)
13066
+ : incomingReceipt;
13067
+ baseReceiptHistory = previousReceipt
13068
+ ? baseReceiptHistory.map((receipt) => stringValue(receipt.message_id) === incomingId ? merged : receipt)
13069
+ : [...baseReceiptHistory, merged];
13070
+ }
13071
+ const { terminal_submission_receipts: _incomingReceiptHistory, ...ledgerWithoutReceiptHistory } = ledger;
13072
+ const nextWithoutHistory = {
13073
+ ...ledgerWithoutReceiptHistory,
11166
13074
  version: 1,
11167
13075
  terminal_key: terminalBridgeRuntimeKey(terminalControl),
11168
13076
  terminal_control: {
@@ -11173,6 +13081,37 @@ function saveTerminalBridgeDispatchLedger(terminalControl, ledger) {
11173
13081
  current_path: terminalControl.currentPath ?? null
11174
13082
  }
11175
13083
  };
13084
+ // The top-level ledger follows the current tmux pane incarnation, while an
13085
+ // append-only receipt must retain the pane that actually accepted its
13086
+ // input. Reconciliation may save an old ledger through a newly-created
13087
+ // pane with the same selector, so derive that receipt from the caller's
13088
+ // original control record instead of silently rebinding its proof.
13089
+ const receiptCandidateLedger = isRecord(ledgerWithoutReceiptHistory.terminal_control)
13090
+ ? {
13091
+ ...nextWithoutHistory,
13092
+ terminal_control: ledgerWithoutReceiptHistory.terminal_control
13093
+ }
13094
+ : nextWithoutHistory;
13095
+ const nextCandidate = terminalLedgerReceiptCandidate(receiptCandidateLedger);
13096
+ let nextReceiptHistory = baseReceiptHistory;
13097
+ if (nextCandidate) {
13098
+ const messageId = String(nextCandidate.message_id);
13099
+ const previousReceipt = baseReceiptHistory.find((receipt) => stringValue(receipt.message_id) === messageId);
13100
+ const nextReceipt = previousReceipt
13101
+ ? mergeTerminalLedgerReceipt(previousReceipt, nextCandidate)
13102
+ : nextCandidate;
13103
+ nextReceiptHistory = previousReceipt
13104
+ ? baseReceiptHistory.map((receipt) => stringValue(receipt.message_id) === messageId
13105
+ ? nextReceipt
13106
+ : receipt)
13107
+ : [...baseReceiptHistory, nextReceipt];
13108
+ }
13109
+ const nextLedger = {
13110
+ ...nextWithoutHistory,
13111
+ ...(nextReceiptHistory.length > 0
13112
+ ? { terminal_submission_receipts: nextReceiptHistory }
13113
+ : {})
13114
+ };
11176
13115
  const tempPath = `${ledgerPath}.${process.pid}.${randomUUID()}.tmp`;
11177
13116
  let tempFd;
11178
13117
  try {
@@ -11271,7 +13210,8 @@ function assertManagedTerminalDispatchOwner({ conversation, terminalControl, act
11271
13210
  const messageId = stringValue(nativeTakeover?.terminal_bridge_message_id);
11272
13211
  const ledger = loadTerminalBridgeDispatchLedger(terminalControl);
11273
13212
  if (!messageId ||
11274
- ledger?.status !== "submitted" ||
13213
+ !ledger ||
13214
+ !["submitted", "agent_accepted"].includes(String(ledger.status)) ||
11275
13215
  stringValue(ledger.conversation_id) !==
11276
13216
  conversation.conversation_id ||
11277
13217
  stringValue(ledger.message_id) !== messageId ||
@@ -11297,13 +13237,22 @@ function terminalBindingLedgerFields(conversation) {
11297
13237
  const nativeThreadId = stringValue(conversation.native_thread_id) ??
11298
13238
  stringValue(takeover?.terminal_agent_session_id);
11299
13239
  const storeDir = managedSessionStoreDirForConversation(conversation);
13240
+ const submission = terminalBridgeSubmission(conversation);
13241
+ const messageType = stringValue(submission?.message_type);
13242
+ const messageBodyHash = stringValue(submission?.message_body_hash);
11300
13243
  return {
11301
13244
  ...(bindingId ? { binding_id: bindingId } : {}),
11302
13245
  ...(Number.isSafeInteger(generation)
11303
13246
  ? { binding_generation: generation }
11304
13247
  : {}),
11305
13248
  ...(nativeThreadId ? { native_thread_id: nativeThreadId } : {}),
11306
- ...(storeDir ? { store_dir: path.resolve(storeDir) } : {})
13249
+ ...(storeDir ? { store_dir: path.resolve(storeDir) } : {}),
13250
+ ...(messageType ? { message_type: messageType } : {}),
13251
+ ...(messageBodyHash ? { message_body_hash: messageBodyHash } : {}),
13252
+ executor_kind: executorForConversation(conversation).kind,
13253
+ ...(conversation.openclaw_session
13254
+ ? { openclaw_session: conversation.openclaw_session }
13255
+ : {})
11307
13256
  };
11308
13257
  }
11309
13258
  function reconcilePreparedTerminalDispatchLedger(terminalControl, ledger) {
@@ -11311,7 +13260,7 @@ function reconcilePreparedTerminalDispatchLedger(terminalControl, ledger) {
11311
13260
  return ledger;
11312
13261
  }
11313
13262
  if (ledger?.status !== "prepared") {
11314
- return ledger;
13263
+ return reconcileLaggingTerminalDispatchLedger(terminalControl, ledger);
11315
13264
  }
11316
13265
  const dispatcherPid = Number(ledger.dispatcher_pid);
11317
13266
  if (Number.isSafeInteger(dispatcherPid) &&
@@ -11358,14 +13307,32 @@ function reconcilePreparedTerminalDispatchLedger(terminalControl, ledger) {
11358
13307
  }
11359
13308
  if (storedMessageId === messageId &&
11360
13309
  stringValue(submission?.message_id) === messageId) {
11361
- if (submission?.status === "submitted") {
11362
- const submittedAt = stringValue(submission.submitted_at) ??
13310
+ if (submission &&
13311
+ ["submitted", "text_injected", "enter_dispatched", "agent_accepted"]
13312
+ .includes(String(submission.status))) {
13313
+ const submittedAt = stringValue(submission.agent_accepted_at) ??
13314
+ stringValue(submission.enter_dispatched_at) ??
13315
+ stringValue(submission.text_injected_at) ??
13316
+ stringValue(submission.submitted_at) ??
11363
13317
  stringValue(conversation.updated_at) ??
11364
13318
  new Date().toISOString();
11365
13319
  saveTerminalBridgeDispatchLedger(terminalControl, {
11366
13320
  ...ledger,
11367
- status: "submitted",
11368
- submitted_at: submittedAt,
13321
+ status: submission.status,
13322
+ ...(submission.text_injected_at
13323
+ ? { text_injected_at: submission.text_injected_at }
13324
+ : {}),
13325
+ ...(submission.enter_dispatched_at
13326
+ ? { enter_dispatched_at: submission.enter_dispatched_at }
13327
+ : {}),
13328
+ ...(submission.status === "agent_accepted"
13329
+ ? {
13330
+ agent_accepted_at: submittedAt,
13331
+ acceptance_evidence: submission.acceptance_evidence
13332
+ }
13333
+ : submission.status === "submitted"
13334
+ ? { submitted_at: submittedAt }
13335
+ : {}),
11369
13336
  reason: "recovered from the durable conversation submission receipt"
11370
13337
  });
11371
13338
  }
@@ -11380,22 +13347,34 @@ function reconcilePreparedTerminalDispatchLedger(terminalControl, ledger) {
11380
13347
  return loadTerminalBridgeDispatchLedger(terminalControl);
11381
13348
  }
11382
13349
  if (storedMessageId &&
11383
- submission?.status === "submitted" &&
13350
+ submission &&
13351
+ ["submitted", "agent_accepted"].includes(String(submission.status)) &&
11384
13352
  stringValue(submission.message_id) === storedMessageId) {
11385
13353
  const requestText = String(nativeTakeover?.terminal_bridge_request_text ??
11386
13354
  conversation.user_request ??
11387
13355
  "");
11388
13356
  saveTerminalBridgeDispatchLedger(terminalControl, {
11389
13357
  ...terminalBindingLedgerFields(conversation),
11390
- status: "submitted",
13358
+ status: submission.status,
11391
13359
  generation_id: storedMessageId,
11392
13360
  conversation_id: conversation.conversation_id,
11393
13361
  message_id: storedMessageId,
13362
+ ...(stringValue(submission.message_type)
13363
+ ? { message_type: stringValue(submission.message_type) }
13364
+ : {}),
11394
13365
  request_hash: terminalBridgeRequestFingerprint(requestText),
11395
13366
  prepared_at: stringValue(submission.prepared_at) ??
11396
13367
  stringValue(conversation.updated_at),
11397
- submitted_at: stringValue(submission.submitted_at) ??
11398
- stringValue(conversation.updated_at),
13368
+ ...(submission.status === "agent_accepted"
13369
+ ? {
13370
+ agent_accepted_at: stringValue(submission.agent_accepted_at) ??
13371
+ stringValue(conversation.updated_at),
13372
+ acceptance_evidence: submission.acceptance_evidence
13373
+ }
13374
+ : {
13375
+ submitted_at: stringValue(submission.submitted_at) ??
13376
+ stringValue(conversation.updated_at)
13377
+ }),
11399
13378
  dispatcher_pid: null,
11400
13379
  state_path: statePath,
11401
13380
  event_log_path: stringValue(ledger.event_log_path) ??
@@ -11414,6 +13393,216 @@ function reconcilePreparedTerminalDispatchLedger(terminalControl, ledger) {
11414
13393
  }
11415
13394
  return loadTerminalBridgeDispatchLedger(terminalControl);
11416
13395
  }
13396
+ function reconcileLaggingTerminalDispatchLedger(terminalControl, ledger) {
13397
+ if (!ledger ||
13398
+ terminalDispatchLedgerLooksLifecycle(ledger) ||
13399
+ ![
13400
+ "text_injected",
13401
+ "enter_dispatched",
13402
+ "submitted",
13403
+ "agent_accepted",
13404
+ "not_accepted",
13405
+ "uncertain"
13406
+ ].includes(String(ledger.status))) {
13407
+ return ledger;
13408
+ }
13409
+ const statePath = stringValue(ledger.state_path);
13410
+ const messageId = stringValue(ledger.message_id);
13411
+ if (!statePath || !messageId) {
13412
+ return ledger;
13413
+ }
13414
+ let conversation;
13415
+ try {
13416
+ conversation = loadState(statePath);
13417
+ }
13418
+ catch {
13419
+ return ledger;
13420
+ }
13421
+ const nativeTakeover = isRecord(conversation.native_session_takeover)
13422
+ ? conversation.native_session_takeover
13423
+ : undefined;
13424
+ const submission = terminalBridgeSubmission(conversation);
13425
+ const storedControl = terminalControlFromTakeover(nativeTakeover);
13426
+ if (conversation.conversation_id !== stringValue(ledger.conversation_id) ||
13427
+ stringValue(nativeTakeover?.terminal_bridge_message_id) !== messageId ||
13428
+ stringValue(submission?.message_id) !== messageId ||
13429
+ !storedControl ||
13430
+ terminalBridgeRuntimeKey(storedControl) !==
13431
+ terminalBridgeRuntimeKey(terminalControl)) {
13432
+ return ledger;
13433
+ }
13434
+ const requestText = String(nativeTakeover?.terminal_bridge_request_text ?? conversation.user_request ?? "");
13435
+ const requestHash = terminalBridgeRequestFingerprint(requestText);
13436
+ if (stringValue(ledger.request_hash) !== requestHash ||
13437
+ stringValue(submission?.request_hash) !== requestHash) {
13438
+ return ledger;
13439
+ }
13440
+ const bindingFields = terminalBindingLedgerFields(conversation);
13441
+ for (const key of [
13442
+ "binding_id",
13443
+ "binding_generation",
13444
+ "native_thread_id",
13445
+ "store_dir"
13446
+ ]) {
13447
+ if (ledger[key] !== undefined &&
13448
+ bindingFields[key] !== undefined &&
13449
+ String(ledger[key]) !== String(bindingFields[key])) {
13450
+ return ledger;
13451
+ }
13452
+ }
13453
+ const stateStatus = String(submission?.status ?? "");
13454
+ let stateAcceptanceEvidence;
13455
+ if (stateStatus === "agent_accepted") {
13456
+ try {
13457
+ stateAcceptanceEvidence = terminalAcceptanceEvidenceForConversation(conversation, requestText, submission?.acceptance_evidence);
13458
+ }
13459
+ catch {
13460
+ stateAcceptanceEvidence = undefined;
13461
+ }
13462
+ }
13463
+ let ledgerAcceptanceEvidence;
13464
+ let ledgerAcceptanceError;
13465
+ if (ledger.status === "agent_accepted") {
13466
+ try {
13467
+ ledgerAcceptanceEvidence = terminalAcceptanceEvidenceForConversation(conversation, requestText, ledger.acceptance_evidence);
13468
+ }
13469
+ catch (error) {
13470
+ ledgerAcceptanceError = error instanceof Error
13471
+ ? error.message
13472
+ : String(error);
13473
+ }
13474
+ }
13475
+ if (ledgerAcceptanceEvidence) {
13476
+ if (!stateAcceptanceEvidence) {
13477
+ const acceptedAt = stringValue(ledger.agent_accepted_at) ?? new Date().toISOString();
13478
+ const acceptedConversation = withTerminalBridgeSubmission({
13479
+ conversation,
13480
+ messageId,
13481
+ requestText,
13482
+ status: "agent_accepted",
13483
+ preparedAt: stringValue(submission?.prepared_at) ??
13484
+ stringValue(ledger.prepared_at) ??
13485
+ acceptedAt,
13486
+ textInjectedAt: stringValue(submission?.text_injected_at) ??
13487
+ stringValue(ledger.text_injected_at),
13488
+ enterDispatchedAt: stringValue(submission?.enter_dispatched_at) ??
13489
+ stringValue(ledger.enter_dispatched_at),
13490
+ agentAcceptedAt: acceptedAt,
13491
+ acceptanceEvidence: ledgerAcceptanceEvidence
13492
+ });
13493
+ saveState(statePath, acceptedConversation);
13494
+ }
13495
+ return ledger;
13496
+ }
13497
+ if (stateAcceptanceEvidence) {
13498
+ const acceptedAt = stringValue(submission?.agent_accepted_at) ?? new Date().toISOString();
13499
+ saveTerminalBridgeDispatchLedger(terminalControl, {
13500
+ ...ledger,
13501
+ ...bindingFields,
13502
+ status: "agent_accepted",
13503
+ text_injected_at: submission?.text_injected_at,
13504
+ enter_dispatched_at: submission?.enter_dispatched_at,
13505
+ agent_accepted_at: acceptedAt,
13506
+ acceptance_evidence: stateAcceptanceEvidence,
13507
+ dispatcher_pid: null,
13508
+ reason: "recovered the strongest durable native acceptance receipt"
13509
+ });
13510
+ return loadTerminalBridgeDispatchLedger(terminalControl);
13511
+ }
13512
+ if (ledger.status === "agent_accepted") {
13513
+ const uncertainAt = new Date().toISOString();
13514
+ const uncertainConversation = withTerminalBridgeSubmission({
13515
+ conversation: {
13516
+ ...conversation,
13517
+ status: "stalled",
13518
+ stalled_at: uncertainAt,
13519
+ stalled_reason: "stored native acceptance evidence is invalid",
13520
+ updated_at: uncertainAt
13521
+ },
13522
+ messageId,
13523
+ requestText,
13524
+ status: "uncertain",
13525
+ preparedAt: stringValue(submission?.prepared_at) ??
13526
+ stringValue(ledger.prepared_at) ??
13527
+ uncertainAt,
13528
+ textInjectedAt: stringValue(submission?.text_injected_at) ??
13529
+ stringValue(ledger.text_injected_at),
13530
+ enterDispatchedAt: stringValue(submission?.enter_dispatched_at) ??
13531
+ stringValue(ledger.enter_dispatched_at),
13532
+ uncertainAt,
13533
+ error: ledgerAcceptanceError ??
13534
+ "stored native acceptance evidence is invalid",
13535
+ lastProvenStage: "enter_dispatched"
13536
+ });
13537
+ saveState(statePath, uncertainConversation);
13538
+ saveTerminalBridgeDispatchLedger(terminalControl, {
13539
+ ...ledger,
13540
+ ...bindingFields,
13541
+ status: "uncertain",
13542
+ uncertain_at: uncertainAt,
13543
+ dispatcher_pid: null,
13544
+ reason: "stored native acceptance evidence is invalid"
13545
+ });
13546
+ return loadTerminalBridgeDispatchLedger(terminalControl);
13547
+ }
13548
+ const stateRank = terminalSubmissionProofRank(stateStatus, stringValue(submission?.last_proven_stage));
13549
+ const ledgerRank = terminalSubmissionProofRank(String(ledger.status), stringValue(ledger.last_proven_stage));
13550
+ const stateIsTerminal = ["not_accepted", "uncertain", "aborted"]
13551
+ .includes(stateStatus);
13552
+ if (!stateIsTerminal && stateRank <= ledgerRank) {
13553
+ return ledger;
13554
+ }
13555
+ if (![
13556
+ "text_injected",
13557
+ "enter_dispatched",
13558
+ "submitted",
13559
+ "not_accepted",
13560
+ "uncertain",
13561
+ "aborted"
13562
+ ].includes(stateStatus)) {
13563
+ return ledger;
13564
+ }
13565
+ saveTerminalBridgeDispatchLedger(terminalControl, {
13566
+ ...ledger,
13567
+ ...bindingFields,
13568
+ status: stateStatus,
13569
+ ...(submission?.text_injected_at
13570
+ ? { text_injected_at: submission.text_injected_at }
13571
+ : {}),
13572
+ ...(submission?.enter_dispatched_at
13573
+ ? { enter_dispatched_at: submission.enter_dispatched_at }
13574
+ : {}),
13575
+ ...(submission?.submitted_at
13576
+ ? { submitted_at: submission.submitted_at }
13577
+ : {}),
13578
+ ...(submission?.not_accepted_at
13579
+ ? { not_accepted_at: submission.not_accepted_at }
13580
+ : {}),
13581
+ ...(submission?.uncertain_at
13582
+ ? { uncertain_at: submission.uncertain_at }
13583
+ : {}),
13584
+ ...(submission?.aborted_at
13585
+ ? { aborted_at: submission.aborted_at }
13586
+ : {}),
13587
+ last_proven_stage: submission?.last_proven_stage,
13588
+ ...(stateIsTerminal ? { dispatcher_pid: null } : {}),
13589
+ reason: "recovered the strongest durable conversation proof level"
13590
+ });
13591
+ return loadTerminalBridgeDispatchLedger(terminalControl);
13592
+ }
13593
+ function terminalSubmissionProofRank(status, lastProven) {
13594
+ if (status === "agent_accepted" || lastProven === "agent_accepted") {
13595
+ return 3;
13596
+ }
13597
+ if (["enter_dispatched", "submitted", "not_accepted"].includes(status) ||
13598
+ lastProven === "enter_dispatched") {
13599
+ return 2;
13600
+ }
13601
+ if (status === "text_injected" || lastProven === "text_injected") {
13602
+ return 1;
13603
+ }
13604
+ return 0;
13605
+ }
11417
13606
  async function recoverLifecycleFenceBeforeMutation({ options, terminal }) {
11418
13607
  const ledger = loadTerminalBridgeDispatchLedger(terminal.terminalControl);
11419
13608
  if (!ledger || ledger.status === "resolved") {
@@ -15428,6 +17617,8 @@ function redactCliOutput(value) {
15428
17617
  }
15429
17618
  if (key === "claude_transcript_anchor" ||
15430
17619
  key === "claudeTranscriptAnchor" ||
17620
+ key === "codex_rollout_acceptance_anchor" ||
17621
+ key === "codexRolloutAcceptanceAnchor" ||
15431
17622
  key === "claude_home" ||
15432
17623
  key === "claudeHome") {
15433
17624
  return [];