@scotthuang/agent-knock-knock 0.8.0 → 0.9.0

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
@@ -10,7 +10,7 @@ import { createClaudeTerminalAgentAdapter } from "./claude-terminal-agent-adapte
10
10
  import { captureClaudeTranscriptAnchor, defaultClaudeHome, detectClaudeTranscriptCompletion, detectClaudeTranscriptPendingApproval } from "./claude-local-transcript-provider.js";
11
11
  import { CodexLocalSessionProvider } from "./codex-local-session-provider.js";
12
12
  import { CodexStoreAdapter } from "./codex-store-adapter.js";
13
- import { applyMessageToConversation, budgetAction, createConversation, createMessage, executorForConversation, extractStructuredMessage, parseMessageJson, resolveExecutor } from "./protocol.js";
13
+ import { applyMessageToConversation, budgetAction, createConversation, createMessage, executorForConversation, extractStructuredMessage, parseMessageJson, resolveExecutor, sessionIdForConversation, turnIdForConversation } from "./protocol.js";
14
14
  import { EXECUTOR_KINDS, executorDefinitionForKind, isExecutorKind } from "./executors.js";
15
15
  import { redactString, writeRuntimeLog } from "./runtime-log.js";
16
16
  import { formatTranscript, readNdjsonLog } from "./transcript.js";
@@ -48,6 +48,16 @@ const TERMINAL_DISPATCH_RELEASE_STATUSES = new Set([
48
48
  "closed",
49
49
  "cancelled"
50
50
  ]);
51
+ const SESSION_SEND_BLOCKING_STATUSES = new Set([
52
+ "created",
53
+ "running",
54
+ "waiting_for_agent",
55
+ "waiting_for_openclaw",
56
+ "stalled",
57
+ "callback_pending",
58
+ "callback_failed",
59
+ "cancelling"
60
+ ]);
51
61
  const TERMINAL_BRIDGE_MONITOR_LOCK_VERSION = 1;
52
62
  const MINIMUM_NODE_VERSION = "22.19.0";
53
63
  const PRIVATE_LOCK_FILE_MODE = 0o600;
