@scotthuang/agent-knock-knock 0.2.44 → 0.2.47

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.
Files changed (49) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +148 -242
  3. package/dist/src/approval-policy.d.ts +1 -0
  4. package/dist/src/approval-policy.js +6 -0
  5. package/dist/src/approval-policy.js.map +1 -1
  6. package/dist/src/claude-hook-installer.d.ts +65 -0
  7. package/dist/src/claude-hook-installer.js +410 -0
  8. package/dist/src/claude-hook-installer.js.map +1 -0
  9. package/dist/src/claude-hook-protocol.d.ts +89 -0
  10. package/dist/src/claude-hook-protocol.js +243 -0
  11. package/dist/src/claude-hook-protocol.js.map +1 -0
  12. package/dist/src/claude-hook-store.d.ts +247 -0
  13. package/dist/src/claude-hook-store.js +1064 -0
  14. package/dist/src/claude-hook-store.js.map +1 -0
  15. package/dist/src/claude-terminal-agent-adapter.d.ts +47 -0
  16. package/dist/src/claude-terminal-agent-adapter.js +920 -0
  17. package/dist/src/claude-terminal-agent-adapter.js.map +1 -0
  18. package/dist/src/cli.js +1565 -772
  19. package/dist/src/cli.js.map +1 -1
  20. package/dist/src/codex-session-provider.d.ts +6 -32
  21. package/dist/src/codex-session-provider.js +2 -1
  22. package/dist/src/codex-session-provider.js.map +1 -1
  23. package/dist/src/codex-store-adapter.d.ts +3 -9
  24. package/dist/src/codex-store-adapter.js +5 -55
  25. package/dist/src/codex-store-adapter.js.map +1 -1
  26. package/dist/src/codex-terminal-agent-adapter.d.ts +34 -0
  27. package/dist/src/codex-terminal-agent-adapter.js +386 -0
  28. package/dist/src/codex-terminal-agent-adapter.js.map +1 -0
  29. package/dist/src/openclaw-plugin.js +60 -11
  30. package/dist/src/openclaw-plugin.js.map +1 -1
  31. package/dist/src/protocol.d.ts +1 -1
  32. package/dist/src/terminal-agent-adapter.d.ts +158 -0
  33. package/dist/src/terminal-agent-adapter.js +109 -0
  34. package/dist/src/terminal-agent-adapter.js.map +1 -0
  35. package/dist/src/terminal-agent-bridge.d.ts +114 -0
  36. package/dist/src/terminal-agent-bridge.js +487 -0
  37. package/dist/src/terminal-agent-bridge.js.map +1 -0
  38. package/dist/src/terminal-agent-registry.d.ts +7 -0
  39. package/dist/src/terminal-agent-registry.js +24 -0
  40. package/dist/src/terminal-agent-registry.js.map +1 -0
  41. package/dist/src/terminal-control-provider.d.ts +8 -6
  42. package/dist/src/terminal-control-provider.js +5 -5
  43. package/dist/src/terminal-control-provider.js.map +1 -1
  44. package/dist/src/terminal-process-source.d.ts +25 -0
  45. package/dist/src/terminal-process-source.js +77 -0
  46. package/dist/src/terminal-process-source.js.map +1 -0
  47. package/openclaw.plugin.json +7 -3
  48. package/package.json +1 -1
  49. package/templates/openclaw-skills/agent-knock-knock/SKILL.md +19 -16
package/dist/src/cli.js CHANGED
@@ -5,21 +5,47 @@ import fs from "node:fs";
5
5
  import path from "node:path";
6
6
  import process from "node:process";
7
7
  import { fileURLToPath } from "node:url";
8
+ import { createCodexTerminalAgentAdapter, detectCodexDurableCompletion } from "./codex-terminal-agent-adapter.js";
9
+ import { createClaudeTerminalAgentAdapter } from "./claude-terminal-agent-adapter.js";
10
+ import { ClaudeHookStore, ClaudeHookStoreError, defaultClaudeHookStoreDir } from "./claude-hook-store.js";
11
+ import { claudePermissionHookOutput } from "./claude-hook-protocol.js";
12
+ import { defaultClaudeSettingsPath, installClaudeHooks, loadTrustedClaudeTokenjuiceLaunchers } from "./claude-hook-installer.js";
8
13
  import { CodexLocalSessionProvider } from "./codex-local-session-provider.js";
9
14
  import { CodexStoreAdapter } from "./codex-store-adapter.js";
10
15
  import { applyMessageToConversation, budgetAction, createConversation, createMessage, executorForConversation, extractStructuredMessage, parseMessageJson, resolveExecutor } from "./protocol.js";
11
16
  import { EXECUTOR_KINDS, acpxCommandForExecutor, executorDefinitionForKind, modelEnvForExecutor, normalizeModelForExecutor, proxyEnvForExecutor } from "./executors.js";
12
17
  import { executorBootstrapPrompt } from "./bootstrap.js";
13
- import { redactString, writeRuntimeLog } from "./runtime-log.js";
18
+ import { writeRuntimeLog } from "./runtime-log.js";
14
19
  import { formatTranscript, readNdjsonLog } from "./transcript.js";
15
20
  import { appendEvent, defaultStoreDir, listConversations, logPathForStatePath, loadConversationById, loadState, messageEvent, pathsForConversation, pathsForConversationDir, saveState, statePathForConversationId } from "./store.js";
16
21
  import { planFork, planTakeover } from "./session-takeover-planner.js";
17
- import { StaticTerminalControlProvider, TmuxTerminalControlProvider, enrichActiveProcessesWithTerminalControl, terminalRefFromPane } from "./terminal-control-provider.js";
22
+ import { StaticTerminalControlProvider, TmuxTerminalControlProvider } from "./terminal-control-provider.js";
23
+ import { parseTerminalConversationId } from "./terminal-agent-adapter.js";
24
+ import { createProductionTerminalAgentRegistry } from "./terminal-agent-registry.js";
25
+ import { StaticTerminalProcessSource, SystemTerminalProcessSource } from "./terminal-process-source.js";
26
+ import { TerminalAgentBridge } from "./terminal-agent-bridge.js";
18
27
  const DEFAULT_IDLE_TIMEOUT_MINUTES = 10080;
19
28
  const DEFAULT_AGENT_TIMEOUT_MINUTES = 60;
20
29
  const DEFAULT_AGENT_HARD_TIMEOUT_MINUTES = 720;
21
30
  const DEFAULT_MONITOR_POLL_INTERVAL_MS = 5000;
31
+ const CALLBACK_RETRY_DELAYS_MS = [5000, 15000, 60000, 60000];
22
32
  const DEFAULT_CODEX_ACPX_AGENT_COMMAND = "npx -y @agentclientprotocol/codex-acp@^1.1.0";
33
+ const CONVERSATION_STATUSES = new Set([
34
+ "created",
35
+ "running",
36
+ "waiting_for_agent",
37
+ "waiting_for_openclaw",
38
+ "idle",
39
+ "stalled",
40
+ "needs_recovery",
41
+ "needs_model_selection",
42
+ "callback_pending",
43
+ "callback_failed",
44
+ "failed",
45
+ "closed",
46
+ "cancelled",
47
+ "cancelling"
48
+ ]);
23
49
  class InlineCodexSessionAdapter {
24
50
  threads;
25
51
  processes;
@@ -121,12 +147,21 @@ async function runCommand(commandName, options) {
121
147
  else if (commandName === "install-openclaw") {
122
148
  runInstallOpenClaw(options);
123
149
  }
150
+ else if (commandName === "install-claude-hooks") {
151
+ runInstallClaudeHooks(options);
152
+ }
153
+ else if (commandName === "claude-hook") {
154
+ await runClaudeHook(options);
155
+ }
124
156
  else if (commandName === "doctor") {
125
157
  runDoctor(options);
126
158
  }
127
159
  else if (commandName === "callback") {
128
160
  runCallback(options);
129
161
  }
162
+ else if (commandName === "retry-callback") {
163
+ runRetryCallback(options);
164
+ }
130
165
  else if (commandName === "monitor") {
131
166
  await runMonitor(options);
132
167
  }
@@ -188,6 +223,88 @@ function runInstallOpenClaw(options) {
188
223
  : "Agent Knock Knock is installed. Try: AKK list"
189
224
  });
190
225
  }
