@scotthuang/agent-knock-knock 0.6.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/src/cli.js CHANGED
@@ -14,7 +14,7 @@ import { applyMessageToConversation, budgetAction, createConversation, createMes
14
14
  import { EXECUTOR_KINDS, executorDefinitionForKind, isExecutorKind } from "./executors.js";
15
15
  import { redactString, writeRuntimeLog } from "./runtime-log.js";
16
16
  import { formatTranscript, readNdjsonLog } from "./transcript.js";
17
- import { appendEvent, defaultStoreDir, ensureDir, listConversations, logPathForStatePath, loadConversationById, loadState, messageEvent, pathsForConversation, pathsForConversationDir, saveState, statePathForConversationId } from "./store.js";
17
+ import { appendEvent, assertStoreWriterCompatible, defaultStoreDir, ensureDir, ensureStoreWritable, inspectStoreCompatibility, listConversations, logPathForStatePath, loadConversationById, loadState, messageEvent, pathsForConversation, pathsForConversationDir, saveState, statePathForConversationId, withStoreWriterLeaseAsync } from "./store.js";
18
18
  import { StaticTerminalControlProvider, TmuxTerminalControlProvider, terminalPaneContainsProcess } from "./terminal-control-provider.js";
19
19
  import { parseTerminalConversationId } from "./terminal-agent-adapter.js";
20
20
  import { createProductionTerminalAgentRegistry } from "./terminal-agent-registry.js";
@@ -77,6 +77,18 @@ const SESSION_SELECTOR_COMMANDS = new Set([
77
77
  "retry-callback",
78
78
  "close"
79
79
  ]);
80
+ const STORE_MUTATION_COMMANDS = new Set([
81
+ "delegate",
82
+ "send",
83
+ "approve",
84
+ "cancel",
85
+ "renew",
86
+ "reconcile-monitors",
87
+ "close",
88
+ "callback",
89
+ "retry-callback",
90
+ "monitor"
91
+ ]);
80
92
  class InlineCodexSessionAdapter {
81
93
  threads;
82
94
  processes;
@@ -132,6 +144,7 @@ catch (error) {
132
144
  }
133
145
  async function runCommand(commandName, options) {
134
146
  await resolveConversationSelectorOption(commandName, options);
147
+ preflightStoreWriter(commandName, options);
135
148
  if (commandName === "help" || commandName === "--help" || commandName === "-h") {
136
149
  usage();
137
150
  }
@@ -188,6 +201,16 @@ async function runCommand(commandName, options) {
188
201
  process.exitCode = commandName ? 1 : 0;
189
202
  }
190
203
  }
204
+ function preflightStoreWriter(commandName, options) {
205
+ if (!STORE_MUTATION_COMMANDS.has(String(commandName ?? ""))) {
206
+ return;
207
+ }
208
+ const statePath = stringValue(options.state);
209
+ const storeDir = statePath
210
+ ? pathsForConversationDir(path.dirname(expandHome(statePath))).storeDir
211
+ : storeDirFromOptions(options);
212
+ assertStoreWriterCompatible(storeDir);
213
+ }
191
214
  function runInstallOpenClaw(options) {
192
215
  const root = packageRootDir();
193
216
  const skillOnly = options.skillOnly === true;
@@ -1327,7 +1350,12 @@ function environmentWithoutGatewayTokens() {
1327
1350
  }
1328
1351
  async function runList(options) {
1329
1352
  const storeDir = expandHome(options.storeDir ?? options.logDir ?? defaultStoreDir(process.cwd()));
1330
- const cleanup = cleanupIdleConversations(storeDir, options);
1353
+ const reconciliation = options.reconcile === true
1354
+ ? await reconcileStoreForList(storeDir, options)
1355
+ : {
1356
+ status: "disabled",
1357
+ reason: "standalone list is read-only unless --reconcile is supplied"
1358
+ };
1331
1359
  const includeAll = Boolean(options.all);
1332
1360
  const agentFilter = options.agent ? resolveExecutor({ kind: options.agent }).kind : undefined;
1333
1361
  const statusFilter = options.status;
@@ -1361,7 +1389,8 @@ async function runList(options) {
1361
1389
  });
1362
1390
  printJson({
1363
1391
  store_dir: storeDir,
1364
- cleanup,
1392
+ store: inspectStoreCompatibility(storeDir),
1393
+ reconciliation,
1365
1394
  action_contracts: listActionContracts(),
1366
1395
  delegated,
1367
1396
  terminal_controlled: terminalControlled,
@@ -1379,8 +1408,40 @@ async function runList(options) {
1379
1408
  include_all: includeAll,
1380
1409
  agent_filter: agentFilter,
1381
1410
  status_filter: statusFilter,
1382
- cleanup
1411
+ reconciliation
1412
+ });
1413
+ }
1414
+ async function reconcileStoreForList(storeDir, options) {
1415
+ try {
1416
+ ensureStoreWritable(storeDir);
1417
+ }
1418
+ catch (error) {
1419
+ if (isRecord(error) && error.code === "AKK_STORE_INCOMPATIBLE") {
1420
+ return {
1421
+ status: "skipped",
1422
+ reason: error instanceof Error ? error.message : String(error),
1423
+ store: inspectStoreCompatibility(storeDir)
1424
+ };
1425
+ }
1426
+ throw error;
1427
+ }
1428
+ const idle = reconcileIdleConversations(storeDir, options);
1429
+ const monitors = await reconcileMonitors(options, {
1430
+ includeCallbackRecovery: false,
1431
+ reason: "list_reconciliation",
1432
+ conversationId: undefined
1383
1433
  });
1434
+ return {
1435
+ status: "completed",
1436
+ checked: Math.max(idle.checked, monitors.checked),
1437
+ changed: idle.closed + monitors.launched,
1438
+ closed: idle.closed,
1439
+ monitors_launched: monitors.launched,
1440
+ monitors_already_running: monitors.already_running,
1441
+ skipped: idle.skipped + monitors.skipped,
1442
+ errors: monitors.errors,
1443
+ idle_timeout_minutes: idle.idle_timeout_minutes
1444
+ };
1384
1445
  }
1385
1446
  async function buildTerminalListGroup({ options, agentFilter, statusFilter }) {
1386
1447
  const empty = {
@@ -1640,13 +1701,34 @@ function managedListApprovalState(conversation) {
1640
1701
  }
1641
1702
  function listActionContracts() {
1642
1703
  return {
1643
- version: 1,
1704
+ version: 2,
1644
1705
  instructions: [
1645
1706
  "Use only actions present in delegated[].available_actions or terminal_controlled[].available_actions.",
1707
+ "Never use commands for routing or tool calls. It is a deprecated, non-authoritative compatibility field with mixed legacy semantics.",
1646
1708
  "Start with the action's prefilled arguments, supply every missing_required field, and consult the top-level action's optional fields only when needed.",
1647
1709
  "Authoritative full IDs are prefilled; short_ref is for display and human input.",
1648
1710
  "Availability is a snapshot. AKK revalidates process, tmux pane, workspace, activity, approval, and recovery state before side effects."
1649
1711
  ],
1712
+ field_semantics: {
1713
+ status: {
1714
+ delegated: "task_lifecycle",
1715
+ terminal_controlled: "process_liveness",
1716
+ authoritative_for_tool_calls: false
1717
+ },
1718
+ activity_state: {
1719
+ terminal_controlled: "screen_activity_classification",
1720
+ authoritative_for_tool_calls: false
1721
+ },
1722
+ commands: {
1723
+ meaning: "legacy_compatibility_flags_with_mixed_semantics",
1724
+ deprecated: true,
1725
+ authoritative_for_tool_calls: false
1726
+ },
1727
+ available_actions: {
1728
+ meaning: "currently_safe_actions",
1729
+ authoritative_for_tool_calls: true
1730
+ }
1731
+ },
1650
1732
  actions: {
1651
1733
  send: {
1652
1734
  tool: "agent_knock_knock_send",
@@ -1841,7 +1923,6 @@ function isSessionSelectorSyntax(value) {
1841
1923
  }
1842
1924
  async function sessionSelectorCandidates(commandName, options) {
1843
1925
  const storeDir = storeDirFromOptions(options);
1844
- cleanupIdleConversations(storeDir, options);
1845
1926
  const storedConversations = listConversations(storeDir);
1846
1927
  const workspaceConversations = storedConversations
1847
1928
  .filter((conversation) => matchesConfiguredWorkspace(options.workspace, conversation.workspace));
@@ -1935,7 +2016,23 @@ async function resolveTerminalConversationFromOptions(options) {
1935
2016
  return createTerminalAgentBridge(options).resolveConversationId(stringValue(options.conversation ?? options.conversationId));
1936
2017
  }
1937
2018
  async function runStatus(options) {
1938
- cleanupIdleConversations(storeDirFromOptions(options), options);
2019
+ const explicitStatePath = options.state
2020
+ ? expandHome(String(options.state))
2021
+ : undefined;
2022
+ const storeDir = explicitStatePath
2023
+ ? pathsForConversationDir(path.dirname(explicitStatePath)).storeDir
2024
+ : storeDirFromOptions(options);
2025
+ const reconciliationConversationId = stringValue(options.conversation ?? options.conversationId) ??
2026
+ (explicitStatePath
2027
+ ? path.basename(pathsForConversationDir(path.dirname(explicitStatePath))
2028
+ .conversationDir)
2029
+ : undefined);
2030
+ const reconciliation = options.reconcile === true
2031
+ ? await reconcileStoreForStatus(storeDir, options, reconciliationConversationId)
2032
+ : {
2033
+ status: "disabled",
2034
+ reason: "standalone status is read-only unless --reconcile is supplied"
2035
+ };
1939
2036
  const terminalConversation = await resolveTerminalConversationFromOptions(options);
1940
2037
  if (terminalConversation) {
1941
2038
  const terminalStatus = await terminalStatusForControl(terminalConversation.agent, terminalConversation.terminalControl, options, {
@@ -1949,6 +2046,8 @@ async function runStatus(options) {
1949
2046
  conversation_id: terminalConversation.conversationId,
1950
2047
  source: "terminal_control",
1951
2048
  agent: terminalConversation.agent,
2049
+ store: inspectStoreCompatibility(storeDir),
2050
+ reconciliation,
1952
2051
  ...context,
1953
2052
  terminal_control: terminalConversation.terminalControl,
1954
2053
  terminal_status: terminalStatus,
@@ -1963,13 +2062,12 @@ async function runStatus(options) {
1963
2062
  }
1964
2063
  const loaded = loadConversationFromOptions(options);
1965
2064
  const { statePath, logPath } = loaded;
1966
- const conversation = await migrateLegacyTerminalAgentIdentity({
1967
- ...loaded,
1968
- options
1969
- });
2065
+ const conversation = loaded.conversation;
1970
2066
  const events = readExistingEvents(logPath);
1971
2067
  const result = {
1972
2068
  conversation,
2069
+ store: inspectStoreCompatibility(storeDir),
2070
+ reconciliation,
1973
2071
  summary: summarizeConversation(conversation),
1974
2072
  confidence: "high",
1975
2073
  about: managedConversationAbout(conversation, events),
@@ -2006,6 +2104,38 @@ async function runStatus(options) {
2006
2104
  trace: Boolean(options.trace)
2007
2105
  });
2008
2106
  }
2107
+ async function reconcileStoreForStatus(storeDir, options, conversationId) {
2108
+ try {
2109
+ ensureStoreWritable(storeDir);
2110
+ }
2111
+ catch (error) {
2112
+ if (isRecord(error) && error.code === "AKK_STORE_INCOMPATIBLE") {
2113
+ return {
2114
+ status: "skipped",
2115
+ reason: error instanceof Error ? error.message : String(error),
2116
+ store: inspectStoreCompatibility(storeDir)
2117
+ };
2118
+ }
2119
+ throw error;
2120
+ }
2121
+ const idle = reconcileIdleConversations(storeDir, options, new Date(), conversationId);
2122
+ const monitors = await reconcileMonitors(options, {
2123
+ includeCallbackRecovery: false,
2124
+ reason: "status_reconciliation",
2125
+ conversationId
2126
+ });
2127
+ return {
2128
+ status: "completed",
2129
+ checked: Math.max(idle.checked, monitors.checked),
2130
+ changed: idle.closed + monitors.launched,
2131
+ closed: idle.closed,
2132
+ monitors_launched: monitors.launched,
2133
+ monitors_already_running: monitors.already_running,
2134
+ skipped: idle.skipped + monitors.skipped,
2135
+ errors: monitors.errors,
2136
+ idle_timeout_minutes: idle.idle_timeout_minutes
2137
+ };
2138
+ }
2009
2139
  async function terminalStatusContext(terminalConversation, terminalStatus, options) {
2010
2140
  if (terminalConversation.agent === "codex") {
2011
2141
  try {
@@ -2476,7 +2606,6 @@ async function runSend(options) {
2476
2606
  if (options.agentHardTimeoutMinutes !== undefined) {
2477
2607
  positiveMinutes(options.agentHardTimeoutMinutes, "--agent-hard-timeout-minutes");
2478
2608
  }
2479
- cleanupIdleConversations(storeDirFromOptions(options), options);
2480
2609
  const terminalConversation = await resolveTerminalConversationFromOptions(options);
2481
2610
  if (terminalConversation) {
2482
2611
  if (!options.background) {
@@ -2493,6 +2622,7 @@ async function runSend(options) {
2493
2622
  messageBody,
2494
2623
  terminalControl: terminalConversation.terminalControl
2495
2624
  });
2625
+ ensureStoreWritable(managed.conversation.store_dir);
2496
2626
  ensureDir(path.dirname(managed.statePath));
2497
2627
  releaseStateLock = acquireFileLock(`${managed.statePath}.lock`);
2498
2628
  await runTerminalControlSend({
@@ -2578,7 +2708,6 @@ async function runSend(options) {
2578
2708
  throw new Error(`conversation ${migratedConversation.conversation_id} is not attached to a live tmux terminal`);
2579
2709
  }
2580
2710
  async function runApprove(options) {
2581
- cleanupIdleConversations(storeDirFromOptions(options), options);
2582
2711
  const terminalConversation = await resolveTerminalConversationFromOptions(options);
2583
2712
  if (terminalConversation) {
2584
2713
  await runTerminalConversationApprove({
@@ -2752,6 +2881,7 @@ async function runApprove(options) {
2752
2881
  }
2753
2882
  };
2754
2883
  let releaseStateLock;
2884
+ let approvalDispatchReserved = false;
2755
2885
  const releaseApprovalStateLock = () => {
2756
2886
  if (releaseStateLock) {
2757
2887
  const release = releaseStateLock;
@@ -2759,323 +2889,330 @@ async function runApprove(options) {
2759
2889
  release();
2760
2890
  }
2761
2891
  };
2892
+ releaseStateLock = acquireFileLock(`${statePath}.lock`);
2893
+ const writerStoreDir = pathsForConversationDir(path.dirname(statePath)).storeDir;
2762
2894
  try {
2763
- let approval;
2764
- let lockedConversation = conversation;
2765
- const currentConversation = loadState(statePath);
2766
- const currentTakeover = isRecord(currentConversation.native_session_takeover)
2767
- ? currentConversation.native_session_takeover
2768
- : undefined;
2769
- const currentControl = terminalControlFromTakeover(currentTakeover);
2770
- const currentApproval = isRecord(currentTakeover?.terminal_bridge_approval)
2771
- ? currentTakeover.terminal_bridge_approval
2772
- : undefined;
2773
- if (currentConversation.status !== conversation.status ||
2774
- currentTakeover?.terminal_bridge_message_id !== nativeTakeover?.terminal_bridge_message_id ||
2775
- currentControl?.target !== terminalControl.target ||
2776
- currentControl?.socketPath !== terminalControl.socketPath ||
2777
- (claudeScreenApproval &&
2778
- currentApproval?.fingerprint !== monitoredApproval?.fingerprint)) {
2779
- throw new Error("approval state changed while waiting for terminal control; refresh status and retry");
2780
- }
2781
- assertManagedTerminalDispatchOwner({
2782
- conversation: currentConversation,
2783
- terminalControl: currentControl,
2784
- action: "approve"
2785
- });
2786
- lockedConversation = currentConversation;
2787
- approval = await createTerminalAgentBridge(options).approve(executor.kind, terminalControl, {
2788
- expectedFingerprint,
2789
- scrollbackLines: Number(options.scrollbackLines ?? 120),
2790
- runtime: runtimeIdentity,
2791
- managedRequest: terminalDurableRequestForConversation(currentConversation, terminalControl),
2792
- requiredDecisionMode: autoApproved && executor.kind === "claude" ? "keys" : undefined,
2793
- authorize: autoApproved
2794
- ? ({ agent, terminalControl: currentTerminalControl, inspection, fingerprint }) => {
2795
- if (!autoApprovalPolicy) {
2796
- return {
2797
- approved: false,
2798
- reason: "automatic approval requires an executor-side policy"
2799
- };
2800
- }
2801
- const candidate = policyCandidateForInspection({
2802
- agent,
2803
- currentTerminalControl,
2804
- inspection,
2805
- fingerprint
2806
- });
2807
- executorPolicyDecision = evaluateApprovalPolicy({
2808
- policy: autoApprovalPolicy,
2809
- candidate
2810
- });
2811
- if (executorPolicyDecision.action !== "approve") {
2812
- return {
2813
- approved: false,
2814
- reason: `executor-side auto-approval policy rejected the current request: ${executorPolicyDecision.reason}`
2815
- };
2816
- }
2817
- if (policyRuleId && executorPolicyDecision.ruleId !== policyRuleId) {
2818
- return {
2819
- approved: false,
2820
- reason: "executor-side auto-approval rule changed before execution"
2821
- };
2822
- }
2823
- if (policyFingerprint &&
2824
- executorPolicyDecision.policyFingerprint !== policyFingerprint) {
2825
- return {
2826
- approved: false,
2827
- reason: "executor-side auto-approval policy changed before execution"
2828
- };
2829
- }
2830
- return { approved: true };
2831
- }
2832
- : undefined,
2833
- beforeKeyDispatch: claudeScreenApproval
2834
- ? ({ fingerprint, terminalControl: dispatchControl, inspection, keys }) => {
2835
- if (autoApproved) {
2895
+ return await withStoreWriterLeaseAsync(writerStoreDir, async () => {
2896
+ let approval;
2897
+ let lockedConversation = conversation;
2898
+ const currentConversation = loadState(statePath);
2899
+ const currentTakeover = isRecord(currentConversation.native_session_takeover)
2900
+ ? currentConversation.native_session_takeover
2901
+ : undefined;
2902
+ const currentControl = terminalControlFromTakeover(currentTakeover);
2903
+ const currentApproval = isRecord(currentTakeover?.terminal_bridge_approval)
2904
+ ? currentTakeover.terminal_bridge_approval
2905
+ : undefined;
2906
+ if (currentConversation.status !== conversation.status ||
2907
+ currentTakeover?.terminal_bridge_message_id !== nativeTakeover?.terminal_bridge_message_id ||
2908
+ currentControl?.target !== terminalControl.target ||
2909
+ currentControl?.socketPath !== terminalControl.socketPath ||
2910
+ (claudeScreenApproval &&
2911
+ currentApproval?.fingerprint !== monitoredApproval?.fingerprint)) {
2912
+ throw new Error("approval state changed while waiting for terminal control; refresh status and retry");
2913
+ }
2914
+ assertManagedTerminalDispatchOwner({
2915
+ conversation: currentConversation,
2916
+ terminalControl: currentControl,
2917
+ action: "approve"
2918
+ });
2919
+ lockedConversation = currentConversation;
2920
+ approval = await createTerminalAgentBridge(options).approve(executor.kind, terminalControl, {
2921
+ expectedFingerprint,
2922
+ scrollbackLines: Number(options.scrollbackLines ?? 120),
2923
+ runtime: runtimeIdentity,
2924
+ managedRequest: terminalDurableRequestForConversation(currentConversation, terminalControl),
2925
+ requiredDecisionMode: autoApproved && executor.kind === "claude" ? "keys" : undefined,
2926
+ authorize: autoApproved
2927
+ ? ({ agent, terminalControl: currentTerminalControl, inspection, fingerprint }) => {
2836
2928
  if (!autoApprovalPolicy) {
2837
- throw new Error("automatic approval requires an executor-side policy before dispatch");
2929
+ return {
2930
+ approved: false,
2931
+ reason: "automatic approval requires an executor-side policy"
2932
+ };
2838
2933
  }
2839
- const freshPolicyDecision = evaluateApprovalPolicy({
2934
+ const candidate = policyCandidateForInspection({
2935
+ agent,
2936
+ currentTerminalControl,
2937
+ inspection,
2938
+ fingerprint
2939
+ });
2940
+ executorPolicyDecision = evaluateApprovalPolicy({
2840
2941
  policy: autoApprovalPolicy,
2841
- candidate: policyCandidateForInspection({
2842
- agent: executor.kind,
2843
- currentTerminalControl: dispatchControl,
2844
- inspection,
2845
- fingerprint
2846
- })
2942
+ candidate
2847
2943
  });
2848
- if (freshPolicyDecision.action !== "approve") {
2849
- throw new Error(`executor-side auto-approval policy rejected the recaptured request: ${freshPolicyDecision.reason}`);
2944
+ if (executorPolicyDecision.action !== "approve") {
2945
+ return {
2946
+ approved: false,
2947
+ reason: `executor-side auto-approval policy rejected the current request: ${executorPolicyDecision.reason}`
2948
+ };
2850
2949
  }
2851
- if (executorPolicyDecision?.ruleId &&
2852
- freshPolicyDecision.ruleId !== executorPolicyDecision.ruleId) {
2853
- throw new Error("executor-side auto-approval rule changed after recapture");
2854
- }
2855
- if (policyRuleId && freshPolicyDecision.ruleId !== policyRuleId) {
2856
- throw new Error("executor-side auto-approval rule changed before dispatch");
2950
+ if (policyRuleId && executorPolicyDecision.ruleId !== policyRuleId) {
2951
+ return {
2952
+ approved: false,
2953
+ reason: "executor-side auto-approval rule changed before execution"
2954
+ };
2857
2955
  }
2858
2956
  if (policyFingerprint &&
2859
- freshPolicyDecision.policyFingerprint !== policyFingerprint) {
2860
- throw new Error("executor-side auto-approval policy changed before dispatch");
2957
+ executorPolicyDecision.policyFingerprint !== policyFingerprint) {
2958
+ return {
2959
+ approved: false,
2960
+ reason: "executor-side auto-approval policy changed before execution"
2961
+ };
2861
2962
  }
2862
- executorPolicyDecision = freshPolicyDecision;
2863
- }
2864
- if (releaseStateLock) {
2865
- throw new Error("Claude approval dispatch was already reserved");
2963
+ return { approved: true };
2866
2964
  }
2867
- releaseStateLock = acquireFileLock(`${statePath}.lock`);
2868
- const latestConversation = loadState(statePath);
2869
- const latestTakeover = isRecord(latestConversation.native_session_takeover)
2870
- ? latestConversation.native_session_takeover
2871
- : undefined;
2872
- const latestControl = terminalControlFromTakeover(latestTakeover);
2873
- const latestApproval = isRecord(latestTakeover?.terminal_bridge_approval)
2874
- ? latestTakeover.terminal_bridge_approval
2875
- : undefined;
2876
- const latestNotifiedAt = validTimestampMs(latestApproval?.notified_at);
2877
- const latestApprovalState = isRecord(latestApproval?.approval_state)
2878
- ? latestApproval.approval_state
2879
- : undefined;
2880
- const latestPolicyEvidence = isRecord(latestApprovalState?.policy_evidence)
2881
- ? latestApprovalState.policy_evidence
2882
- : undefined;
2883
- const recapturedPolicyEvidence = inspection.approval.approvable
2884
- ? inspection.approval.policyEvidence
2885
- : undefined;
2886
- const latestDispatch = isRecord(latestTakeover?.terminal_bridge_approval_dispatch)
2887
- ? latestTakeover.terminal_bridge_approval_dispatch
2888
- : undefined;
2889
- if (!latestTakeover ||
2890
- latestConversation.status !== "waiting_for_openclaw" ||
2891
- latestTakeover.terminal_bridge_message_id !==
2892
- nativeTakeover?.terminal_bridge_message_id ||
2893
- latestApproval?.fingerprint !== fingerprint ||
2894
- latestNotifiedAt === undefined ||
2895
- Date.now() - latestNotifiedAt > CLAUDE_SCREEN_APPROVAL_TTL_MS ||
2896
- expectedFingerprint !== fingerprint ||
2897
- latestControl?.target !== dispatchControl.target ||
2898
- latestControl?.socketPath !== dispatchControl.socketPath ||
2899
- (autoApproved &&
2900
- (latestPolicyEvidence?.source !== "claude_transcript" ||
2901
- latestPolicyEvidence.evidence_fingerprint !==
2902
- recapturedPolicyEvidence?.evidenceFingerprint))) {
2903
- throw new Error("approval state changed before terminal dispatch; refresh status and retry");
2904
- }
2905
- if (latestDispatch?.state === "reserved" &&
2906
- latestDispatch.terminal_bridge_message_id ===
2907
- latestTakeover.terminal_bridge_message_id) {
2908
- throw new Error("a previous Claude approval dispatch has an uncertain outcome; inspect and resolve the terminal manually");
2909
- }
2910
- const reservedAt = new Date().toISOString();
2911
- const reservedConversation = {
2912
- ...latestConversation,
2913
- native_session_takeover: {
2914
- ...latestTakeover,
2915
- terminal_bridge_approval_dispatch: {
2916
- state: "reserved",
2917
- attempt_id: randomUUID(),
2918
- fingerprint,
2919
- keys,
2920
- terminal_target: dispatchControl.target,
2921
- terminal_bridge_message_id: latestTakeover.terminal_bridge_message_id,
2922
- reserved_at: reservedAt
2965
+ : undefined,
2966
+ beforeKeyDispatch: claudeScreenApproval
2967
+ ? ({ fingerprint, terminalControl: dispatchControl, inspection, keys }) => {
2968
+ if (autoApproved) {
2969
+ if (!autoApprovalPolicy) {
2970
+ throw new Error("automatic approval requires an executor-side policy before dispatch");
2923
2971
  }
2924
- },
2925
- updated_at: reservedAt
2926
- };
2927
- saveState(statePath, reservedConversation);
2928
- lockedConversation = reservedConversation;
2972
+ const freshPolicyDecision = evaluateApprovalPolicy({
2973
+ policy: autoApprovalPolicy,
2974
+ candidate: policyCandidateForInspection({
2975
+ agent: executor.kind,
2976
+ currentTerminalControl: dispatchControl,
2977
+ inspection,
2978
+ fingerprint
2979
+ })
2980
+ });
2981
+ if (freshPolicyDecision.action !== "approve") {
2982
+ throw new Error(`executor-side auto-approval policy rejected the recaptured request: ${freshPolicyDecision.reason}`);
2983
+ }
2984
+ if (executorPolicyDecision?.ruleId &&
2985
+ freshPolicyDecision.ruleId !== executorPolicyDecision.ruleId) {
2986
+ throw new Error("executor-side auto-approval rule changed after recapture");
2987
+ }
2988
+ if (policyRuleId && freshPolicyDecision.ruleId !== policyRuleId) {
2989
+ throw new Error("executor-side auto-approval rule changed before dispatch");
2990
+ }
2991
+ if (policyFingerprint &&
2992
+ freshPolicyDecision.policyFingerprint !== policyFingerprint) {
2993
+ throw new Error("executor-side auto-approval policy changed before dispatch");
2994
+ }
2995
+ executorPolicyDecision = freshPolicyDecision;
2996
+ }
2997
+ if (approvalDispatchReserved) {
2998
+ throw new Error("Claude approval dispatch was already reserved");
2999
+ }
3000
+ approvalDispatchReserved = true;
3001
+ if (!releaseStateLock) {
3002
+ throw new Error("approval state lock was released before terminal dispatch");
3003
+ }
3004
+ const latestConversation = loadState(statePath);
3005
+ const latestTakeover = isRecord(latestConversation.native_session_takeover)
3006
+ ? latestConversation.native_session_takeover
3007
+ : undefined;
3008
+ const latestControl = terminalControlFromTakeover(latestTakeover);
3009
+ const latestApproval = isRecord(latestTakeover?.terminal_bridge_approval)
3010
+ ? latestTakeover.terminal_bridge_approval
3011
+ : undefined;
3012
+ const latestNotifiedAt = validTimestampMs(latestApproval?.notified_at);
3013
+ const latestApprovalState = isRecord(latestApproval?.approval_state)
3014
+ ? latestApproval.approval_state
3015
+ : undefined;
3016
+ const latestPolicyEvidence = isRecord(latestApprovalState?.policy_evidence)
3017
+ ? latestApprovalState.policy_evidence
3018
+ : undefined;
3019
+ const recapturedPolicyEvidence = inspection.approval.approvable
3020
+ ? inspection.approval.policyEvidence
3021
+ : undefined;
3022
+ const latestDispatch = isRecord(latestTakeover?.terminal_bridge_approval_dispatch)
3023
+ ? latestTakeover.terminal_bridge_approval_dispatch
3024
+ : undefined;
3025
+ if (!latestTakeover ||
3026
+ latestConversation.status !== "waiting_for_openclaw" ||
3027
+ latestTakeover.terminal_bridge_message_id !==
3028
+ nativeTakeover?.terminal_bridge_message_id ||
3029
+ latestApproval?.fingerprint !== fingerprint ||
3030
+ latestNotifiedAt === undefined ||
3031
+ Date.now() - latestNotifiedAt > CLAUDE_SCREEN_APPROVAL_TTL_MS ||
3032
+ expectedFingerprint !== fingerprint ||
3033
+ latestControl?.target !== dispatchControl.target ||
3034
+ latestControl?.socketPath !== dispatchControl.socketPath ||
3035
+ (autoApproved &&
3036
+ (latestPolicyEvidence?.source !== "claude_transcript" ||
3037
+ latestPolicyEvidence.evidence_fingerprint !==
3038
+ recapturedPolicyEvidence?.evidenceFingerprint))) {
3039
+ throw new Error("approval state changed before terminal dispatch; refresh status and retry");
3040
+ }
3041
+ if (latestDispatch?.state === "reserved" &&
3042
+ latestDispatch.terminal_bridge_message_id ===
3043
+ latestTakeover.terminal_bridge_message_id) {
3044
+ throw new Error("a previous Claude approval dispatch has an uncertain outcome; inspect and resolve the terminal manually");
3045
+ }
3046
+ const reservedAt = new Date().toISOString();
3047
+ const reservedConversation = {
3048
+ ...latestConversation,
3049
+ native_session_takeover: {
3050
+ ...latestTakeover,
3051
+ terminal_bridge_approval_dispatch: {
3052
+ state: "reserved",
3053
+ attempt_id: randomUUID(),
3054
+ fingerprint,
3055
+ keys,
3056
+ terminal_target: dispatchControl.target,
3057
+ terminal_bridge_message_id: latestTakeover.terminal_bridge_message_id,
3058
+ reserved_at: reservedAt
3059
+ }
3060
+ },
3061
+ updated_at: reservedAt
3062
+ };
3063
+ saveState(statePath, reservedConversation);
3064
+ lockedConversation = reservedConversation;
3065
+ }
3066
+ : undefined
3067
+ });
3068
+ const actualFingerprint = approval.fingerprint;
3069
+ const effectivePolicyRuleId = executorPolicyDecision?.ruleId ?? policyRuleId;
3070
+ const effectivePolicyFingerprint = executorPolicyDecision?.policyFingerprint ?? policyFingerprint;
3071
+ if (!approval.approved) {
3072
+ releaseApprovalStateLock();
3073
+ releaseApprovalTerminalLock();
3074
+ if (autoApproved) {
3075
+ appendEvent(logPath, {
3076
+ ts: new Date().toISOString(),
3077
+ conversation_id: conversation.conversation_id,
3078
+ event: "terminal_auto_approval_decision",
3079
+ action: "rejected",
3080
+ reason: approval.reason,
3081
+ terminal_control: terminalControl,
3082
+ expected_fingerprint: expectedFingerprint,
3083
+ actual_fingerprint: actualFingerprint,
3084
+ policy_rule_id: effectivePolicyRuleId,
3085
+ policy_fingerprint: effectivePolicyFingerprint
3086
+ });
2929
3087
  }
2930
- : undefined
2931
- });
2932
- const actualFingerprint = approval.fingerprint;
2933
- const effectivePolicyRuleId = executorPolicyDecision?.ruleId ?? policyRuleId;
2934
- const effectivePolicyFingerprint = executorPolicyDecision?.policyFingerprint ?? policyFingerprint;
2935
- if (!approval.approved) {
2936
- releaseApprovalStateLock();
2937
- releaseApprovalTerminalLock();
3088
+ printJson({
3089
+ conversation,
3090
+ approved: false,
3091
+ blocked: approval.blocked,
3092
+ reason: approval.reason,
3093
+ terminal_control: terminalControl,
3094
+ expected_approval_fingerprint: expectedFingerprint,
3095
+ actual_approval_fingerprint: actualFingerprint,
3096
+ screen_excerpt: approval.screenExcerpt
3097
+ });
3098
+ return;
3099
+ }
3100
+ appendEvent(logPath, {
3101
+ ts: new Date().toISOString(),
3102
+ conversation_id: conversation.conversation_id,
3103
+ event: "terminal_approval_send",
3104
+ terminal_control: terminalControl,
3105
+ key: approval.key,
3106
+ keys: approval.keys,
3107
+ label: approval.label,
3108
+ decision_mode: approval.decisionMode,
3109
+ request_id: approval.requestId,
3110
+ approval_fingerprint: actualFingerprint,
3111
+ auto_approved: autoApproved,
3112
+ policy_rule_id: effectivePolicyRuleId,
3113
+ policy_fingerprint: effectivePolicyFingerprint
3114
+ });
2938
3115
  if (autoApproved) {
2939
3116
  appendEvent(logPath, {
2940
3117
  ts: new Date().toISOString(),
2941
3118
  conversation_id: conversation.conversation_id,
2942
3119
  event: "terminal_auto_approval_decision",
2943
- action: "rejected",
2944
- reason: approval.reason,
3120
+ action: "approved",
2945
3121
  terminal_control: terminalControl,
2946
- expected_fingerprint: expectedFingerprint,
2947
- actual_fingerprint: actualFingerprint,
3122
+ approval_fingerprint: actualFingerprint,
2948
3123
  policy_rule_id: effectivePolicyRuleId,
2949
3124
  policy_fingerprint: effectivePolicyFingerprint
2950
3125
  });
2951
3126
  }
2952
- printJson({
2953
- conversation,
2954
- approved: false,
2955
- blocked: approval.blocked,
2956
- reason: approval.reason,
2957
- terminal_control: terminalControl,
2958
- expected_approval_fingerprint: expectedFingerprint,
2959
- actual_approval_fingerprint: actualFingerprint,
2960
- screen_excerpt: approval.screenExcerpt
2961
- });
2962
- return;
2963
- }
2964
- appendEvent(logPath, {
2965
- ts: new Date().toISOString(),
2966
- conversation_id: conversation.conversation_id,
2967
- event: "terminal_approval_send",
2968
- terminal_control: terminalControl,
2969
- key: approval.key,
2970
- keys: approval.keys,
2971
- label: approval.label,
2972
- decision_mode: approval.decisionMode,
2973
- request_id: approval.requestId,
2974
- approval_fingerprint: actualFingerprint,
2975
- auto_approved: autoApproved,
2976
- policy_rule_id: effectivePolicyRuleId,
2977
- policy_fingerprint: effectivePolicyFingerprint
2978
- });
2979
- if (autoApproved) {
2980
- appendEvent(logPath, {
2981
- ts: new Date().toISOString(),
3127
+ runtimeLog("info", "terminal_approval_send", {
2982
3128
  conversation_id: conversation.conversation_id,
2983
- event: "terminal_auto_approval_decision",
2984
- action: "approved",
2985
- terminal_control: terminalControl,
3129
+ terminal_target: terminalControl.target,
3130
+ key: approval.key,
3131
+ keys: approval.keys,
3132
+ label: approval.label,
3133
+ decision_mode: approval.decisionMode,
3134
+ request_id: approval.requestId,
2986
3135
  approval_fingerprint: actualFingerprint,
3136
+ auto_approved: autoApproved,
2987
3137
  policy_rule_id: effectivePolicyRuleId,
2988
3138
  policy_fingerprint: effectivePolicyFingerprint
2989
3139
  });
2990
- }
2991
- runtimeLog("info", "terminal_approval_send", {
2992
- conversation_id: conversation.conversation_id,
2993
- terminal_target: terminalControl.target,
2994
- key: approval.key,
2995
- keys: approval.keys,
2996
- label: approval.label,
2997
- decision_mode: approval.decisionMode,
2998
- request_id: approval.requestId,
2999
- approval_fingerprint: actualFingerprint,
3000
- auto_approved: autoApproved,
3001
- policy_rule_id: effectivePolicyRuleId,
3002
- policy_fingerprint: effectivePolicyFingerprint
3003
- });
3004
- const nativeTakeoverForUpdate = isRecord(lockedConversation.native_session_takeover)
3005
- ? { ...lockedConversation.native_session_takeover }
3006
- : {};
3007
- const resolvedApproval = isRecord(nativeTakeoverForUpdate.terminal_bridge_approval)
3008
- ? nativeTakeoverForUpdate.terminal_bridge_approval
3009
- : undefined;
3010
- const resolvedApprovalScreenDigest = stringValue(resolvedApproval?.screen_digest);
3011
- const resolvedApprovalState = isRecord(resolvedApproval?.approval_state)
3012
- ? resolvedApproval.approval_state
3013
- : undefined;
3014
- const resolvedTranscriptIdentity = claudeTranscriptApprovalIdentity(resolvedApprovalState);
3015
- const approvalResolvedAt = new Date().toISOString();
3016
- const agentTimeoutMinutes = Number(options.agentTimeoutMinutes ??
3017
- nativeTakeoverForUpdate.terminal_bridge_inactivity_timeout_minutes ??
3018
- DEFAULT_AGENT_TIMEOUT_MINUTES);
3019
- const agentHardTimeoutMinutes = positiveMinutes(options.agentHardTimeoutMinutes ??
3020
- nativeTakeoverForUpdate.terminal_bridge_hard_timeout_minutes ??
3021
- DEFAULT_AGENT_HARD_TIMEOUT_MINUTES, "--agent-hard-timeout-minutes");
3022
- const nextNativeTakeover = {
3023
- ...nativeTakeoverForUpdate,
3024
- terminal_bridge_approval: undefined,
3025
- terminal_bridge_approval_dispatch: undefined,
3026
- terminal_bridge_approval_resolved_at: approvalResolvedAt,
3027
- terminal_bridge_last_approval_fingerprint: actualFingerprint,
3028
- terminal_bridge_last_approval_screen_digest: resolvedApprovalScreenDigest,
3029
- terminal_bridge_last_approval_request_id: resolvedTranscriptIdentity?.requestId,
3030
- terminal_bridge_last_approval_evidence_fingerprint: resolvedTranscriptIdentity?.evidenceFingerprint,
3031
- terminal_bridge_last_approval_prompt_cleared_at: undefined,
3032
- terminal_bridge_last_approval_at: approvalResolvedAt,
3033
- terminal_bridge_last_approval_message_id: nativeTakeoverForUpdate.terminal_bridge_message_id,
3034
- terminal_bridge_monitor_lock_version: TERMINAL_BRIDGE_MONITOR_LOCK_VERSION,
3035
- terminal_bridge_monitor_started_at: approvalResolvedAt,
3036
- terminal_bridge_last_activity_at: approvalResolvedAt,
3037
- terminal_bridge_last_activity_reason: "approval resolved",
3038
- terminal_bridge_inactivity_timeout_minutes: agentTimeoutMinutes,
3039
- terminal_bridge_hard_timeout_minutes: agentHardTimeoutMinutes,
3040
- terminal_bridge_inactivity_deadline_at: deadlineAt(approvalResolvedAt, agentTimeoutMinutes),
3041
- terminal_bridge_hard_deadline_at: deadlineAt(stringValue(nativeTakeoverForUpdate.terminal_bridge_started_at) ?? approvalResolvedAt, agentHardTimeoutMinutes)
3042
- };
3043
- delete nextNativeTakeover.terminal_bridge_approval;
3044
- delete nextNativeTakeover.terminal_bridge_approval_dispatch;
3045
- delete nextNativeTakeover.terminal_bridge_last_approval_prompt_cleared_at;
3046
- const nextConversation = {
3047
- ...lockedConversation,
3048
- status: terminalBridgeEnabled(lockedConversation)
3049
- ? "waiting_for_agent"
3050
- : lockedConversation.status,
3051
- native_session_takeover: nextNativeTakeover,
3052
- updated_at: approvalResolvedAt
3053
- };
3054
- saveState(statePath, nextConversation);
3055
- releaseApprovalStateLock();
3056
- releaseApprovalTerminalLock();
3057
- const bridgeMonitor = ensureTerminalBridgeMonitorAfterApproval({
3058
- conversation: nextConversation,
3059
- statePath,
3060
- logPath,
3061
- terminalControl,
3062
- options
3063
- });
3064
- printJson({
3065
- conversation: nextConversation,
3066
- approved: true,
3067
- terminal_control: terminalControl,
3068
- key: approval.key,
3069
- keys: approval.keys,
3070
- label: approval.label,
3071
- decision_mode: approval.decisionMode,
3072
- request_id: approval.requestId,
3073
- approval_fingerprint: actualFingerprint,
3074
- auto_approved: autoApproved,
3075
- policy_rule_id: effectivePolicyRuleId,
3076
- policy_fingerprint: effectivePolicyFingerprint,
3077
- monitor_pid: bridgeMonitor.monitorPid ?? null,
3078
- monitor_handoff_pid: bridgeMonitor.handoffWatchdog?.pid ?? null
3140
+ const nativeTakeoverForUpdate = isRecord(lockedConversation.native_session_takeover)
3141
+ ? { ...lockedConversation.native_session_takeover }
3142
+ : {};
3143
+ const resolvedApproval = isRecord(nativeTakeoverForUpdate.terminal_bridge_approval)
3144
+ ? nativeTakeoverForUpdate.terminal_bridge_approval
3145
+ : undefined;
3146
+ const resolvedApprovalScreenDigest = stringValue(resolvedApproval?.screen_digest);
3147
+ const resolvedApprovalState = isRecord(resolvedApproval?.approval_state)
3148
+ ? resolvedApproval.approval_state
3149
+ : undefined;
3150
+ const resolvedTranscriptIdentity = claudeTranscriptApprovalIdentity(resolvedApprovalState);
3151
+ const approvalResolvedAt = new Date().toISOString();
3152
+ const agentTimeoutMinutes = Number(options.agentTimeoutMinutes ??
3153
+ nativeTakeoverForUpdate.terminal_bridge_inactivity_timeout_minutes ??
3154
+ DEFAULT_AGENT_TIMEOUT_MINUTES);
3155
+ const agentHardTimeoutMinutes = positiveMinutes(options.agentHardTimeoutMinutes ??
3156
+ nativeTakeoverForUpdate.terminal_bridge_hard_timeout_minutes ??
3157
+ DEFAULT_AGENT_HARD_TIMEOUT_MINUTES, "--agent-hard-timeout-minutes");
3158
+ const nextNativeTakeover = {
3159
+ ...nativeTakeoverForUpdate,
3160
+ terminal_bridge_approval: undefined,
3161
+ terminal_bridge_approval_dispatch: undefined,
3162
+ terminal_bridge_approval_resolved_at: approvalResolvedAt,
3163
+ terminal_bridge_last_approval_fingerprint: actualFingerprint,
3164
+ terminal_bridge_last_approval_screen_digest: resolvedApprovalScreenDigest,
3165
+ terminal_bridge_last_approval_request_id: resolvedTranscriptIdentity?.requestId,
3166
+ terminal_bridge_last_approval_evidence_fingerprint: resolvedTranscriptIdentity?.evidenceFingerprint,
3167
+ terminal_bridge_last_approval_prompt_cleared_at: undefined,
3168
+ terminal_bridge_last_approval_at: approvalResolvedAt,
3169
+ terminal_bridge_last_approval_message_id: nativeTakeoverForUpdate.terminal_bridge_message_id,
3170
+ terminal_bridge_monitor_lock_version: TERMINAL_BRIDGE_MONITOR_LOCK_VERSION,
3171
+ terminal_bridge_monitor_started_at: approvalResolvedAt,
3172
+ terminal_bridge_last_activity_at: approvalResolvedAt,
3173
+ terminal_bridge_last_activity_reason: "approval resolved",
3174
+ terminal_bridge_inactivity_timeout_minutes: agentTimeoutMinutes,
3175
+ terminal_bridge_hard_timeout_minutes: agentHardTimeoutMinutes,
3176
+ terminal_bridge_inactivity_deadline_at: deadlineAt(approvalResolvedAt, agentTimeoutMinutes),
3177
+ terminal_bridge_hard_deadline_at: deadlineAt(stringValue(nativeTakeoverForUpdate.terminal_bridge_started_at) ?? approvalResolvedAt, agentHardTimeoutMinutes)
3178
+ };
3179
+ delete nextNativeTakeover.terminal_bridge_approval;
3180
+ delete nextNativeTakeover.terminal_bridge_approval_dispatch;
3181
+ delete nextNativeTakeover.terminal_bridge_last_approval_prompt_cleared_at;
3182
+ const nextConversation = {
3183
+ ...lockedConversation,
3184
+ status: terminalBridgeEnabled(lockedConversation)
3185
+ ? "waiting_for_agent"
3186
+ : lockedConversation.status,
3187
+ native_session_takeover: nextNativeTakeover,
3188
+ updated_at: approvalResolvedAt
3189
+ };
3190
+ saveState(statePath, nextConversation);
3191
+ releaseApprovalStateLock();
3192
+ releaseApprovalTerminalLock();
3193
+ const bridgeMonitor = ensureTerminalBridgeMonitorAfterApproval({
3194
+ conversation: nextConversation,
3195
+ statePath,
3196
+ logPath,
3197
+ terminalControl,
3198
+ options
3199
+ });
3200
+ printJson({
3201
+ conversation: nextConversation,
3202
+ approved: true,
3203
+ terminal_control: terminalControl,
3204
+ key: approval.key,
3205
+ keys: approval.keys,
3206
+ label: approval.label,
3207
+ decision_mode: approval.decisionMode,
3208
+ request_id: approval.requestId,
3209
+ approval_fingerprint: actualFingerprint,
3210
+ auto_approved: autoApproved,
3211
+ policy_rule_id: effectivePolicyRuleId,
3212
+ policy_fingerprint: effectivePolicyFingerprint,
3213
+ monitor_pid: bridgeMonitor.monitorPid ?? null,
3214
+ monitor_handoff_pid: bridgeMonitor.handoffWatchdog?.pid ?? null
3215
+ });
3079
3216
  });
3080
3217
  }
3081
3218
  finally {
@@ -3150,7 +3287,7 @@ async function runTerminalConversationApprove({ options, conversationId, agent,
3150
3287
  releaseTerminalLock();
3151
3288
  }
3152
3289
  }
3153
- async function runTerminalControlSend({ options, conversation, nextConversation, statePath, logPath, executor, message, terminalControl, terminalSendLockHeld = false, terminalStateLockHeld = false, recordMessageAfterSend = false, recordRawAttachmentAfterSend = false }) {
3290
+ async function runTerminalControlSend({ options, conversation, nextConversation, statePath, logPath, executor, message, terminalControl, terminalSendLockHeld = false, terminalStateLockHeld = false, storeWriterLeaseHeld = false, recordMessageAfterSend = false, recordRawAttachmentAfterSend = false }) {
3154
3291
  const bridge = terminalBridgeEnabled(conversation);
3155
3292
  if (!terminalSendLockHeld) {
3156
3293
  const releaseTerminalLock = acquireFileLock(terminalBridgeSendLockPath(storeDirFromOptions(options), terminalControl), { timeoutMs: 30000 });
@@ -3166,6 +3303,7 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
3166
3303
  terminalControl,
3167
3304
  terminalSendLockHeld: true,
3168
3305
  terminalStateLockHeld,
3306
+ storeWriterLeaseHeld,
3169
3307
  recordMessageAfterSend,
3170
3308
  recordRawAttachmentAfterSend
3171
3309
  });
@@ -3201,6 +3339,7 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
3201
3339
  terminalControl,
3202
3340
  terminalSendLockHeld: true,
3203
3341
  terminalStateLockHeld: true,
3342
+ storeWriterLeaseHeld,
3204
3343
  recordMessageAfterSend,
3205
3344
  recordRawAttachmentAfterSend
3206
3345
  });
@@ -3209,6 +3348,24 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
3209
3348
  releaseStateLock();
3210
3349
  }
3211
3350
  }
3351
+ if (!storeWriterLeaseHeld) {
3352
+ const writerStoreDir = pathsForConversationDir(path.dirname(statePath)).storeDir;
3353
+ return await withStoreWriterLeaseAsync(writerStoreDir, async () => runTerminalControlSend({
3354
+ options,
3355
+ conversation,
3356
+ nextConversation,
3357
+ statePath,
3358
+ logPath,
3359
+ executor,
3360
+ message,
3361
+ terminalControl,
3362
+ terminalSendLockHeld: true,
3363
+ terminalStateLockHeld,
3364
+ storeWriterLeaseHeld: true,
3365
+ recordMessageAfterSend,
3366
+ recordRawAttachmentAfterSend
3367
+ }));
3368
+ }
3212
3369
  const terminalBridge = createTerminalAgentBridge(options);
3213
3370
  const bridgeStartedAt = new Date().toISOString();
3214
3371
  const agentTimeoutMinutes = Number(options.agentTimeoutMinutes ?? DEFAULT_AGENT_TIMEOUT_MINUTES);
@@ -3723,7 +3880,6 @@ function terminalSubmissionPayload(payload) {
3723
3880
  function createManagedTerminalConversationFromRawId({ options, conversationId, agent, pid, messageBody, terminalControl }) {
3724
3881
  const workspace = terminalControl.currentPath ?? process.cwd();
3725
3882
  const storeDir = expandHome(options.storeDir ?? options.logDir ?? defaultStoreDir(workspace));
3726
- cleanupIdleConversations(storeDir, options);
3727
3883
  const executor = resolveExecutor({
3728
3884
  kind: agent,
3729
3885
  session: conversationId
@@ -3948,8 +4104,15 @@ async function runRenew(options) {
3948
4104
  });
3949
4105
  }
3950
4106
  async function runReconcileMonitors(options) {
4107
+ printJson(await reconcileMonitors(options, {
4108
+ includeCallbackRecovery: true,
4109
+ reason: "startup_reconciliation",
4110
+ conversationId: undefined
4111
+ }));
4112
+ }
4113
+ async function reconcileMonitors(options, { includeCallbackRecovery, reason, conversationId }) {
3951
4114
  const storeDir = storeDirFromOptions(options);
3952
- const conversations = listConversations(storeDir);
4115
+ const conversations = listConversations(storeDir).filter((conversation) => conversationId === undefined || conversation.conversation_id === conversationId);
3953
4116
  const items = [];
3954
4117
  let ignored = 0;
3955
4118
  let launched = 0;
@@ -3966,30 +4129,32 @@ async function runReconcileMonitors(options) {
3966
4129
  const logPath = expandHome(stringValue(listedConversation.event_log_path) ??
3967
4130
  logPathForStatePath(statePath));
3968
4131
  try {
3969
- const callbackRecovery = prepareCallbackDeliveryReconciliation({
3970
- statePath,
3971
- logPath,
3972
- delayMs: options.callbackRetryDelayMs
3973
- });
3974
- if (callbackRecovery.handled) {
3975
- if (callbackRecovery.status === "launched") {
3976
- launched += 1;
3977
- }
3978
- else if (callbackRecovery.status === "already_running") {
3979
- alreadyRunning += 1;
3980
- }
3981
- else {
3982
- skipped += 1;
3983
- }
3984
- items.push({
3985
- conversation_id: callbackRecovery.conversationId,
3986
- status: callbackRecovery.status,
3987
- reason: callbackRecovery.reason,
3988
- ...(callbackRecovery.monitorPid === undefined
3989
- ? {}
3990
- : { monitor_pid: callbackRecovery.monitorPid })
4132
+ if (includeCallbackRecovery) {
4133
+ const callbackRecovery = prepareCallbackDeliveryReconciliation({
4134
+ statePath,
4135
+ logPath,
4136
+ delayMs: options.callbackRetryDelayMs
3991
4137
  });
3992
- continue;
4138
+ if (callbackRecovery.handled) {
4139
+ if (callbackRecovery.status === "launched") {
4140
+ launched += 1;
4141
+ }
4142
+ else if (callbackRecovery.status === "already_running") {
4143
+ alreadyRunning += 1;
4144
+ }
4145
+ else {
4146
+ skipped += 1;
4147
+ }
4148
+ items.push({
4149
+ conversation_id: callbackRecovery.conversationId,
4150
+ status: callbackRecovery.status,
4151
+ reason: callbackRecovery.reason,
4152
+ ...(callbackRecovery.monitorPid === undefined
4153
+ ? {}
4154
+ : { monitor_pid: callbackRecovery.monitorPid })
4155
+ });
4156
+ continue;
4157
+ }
3993
4158
  }
3994
4159
  const listedNativeTakeover = isRecord(listedConversation.native_session_takeover)
3995
4160
  ? listedConversation.native_session_takeover
@@ -4104,7 +4269,7 @@ async function runReconcileMonitors(options) {
4104
4269
  event: "terminal_bridge_monitor_launch",
4105
4270
  pid: monitor.pid ?? null,
4106
4271
  terminal_control: prepared.terminalControl,
4107
- reason: "startup_reconciliation",
4272
+ reason,
4108
4273
  agent_timeout_minutes: prepared.inactivityTimeoutMinutes,
4109
4274
  agent_hard_timeout_minutes: prepared.hardTimeoutMinutes
4110
4275
  });
@@ -4117,7 +4282,7 @@ async function runReconcileMonitors(options) {
4117
4282
  items.push({
4118
4283
  conversation_id: prepared.conversation.conversation_id,
4119
4284
  status: "launched",
4120
- reason: "startup_reconciliation",
4285
+ reason,
4121
4286
  monitor_pid: monitor.pid ?? null
4122
4287
  });
4123
4288
  }
@@ -4130,7 +4295,7 @@ async function runReconcileMonitors(options) {
4130
4295
  });
4131
4296
  }
4132
4297
  }
4133
- printJson({
4298
+ return {
4134
4299
  reconciled: true,
4135
4300
  store_dir: storeDir,
4136
4301
  checked: conversations.length,
@@ -4140,7 +4305,7 @@ async function runReconcileMonitors(options) {
4140
4305
  skipped,
4141
4306
  errors,
4142
4307
  items
4143
- });
4308
+ };
4144
4309
  }
4145
4310
  function prepareCallbackDeliveryReconciliation({ statePath, logPath, delayMs }) {
4146
4311
  const releaseStateLock = acquireFileLock(`${statePath}.lock`);
@@ -4383,7 +4548,6 @@ function positiveMinutes(value, optionName) {
4383
4548
  return parsed;
4384
4549
  }
4385
4550
  async function runCancel(options) {
4386
- cleanupIdleConversations(storeDirFromOptions(options), options);
4387
4551
  const terminalConversation = await resolveTerminalConversationFromOptions(options);
4388
4552
  if (terminalConversation) {
4389
4553
  await runTerminalConversationCancel({
@@ -4463,75 +4627,78 @@ async function runTerminalControlCancel({ options, statePath, logPath, agent, te
4463
4627
  let releaseStateLock;
4464
4628
  try {
4465
4629
  releaseStateLock = acquireFileLock(`${statePath}.lock`);
4466
- const currentConversation = loadState(statePath);
4467
- if (!["waiting_for_agent", "waiting_for_openclaw"].includes(currentConversation.status)) {
4468
- throw new Error(`cannot cancel ${currentConversation.conversation_id}; conversation is ${currentConversation.status}`);
4469
- }
4470
- const currentTakeover = isRecord(currentConversation.native_session_takeover)
4471
- ? currentConversation.native_session_takeover
4472
- : undefined;
4473
- const currentControl = terminalControlFromTakeover(currentTakeover);
4474
- if (!currentControl ||
4475
- currentControl.target !== terminalControl.target ||
4476
- currentControl.socketPath !== terminalControl.socketPath) {
4477
- throw new Error("terminal control changed while waiting to cancel; refresh status and retry");
4478
- }
4479
- assertManagedTerminalDispatchOwner({
4480
- conversation: currentConversation,
4481
- terminalControl: currentControl,
4482
- action: "cancel"
4483
- });
4484
- const cancellation = await createTerminalAgentBridge(options).cancel(agent, currentControl, {
4485
- runtime: terminalRuntimeIdentityForConversation(currentConversation, currentControl),
4486
- scrollbackLines: Number(options.scrollbackLines ?? 120)
4487
- });
4488
- if (!cancellation.cancelRequested) {
4489
- printJson({
4630
+ const writerStoreDir = pathsForConversationDir(path.dirname(statePath)).storeDir;
4631
+ return await withStoreWriterLeaseAsync(writerStoreDir, async () => {
4632
+ const currentConversation = loadState(statePath);
4633
+ if (!["waiting_for_agent", "waiting_for_openclaw"].includes(currentConversation.status)) {
4634
+ throw new Error(`cannot cancel ${currentConversation.conversation_id}; conversation is ${currentConversation.status}`);
4635
+ }
4636
+ const currentTakeover = isRecord(currentConversation.native_session_takeover)
4637
+ ? currentConversation.native_session_takeover
4638
+ : undefined;
4639
+ const currentControl = terminalControlFromTakeover(currentTakeover);
4640
+ if (!currentControl ||
4641
+ currentControl.target !== terminalControl.target ||
4642
+ currentControl.socketPath !== terminalControl.socketPath) {
4643
+ throw new Error("terminal control changed while waiting to cancel; refresh status and retry");
4644
+ }
4645
+ assertManagedTerminalDispatchOwner({
4490
4646
  conversation: currentConversation,
4491
- cancel_requested: false,
4492
- reason: cancellation.reason,
4647
+ terminalControl: currentControl,
4648
+ action: "cancel"
4649
+ });
4650
+ const cancellation = await createTerminalAgentBridge(options).cancel(agent, currentControl, {
4651
+ runtime: terminalRuntimeIdentityForConversation(currentConversation, currentControl),
4652
+ scrollbackLines: Number(options.scrollbackLines ?? 120)
4653
+ });
4654
+ if (!cancellation.cancelRequested) {
4655
+ printJson({
4656
+ conversation: currentConversation,
4657
+ cancel_requested: false,
4658
+ reason: cancellation.reason,
4659
+ terminal_control: currentControl,
4660
+ budget: budgetAction(currentConversation)
4661
+ });
4662
+ return;
4663
+ }
4664
+ const now = new Date().toISOString();
4665
+ appendEvent(logPath, {
4666
+ ts: now,
4667
+ conversation_id: currentConversation.conversation_id,
4668
+ event: "terminal_cancel_requested",
4493
4669
  terminal_control: currentControl,
4494
- budget: budgetAction(currentConversation)
4670
+ key: cancellation.key,
4671
+ keys: cancellation.keys,
4672
+ denied_approval: cancellation.deniedApproval,
4673
+ request_id: cancellation.requestId
4674
+ });
4675
+ runtimeLog("info", "terminal_cancel_requested", {
4676
+ conversation_id: currentConversation.conversation_id,
4677
+ agent,
4678
+ terminal_target: currentControl.target,
4679
+ key: cancellation.key,
4680
+ keys: cancellation.keys,
4681
+ denied_approval: cancellation.deniedApproval,
4682
+ request_id: cancellation.requestId
4683
+ });
4684
+ const nextConversation = {
4685
+ ...currentConversation,
4686
+ status: "cancelled",
4687
+ cancelled_at: now,
4688
+ terminal_cancel_requested_at: now,
4689
+ updated_at: now
4690
+ };
4691
+ saveState(statePath, nextConversation);
4692
+ printJson({
4693
+ conversation: nextConversation,
4694
+ cancel_requested: true,
4695
+ terminal_control: currentControl,
4696
+ key: cancellation.key,
4697
+ keys: cancellation.keys,
4698
+ denied_approval: cancellation.deniedApproval,
4699
+ request_id: cancellation.requestId,
4700
+ budget: budgetAction(nextConversation)
4495
4701
  });
4496
- return;
4497
- }
4498
- const now = new Date().toISOString();
4499
- appendEvent(logPath, {
4500
- ts: now,
4501
- conversation_id: currentConversation.conversation_id,
4502
- event: "terminal_cancel_requested",
4503
- terminal_control: currentControl,
4504
- key: cancellation.key,
4505
- keys: cancellation.keys,
4506
- denied_approval: cancellation.deniedApproval,
4507
- request_id: cancellation.requestId
4508
- });
4509
- runtimeLog("info", "terminal_cancel_requested", {
4510
- conversation_id: currentConversation.conversation_id,
4511
- agent,
4512
- terminal_target: currentControl.target,
4513
- key: cancellation.key,
4514
- keys: cancellation.keys,
4515
- denied_approval: cancellation.deniedApproval,
4516
- request_id: cancellation.requestId
4517
- });
4518
- const nextConversation = {
4519
- ...currentConversation,
4520
- status: "cancelled",
4521
- cancelled_at: now,
4522
- terminal_cancel_requested_at: now,
4523
- updated_at: now
4524
- };
4525
- saveState(statePath, nextConversation);
4526
- printJson({
4527
- conversation: nextConversation,
4528
- cancel_requested: true,
4529
- terminal_control: currentControl,
4530
- key: cancellation.key,
4531
- keys: cancellation.keys,
4532
- denied_approval: cancellation.deniedApproval,
4533
- request_id: cancellation.requestId,
4534
- budget: budgetAction(nextConversation)
4535
4702
  });
4536
4703
  }
4537
4704
  finally {
@@ -5962,7 +6129,7 @@ function terminalBridgeRuntimeDir() {
5962
6129
  const configured = stringValue(process.env.AKK_RUNTIME_DIR);
5963
6130
  return configured
5964
6131
  ? path.resolve(expandHome(configured))
5965
- : path.join(path.dirname(defaultStoreDir()), "runtime");
6132
+ : path.join(path.dirname(defaultStoreDir()), "runtime-v2");
5966
6133
  }
5967
6134
  function terminalBridgeRuntimeKey(terminalControl) {
5968
6135
  return createHash("sha256")
@@ -5975,7 +6142,6 @@ function terminalBridgeRuntimeKey(terminalControl) {
5975
6142
  }
5976
6143
  function terminalBridgeDispatchLedgerPath(terminalControl) {
5977
6144
  const ledgerDir = path.join(terminalBridgeRuntimeDir(), "terminal-dispatch");
5978
- ensureDir(ledgerDir);
5979
6145
  return path.join(ledgerDir, `terminal-dispatch-${terminalBridgeRuntimeKey(terminalControl)}.json`);
5980
6146
  }
5981
6147
  function loadTerminalBridgeDispatchLedger(terminalControl) {
@@ -6022,6 +6188,7 @@ function orphanedTerminalDispatchForRecovery(terminalControl) {
6022
6188
  }
6023
6189
  function saveTerminalBridgeDispatchLedger(terminalControl, ledger) {
6024
6190
  const ledgerPath = terminalBridgeDispatchLedgerPath(terminalControl);
6191
+ ensureDir(path.dirname(ledgerPath));
6025
6192
  if (fs.existsSync(ledgerPath) && fs.lstatSync(ledgerPath).isSymbolicLink()) {
6026
6193
  throw new Error(`terminal dispatch ledger is a symlink: ${ledgerPath}`);
6027
6194
  }
@@ -7025,6 +7192,7 @@ function runPreparedCallback(prepared, { emit = true } = {}) {
7025
7192
  }
7026
7193
  return result;
7027
7194
  }
7195
+ assertStoreWriterCompatible(pathsForConversationDir(path.dirname(prepared.statePath)).storeDir);
7028
7196
  try {
7029
7197
  const deliveryKind = deliverCallbackToOpenClaw({
7030
7198
  options: prepared.options,
@@ -7570,7 +7738,13 @@ function loadConversationFromOptions(options) {
7570
7738
  throw new Error("--conversation or --state is required");
7571
7739
  }
7572
7740
  const conversation = options.state
7573
- ? loadState(statePath)
7741
+ ? (() => {
7742
+ const paths = pathsForConversationDir(path.dirname(statePath));
7743
+ if (path.resolve(paths.statePath) !== path.resolve(statePath)) {
7744
+ throw new Error(`AKK state path is not canonical: ${statePath}`);
7745
+ }
7746
+ return loadConversationById(path.basename(paths.conversationDir), paths.storeDir);
7747
+ })()
7574
7748
  : loadConversationById(conversationId, storeDir);
7575
7749
  assertConfiguredWorkspace(options.workspace, conversation.workspace, `access to AKK conversation ${conversation.conversation_id}`);
7576
7750
  return {
@@ -8183,13 +8357,21 @@ function deliverStalledNotification({ statePath, logPath, conversation, message,
8183
8357
  stderr: textSummary(chatSendDelivery.stderr)
8184
8358
  });
8185
8359
  }
8186
- function cleanupIdleConversations(storeDir, options = {}, now = new Date()) {
8360
+ function reconcileIdleConversations(storeDir, options = {}, now = new Date(), conversationId) {
8187
8361
  const timeoutMinutes = Number(options.idleTimeoutMinutes ?? DEFAULT_IDLE_TIMEOUT_MINUTES);
8188
8362
  if (!Number.isFinite(timeoutMinutes) || timeoutMinutes <= 0) {
8189
- return { checked: 0, closed: 0, idle_timeout_minutes: timeoutMinutes };
8363
+ return {
8364
+ checked: 0,
8365
+ closed: 0,
8366
+ skipped: 0,
8367
+ idle_timeout_minutes: timeoutMinutes
8368
+ };
8190
8369
  }
8191
- const conversations = listConversations(storeDir);
8370
+ ensureStoreWritable(storeDir);
8371
+ const conversations = listConversations(storeDir).filter((conversation) => (conversationId === undefined || conversation.conversation_id === conversationId) &&
8372
+ matchesConfiguredWorkspace(options.workspace, conversation.workspace));
8192
8373
  let closed = 0;
8374
+ let skipped = 0;
8193
8375
  for (const listedConversation of conversations) {
8194
8376
  if (listedConversation.status !== "idle" || !listedConversation.idle_since) {
8195
8377
  continue;
@@ -8209,6 +8391,7 @@ function cleanupIdleConversations(storeDir, options = {}, now = new Date()) {
8209
8391
  }
8210
8392
  catch (error) {
8211
8393
  if (isRecord(error) && error.code === "LOCK_TIMEOUT") {
8394
+ skipped += 1;
8212
8395
  continue;
8213
8396
  }
8214
8397
  throw error;
@@ -8267,6 +8450,7 @@ function cleanupIdleConversations(storeDir, options = {}, now = new Date()) {
8267
8450
  return {
8268
8451
  checked: conversations.length,
8269
8452
  closed,
8453
+ skipped,
8270
8454
  idle_timeout_minutes: timeoutMinutes
8271
8455
  };
8272
8456
  }
@@ -8733,8 +8917,8 @@ function usage() {
8733
8917
  agent-knock-knock --help
8734
8918
  agent-knock-knock --version
8735
8919
  agent-knock-knock delegate --request <text> [--agent ${agentList}] [--workspace <path>] [--store-dir <dir>]
8736
- agent-knock-knock list [--store-dir <dir>] [--agent ${agentList}] [--status <status>] [--all] [--managed-only] [--no-approval-scan] [--terminal-debug]
8737
- agent-knock-knock status [--conversation <id|selector>] [--store-dir <dir>] [--trace]
8920
+ agent-knock-knock list [--store-dir <dir>] [--agent ${agentList}] [--status <status>] [--all] [--managed-only] [--reconcile] [--no-approval-scan] [--terminal-debug]
8921
+ agent-knock-knock status [--conversation <id|selector>] [--store-dir <dir>] [--reconcile] [--trace]
8738
8922
  agent-knock-knock send [--conversation <id|selector>] --message <text> [--type answer|task|control] [--agent-timeout-minutes <minutes>] [--agent-hard-timeout-minutes <minutes>]
8739
8923
  agent-knock-knock approve [--conversation <id|selector>] --expected-approval-fingerprint <fingerprint>
8740
8924
  agent-knock-knock cancel [--conversation <id|selector>]