@@ -71,6 +81,7 @@ const CONVERSATION_STATUSES = new Set([
71
81
  const SESSION_SELECTOR_COMMANDS = new Set([
72
82
  "status",
73
83
  "send",
84
+ "respond",
74
85
  "approve",
75
86
  "cancel",
76
87
  "renew",
@@ -80,6 +91,7 @@ const SESSION_SELECTOR_COMMANDS = new Set([
80
91
  const STORE_MUTATION_COMMANDS = new Set([
81
92
  "delegate",
82
93
  "send",
94
+ "respond",
83
95
  "approve",
84
96
  "cancel",
85
97
  "renew",
@@ -95,13 +107,52 @@ class InlineCodexSessionAdapter {
95
107
  processBatches;
96
108
  processBatchIndex = 0;
97
109
  rollouts;
98
- constructor({ threads, processes, rollouts }) {
110
+ activeSessionIdentities;
111
+ constructor({ threads, processes, rollouts, activeSessionIdentities }) {
99
112
  this.threads = Array.isArray(threads) ? threads : [];
100
113
  this.processBatches = Array.isArray(processes?.[0])
101
114
  ? processes
102
115
  : [];
103
116
  this.processes = Array.isArray(processes) && !Array.isArray(processes[0]) ? processes : [];
104
117
  this.rollouts = new Map(Object.entries(rollouts ?? {}));
118
+ this.activeSessionIdentities = new Map(Object.entries(activeSessionIdentities ?? {}).flatMap(([pidValue, value]) => {
119
+ const pid = Number(pidValue);
120
+ if (!Number.isSafeInteger(pid) || pid <= 1 || !isRecord(value)) {
121
+ return [];
122
+ }
123
+ const sessionId = stringValue(value.sessionId ?? value.session_id);
124
+ if (!sessionId) {
125
+ return [];
126
+ }
127
+ return [[pid, {
128
+ sessionId,
129
+ ...(stringValue(value.processUuid ?? value.process_uuid)
130
+ ? {
131
+ processUuid: stringValue(value.processUuid ?? value.process_uuid)
132
+ }
133
+ : {}),
134
+ ...(stringValue(value.processBirth ?? value.process_birth)
135
+ ? {
136
+ processBirth: stringValue(value.processBirth ?? value.process_birth)
137
+ }
138
+ : {}),
139
+ ...(isRecord(value.rollout) &&
140
+ stringValue(value.rollout.fd) &&
141
+ stringValue(value.rollout.device) &&
142
+ stringValue(value.rollout.inode) &&
143
+ stringValue(value.rollout.path)
144
+ ? {
145
+ rollout: {
146
+ fd: stringValue(value.rollout.fd),
147
+ device: stringValue(value.rollout.device),
148
+ inode: stringValue(value.rollout.inode),
149
+ path: stringValue(value.rollout.path)
150
+ }
151
+ }
152
+ : {}),
153
+ evidence: stringValue(value.evidence) ?? "static_exact_fixture"
154
+ }]];
155
+ }));
105
156
  }
106
157
  async listThreadRows() {
107
158
  return this.threads;
@@ -117,6 +168,9 @@ class InlineCodexSessionAdapter {
117
168
  }
118
169
  return this.processes;
119
170
  }
171
+ async resolveActiveSessionIdentityForPid(pid) {
172
+ return this.activeSessionIdentities.get(pid);
173
+ }
120
174
  }
121
175
  const command = process.argv[2];
122
176
  const rawArgs = process.argv.slice(3);
@@ -163,6 +217,9 @@ async function runCommand(commandName, options) {
163
217
  else if (commandName === "send") {
164
218
  await runSend(options);
165
219
  }
220
+ else if (commandName === "respond") {
221
+ await runRespond(options);
222
+ }
166
223
  else if (commandName === "approve") {
167
224
  await runApprove(options);
168
225
  }
@@ -534,7 +591,7 @@ function createTerminalProcessSource(options) {
534
591
  }
535
592
  return new SystemTerminalProcessSource();
536
593
  }
537
- function loadClaudeAgentRows(options = {}) {
594
+ function loadClaudeAgentRows(options = {}, observation = {}) {
538
595
  let value;
539
596
  if (options.claudeAgentsJson !== undefined) {
540
597
  value = typeof options.claudeAgentsJson === "string"
@@ -547,6 +604,9 @@ function loadClaudeAgentRows(options = {}) {
547
604
  else {
548
605
  const claudeExecutable = resolveOptionalExecutable("claude");
549
606
  if (!claudeExecutable) {
607
+ if (observation.required) {
608
+ throw new Error("Claude agent session observation is unavailable because the Claude CLI could not be resolved");
609
+ }
550
610
  return [];
551
611
  }
552
612
  const result = spawnSync(claudeExecutable, ["agents", "--json", "--all"], {
@@ -560,6 +620,9 @@ function loadClaudeAgentRows(options = {}) {
560
620
  error: result.error?.message,
561
621
  stderr: textSummary(cleanProcessText(result.stderr))
562
622
  });
623
+ if (observation.required) {
624
+ throw new Error("Claude agent session observation failed; refusing to treat the process as a virgin session");
625
+ }
563
626
  return [];
564
627
  }
565
628
  try {
@@ -569,6 +632,9 @@ function loadClaudeAgentRows(options = {}) {
569
632
  runtimeLog("warn", "claude_agents_list_invalid_json", {
570
633
  stdout: textSummary(result.stdout)
571
634
  });
635
+ if (observation.required) {
636
+ throw new Error("Claude agent session observation returned invalid JSON; refusing to treat the process as a virgin session");
637
+ }
572
638
  return [];
573
639
  }
574
640
  }
@@ -576,7 +642,13 @@ function loadClaudeAgentRows(options = {}) {
576
642
  ? value
577
643
  : isRecord(value) && Array.isArray(value.agents)
578
644
  ? value.agents
579
- : [];
645
+ : undefined;
646
+ if (!rows) {
647
+ if (observation.required) {
648
+ throw new Error("Claude agent session observation returned an unsupported result shape; refusing to treat the process as a virgin session");
649
+ }
650
+ return [];
651
+ }
580
652
  return rows.flatMap((row) => {
581
653
  if (!isRecord(row) || !Number.isInteger(Number(row.pid))) {
582
654
  return [];
@@ -666,7 +738,7 @@ function createTerminalAgentBridge(options, terminalProvider = createTerminalCon
666
738
  return new TerminalAgentBridge({
667
739
  registry,
668
740
  terminalProvider,
669
- async verifyIdentity({ agent, pid, terminalControl }) {
741
+ async verifyIdentity({ agent, pid, terminalControl, runtime }) {
670
742
  const adapter = registry.require(agent);
671
743
  const expectedWorkspace = options.workspace ?? terminalControl.currentPath;
672
744
  if (!expectedWorkspace) {
@@ -689,6 +761,18 @@ function createTerminalAgentBridge(options, terminalProvider = createTerminalCon
689
761
  }
690
762
  assertConfiguredWorkspace(expectedWorkspace, snapshot.cwd, `terminal access to ${terminalControl.target} by agent process ${pid}`);
691
763
  assertConfiguredWorkspace(expectedWorkspace, pane.currentPath, `terminal access to ${terminalControl.target} by tmux pane`);
764
+ const currentNativeIdentity = await resolveCurrentNativeAgentSessionIdentity({
765
+ options,
766
+ agent,
767
+ pid,
768
+ cwd: snapshot.cwd ?? pane.currentPath
769
+ });
770
+ assertNativeAgentIdentityForRuntime({
771
+ runtime,
772
+ currentIdentity: currentNativeIdentity,
773
+ agent,
774
+ pid
775
+ });
692
776
  return {
693
777
  terminalControl: {
694
778
  ...terminalControl,
@@ -751,11 +835,34 @@ function terminalRuntimeIdentityForConversation(conversation, terminalControl) {
751
835
  const terminalIdentity = parseTerminalConversationId(nativeSessionId);
752
836
  const explicitSessionId = stringValue(nativeTakeover?.terminal_agent_session_id) ??
753
837
  (terminalIdentity ? undefined : nativeSessionId);
838
+ const nativeRollout = isRecord(nativeTakeover?.terminal_agent_rollout)
839
+ ? nativeTakeover.terminal_agent_rollout
840
+ : undefined;
841
+ const strictNativeIdentity = Number(nativeTakeover?.terminal_agent_identity_protocol) === 1;
842
+ const requireNativeProcessUuid = strictNativeIdentity && executorForConversation(conversation).kind === "claude";
843
+ const requireNativeRolloutIdentity = strictNativeIdentity && executorForConversation(conversation).kind === "codex";
754
844
  return {
755
845
  pid: Number.isInteger(Number(nativeTakeover?.terminal_agent_pid))
756
846
  ? Number(nativeTakeover?.terminal_agent_pid)
757
847
  : terminalIdentity?.pid,
758
848
  sessionId: explicitSessionId,
849
+ nativeSessionId: stringValue(nativeTakeover?.terminal_agent_session_id),
850
+ nativeProcessUuid: stringValue(nativeTakeover?.terminal_agent_process_uuid),
851
+ nativeProcessBirth: stringValue(nativeTakeover?.terminal_agent_process_birth),
852
+ requireNativeProcessUuid,
853
+ requireNativeRolloutIdentity,
854
+ ...(nativeRollout
855
+ ? {
856
+ nativeRollout: {
857
+ fd: String(nativeRollout.fd ?? ""),
858
+ device: String(nativeRollout.device ?? ""),
859
+ inode: String(nativeRollout.inode ?? ""),
860
+ path: String(nativeRollout.path ?? "")
861
+ }
862
+ }
863
+ : {}),
864
+ expectedEmptyNativeSession: strictNativeIdentity &&
865
+ !stringValue(nativeTakeover?.terminal_agent_session_id),
759
866
  cwd: stringValue(nativeTakeover?.source_cwd) ?? terminalControl.currentPath,
760
867
  conversationId: stringValue(conversation?.conversation_id),
761
868
  messageId: stringValue(nativeTakeover?.terminal_bridge_message_id),
@@ -1225,6 +1332,8 @@ function withTerminalBridgeSubmission({ conversation, messageId, requestText, st
1225
1332
  ...nativeTakeover,
1226
1333
  terminal_bridge_submission: {
1227
1334
  status,
1335
+ session_id: sessionIdForConversation(conversation),
1336
+ turn_id: turnIdForConversation(conversation),
1228
1337
  message_id: messageId,
1229
1338
  request_hash: terminalBridgeRequestFingerprint(requestText),
1230
1339
  prepared_at: preparedAt,
@@ -1513,14 +1622,21 @@ async function terminalControlDiagnostics(provider) {
1513
1622
  };
1514
1623
  }
1515
1624
  function managedTurnListEntry(task, { terminalBridge = false, approvalState, conversation } = {}) {
1625
+ const sessionId = stringValue(task.session_id) ??
1626
+ stringValue(task.conversation_id);
1627
+ const turnId = stringValue(task.turn_id) ??
1628
+ stringValue(task.conversation_id) ??
1629
+ String(task.id ?? "");
1516
1630
  const entry = {
1517
1631
  ...task,
1518
- id: task.conversation_id,
1519
- short_ref: sessionShortRef(task.conversation_id),
1632
+ session_id: sessionId,
1633
+ turn_id: turnId,
1634
+ id: turnId,
1635
+ short_ref: sessionShortRef(turnId),
1520
1636
  source: "managed_turn",
1521
1637
  ...(approvalState ? { approval_state: approvalState } : {}),
1522
1638
  commands: {
1523
- send: canFollowUpManagedTurn(task.status),
1639
+ respond: task.status === "waiting_for_openclaw",
1524
1640
  cancel: isWaitingForAgent(task.status),
1525
1641
  close: task.status !== "closed",
1526
1642
  status: true,
@@ -1546,6 +1662,23 @@ async function terminalControlledListEntry(session, activeSessions, options, bri
1546
1662
  terminalTarget: terminalControl.target
1547
1663
  });
1548
1664
  const orphanedDispatch = orphanedTerminalDispatchForRecovery(terminalControl);
1665
+ let nativeAgentIdentity;
1666
+ try {
1667
+ nativeAgentIdentity = await resolveCurrentNativeAgentSessionIdentity({
1668
+ options,
1669
+ agent: session.agent,
1670
+ pid: session.pid,
1671
+ cwd: session.cwd ?? terminalControl.currentPath
1672
+ });
1673
+ }
1674
+ catch (error) {
1675
+ runtimeLog("warn", "terminal_native_session_identity_unavailable", {
1676
+ agent: session.agent,
1677
+ terminal_target: terminalControl.target,
1678
+ pid: session.pid,
1679
+ error: error instanceof Error ? error.message : String(error)
1680
+ });
1681
+ }
1549
1682
  const entry = {
1550
1683
  id: bridge.terminalConversationId(session),
1551
1684
  short_ref: sessionShortRef(bridge.terminalConversationId(session)),
@@ -1558,7 +1691,11 @@ async function terminalControlledListEntry(session, activeSessions, options, bri
1558
1691
  cwd: session.cwd,
1559
1692
  workspace: session.cwd,
1560
1693
  elapsed: session.elapsed,
1561
- session_id: session.sessionId,
1694
+ native_agent_session_id: nativeAgentIdentity?.sessionId,
1695
+ native_agent_process_uuid: nativeAgentIdentity?.processUuid,
1696
+ native_agent_process_birth: nativeAgentIdentity?.processBirth,
1697
+ native_agent_rollout: nativeAgentIdentity?.rollout,
1698
+ native_agent_identity_evidence: nativeAgentIdentity?.evidence,
1562
1699
  confidence: session.confidence,
1563
1700
  reason: session.reason,
1564
1701
  terminal_control: terminalControl,
@@ -1627,13 +1764,32 @@ function terminalFirstListProjection({ terminals, allConversations, displayedCon
1627
1764
  !allRelated.some((conversation) => conversation.conversation_id === ownership.conversation.conversation_id)) {
1628
1765
  allRelated.push(ownership.conversation);
1629
1766
  }
1767
+ const sessionIds = new Set(allRelated.map((conversation) => sessionIdForConversation(conversation)));
1768
+ const managedSessionId = ownership.state === "current"
1769
+ ? sessionIdForConversation(ownership.conversation)
1770
+ : [...displayedRelated]
1771
+ .sort(compareManagedConversationRecency)
1772
+ .map((conversation) => sessionIdForConversation(conversation))[0] ??
1773
+ [...allRelated]
1774
+ .sort(compareManagedConversationRecency)
1775
+ .map((conversation) => sessionIdForConversation(conversation))[0];
1776
+ const sessionAllRelated = managedSessionId
1777
+ ? allRelated.filter((conversation) => sessionIdForConversation(conversation) === managedSessionId)
1778
+ : [];
1779
+ const sessionDisplayedRelated = managedSessionId
1780
+ ? displayedRelated.filter((conversation) => sessionIdForConversation(conversation) === managedSessionId)
1781
+ : [];
1782
+ const sessionBindingTurn = [...sessionAllRelated]
1783
+ .sort(compareManagedConversationRecency)[0];
1784
+ const sessionBindingMatchesLiveTerminal = Boolean(sessionBindingTurn &&
1785
+ managedTurnMatchesLiveTerminal(sessionBindingTurn, terminal));
1630
1786
  const currentTurnValue = ownership.state === "current"
1631
1787
  ? currentManagedTurnForTerminal(ownership.conversation, terminal, rawActions)
1632
1788
  : undefined;
1633
1789
  const currentTurn = currentTurnValue && !mutationsAllowed
1634
1790
  ? readOnlyManagedTurn(currentTurnValue)
1635
1791
  : currentTurnValue;
1636
- const sortedDisplayed = [...displayedRelated]
1792
+ const sortedDisplayed = [...sessionDisplayedRelated]
1637
1793
  .filter((conversation) => conversation.conversation_id !== currentTurn?.conversation_id)
1638
1794
  .sort(compareManagedConversationRecency);
1639
1795
  const recentConversation = currentTurn ? undefined : sortedDisplayed[0];
@@ -1654,17 +1810,29 @@ function terminalFirstListProjection({ terminals, allConversations, displayedCon
1654
1810
  .map((turn) => stringValue(turn?.conversation_id))
1655
1811
  .filter((id) => id !== undefined));
1656
1812
  const management = {
1813
+ session_id: managedSessionId ?? null,
1814
+ session_short_ref: managedSessionId
1815
+ ? sessionShortRef(managedSessionId)
1816
+ : null,
1657
1817
  current_turn: currentTurn ?? null,
1658
1818
  recent_turn: recentTurn ?? null,
1659
- turn_count: allRelated.length,
1660
- hidden_turn_count: allRelated.filter((conversation) => !visibleTurnIds.has(conversation.conversation_id)).length,
1819
+ turn_count: sessionAllRelated.length,
1820
+ hidden_turn_count: sessionAllRelated.filter((conversation) => !visibleTurnIds.has(conversation.conversation_id)).length,
1821
+ session_count: sessionIds.size,
1661
1822
  ...(includeAll ? { history } : {})
1662
1823
  };
1663
1824
  const availableActions = ownership.state === "current"
1664
1825
  ? currentTerminalActions(currentTurn)
1665
1826
  : ownership.state === "conflict"
1666
1827
  ? safeTerminalActionsDuringConflict(rawActions)
1667
- : rawActions;
1828
+ : managedSessionId &&
1829
+ sessionBindingMatchesLiveTerminal &&
1830
+ isRecord(rawActions.send)
1831
+ ? {
1832
+ ...rawActions,
1833
+ send: sendActionForManagedSession(rawActions.send, managedSessionId)
1834
+ }
1835
+ : rawActions;
1668
1836
  return {
1669
1837
  ...terminal,
1670
1838
  management_state: ownership.state === "current"
@@ -1871,7 +2039,7 @@ function currentTerminalActions(currentTurn) {
1871
2039
  return {};
1872
2040
  }
1873
2041
  const actions = {};
1874
- for (const action of ["status", "approve", "cancel", "renew", "retry_callback"]) {
2042
+ for (const action of ["status", "respond", "approve", "cancel", "renew", "retry_callback"]) {
1875
2043
  if (isRecord(currentTurn.available_actions[action])) {
1876
2044
  actions[action] = currentTurn.available_actions[action];
1877
2045
  }
@@ -1887,6 +2055,18 @@ function safeTerminalActionsDuringConflict(rawActions) {
1887
2055
  }
1888
2056
  return actions;
1889
2057
  }
2058
+ function sendActionForManagedSession(action, sessionId) {
2059
+ const { selector: _selector, ...existingArguments } = isRecord(action.arguments)
2060
+ ? action.arguments
2061
+ : {};
2062
+ return {
2063
+ ...action,
2064
+ arguments: {
2065
+ ...existingArguments,
2066
+ session_id: sessionId
2067
+ }
2068
+ };
2069
+ }
1890
2070
  function safeUnavailableManagedTurnActions(actionsValue) {
1891
2071
  const actions = {};
1892
2072
  for (const action of ["status", "retry_callback", "close"]) {
@@ -1919,11 +2099,6 @@ function historicalManagedTurnForTerminal(conversation, terminalCanAcceptSend, t
1919
2099
  ? managedTurn.available_actions
1920
2100
  : {};
1921
2101
  const safeActions = safeUnavailableManagedTurnActions(availableActions);
1922
- if (terminalCanAcceptSend &&
1923
- managedTurnMatchesLiveTerminal(conversation, terminal) &&
1924
- isRecord(availableActions.follow_up)) {
1925
- safeActions.follow_up = availableActions.follow_up;
1926
- }
1927
2102
  return {
1928
2103
  ...managedTurn,
1929
2104
  available_actions: safeActions
@@ -1948,9 +2123,31 @@ function managedTurnMatchesLiveTerminal(conversation, terminal) {
1948
2123
  terminalControlSelectorKey(liveControl)) {
1949
2124
  return false;
1950
2125
  }
1951
- const storedSessionId = stringValue(takeover?.terminal_agent_session_id);
1952
- const liveSessionId = stringValue(terminal.session_id);
1953
- if (storedSessionId && storedSessionId !== liveSessionId) {
2126
+ const liveSessionId = stringValue(terminal.native_agent_session_id);
2127
+ const liveProcessUuid = stringValue(terminal.native_agent_process_uuid);
2128
+ const liveProcessBirth = stringValue(terminal.native_agent_process_birth);
2129
+ const liveRollout = isRecord(terminal.native_agent_rollout)
2130
+ ? terminal.native_agent_rollout
2131
+ : undefined;
2132
+ const liveNativeIdentity = liveSessionId
2133
+ ? {
2134
+ sessionId: liveSessionId,
2135
+ ...(liveProcessUuid ? { processUuid: liveProcessUuid } : {}),
2136
+ ...(liveProcessBirth ? { processBirth: liveProcessBirth } : {}),
2137
+ ...(liveRollout
2138
+ ? {
2139
+ rollout: {
2140
+ fd: String(liveRollout.fd ?? ""),
2141
+ device: String(liveRollout.device ?? ""),
2142
+ inode: String(liveRollout.inode ?? ""),
2143
+ path: String(liveRollout.path ?? "")
2144
+ }
2145
+ }
2146
+ : {}),
2147
+ evidence: "live_terminal"
2148
+ }
2149
+ : undefined;
2150
+ if (!nativeAgentIdentityMatchesTurn(conversation, liveNativeIdentity)) {
1954
2151
  return false;
1955
2152
  }
1956
2153
  const liveWorkspace = terminal.workspace ?? terminal.cwd;
@@ -2002,7 +2199,8 @@ function retargetConversationAction(action, conversationId) {
2002
2199
  ...action,
2003
2200
  arguments: {
2004
2201
  ...(isRecord(action.arguments) ? action.arguments : {}),
2005
- conversation_id: conversationId
2202
+ turn_id: conversationId,
2203
+ conversation_id: undefined
2006
2204
  },
2007
2205
  ...(beforeCall
2008
2206
  ? {
@@ -2012,7 +2210,8 @@ function retargetConversationAction(action, conversationId) {
2012
2210
  ...(isRecord(beforeCall.arguments)
2013
2211
  ? beforeCall.arguments
2014
2212
  : {}),
2015
- conversation_id: conversationId
2213
+ turn_id: conversationId,
2214
+ conversation_id: undefined
2016
2215
  }
2017
2216
  }
2018
2217
  }
@@ -2083,9 +2282,6 @@ function childPidsForRoot(root, processes) {
2083
2282
  .filter((process) => process.agent === root.agent && process.ppid === root.pid)
2084
2283
  .map((process) => process.pid);
2085
2284
  }
2086
- function canFollowUpManagedTurn(status) {
2087
- return !["done", "failed", "closed", "cancelled"].includes(status);
2088
- }
2089
2285
  function managedListApprovalState(conversation) {
2090
2286
  if (!terminalBridgeEnabled(conversation) ||
2091
2287
  !["waiting_for_agent", "waiting_for_openclaw"].includes(String(conversation.status))) {
@@ -2121,10 +2317,13 @@ function managedListApprovalState(conversation) {
2121
2317
  }
2122
2318
  function listActionContracts() {
2123
2319
  return {
2124
- version: 3,
2320
+ version: 4,
2125
2321
  instructions: [
2126
2322
  "Treat terminals[] as the primary resource and use only actions present in available_actions.",
2127
- "Use a terminal send action to start a new managed turn. Use a managed turn follow_up action only when continuing that specific managed turn.",
2323
+ "An existing managed session's ordinary send targets session_id and creates a new turn. A turn id is never an ordinary send target.",
2324
+ "For first attach only, use the selector prefilled by that unmanaged raw-terminal row's available send action; never construct, guess, or reuse it.",
2325
+ "Use respond only for an in-flight turn that is explicitly waiting for OpenClaw.",
2326
+ "Managed controls target turn_id. A raw terminal may be controlled only through its own list-prefilled conversation_id action; never construct, guess, or reuse that compatibility selector.",
2128
2327
  "Start with the action's prefilled arguments, supply every missing_required field, and consult the top-level action's optional fields only when needed.",
2129
2328
  "Authoritative full IDs are prefilled; short_ref is for display and human input.",
2130
2329
  "Availability is a snapshot. AKK revalidates process, tmux pane, workspace, activity, approval, and recovery state before side effects."
@@ -2144,7 +2343,8 @@ function listActionContracts() {
2144
2343
  },
2145
2344
  managed: {
2146
2345
  current_turn: "the authoritative dispatch-ledger owner, never inferred from history",
2147
- recent_turn: "the latest visible non-owning turn for intentional follow-up",
2346
+ recent_turn: "the latest visible non-owning turn in the current managed session",
2347
+ session_id: "the continuing agent context and authoritative ordinary-send target",
2148
2348
  history: "older turns, present only with --all"
2149
2349
  },
2150
2350
  available_actions: {
@@ -2155,7 +2355,9 @@ function listActionContracts() {
2155
2355
  actions: {
2156
2356
  send: {
2157
2357
  tool: "agent_knock_knock_send",
2158
- target_argument: "selector",
2358
+ target_argument: "session_id",
2359
+ initial_attach_target_argument: "selector",
2360
+ initial_attach_scope: "Only the selector prefilled by an unmanaged raw-terminal row's available send action; never construct, guess, or reuse it.",
2159
2361
  required: ["request"],
2160
2362
  optional: [
2161
2363
  "selector",
@@ -2165,55 +2367,61 @@ function listActionContracts() {
2165
2367
  "agentHardTimeoutMinutes"
2166
2368
  ],
2167
2369
  unsupported: ["timeoutSeconds"],
2168
- ordinary_use: "Start a new managed turn on the selected physical terminal. Add request only and omit timeout fields unless the user explicitly asks to change monitoring limits."
2370
+ ordinary_use: "Create a new managed turn in the selected session. A live terminal selector is accepted only for initial attach/discovery compatibility."
2169
2371
  },
2170
- follow_up: {
2171
- tool: "agent_knock_knock_send",
2172
- target_argument: "selector",
2173
- required: ["request"],
2174
- optional: [
2175
- "selector",
2176
- "idleTimeoutMinutes",
2177
- "agentTimeoutMinutes",
2178
- "agentHardTimeoutMinutes"
2179
- ],
2180
- ordinary_use: "Continue the explicitly selected managed turn. Start from its prefilled selector and add request."
2372
+ respond: {
2373
+ tool: "agent_knock_knock_respond",
2374
+ target_argument: "turn_id",
2375
+ required: ["turn_id", "request"],
2376
+ ordinary_use: "Answer an agent question inside the explicitly selected in-flight turn without creating another turn."
2181
2377
  },
2182
2378
  status: {
2183
2379
  tool: "agent_knock_knock_status",
2184
- target_argument: "conversation_id",
2185
- required: ["conversation_id"],
2380
+ target_argument: "turn_id",
2381
+ compatibility_target_argument: "conversation_id",
2382
+ compatibility_scope: "A deprecated legacy Turn alias, or only the exact selector prefilled by an unmanaged raw-terminal row's available status action; never construct, guess, or reuse it.",
2383
+ required: ["turn_id"],
2186
2384
  optional: ["idleTimeoutMinutes", "trace"]
2187
2385
  },
2188
2386
  approve: {
2189
2387
  tool: "agent_knock_knock_approve",
2190
- target_argument: "conversation_id",
2191
- required: ["conversation_id", "expected_approval_fingerprint"],
2388
+ target_argument: "turn_id",
2389
+ compatibility_target_argument: "conversation_id",
2390
+ compatibility_scope: "A deprecated legacy Turn alias, or only the exact selector prefilled by an unmanaged raw-terminal row's available approval action; never construct, guess, or reuse it.",
2391
+ required: ["turn_id", "expected_approval_fingerprint"],
2192
2392
  requires_explicit_user_confirmation: true,
2193
2393
  requires_fresh_status: true
2194
2394
  },
2195
2395
  cancel: {
2196
2396
  tool: "agent_knock_knock_cancel",
2197
- target_argument: "conversation_id",
2198
- required: ["conversation_id"],
2397
+ target_argument: "turn_id",
2398
+ compatibility_target_argument: "conversation_id",
2399
+ compatibility_scope: "A deprecated legacy Turn alias, or only the exact selector prefilled by an unmanaged raw-terminal row's available cancellation action; never construct, guess, or reuse it.",
2400
+ required: ["turn_id"],
2199
2401
  optional: ["idleTimeoutMinutes"],
2200
2402
  requires_user_intent: true
2201
2403
  },
2202
2404
  renew: {
2203
2405
  tool: "agent_knock_knock_renew",
2204
- target_argument: "conversation_id",
2205
- required: ["conversation_id"],
2406
+ target_argument: "turn_id",
2407
+ compatibility_target_argument: "conversation_id",
2408
+ compatibility_scope: "Deprecated legacy Turn alias only; unmanaged raw-terminal rows never advertise renew.",
2409
+ required: ["turn_id"],
2206
2410
  optional: ["minutes"]
2207
2411
  },
2208
2412
  retry_callback: {
2209
2413
  tool: "agent_knock_knock_retry_callback",
2210
- target_argument: "conversation_id",
2211
- required: ["conversation_id"]
2414
+ target_argument: "turn_id",
2415
+ compatibility_target_argument: "conversation_id",
2416
+ compatibility_scope: "Deprecated legacy Turn alias only; unmanaged raw-terminal rows never advertise callback retry.",
2417
+ required: ["turn_id"]
2212
2418
  },
2213
2419
  close: {
2214
2420
  tool: "agent_knock_knock_close",
2215
- target_argument: "conversation_id",
2216
- required: ["conversation_id"],
2421
+ target_argument: "turn_id",
2422
+ compatibility_target_argument: "conversation_id",
2423
+ compatibility_scope: "A deprecated legacy Turn alias, or only the exact selector prefilled by an unmanaged raw-terminal row's orphan-close action; never construct, guess, or reuse it.",
2424
+ required: ["turn_id"],
2217
2425
  optional: ["reason", "expected_message_id"],
2218
2426
  requires_explicit_user_confirmation: true
2219
2427
  }
@@ -2226,14 +2434,17 @@ function availableListActions(entry, { conversation } = {}) {
2226
2434
  return {};
2227
2435
  }
2228
2436
  const commands = isRecord(entry.commands) ? entry.commands : {};
2437
+ const managed = entry.source === "managed_turn";
2438
+ const targetArguments = managed
2439
+ ? { turn_id: id }
2440
+ : { conversation_id: id };
2229
2441
  const actions = {
2230
2442
  status: {
2231
2443
  tool: "agent_knock_knock_status",
2232
- arguments: { conversation_id: id }
2444
+ arguments: targetArguments
2233
2445
  }
2234
2446
  };
2235
2447
  const terminalControlled = entry.source === "terminal";
2236
- const managed = entry.source === "managed_turn";
2237
2448
  const approvalState = isRecord(entry.approval_state)
2238
2449
  ? entry.approval_state
2239
2450
  : {};
@@ -2244,20 +2455,28 @@ function availableListActions(entry, { conversation } = {}) {
2244
2455
  const terminalBridgeReady = managed &&
2245
2456
  terminalBridgeEnabled(conversation) &&
2246
2457
  terminalControlFromTakeover(nativeTakeover) !== undefined;
2247
- if (commands.send === true &&
2248
- ((terminalBridgeReady &&
2249
- ["waiting_for_openclaw", "idle"].includes(String(entry.status)) &&
2250
- !managedApprovalPending &&
2251
- approvalState.blocked !== true) ||
2252
- (terminalControlled &&
2253
- entry.activity_state === "idle" &&
2254
- approvalState.blocked !== true))) {
2255
- actions[managed ? "follow_up" : "send"] = {
2458
+ if (terminalControlled &&
2459
+ commands.send === true &&
2460
+ entry.activity_state === "idle" &&
2461
+ approvalState.blocked !== true) {
2462
+ actions.send = {
2256
2463
  tool: "agent_knock_knock_send",
2257
2464
  arguments: { selector: id },
2258
2465
  missing_required: ["request"]
2259
2466
  };
2260
2467
  }
2468
+ if (managed &&
2469
+ commands.respond === true &&
2470
+ terminalBridgeReady &&
2471
+ entry.status === "waiting_for_openclaw" &&
2472
+ !managedApprovalPending &&
2473
+ approvalState.blocked !== true) {
2474
+ actions.respond = {
2475
+ tool: "agent_knock_knock_respond",
2476
+ arguments: { turn_id: id },
2477
+ missing_required: ["request"]
2478
+ };
2479
+ }
2261
2480
  const approvalFingerprint = stringValue(approvalState.fingerprint);
2262
2481
  const managedApprovalEligible = terminalBridgeReady &&
2263
2482
  entry.status === "waiting_for_openclaw" &&
@@ -2271,11 +2490,11 @@ function availableListActions(entry, { conversation } = {}) {
2271
2490
  managedApprovalEligible)) {
2272
2491
  actions.approve = {
2273
2492
  tool: "agent_knock_knock_approve",
2274
- arguments: { conversation_id: id },
2493
+ arguments: targetArguments,
2275
2494
  missing_required: ["expected_approval_fingerprint"],
2276
2495
  before_call: {
2277
2496
  tool: "agent_knock_knock_status",
2278
- arguments: { conversation_id: id },
2497
+ arguments: targetArguments,
2279
2498
  use: "After explicit user confirmation, copy the latest terminal_status.approval_state.fingerprint into expected_approval_fingerprint."
2280
2499
  },
2281
2500
  requires_explicit_user_confirmation: true,
@@ -2294,14 +2513,14 @@ function availableListActions(entry, { conversation } = {}) {
2294
2513
  if (rawCancellable || managedCancellable) {
2295
2514
  actions.cancel = {
2296
2515
  tool: "agent_knock_knock_cancel",
2297
- arguments: { conversation_id: id },
2516
+ arguments: targetArguments,
2298
2517
  requires_user_intent: true
2299
2518
  };
2300
2519
  }
2301
2520
  if (terminalBridgeReady && entry.status === "stalled") {
2302
2521
  actions.renew = {
2303
2522
  tool: "agent_knock_knock_renew",
2304
- arguments: { conversation_id: id }
2523
+ arguments: targetArguments
2305
2524
  };
2306
2525
  }
2307
2526
  const callbackDelivery = isRecord(conversation?.callback_delivery)
@@ -2312,7 +2531,7 @@ function availableListActions(entry, { conversation } = {}) {
2312
2531
  isRetryableCallbackDelivery(conversation, callbackDelivery)) {
2313
2532
  actions.retry_callback = {
2314
2533
  tool: "agent_knock_knock_retry_callback",
2315
- arguments: { conversation_id: id }
2534
+ arguments: targetArguments
2316
2535
  };
2317
2536
  }
2318
2537
  if (commands.close === true) {
@@ -2323,7 +2542,7 @@ function availableListActions(entry, { conversation } = {}) {
2323
2542
  actions.close = {
2324
2543
  tool: "agent_knock_knock_close",
2325
2544
  arguments: {
2326
- conversation_id: id,
2545
+ ...(managed ? { turn_id: id } : { conversation_id: id }),
2327
2546
  ...(expectedMessageId
2328
2547
  ? { expected_message_id: expectedMessageId }
2329
2548
  : {})
@@ -2338,17 +2557,32 @@ async function resolveConversationSelectorOption(commandName, options) {
2338
2557
  options.state) {
2339
2558
  return;
2340
2559
  }
2341
- const supplied = stringValue(options.conversation ?? options.conversationId)?.trim();
2560
+ const sendOperation = commandName === "send";
2561
+ const supplied = stringValue(sendOperation
2562
+ ? options.session ?? options.conversation ?? options.conversationId
2563
+ : options.turn ?? options.conversation ?? options.conversationId)?.trim();
2342
2564
  if (supplied && !isSessionSelectorSyntax(supplied)) {
2343
2565
  // Full authoritative IDs keep their existing command-specific validation
2344
2566
  // path. This avoids a discovery scan before option validation and preserves
2345
2567
  // precise downstream errors for closed or currently non-actionable state.
2568
+ if (sendOperation) {
2569
+ options.session = supplied;
2570
+ }
2571
+ else {
2572
+ options.turn = supplied;
2573
+ }
2346
2574
  return;
2347
2575
  }
2348
2576
  const candidates = await sessionSelectorCandidates(commandName, options);
2349
2577
  const resolution = resolveSessionSelector(supplied, candidates, {
2350
2578
  operation: commandName
2351
2579
  });
2580
+ if (sendOperation) {
2581
+ options.session = resolution.id;
2582
+ }
2583
+ else {
2584
+ options.turn = resolution.id;
2585
+ }
2352
2586
  options.conversation = resolution.id;
2353
2587
  delete options.conversationId;
2354
2588
  }
@@ -2389,6 +2623,86 @@ async function sessionSelectorCandidates(commandName, options) {
2389
2623
  mutationsAllowed
2390
2624
  });
2391
2625
  const observedAtMs = Date.now();
2626
+ if (commandName === "send") {
2627
+ const sessionEntries = terminalProjection.terminals.flatMap((entry) => {
2628
+ const managedState = isRecord(entry.managed) ? entry.managed : undefined;
2629
+ const recentTurn = isRecord(managedState?.recent_turn)
2630
+ ? managedState.recent_turn
2631
+ : undefined;
2632
+ const currentTurn = isRecord(managedState?.current_turn)
2633
+ ? managedState.current_turn
2634
+ : undefined;
2635
+ const sessionId = stringValue(managedState?.session_id);
2636
+ const actions = isRecord(entry.available_actions)
2637
+ ? entry.available_actions
2638
+ : undefined;
2639
+ const sendAction = isRecord(actions?.send)
2640
+ ? actions.send
2641
+ : undefined;
2642
+ const sendArguments = isRecord(sendAction?.arguments)
2643
+ ? sendAction.arguments
2644
+ : undefined;
2645
+ if (!sessionId ||
2646
+ !sendAction ||
2647
+ stringValue(sendArguments?.session_id) !== sessionId) {
2648
+ return [];
2649
+ }
2650
+ const commonEntry = {
2651
+ agent: entry.agent,
2652
+ status: "idle",
2653
+ workspace: entry.workspace ?? entry.cwd,
2654
+ updated_at: recentTurn?.updated_at ??
2655
+ currentTurn?.updated_at,
2656
+ available_actions: {
2657
+ send: sendAction
2658
+ }
2659
+ };
2660
+ return [
2661
+ {
2662
+ ...commonEntry,
2663
+ id: sessionId,
2664
+ short_ref: sessionShortRef(sessionId),
2665
+ source: "managed_session"
2666
+ },
2667
+ {
2668
+ ...commonEntry,
2669
+ id: String(entry.id),
2670
+ short_ref: stringValue(entry.short_ref) ??
2671
+ sessionShortRef(String(entry.id)),
2672
+ source: "managed_session_terminal_alias"
2673
+ }
2674
+ ];
2675
+ });
2676
+ const rawTerminalEntries = terminalProjection.terminals.filter((entry) => {
2677
+ const managedState = isRecord(entry.managed) ? entry.managed : undefined;
2678
+ const actions = isRecord(entry.available_actions)
2679
+ ? entry.available_actions
2680
+ : undefined;
2681
+ const sendAction = isRecord(actions?.send) ? actions.send : undefined;
2682
+ const sendArguments = isRecord(sendAction?.arguments)
2683
+ ? sendAction.arguments
2684
+ : undefined;
2685
+ return (!stringValue(managedState?.session_id) ||
2686
+ !stringValue(sendArguments?.session_id));
2687
+ });
2688
+ return [
2689
+ ...managed.map((entry) => sessionSelectorCandidateForEntry(entry, commandName, observedAtMs, {
2690
+ defaultActionable: false,
2691
+ mutationsAllowed
2692
+ })),
2693
+ ...sessionEntries.map((entry) => sessionSelectorCandidateForEntry(entry, commandName, observedAtMs, {
2694
+ // A managed terminal's full id/@short-ref is an explicit alias for
2695
+ // its Session send target. It must not duplicate the Session in
2696
+ // omitted, only, latest, or agent-name selection.
2697
+ defaultActionable: entry.source !== "managed_session_terminal_alias",
2698
+ mutationsAllowed
2699
+ })),
2700
+ ...rawTerminalEntries.map((entry) => sessionSelectorCandidateForEntry(entry, commandName, observedAtMs, {
2701
+ defaultActionable: true,
2702
+ mutationsAllowed
2703
+ }))
2704
+ ];
2705
+ }
2392
2706
  return [
2393
2707
  ...managed.map((entry) => sessionSelectorCandidateForEntry(entry, commandName, observedAtMs, {
2394
2708
  defaultActionable: options.managedOnly === true,
@@ -2424,9 +2738,7 @@ function listActionForCommand(entry, commandName) {
2424
2738
  : {};
2425
2739
  const actionName = commandName === "retry-callback"
2426
2740
  ? "retry_callback"
2427
- : commandName === "send" && entry.source === "managed_turn"
2428
- ? "follow_up"
2429
- : commandName;
2741
+ : commandName;
2430
2742
  if (isRecord(actions[actionName])) {
2431
2743
  return actions[actionName];
2432
2744
  }
@@ -2463,7 +2775,10 @@ function listActionTargetId(action) {
2463
2775
  const actionArguments = isRecord(action?.arguments)
2464
2776
  ? action.arguments
2465
2777
  : undefined;
2466
- return stringValue(actionArguments?.selector ?? actionArguments?.conversation_id);
2778
+ return stringValue(actionArguments?.session_id ??
2779
+ actionArguments?.turn_id ??
2780
+ actionArguments?.selector ??
2781
+ actionArguments?.conversation_id);
2467
2782
  }
2468
2783
  function terminalControlSelectorKey(value) {
2469
2784
  if (!isRecord(value)) {
@@ -2492,7 +2807,10 @@ function sessionEntryRecency(entry, observedAtMs) {
2492
2807
  return {};
2493
2808
  }
2494
2809
  async function resolveTerminalConversationFromOptions(options) {
2495
- return createTerminalAgentBridge(options).resolveConversationId(stringValue(options.conversation ?? options.conversationId));
2810
+ return createTerminalAgentBridge(options).resolveConversationId(stringValue(options.session ??
2811
+ options.turn ??
2812
+ options.conversation ??
2813
+ options.conversationId));
2496
2814
  }
2497
2815
  async function runStatus(options) {
2498
2816
  const explicitStatePath = options.state
@@ -2501,7 +2819,7 @@ async function runStatus(options) {
2501
2819
  const storeDir = explicitStatePath
2502
2820
  ? pathsForConversationDir(path.dirname(explicitStatePath)).storeDir
2503
2821
  : storeDirFromOptions(options);
2504
- const reconciliationConversationId = stringValue(options.conversation ?? options.conversationId) ??
2822
+ const reconciliationConversationId = stringValue(options.turn ?? options.conversation ?? options.conversationId) ??
2505
2823
  (explicitStatePath
2506
2824
  ? path.basename(pathsForConversationDir(path.dirname(explicitStatePath))
2507
2825
  .conversationDir)
@@ -3022,12 +3340,13 @@ function prepareManagedSend({ options, statePath, logPath, messageBody, stateLoc
3022
3340
  }
3023
3341
  }
3024
3342
  const conversation = loadState(statePath);
3025
- if (!["waiting_for_agent", "waiting_for_openclaw", "idle"].includes(conversation.status)) {
3026
- throw new Error(`cannot send to ${conversation.conversation_id}; conversation is ${conversation.status}`);
3343
+ if (conversation.status !== "waiting_for_openclaw" ||
3344
+ options.type !== "answer") {
3345
+ throw new Error(`cannot respond to turn ${turnIdForConversation(conversation)}; ` +
3346
+ `turn is ${conversation.status}`);
3027
3347
  }
3028
3348
  const executor = executorForConversation(conversation);
3029
- const type = options.type ??
3030
- (conversation.status === "waiting_for_openclaw" ? "answer" : "task");
3349
+ const type = "answer";
3031
3350
  const nativeTakeoverForSend = isRecord(conversation.native_session_takeover)
3032
3351
  ? conversation.native_session_takeover
3033
3352
  : undefined;
@@ -3085,6 +3404,12 @@ async function runSend(options) {
3085
3404
  if (options.agentHardTimeoutMinutes !== undefined) {
3086
3405
  positiveMinutes(options.agentHardTimeoutMinutes, "--agent-hard-timeout-minutes");
3087
3406
  }
3407
+ if (options.respond === true) {
3408
+ return runTurnResponse({ options, messageBody });
3409
+ }
3410
+ if ((options.type ?? "task") !== "task") {
3411
+ throw new Error("ordinary send only accepts message type task; use respond --turn to answer an in-flight Turn");
3412
+ }
3088
3413
  const terminalConversation = await resolveTerminalConversationFromOptions(options);
3089
3414
  if (terminalConversation) {
3090
3415
  if (!options.background) {
@@ -3093,13 +3418,29 @@ async function runSend(options) {
3093
3418
  const releaseTerminalLock = acquireFileLock(terminalBridgeSendLockPath(storeDirFromOptions(options), terminalConversation.terminalControl), { timeoutMs: 30000 });
3094
3419
  let releaseStateLock;
3095
3420
  try {
3096
- const managed = createManagedTerminalConversationFromRawId({
3421
+ const currentNativeIdentity = await resolveCurrentNativeAgentSessionIdentity({
3422
+ options,
3423
+ agent: terminalConversation.agent,
3424
+ pid: terminalConversation.pid,
3425
+ cwd: terminalConversation.terminalControl.currentPath
3426
+ });
3427
+ const reusableTurn = reusableManagedSessionTurnForTerminal({
3428
+ options,
3429
+ terminalConversation,
3430
+ currentNativeIdentity
3431
+ });
3432
+ assertManagedSessionCanStartTurn(reusableTurn
3433
+ ? managedTurnsForSession(storeDirFromOptions(options), sessionIdForConversation(reusableTurn))
3434
+ : []);
3435
+ const managed = createManagedTerminalTurn({
3097
3436
  options,
3098
3437
  conversationId: terminalConversation.conversationId,
3099
3438
  agent: terminalConversation.agent,
3100
3439
  pid: terminalConversation.pid,
3101
3440
  messageBody,
3102
- terminalControl: terminalConversation.terminalControl
3441
+ terminalControl: terminalConversation.terminalControl,
3442
+ previousTurn: reusableTurn,
3443
+ nativeAgentIdentity: currentNativeIdentity
3103
3444
  });
3104
3445
  ensureStoreWritable(managed.conversation.store_dir);
3105
3446
  ensureDir(path.dirname(managed.statePath));
@@ -3116,7 +3457,7 @@ async function runSend(options) {
3116
3457
  terminalSendLockHeld: true,
3117
3458
  terminalStateLockHeld: true,
3118
3459
  recordMessageAfterSend: true,
3119
- recordRawAttachmentAfterSend: true
3460
+ recordRawAttachmentAfterSend: reusableTurn === undefined
3120
3461
  });
3121
3462
  }
3122
3463
  finally {
@@ -3129,62 +3470,185 @@ async function runSend(options) {
3129
3470
  }
3130
3471
  return;
3131
3472
  }
3473
+ const sessionId = required(stringValue(options.session ?? options.conversation ?? options.conversationId), "--session is required for an ordinary managed send");
3474
+ const initialTurns = managedTurnsForSessionTarget(storeDirFromOptions(options), sessionId);
3475
+ assertManagedSessionCanStartTurn(initialTurns);
3476
+ const bindingTurn = initialTurns[0];
3477
+ const bindingTakeover = isRecord(bindingTurn.native_session_takeover)
3478
+ ? bindingTurn.native_session_takeover
3479
+ : undefined;
3480
+ const rawTerminalId = stringValue(bindingTakeover?.native_session_id);
3481
+ const resolvedTerminal = await createTerminalAgentBridge(options)
3482
+ .resolveConversationId(rawTerminalId);
3483
+ if (!resolvedTerminal) {
3484
+ throw new Error(`session ${sessionId} is not attached to a live tmux terminal`);
3485
+ }
3486
+ const currentNativeIdentity = await resolveCurrentNativeAgentSessionIdentity({
3487
+ options,
3488
+ agent: resolvedTerminal.agent,
3489
+ pid: resolvedTerminal.pid,
3490
+ cwd: resolvedTerminal.terminalControl.currentPath
3491
+ });
3492
+ assertNativeAgentIdentityForTurn({
3493
+ conversation: bindingTurn,
3494
+ currentIdentity: currentNativeIdentity,
3495
+ operation: "send to"
3496
+ });
3497
+ if (!managedTurnMatchesResolvedTerminal(bindingTurn, resolvedTerminal, currentNativeIdentity)) {
3498
+ throw new Error(`session ${sessionId} no longer matches its terminal or agent process incarnation`);
3499
+ }
3500
+ const releaseTerminalLock = acquireFileLock(terminalBridgeSendLockPath(storeDirFromOptions(options), resolvedTerminal.terminalControl), { timeoutMs: 30000 });
3501
+ let releaseStateLock;
3502
+ try {
3503
+ const currentTurns = managedTurnsForSessionTarget(storeDirFromOptions(options), sessionId);
3504
+ assertManagedSessionCanStartTurn(currentTurns);
3505
+ const currentBinding = currentTurns[0];
3506
+ const lockedNativeIdentity = await resolveCurrentNativeAgentSessionIdentity({
3507
+ options,
3508
+ agent: resolvedTerminal.agent,
3509
+ pid: resolvedTerminal.pid,
3510
+ cwd: resolvedTerminal.terminalControl.currentPath
3511
+ });
3512
+ assertNativeAgentIdentityForTurn({
3513
+ conversation: currentBinding,
3514
+ currentIdentity: lockedNativeIdentity,
3515
+ operation: "send to"
3516
+ });
3517
+ if (!managedTurnMatchesResolvedTerminal(currentBinding, resolvedTerminal, lockedNativeIdentity)) {
3518
+ throw new Error("managed session identity changed while waiting to send; refresh list and retry");
3519
+ }
3520
+ const managed = createManagedTerminalTurn({
3521
+ options,
3522
+ conversationId: resolvedTerminal.conversationId,
3523
+ agent: resolvedTerminal.agent,
3524
+ pid: resolvedTerminal.pid,
3525
+ messageBody,
3526
+ terminalControl: resolvedTerminal.terminalControl,
3527
+ previousTurn: currentBinding,
3528
+ nativeAgentIdentity: lockedNativeIdentity
3529
+ });
3530
+ ensureStoreWritable(managed.conversation.store_dir);
3531
+ ensureDir(path.dirname(managed.statePath));
3532
+ releaseStateLock = acquireFileLock(`${managed.statePath}.lock`);
3533
+ await runTerminalControlSend({
3534
+ options,
3535
+ conversation: managed.conversation,
3536
+ nextConversation: managed.nextConversation,
3537
+ statePath: managed.statePath,
3538
+ logPath: managed.logPath,
3539
+ executor: managed.executor,
3540
+ message: managed.message,
3541
+ terminalControl: resolvedTerminal.terminalControl,
3542
+ terminalSendLockHeld: true,
3543
+ terminalStateLockHeld: true,
3544
+ recordMessageAfterSend: true
3545
+ });
3546
+ }
3547
+ finally {
3548
+ try {
3549
+ releaseStateLock?.();
3550
+ }
3551
+ finally {
3552
+ releaseTerminalLock();
3553
+ }
3554
+ }
3555
+ }
3556
+ async function runRespond(options) {
3557
+ const turnId = required(stringValue(options.turn ?? options.conversation ?? options.conversationId), "--turn is required");
3558
+ return runSend({
3559
+ ...options,
3560
+ turn: turnId,
3561
+ conversation: turnId,
3562
+ session: undefined,
3563
+ type: "answer",
3564
+ respond: true
3565
+ });
3566
+ }
3567
+ async function runTurnResponse({ options, messageBody }) {
3132
3568
  const loaded = loadConversationFromOptions(options);
3133
3569
  const { statePath, logPath } = loaded;
3134
- const migratedConversation = await migrateLegacyTerminalAgentIdentity({
3570
+ const conversation = await migrateLegacyTerminalAgentIdentity({
3135
3571
  ...loaded,
3136
3572
  options
3137
3573
  });
3138
- const migratedTakeover = isRecord(migratedConversation.native_session_takeover)
3139
- ? migratedConversation.native_session_takeover
3574
+ if (conversation.status !== "waiting_for_openclaw") {
3575
+ throw new Error(`cannot respond to turn ${turnIdForConversation(conversation)}; ` +
3576
+ `turn is ${conversation.status}, not waiting_for_openclaw`);
3577
+ }
3578
+ const nativeTakeover = isRecord(conversation.native_session_takeover)
3579
+ ? conversation.native_session_takeover
3140
3580
  : undefined;
3141
- const migratedTerminalControl = terminalControlFromTakeover(migratedTakeover);
3142
- if (migratedTerminalControl) {
3143
- const releaseTerminalLock = acquireFileLock(terminalBridgeSendLockPath(storeDirFromOptions(options), migratedTerminalControl), { timeoutMs: 30000 });
3144
- let releaseStateLock;
3581
+ const terminalControl = terminalControlFromTakeover(nativeTakeover);
3582
+ if (!terminalControl) {
3583
+ throw new Error(`turn ${turnIdForConversation(conversation)} is not attached to a live tmux terminal`);
3584
+ }
3585
+ const releaseTerminalLock = acquireFileLock(terminalBridgeSendLockPath(storeDirFromOptions(options), terminalControl), { timeoutMs: 30000 });
3586
+ let releaseStateLock;
3587
+ try {
3588
+ releaseStateLock = acquireFileLock(`${statePath}.lock`);
3589
+ const prepared = prepareManagedSend({
3590
+ options: { ...options, type: "answer" },
3591
+ statePath,
3592
+ logPath,
3593
+ messageBody,
3594
+ stateLockHeld: true,
3595
+ persist: false
3596
+ });
3597
+ const preparedTakeover = isRecord(prepared.conversation.native_session_takeover)
3598
+ ? prepared.conversation.native_session_takeover
3599
+ : undefined;
3600
+ const terminalAgentPid = Number(preparedTakeover?.terminal_agent_pid);
3601
+ const currentNativeIdentity = await resolveCurrentNativeAgentSessionIdentity({
3602
+ options,
3603
+ agent: prepared.executor.kind,
3604
+ pid: terminalAgentPid,
3605
+ cwd: terminalControl.currentPath
3606
+ });
3607
+ assertNativeAgentIdentityForTurn({
3608
+ conversation: prepared.conversation,
3609
+ currentIdentity: currentNativeIdentity,
3610
+ operation: "respond to"
3611
+ });
3612
+ const currentTerminalControl = terminalControlFromTakeover(prepared.nativeTakeoverForSend);
3613
+ if (!currentTerminalControl ||
3614
+ currentTerminalControl.target !== terminalControl.target ||
3615
+ currentTerminalControl.socketPath !== terminalControl.socketPath ||
3616
+ currentTerminalControl.panePid !== terminalControl.panePid) {
3617
+ throw new Error("terminal control changed while waiting to respond; refresh status and retry");
3618
+ }
3619
+ const responseOptions = {
3620
+ ...options,
3621
+ type: "answer",
3622
+ agentTimeoutMinutes: options.agentTimeoutMinutes ??
3623
+ preparedTakeover?.terminal_bridge_inactivity_timeout_minutes ??
3624
+ DEFAULT_AGENT_TIMEOUT_MINUTES,
3625
+ agentHardTimeoutMinutes: options.agentHardTimeoutMinutes ??
3626
+ preparedTakeover?.terminal_bridge_hard_timeout_minutes ??
3627
+ DEFAULT_AGENT_HARD_TIMEOUT_MINUTES
3628
+ };
3629
+ await runTerminalControlSend({
3630
+ options: responseOptions,
3631
+ conversation: prepared.conversation,
3632
+ nextConversation: prepared.nextConversation,
3633
+ statePath,
3634
+ logPath,
3635
+ executor: prepared.executor,
3636
+ message: prepared.message,
3637
+ terminalControl: currentTerminalControl,
3638
+ terminalSendLockHeld: true,
3639
+ terminalStateLockHeld: true,
3640
+ recordMessageAfterSend: true,
3641
+ continuingTurnResponse: true
3642
+ });
3643
+ }
3644
+ finally {
3145
3645
  try {
3146
- releaseStateLock = acquireFileLock(`${statePath}.lock`);
3147
- const prepared = prepareManagedSend({
3148
- options,
3149
- statePath,
3150
- logPath,
3151
- messageBody,
3152
- stateLockHeld: true,
3153
- persist: false
3154
- });
3155
- const currentTerminalControl = terminalControlFromTakeover(prepared.nativeTakeoverForSend);
3156
- if (!currentTerminalControl ||
3157
- currentTerminalControl.kind !== migratedTerminalControl.kind ||
3158
- currentTerminalControl.target !== migratedTerminalControl.target ||
3159
- currentTerminalControl.socketPath !== migratedTerminalControl.socketPath ||
3160
- currentTerminalControl.panePid !== migratedTerminalControl.panePid) {
3161
- throw new Error("terminal control changed while waiting to send; refresh status and retry");
3162
- }
3163
- await runTerminalControlSend({
3164
- options,
3165
- conversation: prepared.conversation,
3166
- nextConversation: prepared.nextConversation,
3167
- statePath,
3168
- logPath,
3169
- executor: prepared.executor,
3170
- message: prepared.message,
3171
- terminalControl: currentTerminalControl,
3172
- terminalSendLockHeld: true,
3173
- terminalStateLockHeld: true,
3174
- recordMessageAfterSend: true
3175
- });
3646
+ releaseStateLock?.();
3176
3647
  }
3177
3648
  finally {
3178
- try {
3179
- releaseStateLock?.();
3180
- }
3181
- finally {
3182
- releaseTerminalLock();
3183
- }
3649
+ releaseTerminalLock();
3184
3650
  }
3185
- return;
3186
3651
  }
3187
- throw new Error(`conversation ${migratedConversation.conversation_id} is not attached to a live tmux terminal`);
3188
3652
  }
3189
3653
  async function runApprove(options) {
3190
3654
  const terminalConversation = await resolveTerminalConversationFromOptions(options);
@@ -3327,7 +3791,6 @@ async function runApprove(options) {
3327
3791
  const autoApprovalPolicy = autoApproved
3328
3792
  ? parseJsonOption(options.autoApprovalPolicyJson, "--auto-approval-policy-json")
3329
3793
  : undefined;
3330
- const runtimeIdentity = terminalRuntimeIdentityForConversation(conversation, terminalControl);
3331
3794
  let executorPolicyDecision;
3332
3795
  const policyCandidateForInspection = ({ agent, currentTerminalControl, inspection, fingerprint }) => {
3333
3796
  const evidence = inspection.approval.approvable
@@ -3396,11 +3859,12 @@ async function runApprove(options) {
3396
3859
  action: "approve"
3397
3860
  });
3398
3861
  lockedConversation = currentConversation;
3399
- approval = await createTerminalAgentBridge(options).approve(executor.kind, terminalControl, {
3862
+ const currentRuntimeIdentity = terminalRuntimeIdentityForConversation(currentConversation, currentControl);
3863
+ approval = await createTerminalAgentBridge(options).approve(executor.kind, currentControl, {
3400
3864
  expectedFingerprint,
3401
3865
  scrollbackLines: Number(options.scrollbackLines ?? 120),
3402
- runtime: runtimeIdentity,
3403
- managedRequest: terminalDurableRequestForConversation(currentConversation, terminalControl),
3866
+ runtime: currentRuntimeIdentity,
3867
+ managedRequest: terminalDurableRequestForConversation(currentConversation, currentControl),
3404
3868
  requiredDecisionMode: autoApproved && executor.kind === "claude" ? "keys" : undefined,
3405
3869
  authorize: autoApproved
3406
3870
  ? ({ agent, terminalControl: currentTerminalControl, inspection, fingerprint }) => {
@@ -3766,7 +4230,7 @@ async function runTerminalConversationApprove({ options, conversationId, agent,
3766
4230
  releaseTerminalLock();
3767
4231
  }
3768
4232
  }
3769
- async function runTerminalControlSend({ options, conversation, nextConversation, statePath, logPath, executor, message, terminalControl, terminalSendLockHeld = false, terminalStateLockHeld = false, storeWriterLeaseHeld = false, recordMessageAfterSend = false, recordRawAttachmentAfterSend = false }) {
4233
+ async function runTerminalControlSend({ options, conversation, nextConversation, statePath, logPath, executor, message, terminalControl, terminalSendLockHeld = false, terminalStateLockHeld = false, storeWriterLeaseHeld = false, recordMessageAfterSend = false, recordRawAttachmentAfterSend = false, continuingTurnResponse = false }) {
3770
4234
  const bridge = terminalBridgeEnabled(conversation);
3771
4235
  if (!terminalSendLockHeld) {
3772
4236
  const releaseTerminalLock = acquireFileLock(terminalBridgeSendLockPath(storeDirFromOptions(options), terminalControl), { timeoutMs: 30000 });
@@ -3784,7 +4248,8 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
3784
4248
  terminalStateLockHeld,
3785
4249
  storeWriterLeaseHeld,
3786
4250
  recordMessageAfterSend,
3787
- recordRawAttachmentAfterSend
4251
+ recordRawAttachmentAfterSend,
4252
+ continuingTurnResponse
3788
4253
  });
3789
4254
  }
3790
4255
  finally {
@@ -3820,7 +4285,8 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
3820
4285
  terminalStateLockHeld: true,
3821
4286
  storeWriterLeaseHeld,
3822
4287
  recordMessageAfterSend,
3823
- recordRawAttachmentAfterSend
4288
+ recordRawAttachmentAfterSend,
4289
+ continuingTurnResponse
3824
4290
  });
3825
4291
  }
3826
4292
  finally {
@@ -3842,7 +4308,8 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
3842
4308
  terminalStateLockHeld,
3843
4309
  storeWriterLeaseHeld: true,
3844
4310
  recordMessageAfterSend,
3845
- recordRawAttachmentAfterSend
4311
+ recordRawAttachmentAfterSend,
4312
+ continuingTurnResponse
3846
4313
  }));
3847
4314
  }
3848
4315
  const terminalBridge = createTerminalAgentBridge(options);
@@ -3868,19 +4335,41 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
3868
4335
  "owner state is unavailable; inspect the shared tmux pane and repair " +
3869
4336
  "or explicitly resolve that conversation before sending another task");
3870
4337
  }
3871
- if (!TERMINAL_DISPATCH_RELEASE_STATUSES.has(owner.status)) {
3872
- if (stringValue(previousDispatchLedger.request_hash) ===
3873
- terminalRequestHash) {
4338
+ const continuingSameTurn = Boolean(continuingTurnResponse &&
4339
+ sessionIdForConversation(owner) === sessionIdForConversation(conversation) &&
4340
+ turnIdForConversation(owner) === turnIdForConversation(conversation) &&
4341
+ sameCanonicalStatePath(previousDispatchLedger.state_path, statePath));
4342
+ if (!TERMINAL_DISPATCH_RELEASE_STATUSES.has(owner.status) &&
4343
+ !continuingSameTurn) {
4344
+ const exactDispatchReplay = Boolean(stringValue(previousDispatchLedger.request_hash) ===
4345
+ terminalRequestHash &&
4346
+ stringValue(previousDispatchLedger.conversation_id) ===
4347
+ conversation.conversation_id &&
4348
+ stringValue(previousDispatchLedger.message_id) === message.id &&
4349
+ sameCanonicalStatePath(previousDispatchLedger.state_path, statePath));
4350
+ if (exactDispatchReplay) {
3874
4351
  const receiptConversationId = stringValue(previousDispatchLedger.conversation_id) ??
3875
4352
  owner.conversation_id;
4353
+ const receiptSessionId = sessionIdForConversation(owner);
4354
+ const receiptTurnId = turnIdForConversation(owner);
3876
4355
  const receiptMessageId = stringValue(previousDispatchLedger.message_id) ??
3877
4356
  message.id;
3878
4357
  printJson({
4358
+ session_id: receiptSessionId,
4359
+ turn_id: receiptTurnId,
3879
4360
  conversation: owner,
3880
4361
  message: {
3881
4362
  ...message,
3882
4363
  id: receiptMessageId,
3883
- conversation_id: receiptConversationId
4364
+ conversation_id: receiptConversationId,
4365
+ session_id: receiptSessionId,
4366
+ turn_id: receiptTurnId,
4367
+ metadata: {
4368
+ ...(isRecord(message.metadata) ? message.metadata : {}),
4369
+ task_id: receiptConversationId,
4370
+ session_id: receiptSessionId,
4371
+ turn_id: receiptTurnId
4372
+ }
3884
4373
  },
3885
4374
  delivered: true,
3886
4375
  status: "async_pending",
@@ -3894,6 +4383,8 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
3894
4383
  reason: "AKK replayed the durable receipt for an identical active terminal request and did not send tmux input again.",
3895
4384
  openclaw_next_action: openClawYieldNextAction({
3896
4385
  conversationId: receiptConversationId,
4386
+ sessionId: receiptSessionId,
4387
+ turnId: receiptTurnId,
3897
4388
  source: "terminal_control",
3898
4389
  callbackExpected: Boolean(owner.gateway_method ??
3899
4390
  previousDispatchLedger.callback_expected)
@@ -3910,6 +4401,30 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
3910
4401
  if (bridge) {
3911
4402
  assertNoUnresolvedTerminalBridgeSubmission(storeDirFromOptions(options), terminalControl, conversation.conversation_id, terminalPayload);
3912
4403
  }
4404
+ const sendTakeover = isRecord(conversation.native_session_takeover)
4405
+ ? conversation.native_session_takeover
4406
+ : undefined;
4407
+ const terminalAgentPid = Number(sendTakeover?.terminal_agent_pid);
4408
+ const currentNativeIdentity = await resolveCurrentNativeAgentSessionIdentity({
4409
+ options,
4410
+ agent: executor.kind,
4411
+ pid: terminalAgentPid,
4412
+ cwd: terminalControl.currentPath
4413
+ });
4414
+ const virginRawAttach = Boolean(recordRawAttachmentAfterSend &&
4415
+ !stringValue(sendTakeover?.terminal_agent_session_id));
4416
+ if (virginRawAttach) {
4417
+ if (currentNativeIdentity) {
4418
+ throw new Error("native agent session appeared while preparing a virgin terminal attach; refresh list and retry");
4419
+ }
4420
+ }
4421
+ else {
4422
+ assertNativeAgentIdentityForTurn({
4423
+ conversation,
4424
+ currentIdentity: currentNativeIdentity,
4425
+ operation: "send to"
4426
+ });
4427
+ }
3913
4428
  const preSendRuntime = {
3914
4429
  ...terminalRuntimeIdentityForConversation(nextConversation, terminalControl),
3915
4430
  messageId: message.id
@@ -3969,6 +4484,8 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
3969
4484
  status: "prepared",
3970
4485
  generation_id: message.id,
3971
4486
  conversation_id: preparedConversation.conversation_id,
4487
+ session_id: sessionIdForConversation(preparedConversation),
4488
+ turn_id: turnIdForConversation(preparedConversation),
3972
4489
  message_id: message.id,
3973
4490
  request_hash: terminalRequestHash,
3974
4491
  prepared_at: bridgeStartedAt,
@@ -4132,6 +4649,8 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
4132
4649
  safe_to_retry: dispatchLedgerRestored
4133
4650
  });
4134
4651
  printJson({
4652
+ session_id: sessionIdForConversation(abortedConversation),
4653
+ turn_id: turnIdForConversation(abortedConversation),
4135
4654
  conversation: abortedConversation,
4136
4655
  message,
4137
4656
  delivered: false,
@@ -4150,6 +4669,8 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
4150
4669
  openclaw_next_action: {
4151
4670
  action: dispatchLedgerRestored ? "retry" : "inspect",
4152
4671
  conversation_id: abortedConversation.conversation_id,
4672
+ session_id: sessionIdForConversation(abortedConversation),
4673
+ turn_id: turnIdForConversation(abortedConversation),
4153
4674
  safe_to_retry: dispatchLedgerRestored,
4154
4675
  do_not_retry: !dispatchLedgerRestored,
4155
4676
  reason: dispatchLedgerRestored
@@ -4165,8 +4686,134 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
4165
4686
  runtime: preSendRuntime
4166
4687
  });
4167
4688
  const submittedAt = new Date().toISOString();
4689
+ let submittedBase = preparedConversation;
4690
+ if (virginRawAttach) {
4691
+ let boundIdentity;
4692
+ let boundConversation;
4693
+ let bindingError;
4694
+ try {
4695
+ boundIdentity = await pollNativeAgentSessionIdentity({
4696
+ options,
4697
+ executor,
4698
+ terminalControl,
4699
+ pid: terminalAgentPid
4700
+ });
4701
+ }
4702
+ catch (error) {
4703
+ bindingError = error instanceof Error ? error.message : String(error);
4704
+ }
4705
+ if (boundIdentity) {
4706
+ try {
4707
+ boundConversation = withNativeAgentSessionIdentity(preparedConversation, boundIdentity);
4708
+ assertNativeAgentIdentityForTurn({
4709
+ conversation: boundConversation,
4710
+ currentIdentity: boundIdentity,
4711
+ operation: "bind"
4712
+ });
4713
+ }
4714
+ catch (error) {
4715
+ bindingError = error instanceof Error ? error.message : String(error);
4716
+ boundIdentity = undefined;
4717
+ boundConversation = undefined;
4718
+ }
4719
+ }
4720
+ if (!boundIdentity) {
4721
+ const bindingFailedAt = new Date().toISOString();
4722
+ const bindingReason = bindingError
4723
+ ? `AKK delivered the terminal input but could not verify the new native agent session: ${bindingError}`
4724
+ : "AKK delivered the terminal input but no exact native agent session appeared within the binding window";
4725
+ const unfencedBase = {
4726
+ ...preparedConversation,
4727
+ status: "stalled",
4728
+ stalled_at: bindingFailedAt,
4729
+ stalled_reason: bindingReason,
4730
+ native_session_takeover: {
4731
+ ...(isRecord(preparedConversation.native_session_takeover)
4732
+ ? preparedConversation.native_session_takeover
4733
+ : {}),
4734
+ terminal_agent_identity_status: "unresolved_after_submit",
4735
+ terminal_agent_identity_error: textSummary(bindingReason)
4736
+ },
4737
+ updated_at: bindingFailedAt
4738
+ };
4739
+ const unfencedConversation = withTerminalBridgeSubmission({
4740
+ conversation: unfencedBase,
4741
+ messageId: message.id,
4742
+ requestText: terminalPayload,
4743
+ status: "submitted",
4744
+ preparedAt: bridgeStartedAt,
4745
+ submittedAt
4746
+ });
4747
+ saveTerminalBridgeDispatchLedger(terminalControl, {
4748
+ status: "submitted",
4749
+ generation_id: message.id,
4750
+ conversation_id: unfencedConversation.conversation_id,
4751
+ session_id: sessionIdForConversation(unfencedConversation),
4752
+ turn_id: turnIdForConversation(unfencedConversation),
4753
+ message_id: message.id,
4754
+ request_hash: terminalRequestHash,
4755
+ prepared_at: bridgeStartedAt,
4756
+ submitted_at: submittedAt,
4757
+ dispatcher_pid: process.pid,
4758
+ state_path: statePath,
4759
+ event_log_path: logPath,
4760
+ callback_expected: false,
4761
+ native_identity_status: "unresolved_after_submit",
4762
+ error: textSummary(bindingReason),
4763
+ previous_generation_id: stringValue(previousDispatchLedger?.generation_id) ??
4764
+ stringValue(previousDispatchLedger?.message_id)
4765
+ });
4766
+ saveState(statePath, unfencedConversation);
4767
+ appendEvent(logPath, {
4768
+ ts: bindingFailedAt,
4769
+ conversation_id: unfencedConversation.conversation_id,
4770
+ event: "terminal_agent_identity_binding_failed",
4771
+ message_id: message.id,
4772
+ executor,
4773
+ terminal_control: terminalControl,
4774
+ error: textSummary(bindingReason),
4775
+ delivered: true,
4776
+ do_not_retry: true
4777
+ });
4778
+ runtimeLog("error", "terminal_agent_identity_binding_failed", {
4779
+ conversation_id: unfencedConversation.conversation_id,
4780
+ agent: executor.kind,
4781
+ terminal_target: terminalControl.target,
4782
+ error: bindingReason,
4783
+ delivered: true,
4784
+ do_not_retry: true
4785
+ });
4786
+ printJson({
4787
+ session_id: sessionIdForConversation(unfencedConversation),
4788
+ turn_id: turnIdForConversation(unfencedConversation),
4789
+ conversation: unfencedConversation,
4790
+ message,
4791
+ delivered: true,
4792
+ status: "delivered_unfenced",
4793
+ submission_outcome: "submitted",
4794
+ background: true,
4795
+ callback_expected: false,
4796
+ terminal_control: terminalControl,
4797
+ monitor_pid: bridgeMonitor?.pid ?? null,
4798
+ executor,
4799
+ delivery_receipt: "submitted",
4800
+ do_not_retry: true,
4801
+ reason: bindingReason,
4802
+ openclaw_next_action: {
4803
+ action: "inspect",
4804
+ conversation_id: unfencedConversation.conversation_id,
4805
+ session_id: sessionIdForConversation(unfencedConversation),
4806
+ turn_id: turnIdForConversation(unfencedConversation),
4807
+ do_not_retry: true,
4808
+ reason: "The input was submitted, but AKK could not fence later side effects to an exact native session. Inspect the pane and close this Turn before continuing."
4809
+ }
4810
+ });
4811
+ return;
4812
+ }
4813
+ submittedBase = boundConversation;
4814
+ }
4168
4815
  deliveredConversation = withTerminalBridgeSubmission({
4169
- conversation: preparedConversation,
4816
+ conversation: submittedBase,
4170
4817
  messageId: message.id,
4171
4818
  requestText: terminalPayload,
4172
4819
  status: "submitted",
@@ -4178,6 +4825,8 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
4178
4825
  status: "submitted",
4179
4826
  generation_id: message.id,
4180
4827
  conversation_id: deliveredConversation.conversation_id,
4828
+ session_id: sessionIdForConversation(deliveredConversation),
4829
+ turn_id: turnIdForConversation(deliveredConversation),
4181
4830
  message_id: message.id,
4182
4831
  request_hash: terminalRequestHash,
4183
4832
  prepared_at: bridgeStartedAt,
@@ -4217,6 +4866,8 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
4217
4866
  status: "uncertain",
4218
4867
  generation_id: message.id,
4219
4868
  conversation_id: uncertainConversation.conversation_id,
4869
+ session_id: sessionIdForConversation(uncertainConversation),
4870
+ turn_id: turnIdForConversation(uncertainConversation),
4220
4871
  message_id: message.id,
4221
4872
  request_hash: terminalRequestHash,
4222
4873
  prepared_at: bridgeStartedAt,
@@ -4265,6 +4916,8 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
4265
4916
  stalled_conversation_ids: stalledConversationIds
4266
4917
  });
4267
4918
  printJson({
4919
+ session_id: sessionIdForConversation(uncertainConversation),
4920
+ turn_id: turnIdForConversation(uncertainConversation),
4268
4921
  conversation: uncertainConversation,
4269
4922
  message,
4270
4923
  delivered: false,
@@ -4281,6 +4934,8 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
4281
4934
  openclaw_next_action: {
4282
4935
  action: "inspect",
4283
4936
  conversation_id: uncertainConversation.conversation_id,
4937
+ session_id: sessionIdForConversation(uncertainConversation),
4938
+ turn_id: turnIdForConversation(uncertainConversation),
4284
4939
  do_not_retry: true,
4285
4940
  reason: "The terminal submission outcome is uncertain. Inspect AKK status and the shared tmux pane before deciding whether to close or continue."
4286
4941
  }
@@ -4330,6 +4985,8 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
4330
4985
  }
4331
4986
  }
4332
4987
  printJson({
4988
+ session_id: sessionIdForConversation(deliveredConversation),
4989
+ turn_id: turnIdForConversation(deliveredConversation),
4333
4990
  conversation: deliveredConversation,
4334
4991
  message,
4335
4992
  delivered: true,
@@ -4348,6 +5005,8 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
4348
5005
  : {}),
4349
5006
  openclaw_next_action: openClawYieldNextAction({
4350
5007
  conversationId: deliveredConversation.conversation_id,
5008
+ sessionId: sessionIdForConversation(deliveredConversation),
5009
+ turnId: turnIdForConversation(deliveredConversation),
4351
5010
  source: "terminal_control",
4352
5011
  callbackExpected: Boolean(deliveredConversation.gateway_method)
4353
5012
  })
@@ -4356,18 +5015,290 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
4356
5015
  function terminalSubmissionPayload(payload) {
4357
5016
  return payload.trimEnd();
4358
5017
  }
4359
- function createManagedTerminalConversationFromRawId({ options, conversationId, agent, pid, messageBody, terminalControl }) {
5018
+ function isCompleteNativeRollout(value) {
5019
+ return isRecord(value) &&
5020
+ Boolean(stringValue(value.fd)) &&
5021
+ Boolean(stringValue(value.device)) &&
5022
+ Boolean(stringValue(value.inode)) &&
5023
+ Boolean(stringValue(value.path));
5024
+ }
5025
+ async function resolveCurrentNativeAgentSessionIdentity({ options, agent, pid, cwd }) {
5026
+ if (agent === "codex") {
5027
+ return createAgentSessionProvider("codex", options)
5028
+ .resolveActiveSessionIdentityForPid(pid, cwd);
5029
+ }
5030
+ const rows = loadClaudeAgentRows(options, { required: true })
5031
+ .filter((row) => row.pid === pid);
5032
+ if (rows.length === 0) {
5033
+ return undefined;
5034
+ }
5035
+ const sessionIds = [...new Set(rows.map((row) => stringValue(row.sessionId)).filter((value) => value !== undefined))];
5036
+ if (sessionIds.length > 1) {
5037
+ throw new Error(`Claude process ${pid} has conflicting exact session identities`);
5038
+ }
5039
+ const sessionId = sessionIds[0];
5040
+ if (!sessionId) {
5041
+ throw new Error(`Claude process ${pid} is visible but its exact sessionId is unavailable`);
5042
+ }
5043
+ const startedAtValues = [...new Set(rows
5044
+ .map((row) => Number(row.startedAt))
5045
+ .filter((value) => Number.isSafeInteger(value) && value > 0))];
5046
+ if (startedAtValues.length > 1) {
5047
+ throw new Error(`Claude process ${pid} has conflicting process-incarnation timestamps`);
5048
+ }
5049
+ const startedAt = startedAtValues[0];
5050
+ if (!startedAt) {
5051
+ throw new Error(`Claude process ${pid} is visible but its process-incarnation startedAt is unavailable`);
5052
+ }
5053
+ return {
5054
+ sessionId,
5055
+ processUuid: `claude-pid:${pid}:started:${startedAt}`,
5056
+ evidence: "claude_agents_exact_pid"
5057
+ };
5058
+ }
5059
+ function nativeAgentIdentityMatchesTurn(conversation, currentIdentity) {
5060
+ const takeover = isRecord(conversation.native_session_takeover)
5061
+ ? conversation.native_session_takeover
5062
+ : undefined;
5063
+ const storedSessionId = stringValue(takeover?.terminal_agent_session_id);
5064
+ const storedProcessUuid = stringValue(takeover?.terminal_agent_process_uuid);
5065
+ const storedProcessBirth = stringValue(takeover?.terminal_agent_process_birth);
5066
+ const storedRollout = isRecord(takeover?.terminal_agent_rollout)
5067
+ ? takeover.terminal_agent_rollout
5068
+ : undefined;
5069
+ const strictNativeIdentity = Number(takeover?.terminal_agent_identity_protocol) === 1;
5070
+ const strictClaudeTurn = strictNativeIdentity && executorForConversation(conversation).kind === "claude";
5071
+ const strictCodexTurn = strictNativeIdentity && executorForConversation(conversation).kind === "codex";
5072
+ if (strictNativeIdentity &&
5073
+ (!storedSessionId || !currentIdentity?.sessionId)) {
5074
+ return false;
5075
+ }
5076
+ if (strictClaudeTurn &&
5077
+ (!storedProcessUuid ||
5078
+ !currentIdentity?.processUuid ||
5079
+ storedProcessUuid !== currentIdentity.processUuid)) {
5080
+ return false;
5081
+ }
5082
+ if (strictCodexTurn &&
5083
+ (!storedProcessUuid ||
5084
+ !storedProcessBirth ||
5085
+ !isCompleteNativeRollout(storedRollout) ||
5086
+ !currentIdentity?.processUuid ||
5087
+ !currentIdentity.processBirth ||
5088
+ !isCompleteNativeRollout(currentIdentity.rollout))) {
5089
+ return false;
5090
+ }
5091
+ if (storedSessionId && storedSessionId !== currentIdentity?.sessionId) {
5092
+ return false;
5093
+ }
5094
+ if (storedProcessUuid &&
5095
+ storedProcessUuid !== currentIdentity?.processUuid) {
5096
+ return false;
5097
+ }
5098
+ if (storedProcessBirth &&
5099
+ storedProcessBirth !== currentIdentity?.processBirth) {
5100
+ return false;
5101
+ }
5102
+ if (storedRollout &&
5103
+ (stringValue(storedRollout.fd) !== currentIdentity?.rollout?.fd ||
5104
+ stringValue(storedRollout.device) !== currentIdentity?.rollout?.device ||
5105
+ stringValue(storedRollout.inode) !== currentIdentity?.rollout?.inode ||
5106
+ stringValue(storedRollout.path) !== currentIdentity?.rollout?.path)) {
5107
+ return false;
5108
+ }
5109
+ return true;
5110
+ }
5111
+ function assertNativeAgentIdentityForRuntime({ runtime, currentIdentity, agent, pid }) {
5112
+ if (runtime?.expectedEmptyNativeSession === true) {
5113
+ if (!currentIdentity) {
5114
+ return;
5115
+ }
5116
+ throw new Error(`native ${agent} session appeared for process ${pid} during terminal control`);
5117
+ }
5118
+ if (runtime?.requireNativeRolloutIdentity === true &&
5119
+ (!runtime.nativeSessionId ||
5120
+ !runtime.nativeProcessUuid ||
5121
+ !runtime.nativeProcessBirth ||
5122
+ !isCompleteNativeRollout(runtime.nativeRollout) ||
5123
+ !currentIdentity?.sessionId ||
5124
+ !currentIdentity.processUuid ||
5125
+ !currentIdentity.processBirth ||
5126
+ !isCompleteNativeRollout(currentIdentity.rollout))) {
5127
+ throw new Error(`native ${agent} rollout incarnation cannot be verified for process ${pid}; ` +
5128
+ "refresh list before controlling the terminal");
5129
+ }
5130
+ if (runtime?.requireNativeProcessUuid === true &&
5131
+ (!runtime.nativeProcessUuid ||
5132
+ !currentIdentity?.processUuid ||
5133
+ currentIdentity.processUuid !== runtime.nativeProcessUuid)) {
5134
+ throw new Error(`native ${agent} process incarnation cannot be verified for process ${pid}; ` +
5135
+ "refresh list before controlling the terminal");
5136
+ }
5137
+ if (!runtime?.nativeSessionId) {
5138
+ return;
5139
+ }
5140
+ const expectedRollout = runtime.nativeRollout;
5141
+ if (currentIdentity?.sessionId !== runtime.nativeSessionId ||
5142
+ (runtime.nativeProcessUuid &&
5143
+ currentIdentity?.processUuid !== runtime.nativeProcessUuid) ||
5144
+ (runtime.nativeProcessBirth &&
5145
+ currentIdentity?.processBirth !== runtime.nativeProcessBirth) ||
5146
+ (expectedRollout &&
5147
+ (currentIdentity?.rollout?.fd !== expectedRollout.fd ||
5148
+ currentIdentity?.rollout?.device !== expectedRollout.device ||
5149
+ currentIdentity?.rollout?.inode !== expectedRollout.inode ||
5150
+ currentIdentity?.rollout?.path !== expectedRollout.path))) {
5151
+ throw new Error(`native ${agent} session identity changed for process ${pid}; ` +
5152
+ "refresh list before controlling the terminal");
5153
+ }
5154
+ }
5155
+ function assertNativeAgentIdentityForTurn({ conversation, currentIdentity, operation }) {
5156
+ if (nativeAgentIdentityMatchesTurn(conversation, currentIdentity)) {
5157
+ return;
5158
+ }
5159
+ const takeover = isRecord(conversation.native_session_takeover)
5160
+ ? conversation.native_session_takeover
5161
+ : undefined;
5162
+ const strictClaudeTurn = Boolean(Number(takeover?.terminal_agent_identity_protocol) === 1 &&
5163
+ executorForConversation(conversation).kind === "claude");
5164
+ const storedProcessUuid = stringValue(takeover?.terminal_agent_process_uuid);
5165
+ if (strictClaudeTurn &&
5166
+ (!storedProcessUuid || !currentIdentity?.processUuid)) {
5167
+ throw new Error(`cannot ${operation} Turn ${turnIdForConversation(conversation)}: ` +
5168
+ "native Claude process incarnation cannot be verified; this Claude CLI " +
5169
+ "must report both sessionId and startedAt before AKK can control it");
5170
+ }
5171
+ const expected = stringValue(takeover?.terminal_agent_session_id) ??
5172
+ "unavailable";
5173
+ const observed = currentIdentity?.sessionId ?? "unverifiable";
5174
+ throw new Error(`cannot ${operation} Turn ${turnIdForConversation(conversation)}: native ` +
5175
+ `agent session identity changed or cannot be verified ` +
5176
+ `(expected ${expected}, observed ${observed})`);
5177
+ }
5178
+ function withNativeAgentSessionIdentity(conversation, identity) {
5179
+ const takeover = isRecord(conversation.native_session_takeover)
5180
+ ? conversation.native_session_takeover
5181
+ : {};
5182
+ return {
5183
+ ...conversation,
5184
+ native_session_takeover: {
5185
+ ...takeover,
5186
+ terminal_agent_identity_protocol: 1,
5187
+ terminal_agent_session_id: identity.sessionId,
5188
+ terminal_agent_process_uuid: identity.processUuid,
5189
+ terminal_agent_process_birth: identity.processBirth,
5190
+ terminal_agent_rollout: identity.rollout,
5191
+ terminal_agent_identity_evidence: identity.evidence,
5192
+ terminal_agent_identity_bound_at: new Date().toISOString()
5193
+ }
5194
+ };
5195
+ }
5196
+ async function pollNativeAgentSessionIdentity({ options, executor, terminalControl, pid, attempts = 40, delayMs = 50 }) {
5197
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
5198
+ const identity = await resolveCurrentNativeAgentSessionIdentity({
5199
+ options,
5200
+ agent: executor.kind,
5201
+ pid,
5202
+ cwd: terminalControl.currentPath
5203
+ });
5204
+ if (identity) {
5205
+ return identity;
5206
+ }
5207
+ if (attempt + 1 < attempts) {
5208
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
5209
+ }
5210
+ }
5211
+ return undefined;
5212
+ }
5213
+ function managedTurnsForSession(storeDir, sessionId) {
5214
+ return listConversations(storeDir)
5215
+ .filter(isDiscoverableTmuxConversation)
5216
+ .filter((conversation) => sessionIdForConversation(conversation) === sessionId)
5217
+ .sort(compareManagedConversationRecency);
5218
+ }
5219
+ function managedTurnsForSessionTarget(storeDir, targetId) {
5220
+ const allTurns = listConversations(storeDir)
5221
+ .filter(isDiscoverableTmuxConversation);
5222
+ const exactTurn = allTurns.find((conversation) => turnIdForConversation(conversation) === targetId ||
5223
+ conversation.conversation_id === targetId);
5224
+ if (exactTurn &&
5225
+ sessionIdForConversation(exactTurn) !== targetId) {
5226
+ throw new Error(`turn ${targetId} is an execution identity, not an ordinary send target; ` +
5227
+ `send to session ${sessionIdForConversation(exactTurn)} instead`);
5228
+ }
5229
+ const turns = allTurns
5230
+ .filter((conversation) => sessionIdForConversation(conversation) === targetId)
5231
+ .sort(compareManagedConversationRecency);
5232
+ if (turns.length === 0) {
5233
+ throw new Error(`managed session ${targetId} was not found`);
5234
+ }
5235
+ return turns;
5236
+ }
5237
+ function assertManagedSessionCanStartTurn(turns) {
5238
+ const blocking = turns.filter((conversation) => SESSION_SEND_BLOCKING_STATUSES.has(conversation.status));
5239
+ if (blocking.length > 0) {
5240
+ const owner = blocking[0];
5241
+ throw new Error(`session ${sessionIdForConversation(owner)} already has active turn ` +
5242
+ `${turnIdForConversation(owner)} (${owner.status}); wait for its callback, ` +
5243
+ "respond to it if it is waiting for OpenClaw, cancel it, or close it before sending another turn");
5244
+ }
5245
+ }
5246
+ function reusableManagedSessionTurnForTerminal({ options, terminalConversation, currentNativeIdentity }) {
5247
+ const matches = listConversations(storeDirFromOptions(options))
5248
+ .filter(isDiscoverableTmuxConversation)
5249
+ .filter((conversation) => managedTurnMatchesResolvedTerminal(conversation, terminalConversation, currentNativeIdentity));
5250
+ const sessions = new Map();
5251
+ for (const turn of matches) {
5252
+ const sessionId = sessionIdForConversation(turn);
5253
+ const group = sessions.get(sessionId) ?? [];
5254
+ group.push(turn);
5255
+ sessions.set(sessionId, group);
5256
+ }
5257
+ if (sessions.size > 1) {
5258
+ throw new Error(`terminal ${terminalConversation.terminalControl.target} matches multiple ` +
5259
+ "managed sessions; send to an explicit session_id from AKK list");
5260
+ }
5261
+ const group = [...sessions.values()][0];
5262
+ return group?.sort(compareManagedConversationRecency)[0];
5263
+ }
5264
+ function managedTurnMatchesResolvedTerminal(conversation, terminalConversation, currentNativeIdentity) {
5265
+ const takeover = isRecord(conversation.native_session_takeover)
5266
+ ? conversation.native_session_takeover
5267
+ : undefined;
5268
+ const storedControl = terminalControlFromTakeover(takeover);
5269
+ const storedTerminalIdentity = parseTerminalConversationId(stringValue(takeover?.native_session_id));
5270
+ const currentTerminalIdentity = parseTerminalConversationId(terminalConversation.conversationId);
5271
+ return Boolean(storedControl &&
5272
+ storedTerminalIdentity &&
5273
+ currentTerminalIdentity &&
5274
+ executorForConversation(conversation).kind === terminalConversation.agent &&
5275
+ storedTerminalIdentity.agent === currentTerminalIdentity.agent &&
5276
+ storedTerminalIdentity.target === currentTerminalIdentity.target &&
5277
+ storedTerminalIdentity.pid === currentTerminalIdentity.pid &&
5278
+ Number(takeover?.terminal_agent_pid) === terminalConversation.pid &&
5279
+ nativeAgentIdentityMatchesTurn(conversation, currentNativeIdentity) &&
5280
+ terminalControlSelectorKey(storedControl) ===
5281
+ terminalControlSelectorKey(terminalConversation.terminalControl) &&
5282
+ matchesConfiguredWorkspace(conversation.workspace, terminalConversation.terminalControl.currentPath));
5283
+ }
5284
+ function createManagedTerminalTurn({ options, conversationId, agent, pid, messageBody, terminalControl, previousTurn, nativeAgentIdentity }) {
4360
5285
  const workspace = terminalControl.currentPath ?? process.cwd();
4361
5286
  const storeDir = expandHome(options.storeDir ?? options.logDir ?? defaultStoreDir(workspace));
4362
- const executor = resolveExecutor({
4363
- kind: agent,
4364
- session: conversationId
4365
- });
5287
+ const executor = previousTurn
5288
+ ? executorForConversation(previousTurn)
5289
+ : resolveExecutor({
5290
+ kind: agent,
5291
+ session: conversationId
5292
+ });
4366
5293
  const now = new Date();
4367
5294
  const conversation = createConversation({
4368
5295
  userRequest: String(messageBody),
5296
+ sessionId: previousTurn
5297
+ ? sessionIdForConversation(previousTurn)
5298
+ : undefined,
4369
5299
  workspace,
4370
- openclawSession: options.openclawSession ?? "agent:main:main",
5300
+ openclawSession: options.openclawSession ?? previousTurn?.openclaw_session ??
5301
+ "agent:main:main",
4371
5302
  executorKind: executor.kind,
4372
5303
  executorSession: executor.session,
4373
5304
  softLimit: Number(options.softLimit ?? 50),
@@ -4375,8 +5306,8 @@ function createManagedTerminalConversationFromRawId({ options, conversationId, a
4375
5306
  now
4376
5307
  });
4377
5308
  const paths = pathsForConversation(conversation.conversation_id, storeDir);
4378
- const claudeAgent = agent === "claude"
4379
- ? loadClaudeAgentRows(options).find((row) => row.pid === pid)
5309
+ const previousTakeover = previousTurn && isRecord(previousTurn.native_session_takeover)
5310
+ ? previousTurn.native_session_takeover
4380
5311
  : undefined;
4381
5312
  const attachedConversation = withStoragePaths({
4382
5313
  ...conversation,
@@ -4384,20 +5315,31 @@ function createManagedTerminalConversationFromRawId({ options, conversationId, a
4384
5315
  status: "idle",
4385
5316
  idle_since: now.toISOString(),
4386
5317
  updated_at: now.toISOString(),
4387
- gateway_url: options.gatewayUrl ?? "ws://127.0.0.1:18789",
4388
- gateway_method: options.gatewayMethod,
4389
- gateway_session: options.gatewaySession ?? options.openclawSession ?? "agent:main:main",
4390
- openclaw_bin: options.openclawBin ?? resolveOptionalExecutable("openclaw"),
5318
+ gateway_url: options.gatewayUrl ?? previousTurn?.gateway_url ??
5319
+ "ws://127.0.0.1:18789",
5320
+ gateway_method: options.gatewayMethod ?? previousTurn?.gateway_method,
5321
+ gateway_session: options.gatewaySession ?? options.openclawSession ??
5322
+ previousTurn?.gateway_session ?? previousTurn?.openclaw_session ??
5323
+ "agent:main:main",
5324
+ openclaw_bin: options.openclawBin ?? previousTurn?.openclaw_bin ??
5325
+ resolveOptionalExecutable("openclaw"),
4391
5326
  native_session_takeover: {
4392
5327
  agent,
5328
+ terminal_agent_identity_protocol: 1,
4393
5329
  native_session_id: conversationId,
4394
5330
  terminal_agent_pid: pid,
4395
- terminal_agent_session_id: claudeAgent?.sessionId,
5331
+ terminal_agent_session_id: nativeAgentIdentity?.sessionId,
5332
+ terminal_agent_process_uuid: nativeAgentIdentity?.processUuid,
5333
+ terminal_agent_process_birth: nativeAgentIdentity?.processBirth,
5334
+ terminal_agent_rollout: nativeAgentIdentity?.rollout,
5335
+ terminal_agent_identity_evidence: nativeAgentIdentity?.evidence,
4396
5336
  source_cwd: workspace,
4397
5337
  source_title: `Terminal-controlled ${executor.display_name} ${terminalControl.target}`,
4398
5338
  strategy: "terminal_control",
4399
- attached_at: now.toISOString(),
4400
- takeover_match_kind: "raw_terminal_send",
5339
+ attached_at: stringValue(previousTakeover?.attached_at) ?? now.toISOString(),
5340
+ takeover_match_kind: previousTurn
5341
+ ? "managed_session_send"
5342
+ : "raw_terminal_send",
4401
5343
  terminal_control: terminalControl,
4402
5344
  needs_bootstrap: false,
4403
5345
  terminal_bridge: true
@@ -4425,15 +5367,17 @@ function createManagedTerminalConversationFromRawId({ options, conversationId, a
4425
5367
  message
4426
5368
  };
4427
5369
  }
4428
- function openClawYieldNextAction({ conversationId, source, callbackExpected }) {
5370
+ function openClawYieldNextAction({ conversationId, sessionId, turnId, source, callbackExpected }) {
4429
5371
  const callbackText = callbackExpected
4430
5372
  ? "The coding agent should report completion, questions, or errors through the existing Agent Knock Knock callback for this conversation."
4431
5373
  : "No AKK-managed callback is registered for this raw terminal-controlled id; do not wait synchronously. Use AKK status/list later or attach/create an AKK conversation when callback delivery is required.";
4432
5374
  return {
4433
5375
  action: "yield",
4434
- reason: "The follow-up was handed off asynchronously. End this OpenClaw turn now instead of waiting, polling, or treating the send as a synchronous agent result.",
5376
+ reason: "The requested agent work was handed off asynchronously. End this OpenClaw turn now instead of waiting, polling, or treating the send as a synchronous agent result.",
4435
5377
  source,
4436
5378
  conversation_id: conversationId,
5379
+ session_id: sessionId,
5380
+ turn_id: turnId,
4437
5381
  callback_expected: callbackExpected,
4438
5382
  do_not: "Do not inspect event logs, process lists, terminal screens, files, stdout, or stderr while waiting unless the user explicitly asks for status.",
4439
5383
  expected_callback: callbackText
@@ -5400,6 +6344,13 @@ function runCallbackRetryMonitor(options) {
5400
6344
  }
5401
6345
  return;
5402
6346
  }
6347
+ const gatewayRoute = resolveCallbackGatewayRoute({
6348
+ gatewayUrl: callbackDelivery.gateway_url,
6349
+ token: callbackDelivery.gateway_token
6350
+ }, {
6351
+ gatewayUrl: conversation.gateway_url,
6352
+ token: conversation.gateway_token
6353
+ });
5403
6354
  try {
5404
6355
  runCallbackTransaction({
5405
6356
  statePath,
@@ -5408,8 +6359,8 @@ function runCallbackRetryMonitor(options) {
5408
6359
  gatewaySession: stringValue(callbackDelivery.gateway_session) ?? conversation.gateway_session,
5409
6360
  openclawSession: conversation.openclaw_session,
5410
6361
  openclawBin: stringValue(callbackDelivery.openclaw_bin) ?? conversation.openclaw_bin,
5411
- gatewayUrl: stringValue(callbackDelivery.gateway_url) ?? conversation.gateway_url,
5412
- token: conversation.gateway_token,
6362
+ gatewayUrl: gatewayRoute.gatewayUrl,
6363
+ token: gatewayRoute.token,
5413
6364
  closeTerminalBridgeOnDone: callbackDelivery.close_terminal_bridge_on_done === true,
5414
6365
  retryPending: true,
5415
6366
  disableCallbackRetry: true
@@ -7414,6 +8365,16 @@ function runRetryCallback(options) {
7414
8365
  if (!callbackDelivery || !isRecord(callbackDelivery.message)) {
7415
8366
  throw new Error(`cannot retry callback for ${conversation.conversation_id}; pending callback is missing`);
7416
8367
  }
8368
+ const gatewayRoute = resolveCallbackGatewayRoute({
8369
+ gatewayUrl: options.gatewayUrl,
8370
+ token: options.token
8371
+ }, {
8372
+ gatewayUrl: callbackDelivery.gateway_url,
8373
+ token: callbackDelivery.gateway_token
8374
+ }, {
8375
+ gatewayUrl: conversation.gateway_url,
8376
+ token: conversation.gateway_token
8377
+ });
7417
8378
  runCallbackTransaction({
7418
8379
  ...options,
7419
8380
  statePath,
@@ -7422,12 +8383,28 @@ function runRetryCallback(options) {
7422
8383
  gatewaySession: stringValue(callbackDelivery.gateway_session) ?? conversation.gateway_session,
7423
8384
  openclawSession: conversation.openclaw_session,
7424
8385
  openclawBin: stringValue(callbackDelivery.openclaw_bin) ?? conversation.openclaw_bin,
7425
- gatewayUrl: stringValue(callbackDelivery.gateway_url) ?? conversation.gateway_url,
7426
- token: stringValue(callbackDelivery.gateway_token) ?? conversation.gateway_token,
8386
+ gatewayUrl: gatewayRoute.gatewayUrl,
8387
+ token: gatewayRoute.token,
7427
8388
  closeTerminalBridgeOnDone: callbackDelivery.close_terminal_bridge_on_done === true,
7428
8389
  retryPending: true
7429
8390
  });
7430
8391
  }
8392
+ function resolveCallbackGatewayRoute(...candidates) {
8393
+ for (const candidate of candidates) {
8394
+ const token = stringValue(candidate?.token);
8395
+ if (!token || token === "<token>") {
8396
+ continue;
8397
+ }
8398
+ return {
8399
+ gatewayUrl: stringValue(candidate?.gatewayUrl),
8400
+ token
8401
+ };
8402
+ }
8403
+ return {
8404
+ gatewayUrl: undefined,
8405
+ token: undefined
8406
+ };
8407
+ }
7431
8408
  function runCallbackTransaction(options) {
7432
8409
  const releaseLock = acquireFileLock(`${options.statePath}.lock`);
7433
8410
  let prepared;
@@ -7446,6 +8423,10 @@ function prepareLockedCallback(options) {
7446
8423
  ? options.conversationOverride
7447
8424
  : loadState(options.statePath);
7448
8425
  const executor = executorForConversation(conversation);
8426
+ const persistedGatewayRoute = resolveCallbackGatewayRoute({
8427
+ gatewayUrl: options.gatewayUrl,
8428
+ token: options.token
8429
+ });
7449
8430
  const message = options.retryPending === true || options.preserveMessageId === true
7450
8431
  ? parseMessageJson(messageInput)
7451
8432
  : extractStructuredMessage({
@@ -7470,6 +8451,11 @@ function prepareLockedCallback(options) {
7470
8451
  sameDeliveryMessage &&
7471
8452
  isRetryableCallbackDelivery(conversation, inheritedDelivery);
7472
8453
  const duplicateMessage = isDuplicateMessage(existingEvents, message);
8454
+ if (TERMINAL_DISPATCH_RELEASE_STATUSES.has(conversation.status) &&
8455
+ !duplicateMessage) {
8456
+ throw new Error(`refusing late callback ${message.id} for released Turn ` +
8457
+ `${turnIdForConversation(conversation)} (${conversation.status})`);
8458
+ }
7473
8459
  const recoveryMessageAlreadyLogged = options.recoverMissingOutbox === true
7474
8460
  ? exactLoggedMessageForRecovery(existingEvents, message)
7475
8461
  : false;
@@ -7555,7 +8541,7 @@ function prepareLockedCallback(options) {
7555
8541
  last_attempt_at: now,
7556
8542
  gateway_method: options.gatewayMethod,
7557
8543
  gateway_session: options.gatewaySession ?? options.openclawSession ?? conversation.openclaw_session,
7558
- gateway_url: options.gatewayUrl ?? conversation.gateway_url,
8544
+ gateway_url: persistedGatewayRoute.gatewayUrl,
7559
8545
  openclaw_bin: options.openclawBin ?? conversation.openclaw_bin,
7560
8546
  close_terminal_bridge_on_done: closeTerminalBridgeOnDone,
7561
8547
  track_delivery: true,
@@ -8211,7 +9197,7 @@ function readExistingEvents(logPath) {
8211
9197
  }
8212
9198
  function loadConversationFromOptions(options) {
8213
9199
  const storeDir = storeDirFromOptions(options);
8214
- const conversationId = options.conversation ?? options.conversationId;
9200
+ const conversationId = options.turn ?? options.conversation ?? options.conversationId;
8215
9201
  const statePath = expandHome(options.state ?? (conversationId ? statePathForConversationId(conversationId, storeDir) : undefined));
8216
9202
  if (!statePath) {
8217
9203
  throw new Error("--conversation or --state is required");
@@ -8238,6 +9224,8 @@ function storeDirFromOptions(options) {
8238
9224
  function summarizeConversation(conversation) {
8239
9225
  const executor = executorForConversation(conversation);
8240
9226
  return {
9227
+ session_id: sessionIdForConversation(conversation),
9228
+ turn_id: turnIdForConversation(conversation),
8241
9229
  conversation_id: conversation.conversation_id,
8242
9230
  agent: executor.kind,
8243
9231
  executor,
@@ -8719,11 +9707,12 @@ function markConversationStalled({ statePath, logPath, reason, detail = {} }) {
8719
9707
  body: [
8720
9708
  `AKK marked this ${executor.display_name} task as stalled: ${reason}.`,
8721
9709
  "",
8722
- `Conversation: ${conversation.conversation_id}`,
8723
- `Session: ${executor.session}`,
9710
+ `Turn: ${turnIdForConversation(conversation)}`,
9711
+ `AKK session: ${sessionIdForConversation(conversation)}`,
9712
+ `Agent session: ${executor.session}`,
8724
9713
  terminalBridge
8725
- ? `Use \`AKK status ${conversation.conversation_id}\` for details, \`AKK renew ${conversation.conversation_id}\` to resume monitoring without sending another task, or \`AKK close ${conversation.conversation_id}\` to close it.`
8726
- : "Use `AKK status` for details, `AKK send` to retry/follow up, or `AKK close` to close it."
9714
+ ? `Use \`AKK status --turn ${turnIdForConversation(conversation)}\` for details, \`AKK renew --turn ${turnIdForConversation(conversation)}\` to resume monitoring in this Turn, or \`AKK close --turn ${turnIdForConversation(conversation)}\` to close it. Start any independent retry with \`AKK send --session ${sessionIdForConversation(conversation)}\`.`
9715
+ : `Use \`AKK status --turn ${turnIdForConversation(conversation)}\` for details or \`AKK close --turn ${turnIdForConversation(conversation)}\` to close this Turn.`
8727
9716
  ].join("\n")
8728
9717
  })
8729
9718
  : undefined;
@@ -8973,6 +9962,8 @@ function canonicalJson(value) {
8973
9962
  }
8974
9963
  function messageFingerprint(message) {
8975
9964
  return JSON.stringify({
9965
+ session_id: message.session_id,
9966
+ turn_id: message.turn_id,
8976
9967
  conversation_id: message.conversation_id,
8977
9968
  from: message.from,
8978
9969
  to: message.to,
@@ -8989,6 +9980,8 @@ function deliverToGatewayMethod({ method, openclawBin, gatewayUrl, token, sessio
8989
9980
  "--params",
8990
9981
  JSON.stringify({
8991
9982
  sessionKey,
9983
+ session_id: sessionIdForConversation(conversation),
9984
+ turn_id: turnIdForConversation(conversation),
8992
9985
  statePath,
8993
9986
  logPath,
8994
9987
  conversation: redactCliOutput(conversation),
@@ -9265,11 +10258,15 @@ function createAgentSessionProvider(agent, options) {
9265
10258
  if (agent !== "codex") {
9266
10259
  throw new Error(`unsupported agent session provider: ${agent}`);
9267
10260
  }
9268
- if (options.threadsJson || options.processesJson || options.rolloutsJson) {
10261
+ if (options.threadsJson ||
10262
+ options.processesJson ||
10263
+ options.rolloutsJson ||
10264
+ options.codexActiveSessionIdentitiesJson) {
9269
10265
  return new CodexLocalSessionProvider(new InlineCodexSessionAdapter({
9270
10266
  threads: parseJsonOption(options.threadsJson, "--threads-json"),
9271
10267
  processes: parseJsonOption(options.processesJson, "--processes-json"),
9272
- rollouts: parseJsonOption(options.rolloutsJson, "--rollouts-json")
10268
+ rollouts: parseJsonOption(options.rolloutsJson, "--rollouts-json"),
10269
+ activeSessionIdentities: parseJsonOption(options.codexActiveSessionIdentitiesJson, "--codex-active-session-identities-json")
9273
10270
  }));
9274
10271
  }
9275
10272
  return new CodexLocalSessionProvider(new CodexStoreAdapter({
@@ -9397,10 +10394,14 @@ function usage() {
9397
10394
  agent-knock-knock --version
9398
10395
  agent-knock-knock delegate --request <text> [--agent ${agentList}] [--workspace <path>] [--store-dir <dir>]
9399
10396
  agent-knock-knock list [--store-dir <dir>] [--agent ${agentList}] [--status <status>] [--all] [--reconcile] [--no-approval-scan] [--terminal-debug]
9400
- agent-knock-knock status [--conversation <id|selector>] [--store-dir <dir>] [--reconcile] [--trace]
9401
- agent-knock-knock send [--conversation <id|selector>] --message <text> [--type answer|task|control] [--agent-timeout-minutes <minutes>] [--agent-hard-timeout-minutes <minutes>]
9402
- agent-knock-knock approve [--conversation <id|selector>] --expected-approval-fingerprint <fingerprint>
9403
- agent-knock-knock cancel [--conversation <id|selector>]
10397
+ agent-knock-knock status [--turn <turn-id|selector>] [--conversation <selector>] [--store-dir <dir>] [--reconcile] [--trace]
10398
+ agent-knock-knock send [--session <session-id|selector>] [--conversation <selector>] --message <text> [--type task] [--agent-timeout-minutes <minutes>] [--agent-hard-timeout-minutes <minutes>]
10399
+ agent-knock-knock respond --turn <turn-id|selector> --message <text> [--conversation <selector>]
10400
+ agent-knock-knock approve [--turn <turn-id|selector>] [--conversation <selector>] --expected-approval-fingerprint <fingerprint>
10401
+ agent-knock-knock cancel [--turn <turn-id|selector>] [--conversation <selector>]
10402
+ agent-knock-knock renew [--turn <turn-id|selector>] [--conversation <selector>]
10403
+ agent-knock-knock retry-callback [--turn <turn-id|selector>] [--conversation <selector>]
10404
+ agent-knock-knock close [--turn <turn-id|selector>] [--conversation <selector>]
9404
10405
  agent-knock-knock install-openclaw [--verify] [--openclaw-bin <path>] [--skill-path <path>] [--skill-only] [--no-restart]
9405
10406
  agent-knock-knock doctor [--openclaw-bin <path>]
9406
10407
  agent-knock-knock callback --state <file> --message-json <json> [--record-only]