226
+ function runInstallClaudeHooks(options) {
227
+ const configuredExecutable = stringValue(options.executablePath ?? options.binPath);
228
+ let executablePath;
229
+ if (configuredExecutable) {
230
+ executablePath = path.resolve(expandHome(configuredExecutable));
231
+ }
232
+ else {
233
+ try {
234
+ executablePath = resolveExecutable("agent-knock-knock");
235
+ }
236
+ catch {
237
+ executablePath = path.resolve(process.argv[1]);
238
+ }
239
+ }
240
+ const result = installClaudeHooks({
241
+ executablePath,
242
+ settingsPath: expandHome(options.settingsPath ?? options.settings),
243
+ dryRun: options.dryRun === true
244
+ });
245
+ printJson({
246
+ installed: result.written || !result.changed,
247
+ ...result,
248
+ executablePath,
249
+ next: result.dryRun
250
+ ? "Run again without --dry-run to install the Claude Code hooks."
251
+ : "Claude Code hooks are configured. Existing sessions pick them up through settings reload; start a new session if needed."
252
+ });
253
+ }
254
+ async function runClaudeHook(options) {
255
+ const rawInput = fs.readFileSync(0, "utf8");
256
+ let input;
257
+ try {
258
+ input = JSON.parse(rawInput);
259
+ }
260
+ catch {
261
+ throw new Error("Claude hook input must be valid JSON");
262
+ }
263
+ const agentRows = loadClaudeAgentRows(options);
264
+ const claudePid = inferClaudeAncestorPid(agentRows);
265
+ const store = createClaudeHookStore(options);
266
+ const record = store.record(input, {
267
+ ...(claudePid === undefined ? {} : { claudePid })
268
+ });
269
+ if (record.event.input.hook_event_name !== "PermissionRequest" || !record.permission) {
270
+ return;
271
+ }
272
+ const permission = record.permission;
273
+ const requestedTimeout = options.permissionWaitTimeoutMs ?? options.timeoutMs;
274
+ const timeoutMs = requestedTimeout === undefined
275
+ ? undefined
276
+ : Math.max(0, Number(requestedTimeout));
277
+ try {
278
+ const decision = await store.waitForPermissionDecision({
279
+ sessionId: permission.sessionId,
280
+ requestId: permission.requestId,
281
+ fingerprint: permission.fingerprint,
282
+ conversationId: permission.conversationId,
283
+ messageId: permission.messageId,
284
+ ...(timeoutMs === undefined ? {} : { timeoutMs })
285
+ });
286
+ process.stdout.write(`${JSON.stringify(decision?.hookOutput ?? claudePermissionHookOutput({
287
+ behavior: "deny",
288
+ interrupt: false,
289
+ message: "Agent Knock Knock approval timed out. Review the request and retry the task."
290
+ }))}\n`);
291
+ }
292
+ catch (error) {
293
+ if (error instanceof ClaudeHookStoreError && [
294
+ "PERMISSION_EXPIRED",
295
+ "PERMISSION_CONSUMED",
296
+ "PERMISSION_ALREADY_DECIDED"
297
+ ].includes(error.code)) {
298
+ process.stdout.write(`${JSON.stringify(claudePermissionHookOutput({
299
+ behavior: "deny",
300
+ interrupt: false,
301
+ message: "Agent Knock Knock approval expired before a safe decision was received."
302
+ }))}\n`);
303
+ return;
304
+ }
305
+ throw error;
306
+ }
307
+ }
191
308
  function installOpenClawPlugin(openclawBin, root) {
192
309
  const linked = spawnSync(openclawBin, ["plugins", "install", "--link", root], {
193
310
  encoding: "utf8",
@@ -549,7 +666,7 @@ function selectTerminateTarget({ plan, session, activeSessions, expectedPid, all
549
666
  }
550
667
  async function listActiveSessionsWithTerminalControl(provider, options, terminalProvider = createTerminalControlProvider(options)) {
551
668
  const activeSessions = await provider.listActiveSessions();
552
- return enrichActiveProcessesWithTerminalControl(activeSessions, terminalProvider);
669
+ return createTerminalAgentBridge(options, terminalProvider).attachProcesses(provider.agent, activeSessions);
553
670
  }
554
671
  function createTerminalControlProvider(options) {
555
672
  if (options.terminalsJson || options.terminalScreensJson || options.processesJson) {
@@ -560,6 +677,161 @@ function createTerminalControlProvider(options) {
560
677
  }
561
678
  return new TmuxTerminalControlProvider();
562
679
  }
680
+ function createTerminalProcessSource(options) {
681
+ if (options.processesJson) {
682
+ return new StaticTerminalProcessSource(parseJsonOption(options.processesJson, "--processes-json"));
683
+ }
684
+ return new SystemTerminalProcessSource();
685
+ }
686
+ function createClaudeHookStore(options = {}) {
687
+ const configuredRoot = stringValue(options.claudeHookStoreDir);
688
+ return new ClaudeHookStore({
689
+ rootDir: expandHome(configuredRoot ?? defaultClaudeHookStoreDir())
690
+ });
691
+ }
692
+ function loadClaudeAgentRows(options = {}) {
693
+ let value;
694
+ if (options.claudeAgentsJson !== undefined) {
695
+ value = typeof options.claudeAgentsJson === "string"
696
+ ? parseJsonOption(options.claudeAgentsJson, "--claude-agents-json")
697
+ : options.claudeAgentsJson;
698
+ }
699
+ else if (options.processesJson || options.terminalsJson || options.terminalScreensJson) {
700
+ return [];
701
+ }
702
+ else {
703
+ const claudeExecutable = resolveOptionalExecutable("claude");
704
+ if (!claudeExecutable) {
705
+ return [];
706
+ }
707
+ const result = spawnSync(claudeExecutable, ["agents", "--json", "--all"], {
708
+ encoding: "utf8",
709
+ maxBuffer: 1024 * 1024 * 10,
710
+ timeout: 10_000
711
+ });
712
+ if (result.error || result.status !== 0) {
713
+ runtimeLog("warn", "claude_agents_list_failed", {
714
+ status: result.status ?? null,
715
+ error: result.error?.message,
716
+ stderr: textSummary(cleanProcessText(result.stderr))
717
+ });
718
+ return [];
719
+ }
720
+ try {
721
+ value = JSON.parse(result.stdout);
722
+ }
723
+ catch {
724
+ runtimeLog("warn", "claude_agents_list_invalid_json", {
725
+ stdout: textSummary(result.stdout)
726
+ });
727
+ return [];
728
+ }
729
+ }
730
+ const rows = Array.isArray(value)
731
+ ? value
732
+ : isRecord(value) && Array.isArray(value.agents)
733
+ ? value.agents
734
+ : [];
735
+ return rows.flatMap((row) => {
736
+ if (!isRecord(row) || !Number.isInteger(Number(row.pid))) {
737
+ return [];
738
+ }
739
+ return [{
740
+ pid: Number(row.pid),
741
+ ...(stringValue(row.cwd) ? { cwd: stringValue(row.cwd) } : {}),
742
+ ...(stringValue(row.kind) ? { kind: stringValue(row.kind) } : {}),
743
+ ...(stringValue(row.sessionId) ? { sessionId: stringValue(row.sessionId) } : {}),
744
+ ...(stringValue(row.status) ? { status: stringValue(row.status) } : {})
745
+ }];
746
+ });
747
+ }
748
+ function inferClaudeAncestorPid(agentRows, startingPid = process.ppid) {
749
+ const interactivePids = new Set(agentRows
750
+ .filter((row) => row.kind === undefined || row.kind === "interactive")
751
+ .map((row) => row.pid)
752
+ .filter((pid) => Number.isInteger(pid)));
753
+ let pid = startingPid;
754
+ const visited = new Set();
755
+ for (let depth = 0; depth < 16 && Number.isInteger(pid) && pid > 1 && !visited.has(pid); depth += 1) {
756
+ visited.add(pid);
757
+ const processRow = spawnSync("ps", ["-p", String(pid), "-o", "pid=,ppid=,command="], {
758
+ encoding: "utf8",
759
+ timeout: 2_000
760
+ });
761
+ if (processRow.error || processRow.status !== 0) {
762
+ return undefined;
763
+ }
764
+ const match = /^\s*(\d+)\s+(\d+)\s+(.+)$/u.exec(String(processRow.stdout).trim());
765
+ if (!match) {
766
+ return undefined;
767
+ }
768
+ const currentPid = Number(match[1]);
769
+ const parentPid = Number(match[2]);
770
+ const commandText = match[3];
771
+ if (interactivePids.has(currentPid) || isClaudeProcessCommand(commandText)) {
772
+ return currentPid;
773
+ }
774
+ pid = parentPid;
775
+ }
776
+ return undefined;
777
+ }
778
+ function isClaudeProcessCommand(commandText) {
779
+ const executable = commandText.trim().split(/\s+/u, 1)[0];
780
+ return path.basename(executable) === "claude" ||
781
+ /[\\/]\.local[\\/]share[\\/]claude[\\/]versions[\\/][^\\/\s]+$/u.test(executable);
782
+ }
783
+ function createRuntimeTerminalAgentRegistry(options) {
784
+ return createProductionTerminalAgentRegistry({
785
+ overrides: [
786
+ createCodexTerminalAgentAdapter({
787
+ async detectDurableCompletion(request) {
788
+ const runtime = isRecord(request.context) ? request.context : undefined;
789
+ const conversation = runtime?.conversation;
790
+ const nativeTakeover = isRecord(runtime?.nativeTakeover)
791
+ ? runtime?.nativeTakeover
792
+ : undefined;
793
+ if (!isRecord(conversation)) {
794
+ return undefined;
795
+ }
796
+ const contextMatch = await loadCodexTerminalContext({
797
+ conversation,
798
+ nativeTakeover,
799
+ options
800
+ });
801
+ if (!contextMatch?.context) {
802
+ return undefined;
803
+ }
804
+ const evidence = detectCodexDurableCompletion({
805
+ ...request,
806
+ context: contextMatch.context
807
+ });
808
+ return evidence
809
+ ? {
810
+ ...evidence,
811
+ confidence: contextMatch.confidence,
812
+ metadata: {
813
+ ...evidence.metadata,
814
+ context_match: contextMatch.match,
815
+ session: contextMatch.context.source
816
+ }
817
+ }
818
+ : undefined;
819
+ }
820
+ }),
821
+ createClaudeTerminalAgentAdapter({
822
+ agentRows: loadClaudeAgentRows(options),
823
+ hookStore: createClaudeHookStore(options),
824
+ trustedTokenjuiceLaunchers: loadTrustedClaudeTokenjuiceLaunchers(expandHome(options.claudeSettingsPath ?? defaultClaudeSettingsPath()))
825
+ })
826
+ ]
827
+ });
828
+ }
829
+ function createTerminalAgentBridge(options, terminalProvider = createTerminalControlProvider(options)) {
830
+ return new TerminalAgentBridge({
831
+ registry: createRuntimeTerminalAgentRegistry(options),
832
+ terminalProvider
833
+ });
834
+ }
563
835
  function planTerminalControlTakeover(session, activeSessions) {
564
836
  const matched = activeSessions
565
837
  .filter((process) => process.kind === "codex_cli" &&
@@ -609,6 +881,9 @@ function terminalControlFromTakeover(nativeTakeover) {
609
881
  if (!target || !session || !Number.isInteger(window) || !Number.isInteger(pane) || !Number.isInteger(panePid)) {
610
882
  return undefined;
611
883
  }
884
+ const storedCapabilities = Array.isArray(terminalControl.capabilities)
885
+ ? terminalControl.capabilities.filter(isTerminalControlCapability)
886
+ : [];
612
887
  return {
613
888
  kind: "tmux",
614
889
  target,
@@ -619,181 +894,47 @@ function terminalControlFromTakeover(nativeTakeover) {
619
894
  currentCommand: stringValue(terminalControl.currentCommand),
620
895
  currentPath: stringValue(terminalControl.currentPath),
621
896
  socketPath: stringValue(terminalControl.socketPath),
622
- capabilities: ["screen_status", "send_keys", "terminal_approval"]
623
- };
624
- }
625
- function detectCodexApprovalPrompt(screen) {
626
- const prompt = codexApprovalPromptRegion(screen);
627
- if (!prompt.visible) {
628
- return {
629
- approvable: false,
630
- reason: prompt.reason
631
- };
632
- }
633
- for (const line of prompt.region.split(/\r?\n/)) {
634
- const match = /^[\s›]*1\.\s+(Yes,[^(]+)\(([^)]+)\)/u.exec(line.trim());
635
- if (!match) {
636
- continue;
637
- }
638
- const key = match[2].trim();
639
- if (key !== "y") {
640
- return {
641
- approvable: false,
642
- reason: `primary approval shortcut is ${key}, not y`,
643
- ...approvalCandidateFromPrompt(prompt.marker, prompt.region)
644
- };
645
- }
646
- return {
647
- approvable: true,
648
- key,
649
- label: match[1].trim(),
650
- ...approvalCandidateFromPrompt(prompt.marker, prompt.region)
651
- };
652
- }
653
- return {
654
- approvable: false,
655
- reason: "no primary approve option with a shortcut was detected",
656
- ...approvalCandidateFromPrompt(prompt.marker, prompt.region)
657
- };
658
- }
659
- function isCodexApprovalPromptVisible(screen) {
660
- return codexApprovalPromptRegion(screen).visible;
661
- }
662
- function codexApprovalPromptRegion(screen) {
663
- const approvalMarkers = [
664
- "Would you like to run the following command?",
665
- "Would you like to make the following edits?",
666
- "Would you like to grant these permissions?",
667
- "needs your approval."
668
- ];
669
- const lines = screen.split(/\r?\n/);
670
- let markerIndex = -1;
671
- let matchedMarker = "";
672
- for (let index = lines.length - 1; index >= 0; index -= 1) {
673
- const marker = approvalMarkers.find((candidate) => lines[index].includes(candidate));
674
- if (marker) {
675
- markerIndex = index;
676
- matchedMarker = marker;
677
- break;
678
- }
679
- }
680
- if (markerIndex < 0) {
681
- return {
682
- visible: false,
683
- reason: "no Codex approval prompt was detected in the terminal screen"
684
- };
685
- }
686
- const regionLines = lines.slice(markerIndex);
687
- const staleLine = regionLines.slice(1).find((line) => isPostApprovalActivityLine(line));
688
- if (staleLine) {
689
- return {
690
- visible: false,
691
- reason: `Codex approval prompt appears stale after later terminal activity: ${staleLine.trim()}`
692
- };
693
- }
694
- return {
695
- visible: true,
696
- region: regionLines.join("\n"),
697
- marker: matchedMarker
698
- };
699
- }
700
- function approvalCandidateFromPrompt(marker, region) {
701
- const promptKind = marker === "Would you like to run the following command?"
702
- ? "run_command"
703
- : marker === "Would you like to make the following edits?"
704
- ? "file_edit"
705
- : marker === "Would you like to grant these permissions?"
706
- ? "grant_permissions"
707
- : "unknown";
708
- return {
709
- promptKind,
710
- command: promptKind === "run_command" ? commandFromApprovalRegion(region) : undefined
897
+ // State written before adapter capabilities were persisted always represented Codex.
898
+ capabilities: storedCapabilities.length > 0
899
+ ? storedCapabilities
900
+ : [
901
+ "screen_status",
902
+ "send_keys",
903
+ "terminal_approval",
904
+ "screen_completion",
905
+ "durable_completion",
906
+ "terminal_cancel"
907
+ ]
711
908
  };
712
909
  }
713
- function commandFromApprovalRegion(region) {
714
- const lines = region.split(/\r?\n/);
715
- const commandStart = lines.findIndex((line) => /^\s*\$\s+/u.test(line));
716
- if (commandStart < 0) {
717
- return undefined;
718
- }
719
- const parts = [];
720
- for (let index = commandStart; index < lines.length; index += 1) {
721
- const line = lines[index];
722
- if (index > commandStart && (!line.trim() || /^[\s›]*\d+\.\s+/u.test(line) || /Press enter to confirm/u.test(line))) {
723
- break;
724
- }
725
- parts.push(index === commandStart ? line.replace(/^\s*\$\s+/u, "").trim() : line.trim());
726
- }
727
- const command = parts.filter(Boolean).join(" ").trim();
728
- return command ? redactString(command) : undefined;
729
- }
730
- function isPostApprovalActivityLine(line) {
731
- const trimmed = line.trim();
732
- if (!trimmed) {
733
- return false;
734
- }
735
- if (/^✔\s+You approved\b/u.test(trimmed)) {
736
- return true;
737
- }
738
- if (/^›\s+(?!1\.)\S/u.test(trimmed)) {
739
- return true;
740
- }
741
- if (/^•\s+(Working|Ran|Explored|Edited|Read|Called|Searching|Planning|Updated|Added|Deleted|Modified|Running|Thinking)\b/u.test(trimmed)) {
742
- return true;
743
- }
744
- return /^─\s*Worked for\b/u.test(trimmed);
745
- }
746
- function detectCodexActivityState(screen, approval = detectCodexApprovalPrompt(screen)) {
747
- if (approval.approvable || isCodexApprovalPromptVisible(screen)) {
748
- return {
749
- state: "awaiting_approval",
750
- reason: "current Codex approval prompt is visible"
751
- };
752
- }
753
- const tailLines = screen.trimEnd().split(/\r?\n/).slice(-30);
754
- const workingLine = tailLines.find((line) => isCodexWorkingLine(line));
755
- if (workingLine) {
756
- return {
757
- state: "working",
758
- reason: `Codex working marker detected: ${workingLine.trim()}`
759
- };
760
- }
761
- const idleLine = tailLines
762
- .slice(-6)
763
- .map((line) => line.trim())
764
- .find((line) => isCodexIdlePromptLine(line));
765
- if (idleLine) {
766
- return {
767
- state: "idle",
768
- reason: `Codex input prompt detected: ${idleLine}`
769
- };
770
- }
910
+ function terminalRuntimeIdentityForConversation(conversation, terminalControl) {
911
+ const nativeTakeover = isRecord(conversation?.native_session_takeover)
912
+ ? conversation.native_session_takeover
913
+ : undefined;
914
+ const nativeSessionId = stringValue(nativeTakeover?.native_session_id);
915
+ const terminalIdentity = parseTerminalConversationId(nativeSessionId);
916
+ const explicitSessionId = stringValue(nativeTakeover?.terminal_agent_session_id) ??
917
+ (terminalIdentity ? undefined : nativeSessionId);
771
918
  return {
772
- state: "unknown",
773
- reason: "no current Codex working, idle, or approval marker was detected in the terminal screen"
919
+ pid: Number.isInteger(Number(nativeTakeover?.terminal_agent_pid))
920
+ ? Number(nativeTakeover?.terminal_agent_pid)
921
+ : terminalIdentity?.pid,
922
+ sessionId: explicitSessionId,
923
+ cwd: stringValue(nativeTakeover?.source_cwd) ?? terminalControl.currentPath,
924
+ conversationId: stringValue(conversation?.conversation_id),
925
+ messageId: stringValue(nativeTakeover?.terminal_bridge_message_id),
926
+ terminalTarget: terminalControl.target
774
927
  };
775
928
  }
776
- function isCodexWorkingLine(line) {
777
- const trimmed = line.trim();
778
- if (!trimmed) {
779
- return false;
780
- }
781
- if (/^•\s+Working\b/u.test(trimmed) && (/\besc to interrupt\b/u.test(trimmed) || trimmed === "• Working")) {
782
- return true;
783
- }
784
- if (/^•\s+Waiting for background terminals?\b(?:\s*·|$)/u.test(trimmed)) {
785
- return true;
786
- }
787
- return /^\d+\s+background terminals? running\b/u.test(trimmed) && /\/(?:ps|stop)\b/u.test(trimmed);
788
- }
789
- function isCodexIdlePromptLine(line) {
790
- const trimmed = line.trim();
791
- return /^›(?:\s|$)/u.test(trimmed) && !/^›\s*1\./u.test(trimmed);
792
- }
793
- function screenExcerpt(screen, maxLength = 4000) {
794
- const lines = screen.trimEnd().split(/\r?\n/);
795
- const excerpt = lines.slice(Math.max(0, lines.length - 80)).join("\n");
796
- return redactString(excerpt).slice(-maxLength);
929
+ function isTerminalControlCapability(value) {
930
+ return typeof value === "string" && [
931
+ "screen_status",
932
+ "send_keys",
933
+ "terminal_approval",
934
+ "screen_completion",
935
+ "durable_completion",
936
+ "terminal_cancel"
937
+ ].includes(value);
797
938
  }
798
939
  function createForkConversation({ agent, strategy, session, contextPackage, forkSummary, modelInfo, options }) {
799
940
  const workspace = session.cwd;
@@ -1336,7 +1477,7 @@ function startExecutorMonitor({ statePath, logPath, pid, outputPath, agentTimeou
1336
1477
  child.unref();
1337
1478
  return child;
1338
1479
  }
1339
- function startTerminalBridgeMonitor({ statePath, logPath, agentTimeoutMinutes, agentHardTimeoutMinutes, pollIntervalMs, codexHome }) {
1480
+ function startTerminalBridgeMonitor({ statePath, logPath, agentTimeoutMinutes, agentHardTimeoutMinutes, pollIntervalMs, codexHome, claudeHookStoreDir }) {
1340
1481
  const args = [
1341
1482
  new URL(import.meta.url).pathname,
1342
1483
  "monitor",
@@ -1355,6 +1496,9 @@ function startTerminalBridgeMonitor({ statePath, logPath, agentTimeoutMinutes, a
1355
1496
  if (codexHome) {
1356
1497
  args.push("--codex-home", codexHome);
1357
1498
  }
1499
+ if (claudeHookStoreDir) {
1500
+ args.push("--claude-hook-store-dir", claudeHookStoreDir);
1501
+ }
1358
1502
  const child = spawn(process.execPath, args, {
1359
1503
  detached: true,
1360
1504
  stdio: "ignore",
@@ -1381,7 +1525,8 @@ function startTerminalBridgeMonitorForConversation({ conversation, statePath, lo
1381
1525
  nativeTakeover?.["terminal_bridge_hard_timeout_minutes"] ??
1382
1526
  DEFAULT_AGENT_HARD_TIMEOUT_MINUTES),
1383
1527
  pollIntervalMs: Number(options.monitorPollIntervalMs ?? DEFAULT_MONITOR_POLL_INTERVAL_MS),
1384
- codexHome: options.codexHome
1528
+ codexHome: options.codexHome,
1529
+ claudeHookStoreDir: options.claudeHookStoreDir ?? nativeTakeover?.["claude_hook_store_dir"]
1385
1530
  });
1386
1531
  }
1387
1532
  function terminalBridgeEnabled(conversation) {
@@ -1404,6 +1549,7 @@ function withTerminalBridgeState({ conversation, message, startedAt, agentTimeou
1404
1549
  terminal_bridge_request_text: String(message.body ?? ""),
1405
1550
  terminal_bridge_request_hash: terminalBridgeRequestFingerprint(message.body),
1406
1551
  terminal_bridge_pre_send_screen_fingerprint: preSendScreenFingerprint,
1552
+ terminal_bridge_completion_claim: undefined,
1407
1553
  terminal_bridge_monitor_started_at: startedAt,
1408
1554
  terminal_bridge_last_activity_at: startedAt,
1409
1555
  terminal_bridge_inactivity_timeout_minutes: agentTimeoutMinutes,
@@ -1414,6 +1560,165 @@ function withTerminalBridgeState({ conversation, message, startedAt, agentTimeou
1414
1560
  updated_at: startedAt
1415
1561
  };
1416
1562
  }
1563
+ function activateClaudeHookLease({ options, conversation, message, terminalControl, expiresAt }) {
1564
+ const runtime = terminalRuntimeIdentityForConversation(conversation, terminalControl);
1565
+ const pid = runtime.pid;
1566
+ const agentRow = pid === undefined
1567
+ ? undefined
1568
+ : loadClaudeAgentRows(options).find((row) => row.pid === pid && (row.kind === undefined || row.kind === "interactive"));
1569
+ const sessionId = agentRow?.sessionId ?? runtime.sessionId;
1570
+ const cwd = agentRow?.cwd ?? runtime.cwd ?? terminalControl.currentPath;
1571
+ if (!cwd || (pid === undefined && !sessionId)) {
1572
+ runtimeLog("warn", "claude_hook_lease_unavailable", {
1573
+ conversation_id: conversation.conversation_id,
1574
+ terminal_target: terminalControl.target,
1575
+ reason: "exact Claude pid/session identity is unavailable"
1576
+ });
1577
+ return undefined;
1578
+ }
1579
+ const store = createClaudeHookStore(options);
1580
+ try {
1581
+ const previous = store.resolveLease({
1582
+ ...(sessionId === undefined ? {} : { sessionId }),
1583
+ ...(pid === undefined ? {} : { pid }),
1584
+ cwd,
1585
+ requireUnique: true
1586
+ });
1587
+ if (previous && (previous.lease.conversationId !== conversation.conversation_id ||
1588
+ previous.lease.messageId !== message.id)) {
1589
+ store.releaseLease({ leaseId: previous.lease.id });
1590
+ }
1591
+ const lease = store.activateLease({
1592
+ ...(sessionId === undefined ? {} : { sessionId }),
1593
+ ...(pid === undefined ? {} : { pid }),
1594
+ cwd,
1595
+ conversationId: conversation.conversation_id,
1596
+ messageId: message.id,
1597
+ terminalTarget: terminalControl.target,
1598
+ expiresAt
1599
+ });
1600
+ runtimeLog("info", "claude_hook_lease_activated", {
1601
+ conversation_id: conversation.conversation_id,
1602
+ message_id: message.id,
1603
+ terminal_target: terminalControl.target,
1604
+ lease_id: lease.id,
1605
+ pid,
1606
+ session_id: sessionId,
1607
+ expires_at: lease.expiresAt
1608
+ });
1609
+ return {
1610
+ lease,
1611
+ storeDir: store.rootDir,
1612
+ ...(pid === undefined ? {} : { pid }),
1613
+ ...(sessionId === undefined ? {} : { sessionId })
1614
+ };
1615
+ }
1616
+ catch (error) {
1617
+ runtimeLog("warn", "claude_hook_lease_unavailable", {
1618
+ conversation_id: conversation.conversation_id,
1619
+ terminal_target: terminalControl.target,
1620
+ reason: error instanceof Error ? error.message : String(error)
1621
+ });
1622
+ return undefined;
1623
+ }
1624
+ }
1625
+ function withClaudeHookLeaseState(conversation, state) {
1626
+ const nativeTakeover = isRecord(conversation.native_session_takeover)
1627
+ ? conversation.native_session_takeover
1628
+ : {};
1629
+ return {
1630
+ ...conversation,
1631
+ native_session_takeover: {
1632
+ ...nativeTakeover,
1633
+ claude_hook_mode: "enabled",
1634
+ claude_hook_store_dir: state.storeDir,
1635
+ claude_hook_lease_id: state.lease.id,
1636
+ terminal_agent_pid: state.pid,
1637
+ terminal_agent_session_id: state.sessionId
1638
+ }
1639
+ };
1640
+ }
1641
+ function releaseClaudeHookLease(conversation) {
1642
+ const nativeTakeover = isRecord(conversation?.native_session_takeover)
1643
+ ? conversation.native_session_takeover
1644
+ : undefined;
1645
+ const leaseId = stringValue(nativeTakeover?.claude_hook_lease_id);
1646
+ if (!leaseId || nativeTakeover?.agent !== "claude") {
1647
+ return;
1648
+ }
1649
+ const storeDir = stringValue(nativeTakeover.claude_hook_store_dir) ?? defaultClaudeHookStoreDir();
1650
+ try {
1651
+ const released = new ClaudeHookStore({ rootDir: storeDir }).releaseLease({ leaseId });
1652
+ runtimeLog("info", "claude_hook_lease_released", {
1653
+ conversation_id: conversation.conversation_id,
1654
+ message_id: released?.messageId,
1655
+ lease_id: leaseId
1656
+ });
1657
+ }
1658
+ catch (error) {
1659
+ runtimeLog("warn", "claude_hook_lease_release_failed", {
1660
+ conversation_id: conversation.conversation_id,
1661
+ lease_id: leaseId,
1662
+ reason: error instanceof Error ? error.message : String(error)
1663
+ });
1664
+ }
1665
+ }
1666
+ function renewClaudeHookLease(conversation, expiresAt) {
1667
+ const nativeTakeover = isRecord(conversation?.native_session_takeover)
1668
+ ? conversation.native_session_takeover
1669
+ : undefined;
1670
+ if (nativeTakeover?.agent !== "claude") {
1671
+ return undefined;
1672
+ }
1673
+ const conversationId = stringValue(conversation.conversation_id);
1674
+ const messageId = stringValue(nativeTakeover.terminal_bridge_message_id);
1675
+ const terminalControl = terminalControlFromTakeover(nativeTakeover);
1676
+ const cwd = stringValue(nativeTakeover.source_cwd) ?? terminalControl?.currentPath;
1677
+ const pid = Number.isInteger(Number(nativeTakeover.terminal_agent_pid))
1678
+ ? Number(nativeTakeover.terminal_agent_pid)
1679
+ : undefined;
1680
+ const sessionId = stringValue(nativeTakeover.terminal_agent_session_id);
1681
+ if (!conversationId || !messageId || !terminalControl || !cwd || (pid === undefined && !sessionId)) {
1682
+ return undefined;
1683
+ }
1684
+ const storeDir = stringValue(nativeTakeover.claude_hook_store_dir) ?? defaultClaudeHookStoreDir();
1685
+ try {
1686
+ return new ClaudeHookStore({ rootDir: storeDir }).activateLease({
1687
+ ...(sessionId === undefined ? {} : { sessionId }),
1688
+ ...(pid === undefined ? {} : { pid }),
1689
+ cwd,
1690
+ conversationId,
1691
+ messageId,
1692
+ terminalTarget: terminalControl.target,
1693
+ expiresAt
1694
+ });
1695
+ }
1696
+ catch (error) {
1697
+ runtimeLog("warn", "claude_hook_lease_renew_failed", {
1698
+ conversation_id: conversationId,
1699
+ message_id: messageId,
1700
+ reason: error instanceof Error ? error.message : String(error)
1701
+ });
1702
+ return undefined;
1703
+ }
1704
+ }
1705
+ function releaseClaudeHookLeasesForTerminal({ storeDir, terminalControl, replacementConversationId }) {
1706
+ for (const candidate of listConversations(storeDir)) {
1707
+ if (candidate.conversation_id === replacementConversationId) {
1708
+ continue;
1709
+ }
1710
+ const nativeTakeover = isRecord(candidate.native_session_takeover)
1711
+ ? candidate.native_session_takeover
1712
+ : undefined;
1713
+ const candidateControl = terminalControlFromTakeover(nativeTakeover);
1714
+ if (nativeTakeover?.agent === "claude" &&
1715
+ nativeTakeover?.terminal_bridge === true &&
1716
+ candidateControl?.target === terminalControl.target &&
1717
+ candidateControl?.socketPath === terminalControl.socketPath) {
1718
+ releaseClaudeHookLease(candidate);
1719
+ }
1720
+ }
1721
+ }
1417
1722
  function uniqueDelegateSessionName(kind) {
1418
1723
  const { sessionPrefix } = executorDefinitionForKind(kind || "claude");
1419
1724
  const timestamp = new Date().toISOString().replace(/\D/g, "").slice(0, 14);
@@ -1535,57 +1840,59 @@ async function buildNativeListGroups({ options, agentFilter, statusFilter }) {
1535
1840
  }
1536
1841
  };
1537
1842
  }
1538
- if (agentFilter && agentFilter !== "codex") {
1843
+ const registry = createRuntimeTerminalAgentRegistry(options);
1844
+ const adapters = agentFilter
1845
+ ? [registry.get(agentFilter)].filter((adapter) => adapter !== undefined)
1846
+ : registry.list();
1847
+ if (agentFilter && adapters.length === 0) {
1539
1848
  return {
1540
1849
  ...empty,
1541
1850
  summary: {
1542
1851
  enabled: true,
1543
1852
  agents: [],
1544
- skipped: `native active discovery is not implemented for ${agentFilter}`
1853
+ skipped: `terminal agent adapter is not registered for ${agentFilter}`
1545
1854
  }
1546
1855
  };
1547
1856
  }
1857
+ const terminalProvider = createTerminalControlProvider(options);
1858
+ const bridge = new TerminalAgentBridge({ registry, terminalProvider });
1859
+ const terminalScan = options.terminalDebug ? await terminalControlDiagnostics(terminalProvider) : undefined;
1860
+ const terminalControlled = [];
1861
+ const native = [];
1862
+ let activeCount = 0;
1863
+ const errors = [];
1548
1864
  try {
1549
- const provider = createAgentSessionProvider("codex", options);
1550
- const terminalProvider = createTerminalControlProvider(options);
1551
- const terminalScan = options.terminalDebug ? await terminalControlDiagnostics(terminalProvider) : undefined;
1552
- const activeSessions = await listActiveSessionsWithTerminalControl(provider, options, terminalProvider);
1865
+ const processSource = createTerminalProcessSource(options);
1866
+ const snapshots = await processSource.listProcessSnapshots((snapshot) => adapters.some((adapter) => adapter.capabilities.processDiscovery && adapter.classifyProcess(snapshot) !== undefined));
1867
+ const activeSessions = await bridge.listProcesses(snapshots, adapters.map((adapter) => adapter.agent));
1868
+ activeCount = activeSessions.length;
1553
1869
  const rootSessions = rootActiveProcesses(activeSessions);
1554
- const terminalControlled = [];
1555
- const native = [];
1556
1870
  for (const session of rootSessions) {
1557
1871
  if (session.terminalControl) {
1558
- terminalControlled.push(await terminalControlledListEntry(session, activeSessions, options));
1872
+ terminalControlled.push(await terminalControlledListEntry(session, activeSessions, options, bridge));
1559
1873
  }
1560
1874
  else {
1561
1875
  native.push(nativeListEntry(session, activeSessions));
1562
1876
  }
1563
1877
  }
1564
- return {
1565
- native,
1566
- terminalControlled,
1567
- summary: {
1568
- enabled: true,
1569
- agents: ["codex"],
1570
- active_count: activeSessions.length,
1571
- native_count: native.length,
1572
- terminal_controlled_count: terminalControlled.length,
1573
- approval_scan: options.noApprovalScan ? "disabled" : "enabled",
1574
- terminal_scan: terminalScan
1575
- }
1576
- };
1577
1878
  }
1578
1879
  catch (error) {
1579
- return {
1580
- native: [],
1581
- terminalControlled: [],
1582
- summary: {
1583
- enabled: true,
1584
- agents: ["codex"],
1585
- error: error instanceof Error ? error.message : String(error)
1586
- }
1587
- };
1880
+ errors.push(error instanceof Error ? error.message : String(error));
1588
1881
  }
1882
+ return {
1883
+ native,
1884
+ terminalControlled,
1885
+ summary: {
1886
+ enabled: true,
1887
+ agents: adapters.map((adapter) => adapter.agent),
1888
+ active_count: activeCount,
1889
+ native_count: native.length,
1890
+ terminal_controlled_count: terminalControlled.length,
1891
+ approval_scan: options.noApprovalScan ? "disabled" : "enabled",
1892
+ terminal_scan: terminalScan,
1893
+ error: errors.length > 0 ? errors.join("; ") : undefined
1894
+ }
1895
+ };
1589
1896
  }
1590
1897
  async function terminalControlDiagnostics(provider) {
1591
1898
  if (provider instanceof TmuxTerminalControlProvider) {
@@ -1612,9 +1919,9 @@ function delegatedListEntry(task) {
1612
1919
  }
1613
1920
  function nativeListEntry(session, activeSessions) {
1614
1921
  return {
1615
- id: `native:codex:${session.pid}`,
1922
+ id: `native:${session.agent}:${session.pid}`,
1616
1923
  source: "native_active",
1617
- agent: "codex",
1924
+ agent: session.agent,
1618
1925
  status: "active",
1619
1926
  pid: session.pid,
1620
1927
  child_pids: childPidsForRoot(session, activeSessions),
@@ -1637,16 +1944,21 @@ function nativeListEntry(session, activeSessions) {
1637
1944
  }
1638
1945
  };
1639
1946
  }
1640
- async function terminalControlledListEntry(session, activeSessions, options) {
1947
+ async function terminalControlledListEntry(session, activeSessions, options, bridge = createTerminalAgentBridge(options)) {
1641
1948
  const terminalControl = session.terminalControl;
1642
1949
  if (!terminalControl) {
1643
1950
  throw new Error(`process ${session.pid} is not terminal-controlled`);
1644
1951
  }
1645
- const terminalState = await listStateForTerminal(terminalControl, options);
1952
+ const terminalState = await listStateForTerminal(session.agent, terminalControl, options, bridge, {
1953
+ pid: session.pid,
1954
+ sessionId: session.sessionId,
1955
+ cwd: session.cwd,
1956
+ terminalTarget: terminalControl.target
1957
+ });
1646
1958
  return {
1647
- id: `terminal:${terminalControl.kind}:${terminalControl.target}:${session.pid}`,
1959
+ id: bridge.terminalConversationId(session),
1648
1960
  source: "terminal_control",
1649
- agent: "codex",
1961
+ agent: session.agent,
1650
1962
  status: "active",
1651
1963
  pid: session.pid,
1652
1964
  child_pids: childPidsForRoot(session, activeSessions),
@@ -1663,14 +1975,15 @@ async function terminalControlledListEntry(session, activeSessions, options) {
1663
1975
  activity_reason: terminalState.activity_reason,
1664
1976
  commands: {
1665
1977
  send: true,
1666
- approve: terminalState.approval_state.approvable === true,
1978
+ approve: terminalControl.capabilities.includes("terminal_approval") &&
1979
+ terminalState.approval_state.approvable === true,
1667
1980
  status: true,
1668
- cancel: true,
1981
+ cancel: terminalControl.capabilities.includes("terminal_cancel"),
1669
1982
  close: false
1670
1983
  }
1671
1984
  };
1672
1985
  }
1673
- async function listStateForTerminal(terminalControl, options) {
1986
+ async function listStateForTerminal(agent, terminalControl, options, bridge = createTerminalAgentBridge(options), runtime) {
1674
1987
  if (options.noApprovalScan) {
1675
1988
  return {
1676
1989
  approval_state: {
@@ -1684,27 +1997,18 @@ async function listStateForTerminal(terminalControl, options) {
1684
1997
  };
1685
1998
  }
1686
1999
  try {
1687
- const screen = await createTerminalControlProvider(options).capture(terminalControl.target, {
2000
+ const status = await bridge.status(agent, terminalControl, {
1688
2001
  scrollbackLines: Number(options.scrollbackLines ?? 120),
1689
- socketPath: terminalControl.socketPath
2002
+ runtime
1690
2003
  });
1691
- const approval = detectCodexApprovalPrompt(screen);
1692
- const blocked = isCodexApprovalPromptVisible(screen);
1693
- const activity = detectCodexActivityState(screen, approval);
1694
2004
  return {
1695
2005
  approval_state: {
1696
- scanned: true,
1697
- blocked,
1698
- approvable: approval.approvable,
1699
- key: approval.approvable ? approval.key : undefined,
1700
- label: approval.approvable ? approval.label : undefined,
1701
- prompt_kind: approval.promptKind,
1702
- command: approval.command,
1703
- reason: approval.approvable ? undefined : approval.reason,
1704
- screen_excerpt: blocked ? screenExcerpt(screen, 1000) : undefined
2006
+ ...status.approval_state,
2007
+ screen_excerpt: status.approval_state.blocked ? status.screen.excerpt?.slice(-1000) : undefined
1705
2008
  },
1706
- activity_state: activity.state,
1707
- activity_reason: activity.reason
2009
+ activity_state: status.activity_state,
2010
+ activity_reason: status.activity_reason,
2011
+ capability_limitation: status.capability_limitation
1708
2012
  };
1709
2013
  }
1710
2014
  catch (error) {
@@ -1721,11 +2025,13 @@ async function listStateForTerminal(terminalControl, options) {
1721
2025
  }
1722
2026
  }
1723
2027
  function rootActiveProcesses(processes) {
1724
- const pids = new Set(processes.map((process) => process.pid));
1725
- const roots = processes.filter((process) => !process.ppid || !pids.has(process.ppid));
2028
+ const pids = new Set(processes.map((process) => `${process.agent}:${process.pid}`));
2029
+ const roots = processes.filter((process) => !process.ppid || !pids.has(`${process.agent}:${process.ppid}`));
1726
2030
  const seenTerminalTargets = new Set();
1727
2031
  return roots.filter((process) => {
1728
- const terminalTarget = process.terminalControl?.target;
2032
+ const terminalTarget = process.terminalControl?.target
2033
+ ? `${process.agent}:${process.terminalControl.target}`
2034
+ : undefined;
1729
2035
  if (!terminalTarget) {
1730
2036
  return true;
1731
2037
  }
@@ -1738,50 +2044,14 @@ function rootActiveProcesses(processes) {
1738
2044
  }
1739
2045
  function childPidsForRoot(root, processes) {
1740
2046
  return processes
1741
- .filter((process) => process.ppid === root.pid)
2047
+ .filter((process) => process.agent === root.agent && process.ppid === root.pid)
1742
2048
  .map((process) => process.pid);
1743
2049
  }
1744
2050
  function canSendDelegated(status) {
1745
2051
  return !["failed", "closed", "cancelled"].includes(status);
1746
2052
  }
1747
2053
  async function resolveTerminalConversationFromOptions(options) {
1748
- const parsed = parseTerminalConversationId(stringValue(options.conversation));
1749
- if (!parsed) {
1750
- return undefined;
1751
- }
1752
- const provider = createTerminalControlProvider(options);
1753
- const panes = await provider.listPanes();
1754
- const pane = panes.find((candidate) => candidate.kind === parsed.kind &&
1755
- candidate.target === parsed.target);
1756
- if (!pane) {
1757
- throw new Error(`terminal-controlled session ${parsed.conversationId} is no longer available`);
1758
- }
1759
- return {
1760
- conversationId: parsed.conversationId,
1761
- terminalControl: terminalRefFromPane(pane)
1762
- };
1763
- }
1764
- function parseTerminalConversationId(conversationId) {
1765
- const prefix = "terminal:tmux:";
1766
- if (!conversationId?.startsWith(prefix)) {
1767
- return undefined;
1768
- }
1769
- const rest = conversationId.slice(prefix.length);
1770
- const pidSeparator = rest.lastIndexOf(":");
1771
- if (pidSeparator <= 0 || pidSeparator === rest.length - 1) {
1772
- throw new Error(`invalid terminal-controlled conversation id: ${conversationId}`);
1773
- }
1774
- const target = rest.slice(0, pidSeparator);
1775
- const pid = Number(rest.slice(pidSeparator + 1));
1776
- if (!target || !Number.isInteger(pid)) {
1777
- throw new Error(`invalid terminal-controlled conversation id: ${conversationId}`);
1778
- }
1779
- return {
1780
- conversationId,
1781
- kind: "tmux",
1782
- target,
1783
- pid
1784
- };
2054
+ return createTerminalAgentBridge(options).resolveConversationId(stringValue(options.conversation ?? options.conversationId));
1785
2055
  }
1786
2056
  function parseNativeConversationId(conversationId) {
1787
2057
  const prefix = "native:codex:";
@@ -1802,7 +2072,11 @@ async function runStatus(options) {
1802
2072
  cleanupIdleConversations(storeDirFromOptions(options), options);
1803
2073
  const terminalConversation = await resolveTerminalConversationFromOptions(options);
1804
2074
  if (terminalConversation) {
1805
- const terminalStatus = await terminalStatusForControl(terminalConversation.terminalControl, options);
2075
+ const terminalStatus = await terminalStatusForControl(terminalConversation.agent, terminalConversation.terminalControl, options, {
2076
+ pid: terminalConversation.pid,
2077
+ cwd: terminalConversation.terminalControl.currentPath,
2078
+ terminalTarget: terminalConversation.terminalControl.target
2079
+ });
1806
2080
  printJson({
1807
2081
  conversation_id: terminalConversation.conversationId,
1808
2082
  source: "terminal_control",
@@ -1832,8 +2106,9 @@ async function runStatus(options) {
1832
2106
  }
1833
2107
  const terminalControl = terminalControlFromTakeover(isRecord(conversation.native_session_takeover) ? conversation.native_session_takeover : undefined);
1834
2108
  if (terminalControl) {
2109
+ const executor = executorForConversation(conversation);
1835
2110
  result.terminal_control = terminalControl;
1836
- result.terminal_status = await terminalStatusForControl(terminalControl, options);
2111
+ result.terminal_status = await terminalStatusForControl(executor.kind, terminalControl, options, terminalRuntimeIdentityForConversation(conversation, terminalControl));
1837
2112
  result.terminal_screen = result.terminal_status.screen;
1838
2113
  }
1839
2114
  printJson(result);
@@ -1851,8 +2126,31 @@ async function runDescribe(options) {
1851
2126
  const conversationId = required(options.conversation ?? options.conversationId, "--conversation is required");
1852
2127
  const terminalConversation = await resolveTerminalConversationFromOptions(options);
1853
2128
  if (terminalConversation) {
1854
- const terminalStatus = await terminalStatusForControl(terminalConversation.terminalControl, options);
1855
- const process = await activeCodexProcessForPid(options, parseTerminalConversationId(conversationId)?.pid);
2129
+ const terminalStatus = await terminalStatusForControl(terminalConversation.agent, terminalConversation.terminalControl, options, {
2130
+ pid: terminalConversation.pid,
2131
+ cwd: terminalConversation.terminalControl.currentPath,
2132
+ terminalTarget: terminalConversation.terminalControl.target
2133
+ });
2134
+ if (terminalConversation.agent !== "codex") {
2135
+ const adapter = createRuntimeTerminalAgentRegistry(options).require(terminalConversation.agent);
2136
+ printJson({
2137
+ conversation_id: conversationId,
2138
+ source: "terminal_control",
2139
+ agent: terminalConversation.agent,
2140
+ confidence: terminalStatus.reachable ? "medium" : "low",
2141
+ about: terminalStatus.reachable
2142
+ ? `${adapter.displayName} is attached through ${terminalConversation.terminalControl.kind}:${terminalConversation.terminalControl.target}.`
2143
+ : `${adapter.displayName} terminal status is unavailable.`,
2144
+ evidence: {
2145
+ terminal_status: terminalStatus,
2146
+ terminal_screen: terminalStatus.screen
2147
+ },
2148
+ limitations: ["Historical session context is not available for this terminal adapter."],
2149
+ terminal_control: terminalConversation.terminalControl
2150
+ });
2151
+ return;
2152
+ }
2153
+ const process = await activeCodexProcessForPid(options, terminalConversation.pid);
1856
2154
  printJson(await describeNativeCodexSession({
1857
2155
  id: conversationId,
1858
2156
  source: "terminal_control",
@@ -1877,7 +2175,9 @@ async function runDescribe(options) {
1877
2175
  const { conversation, statePath, logPath } = loadConversationFromOptions(options);
1878
2176
  const events = readExistingEvents(logPath);
1879
2177
  const terminalControl = terminalControlFromTakeover(isRecord(conversation.native_session_takeover) ? conversation.native_session_takeover : undefined);
1880
- const terminalStatus = terminalControl ? await terminalStatusForControl(terminalControl, options) : undefined;
2178
+ const terminalStatus = terminalControl
2179
+ ? await terminalStatusForControl(executorForConversation(conversation).kind, terminalControl, options, terminalRuntimeIdentityForConversation(conversation, terminalControl))
2180
+ : undefined;
1881
2181
  printJson({
1882
2182
  conversation_id: conversation.conversation_id,
1883
2183
  source: "akk_managed",
@@ -1895,110 +2195,93 @@ async function runDescribe(options) {
1895
2195
  event_log_path: logPath
1896
2196
  });
1897
2197
  }
1898
- async function terminalStatusForControl(terminalControl, options) {
1899
- try {
1900
- const screen = await createTerminalControlProvider(options).capture(terminalControl.target, {
1901
- scrollbackLines: Number(options.scrollbackLines ?? 120),
1902
- socketPath: terminalControl.socketPath
1903
- });
1904
- const approval = detectCodexApprovalPrompt(screen);
1905
- const blocked = isCodexApprovalPromptVisible(screen);
1906
- const activity = detectCodexActivityState(screen, approval);
1907
- return {
1908
- provider: terminalControl.kind,
1909
- target: terminalControl.target,
1910
- reachable: true,
1911
- activity_state: activity.state,
1912
- activity_reason: activity.reason,
1913
- approval_state: {
1914
- scanned: true,
1915
- blocked,
1916
- approvable: approval.approvable,
1917
- key: approval.approvable ? approval.key : undefined,
1918
- label: approval.approvable ? approval.label : undefined,
1919
- prompt_kind: approval.promptKind,
1920
- command: approval.command,
1921
- reason: approval.approvable ? undefined : approval.reason
1922
- },
1923
- screen: {
1924
- excerpt: screenExcerpt(screen),
1925
- approval
1926
- }
1927
- };
1928
- }
1929
- catch (error) {
1930
- return {
1931
- provider: terminalControl.kind,
1932
- target: terminalControl.target,
1933
- reachable: false,
1934
- activity_state: "unknown",
1935
- activity_reason: error instanceof Error ? error.message : String(error),
1936
- approval_state: {
1937
- scanned: false,
1938
- blocked: false,
1939
- approvable: false
1940
- },
1941
- screen: {
1942
- error: error instanceof Error ? error.message : String(error)
1943
- }
1944
- };
1945
- }
2198
+ async function terminalStatusForControl(agent, terminalControl, options, runtime) {
2199
+ return createTerminalAgentBridge(options).status(agent, terminalControl, {
2200
+ scrollbackLines: Number(options.scrollbackLines ?? 120),
2201
+ runtime
2202
+ });
1946
2203
  }
1947
2204
  function terminalBridgeApprovalFingerprint({ terminalControl, terminalStatus }) {
1948
2205
  const approval = isRecord(terminalStatus?.approval_state) ? terminalStatus.approval_state : {};
2206
+ const adapterFingerprint = stringValue(approval.fingerprint);
2207
+ if (adapterFingerprint) {
2208
+ return adapterFingerprint;
2209
+ }
1949
2210
  const screen = isRecord(terminalStatus?.screen) ? terminalStatus.screen : {};
1950
2211
  return createHash("sha256")
1951
2212
  .update(JSON.stringify({
1952
2213
  target: terminalControl.target,
1953
- key: approval.key,
2214
+ keys: approval.keys ?? (approval.key ? [approval.key] : undefined),
1954
2215
  label: approval.label,
1955
2216
  prompt_kind: approval.prompt_kind,
1956
2217
  command: approval.command,
2218
+ tool_name: approval.tool_name,
2219
+ request_detail: approval.request_detail,
1957
2220
  excerpt: screen.excerpt
1958
2221
  }))
1959
- .digest("hex")
1960
- .slice(0, 16);
2222
+ .digest("hex");
1961
2223
  }
1962
- function terminalBridgeApprovalFingerprintForScreen({ terminalControl, screen, approval }) {
1963
- return terminalBridgeApprovalFingerprint({
1964
- terminalControl,
1965
- terminalStatus: {
1966
- approval_state: {
1967
- key: approval.key,
1968
- label: approval.label,
1969
- prompt_kind: approval.promptKind,
1970
- command: approval.command
1971
- },
1972
- screen: {
1973
- excerpt: screenExcerpt(screen)
1974
- }
1975
- }
1976
- });
2224
+ function assertSafeClaudeTerminalSend(terminalStatus) {
2225
+ const approval = isRecord(terminalStatus?.approval_state)
2226
+ ? terminalStatus.approval_state
2227
+ : undefined;
2228
+ if (terminalStatus?.reachable !== true) {
2229
+ throw new Error("Claude Code terminal status is unavailable");
2230
+ }
2231
+ if (approval?.blocked === true) {
2232
+ throw new Error(stringValue(approval.reason) ?? "Claude Code is waiting at a permission dialog");
2233
+ }
2234
+ if (terminalStatus.activity_state !== "idle") {
2235
+ throw new Error(`Claude Code terminal is ${stringValue(terminalStatus.activity_state) ?? "unknown"}, not idle`);
2236
+ }
1977
2237
  }
1978
2238
  function terminalBridgeApprovalInstructions({ conversation, terminalControl, terminalStatus }) {
1979
2239
  const approval = isRecord(terminalStatus?.approval_state) ? terminalStatus.approval_state : {};
1980
2240
  const screen = isRecord(terminalStatus?.screen) ? terminalStatus.screen : {};
1981
- const label = stringValue(approval.label) || "the current Codex approval prompt";
1982
- const key = stringValue(approval.key) || "the detected approve key";
2241
+ const executor = executorForConversation(conversation);
2242
+ const agentName = executorDefinitionForKind(executor.kind).displayName;
2243
+ const label = stringValue(approval.label) || `the current ${agentName} approval prompt`;
2244
+ const keys = Array.isArray(approval.keys)
2245
+ ? approval.keys.filter((value) => typeof value === "string")
2246
+ : [];
2247
+ const decisionMode = stringValue(approval.decision_mode);
2248
+ const keyDescription = decisionMode === "structured"
2249
+ ? "structured one-time Hook decision"
2250
+ : keys.length > 0
2251
+ ? keys.join(" then ")
2252
+ : stringValue(approval.key) || "the detected approve key sequence";
2253
+ const fingerprint = stringValue(approval.fingerprint);
2254
+ const promptKind = stringValue(approval.prompt_kind);
2255
+ const command = stringValue(approval.command);
2256
+ const toolName = stringValue(approval.tool_name);
2257
+ const requestDetail = stringValue(approval.request_detail);
2258
+ const requestId = stringValue(approval.request_id);
1983
2259
  const excerpt = stringValue(screen.excerpt) || "(No terminal excerpt was available.)";
1984
2260
  return [
1985
- "Codex is waiting for approval in a terminal-controlled AKK session.",
2261
+ `${agentName} is waiting for approval in a terminal-controlled AKK session.`,
1986
2262
  "",
1987
2263
  `Conversation: ${conversation.conversation_id}`,
1988
2264
  `Terminal: ${terminalControl.kind}:${terminalControl.target}`,
1989
- `Approval option: ${label} (${key})`,
2265
+ `Approval option: ${label} (${keyDescription})`,
2266
+ promptKind ? `Request kind: ${promptKind}` : undefined,
2267
+ toolName ? `Tool: ${toolName}` : undefined,
2268
+ requestDetail ? `Request: ${requestDetail}` : undefined,
2269
+ command ? `Command: ${command}` : undefined,
2270
+ requestId ? `Structured request id: ${requestId}` : undefined,
1990
2271
  "",
1991
2272
  "Safe terminal excerpt:",
1992
2273
  "```text",
1993
2274
  excerpt,
1994
2275
  "```",
1995
2276
  "",
1996
- "Ask the user whether to approve or deny this Codex request.",
2277
+ `Ask the user whether to approve or deny this ${agentName} request.`,
1997
2278
  "",
1998
2279
  "If the user approves, call `agent_knock_knock_approve` with:",
1999
2280
  `- conversation_id: ${conversation.conversation_id}`,
2281
+ `- expected_approval_fingerprint: ${fingerprint ?? "(missing; refresh status before approval)"}`,
2000
2282
  "",
2001
- "Equivalent user command: `AKK approve " + conversation.conversation_id + "`",
2283
+ "Equivalent user command: `AKK approve " + conversation.conversation_id +
2284
+ (fingerprint ? ` --expected-approval-fingerprint ${fingerprint}` : "") + "`",
2002
2285
  "",
2003
2286
  "If the user denies or wants to stop this request, call `agent_knock_knock_cancel` with:",
2004
2287
  `- conversation_id: ${conversation.conversation_id}`,
@@ -2006,7 +2289,7 @@ function terminalBridgeApprovalInstructions({ conversation, terminalControl, ter
2006
2289
  "Equivalent user command: `AKK cancel " + conversation.conversation_id + "`",
2007
2290
  "",
2008
2291
  "Do not use raw tmux, shell, or manual key presses for this approval. Do not approve without explicit user confirmation."
2009
- ].join("\n");
2292
+ ].filter((line) => line !== undefined).join("\n");
2010
2293
  }
2011
2294
  function recordTerminalBridgeApprovalNotification({ statePath, logPath, terminalControl, terminalStatus, fingerprint }) {
2012
2295
  const releaseLock = acquireFileLock(`${statePath}.lock`);
@@ -2068,6 +2351,8 @@ async function runSend(options) {
2068
2351
  const managed = createManagedTerminalConversationFromRawId({
2069
2352
  options,
2070
2353
  conversationId: terminalConversation.conversationId,
2354
+ agent: terminalConversation.agent,
2355
+ pid: terminalConversation.pid,
2071
2356
  messageBody,
2072
2357
  terminalControl: terminalConversation.terminalControl
2073
2358
  });
@@ -2087,6 +2372,8 @@ async function runSend(options) {
2087
2372
  await runTerminalConversationSend({
2088
2373
  options,
2089
2374
  conversationId: terminalConversation.conversationId,
2375
+ agent: terminalConversation.agent,
2376
+ pid: terminalConversation.pid,
2090
2377
  messageBody,
2091
2378
  terminalControl: terminalConversation.terminalControl
2092
2379
  });
@@ -2450,7 +2737,9 @@ async function runApprove(options) {
2450
2737
  await runTerminalConversationApprove({
2451
2738
  options,
2452
2739
  conversationId: terminalConversation.conversationId,
2453
- terminalControl: terminalConversation.terminalControl
2740
+ agent: terminalConversation.agent,
2741
+ terminalControl: terminalConversation.terminalControl,
2742
+ pid: terminalConversation.pid
2454
2743
  });
2455
2744
  return;
2456
2745
  }
@@ -2462,36 +2751,32 @@ async function runApprove(options) {
2462
2751
  if (!terminalControl) {
2463
2752
  throw new Error(`conversation ${conversation.conversation_id} is not controlled through a terminal`);
2464
2753
  }
2465
- const provider = createTerminalControlProvider(options);
2466
- const screen = await provider.capture(terminalControl.target, {
2467
- scrollbackLines: Number(options.scrollbackLines ?? 120),
2468
- socketPath: terminalControl.socketPath
2469
- });
2470
- const approval = detectCodexApprovalPrompt(screen);
2471
- if (!approval.approvable) {
2472
- printJson({
2473
- conversation,
2474
- approved: false,
2475
- blocked: true,
2476
- reason: approval.reason,
2477
- terminal_control: terminalControl,
2478
- screen_excerpt: screenExcerpt(screen)
2479
- });
2480
- return;
2481
- }
2482
- const expectedFingerprint = stringValue(options.expectedApprovalFingerprint);
2483
- const actualFingerprint = terminalBridgeApprovalFingerprintForScreen({ terminalControl, screen, approval });
2754
+ const executor = executorForConversation(conversation);
2755
+ const monitoredApproval = isRecord(nativeTakeover?.["terminal_bridge_approval"])
2756
+ ? nativeTakeover.terminal_bridge_approval
2757
+ : undefined;
2758
+ const expectedFingerprint = stringValue(options.expectedApprovalFingerprint) ??
2759
+ stringValue(monitoredApproval?.fingerprint);
2484
2760
  const autoApproved = options.autoApproved === true;
2485
2761
  const policyRuleId = stringValue(options.policyRuleId);
2486
2762
  const policyFingerprint = stringValue(options.policyFingerprint);
2487
- if (expectedFingerprint && actualFingerprint !== expectedFingerprint) {
2763
+ const approval = await createTerminalAgentBridge(options).approve(executor.kind, terminalControl, {
2764
+ expectedFingerprint,
2765
+ scrollbackLines: Number(options.scrollbackLines ?? 120),
2766
+ runtime: terminalRuntimeIdentityForConversation(conversation, terminalControl),
2767
+ requiredDecisionMode: autoApproved && executor.kind === "claude"
2768
+ ? "structured"
2769
+ : undefined
2770
+ });
2771
+ const actualFingerprint = approval.fingerprint;
2772
+ if (!approval.approved) {
2488
2773
  if (autoApproved) {
2489
2774
  appendEvent(logPath, {
2490
2775
  ts: new Date().toISOString(),
2491
2776
  conversation_id: conversation.conversation_id,
2492
2777
  event: "terminal_auto_approval_decision",
2493
2778
  action: "rejected",
2494
- reason: "approval fingerprint changed before execution",
2779
+ reason: approval.reason,
2495
2780
  terminal_control: terminalControl,
2496
2781
  expected_fingerprint: expectedFingerprint,
2497
2782
  actual_fingerprint: actualFingerprint,
@@ -2502,25 +2787,25 @@ async function runApprove(options) {
2502
2787
  printJson({
2503
2788
  conversation,
2504
2789
  approved: false,
2505
- blocked: true,
2506
- reason: "approval fingerprint changed before execution",
2790
+ blocked: approval.blocked,
2791
+ reason: approval.reason,
2507
2792
  terminal_control: terminalControl,
2508
2793
  expected_approval_fingerprint: expectedFingerprint,
2509
2794
  actual_approval_fingerprint: actualFingerprint,
2510
- screen_excerpt: screenExcerpt(screen)
2795
+ screen_excerpt: approval.screenExcerpt
2511
2796
  });
2512
2797
  return;
2513
2798
  }
2514
- await provider.sendKeys(terminalControl.target, [approval.key], {
2515
- socketPath: terminalControl.socketPath
2516
- });
2517
2799
  appendEvent(logPath, {
2518
2800
  ts: new Date().toISOString(),
2519
2801
  conversation_id: conversation.conversation_id,
2520
2802
  event: "terminal_approval_send",
2521
2803
  terminal_control: terminalControl,
2522
2804
  key: approval.key,
2805
+ keys: approval.keys,
2523
2806
  label: approval.label,
2807
+ decision_mode: approval.decisionMode,
2808
+ request_id: approval.requestId,
2524
2809
  approval_fingerprint: actualFingerprint,
2525
2810
  auto_approved: autoApproved,
2526
2811
  policy_rule_id: policyRuleId,
@@ -2542,7 +2827,10 @@ async function runApprove(options) {
2542
2827
  conversation_id: conversation.conversation_id,
2543
2828
  terminal_target: terminalControl.target,
2544
2829
  key: approval.key,
2830
+ keys: approval.keys,
2545
2831
  label: approval.label,
2832
+ decision_mode: approval.decisionMode,
2833
+ request_id: approval.requestId,
2546
2834
  approval_fingerprint: actualFingerprint,
2547
2835
  auto_approved: autoApproved,
2548
2836
  policy_rule_id: policyRuleId,
@@ -2571,12 +2859,28 @@ async function runApprove(options) {
2571
2859
  terminal_bridge_hard_deadline_at: deadlineAt(stringValue(nativeTakeoverForUpdate.terminal_bridge_started_at) ?? approvalResolvedAt, agentHardTimeoutMinutes)
2572
2860
  };
2573
2861
  delete nextNativeTakeover.terminal_bridge_approval;
2574
- const nextConversation = {
2862
+ let nextConversation = {
2575
2863
  ...conversation,
2576
2864
  status: terminalBridgeEnabled(conversation) ? "waiting_for_agent" : conversation.status,
2577
2865
  native_session_takeover: nextNativeTakeover,
2578
2866
  updated_at: approvalResolvedAt
2579
2867
  };
2868
+ const approvalLeaseDeadlines = [
2869
+ stringValue(nextNativeTakeover.terminal_bridge_inactivity_deadline_at),
2870
+ stringValue(nextNativeTakeover.terminal_bridge_hard_deadline_at)
2871
+ ].filter((value) => Boolean(value));
2872
+ if (approvalLeaseDeadlines.length > 0) {
2873
+ const renewedLease = renewClaudeHookLease(nextConversation, new Date(Math.min(...approvalLeaseDeadlines.map((value) => Date.parse(value)))).toISOString());
2874
+ if (renewedLease) {
2875
+ nextConversation = {
2876
+ ...nextConversation,
2877
+ native_session_takeover: {
2878
+ ...nextNativeTakeover,
2879
+ claude_hook_lease_id: renewedLease.id
2880
+ }
2881
+ };
2882
+ }
2883
+ }
2580
2884
  saveState(statePath, nextConversation);
2581
2885
  const bridgeMonitor = startTerminalBridgeMonitorForConversation({
2582
2886
  conversation: nextConversation,
@@ -2607,40 +2911,47 @@ async function runApprove(options) {
2607
2911
  approved: true,
2608
2912
  terminal_control: terminalControl,
2609
2913
  key: approval.key,
2914
+ keys: approval.keys,
2610
2915
  label: approval.label,
2916
+ decision_mode: approval.decisionMode,
2917
+ request_id: approval.requestId,
2611
2918
  approval_fingerprint: actualFingerprint,
2612
2919
  auto_approved: autoApproved,
2613
2920
  policy_rule_id: policyRuleId,
2614
2921
  monitor_pid: bridgeMonitor?.pid ?? null
2615
2922
  });
2616
2923
  }
2617
- async function runTerminalConversationApprove({ options, conversationId, terminalControl }) {
2618
- const provider = createTerminalControlProvider(options);
2619
- const screen = await provider.capture(terminalControl.target, {
2924
+ async function runTerminalConversationApprove({ options, conversationId, agent, terminalControl, pid }) {
2925
+ const approval = await createTerminalAgentBridge(options).approve(agent, terminalControl, {
2926
+ expectedFingerprint: stringValue(options.expectedApprovalFingerprint),
2620
2927
  scrollbackLines: Number(options.scrollbackLines ?? 120),
2621
- socketPath: terminalControl.socketPath
2928
+ runtime: {
2929
+ pid,
2930
+ cwd: terminalControl.currentPath,
2931
+ terminalTarget: terminalControl.target
2932
+ }
2622
2933
  });
2623
- const approval = detectCodexApprovalPrompt(screen);
2624
- if (!approval.approvable) {
2934
+ if (!approval.approved) {
2625
2935
  printJson({
2626
2936
  conversation_id: conversationId,
2627
2937
  source: "terminal_control",
2628
2938
  approved: false,
2629
- blocked: true,
2939
+ blocked: approval.blocked,
2630
2940
  reason: approval.reason,
2631
2941
  terminal_control: terminalControl,
2632
- screen_excerpt: screenExcerpt(screen)
2942
+ screen_excerpt: approval.screenExcerpt
2633
2943
  });
2634
2944
  return;
2635
2945
  }
2636
- await provider.sendKeys(terminalControl.target, [approval.key], {
2637
- socketPath: terminalControl.socketPath
2638
- });
2639
2946
  runtimeLog("info", "terminal_approval_send", {
2640
2947
  conversation_id: conversationId,
2948
+ agent,
2641
2949
  terminal_target: terminalControl.target,
2642
2950
  key: approval.key,
2643
- label: approval.label
2951
+ keys: approval.keys,
2952
+ label: approval.label,
2953
+ decision_mode: approval.decisionMode,
2954
+ request_id: approval.requestId
2644
2955
  });
2645
2956
  printJson({
2646
2957
  conversation_id: conversationId,
@@ -2648,20 +2959,31 @@ async function runTerminalConversationApprove({ options, conversationId, termina
2648
2959
  approved: true,
2649
2960
  terminal_control: terminalControl,
2650
2961
  key: approval.key,
2651
- label: approval.label
2962
+ keys: approval.keys,
2963
+ label: approval.label,
2964
+ approval_fingerprint: approval.fingerprint,
2965
+ decision_mode: approval.decisionMode,
2966
+ request_id: approval.requestId
2652
2967
  });
2653
2968
  }
2654
- async function runTerminalConversationSend({ options, conversationId, messageBody, terminalControl }) {
2655
- const provider = createTerminalControlProvider(options);
2969
+ async function runTerminalConversationSend({ options, conversationId, agent, pid, messageBody, terminalControl }) {
2656
2970
  const terminalPayload = terminalSubmissionPayload(String(messageBody));
2657
- await provider.sendText(terminalControl.target, terminalPayload, {
2658
- socketPath: terminalControl.socketPath
2659
- });
2660
- await provider.sendKeys(terminalControl.target, ["C-m"], {
2661
- socketPath: terminalControl.socketPath
2662
- });
2971
+ const terminalBridge = createTerminalAgentBridge(options);
2972
+ if (agent === "claude") {
2973
+ const status = await terminalBridge.status(agent, terminalControl, {
2974
+ scrollbackLines: Number(options.scrollbackLines ?? 120),
2975
+ runtime: {
2976
+ pid,
2977
+ cwd: terminalControl.currentPath,
2978
+ terminalTarget: terminalControl.target
2979
+ }
2980
+ });
2981
+ assertSafeClaudeTerminalSend(status);
2982
+ }
2983
+ await terminalBridge.send(agent, terminalControl, terminalPayload);
2663
2984
  runtimeLog("info", "terminal_message_send", {
2664
2985
  conversation_id: conversationId,
2986
+ agent,
2665
2987
  terminal_target: terminalControl.target,
2666
2988
  message: textSummary(messageBody)
2667
2989
  });
@@ -2705,7 +3027,7 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
2705
3027
  releaseTerminalLock();
2706
3028
  }
2707
3029
  }
2708
- const provider = createTerminalControlProvider(options);
3030
+ const terminalBridge = createTerminalAgentBridge(options);
2709
3031
  const bridgeStartedAt = new Date().toISOString();
2710
3032
  const agentTimeoutMinutes = Number(options.agentTimeoutMinutes ?? DEFAULT_AGENT_TIMEOUT_MINUTES);
2711
3033
  const agentHardTimeoutMinutes = positiveMinutes(options.agentHardTimeoutMinutes ?? DEFAULT_AGENT_HARD_TIMEOUT_MINUTES, "--agent-hard-timeout-minutes");
@@ -2719,25 +3041,60 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
2719
3041
  forkTakeover: undefined
2720
3042
  }))
2721
3043
  : terminalSubmissionPayload(String(message.body ?? ""));
3044
+ const preSendRuntime = {
3045
+ ...terminalRuntimeIdentityForConversation(nextConversation, terminalControl),
3046
+ messageId: message.id
3047
+ };
2722
3048
  let preSendScreenFingerprint;
2723
3049
  if (bridge) {
2724
3050
  try {
2725
- const screen = await provider.capture(terminalControl.target, {
3051
+ const status = await terminalBridge.status(executor.kind, terminalControl, {
2726
3052
  scrollbackLines: Number(options.scrollbackLines ?? 120),
2727
- socketPath: terminalControl.socketPath
3053
+ runtime: preSendRuntime
2728
3054
  });
2729
- preSendScreenFingerprint = terminalBridgeScreenFingerprint(screenExcerpt(screen));
3055
+ if (executor.kind === "claude") {
3056
+ assertSafeClaudeTerminalSend(status);
3057
+ }
3058
+ preSendScreenFingerprint = terminalBridgeScreenFingerprint(status.screen.excerpt);
2730
3059
  }
2731
- catch {
2732
- // Delivery can still succeed when capture is unavailable; prompt matching remains the fallback boundary.
3060
+ catch (error) {
3061
+ if (executor.kind === "claude") {
3062
+ throw new Error(`refusing to send to Claude Code without a verified idle terminal: ${error instanceof Error ? error.message : String(error)}`);
3063
+ }
3064
+ // Codex delivery can still use its established fallback when capture is unavailable.
2733
3065
  }
2734
3066
  }
2735
- await provider.sendText(terminalControl.target, terminalPayload, {
2736
- socketPath: terminalControl.socketPath
2737
- });
2738
- await provider.sendKeys(terminalControl.target, ["C-m"], {
2739
- socketPath: terminalControl.socketPath
2740
- });
3067
+ if (bridge && executor.kind === "claude") {
3068
+ releaseClaudeHookLeasesForTerminal({
3069
+ storeDir: storeDirFromOptions(options),
3070
+ terminalControl,
3071
+ replacementConversationId: conversation.conversation_id
3072
+ });
3073
+ releaseClaudeHookLease(nextConversation);
3074
+ }
3075
+ const claudeHookLeaseState = bridge && executor.kind === "claude"
3076
+ ? activateClaudeHookLease({
3077
+ options,
3078
+ conversation: nextConversation,
3079
+ message,
3080
+ terminalControl,
3081
+ expiresAt: deadlineAt(bridgeStartedAt, agentTimeoutMinutes > 0
3082
+ ? Math.min(agentTimeoutMinutes, agentHardTimeoutMinutes)
3083
+ : agentHardTimeoutMinutes)
3084
+ })
3085
+ : undefined;
3086
+ const conversationWithHookLease = claudeHookLeaseState
3087
+ ? withClaudeHookLeaseState(nextConversation, claudeHookLeaseState)
3088
+ : nextConversation;
3089
+ try {
3090
+ await terminalBridge.send(executor.kind, terminalControl, terminalPayload);
3091
+ }
3092
+ catch (error) {
3093
+ if (claudeHookLeaseState) {
3094
+ releaseClaudeHookLease(conversationWithHookLease);
3095
+ }
3096
+ throw error;
3097
+ }
2741
3098
  const supersededConversationIds = bridge
2742
3099
  ? supersedeTerminalBridgeConversations({
2743
3100
  storeDir: storeDirFromOptions(options),
@@ -2765,7 +3122,7 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
2765
3122
  });
2766
3123
  const bridgeConversation = bridge
2767
3124
  ? withTerminalBridgeState({
2768
- conversation: nextConversation,
3125
+ conversation: conversationWithHookLease,
2769
3126
  message,
2770
3127
  startedAt: bridgeStartedAt,
2771
3128
  agentTimeoutMinutes,
@@ -2827,12 +3184,12 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
2827
3184
  function terminalSubmissionPayload(payload) {
2828
3185
  return payload.replace(/[\r\n]+$/u, "");
2829
3186
  }
2830
- function createManagedTerminalConversationFromRawId({ options, conversationId, messageBody, terminalControl }) {
3187
+ function createManagedTerminalConversationFromRawId({ options, conversationId, agent, pid, messageBody, terminalControl }) {
2831
3188
  const workspace = terminalControl.currentPath ?? process.cwd();
2832
3189
  const storeDir = expandHome(options.storeDir ?? options.logDir ?? defaultStoreDir(workspace));
2833
3190
  cleanupIdleConversations(storeDir, options);
2834
3191
  const executor = resolveExecutor({
2835
- kind: "codex",
3192
+ kind: agent,
2836
3193
  session: conversationId
2837
3194
  });
2838
3195
  const now = new Date();
@@ -2858,6 +3215,9 @@ function createManagedTerminalConversationFromRawId({ options, conversationId, m
2858
3215
  gatewaySession: options.gatewaySession,
2859
3216
  openclawBin: options.openclawBin ?? resolveOptionalExecutable("openclaw")
2860
3217
  });
3218
+ const claudeAgent = agent === "claude"
3219
+ ? loadClaudeAgentRows(options).find((row) => row.pid === pid)
3220
+ : undefined;
2861
3221
  const attachedConversation = withStoragePaths({
2862
3222
  ...conversation,
2863
3223
  executor,
@@ -2870,12 +3230,14 @@ function createManagedTerminalConversationFromRawId({ options, conversationId, m
2870
3230
  gateway_session: options.gatewaySession ?? options.openclawSession ?? "agent:main:main",
2871
3231
  openclaw_bin: options.openclawBin ?? resolveOptionalExecutable("openclaw"),
2872
3232
  executor_all_proxy: proxyForExecutor(executor, options),
2873
- executor_model: options.model ?? options.codexModel,
3233
+ executor_model: options.model ?? (agent === "codex" ? options.codexModel : undefined),
2874
3234
  native_session_takeover: {
2875
- agent: "codex",
3235
+ agent,
2876
3236
  native_session_id: conversationId,
3237
+ terminal_agent_pid: pid,
3238
+ terminal_agent_session_id: claudeAgent?.sessionId,
2877
3239
  source_cwd: workspace,
2878
- source_title: `Terminal-controlled Codex ${terminalControl.target}`,
3240
+ source_title: `Terminal-controlled ${executor.display_name} ${terminalControl.target}`,
2879
3241
  strategy: "terminal_control",
2880
3242
  attached_at: now.toISOString(),
2881
3243
  takeover_match_kind: "raw_terminal_send",
@@ -2890,7 +3252,7 @@ function createManagedTerminalConversationFromRawId({ options, conversationId, m
2890
3252
  conversation_id: attachedConversation.conversation_id,
2891
3253
  event: "raw_terminal_session_attached",
2892
3254
  source_conversation_id: conversationId,
2893
- agent: "codex",
3255
+ agent,
2894
3256
  terminal_control: terminalControl,
2895
3257
  executor
2896
3258
  });
@@ -2978,14 +3340,16 @@ function supersedeTerminalBridgeConversations({ storeDir, terminalControl, repla
2978
3340
  continue;
2979
3341
  }
2980
3342
  const now = new Date().toISOString();
2981
- saveState(candidateStatePath, {
3343
+ const closedConversation = {
2982
3344
  ...current,
2983
3345
  status: "closed",
2984
3346
  closed_at: now,
2985
3347
  close_reason: "terminal bridge superseded by a newer task on the same terminal",
2986
3348
  superseded_by_conversation_id: replacementConversationId,
2987
3349
  updated_at: now
2988
- });
3350
+ };
3351
+ saveState(candidateStatePath, closedConversation);
3352
+ releaseClaudeHookLease(closedConversation);
2989
3353
  appendEvent(logPathForStatePath(candidateStatePath), {
2990
3354
  ts: now,
2991
3355
  conversation_id: current.conversation_id,
@@ -3415,17 +3779,37 @@ async function runRenew(options) {
3415
3779
  throw new Error(`cannot renew ${conversation.conversation_id}; terminal bridge hard lifetime of ${hardTimeoutMinutes} minutes has elapsed`);
3416
3780
  }
3417
3781
  const now = new Date().toISOString();
3782
+ const currentMessageId = stringValue(nativeTakeover?.terminal_bridge_message_id);
3783
+ const hardDeadline = deadlineAt(startedAt ?? now, hardTimeoutMinutes) ??
3784
+ new Date(Date.now() + hardTimeoutMinutes * 60 * 1000).toISOString();
3785
+ const inactivityDeadline = deadlineAt(now, inactivityTimeoutMinutes) ??
3786
+ new Date(Date.now() + inactivityTimeoutMinutes * 60 * 1000).toISOString();
3787
+ const renewedLease = executorForConversation(conversation).kind === "claude" && currentMessageId
3788
+ ? activateClaudeHookLease({
3789
+ options,
3790
+ conversation,
3791
+ message: { id: currentMessageId },
3792
+ terminalControl,
3793
+ expiresAt: new Date(Math.min(Date.parse(hardDeadline), Date.parse(inactivityDeadline))).toISOString()
3794
+ })
3795
+ : undefined;
3796
+ const renewedBase = renewedLease
3797
+ ? withClaudeHookLeaseState(conversation, renewedLease)
3798
+ : conversation;
3799
+ const renewedNativeTakeover = isRecord(renewedBase.native_session_takeover)
3800
+ ? renewedBase.native_session_takeover
3801
+ : nativeTakeover;
3418
3802
  const renewed = {
3419
- ...conversation,
3803
+ ...renewedBase,
3420
3804
  status: "waiting_for_agent",
3421
3805
  native_session_takeover: {
3422
- ...nativeTakeover,
3806
+ ...renewedNativeTakeover,
3423
3807
  terminal_bridge_monitor_started_at: now,
3424
3808
  terminal_bridge_last_activity_at: now,
3425
3809
  terminal_bridge_inactivity_timeout_minutes: inactivityTimeoutMinutes,
3426
3810
  terminal_bridge_hard_timeout_minutes: hardTimeoutMinutes,
3427
- terminal_bridge_inactivity_deadline_at: deadlineAt(now, inactivityTimeoutMinutes),
3428
- terminal_bridge_hard_deadline_at: deadlineAt(startedAt ?? now, hardTimeoutMinutes),
3811
+ terminal_bridge_inactivity_deadline_at: inactivityDeadline,
3812
+ terminal_bridge_hard_deadline_at: hardDeadline,
3429
3813
  terminal_bridge_renewed_at: now
3430
3814
  },
3431
3815
  updated_at: now
@@ -3496,7 +3880,9 @@ async function runCancel(options) {
3496
3880
  await runTerminalConversationCancel({
3497
3881
  options,
3498
3882
  conversationId: terminalConversation.conversationId,
3499
- terminalControl: terminalConversation.terminalControl
3883
+ agent: terminalConversation.agent,
3884
+ terminalControl: terminalConversation.terminalControl,
3885
+ pid: terminalConversation.pid
3500
3886
  });
3501
3887
  return;
3502
3888
  }
@@ -3514,6 +3900,7 @@ async function runCancel(options) {
3514
3900
  conversation,
3515
3901
  statePath,
3516
3902
  logPath,
3903
+ agent: executorForConversation(conversation).kind,
3517
3904
  terminalControl
3518
3905
  });
3519
3906
  return;
@@ -3576,53 +3963,90 @@ async function runCancel(options) {
3576
3963
  budget: budgetAction(nextConversation)
3577
3964
  });
3578
3965
  }
3579
- async function runTerminalConversationCancel({ options, conversationId, terminalControl }) {
3580
- const provider = createTerminalControlProvider(options);
3581
- await provider.sendKeys(terminalControl.target, ["C-c"], {
3582
- socketPath: terminalControl.socketPath
3966
+ async function runTerminalConversationCancel({ options, conversationId, agent, terminalControl, pid }) {
3967
+ const cancellation = await createTerminalAgentBridge(options).cancel(agent, terminalControl, {
3968
+ runtime: {
3969
+ pid,
3970
+ cwd: terminalControl.currentPath,
3971
+ terminalTarget: terminalControl.target
3972
+ },
3973
+ scrollbackLines: Number(options.scrollbackLines ?? 120)
3583
3974
  });
3584
3975
  runtimeLog("info", "terminal_cancel_requested", {
3585
3976
  conversation_id: conversationId,
3977
+ agent,
3586
3978
  terminal_target: terminalControl.target,
3587
- key: "C-c"
3979
+ key: cancellation.key,
3980
+ keys: cancellation.keys,
3981
+ denied_approval: cancellation.deniedApproval,
3982
+ request_id: cancellation.requestId,
3983
+ cancel_requested: cancellation.cancelRequested,
3984
+ reason: cancellation.reason
3588
3985
  });
3589
3986
  printJson({
3590
3987
  conversation_id: conversationId,
3591
3988
  source: "terminal_control",
3592
- cancel_requested: true,
3989
+ cancel_requested: cancellation.cancelRequested,
3990
+ reason: cancellation.reason,
3593
3991
  terminal_control: terminalControl,
3594
- key: "C-c"
3992
+ key: cancellation.key,
3993
+ keys: cancellation.keys,
3994
+ denied_approval: cancellation.deniedApproval,
3995
+ request_id: cancellation.requestId
3595
3996
  });
3596
3997
  }
3597
- async function runTerminalControlCancel({ options, conversation, statePath, logPath, terminalControl }) {
3598
- const provider = createTerminalControlProvider(options);
3599
- await provider.sendKeys(terminalControl.target, ["C-c"], {
3600
- socketPath: terminalControl.socketPath
3998
+ async function runTerminalControlCancel({ options, conversation, statePath, logPath, agent, terminalControl }) {
3999
+ const cancellation = await createTerminalAgentBridge(options).cancel(agent, terminalControl, {
4000
+ runtime: terminalRuntimeIdentityForConversation(conversation, terminalControl),
4001
+ scrollbackLines: Number(options.scrollbackLines ?? 120)
3601
4002
  });
4003
+ if (!cancellation.cancelRequested) {
4004
+ printJson({
4005
+ conversation,
4006
+ cancel_requested: false,
4007
+ reason: cancellation.reason,
4008
+ terminal_control: terminalControl,
4009
+ budget: budgetAction(conversation)
4010
+ });
4011
+ return;
4012
+ }
3602
4013
  const now = new Date().toISOString();
3603
4014
  appendEvent(logPath, {
3604
4015
  ts: now,
3605
4016
  conversation_id: conversation.conversation_id,
3606
4017
  event: "terminal_cancel_requested",
3607
4018
  terminal_control: terminalControl,
3608
- key: "C-c"
4019
+ key: cancellation.key,
4020
+ keys: cancellation.keys,
4021
+ denied_approval: cancellation.deniedApproval,
4022
+ request_id: cancellation.requestId
3609
4023
  });
3610
4024
  runtimeLog("info", "terminal_cancel_requested", {
3611
4025
  conversation_id: conversation.conversation_id,
4026
+ agent,
3612
4027
  terminal_target: terminalControl.target,
3613
- key: "C-c"
4028
+ key: cancellation.key,
4029
+ keys: cancellation.keys,
4030
+ denied_approval: cancellation.deniedApproval,
4031
+ request_id: cancellation.requestId
3614
4032
  });
3615
4033
  const nextConversation = {
3616
4034
  ...conversation,
4035
+ status: "cancelled",
4036
+ cancelled_at: now,
3617
4037
  terminal_cancel_requested_at: now,
3618
4038
  updated_at: now
3619
4039
  };
3620
4040
  saveState(statePath, nextConversation);
4041
+ releaseClaudeHookLease(nextConversation);
3621
4042
  printJson({
3622
4043
  conversation: nextConversation,
3623
4044
  cancel_requested: true,
3624
4045
  terminal_control: terminalControl,
3625
- key: "C-c",
4046
+ key: cancellation.key,
4047
+ keys: cancellation.keys,
4048
+ denied_approval: cancellation.deniedApproval,
4049
+ request_id: cancellation.requestId,
3626
4050
  budget: budgetAction(nextConversation)
3627
4051
  });
3628
4052
  }
@@ -3804,6 +4228,7 @@ function runClose(options) {
3804
4228
  updated_at: now
3805
4229
  };
3806
4230
  saveState(statePath, closed);
4231
+ releaseClaudeHookLease(closed);
3807
4232
  appendEvent(logPath, {
3808
4233
  ts: now,
3809
4234
  conversation_id: conversation.conversation_id,
@@ -3824,6 +4249,9 @@ function runClose(options) {
3824
4249
  });
3825
4250
  }
3826
4251
  async function runMonitor(options) {
4252
+ if (options.callbackRetry) {
4253
+ return runCallbackRetryMonitor(options);
4254
+ }
3827
4255
  if (options.terminalBridge) {
3828
4256
  return await runTerminalBridgeMonitor(options);
3829
4257
  }
@@ -3955,6 +4383,72 @@ async function runMonitor(options) {
3955
4383
  sleepSync(pollIntervalMs);
3956
4384
  }
3957
4385
  }
4386
+ function startCallbackRetryMonitor({ statePath }) {
4387
+ const child = spawn(process.execPath, [
4388
+ new URL(import.meta.url).pathname,
4389
+ "monitor",
4390
+ "--callback-retry",
4391
+ "--state",
4392
+ statePath
4393
+ ], {
4394
+ detached: true,
4395
+ stdio: "ignore",
4396
+ cwd: process.cwd(),
4397
+ env: process.env
4398
+ });
4399
+ child.unref();
4400
+ return child;
4401
+ }
4402
+ function runCallbackRetryMonitor(options) {
4403
+ const statePath = expandHome(required(options.state, "--state is required"));
4404
+ while (true) {
4405
+ const conversation = loadState(statePath);
4406
+ const callbackDelivery = isRecord(conversation.callback_delivery)
4407
+ ? conversation.callback_delivery
4408
+ : undefined;
4409
+ const attempts = Number(callbackDelivery?.attempts ?? 0);
4410
+ if (conversation.status !== "callback_failed" || !isRecord(callbackDelivery?.message)) {
4411
+ return;
4412
+ }
4413
+ if (attempts > CALLBACK_RETRY_DELAYS_MS.length) {
4414
+ return;
4415
+ }
4416
+ const delayMs = CALLBACK_RETRY_DELAYS_MS[Math.max(0, attempts - 1)];
4417
+ sleepSync(delayMs);
4418
+ const releaseLock = acquireFileLock(`${statePath}.lock`);
4419
+ try {
4420
+ const current = loadState(statePath);
4421
+ const currentDelivery = isRecord(current.callback_delivery)
4422
+ ? current.callback_delivery
4423
+ : undefined;
4424
+ if (current.status !== "callback_failed" || !isRecord(currentDelivery?.message)) {
4425
+ return;
4426
+ }
4427
+ try {
4428
+ runLockedCallback({
4429
+ statePath,
4430
+ messageJson: JSON.stringify(currentDelivery.message),
4431
+ gatewayMethod: stringValue(currentDelivery.gateway_method) ?? current.gateway_method,
4432
+ gatewaySession: stringValue(currentDelivery.gateway_session) ?? current.gateway_session,
4433
+ openclawSession: current.openclaw_session,
4434
+ openclawBin: stringValue(currentDelivery.openclaw_bin) ?? current.openclaw_bin,
4435
+ gatewayUrl: stringValue(currentDelivery.gateway_url) ?? current.gateway_url,
4436
+ token: current.gateway_token,
4437
+ closeTerminalBridgeOnDone: currentDelivery.close_terminal_bridge_on_done === true,
4438
+ retryPending: true,
4439
+ disableCallbackRetry: true
4440
+ });
4441
+ return;
4442
+ }
4443
+ catch {
4444
+ // The failed attempt is persisted by runLockedCallback; continue with bounded backoff.
4445
+ }
4446
+ }
4447
+ finally {
4448
+ releaseLock();
4449
+ }
4450
+ }
4451
+ }
3958
4452
  async function runTerminalBridgeMonitor(options) {
3959
4453
  const statePath = expandHome(required(options.state, "--state is required"));
3960
4454
  const logPath = expandHome(options.log ?? logPathForStatePath(statePath));
@@ -3977,9 +4471,10 @@ async function runTerminalBridgeMonitor(options) {
3977
4471
  const activityPersistIntervalMs = terminalBridgeActivityPersistIntervalMs(timeoutMinutes, pollIntervalMs);
3978
4472
  const preSendScreenFingerprint = stringValue(initialNativeTakeover?.["terminal_bridge_pre_send_screen_fingerprint"]);
3979
4473
  let previousScreenFingerprint = preSendScreenFingerprint;
3980
- let previousRolloutFingerprint;
4474
+ let previousDurableFingerprint;
3981
4475
  let persistedActivityReason = stringValue(initialNativeTakeover?.["terminal_bridge_last_activity_reason"]);
3982
4476
  const executor = executorForConversation(conversation);
4477
+ const terminalBridge = createTerminalAgentBridge(options);
3983
4478
  appendEvent(logPath, {
3984
4479
  ts: new Date().toISOString(),
3985
4480
  conversation_id: conversation.conversation_id,
@@ -4062,8 +4557,126 @@ async function runTerminalBridgeMonitor(options) {
4062
4557
  });
4063
4558
  return;
4064
4559
  }
4065
- const terminalStatus = await terminalStatusForControl(terminalControl, options);
4560
+ const requestText = String(nativeTakeover?.["terminal_bridge_request_text"] ?? conversation.user_request ?? "");
4561
+ const startedAt = stringValue(nativeTakeover?.["terminal_bridge_started_at"]);
4562
+ const terminalRuntime = terminalRuntimeIdentityForConversation(conversation, terminalControl);
4563
+ const screenChangedSinceSend = preSendScreenFingerprint !== undefined &&
4564
+ previousScreenFingerprint !== undefined &&
4565
+ previousScreenFingerprint !== preSendScreenFingerprint;
4566
+ const poll = await terminalBridge.monitorPoll({
4567
+ agent: executor.kind,
4568
+ terminalControl,
4569
+ screenOptions: {
4570
+ scrollbackLines: Number(options.scrollbackLines ?? 120),
4571
+ requestText,
4572
+ screenChangedSinceSend,
4573
+ runtime: terminalRuntime
4574
+ },
4575
+ durableRequest: {
4576
+ sessionId: terminalRuntime.sessionId,
4577
+ cwd: stringValue(nativeTakeover?.["source_cwd"]),
4578
+ requestText,
4579
+ requestHash: stringValue(nativeTakeover?.["terminal_bridge_request_hash"]),
4580
+ startedAt,
4581
+ context: {
4582
+ conversation,
4583
+ nativeTakeover,
4584
+ ...terminalRuntime
4585
+ }
4586
+ }
4587
+ });
4588
+ const terminalStatus = poll.status;
4066
4589
  const approval = terminalStatus.approval_state;
4590
+ if (isRecord(approval) && approval.blocked === true && approval.approvable !== true) {
4591
+ const approvalReason = stringValue(approval.reason) ??
4592
+ "Claude Code permission state cannot be safely resolved through AKK";
4593
+ appendEvent(logPath, {
4594
+ ts: new Date().toISOString(),
4595
+ conversation_id: conversation.conversation_id,
4596
+ event: "terminal_bridge_approval_not_approvable",
4597
+ terminal_control: terminalControl,
4598
+ activity_state: terminalStatus.activity_state,
4599
+ reason: approvalReason
4600
+ });
4601
+ if (/waiting for hook consumption/iu.test(approvalReason)) {
4602
+ sleepSync(pollIntervalMs);
4603
+ continue;
4604
+ }
4605
+ const fingerprint = terminalBridgeApprovalFingerprint({ terminalControl, terminalStatus });
4606
+ const notification = recordTerminalBridgeApprovalNotification({
4607
+ statePath,
4608
+ logPath,
4609
+ terminalControl,
4610
+ terminalStatus,
4611
+ fingerprint
4612
+ });
4613
+ if (notification.duplicate) {
4614
+ printJson({
4615
+ conversation: notification.conversation,
4616
+ monitored: true,
4617
+ terminal_bridge: true,
4618
+ awaiting_approval: true,
4619
+ approvable: false,
4620
+ duplicate: true,
4621
+ reason: approvalReason,
4622
+ terminal_control: terminalControl,
4623
+ terminal_status: terminalStatus
4624
+ });
4625
+ return;
4626
+ }
4627
+ const callbackMessage = createMessage({
4628
+ conversation: notification.conversation,
4629
+ from: executor.actor,
4630
+ to: "openclaw",
4631
+ type: "blocked",
4632
+ requiresResponse: true,
4633
+ body: [
4634
+ `${executor.display_name} is waiting at a permission state that AKK cannot safely approve.`,
4635
+ approvalReason,
4636
+ "",
4637
+ `Conversation: ${notification.conversation.conversation_id}`,
4638
+ `Terminal: ${terminalControl.target}`,
4639
+ "Review and resolve this dialog in the terminal manually. AKK intentionally sends no key when the request identity cannot be revalidated."
4640
+ ].join("\n"),
4641
+ metadata: {
4642
+ source: "terminal_bridge",
4643
+ reason: "approval_not_approvable",
4644
+ terminal_control: terminalControl,
4645
+ terminal_status: terminalStatus,
4646
+ approval_fingerprint: fingerprint
4647
+ }
4648
+ });
4649
+ if (notification.conversation.gateway_method) {
4650
+ runLockedCallback({
4651
+ ...options,
4652
+ statePath,
4653
+ log: logPath,
4654
+ messageJson: JSON.stringify(callbackMessage),
4655
+ gatewayMethod: notification.conversation.gateway_method,
4656
+ gatewaySession: notification.conversation.gateway_session,
4657
+ openclawSession: notification.conversation.openclaw_session,
4658
+ openclawBin: notification.conversation.openclaw_bin,
4659
+ gatewayUrl: stringValue(notification.conversation.gateway_token)
4660
+ ? notification.conversation.gateway_url
4661
+ : undefined,
4662
+ token: stringValue(notification.conversation.gateway_token)
4663
+ });
4664
+ return;
4665
+ }
4666
+ printJson({
4667
+ conversation: notification.conversation,
4668
+ monitored: true,
4669
+ terminal_bridge: true,
4670
+ awaiting_approval: true,
4671
+ approvable: false,
4672
+ delivered: false,
4673
+ message: callbackMessage,
4674
+ reason: "gateway_method_missing",
4675
+ terminal_control: terminalControl,
4676
+ terminal_status: terminalStatus
4677
+ });
4678
+ return;
4679
+ }
4067
4680
  if (isRecord(approval) && approval.blocked === true) {
4068
4681
  const fingerprint = terminalBridgeApprovalFingerprint({ terminalControl, terminalStatus });
4069
4682
  appendEvent(logPath, {
@@ -4117,7 +4730,7 @@ async function runTerminalBridgeMonitor(options) {
4117
4730
  terminalStatus,
4118
4731
  fingerprint
4119
4732
  }),
4120
- approve_command: `AKK approve ${notification.conversation.conversation_id}`,
4733
+ approve_command: `AKK approve ${notification.conversation.conversation_id} --expected-approval-fingerprint ${fingerprint}`,
4121
4734
  deny_command: `AKK cancel ${notification.conversation.conversation_id}`,
4122
4735
  approve_tool: "agent_knock_knock_approve",
4123
4736
  deny_tool: "agent_knock_knock_cancel"
@@ -4155,40 +4768,22 @@ async function runTerminalBridgeMonitor(options) {
4155
4768
  const screenChanged = previousScreenFingerprint !== undefined &&
4156
4769
  screenFingerprint !== undefined &&
4157
4770
  screenFingerprint !== previousScreenFingerprint;
4158
- const screenChangedSinceSend = preSendScreenFingerprint !== undefined &&
4159
- screenFingerprint !== undefined &&
4160
- screenFingerprint !== preSendScreenFingerprint;
4161
4771
  previousScreenFingerprint = screenFingerprint;
4162
- const contextMatch = await terminalBridgeCodexContext({
4163
- conversation,
4164
- nativeTakeover,
4165
- options
4166
- });
4167
- const startedAt = stringValue(nativeTakeover?.["terminal_bridge_started_at"]);
4168
- const rolloutCompletion = contextMatch?.context
4169
- ? latestCompletedRolloutTurn({
4170
- context: contextMatch.context,
4171
- conversation,
4172
- nativeTakeover,
4173
- startedAt
4174
- })
4175
- : undefined;
4176
- const assistantMessage = contextMatch?.context
4177
- ? latestAssistantAfter(contextMatch.context, startedAt)
4178
- : undefined;
4179
- const rolloutFingerprint = rolloutCompletion || assistantMessage
4772
+ const durableCompletion = poll.durableCompletion;
4773
+ const durableFingerprint = durableCompletion
4180
4774
  ? terminalBridgeActivityFingerprint(JSON.stringify({
4181
- text: rolloutCompletion?.text ?? assistantMessage?.text,
4182
- timestamp: rolloutCompletion?.timestamp ?? assistantMessage?.timestamp,
4183
- turn_id: rolloutCompletion?.turnId
4775
+ text: durableCompletion.text,
4776
+ timestamp: durableCompletion.timestamp,
4777
+ id: durableCompletion.id,
4778
+ metadata: durableCompletion.metadata
4184
4779
  }))
4185
4780
  : undefined;
4186
- const rolloutChanged = rolloutFingerprint !== undefined && rolloutFingerprint !== previousRolloutFingerprint;
4187
- previousRolloutFingerprint = rolloutFingerprint;
4781
+ const durableChanged = durableFingerprint !== undefined && durableFingerprint !== previousDurableFingerprint;
4782
+ previousDurableFingerprint = durableFingerprint;
4188
4783
  const activityReasons = [
4189
4784
  terminalStatus.activity_state === "working" ? terminalStatus.activity_reason : undefined,
4190
4785
  screenChanged ? "terminal screen changed" : undefined,
4191
- rolloutChanged ? "Codex rollout assistant output changed" : undefined
4786
+ durableChanged ? "durable completion evidence changed" : undefined
4192
4787
  ].filter((value) => Boolean(value));
4193
4788
  if (activityReasons.length > 0) {
4194
4789
  const observedAtMs = Date.now();
@@ -4213,77 +4808,118 @@ async function runTerminalBridgeMonitor(options) {
4213
4808
  }
4214
4809
  }
4215
4810
  }
4216
- const terminalIdle = terminalStatus.activity_state === "idle";
4217
- const screenMessage = !rolloutCompletion && !assistantMessage && terminalIdle
4218
- ? terminalBridgeScreenMessage({
4219
- conversation,
4220
- nativeTakeover,
4221
- terminalStatus,
4222
- screenChangedSinceSend
4223
- })
4811
+ const completion = poll.completion;
4812
+ const completionMetadata = isRecord(completion?.metadata) ? completion.metadata : {};
4813
+ const completionMatch = completion
4814
+ ? stringValue(completionMetadata.match) ??
4815
+ (completion.source === "screen" ? "terminal_screen" : "durable_completion")
4224
4816
  : undefined;
4225
- const completion = rolloutCompletion ?? (terminalIdle ? assistantMessage ?? screenMessage : undefined);
4226
- const completionMatch = rolloutCompletion
4227
- ? "rollout_task_complete"
4228
- : assistantMessage
4229
- ? contextMatch?.match
4230
- : "terminal_screen";
4231
4817
  const completionFingerprint = completion
4232
4818
  ? createHash("sha256")
4233
4819
  .update(JSON.stringify({
4234
4820
  text: completion.text,
4235
4821
  timestamp: completion.timestamp,
4236
4822
  match: completionMatch,
4237
- turn_id: rolloutCompletion?.turnId,
4823
+ source: completion.source,
4824
+ id: completion.id,
4238
4825
  message_id: currentMessageId
4239
4826
  }))
4240
4827
  .digest("hex")
4241
4828
  : undefined;
4242
4829
  const completionStable = completionFingerprint !== undefined && completionFingerprint === idleCompletionFingerprint;
4243
4830
  idleCompletionFingerprint = completionFingerprint;
4244
- if (completion && completionStable) {
4245
- appendEvent(logPath, {
4246
- ts: new Date().toISOString(),
4247
- conversation_id: conversation.conversation_id,
4248
- event: "terminal_bridge_completion_detected",
4249
- terminal_control: terminalControl,
4250
- match: completionMatch,
4251
- codex_session: contextMatch?.context.source,
4252
- assistant_timestamp: completion?.timestamp,
4253
- rollout_turn_id: rolloutCompletion?.turnId,
4254
- terminal_bridge_message_id: currentMessageId
4831
+ if (completion && completionStable && completionFingerprint) {
4832
+ const completionOutcome = completion.outcome === "failure" ? "failure" : "success";
4833
+ const callbackMessageId = deterministicTerminalCallbackMessageId({
4834
+ conversationId: conversation.conversation_id,
4835
+ terminalMessageId: currentMessageId,
4836
+ completionFingerprint,
4837
+ outcome: completionOutcome
4255
4838
  });
4256
- const callbackMessage = createMessage({
4257
- conversation,
4258
- from: executor.actor,
4259
- to: "openclaw",
4260
- type: "done",
4261
- requiresResponse: false,
4262
- body: completion?.text ?? "",
4263
- metadata: {
4264
- source: "terminal_bridge",
4839
+ const claim = claimTerminalBridgeCompletion({
4840
+ statePath,
4841
+ logPath,
4842
+ terminalMessageId: currentMessageId,
4843
+ completionFingerprint,
4844
+ completionId: completion.id,
4845
+ callbackMessageId,
4846
+ outcome: completionOutcome
4847
+ });
4848
+ if (!claim.claimed) {
4849
+ printJson({
4850
+ conversation: claim.conversation,
4851
+ monitored: true,
4852
+ terminal_bridge: true,
4853
+ completed: false,
4854
+ duplicate: true,
4855
+ reason: claim.reason
4856
+ });
4857
+ return;
4858
+ }
4859
+ try {
4860
+ conversation = claim.conversation;
4861
+ appendEvent(logPath, {
4862
+ ts: new Date().toISOString(),
4863
+ conversation_id: conversation.conversation_id,
4864
+ event: "terminal_bridge_completion_detected",
4265
4865
  terminal_control: terminalControl,
4266
- codex_session: contextMatch?.context.source,
4267
- confidence: rolloutCompletion || assistantMessage ? contextMatch?.confidence : "screen_only",
4268
4866
  match: completionMatch,
4867
+ completion_source: completion.source,
4868
+ completion_outcome: completionOutcome,
4869
+ completion_id: completion.id,
4870
+ terminal_session: completionMetadata.session,
4871
+ context_match: completionMetadata.context_match,
4269
4872
  assistant_timestamp: completion?.timestamp,
4270
- rollout_turn_id: rolloutCompletion?.turnId,
4271
- terminal_bridge_message_id: currentMessageId
4272
- }
4273
- });
4274
- runLockedCallback({
4275
- ...options,
4276
- statePath,
4277
- log: logPath,
4278
- closeTerminalBridgeOnDone: true,
4279
- messageJson: JSON.stringify(callbackMessage),
4280
- gatewayMethod: conversation.gateway_method,
4281
- gatewaySession: conversation.gateway_session,
4282
- openclawSession: conversation.openclaw_session,
4283
- openclawBin: conversation.openclaw_bin,
4284
- gatewayUrl: stringValue(conversation.gateway_token) ? conversation.gateway_url : undefined,
4285
- token: stringValue(conversation.gateway_token)
4286
- });
4873
+ rollout_turn_id: completion.source === "durable" ? completion.id : undefined,
4874
+ terminal_bridge_message_id: currentMessageId,
4875
+ callback_message_id: callbackMessageId
4876
+ });
4877
+ const callbackMessage = {
4878
+ ...createMessage({
4879
+ conversation,
4880
+ from: executor.actor,
4881
+ to: "openclaw",
4882
+ type: completionOutcome === "failure" ? "error" : "done",
4883
+ requiresResponse: false,
4884
+ body: completion.text,
4885
+ metadata: {
4886
+ source: "terminal_bridge",
4887
+ terminal_control: terminalControl,
4888
+ ...completionMetadata,
4889
+ completion_source: completion.source,
4890
+ completion_outcome: completionOutcome,
4891
+ completion_id: completion.id,
4892
+ terminal_session: completionMetadata.session,
4893
+ confidence: completion.confidence,
4894
+ match: completionMatch,
4895
+ assistant_timestamp: completion?.timestamp,
4896
+ rollout_turn_id: completion.source === "durable" ? completion.id : undefined,
4897
+ terminal_bridge_message_id: currentMessageId
4898
+ }
4899
+ }),
4900
+ id: callbackMessageId
4901
+ };
4902
+ runLockedCallback({
4903
+ ...options,
4904
+ statePath,
4905
+ log: logPath,
4906
+ closeTerminalBridgeOnDone: completionOutcome === "success",
4907
+ trackCallbackDelivery: true,
4908
+ recoverTerminalCompletion: claim.resumed === true,
4909
+ preserveMessageId: true,
4910
+ messageJson: JSON.stringify(callbackMessage),
4911
+ gatewayMethod: conversation.gateway_method,
4912
+ gatewaySession: conversation.gateway_session,
4913
+ openclawSession: conversation.openclaw_session,
4914
+ openclawBin: conversation.openclaw_bin,
4915
+ gatewayUrl: stringValue(conversation.gateway_token) ? conversation.gateway_url : undefined,
4916
+ token: stringValue(conversation.gateway_token)
4917
+ });
4918
+ }
4919
+ finally {
4920
+ releaseClaudeHookLease(conversation);
4921
+ claim.release();
4922
+ }
4287
4923
  return;
4288
4924
  }
4289
4925
  // A concrete approval or completion observed on this poll wins over a timeout boundary.
@@ -4334,7 +4970,7 @@ async function runTerminalBridgeMonitor(options) {
4334
4970
  detail: {
4335
4971
  terminal_bridge: true,
4336
4972
  terminal_control: terminalControl,
4337
- match: contextMatch?.match,
4973
+ match: completionMetadata.context_match,
4338
4974
  terminal_activity_state: terminalStatus.activity_state,
4339
4975
  last_activity_at: new Date(lastActivityAtMs).toISOString(),
4340
4976
  inactivity_deadline_at: new Date(lastActivityAtMs + timeoutMinutes * 60 * 1000).toISOString(),
@@ -4387,6 +5023,111 @@ function terminalBridgeRequestFingerprint(value) {
4387
5023
  const text = String(value ?? "").replace(/\s+/gu, " ").trim();
4388
5024
  return text ? createHash("sha256").update(text).digest("hex") : undefined;
4389
5025
  }
5026
+ function deterministicTerminalCallbackMessageId({ conversationId, terminalMessageId, completionFingerprint, outcome }) {
5027
+ const digest = createHash("sha256")
5028
+ .update(JSON.stringify({
5029
+ conversation_id: conversationId,
5030
+ terminal_message_id: terminalMessageId,
5031
+ completion_fingerprint: completionFingerprint,
5032
+ outcome
5033
+ }))
5034
+ .digest("hex")
5035
+ .slice(0, 32);
5036
+ return `msg-terminal-${digest}`;
5037
+ }
5038
+ function claimTerminalBridgeCompletion({ statePath, logPath, terminalMessageId, completionFingerprint, completionId, callbackMessageId, outcome }) {
5039
+ const release = acquireFileLock(`${statePath}.lock`);
5040
+ try {
5041
+ const conversation = loadState(statePath);
5042
+ const nativeTakeover = isRecord(conversation.native_session_takeover)
5043
+ ? conversation.native_session_takeover
5044
+ : {};
5045
+ if (!isWaitingForAgent(conversation.status)) {
5046
+ release();
5047
+ return {
5048
+ claimed: false,
5049
+ conversation,
5050
+ reason: "conversation_no_longer_waiting"
5051
+ };
5052
+ }
5053
+ if (stringValue(nativeTakeover.terminal_bridge_message_id) !== terminalMessageId) {
5054
+ release();
5055
+ return {
5056
+ claimed: false,
5057
+ conversation,
5058
+ reason: "terminal_bridge_task_replaced"
5059
+ };
5060
+ }
5061
+ const existing = isRecord(nativeTakeover.terminal_bridge_completion_claim)
5062
+ ? nativeTakeover.terminal_bridge_completion_claim
5063
+ : undefined;
5064
+ if (existing) {
5065
+ if (existing.callback_message_id === callbackMessageId &&
5066
+ existing.terminal_bridge_message_id === terminalMessageId &&
5067
+ existing.completion_fingerprint === completionFingerprint &&
5068
+ existing.outcome === outcome) {
5069
+ appendEvent(logPath, {
5070
+ ts: new Date().toISOString(),
5071
+ conversation_id: conversation.conversation_id,
5072
+ event: "terminal_bridge_completion_claim_resumed",
5073
+ terminal_bridge_message_id: terminalMessageId,
5074
+ completion_fingerprint: completionFingerprint,
5075
+ callback_message_id: callbackMessageId,
5076
+ outcome
5077
+ });
5078
+ return {
5079
+ claimed: true,
5080
+ resumed: true,
5081
+ conversation,
5082
+ release
5083
+ };
5084
+ }
5085
+ release();
5086
+ return {
5087
+ claimed: false,
5088
+ conversation,
5089
+ reason: "terminal_bridge_completion_claim_conflict"
5090
+ };
5091
+ }
5092
+ const claimedAt = new Date().toISOString();
5093
+ const claimedConversation = {
5094
+ ...conversation,
5095
+ native_session_takeover: {
5096
+ ...nativeTakeover,
5097
+ terminal_bridge_completion_claim: {
5098
+ terminal_bridge_message_id: terminalMessageId,
5099
+ completion_fingerprint: completionFingerprint,
5100
+ completion_id: completionId,
5101
+ callback_message_id: callbackMessageId,
5102
+ outcome,
5103
+ claimed_at: claimedAt
5104
+ }
5105
+ },
5106
+ updated_at: claimedAt
5107
+ };
5108
+ saveState(statePath, claimedConversation);
5109
+ appendEvent(logPath, {
5110
+ ts: claimedAt,
5111
+ conversation_id: conversation.conversation_id,
5112
+ event: "terminal_bridge_completion_claimed",
5113
+ terminal_bridge_message_id: terminalMessageId,
5114
+ completion_fingerprint: completionFingerprint,
5115
+ completion_id: completionId,
5116
+ callback_message_id: callbackMessageId,
5117
+ outcome
5118
+ });
5119
+ return {
5120
+ claimed: true,
5121
+ resumed: false,
5122
+ conversation: claimedConversation,
5123
+ release
5124
+ };
5125
+ }
5126
+ catch (error) {
5127
+ release();
5128
+ throw error;
5129
+ }
5130
+ }
4390
5131
  function terminalBridgeActivityPersistIntervalMs(timeoutMinutes, pollIntervalMs) {
4391
5132
  if (!Number.isFinite(timeoutMinutes) || timeoutMinutes <= 0) {
4392
5133
  return 5 * 60 * 1000;
@@ -4415,6 +5156,13 @@ function persistTerminalBridgeActivity({ conversation, statePath, logPath, obser
4415
5156
  const inactivityDeadlineAt = Number.isFinite(timeoutMinutes) && timeoutMinutes > 0
4416
5157
  ? new Date(observedAtMs + timeoutMinutes * 60 * 1000).toISOString()
4417
5158
  : undefined;
5159
+ const hardDeadlineAt = stringValue(nativeTakeover.terminal_bridge_hard_deadline_at);
5160
+ const leaseDeadlineAt = inactivityDeadlineAt && hardDeadlineAt
5161
+ ? new Date(Math.min(Date.parse(inactivityDeadlineAt), Date.parse(hardDeadlineAt))).toISOString()
5162
+ : inactivityDeadlineAt ?? hardDeadlineAt;
5163
+ const renewedLease = leaseDeadlineAt
5164
+ ? renewClaudeHookLease(currentConversation, leaseDeadlineAt)
5165
+ : undefined;
4418
5166
  const nextConversation = {
4419
5167
  ...currentConversation,
4420
5168
  native_session_takeover: {
@@ -4423,7 +5171,8 @@ function persistTerminalBridgeActivity({ conversation, statePath, logPath, obser
4423
5171
  terminal_bridge_last_activity_reason: reason,
4424
5172
  terminal_bridge_inactivity_deadline_at: inactivityDeadlineAt,
4425
5173
  terminal_bridge_inactivity_timeout_minutes: timeoutMinutes,
4426
- terminal_bridge_hard_timeout_minutes: hardTimeoutMinutes
5174
+ terminal_bridge_hard_timeout_minutes: hardTimeoutMinutes,
5175
+ claude_hook_lease_id: renewedLease?.id ?? nativeTakeover.claude_hook_lease_id
4427
5176
  },
4428
5177
  updated_at: observedAt
4429
5178
  };
@@ -4465,12 +5214,15 @@ function terminalBridgeApprovalCandidate({ executor, terminalControl, terminalSt
4465
5214
  agent: executor.kind,
4466
5215
  kind: stringValue(approval.prompt_kind) ?? "unknown",
4467
5216
  command: stringValue(approval.command),
5217
+ tool_name: stringValue(approval.tool_name),
5218
+ request_detail: stringValue(approval.request_detail),
4468
5219
  cwd: terminalControl.currentPath,
4469
5220
  fingerprint,
4470
- terminal_target: terminalControl.target
5221
+ terminal_target: terminalControl.target,
5222
+ decision_mode: stringValue(approval.decision_mode)
4471
5223
  };
4472
5224
  }
4473
- async function terminalBridgeCodexContext({ conversation, nativeTakeover, options }) {
5225
+ async function loadCodexTerminalContext({ conversation, nativeTakeover, options }) {
4474
5226
  const provider = createAgentSessionProvider("codex", options);
4475
5227
  const nativeSessionId = stringValue(nativeTakeover?.["native_session_id"]);
4476
5228
  const startedAtMs = Date.parse(String(nativeTakeover?.["terminal_bridge_started_at"] ?? ""));
@@ -4526,146 +5278,6 @@ async function terminalBridgeCodexContext({ conversation, nativeTakeover, option
4526
5278
  confidence: sessions.length === 1 ? "medium" : "low"
4527
5279
  };
4528
5280
  }
4529
- function latestAssistantAfter(context, startedAt) {
4530
- const threshold = startedAt ? Date.parse(startedAt) : undefined;
4531
- return [...visibleRolloutMessages(context)]
4532
- .reverse()
4533
- .find((message) => {
4534
- if (message.role !== "assistant") {
4535
- return false;
4536
- }
4537
- if (!Number.isFinite(threshold)) {
4538
- return true;
4539
- }
4540
- const messageTime = message.timestamp ? Date.parse(message.timestamp) : NaN;
4541
- return Number.isFinite(messageTime) && messageTime >= Number(threshold);
4542
- });
4543
- }
4544
- function latestCompletedRolloutTurn({ context, conversation, nativeTakeover, startedAt }) {
4545
- const threshold = validTimestampMs(startedAt);
4546
- const expectedRequestHash = stringValue(nativeTakeover?.["terminal_bridge_request_hash"]) ??
4547
- terminalBridgeRequestFingerprint(nativeTakeover?.["terminal_bridge_request_text"] ?? conversation.user_request);
4548
- if (threshold === undefined || !expectedRequestHash) {
4549
- return undefined;
4550
- }
4551
- const turn = [...(context.turns ?? [])]
4552
- .reverse()
4553
- .find((candidate) => {
4554
- const userTimestamp = validTimestampMs(candidate.userTimestamp);
4555
- const completedAt = validTimestampMs(candidate.completedAt);
4556
- return candidate.userTextHash === expectedRequestHash &&
4557
- userTimestamp !== undefined &&
4558
- completedAt !== undefined &&
4559
- userTimestamp >= threshold &&
4560
- completedAt >= userTimestamp &&
4561
- Boolean(candidate.lastAssistantMessage);
4562
- });
4563
- if (!turn?.lastAssistantMessage) {
4564
- return undefined;
4565
- }
4566
- return {
4567
- role: "assistant",
4568
- text: turn.lastAssistantMessage,
4569
- timestamp: turn.completedAt,
4570
- turnId: turn.turnId,
4571
- userTimestamp: turn.userTimestamp
4572
- };
4573
- }
4574
- function terminalBridgeScreenMessage({ conversation, nativeTakeover, terminalStatus, screenChangedSinceSend }) {
4575
- const excerpt = stringValue(terminalStatus?.screen?.excerpt);
4576
- if (!excerpt) {
4577
- return undefined;
4578
- }
4579
- const request = String(nativeTakeover?.["terminal_bridge_request_text"] ?? conversation.user_request ?? "").trim();
4580
- const promptEnd = request ? whitespaceInsensitiveMatchEnd(excerpt, request) : undefined;
4581
- const afterPrompt = promptEnd === undefined ? undefined : excerpt.slice(promptEnd);
4582
- const completionBoundary = afterPrompt === undefined
4583
- ? undefined
4584
- : terminalBridgeCompletionBoundary(afterPrompt);
4585
- const completionText = afterPrompt === undefined
4586
- ? screenChangedSinceSend
4587
- ? terminalBridgeLatestCompletedSegment(excerpt)
4588
- : undefined
4589
- : completionBoundary === undefined
4590
- ? afterPrompt
4591
- : afterPrompt.slice(0, completionBoundary);
4592
- if (completionText === undefined) {
4593
- return undefined;
4594
- }
4595
- const cleaned = cleanTerminalBridgeScreenText(completionText);
4596
- const hasCompletionEvidence = promptEnd === undefined || completionBoundary !== undefined || /[•└]/u.test(cleaned ?? "");
4597
- if (!cleaned || cleaned.length < 40 || !hasCompletionEvidence) {
4598
- return undefined;
4599
- }
4600
- return {
4601
- role: "assistant",
4602
- text: truncateText(cleaned, 4000),
4603
- timestamp: undefined
4604
- };
4605
- }
4606
- function whitespaceInsensitiveMatchEnd(text, expected) {
4607
- const normalizedExpected = expected.replace(/\s/gu, "");
4608
- if (!normalizedExpected) {
4609
- return undefined;
4610
- }
4611
- let normalizedText = "";
4612
- const sourceEnds = [];
4613
- for (let index = 0; index < text.length;) {
4614
- const codePoint = text.codePointAt(index);
4615
- if (codePoint === undefined) {
4616
- break;
4617
- }
4618
- const character = String.fromCodePoint(codePoint);
4619
- if (!/\s/u.test(character)) {
4620
- normalizedText += character;
4621
- for (let codeUnit = 0; codeUnit < character.length; codeUnit += 1) {
4622
- sourceEnds.push(index + character.length);
4623
- }
4624
- }
4625
- index += character.length;
4626
- }
4627
- const matchIndex = normalizedText.lastIndexOf(normalizedExpected);
4628
- if (matchIndex < 0) {
4629
- return undefined;
4630
- }
4631
- return sourceEnds[matchIndex + normalizedExpected.length - 1];
4632
- }
4633
- function terminalBridgeLatestCompletedSegment(text) {
4634
- const matches = [...text.matchAll(/^[ \t]*[─━-]+\s+Worked for\b.*$/gmu)];
4635
- const completion = matches.at(-1);
4636
- if (completion?.index === undefined) {
4637
- return undefined;
4638
- }
4639
- const previousCompletion = matches.at(-2);
4640
- let start = previousCompletion?.index === undefined
4641
- ? 0
4642
- : previousCompletion.index + previousCompletion[0].length;
4643
- const beforeCompletion = text.slice(0, completion.index);
4644
- const prompts = [...beforeCompletion.matchAll(/^[ \t]*›(?:\s|$).*$/gmu)];
4645
- const latestPrompt = prompts.at(-1);
4646
- if (latestPrompt?.index !== undefined && latestPrompt.index >= start) {
4647
- start = latestPrompt.index + latestPrompt[0].length;
4648
- }
4649
- return text.slice(start, completion.index);
4650
- }
4651
- function terminalBridgeCompletionBoundary(text) {
4652
- const matches = [...text.matchAll(/^[ \t]*[─━-]+\s+Worked for\b.*$/gmu)];
4653
- return matches.at(-1)?.index;
4654
- }
4655
- function cleanTerminalBridgeScreenText(text) {
4656
- const lines = text
4657
- .split(/\r?\n/)
4658
- .map((line) => line.replace(/\s+$/u, ""))
4659
- .filter((line) => {
4660
- const trimmed = line.trim();
4661
- return trimmed &&
4662
- !trimmed.startsWith("› Use /skills") &&
4663
- !/^gpt-[\w.-]+/u.test(trimmed) &&
4664
- !/^[-\w.]+ default ·/u.test(trimmed);
4665
- });
4666
- const cleaned = lines.join("\n").trim();
4667
- return cleaned || undefined;
4668
- }
4669
5281
  function resolveExecutable(command) {
4670
5282
  if (command.includes(path.sep)) {
4671
5283
  return command;
@@ -4784,19 +5396,66 @@ function runCallback(options) {
4784
5396
  releaseLock();
4785
5397
  }
4786
5398
  }
5399
+ function runRetryCallback(options) {
5400
+ const { conversation, statePath } = loadConversationFromOptions(options);
5401
+ const callbackDelivery = isRecord(conversation.callback_delivery)
5402
+ ? conversation.callback_delivery
5403
+ : undefined;
5404
+ if (!["callback_pending", "callback_failed"].includes(conversation.status)) {
5405
+ throw new Error(`cannot retry callback for ${conversation.conversation_id}; conversation is ${conversation.status}`);
5406
+ }
5407
+ if (!callbackDelivery || !isRecord(callbackDelivery.message)) {
5408
+ throw new Error(`cannot retry callback for ${conversation.conversation_id}; pending callback is missing`);
5409
+ }
5410
+ const releaseLock = acquireFileLock(`${statePath}.lock`);
5411
+ try {
5412
+ runLockedCallback({
5413
+ ...options,
5414
+ statePath,
5415
+ messageJson: JSON.stringify(callbackDelivery.message),
5416
+ gatewayMethod: stringValue(callbackDelivery.gateway_method) ?? conversation.gateway_method,
5417
+ gatewaySession: stringValue(callbackDelivery.gateway_session) ?? conversation.gateway_session,
5418
+ openclawSession: conversation.openclaw_session,
5419
+ openclawBin: stringValue(callbackDelivery.openclaw_bin) ?? conversation.openclaw_bin,
5420
+ gatewayUrl: stringValue(callbackDelivery.gateway_url) ?? conversation.gateway_url,
5421
+ token: stringValue(callbackDelivery.gateway_token) ?? conversation.gateway_token,
5422
+ closeTerminalBridgeOnDone: callbackDelivery.close_terminal_bridge_on_done === true,
5423
+ retryPending: true
5424
+ });
5425
+ }
5426
+ finally {
5427
+ releaseLock();
5428
+ }
5429
+ }
4787
5430
  function runLockedCallback(options) {
4788
5431
  const messageInput = required(options.messageJson, "--message-json is required");
4789
5432
  const logPath = expandHome(options.log ?? logPathForStatePath(options.statePath));
4790
5433
  const conversation = loadState(options.statePath);
4791
5434
  const executor = executorForConversation(conversation);
4792
- const message = extractStructuredMessage({
4793
- conversation,
4794
- input: messageInput,
4795
- defaultFrom: executor.actor,
4796
- defaultTo: "openclaw"
4797
- });
5435
+ const message = options.retryPending === true || options.preserveMessageId === true
5436
+ ? parseMessageJson(messageInput)
5437
+ : extractStructuredMessage({
5438
+ conversation,
5439
+ input: messageInput,
5440
+ defaultFrom: executor.actor,
5441
+ defaultTo: "openclaw"
5442
+ });
5443
+ if (message.conversation_id !== conversation.conversation_id) {
5444
+ throw new Error(`message.conversation_id ${message.conversation_id} does not match conversation ${conversation.conversation_id}`);
5445
+ }
4798
5446
  const existingEvents = readExistingEvents(logPath);
4799
- if (isDuplicateMessage(existingEvents, message)) {
5447
+ const callbackDelivery = isRecord(conversation.callback_delivery)
5448
+ ? conversation.callback_delivery
5449
+ : undefined;
5450
+ const retryingPending = options.retryPending === true &&
5451
+ isRecord(callbackDelivery?.message) &&
5452
+ callbackDelivery.message.id === message.id &&
5453
+ ["pending", "failed"].includes(String(callbackDelivery.status ?? ""));
5454
+ const duplicateMessage = isDuplicateMessage(existingEvents, message);
5455
+ const recoveringTerminalCompletion = options.recoverTerminalCompletion === true &&
5456
+ duplicateMessage &&
5457
+ isWaitingForAgent(conversation.status);
5458
+ if (duplicateMessage && !retryingPending && !recoveringTerminalCompletion) {
4800
5459
  runtimeLog("info", "callback_duplicate", {
4801
5460
  conversation_id: conversation.conversation_id,
4802
5461
  agent: executor.kind,
@@ -4816,19 +5475,56 @@ function runLockedCallback(options) {
4816
5475
  });
4817
5476
  return;
4818
5477
  }
4819
- let nextConversation = applyMessageToConversation(conversation, message);
4820
- if (message.type === "done" && options.closeTerminalBridgeOnDone === true) {
5478
+ const closeTerminalBridgeOnDone = message.type === "done" &&
5479
+ options.closeTerminalBridgeOnDone === true;
5480
+ const trackCallbackDelivery = closeTerminalBridgeOnDone ||
5481
+ options.trackCallbackDelivery === true ||
5482
+ callbackDelivery?.track_delivery === true;
5483
+ const requiresDelivery = Boolean(options.gatewayMethod) || options.recordOnly !== true;
5484
+ const deliveryAttempt = Number(callbackDelivery?.attempts ?? 0) + 1;
5485
+ let nextConversation = retryingPending
5486
+ ? conversation
5487
+ : applyMessageToConversation(conversation, message);
5488
+ const storedFinalStatus = stringValue(callbackDelivery?.final_status);
5489
+ const finalStatus = storedFinalStatus &&
5490
+ CONVERSATION_STATUSES.has(storedFinalStatus)
5491
+ ? storedFinalStatus
5492
+ : nextConversation.status;
5493
+ if (!retryingPending && !recoveringTerminalCompletion) {
5494
+ appendEvent(logPath, messageEvent(message));
5495
+ }
5496
+ if (trackCallbackDelivery && requiresDelivery) {
4821
5497
  const now = new Date().toISOString();
4822
5498
  nextConversation = {
4823
5499
  ...nextConversation,
4824
- status: "closed",
4825
- closed_at: now,
4826
- close_reason: "terminal bridge task completed",
5500
+ status: "callback_pending",
5501
+ callback_delivery: {
5502
+ status: "pending",
5503
+ message,
5504
+ attempts: deliveryAttempt,
5505
+ created_at: stringValue(callbackDelivery?.created_at) ?? now,
5506
+ last_attempt_at: now,
5507
+ gateway_method: options.gatewayMethod,
5508
+ gateway_session: options.gatewaySession ?? options.openclawSession ?? conversation.openclaw_session,
5509
+ gateway_url: options.gatewayUrl ?? conversation.gateway_url,
5510
+ openclaw_bin: options.openclawBin ?? conversation.openclaw_bin,
5511
+ close_terminal_bridge_on_done: closeTerminalBridgeOnDone,
5512
+ track_delivery: true,
5513
+ final_status: finalStatus
5514
+ },
4827
5515
  updated_at: now
4828
5516
  };
4829
5517
  delete nextConversation.idle_since;
5518
+ delete nextConversation.closed_at;
5519
+ delete nextConversation.close_reason;
5520
+ appendEvent(logPath, {
5521
+ ts: now,
5522
+ conversation_id: conversation.conversation_id,
5523
+ event: retryingPending ? "callback_delivery_retry_started" : "callback_delivery_pending",
5524
+ message_id: message.id,
5525
+ attempt: deliveryAttempt
5526
+ });
4830
5527
  }
4831
- appendEvent(logPath, messageEvent(message));
4832
5528
  saveState(options.statePath, nextConversation);
4833
5529
  runtimeLog("info", "callback_received", {
4834
5530
  conversation_id: conversation.conversation_id,
@@ -4843,13 +5539,120 @@ function runLockedCallback(options) {
4843
5539
  event_log_path: logPath,
4844
5540
  message: textSummary(message.body)
4845
5541
  });
4846
- const result = {
4847
- conversation: nextConversation,
4848
- message,
4849
- budget: budgetAction(nextConversation),
4850
- delivered: false,
4851
- duplicate: false
4852
- };
5542
+ if (options.recordOnly) {
5543
+ runtimeLog("info", "callback_recorded_only", {
5544
+ conversation_id: conversation.conversation_id,
5545
+ status: nextConversation.status
5546
+ });
5547
+ printJson({
5548
+ conversation: nextConversation,
5549
+ message,
5550
+ budget: budgetAction(nextConversation),
5551
+ delivered: false,
5552
+ duplicate: false
5553
+ });
5554
+ return;
5555
+ }
5556
+ try {
5557
+ const deliveryKind = deliverCallbackToOpenClaw({
5558
+ options,
5559
+ statePath: options.statePath,
5560
+ logPath,
5561
+ conversation: nextConversation,
5562
+ message
5563
+ });
5564
+ const deliveredAt = new Date().toISOString();
5565
+ let deliveredConversation = nextConversation;
5566
+ if (trackCallbackDelivery) {
5567
+ const deliveredStatus = closeTerminalBridgeOnDone ? "closed" : finalStatus;
5568
+ deliveredConversation = {
5569
+ ...nextConversation,
5570
+ status: deliveredStatus,
5571
+ ...(closeTerminalBridgeOnDone
5572
+ ? {
5573
+ closed_at: deliveredAt,
5574
+ close_reason: "terminal bridge task completed"
5575
+ }
5576
+ : {}),
5577
+ callback_delivery: {
5578
+ ...(isRecord(nextConversation.callback_delivery) ? nextConversation.callback_delivery : {}),
5579
+ status: "delivered",
5580
+ delivered_at: deliveredAt,
5581
+ last_error: undefined
5582
+ },
5583
+ updated_at: deliveredAt
5584
+ };
5585
+ delete deliveredConversation.idle_since;
5586
+ saveState(options.statePath, deliveredConversation);
5587
+ appendEvent(logPath, {
5588
+ ts: deliveredAt,
5589
+ conversation_id: conversation.conversation_id,
5590
+ event: "callback_delivery_succeeded",
5591
+ message_id: message.id,
5592
+ attempt: deliveryAttempt,
5593
+ status: deliveredStatus
5594
+ });
5595
+ }
5596
+ printJson({
5597
+ conversation: deliveredConversation,
5598
+ message,
5599
+ budget: budgetAction(deliveredConversation),
5600
+ delivered: true,
5601
+ duplicate: false,
5602
+ delivery: deliveryKind
5603
+ });
5604
+ }
5605
+ catch (error) {
5606
+ if (trackCallbackDelivery) {
5607
+ const failedAt = new Date().toISOString();
5608
+ const failedConversation = {
5609
+ ...nextConversation,
5610
+ status: "callback_failed",
5611
+ callback_delivery: {
5612
+ ...(isRecord(nextConversation.callback_delivery) ? nextConversation.callback_delivery : {}),
5613
+ status: "failed",
5614
+ failed_at: failedAt,
5615
+ last_error: error instanceof Error ? error.message : String(error)
5616
+ },
5617
+ updated_at: failedAt
5618
+ };
5619
+ saveState(options.statePath, failedConversation);
5620
+ appendEvent(logPath, {
5621
+ ts: failedAt,
5622
+ conversation_id: conversation.conversation_id,
5623
+ event: "callback_delivery_failed",
5624
+ message_id: message.id,
5625
+ attempt: deliveryAttempt,
5626
+ error: failedConversation.callback_delivery.last_error
5627
+ });
5628
+ if (options.retryPending !== true &&
5629
+ options.disableCallbackRetry !== true &&
5630
+ deliveryAttempt <= CALLBACK_RETRY_DELAYS_MS.length) {
5631
+ const retryMonitor = startCallbackRetryMonitor({ statePath: options.statePath });
5632
+ const retryDelayMs = CALLBACK_RETRY_DELAYS_MS[Math.max(0, deliveryAttempt - 1)];
5633
+ const retryState = {
5634
+ ...failedConversation,
5635
+ callback_delivery: {
5636
+ ...failedConversation.callback_delivery,
5637
+ retry_monitor_pid: retryMonitor.pid ?? null,
5638
+ next_attempt_at: new Date(Date.now() + retryDelayMs).toISOString()
5639
+ }
5640
+ };
5641
+ saveState(options.statePath, retryState);
5642
+ appendEvent(logPath, {
5643
+ ts: new Date().toISOString(),
5644
+ conversation_id: conversation.conversation_id,
5645
+ event: "callback_retry_monitor_launched",
5646
+ message_id: message.id,
5647
+ pid: retryMonitor.pid ?? null,
5648
+ next_attempt_at: retryState.callback_delivery.next_attempt_at
5649
+ });
5650
+ }
5651
+ }
5652
+ throw error;
5653
+ }
5654
+ }
5655
+ function deliverCallbackToOpenClaw({ options, statePath, logPath, conversation, message }) {
4853
5656
  if (options.gatewayMethod) {
4854
5657
  const delivery = deliverToGatewayMethod({
4855
5658
  method: options.gatewayMethod,
@@ -4857,30 +5660,19 @@ function runLockedCallback(options) {
4857
5660
  gatewayUrl: options.gatewayUrl,
4858
5661
  token: options.token,
4859
5662
  sessionKey: options.gatewaySession ?? options.openclawSession ?? conversation.openclaw_session,
4860
- statePath: options.statePath,
5663
+ statePath,
4861
5664
  logPath,
4862
- conversation: nextConversation,
5665
+ conversation,
4863
5666
  message
4864
5667
  });
4865
- appendEvent(logPath, {
4866
- ts: new Date().toISOString(),
4867
- conversation_id: conversation.conversation_id,
5668
+ recordCallbackProcessDelivery({
5669
+ logPath,
5670
+ conversation,
5671
+ message,
4868
5672
  event: "callback_gateway_method_delivery",
4869
- from: message.from,
4870
- to: "openclaw",
4871
- round: message.round,
4872
- method: options.gatewayMethod,
4873
- status: delivery.status,
4874
- stdout: delivery.stdout,
4875
- stderr: delivery.stderr
4876
- });
4877
- runtimeLog("info", "callback_gateway_method_delivery", {
4878
- conversation_id: conversation.conversation_id,
4879
- method: options.gatewayMethod,
4880
- status: delivery.status,
4881
- failure_kind: classifyProcessFailure(delivery),
4882
- stdout: textSummary(delivery.stdout),
4883
- stderr: textSummary(delivery.stderr)
5673
+ runtimeEvent: "callback_gateway_method_delivery",
5674
+ delivery,
5675
+ detail: { method: options.gatewayMethod }
4884
5676
  });
4885
5677
  if (delivery.status !== 0) {
4886
5678
  throw new Error(delivery.stderr || delivery.stdout || `gateway method delivery failed with status ${delivery.status}`);
@@ -4888,84 +5680,47 @@ function runLockedCallback(options) {
4888
5680
  const gatewayPayload = parseOptionalJson(delivery.stdout);
4889
5681
  const chatSendParams = isRecord(gatewayPayload?.chat_send) ? gatewayPayload.chat_send : undefined;
4890
5682
  const sessionSendParams = isRecord(gatewayPayload?.session_send) ? gatewayPayload.session_send : undefined;
4891
- let chatSendDelivery;
4892
- let sessionSendDelivery;
4893
5683
  if (chatSendParams) {
4894
- chatSendDelivery = deliverToChatSend({
5684
+ const chatSendDelivery = deliverToChatSend({
4895
5685
  openclawBin: options.openclawBin,
4896
5686
  gatewayUrl: options.gatewayUrl,
4897
5687
  token: options.token,
4898
5688
  params: chatSendParams
4899
5689
  });
4900
- appendEvent(logPath, {
4901
- ts: new Date().toISOString(),
4902
- conversation_id: conversation.conversation_id,
5690
+ recordCallbackProcessDelivery({
5691
+ logPath,
5692
+ conversation,
5693
+ message,
4903
5694
  event: "callback_chat_send_delivery",
4904
- from: message.from,
4905
- to: "openclaw",
4906
- round: message.round,
4907
- status: chatSendDelivery.status,
4908
- stdout: chatSendDelivery.stdout,
4909
- stderr: chatSendDelivery.stderr
4910
- });
4911
- runtimeLog("info", "callback_chat_send_delivery", {
4912
- conversation_id: conversation.conversation_id,
4913
- status: chatSendDelivery.status,
4914
- failure_kind: classifyProcessFailure(chatSendDelivery),
4915
- stdout: textSummary(chatSendDelivery.stdout),
4916
- stderr: textSummary(chatSendDelivery.stderr)
5695
+ runtimeEvent: "callback_chat_send_delivery",
5696
+ delivery: chatSendDelivery
4917
5697
  });
4918
5698
  if (chatSendDelivery.status !== 0) {
4919
5699
  throw new Error(chatSendDelivery.stderr || chatSendDelivery.stdout || `chat callback delivery failed with status ${chatSendDelivery.status}`);
4920
5700
  }
5701
+ return "gateway_method+chat_send";
4921
5702
  }
4922
- else if (sessionSendParams) {
4923
- sessionSendDelivery = deliverToSessionSend({
5703
+ if (sessionSendParams) {
5704
+ const sessionSendDelivery = deliverToSessionSend({
4924
5705
  openclawBin: options.openclawBin,
4925
5706
  gatewayUrl: options.gatewayUrl,
4926
5707
  token: options.token,
4927
5708
  params: sessionSendParams
4928
5709
  });
4929
- appendEvent(logPath, {
4930
- ts: new Date().toISOString(),
4931
- conversation_id: conversation.conversation_id,
5710
+ recordCallbackProcessDelivery({
5711
+ logPath,
5712
+ conversation,
5713
+ message,
4932
5714
  event: "callback_session_send_delivery",
4933
- from: message.from,
4934
- to: "openclaw",
4935
- round: message.round,
4936
- status: sessionSendDelivery.status,
4937
- stdout: sessionSendDelivery.stdout,
4938
- stderr: sessionSendDelivery.stderr
4939
- });
4940
- runtimeLog("info", "callback_session_send_delivery", {
4941
- conversation_id: conversation.conversation_id,
4942
- status: sessionSendDelivery.status,
4943
- failure_kind: classifyProcessFailure(sessionSendDelivery),
4944
- stdout: textSummary(sessionSendDelivery.stdout),
4945
- stderr: textSummary(sessionSendDelivery.stderr)
5715
+ runtimeEvent: "callback_session_send_delivery",
5716
+ delivery: sessionSendDelivery
4946
5717
  });
4947
5718
  if (sessionSendDelivery.status !== 0) {
4948
5719
  throw new Error(sessionSendDelivery.stderr || sessionSendDelivery.stdout || `session callback delivery failed with status ${sessionSendDelivery.status}`);
4949
5720
  }
5721
+ return "gateway_method+sessions_send";
4950
5722
  }
4951
- printJson({
4952
- ...result,
4953
- delivered: true,
4954
- delivery: chatSendDelivery
4955
- ? "gateway_method+chat_send"
4956
- : sessionSendDelivery
4957
- ? "gateway_method+sessions_send"
4958
- : "gateway_method"
4959
- });
4960
- return;
4961
- }
4962
- if (options.recordOnly) {
4963
- runtimeLog("info", "callback_recorded_only", {
4964
- conversation_id: conversation.conversation_id,
4965
- status: nextConversation.status
4966
- });
4967
- printJson(result);
4968
- return;
5723
+ return "gateway_method";
4969
5724
  }
4970
5725
  const gatewayUrl = options.gatewayUrl ?? conversation.gateway_url;
4971
5726
  const token = options.token ?? conversation.gateway_token;
@@ -4980,37 +5735,48 @@ function runLockedCallback(options) {
4980
5735
  throw new Error("--openclaw-session is required unless state has openclaw_session");
4981
5736
  }
4982
5737
  const delivery = deliverToOpenClaw({ gatewayUrl, token, openclawSession, message });
5738
+ recordCallbackProcessDelivery({
5739
+ logPath,
5740
+ conversation,
5741
+ message,
5742
+ event: "callback_delivery",
5743
+ runtimeEvent: "callback_delivery",
5744
+ delivery
5745
+ });
5746
+ if (delivery.status !== 0) {
5747
+ throw new Error(delivery.stderr || delivery.stdout || `callback delivery failed with status ${delivery.status}`);
5748
+ }
5749
+ return "acpx";
5750
+ }
5751
+ function recordCallbackProcessDelivery({ logPath, conversation, message, event, runtimeEvent, delivery, detail = {} }) {
4983
5752
  appendEvent(logPath, {
4984
5753
  ts: new Date().toISOString(),
4985
5754
  conversation_id: conversation.conversation_id,
4986
- event: "callback_delivery",
5755
+ event,
4987
5756
  from: message.from,
4988
5757
  to: "openclaw",
4989
5758
  round: message.round,
5759
+ ...detail,
4990
5760
  status: delivery.status,
4991
5761
  stdout: delivery.stdout,
4992
5762
  stderr: delivery.stderr
4993
5763
  });
4994
- runtimeLog("info", "callback_delivery", {
5764
+ runtimeLog("info", runtimeEvent, {
4995
5765
  conversation_id: conversation.conversation_id,
5766
+ ...detail,
4996
5767
  status: delivery.status,
4997
5768
  failure_kind: classifyProcessFailure(delivery),
4998
5769
  stdout: textSummary(delivery.stdout),
4999
5770
  stderr: textSummary(delivery.stderr)
5000
5771
  });
5001
- if (delivery.status !== 0) {
5002
- throw new Error(delivery.stderr || delivery.stdout || `callback delivery failed with status ${delivery.status}`);
5003
- }
5004
- printJson({
5005
- ...result,
5006
- delivered: true
5007
- });
5008
5772
  }
5009
5773
  function acquireFileLock(lockPath, { timeoutMs = 5000, retryMs = 50 } = {}) {
5010
5774
  const started = Date.now();
5011
5775
  while (true) {
5012
5776
  try {
5013
5777
  const fd = fs.openSync(lockPath, "wx");
5778
+ fs.writeFileSync(fd, `${process.pid}\n`, "utf8");
5779
+ fs.fsyncSync(fd);
5014
5780
  fs.closeSync(fd);
5015
5781
  return () => {
5016
5782
  fs.rmSync(lockPath, { force: true });
@@ -5020,6 +5786,10 @@ function acquireFileLock(lockPath, { timeoutMs = 5000, retryMs = 50 } = {}) {
5020
5786
  if (error.code !== "EEXIST") {
5021
5787
  throw error;
5022
5788
  }
5789
+ if (staleFileLock(lockPath)) {
5790
+ fs.rmSync(lockPath, { force: true });
5791
+ continue;
5792
+ }
5023
5793
  if (Date.now() - started >= timeoutMs) {
5024
5794
  throw new Error(`timed out waiting for file lock: ${lockPath}`);
5025
5795
  }
@@ -5027,6 +5797,26 @@ function acquireFileLock(lockPath, { timeoutMs = 5000, retryMs = 50 } = {}) {
5027
5797
  }
5028
5798
  }
5029
5799
  }
5800
+ function staleFileLock(lockPath) {
5801
+ try {
5802
+ const stat = fs.statSync(lockPath);
5803
+ const ownerText = fs.readFileSync(lockPath, "utf8").trim();
5804
+ const ownerPid = Number(ownerText);
5805
+ if (Number.isSafeInteger(ownerPid) && ownerPid > 1) {
5806
+ try {
5807
+ process.kill(ownerPid, 0);
5808
+ return false;
5809
+ }
5810
+ catch (error) {
5811
+ return isRecord(error) && error.code === "ESRCH";
5812
+ }
5813
+ }
5814
+ return Date.now() - stat.mtimeMs > 30_000;
5815
+ }
5816
+ catch (error) {
5817
+ return isRecord(error) && error.code === "ENOENT";
5818
+ }
5819
+ }
5030
5820
  function sleepSync(ms) {
5031
5821
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
5032
5822
  }
@@ -5589,6 +6379,7 @@ function markConversationStalled({ statePath, logPath, reason, detail = {} }) {
5589
6379
  updated_at: now
5590
6380
  };
5591
6381
  saveState(statePath, stalledConversation);
6382
+ releaseClaudeHookLease(stalledConversation);
5592
6383
  appendEvent(logPath, {
5593
6384
  ts: now,
5594
6385
  conversation_id: conversation.conversation_id,
@@ -6297,9 +7088,11 @@ function usage() {
6297
7088
  agent-knock-knock approve --conversation <id>
6298
7089
  agent-knock-knock cancel --conversation <id> [--all-proxy <url>]
6299
7090
  agent-knock-knock renew --conversation <id> [--minutes <inactivity-minutes>]
7091
+ agent-knock-knock retry-callback --conversation <id> [--store-dir <dir>]
6300
7092
  agent-knock-knock recover --conversation <id> [--session <name>] [--all-proxy <url>]
6301
7093
  agent-knock-knock close --conversation <id> [--reason <text>]
6302
7094
  agent-knock-knock install-openclaw [--openclaw-bin <path>] [--skill-path <path>] [--skill-only] [--no-restart]
7095
+ agent-knock-knock install-claude-hooks [--settings-path <path>] [--executable-path <path>] [--dry-run]
6303
7096
  agent-knock-knock doctor
6304
7097
  agent-knock-knock agent takeover --agent codex --session-id <id> --strategy terminate_then_resume|terminal_control|fork [--create-conversation]
6305
7098
  agent-knock-knock callback --state <file> --message-json <json> [--record-only]