@scotthuang/agent-knock-knock 0.6.2 → 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,9 +1408,41 @@ async function runList(options) {
1379
1408
  include_all: includeAll,
1380
1409
  agent_filter: agentFilter,
1381
1410
  status_filter: statusFilter,
1382
- cleanup
1411
+ reconciliation
1383
1412
  });
1384
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
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
+ };
1445
+ }
1385
1446
  async function buildTerminalListGroup({ options, agentFilter, statusFilter }) {
1386
1447
  const empty = {
1387
1448
  terminalControlled: [],
@@ -1862,7 +1923,6 @@ function isSessionSelectorSyntax(value) {
1862
1923
  }
1863
1924
  async function sessionSelectorCandidates(commandName, options) {
1864
1925
  const storeDir = storeDirFromOptions(options);
1865
- cleanupIdleConversations(storeDir, options);
1866
1926
  const storedConversations = listConversations(storeDir);
1867
1927
  const workspaceConversations = storedConversations
1868
1928
  .filter((conversation) => matchesConfiguredWorkspace(options.workspace, conversation.workspace));
@@ -1956,7 +2016,23 @@ async function resolveTerminalConversationFromOptions(options) {
1956
2016
  return createTerminalAgentBridge(options).resolveConversationId(stringValue(options.conversation ?? options.conversationId));
1957
2017
  }
1958
2018
  async function runStatus(options) {
1959
- 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
+ };
1960
2036
  const terminalConversation = await resolveTerminalConversationFromOptions(options);
1961
2037
  if (terminalConversation) {
1962
2038
  const terminalStatus = await terminalStatusForControl(terminalConversation.agent, terminalConversation.terminalControl, options, {
@@ -1970,6 +2046,8 @@ async function runStatus(options) {
1970
2046
  conversation_id: terminalConversation.conversationId,
1971
2047
  source: "terminal_control",
1972
2048
  agent: terminalConversation.agent,
2049
+ store: inspectStoreCompatibility(storeDir),
2050
+ reconciliation,
1973
2051
  ...context,
1974
2052
  terminal_control: terminalConversation.terminalControl,
1975
2053
  terminal_status: terminalStatus,
@@ -1984,13 +2062,12 @@ async function runStatus(options) {
1984
2062
  }
1985
2063
  const loaded = loadConversationFromOptions(options);
1986
2064
  const { statePath, logPath } = loaded;
1987
- const conversation = await migrateLegacyTerminalAgentIdentity({
1988
- ...loaded,
1989
- options
1990
- });
2065
+ const conversation = loaded.conversation;
1991
2066
  const events = readExistingEvents(logPath);
1992
2067
  const result = {
1993
2068
  conversation,
2069
+ store: inspectStoreCompatibility(storeDir),
2070
+ reconciliation,
1994
2071
  summary: summarizeConversation(conversation),
1995
2072
  confidence: "high",
1996
2073
  about: managedConversationAbout(conversation, events),
@@ -2027,6 +2104,38 @@ async function runStatus(options) {
2027
2104
  trace: Boolean(options.trace)
2028
2105
  });
2029
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
+ }
2030
2139
  async function terminalStatusContext(terminalConversation, terminalStatus, options) {
2031
2140
  if (terminalConversation.agent === "codex") {
2032
2141
  try {
@@ -2497,7 +2606,6 @@ async function runSend(options) {
2497
2606
  if (options.agentHardTimeoutMinutes !== undefined) {
2498
2607
  positiveMinutes(options.agentHardTimeoutMinutes, "--agent-hard-timeout-minutes");
2499
2608
  }
2500
- cleanupIdleConversations(storeDirFromOptions(options), options);
2501
2609
  const terminalConversation = await resolveTerminalConversationFromOptions(options);
2502
2610
  if (terminalConversation) {
2503
2611
  if (!options.background) {
@@ -2514,6 +2622,7 @@ async function runSend(options) {
2514
2622
  messageBody,
2515
2623
  terminalControl: terminalConversation.terminalControl
2516
2624
  });
2625
+ ensureStoreWritable(managed.conversation.store_dir);
2517
2626
  ensureDir(path.dirname(managed.statePath));
2518
2627
  releaseStateLock = acquireFileLock(`${managed.statePath}.lock`);
2519
2628
  await runTerminalControlSend({
@@ -2599,7 +2708,6 @@ async function runSend(options) {
2599
2708
  throw new Error(`conversation ${migratedConversation.conversation_id} is not attached to a live tmux terminal`);
2600
2709
  }
2601
2710
  async function runApprove(options) {
2602
- cleanupIdleConversations(storeDirFromOptions(options), options);
2603
2711
  const terminalConversation = await resolveTerminalConversationFromOptions(options);
2604
2712
  if (terminalConversation) {
2605
2713
  await runTerminalConversationApprove({
@@ -2773,6 +2881,7 @@ async function runApprove(options) {
2773
2881
  }
2774
2882
  };
2775
2883
  let releaseStateLock;
2884
+ let approvalDispatchReserved = false;
2776
2885
  const releaseApprovalStateLock = () => {
2777
2886
  if (releaseStateLock) {
2778
2887
  const release = releaseStateLock;
@@ -2780,323 +2889,330 @@ async function runApprove(options) {
2780
2889
  release();
2781
2890
  }
2782
2891
  };
2892
+ releaseStateLock = acquireFileLock(`${statePath}.lock`);
2893
+ const writerStoreDir = pathsForConversationDir(path.dirname(statePath)).storeDir;
2783
2894
  try {
2784
- let approval;
2785
- let lockedConversation = conversation;
2786
- const currentConversation = loadState(statePath);
2787
- const currentTakeover = isRecord(currentConversation.native_session_takeover)
2788
- ? currentConversation.native_session_takeover
2789
- : undefined;
2790
- const currentControl = terminalControlFromTakeover(currentTakeover);
2791
- const currentApproval = isRecord(currentTakeover?.terminal_bridge_approval)
2792
- ? currentTakeover.terminal_bridge_approval
2793
- : undefined;
2794
- if (currentConversation.status !== conversation.status ||
2795
- currentTakeover?.terminal_bridge_message_id !== nativeTakeover?.terminal_bridge_message_id ||
2796
- currentControl?.target !== terminalControl.target ||
2797
- currentControl?.socketPath !== terminalControl.socketPath ||
2798
- (claudeScreenApproval &&
2799
- currentApproval?.fingerprint !== monitoredApproval?.fingerprint)) {
2800
- throw new Error("approval state changed while waiting for terminal control; refresh status and retry");
2801
- }
2802
- assertManagedTerminalDispatchOwner({
2803
- conversation: currentConversation,
2804
- terminalControl: currentControl,
2805
- action: "approve"
2806
- });
2807
- lockedConversation = currentConversation;
2808
- approval = await createTerminalAgentBridge(options).approve(executor.kind, terminalControl, {
2809
- expectedFingerprint,
2810
- scrollbackLines: Number(options.scrollbackLines ?? 120),
2811
- runtime: runtimeIdentity,
2812
- managedRequest: terminalDurableRequestForConversation(currentConversation, terminalControl),
2813
- requiredDecisionMode: autoApproved && executor.kind === "claude" ? "keys" : undefined,
2814
- authorize: autoApproved
2815
- ? ({ agent, terminalControl: currentTerminalControl, inspection, fingerprint }) => {
2816
- if (!autoApprovalPolicy) {
2817
- return {
2818
- approved: false,
2819
- reason: "automatic approval requires an executor-side policy"
2820
- };
2821
- }
2822
- const candidate = policyCandidateForInspection({
2823
- agent,
2824
- currentTerminalControl,
2825
- inspection,
2826
- fingerprint
2827
- });
2828
- executorPolicyDecision = evaluateApprovalPolicy({
2829
- policy: autoApprovalPolicy,
2830
- candidate
2831
- });
2832
- if (executorPolicyDecision.action !== "approve") {
2833
- return {
2834
- approved: false,
2835
- reason: `executor-side auto-approval policy rejected the current request: ${executorPolicyDecision.reason}`
2836
- };
2837
- }
2838
- if (policyRuleId && executorPolicyDecision.ruleId !== policyRuleId) {
2839
- return {
2840
- approved: false,
2841
- reason: "executor-side auto-approval rule changed before execution"
2842
- };
2843
- }
2844
- if (policyFingerprint &&
2845
- executorPolicyDecision.policyFingerprint !== policyFingerprint) {
2846
- return {
2847
- approved: false,
2848
- reason: "executor-side auto-approval policy changed before execution"
2849
- };
2850
- }
2851
- return { approved: true };
2852
- }
2853
- : undefined,
2854
- beforeKeyDispatch: claudeScreenApproval
2855
- ? ({ fingerprint, terminalControl: dispatchControl, inspection, keys }) => {
2856
- 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 }) => {
2857
2928
  if (!autoApprovalPolicy) {
2858
- 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
+ };
2859
2933
  }
2860
- const freshPolicyDecision = evaluateApprovalPolicy({
2934
+ const candidate = policyCandidateForInspection({
2935
+ agent,
2936
+ currentTerminalControl,
2937
+ inspection,
2938
+ fingerprint
2939
+ });
2940
+ executorPolicyDecision = evaluateApprovalPolicy({
2861
2941
  policy: autoApprovalPolicy,
2862
- candidate: policyCandidateForInspection({
2863
- agent: executor.kind,
2864
- currentTerminalControl: dispatchControl,
2865
- inspection,
2866
- fingerprint
2867
- })
2942
+ candidate
2868
2943
  });
2869
- if (freshPolicyDecision.action !== "approve") {
2870
- 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
+ };
2871
2949
  }
2872
- if (executorPolicyDecision?.ruleId &&
2873
- freshPolicyDecision.ruleId !== executorPolicyDecision.ruleId) {
2874
- throw new Error("executor-side auto-approval rule changed after recapture");
2875
- }
2876
- if (policyRuleId && freshPolicyDecision.ruleId !== policyRuleId) {
2877
- 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
+ };
2878
2955
  }
2879
2956
  if (policyFingerprint &&
2880
- freshPolicyDecision.policyFingerprint !== policyFingerprint) {
2881
- 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
+ };
2882
2962
  }
2883
- executorPolicyDecision = freshPolicyDecision;
2884
- }
2885
- if (releaseStateLock) {
2886
- throw new Error("Claude approval dispatch was already reserved");
2963
+ return { approved: true };
2887
2964
  }
2888
- releaseStateLock = acquireFileLock(`${statePath}.lock`);
2889
- const latestConversation = loadState(statePath);
2890
- const latestTakeover = isRecord(latestConversation.native_session_takeover)
2891
- ? latestConversation.native_session_takeover
2892
- : undefined;
2893
- const latestControl = terminalControlFromTakeover(latestTakeover);
2894
- const latestApproval = isRecord(latestTakeover?.terminal_bridge_approval)
2895
- ? latestTakeover.terminal_bridge_approval
2896
- : undefined;
2897
- const latestNotifiedAt = validTimestampMs(latestApproval?.notified_at);
2898
- const latestApprovalState = isRecord(latestApproval?.approval_state)
2899
- ? latestApproval.approval_state
2900
- : undefined;
2901
- const latestPolicyEvidence = isRecord(latestApprovalState?.policy_evidence)
2902
- ? latestApprovalState.policy_evidence
2903
- : undefined;
2904
- const recapturedPolicyEvidence = inspection.approval.approvable
2905
- ? inspection.approval.policyEvidence
2906
- : undefined;
2907
- const latestDispatch = isRecord(latestTakeover?.terminal_bridge_approval_dispatch)
2908
- ? latestTakeover.terminal_bridge_approval_dispatch
2909
- : undefined;
2910
- if (!latestTakeover ||
2911
- latestConversation.status !== "waiting_for_openclaw" ||
2912
- latestTakeover.terminal_bridge_message_id !==
2913
- nativeTakeover?.terminal_bridge_message_id ||
2914
- latestApproval?.fingerprint !== fingerprint ||
2915
- latestNotifiedAt === undefined ||
2916
- Date.now() - latestNotifiedAt > CLAUDE_SCREEN_APPROVAL_TTL_MS ||
2917
- expectedFingerprint !== fingerprint ||
2918
- latestControl?.target !== dispatchControl.target ||
2919
- latestControl?.socketPath !== dispatchControl.socketPath ||
2920
- (autoApproved &&
2921
- (latestPolicyEvidence?.source !== "claude_transcript" ||
2922
- latestPolicyEvidence.evidence_fingerprint !==
2923
- recapturedPolicyEvidence?.evidenceFingerprint))) {
2924
- throw new Error("approval state changed before terminal dispatch; refresh status and retry");
2925
- }
2926
- if (latestDispatch?.state === "reserved" &&
2927
- latestDispatch.terminal_bridge_message_id ===
2928
- latestTakeover.terminal_bridge_message_id) {
2929
- throw new Error("a previous Claude approval dispatch has an uncertain outcome; inspect and resolve the terminal manually");
2930
- }
2931
- const reservedAt = new Date().toISOString();
2932
- const reservedConversation = {
2933
- ...latestConversation,
2934
- native_session_takeover: {
2935
- ...latestTakeover,
2936
- terminal_bridge_approval_dispatch: {
2937
- state: "reserved",
2938
- attempt_id: randomUUID(),
2939
- fingerprint,
2940
- keys,
2941
- terminal_target: dispatchControl.target,
2942
- terminal_bridge_message_id: latestTakeover.terminal_bridge_message_id,
2943
- 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");
2944
2971
  }
2945
- },
2946
- updated_at: reservedAt
2947
- };
2948
- saveState(statePath, reservedConversation);
2949
- 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
+ });
2950
3087
  }
2951
- : undefined
2952
- });
2953
- const actualFingerprint = approval.fingerprint;
2954
- const effectivePolicyRuleId = executorPolicyDecision?.ruleId ?? policyRuleId;
2955
- const effectivePolicyFingerprint = executorPolicyDecision?.policyFingerprint ?? policyFingerprint;
2956
- if (!approval.approved) {
2957
- releaseApprovalStateLock();
2958
- 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
+ });
2959
3115
  if (autoApproved) {
2960
3116
  appendEvent(logPath, {
2961
3117
  ts: new Date().toISOString(),
2962
3118
  conversation_id: conversation.conversation_id,
2963
3119
  event: "terminal_auto_approval_decision",
2964
- action: "rejected",
2965
- reason: approval.reason,
3120
+ action: "approved",
2966
3121
  terminal_control: terminalControl,
2967
- expected_fingerprint: expectedFingerprint,
2968
- actual_fingerprint: actualFingerprint,
3122
+ approval_fingerprint: actualFingerprint,
2969
3123
  policy_rule_id: effectivePolicyRuleId,
2970
3124
  policy_fingerprint: effectivePolicyFingerprint
2971
3125
  });
2972
3126
  }
2973
- printJson({
2974
- conversation,
2975
- approved: false,
2976
- blocked: approval.blocked,
2977
- reason: approval.reason,
2978
- terminal_control: terminalControl,
2979
- expected_approval_fingerprint: expectedFingerprint,
2980
- actual_approval_fingerprint: actualFingerprint,
2981
- screen_excerpt: approval.screenExcerpt
2982
- });
2983
- return;
2984
- }
2985
- appendEvent(logPath, {
2986
- ts: new Date().toISOString(),
2987
- conversation_id: conversation.conversation_id,
2988
- event: "terminal_approval_send",
2989
- terminal_control: terminalControl,
2990
- key: approval.key,
2991
- keys: approval.keys,
2992
- label: approval.label,
2993
- decision_mode: approval.decisionMode,
2994
- request_id: approval.requestId,
2995
- approval_fingerprint: actualFingerprint,
2996
- auto_approved: autoApproved,
2997
- policy_rule_id: effectivePolicyRuleId,
2998
- policy_fingerprint: effectivePolicyFingerprint
2999
- });
3000
- if (autoApproved) {
3001
- appendEvent(logPath, {
3002
- ts: new Date().toISOString(),
3127
+ runtimeLog("info", "terminal_approval_send", {
3003
3128
  conversation_id: conversation.conversation_id,
3004
- event: "terminal_auto_approval_decision",
3005
- action: "approved",
3006
- 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,
3007
3135
  approval_fingerprint: actualFingerprint,
3136
+ auto_approved: autoApproved,
3008
3137
  policy_rule_id: effectivePolicyRuleId,
3009
3138
  policy_fingerprint: effectivePolicyFingerprint
3010
3139
  });
3011
- }
3012
- runtimeLog("info", "terminal_approval_send", {
3013
- conversation_id: conversation.conversation_id,
3014
- terminal_target: terminalControl.target,
3015
- key: approval.key,
3016
- keys: approval.keys,
3017
- label: approval.label,
3018
- decision_mode: approval.decisionMode,
3019
- request_id: approval.requestId,
3020
- approval_fingerprint: actualFingerprint,
3021
- auto_approved: autoApproved,
3022
- policy_rule_id: effectivePolicyRuleId,
3023
- policy_fingerprint: effectivePolicyFingerprint
3024
- });
3025
- const nativeTakeoverForUpdate = isRecord(lockedConversation.native_session_takeover)
3026
- ? { ...lockedConversation.native_session_takeover }
3027
- : {};
3028
- const resolvedApproval = isRecord(nativeTakeoverForUpdate.terminal_bridge_approval)
3029
- ? nativeTakeoverForUpdate.terminal_bridge_approval
3030
- : undefined;
3031
- const resolvedApprovalScreenDigest = stringValue(resolvedApproval?.screen_digest);
3032
- const resolvedApprovalState = isRecord(resolvedApproval?.approval_state)
3033
- ? resolvedApproval.approval_state
3034
- : undefined;
3035
- const resolvedTranscriptIdentity = claudeTranscriptApprovalIdentity(resolvedApprovalState);
3036
- const approvalResolvedAt = new Date().toISOString();
3037
- const agentTimeoutMinutes = Number(options.agentTimeoutMinutes ??
3038
- nativeTakeoverForUpdate.terminal_bridge_inactivity_timeout_minutes ??
3039
- DEFAULT_AGENT_TIMEOUT_MINUTES);
3040
- const agentHardTimeoutMinutes = positiveMinutes(options.agentHardTimeoutMinutes ??
3041
- nativeTakeoverForUpdate.terminal_bridge_hard_timeout_minutes ??
3042
- DEFAULT_AGENT_HARD_TIMEOUT_MINUTES, "--agent-hard-timeout-minutes");
3043
- const nextNativeTakeover = {
3044
- ...nativeTakeoverForUpdate,
3045
- terminal_bridge_approval: undefined,
3046
- terminal_bridge_approval_dispatch: undefined,
3047
- terminal_bridge_approval_resolved_at: approvalResolvedAt,
3048
- terminal_bridge_last_approval_fingerprint: actualFingerprint,
3049
- terminal_bridge_last_approval_screen_digest: resolvedApprovalScreenDigest,
3050
- terminal_bridge_last_approval_request_id: resolvedTranscriptIdentity?.requestId,
3051
- terminal_bridge_last_approval_evidence_fingerprint: resolvedTranscriptIdentity?.evidenceFingerprint,
3052
- terminal_bridge_last_approval_prompt_cleared_at: undefined,
3053
- terminal_bridge_last_approval_at: approvalResolvedAt,
3054
- terminal_bridge_last_approval_message_id: nativeTakeoverForUpdate.terminal_bridge_message_id,
3055
- terminal_bridge_monitor_lock_version: TERMINAL_BRIDGE_MONITOR_LOCK_VERSION,
3056
- terminal_bridge_monitor_started_at: approvalResolvedAt,
3057
- terminal_bridge_last_activity_at: approvalResolvedAt,
3058
- terminal_bridge_last_activity_reason: "approval resolved",
3059
- terminal_bridge_inactivity_timeout_minutes: agentTimeoutMinutes,
3060
- terminal_bridge_hard_timeout_minutes: agentHardTimeoutMinutes,
3061
- terminal_bridge_inactivity_deadline_at: deadlineAt(approvalResolvedAt, agentTimeoutMinutes),
3062
- terminal_bridge_hard_deadline_at: deadlineAt(stringValue(nativeTakeoverForUpdate.terminal_bridge_started_at) ?? approvalResolvedAt, agentHardTimeoutMinutes)
3063
- };
3064
- delete nextNativeTakeover.terminal_bridge_approval;
3065
- delete nextNativeTakeover.terminal_bridge_approval_dispatch;
3066
- delete nextNativeTakeover.terminal_bridge_last_approval_prompt_cleared_at;
3067
- const nextConversation = {
3068
- ...lockedConversation,
3069
- status: terminalBridgeEnabled(lockedConversation)
3070
- ? "waiting_for_agent"
3071
- : lockedConversation.status,
3072
- native_session_takeover: nextNativeTakeover,
3073
- updated_at: approvalResolvedAt
3074
- };
3075
- saveState(statePath, nextConversation);
3076
- releaseApprovalStateLock();
3077
- releaseApprovalTerminalLock();
3078
- const bridgeMonitor = ensureTerminalBridgeMonitorAfterApproval({
3079
- conversation: nextConversation,
3080
- statePath,
3081
- logPath,
3082
- terminalControl,
3083
- options
3084
- });
3085
- printJson({
3086
- conversation: nextConversation,
3087
- approved: true,
3088
- terminal_control: terminalControl,
3089
- key: approval.key,
3090
- keys: approval.keys,
3091
- label: approval.label,
3092
- decision_mode: approval.decisionMode,
3093
- request_id: approval.requestId,
3094
- approval_fingerprint: actualFingerprint,
3095
- auto_approved: autoApproved,
3096
- policy_rule_id: effectivePolicyRuleId,
3097
- policy_fingerprint: effectivePolicyFingerprint,
3098
- monitor_pid: bridgeMonitor.monitorPid ?? null,
3099
- 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
+ });
3100
3216
  });
3101
3217
  }
3102
3218
  finally {
@@ -3171,7 +3287,7 @@ async function runTerminalConversationApprove({ options, conversationId, agent,
3171
3287
  releaseTerminalLock();
3172
3288
  }
3173
3289
  }
3174
- 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 }) {
3175
3291
  const bridge = terminalBridgeEnabled(conversation);
3176
3292
  if (!terminalSendLockHeld) {
3177
3293
  const releaseTerminalLock = acquireFileLock(terminalBridgeSendLockPath(storeDirFromOptions(options), terminalControl), { timeoutMs: 30000 });
@@ -3187,6 +3303,7 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
3187
3303
  terminalControl,
3188
3304
  terminalSendLockHeld: true,
3189
3305
  terminalStateLockHeld,
3306
+ storeWriterLeaseHeld,
3190
3307
  recordMessageAfterSend,
3191
3308
  recordRawAttachmentAfterSend
3192
3309
  });
@@ -3222,6 +3339,7 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
3222
3339
  terminalControl,
3223
3340
  terminalSendLockHeld: true,
3224
3341
  terminalStateLockHeld: true,
3342
+ storeWriterLeaseHeld,
3225
3343
  recordMessageAfterSend,
3226
3344
  recordRawAttachmentAfterSend
3227
3345
  });
@@ -3230,6 +3348,24 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
3230
3348
  releaseStateLock();
3231
3349
  }
3232
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
+ }
3233
3369
  const terminalBridge = createTerminalAgentBridge(options);
3234
3370
  const bridgeStartedAt = new Date().toISOString();
3235
3371
  const agentTimeoutMinutes = Number(options.agentTimeoutMinutes ?? DEFAULT_AGENT_TIMEOUT_MINUTES);
@@ -3744,7 +3880,6 @@ function terminalSubmissionPayload(payload) {
3744
3880
  function createManagedTerminalConversationFromRawId({ options, conversationId, agent, pid, messageBody, terminalControl }) {
3745
3881
  const workspace = terminalControl.currentPath ?? process.cwd();
3746
3882
  const storeDir = expandHome(options.storeDir ?? options.logDir ?? defaultStoreDir(workspace));
3747
- cleanupIdleConversations(storeDir, options);
3748
3883
  const executor = resolveExecutor({
3749
3884
  kind: agent,
3750
3885
  session: conversationId
@@ -3969,8 +4104,15 @@ async function runRenew(options) {
3969
4104
  });
3970
4105
  }
3971
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 }) {
3972
4114
  const storeDir = storeDirFromOptions(options);
3973
- const conversations = listConversations(storeDir);
4115
+ const conversations = listConversations(storeDir).filter((conversation) => conversationId === undefined || conversation.conversation_id === conversationId);
3974
4116
  const items = [];
3975
4117
  let ignored = 0;
3976
4118
  let launched = 0;
@@ -3987,30 +4129,32 @@ async function runReconcileMonitors(options) {
3987
4129
  const logPath = expandHome(stringValue(listedConversation.event_log_path) ??
3988
4130
  logPathForStatePath(statePath));
3989
4131
  try {
3990
- const callbackRecovery = prepareCallbackDeliveryReconciliation({
3991
- statePath,
3992
- logPath,
3993
- delayMs: options.callbackRetryDelayMs
3994
- });
3995
- if (callbackRecovery.handled) {
3996
- if (callbackRecovery.status === "launched") {
3997
- launched += 1;
3998
- }
3999
- else if (callbackRecovery.status === "already_running") {
4000
- alreadyRunning += 1;
4001
- }
4002
- else {
4003
- skipped += 1;
4004
- }
4005
- items.push({
4006
- conversation_id: callbackRecovery.conversationId,
4007
- status: callbackRecovery.status,
4008
- reason: callbackRecovery.reason,
4009
- ...(callbackRecovery.monitorPid === undefined
4010
- ? {}
4011
- : { monitor_pid: callbackRecovery.monitorPid })
4132
+ if (includeCallbackRecovery) {
4133
+ const callbackRecovery = prepareCallbackDeliveryReconciliation({
4134
+ statePath,
4135
+ logPath,
4136
+ delayMs: options.callbackRetryDelayMs
4012
4137
  });
4013
- 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
+ }
4014
4158
  }
4015
4159
  const listedNativeTakeover = isRecord(listedConversation.native_session_takeover)
4016
4160
  ? listedConversation.native_session_takeover
@@ -4125,7 +4269,7 @@ async function runReconcileMonitors(options) {
4125
4269
  event: "terminal_bridge_monitor_launch",
4126
4270
  pid: monitor.pid ?? null,
4127
4271
  terminal_control: prepared.terminalControl,
4128
- reason: "startup_reconciliation",
4272
+ reason,
4129
4273
  agent_timeout_minutes: prepared.inactivityTimeoutMinutes,
4130
4274
  agent_hard_timeout_minutes: prepared.hardTimeoutMinutes
4131
4275
  });
@@ -4138,7 +4282,7 @@ async function runReconcileMonitors(options) {
4138
4282
  items.push({
4139
4283
  conversation_id: prepared.conversation.conversation_id,
4140
4284
  status: "launched",
4141
- reason: "startup_reconciliation",
4285
+ reason,
4142
4286
  monitor_pid: monitor.pid ?? null
4143
4287
  });
4144
4288
  }
@@ -4151,7 +4295,7 @@ async function runReconcileMonitors(options) {
4151
4295
  });
4152
4296
  }
4153
4297
  }
4154
- printJson({
4298
+ return {
4155
4299
  reconciled: true,
4156
4300
  store_dir: storeDir,
4157
4301
  checked: conversations.length,
@@ -4161,7 +4305,7 @@ async function runReconcileMonitors(options) {
4161
4305
  skipped,
4162
4306
  errors,
4163
4307
  items
4164
- });
4308
+ };
4165
4309
  }
4166
4310
  function prepareCallbackDeliveryReconciliation({ statePath, logPath, delayMs }) {
4167
4311
  const releaseStateLock = acquireFileLock(`${statePath}.lock`);
@@ -4404,7 +4548,6 @@ function positiveMinutes(value, optionName) {
4404
4548
  return parsed;
4405
4549
  }
4406
4550
  async function runCancel(options) {
4407
- cleanupIdleConversations(storeDirFromOptions(options), options);
4408
4551
  const terminalConversation = await resolveTerminalConversationFromOptions(options);
4409
4552
  if (terminalConversation) {
4410
4553
  await runTerminalConversationCancel({
@@ -4484,75 +4627,78 @@ async function runTerminalControlCancel({ options, statePath, logPath, agent, te
4484
4627
  let releaseStateLock;
4485
4628
  try {
4486
4629
  releaseStateLock = acquireFileLock(`${statePath}.lock`);
4487
- const currentConversation = loadState(statePath);
4488
- if (!["waiting_for_agent", "waiting_for_openclaw"].includes(currentConversation.status)) {
4489
- throw new Error(`cannot cancel ${currentConversation.conversation_id}; conversation is ${currentConversation.status}`);
4490
- }
4491
- const currentTakeover = isRecord(currentConversation.native_session_takeover)
4492
- ? currentConversation.native_session_takeover
4493
- : undefined;
4494
- const currentControl = terminalControlFromTakeover(currentTakeover);
4495
- if (!currentControl ||
4496
- currentControl.target !== terminalControl.target ||
4497
- currentControl.socketPath !== terminalControl.socketPath) {
4498
- throw new Error("terminal control changed while waiting to cancel; refresh status and retry");
4499
- }
4500
- assertManagedTerminalDispatchOwner({
4501
- conversation: currentConversation,
4502
- terminalControl: currentControl,
4503
- action: "cancel"
4504
- });
4505
- const cancellation = await createTerminalAgentBridge(options).cancel(agent, currentControl, {
4506
- runtime: terminalRuntimeIdentityForConversation(currentConversation, currentControl),
4507
- scrollbackLines: Number(options.scrollbackLines ?? 120)
4508
- });
4509
- if (!cancellation.cancelRequested) {
4510
- 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({
4511
4646
  conversation: currentConversation,
4512
- cancel_requested: false,
4513
- 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",
4514
4669
  terminal_control: currentControl,
4515
- 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)
4516
4701
  });
4517
- return;
4518
- }
4519
- const now = new Date().toISOString();
4520
- appendEvent(logPath, {
4521
- ts: now,
4522
- conversation_id: currentConversation.conversation_id,
4523
- event: "terminal_cancel_requested",
4524
- terminal_control: currentControl,
4525
- key: cancellation.key,
4526
- keys: cancellation.keys,
4527
- denied_approval: cancellation.deniedApproval,
4528
- request_id: cancellation.requestId
4529
- });
4530
- runtimeLog("info", "terminal_cancel_requested", {
4531
- conversation_id: currentConversation.conversation_id,
4532
- agent,
4533
- terminal_target: currentControl.target,
4534
- key: cancellation.key,
4535
- keys: cancellation.keys,
4536
- denied_approval: cancellation.deniedApproval,
4537
- request_id: cancellation.requestId
4538
- });
4539
- const nextConversation = {
4540
- ...currentConversation,
4541
- status: "cancelled",
4542
- cancelled_at: now,
4543
- terminal_cancel_requested_at: now,
4544
- updated_at: now
4545
- };
4546
- saveState(statePath, nextConversation);
4547
- printJson({
4548
- conversation: nextConversation,
4549
- cancel_requested: true,
4550
- terminal_control: currentControl,
4551
- key: cancellation.key,
4552
- keys: cancellation.keys,
4553
- denied_approval: cancellation.deniedApproval,
4554
- request_id: cancellation.requestId,
4555
- budget: budgetAction(nextConversation)
4556
4702
  });
4557
4703
  }
4558
4704
  finally {
@@ -5983,7 +6129,7 @@ function terminalBridgeRuntimeDir() {
5983
6129
  const configured = stringValue(process.env.AKK_RUNTIME_DIR);
5984
6130
  return configured
5985
6131
  ? path.resolve(expandHome(configured))
5986
- : path.join(path.dirname(defaultStoreDir()), "runtime");
6132
+ : path.join(path.dirname(defaultStoreDir()), "runtime-v2");
5987
6133
  }
5988
6134
  function terminalBridgeRuntimeKey(terminalControl) {
5989
6135
  return createHash("sha256")
@@ -5996,7 +6142,6 @@ function terminalBridgeRuntimeKey(terminalControl) {
5996
6142
  }
5997
6143
  function terminalBridgeDispatchLedgerPath(terminalControl) {
5998
6144
  const ledgerDir = path.join(terminalBridgeRuntimeDir(), "terminal-dispatch");
5999
- ensureDir(ledgerDir);
6000
6145
  return path.join(ledgerDir, `terminal-dispatch-${terminalBridgeRuntimeKey(terminalControl)}.json`);
6001
6146
  }
6002
6147
  function loadTerminalBridgeDispatchLedger(terminalControl) {
@@ -6043,6 +6188,7 @@ function orphanedTerminalDispatchForRecovery(terminalControl) {
6043
6188
  }
6044
6189
  function saveTerminalBridgeDispatchLedger(terminalControl, ledger) {
6045
6190
  const ledgerPath = terminalBridgeDispatchLedgerPath(terminalControl);
6191
+ ensureDir(path.dirname(ledgerPath));
6046
6192
  if (fs.existsSync(ledgerPath) && fs.lstatSync(ledgerPath).isSymbolicLink()) {
6047
6193
  throw new Error(`terminal dispatch ledger is a symlink: ${ledgerPath}`);
6048
6194
  }
@@ -7046,6 +7192,7 @@ function runPreparedCallback(prepared, { emit = true } = {}) {
7046
7192
  }
7047
7193
  return result;
7048
7194
  }
7195
+ assertStoreWriterCompatible(pathsForConversationDir(path.dirname(prepared.statePath)).storeDir);
7049
7196
  try {
7050
7197
  const deliveryKind = deliverCallbackToOpenClaw({
7051
7198
  options: prepared.options,
@@ -7591,7 +7738,13 @@ function loadConversationFromOptions(options) {
7591
7738
  throw new Error("--conversation or --state is required");
7592
7739
  }
7593
7740
  const conversation = options.state
7594
- ? 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
+ })()
7595
7748
  : loadConversationById(conversationId, storeDir);
7596
7749
  assertConfiguredWorkspace(options.workspace, conversation.workspace, `access to AKK conversation ${conversation.conversation_id}`);
7597
7750
  return {
@@ -8204,13 +8357,21 @@ function deliverStalledNotification({ statePath, logPath, conversation, message,
8204
8357
  stderr: textSummary(chatSendDelivery.stderr)
8205
8358
  });
8206
8359
  }
8207
- function cleanupIdleConversations(storeDir, options = {}, now = new Date()) {
8360
+ function reconcileIdleConversations(storeDir, options = {}, now = new Date(), conversationId) {
8208
8361
  const timeoutMinutes = Number(options.idleTimeoutMinutes ?? DEFAULT_IDLE_TIMEOUT_MINUTES);
8209
8362
  if (!Number.isFinite(timeoutMinutes) || timeoutMinutes <= 0) {
8210
- 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
+ };
8211
8369
  }
8212
- 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));
8213
8373
  let closed = 0;
8374
+ let skipped = 0;
8214
8375
  for (const listedConversation of conversations) {
8215
8376
  if (listedConversation.status !== "idle" || !listedConversation.idle_since) {
8216
8377
  continue;
@@ -8230,6 +8391,7 @@ function cleanupIdleConversations(storeDir, options = {}, now = new Date()) {
8230
8391
  }
8231
8392
  catch (error) {
8232
8393
  if (isRecord(error) && error.code === "LOCK_TIMEOUT") {
8394
+ skipped += 1;
8233
8395
  continue;
8234
8396
  }
8235
8397
  throw error;
@@ -8288,6 +8450,7 @@ function cleanupIdleConversations(storeDir, options = {}, now = new Date()) {
8288
8450
  return {
8289
8451
  checked: conversations.length,
8290
8452
  closed,
8453
+ skipped,
8291
8454
  idle_timeout_minutes: timeoutMinutes
8292
8455
  };
8293
8456
  }
@@ -8754,8 +8917,8 @@ function usage() {
8754
8917
  agent-knock-knock --help
8755
8918
  agent-knock-knock --version
8756
8919
  agent-knock-knock delegate --request <text> [--agent ${agentList}] [--workspace <path>] [--store-dir <dir>]
8757
- agent-knock-knock list [--store-dir <dir>] [--agent ${agentList}] [--status <status>] [--all] [--managed-only] [--no-approval-scan] [--terminal-debug]
8758
- 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]
8759
8922
  agent-knock-knock send [--conversation <id|selector>] --message <text> [--type answer|task|control] [--agent-timeout-minutes <minutes>] [--agent-hard-timeout-minutes <minutes>]
8760
8923
  agent-knock-knock approve [--conversation <id|selector>] --expected-approval-fingerprint <fingerprint>
8761
8924
  agent-knock-knock cancel [--conversation <id|selector>]