@scotthuang/agent-knock-knock 0.2.46 → 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.
package/dist/src/cli.js CHANGED
@@ -6,6 +6,10 @@ import path from "node:path";
6
6
  import process from "node:process";
7
7
  import { fileURLToPath } from "node:url";
8
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";
9
13
  import { CodexLocalSessionProvider } from "./codex-local-session-provider.js";
10
14
  import { CodexStoreAdapter } from "./codex-store-adapter.js";
11
15
  import { applyMessageToConversation, budgetAction, createConversation, createMessage, executorForConversation, extractStructuredMessage, parseMessageJson, resolveExecutor } from "./protocol.js";
@@ -26,6 +30,22 @@ const DEFAULT_AGENT_HARD_TIMEOUT_MINUTES = 720;
26
30
  const DEFAULT_MONITOR_POLL_INTERVAL_MS = 5000;
27
31
  const CALLBACK_RETRY_DELAYS_MS = [5000, 15000, 60000, 60000];
28
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
+ ]);
29
49
  class InlineCodexSessionAdapter {
30
50
  threads;
31
51
  processes;
@@ -127,6 +147,12 @@ async function runCommand(commandName, options) {
127
147
  else if (commandName === "install-openclaw") {
128
148
  runInstallOpenClaw(options);
129
149
  }
150
+ else if (commandName === "install-claude-hooks") {
151
+ runInstallClaudeHooks(options);
152
+ }
153
+ else if (commandName === "claude-hook") {
154
+ await runClaudeHook(options);
155
+ }
130
156
  else if (commandName === "doctor") {
131
157
  runDoctor(options);
132
158
  }
@@ -197,6 +223,88 @@ function runInstallOpenClaw(options) {
197
223
  : "Agent Knock Knock is installed. Try: AKK list"
198
224
  });
199
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
+ }
200
308
  function installOpenClawPlugin(openclawBin, root) {
201
309
  const linked = spawnSync(openclawBin, ["plugins", "install", "--link", root], {
202
310
  encoding: "utf8",
@@ -575,9 +683,107 @@ function createTerminalProcessSource(options) {
575
683
  }
576
684
  return new SystemTerminalProcessSource();
577
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
+ }
578
783
  function createRuntimeTerminalAgentRegistry(options) {
579
784
  return createProductionTerminalAgentRegistry({
580
- overrides: [createCodexTerminalAgentAdapter({
785
+ overrides: [
786
+ createCodexTerminalAgentAdapter({
581
787
  async detectDurableCompletion(request) {
582
788
  const runtime = isRecord(request.context) ? request.context : undefined;
583
789
  const conversation = runtime?.conversation;
@@ -611,7 +817,13 @@ function createRuntimeTerminalAgentRegistry(options) {
611
817
  }
612
818
  : undefined;
613
819
  }
614
- })]
820
+ }),
821
+ createClaudeTerminalAgentAdapter({
822
+ agentRows: loadClaudeAgentRows(options),
823
+ hookStore: createClaudeHookStore(options),
824
+ trustedTokenjuiceLaunchers: loadTrustedClaudeTokenjuiceLaunchers(expandHome(options.claudeSettingsPath ?? defaultClaudeSettingsPath()))
825
+ })
826
+ ]
615
827
  });
616
828
  }
617
829
  function createTerminalAgentBridge(options, terminalProvider = createTerminalControlProvider(options)) {
@@ -695,6 +907,25 @@ function terminalControlFromTakeover(nativeTakeover) {
695
907
  ]
696
908
  };
697
909
  }
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);
918
+ return {
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
927
+ };
928
+ }
698
929
  function isTerminalControlCapability(value) {
699
930
  return typeof value === "string" && [
700
931
  "screen_status",
@@ -1246,7 +1477,7 @@ function startExecutorMonitor({ statePath, logPath, pid, outputPath, agentTimeou
1246
1477
  child.unref();
1247
1478
  return child;
1248
1479
  }
1249
- function startTerminalBridgeMonitor({ statePath, logPath, agentTimeoutMinutes, agentHardTimeoutMinutes, pollIntervalMs, codexHome }) {
1480
+ function startTerminalBridgeMonitor({ statePath, logPath, agentTimeoutMinutes, agentHardTimeoutMinutes, pollIntervalMs, codexHome, claudeHookStoreDir }) {
1250
1481
  const args = [
1251
1482
  new URL(import.meta.url).pathname,
1252
1483
  "monitor",
@@ -1265,6 +1496,9 @@ function startTerminalBridgeMonitor({ statePath, logPath, agentTimeoutMinutes, a
1265
1496
  if (codexHome) {
1266
1497
  args.push("--codex-home", codexHome);
1267
1498
  }
1499
+ if (claudeHookStoreDir) {
1500
+ args.push("--claude-hook-store-dir", claudeHookStoreDir);
1501
+ }
1268
1502
  const child = spawn(process.execPath, args, {
1269
1503
  detached: true,
1270
1504
  stdio: "ignore",
@@ -1291,7 +1525,8 @@ function startTerminalBridgeMonitorForConversation({ conversation, statePath, lo
1291
1525
  nativeTakeover?.["terminal_bridge_hard_timeout_minutes"] ??
1292
1526
  DEFAULT_AGENT_HARD_TIMEOUT_MINUTES),
1293
1527
  pollIntervalMs: Number(options.monitorPollIntervalMs ?? DEFAULT_MONITOR_POLL_INTERVAL_MS),
1294
- codexHome: options.codexHome
1528
+ codexHome: options.codexHome,
1529
+ claudeHookStoreDir: options.claudeHookStoreDir ?? nativeTakeover?.["claude_hook_store_dir"]
1295
1530
  });
1296
1531
  }
1297
1532
  function terminalBridgeEnabled(conversation) {
@@ -1314,6 +1549,7 @@ function withTerminalBridgeState({ conversation, message, startedAt, agentTimeou
1314
1549
  terminal_bridge_request_text: String(message.body ?? ""),
1315
1550
  terminal_bridge_request_hash: terminalBridgeRequestFingerprint(message.body),
1316
1551
  terminal_bridge_pre_send_screen_fingerprint: preSendScreenFingerprint,
1552
+ terminal_bridge_completion_claim: undefined,
1317
1553
  terminal_bridge_monitor_started_at: startedAt,
1318
1554
  terminal_bridge_last_activity_at: startedAt,
1319
1555
  terminal_bridge_inactivity_timeout_minutes: agentTimeoutMinutes,
@@ -1324,6 +1560,165 @@ function withTerminalBridgeState({ conversation, message, startedAt, agentTimeou
1324
1560
  updated_at: startedAt
1325
1561
  };
1326
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
+ }
1327
1722
  function uniqueDelegateSessionName(kind) {
1328
1723
  const { sessionPrefix } = executorDefinitionForKind(kind || "claude");
1329
1724
  const timestamp = new Date().toISOString().replace(/\D/g, "").slice(0, 14);
@@ -1554,7 +1949,12 @@ async function terminalControlledListEntry(session, activeSessions, options, bri
1554
1949
  if (!terminalControl) {
1555
1950
  throw new Error(`process ${session.pid} is not terminal-controlled`);
1556
1951
  }
1557
- const terminalState = await listStateForTerminal(session.agent, terminalControl, options, bridge);
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
+ });
1558
1958
  return {
1559
1959
  id: bridge.terminalConversationId(session),
1560
1960
  source: "terminal_control",
@@ -1583,7 +1983,7 @@ async function terminalControlledListEntry(session, activeSessions, options, bri
1583
1983
  }
1584
1984
  };
1585
1985
  }
1586
- async function listStateForTerminal(agent, terminalControl, options, bridge = createTerminalAgentBridge(options)) {
1986
+ async function listStateForTerminal(agent, terminalControl, options, bridge = createTerminalAgentBridge(options), runtime) {
1587
1987
  if (options.noApprovalScan) {
1588
1988
  return {
1589
1989
  approval_state: {
@@ -1598,7 +1998,8 @@ async function listStateForTerminal(agent, terminalControl, options, bridge = cr
1598
1998
  }
1599
1999
  try {
1600
2000
  const status = await bridge.status(agent, terminalControl, {
1601
- scrollbackLines: Number(options.scrollbackLines ?? 120)
2001
+ scrollbackLines: Number(options.scrollbackLines ?? 120),
2002
+ runtime
1602
2003
  });
1603
2004
  return {
1604
2005
  approval_state: {
@@ -1671,7 +2072,11 @@ async function runStatus(options) {
1671
2072
  cleanupIdleConversations(storeDirFromOptions(options), options);
1672
2073
  const terminalConversation = await resolveTerminalConversationFromOptions(options);
1673
2074
  if (terminalConversation) {
1674
- const terminalStatus = await terminalStatusForControl(terminalConversation.agent, 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
+ });
1675
2080
  printJson({
1676
2081
  conversation_id: terminalConversation.conversationId,
1677
2082
  source: "terminal_control",
@@ -1703,7 +2108,7 @@ async function runStatus(options) {
1703
2108
  if (terminalControl) {
1704
2109
  const executor = executorForConversation(conversation);
1705
2110
  result.terminal_control = terminalControl;
1706
- result.terminal_status = await terminalStatusForControl(executor.kind, terminalControl, options);
2111
+ result.terminal_status = await terminalStatusForControl(executor.kind, terminalControl, options, terminalRuntimeIdentityForConversation(conversation, terminalControl));
1707
2112
  result.terminal_screen = result.terminal_status.screen;
1708
2113
  }
1709
2114
  printJson(result);
@@ -1721,7 +2126,11 @@ async function runDescribe(options) {
1721
2126
  const conversationId = required(options.conversation ?? options.conversationId, "--conversation is required");
1722
2127
  const terminalConversation = await resolveTerminalConversationFromOptions(options);
1723
2128
  if (terminalConversation) {
1724
- const terminalStatus = await terminalStatusForControl(terminalConversation.agent, terminalConversation.terminalControl, options);
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
+ });
1725
2134
  if (terminalConversation.agent !== "codex") {
1726
2135
  const adapter = createRuntimeTerminalAgentRegistry(options).require(terminalConversation.agent);
1727
2136
  printJson({
@@ -1767,7 +2176,7 @@ async function runDescribe(options) {
1767
2176
  const events = readExistingEvents(logPath);
1768
2177
  const terminalControl = terminalControlFromTakeover(isRecord(conversation.native_session_takeover) ? conversation.native_session_takeover : undefined);
1769
2178
  const terminalStatus = terminalControl
1770
- ? await terminalStatusForControl(executorForConversation(conversation).kind, terminalControl, options)
2179
+ ? await terminalStatusForControl(executorForConversation(conversation).kind, terminalControl, options, terminalRuntimeIdentityForConversation(conversation, terminalControl))
1771
2180
  : undefined;
1772
2181
  printJson({
1773
2182
  conversation_id: conversation.conversation_id,
@@ -1786,9 +2195,10 @@ async function runDescribe(options) {
1786
2195
  event_log_path: logPath
1787
2196
  });
1788
2197
  }
1789
- async function terminalStatusForControl(agent, terminalControl, options) {
2198
+ async function terminalStatusForControl(agent, terminalControl, options, runtime) {
1790
2199
  return createTerminalAgentBridge(options).status(agent, terminalControl, {
1791
- scrollbackLines: Number(options.scrollbackLines ?? 120)
2200
+ scrollbackLines: Number(options.scrollbackLines ?? 120),
2201
+ runtime
1792
2202
  });
1793
2203
  }
1794
2204
  function terminalBridgeApprovalFingerprint({ terminalControl, terminalStatus }) {
@@ -1805,10 +2215,26 @@ function terminalBridgeApprovalFingerprint({ terminalControl, terminalStatus })
1805
2215
  label: approval.label,
1806
2216
  prompt_kind: approval.prompt_kind,
1807
2217
  command: approval.command,
2218
+ tool_name: approval.tool_name,
2219
+ request_detail: approval.request_detail,
1808
2220
  excerpt: screen.excerpt
1809
2221
  }))
1810
2222
  .digest("hex");
1811
2223
  }
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
+ }
2237
+ }
1812
2238
  function terminalBridgeApprovalInstructions({ conversation, terminalControl, terminalStatus }) {
1813
2239
  const approval = isRecord(terminalStatus?.approval_state) ? terminalStatus.approval_state : {};
1814
2240
  const screen = isRecord(terminalStatus?.screen) ? terminalStatus.screen : {};
@@ -1818,10 +2244,18 @@ function terminalBridgeApprovalInstructions({ conversation, terminalControl, ter
1818
2244
  const keys = Array.isArray(approval.keys)
1819
2245
  ? approval.keys.filter((value) => typeof value === "string")
1820
2246
  : [];
1821
- const keyDescription = keys.length > 0
1822
- ? keys.join(" then ")
1823
- : stringValue(approval.key) || "the detected approve key sequence";
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";
1824
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);
1825
2259
  const excerpt = stringValue(screen.excerpt) || "(No terminal excerpt was available.)";
1826
2260
  return [
1827
2261
  `${agentName} is waiting for approval in a terminal-controlled AKK session.`,
@@ -1829,6 +2263,11 @@ function terminalBridgeApprovalInstructions({ conversation, terminalControl, ter
1829
2263
  `Conversation: ${conversation.conversation_id}`,
1830
2264
  `Terminal: ${terminalControl.kind}:${terminalControl.target}`,
1831
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,
1832
2271
  "",
1833
2272
  "Safe terminal excerpt:",
1834
2273
  "```text",
@@ -1850,7 +2289,7 @@ function terminalBridgeApprovalInstructions({ conversation, terminalControl, ter
1850
2289
  "Equivalent user command: `AKK cancel " + conversation.conversation_id + "`",
1851
2290
  "",
1852
2291
  "Do not use raw tmux, shell, or manual key presses for this approval. Do not approve without explicit user confirmation."
1853
- ].join("\n");
2292
+ ].filter((line) => line !== undefined).join("\n");
1854
2293
  }
1855
2294
  function recordTerminalBridgeApprovalNotification({ statePath, logPath, terminalControl, terminalStatus, fingerprint }) {
1856
2295
  const releaseLock = acquireFileLock(`${statePath}.lock`);
@@ -1913,6 +2352,7 @@ async function runSend(options) {
1913
2352
  options,
1914
2353
  conversationId: terminalConversation.conversationId,
1915
2354
  agent: terminalConversation.agent,
2355
+ pid: terminalConversation.pid,
1916
2356
  messageBody,
1917
2357
  terminalControl: terminalConversation.terminalControl
1918
2358
  });
@@ -1933,6 +2373,7 @@ async function runSend(options) {
1933
2373
  options,
1934
2374
  conversationId: terminalConversation.conversationId,
1935
2375
  agent: terminalConversation.agent,
2376
+ pid: terminalConversation.pid,
1936
2377
  messageBody,
1937
2378
  terminalControl: terminalConversation.terminalControl
1938
2379
  });
@@ -2297,7 +2738,8 @@ async function runApprove(options) {
2297
2738
  options,
2298
2739
  conversationId: terminalConversation.conversationId,
2299
2740
  agent: terminalConversation.agent,
2300
- terminalControl: terminalConversation.terminalControl
2741
+ terminalControl: terminalConversation.terminalControl,
2742
+ pid: terminalConversation.pid
2301
2743
  });
2302
2744
  return;
2303
2745
  }
@@ -2320,7 +2762,11 @@ async function runApprove(options) {
2320
2762
  const policyFingerprint = stringValue(options.policyFingerprint);
2321
2763
  const approval = await createTerminalAgentBridge(options).approve(executor.kind, terminalControl, {
2322
2764
  expectedFingerprint,
2323
- scrollbackLines: Number(options.scrollbackLines ?? 120)
2765
+ scrollbackLines: Number(options.scrollbackLines ?? 120),
2766
+ runtime: terminalRuntimeIdentityForConversation(conversation, terminalControl),
2767
+ requiredDecisionMode: autoApproved && executor.kind === "claude"
2768
+ ? "structured"
2769
+ : undefined
2324
2770
  });
2325
2771
  const actualFingerprint = approval.fingerprint;
2326
2772
  if (!approval.approved) {
@@ -2358,6 +2804,8 @@ async function runApprove(options) {
2358
2804
  key: approval.key,
2359
2805
  keys: approval.keys,
2360
2806
  label: approval.label,
2807
+ decision_mode: approval.decisionMode,
2808
+ request_id: approval.requestId,
2361
2809
  approval_fingerprint: actualFingerprint,
2362
2810
  auto_approved: autoApproved,
2363
2811
  policy_rule_id: policyRuleId,
@@ -2381,6 +2829,8 @@ async function runApprove(options) {
2381
2829
  key: approval.key,
2382
2830
  keys: approval.keys,
2383
2831
  label: approval.label,
2832
+ decision_mode: approval.decisionMode,
2833
+ request_id: approval.requestId,
2384
2834
  approval_fingerprint: actualFingerprint,
2385
2835
  auto_approved: autoApproved,
2386
2836
  policy_rule_id: policyRuleId,
@@ -2409,12 +2859,28 @@ async function runApprove(options) {
2409
2859
  terminal_bridge_hard_deadline_at: deadlineAt(stringValue(nativeTakeoverForUpdate.terminal_bridge_started_at) ?? approvalResolvedAt, agentHardTimeoutMinutes)
2410
2860
  };
2411
2861
  delete nextNativeTakeover.terminal_bridge_approval;
2412
- const nextConversation = {
2862
+ let nextConversation = {
2413
2863
  ...conversation,
2414
2864
  status: terminalBridgeEnabled(conversation) ? "waiting_for_agent" : conversation.status,
2415
2865
  native_session_takeover: nextNativeTakeover,
2416
2866
  updated_at: approvalResolvedAt
2417
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
+ }
2418
2884
  saveState(statePath, nextConversation);
2419
2885
  const bridgeMonitor = startTerminalBridgeMonitorForConversation({
2420
2886
  conversation: nextConversation,
@@ -2447,16 +2913,23 @@ async function runApprove(options) {
2447
2913
  key: approval.key,
2448
2914
  keys: approval.keys,
2449
2915
  label: approval.label,
2916
+ decision_mode: approval.decisionMode,
2917
+ request_id: approval.requestId,
2450
2918
  approval_fingerprint: actualFingerprint,
2451
2919
  auto_approved: autoApproved,
2452
2920
  policy_rule_id: policyRuleId,
2453
2921
  monitor_pid: bridgeMonitor?.pid ?? null
2454
2922
  });
2455
2923
  }
2456
- async function runTerminalConversationApprove({ options, conversationId, agent, terminalControl }) {
2924
+ async function runTerminalConversationApprove({ options, conversationId, agent, terminalControl, pid }) {
2457
2925
  const approval = await createTerminalAgentBridge(options).approve(agent, terminalControl, {
2458
2926
  expectedFingerprint: stringValue(options.expectedApprovalFingerprint),
2459
- scrollbackLines: Number(options.scrollbackLines ?? 120)
2927
+ scrollbackLines: Number(options.scrollbackLines ?? 120),
2928
+ runtime: {
2929
+ pid,
2930
+ cwd: terminalControl.currentPath,
2931
+ terminalTarget: terminalControl.target
2932
+ }
2460
2933
  });
2461
2934
  if (!approval.approved) {
2462
2935
  printJson({
@@ -2476,7 +2949,9 @@ async function runTerminalConversationApprove({ options, conversationId, agent,
2476
2949
  terminal_target: terminalControl.target,
2477
2950
  key: approval.key,
2478
2951
  keys: approval.keys,
2479
- label: approval.label
2952
+ label: approval.label,
2953
+ decision_mode: approval.decisionMode,
2954
+ request_id: approval.requestId
2480
2955
  });
2481
2956
  printJson({
2482
2957
  conversation_id: conversationId,
@@ -2486,12 +2961,26 @@ async function runTerminalConversationApprove({ options, conversationId, agent,
2486
2961
  key: approval.key,
2487
2962
  keys: approval.keys,
2488
2963
  label: approval.label,
2489
- approval_fingerprint: approval.fingerprint
2964
+ approval_fingerprint: approval.fingerprint,
2965
+ decision_mode: approval.decisionMode,
2966
+ request_id: approval.requestId
2490
2967
  });
2491
2968
  }
2492
- async function runTerminalConversationSend({ options, conversationId, agent, messageBody, terminalControl }) {
2969
+ async function runTerminalConversationSend({ options, conversationId, agent, pid, messageBody, terminalControl }) {
2493
2970
  const terminalPayload = terminalSubmissionPayload(String(messageBody));
2494
- await createTerminalAgentBridge(options).send(agent, terminalControl, terminalPayload);
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);
2495
2984
  runtimeLog("info", "terminal_message_send", {
2496
2985
  conversation_id: conversationId,
2497
2986
  agent,
@@ -2552,19 +3041,60 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
2552
3041
  forkTakeover: undefined
2553
3042
  }))
2554
3043
  : terminalSubmissionPayload(String(message.body ?? ""));
3044
+ const preSendRuntime = {
3045
+ ...terminalRuntimeIdentityForConversation(nextConversation, terminalControl),
3046
+ messageId: message.id
3047
+ };
2555
3048
  let preSendScreenFingerprint;
2556
3049
  if (bridge) {
2557
3050
  try {
2558
3051
  const status = await terminalBridge.status(executor.kind, terminalControl, {
2559
- scrollbackLines: Number(options.scrollbackLines ?? 120)
3052
+ scrollbackLines: Number(options.scrollbackLines ?? 120),
3053
+ runtime: preSendRuntime
2560
3054
  });
3055
+ if (executor.kind === "claude") {
3056
+ assertSafeClaudeTerminalSend(status);
3057
+ }
2561
3058
  preSendScreenFingerprint = terminalBridgeScreenFingerprint(status.screen.excerpt);
2562
3059
  }
2563
- catch {
2564
- // 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.
2565
3065
  }
2566
3066
  }
2567
- await terminalBridge.send(executor.kind, terminalControl, terminalPayload);
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
+ }
2568
3098
  const supersededConversationIds = bridge
2569
3099
  ? supersedeTerminalBridgeConversations({
2570
3100
  storeDir: storeDirFromOptions(options),
@@ -2592,7 +3122,7 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
2592
3122
  });
2593
3123
  const bridgeConversation = bridge
2594
3124
  ? withTerminalBridgeState({
2595
- conversation: nextConversation,
3125
+ conversation: conversationWithHookLease,
2596
3126
  message,
2597
3127
  startedAt: bridgeStartedAt,
2598
3128
  agentTimeoutMinutes,
@@ -2654,7 +3184,7 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
2654
3184
  function terminalSubmissionPayload(payload) {
2655
3185
  return payload.replace(/[\r\n]+$/u, "");
2656
3186
  }
2657
- function createManagedTerminalConversationFromRawId({ options, conversationId, agent, messageBody, terminalControl }) {
3187
+ function createManagedTerminalConversationFromRawId({ options, conversationId, agent, pid, messageBody, terminalControl }) {
2658
3188
  const workspace = terminalControl.currentPath ?? process.cwd();
2659
3189
  const storeDir = expandHome(options.storeDir ?? options.logDir ?? defaultStoreDir(workspace));
2660
3190
  cleanupIdleConversations(storeDir, options);
@@ -2685,6 +3215,9 @@ function createManagedTerminalConversationFromRawId({ options, conversationId, a
2685
3215
  gatewaySession: options.gatewaySession,
2686
3216
  openclawBin: options.openclawBin ?? resolveOptionalExecutable("openclaw")
2687
3217
  });
3218
+ const claudeAgent = agent === "claude"
3219
+ ? loadClaudeAgentRows(options).find((row) => row.pid === pid)
3220
+ : undefined;
2688
3221
  const attachedConversation = withStoragePaths({
2689
3222
  ...conversation,
2690
3223
  executor,
@@ -2701,6 +3234,8 @@ function createManagedTerminalConversationFromRawId({ options, conversationId, a
2701
3234
  native_session_takeover: {
2702
3235
  agent,
2703
3236
  native_session_id: conversationId,
3237
+ terminal_agent_pid: pid,
3238
+ terminal_agent_session_id: claudeAgent?.sessionId,
2704
3239
  source_cwd: workspace,
2705
3240
  source_title: `Terminal-controlled ${executor.display_name} ${terminalControl.target}`,
2706
3241
  strategy: "terminal_control",
@@ -2805,14 +3340,16 @@ function supersedeTerminalBridgeConversations({ storeDir, terminalControl, repla
2805
3340
  continue;
2806
3341
  }
2807
3342
  const now = new Date().toISOString();
2808
- saveState(candidateStatePath, {
3343
+ const closedConversation = {
2809
3344
  ...current,
2810
3345
  status: "closed",
2811
3346
  closed_at: now,
2812
3347
  close_reason: "terminal bridge superseded by a newer task on the same terminal",
2813
3348
  superseded_by_conversation_id: replacementConversationId,
2814
3349
  updated_at: now
2815
- });
3350
+ };
3351
+ saveState(candidateStatePath, closedConversation);
3352
+ releaseClaudeHookLease(closedConversation);
2816
3353
  appendEvent(logPathForStatePath(candidateStatePath), {
2817
3354
  ts: now,
2818
3355
  conversation_id: current.conversation_id,
@@ -3242,17 +3779,37 @@ async function runRenew(options) {
3242
3779
  throw new Error(`cannot renew ${conversation.conversation_id}; terminal bridge hard lifetime of ${hardTimeoutMinutes} minutes has elapsed`);
3243
3780
  }
3244
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;
3245
3802
  const renewed = {
3246
- ...conversation,
3803
+ ...renewedBase,
3247
3804
  status: "waiting_for_agent",
3248
3805
  native_session_takeover: {
3249
- ...nativeTakeover,
3806
+ ...renewedNativeTakeover,
3250
3807
  terminal_bridge_monitor_started_at: now,
3251
3808
  terminal_bridge_last_activity_at: now,
3252
3809
  terminal_bridge_inactivity_timeout_minutes: inactivityTimeoutMinutes,
3253
3810
  terminal_bridge_hard_timeout_minutes: hardTimeoutMinutes,
3254
- terminal_bridge_inactivity_deadline_at: deadlineAt(now, inactivityTimeoutMinutes),
3255
- terminal_bridge_hard_deadline_at: deadlineAt(startedAt ?? now, hardTimeoutMinutes),
3811
+ terminal_bridge_inactivity_deadline_at: inactivityDeadline,
3812
+ terminal_bridge_hard_deadline_at: hardDeadline,
3256
3813
  terminal_bridge_renewed_at: now
3257
3814
  },
3258
3815
  updated_at: now
@@ -3324,7 +3881,8 @@ async function runCancel(options) {
3324
3881
  options,
3325
3882
  conversationId: terminalConversation.conversationId,
3326
3883
  agent: terminalConversation.agent,
3327
- terminalControl: terminalConversation.terminalControl
3884
+ terminalControl: terminalConversation.terminalControl,
3885
+ pid: terminalConversation.pid
3328
3886
  });
3329
3887
  return;
3330
3888
  }
@@ -3405,14 +3963,23 @@ async function runCancel(options) {
3405
3963
  budget: budgetAction(nextConversation)
3406
3964
  });
3407
3965
  }
3408
- async function runTerminalConversationCancel({ options, conversationId, agent, terminalControl }) {
3409
- const cancellation = await createTerminalAgentBridge(options).cancel(agent, terminalControl);
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)
3974
+ });
3410
3975
  runtimeLog("info", "terminal_cancel_requested", {
3411
3976
  conversation_id: conversationId,
3412
3977
  agent,
3413
3978
  terminal_target: terminalControl.target,
3414
3979
  key: cancellation.key,
3415
3980
  keys: cancellation.keys,
3981
+ denied_approval: cancellation.deniedApproval,
3982
+ request_id: cancellation.requestId,
3416
3983
  cancel_requested: cancellation.cancelRequested,
3417
3984
  reason: cancellation.reason
3418
3985
  });
@@ -3423,11 +3990,16 @@ async function runTerminalConversationCancel({ options, conversationId, agent, t
3423
3990
  reason: cancellation.reason,
3424
3991
  terminal_control: terminalControl,
3425
3992
  key: cancellation.key,
3426
- keys: cancellation.keys
3993
+ keys: cancellation.keys,
3994
+ denied_approval: cancellation.deniedApproval,
3995
+ request_id: cancellation.requestId
3427
3996
  });
3428
3997
  }
3429
3998
  async function runTerminalControlCancel({ options, conversation, statePath, logPath, agent, terminalControl }) {
3430
- const cancellation = await createTerminalAgentBridge(options).cancel(agent, terminalControl);
3999
+ const cancellation = await createTerminalAgentBridge(options).cancel(agent, terminalControl, {
4000
+ runtime: terminalRuntimeIdentityForConversation(conversation, terminalControl),
4001
+ scrollbackLines: Number(options.scrollbackLines ?? 120)
4002
+ });
3431
4003
  if (!cancellation.cancelRequested) {
3432
4004
  printJson({
3433
4005
  conversation,
@@ -3445,27 +4017,36 @@ async function runTerminalControlCancel({ options, conversation, statePath, logP
3445
4017
  event: "terminal_cancel_requested",
3446
4018
  terminal_control: terminalControl,
3447
4019
  key: cancellation.key,
3448
- keys: cancellation.keys
4020
+ keys: cancellation.keys,
4021
+ denied_approval: cancellation.deniedApproval,
4022
+ request_id: cancellation.requestId
3449
4023
  });
3450
4024
  runtimeLog("info", "terminal_cancel_requested", {
3451
4025
  conversation_id: conversation.conversation_id,
3452
4026
  agent,
3453
4027
  terminal_target: terminalControl.target,
3454
4028
  key: cancellation.key,
3455
- keys: cancellation.keys
4029
+ keys: cancellation.keys,
4030
+ denied_approval: cancellation.deniedApproval,
4031
+ request_id: cancellation.requestId
3456
4032
  });
3457
4033
  const nextConversation = {
3458
4034
  ...conversation,
4035
+ status: "cancelled",
4036
+ cancelled_at: now,
3459
4037
  terminal_cancel_requested_at: now,
3460
4038
  updated_at: now
3461
4039
  };
3462
4040
  saveState(statePath, nextConversation);
4041
+ releaseClaudeHookLease(nextConversation);
3463
4042
  printJson({
3464
4043
  conversation: nextConversation,
3465
4044
  cancel_requested: true,
3466
4045
  terminal_control: terminalControl,
3467
4046
  key: cancellation.key,
3468
4047
  keys: cancellation.keys,
4048
+ denied_approval: cancellation.deniedApproval,
4049
+ request_id: cancellation.requestId,
3469
4050
  budget: budgetAction(nextConversation)
3470
4051
  });
3471
4052
  }
@@ -3647,6 +4228,7 @@ function runClose(options) {
3647
4228
  updated_at: now
3648
4229
  };
3649
4230
  saveState(statePath, closed);
4231
+ releaseClaudeHookLease(closed);
3650
4232
  appendEvent(logPath, {
3651
4233
  ts: now,
3652
4234
  conversation_id: conversation.conversation_id,
@@ -3977,6 +4559,7 @@ async function runTerminalBridgeMonitor(options) {
3977
4559
  }
3978
4560
  const requestText = String(nativeTakeover?.["terminal_bridge_request_text"] ?? conversation.user_request ?? "");
3979
4561
  const startedAt = stringValue(nativeTakeover?.["terminal_bridge_started_at"]);
4562
+ const terminalRuntime = terminalRuntimeIdentityForConversation(conversation, terminalControl);
3980
4563
  const screenChangedSinceSend = preSendScreenFingerprint !== undefined &&
3981
4564
  previousScreenFingerprint !== undefined &&
3982
4565
  previousScreenFingerprint !== preSendScreenFingerprint;
@@ -3986,19 +4569,114 @@ async function runTerminalBridgeMonitor(options) {
3986
4569
  screenOptions: {
3987
4570
  scrollbackLines: Number(options.scrollbackLines ?? 120),
3988
4571
  requestText,
3989
- screenChangedSinceSend
4572
+ screenChangedSinceSend,
4573
+ runtime: terminalRuntime
3990
4574
  },
3991
4575
  durableRequest: {
3992
- sessionId: stringValue(nativeTakeover?.["native_session_id"]),
4576
+ sessionId: terminalRuntime.sessionId,
3993
4577
  cwd: stringValue(nativeTakeover?.["source_cwd"]),
3994
4578
  requestText,
3995
4579
  requestHash: stringValue(nativeTakeover?.["terminal_bridge_request_hash"]),
3996
4580
  startedAt,
3997
- context: { conversation, nativeTakeover }
4581
+ context: {
4582
+ conversation,
4583
+ nativeTakeover,
4584
+ ...terminalRuntime
4585
+ }
3998
4586
  }
3999
4587
  });
4000
4588
  const terminalStatus = poll.status;
4001
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
+ }
4002
4680
  if (isRecord(approval) && approval.blocked === true) {
4003
4681
  const fingerprint = terminalBridgeApprovalFingerprint({ terminalControl, terminalStatus });
4004
4682
  appendEvent(logPath, {
@@ -4150,55 +4828,98 @@ async function runTerminalBridgeMonitor(options) {
4150
4828
  : undefined;
4151
4829
  const completionStable = completionFingerprint !== undefined && completionFingerprint === idleCompletionFingerprint;
4152
4830
  idleCompletionFingerprint = completionFingerprint;
4153
- if (completion && completionStable) {
4154
- appendEvent(logPath, {
4155
- ts: new Date().toISOString(),
4156
- conversation_id: conversation.conversation_id,
4157
- event: "terminal_bridge_completion_detected",
4158
- terminal_control: terminalControl,
4159
- match: completionMatch,
4160
- completion_source: completion.source,
4161
- completion_id: completion.id,
4162
- terminal_session: completionMetadata.session,
4163
- context_match: completionMetadata.context_match,
4164
- assistant_timestamp: completion?.timestamp,
4165
- rollout_turn_id: completion.source === "durable" ? completion.id : undefined,
4166
- 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
4167
4838
  });
4168
- const callbackMessage = createMessage({
4169
- conversation,
4170
- from: executor.actor,
4171
- to: "openclaw",
4172
- type: "done",
4173
- requiresResponse: false,
4174
- body: completion?.text ?? "",
4175
- metadata: {
4176
- 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",
4177
4865
  terminal_control: terminalControl,
4178
- ...completionMetadata,
4866
+ match: completionMatch,
4179
4867
  completion_source: completion.source,
4868
+ completion_outcome: completionOutcome,
4180
4869
  completion_id: completion.id,
4181
4870
  terminal_session: completionMetadata.session,
4182
- confidence: completion.confidence,
4183
- match: completionMatch,
4871
+ context_match: completionMetadata.context_match,
4184
4872
  assistant_timestamp: completion?.timestamp,
4185
4873
  rollout_turn_id: completion.source === "durable" ? completion.id : undefined,
4186
- terminal_bridge_message_id: currentMessageId
4187
- }
4188
- });
4189
- runLockedCallback({
4190
- ...options,
4191
- statePath,
4192
- log: logPath,
4193
- closeTerminalBridgeOnDone: true,
4194
- messageJson: JSON.stringify(callbackMessage),
4195
- gatewayMethod: conversation.gateway_method,
4196
- gatewaySession: conversation.gateway_session,
4197
- openclawSession: conversation.openclaw_session,
4198
- openclawBin: conversation.openclaw_bin,
4199
- gatewayUrl: stringValue(conversation.gateway_token) ? conversation.gateway_url : undefined,
4200
- token: stringValue(conversation.gateway_token)
4201
- });
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
+ }
4202
4923
  return;
4203
4924
  }
4204
4925
  // A concrete approval or completion observed on this poll wins over a timeout boundary.
@@ -4302,6 +5023,111 @@ function terminalBridgeRequestFingerprint(value) {
4302
5023
  const text = String(value ?? "").replace(/\s+/gu, " ").trim();
4303
5024
  return text ? createHash("sha256").update(text).digest("hex") : undefined;
4304
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
+ }
4305
5131
  function terminalBridgeActivityPersistIntervalMs(timeoutMinutes, pollIntervalMs) {
4306
5132
  if (!Number.isFinite(timeoutMinutes) || timeoutMinutes <= 0) {
4307
5133
  return 5 * 60 * 1000;
@@ -4330,6 +5156,13 @@ function persistTerminalBridgeActivity({ conversation, statePath, logPath, obser
4330
5156
  const inactivityDeadlineAt = Number.isFinite(timeoutMinutes) && timeoutMinutes > 0
4331
5157
  ? new Date(observedAtMs + timeoutMinutes * 60 * 1000).toISOString()
4332
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;
4333
5166
  const nextConversation = {
4334
5167
  ...currentConversation,
4335
5168
  native_session_takeover: {
@@ -4338,7 +5171,8 @@ function persistTerminalBridgeActivity({ conversation, statePath, logPath, obser
4338
5171
  terminal_bridge_last_activity_reason: reason,
4339
5172
  terminal_bridge_inactivity_deadline_at: inactivityDeadlineAt,
4340
5173
  terminal_bridge_inactivity_timeout_minutes: timeoutMinutes,
4341
- 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
4342
5176
  },
4343
5177
  updated_at: observedAt
4344
5178
  };
@@ -4380,9 +5214,12 @@ function terminalBridgeApprovalCandidate({ executor, terminalControl, terminalSt
4380
5214
  agent: executor.kind,
4381
5215
  kind: stringValue(approval.prompt_kind) ?? "unknown",
4382
5216
  command: stringValue(approval.command),
5217
+ tool_name: stringValue(approval.tool_name),
5218
+ request_detail: stringValue(approval.request_detail),
4383
5219
  cwd: terminalControl.currentPath,
4384
5220
  fingerprint,
4385
- terminal_target: terminalControl.target
5221
+ terminal_target: terminalControl.target,
5222
+ decision_mode: stringValue(approval.decision_mode)
4386
5223
  };
4387
5224
  }
4388
5225
  async function loadCodexTerminalContext({ conversation, nativeTakeover, options }) {
@@ -4595,7 +5432,7 @@ function runLockedCallback(options) {
4595
5432
  const logPath = expandHome(options.log ?? logPathForStatePath(options.statePath));
4596
5433
  const conversation = loadState(options.statePath);
4597
5434
  const executor = executorForConversation(conversation);
4598
- const message = options.retryPending === true
5435
+ const message = options.retryPending === true || options.preserveMessageId === true
4599
5436
  ? parseMessageJson(messageInput)
4600
5437
  : extractStructuredMessage({
4601
5438
  conversation,
@@ -4614,7 +5451,11 @@ function runLockedCallback(options) {
4614
5451
  isRecord(callbackDelivery?.message) &&
4615
5452
  callbackDelivery.message.id === message.id &&
4616
5453
  ["pending", "failed"].includes(String(callbackDelivery.status ?? ""));
4617
- if (isDuplicateMessage(existingEvents, message) && !retryingPending) {
5454
+ const duplicateMessage = isDuplicateMessage(existingEvents, message);
5455
+ const recoveringTerminalCompletion = options.recoverTerminalCompletion === true &&
5456
+ duplicateMessage &&
5457
+ isWaitingForAgent(conversation.status);
5458
+ if (duplicateMessage && !retryingPending && !recoveringTerminalCompletion) {
4618
5459
  runtimeLog("info", "callback_duplicate", {
4619
5460
  conversation_id: conversation.conversation_id,
4620
5461
  agent: executor.kind,
@@ -4636,15 +5477,23 @@ function runLockedCallback(options) {
4636
5477
  }
4637
5478
  const closeTerminalBridgeOnDone = message.type === "done" &&
4638
5479
  options.closeTerminalBridgeOnDone === true;
5480
+ const trackCallbackDelivery = closeTerminalBridgeOnDone ||
5481
+ options.trackCallbackDelivery === true ||
5482
+ callbackDelivery?.track_delivery === true;
4639
5483
  const requiresDelivery = Boolean(options.gatewayMethod) || options.recordOnly !== true;
4640
5484
  const deliveryAttempt = Number(callbackDelivery?.attempts ?? 0) + 1;
4641
5485
  let nextConversation = retryingPending
4642
5486
  ? conversation
4643
5487
  : applyMessageToConversation(conversation, message);
4644
- if (!retryingPending) {
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) {
4645
5494
  appendEvent(logPath, messageEvent(message));
4646
5495
  }
4647
- if (closeTerminalBridgeOnDone && requiresDelivery) {
5496
+ if (trackCallbackDelivery && requiresDelivery) {
4648
5497
  const now = new Date().toISOString();
4649
5498
  nextConversation = {
4650
5499
  ...nextConversation,
@@ -4659,7 +5508,9 @@ function runLockedCallback(options) {
4659
5508
  gateway_session: options.gatewaySession ?? options.openclawSession ?? conversation.openclaw_session,
4660
5509
  gateway_url: options.gatewayUrl ?? conversation.gateway_url,
4661
5510
  openclaw_bin: options.openclawBin ?? conversation.openclaw_bin,
4662
- close_terminal_bridge_on_done: true
5511
+ close_terminal_bridge_on_done: closeTerminalBridgeOnDone,
5512
+ track_delivery: true,
5513
+ final_status: finalStatus
4663
5514
  },
4664
5515
  updated_at: now
4665
5516
  };
@@ -4712,12 +5563,17 @@ function runLockedCallback(options) {
4712
5563
  });
4713
5564
  const deliveredAt = new Date().toISOString();
4714
5565
  let deliveredConversation = nextConversation;
4715
- if (closeTerminalBridgeOnDone) {
5566
+ if (trackCallbackDelivery) {
5567
+ const deliveredStatus = closeTerminalBridgeOnDone ? "closed" : finalStatus;
4716
5568
  deliveredConversation = {
4717
5569
  ...nextConversation,
4718
- status: "closed",
4719
- closed_at: deliveredAt,
4720
- close_reason: "terminal bridge task completed",
5570
+ status: deliveredStatus,
5571
+ ...(closeTerminalBridgeOnDone
5572
+ ? {
5573
+ closed_at: deliveredAt,
5574
+ close_reason: "terminal bridge task completed"
5575
+ }
5576
+ : {}),
4721
5577
  callback_delivery: {
4722
5578
  ...(isRecord(nextConversation.callback_delivery) ? nextConversation.callback_delivery : {}),
4723
5579
  status: "delivered",
@@ -4734,7 +5590,7 @@ function runLockedCallback(options) {
4734
5590
  event: "callback_delivery_succeeded",
4735
5591
  message_id: message.id,
4736
5592
  attempt: deliveryAttempt,
4737
- status: "closed"
5593
+ status: deliveredStatus
4738
5594
  });
4739
5595
  }
4740
5596
  printJson({
@@ -4747,7 +5603,7 @@ function runLockedCallback(options) {
4747
5603
  });
4748
5604
  }
4749
5605
  catch (error) {
4750
- if (closeTerminalBridgeOnDone) {
5606
+ if (trackCallbackDelivery) {
4751
5607
  const failedAt = new Date().toISOString();
4752
5608
  const failedConversation = {
4753
5609
  ...nextConversation,
@@ -4919,6 +5775,8 @@ function acquireFileLock(lockPath, { timeoutMs = 5000, retryMs = 50 } = {}) {
4919
5775
  while (true) {
4920
5776
  try {
4921
5777
  const fd = fs.openSync(lockPath, "wx");
5778
+ fs.writeFileSync(fd, `${process.pid}\n`, "utf8");
5779
+ fs.fsyncSync(fd);
4922
5780
  fs.closeSync(fd);
4923
5781
  return () => {
4924
5782
  fs.rmSync(lockPath, { force: true });
@@ -4928,6 +5786,10 @@ function acquireFileLock(lockPath, { timeoutMs = 5000, retryMs = 50 } = {}) {
4928
5786
  if (error.code !== "EEXIST") {
4929
5787
  throw error;
4930
5788
  }
5789
+ if (staleFileLock(lockPath)) {
5790
+ fs.rmSync(lockPath, { force: true });
5791
+ continue;
5792
+ }
4931
5793
  if (Date.now() - started >= timeoutMs) {
4932
5794
  throw new Error(`timed out waiting for file lock: ${lockPath}`);
4933
5795
  }
@@ -4935,6 +5797,26 @@ function acquireFileLock(lockPath, { timeoutMs = 5000, retryMs = 50 } = {}) {
4935
5797
  }
4936
5798
  }
4937
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
+ }
4938
5820
  function sleepSync(ms) {
4939
5821
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
4940
5822
  }
@@ -5497,6 +6379,7 @@ function markConversationStalled({ statePath, logPath, reason, detail = {} }) {
5497
6379
  updated_at: now
5498
6380
  };
5499
6381
  saveState(statePath, stalledConversation);
6382
+ releaseClaudeHookLease(stalledConversation);
5500
6383
  appendEvent(logPath, {
5501
6384
  ts: now,
5502
6385
  conversation_id: conversation.conversation_id,
@@ -6209,6 +7092,7 @@ function usage() {
6209
7092
  agent-knock-knock recover --conversation <id> [--session <name>] [--all-proxy <url>]
6210
7093
  agent-knock-knock close --conversation <id> [--reason <text>]
6211
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]
6212
7096
  agent-knock-knock doctor
6213
7097
  agent-knock-knock agent takeover --agent codex --session-id <id> --strategy terminate_then_resume|terminal_control|fork [--create-conversation]
6214
7098
  agent-knock-knock callback --state <file> --message-json <json> [--record-only]