@scotthuang/agent-knock-knock 0.2.42 → 0.2.44

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
@@ -17,6 +17,7 @@ import { planFork, planTakeover } from "./session-takeover-planner.js";
17
17
  import { StaticTerminalControlProvider, TmuxTerminalControlProvider, enrichActiveProcessesWithTerminalControl, terminalRefFromPane } from "./terminal-control-provider.js";
18
18
  const DEFAULT_IDLE_TIMEOUT_MINUTES = 10080;
19
19
  const DEFAULT_AGENT_TIMEOUT_MINUTES = 60;
20
+ const DEFAULT_AGENT_HARD_TIMEOUT_MINUTES = 720;
20
21
  const DEFAULT_MONITOR_POLL_INTERVAL_MS = 5000;
21
22
  const DEFAULT_CODEX_ACPX_AGENT_COMMAND = "npx -y @agentclientprotocol/codex-acp@^1.1.0";
22
23
  class InlineCodexSessionAdapter {
@@ -105,6 +106,9 @@ async function runCommand(commandName, options) {
105
106
  else if (commandName === "cancel") {
106
107
  await runCancel(options);
107
108
  }
109
+ else if (commandName === "renew") {
110
+ await runRenew(options);
111
+ }
108
112
  else if (commandName === "recover") {
109
113
  runRecover(options);
110
114
  }
@@ -197,7 +201,9 @@ function installOpenClawPlugin(openclawBin, root) {
197
201
  }
198
202
  const failure = cleanProcessText(linked.stderr || linked.stdout)
199
203
  ?? `openclaw plugins install exited with status ${linked.status}`;
200
- if (!/plugin already exists:/i.test(failure)) {
204
+ const canRetryWithForce = /plugin already exists:/i.test(failure) ||
205
+ /install cancelled;\s*rerun with --force\b/i.test(failure);
206
+ if (!canRetryWithForce) {
201
207
  throw new Error(failure);
202
208
  }
203
209
  runCheckedCommand(openclawBin, ["plugins", "install", "--force", root], {
@@ -772,13 +778,13 @@ function isCodexWorkingLine(line) {
772
778
  if (!trimmed) {
773
779
  return false;
774
780
  }
775
- if (/^•\s+Working\b/u.test(trimmed)) {
781
+ if (/^•\s+Working\b/u.test(trimmed) && (/\besc to interrupt\b/u.test(trimmed) || trimmed === "• Working")) {
776
782
  return true;
777
783
  }
778
- if (/\besc to interrupt\b/u.test(trimmed) && (/\bWorking\b/u.test(trimmed) || /\b\/stop to close\b/u.test(trimmed))) {
784
+ if (/^•\s+Waiting for background terminals?\b(?:\s*·|$)/u.test(trimmed)) {
779
785
  return true;
780
786
  }
781
- return /\bbackground terminal running\b/u.test(trimmed);
787
+ return /^\d+\s+background terminals? running\b/u.test(trimmed) && /\/(?:ps|stop)\b/u.test(trimmed);
782
788
  }
783
789
  function isCodexIdlePromptLine(line) {
784
790
  const trimmed = line.trim();
@@ -1330,7 +1336,7 @@ function startExecutorMonitor({ statePath, logPath, pid, outputPath, agentTimeou
1330
1336
  child.unref();
1331
1337
  return child;
1332
1338
  }
1333
- function startTerminalBridgeMonitor({ statePath, logPath, agentTimeoutMinutes, pollIntervalMs, codexHome }) {
1339
+ function startTerminalBridgeMonitor({ statePath, logPath, agentTimeoutMinutes, agentHardTimeoutMinutes, pollIntervalMs, codexHome }) {
1334
1340
  const args = [
1335
1341
  new URL(import.meta.url).pathname,
1336
1342
  "monitor",
@@ -1341,6 +1347,8 @@ function startTerminalBridgeMonitor({ statePath, logPath, agentTimeoutMinutes, p
1341
1347
  logPath,
1342
1348
  "--agent-timeout-minutes",
1343
1349
  String(agentTimeoutMinutes),
1350
+ "--agent-hard-timeout-minutes",
1351
+ String(agentHardTimeoutMinutes),
1344
1352
  "--poll-interval-ms",
1345
1353
  String(pollIntervalMs)
1346
1354
  ];
@@ -1360,10 +1368,18 @@ function startTerminalBridgeMonitorForConversation({ conversation, statePath, lo
1360
1368
  if (!terminalBridgeEnabled(conversation) || !conversation.gateway_method || options.disableTerminalBridgeMonitor === true) {
1361
1369
  return undefined;
1362
1370
  }
1371
+ const nativeTakeover = isRecord(conversation.native_session_takeover)
1372
+ ? conversation.native_session_takeover
1373
+ : undefined;
1363
1374
  return startTerminalBridgeMonitor({
1364
1375
  statePath,
1365
1376
  logPath,
1366
- agentTimeoutMinutes: Number(options.agentTimeoutMinutes ?? DEFAULT_AGENT_TIMEOUT_MINUTES),
1377
+ agentTimeoutMinutes: Number(options.agentTimeoutMinutes ??
1378
+ nativeTakeover?.["terminal_bridge_inactivity_timeout_minutes"] ??
1379
+ DEFAULT_AGENT_TIMEOUT_MINUTES),
1380
+ agentHardTimeoutMinutes: Number(options.agentHardTimeoutMinutes ??
1381
+ nativeTakeover?.["terminal_bridge_hard_timeout_minutes"] ??
1382
+ DEFAULT_AGENT_HARD_TIMEOUT_MINUTES),
1367
1383
  pollIntervalMs: Number(options.monitorPollIntervalMs ?? DEFAULT_MONITOR_POLL_INTERVAL_MS),
1368
1384
  codexHome: options.codexHome
1369
1385
  });
@@ -1374,7 +1390,7 @@ function terminalBridgeEnabled(conversation) {
1374
1390
  : undefined;
1375
1391
  return nativeTakeover?.["terminal_bridge"] === true;
1376
1392
  }
1377
- function withTerminalBridgeState({ conversation, message, startedAt }) {
1393
+ function withTerminalBridgeState({ conversation, message, startedAt, agentTimeoutMinutes, agentHardTimeoutMinutes, preSendScreenFingerprint }) {
1378
1394
  const nativeTakeover = isRecord(conversation.native_session_takeover)
1379
1395
  ? conversation.native_session_takeover
1380
1396
  : {};
@@ -1384,7 +1400,16 @@ function withTerminalBridgeState({ conversation, message, startedAt }) {
1384
1400
  ...nativeTakeover,
1385
1401
  terminal_bridge: true,
1386
1402
  terminal_bridge_started_at: startedAt,
1387
- terminal_bridge_message_id: message.id
1403
+ terminal_bridge_message_id: message.id,
1404
+ terminal_bridge_request_text: String(message.body ?? ""),
1405
+ terminal_bridge_request_hash: terminalBridgeRequestFingerprint(message.body),
1406
+ terminal_bridge_pre_send_screen_fingerprint: preSendScreenFingerprint,
1407
+ terminal_bridge_monitor_started_at: startedAt,
1408
+ terminal_bridge_last_activity_at: startedAt,
1409
+ terminal_bridge_inactivity_timeout_minutes: agentTimeoutMinutes,
1410
+ terminal_bridge_hard_timeout_minutes: agentHardTimeoutMinutes,
1411
+ terminal_bridge_inactivity_deadline_at: deadlineAt(startedAt, agentTimeoutMinutes),
1412
+ terminal_bridge_hard_deadline_at: deadlineAt(startedAt, agentHardTimeoutMinutes)
1388
1413
  },
1389
1414
  updated_at: startedAt
1390
1415
  };
@@ -2033,6 +2058,9 @@ function recordTerminalBridgeApprovalNotification({ statePath, logPath, terminal
2033
2058
  }
2034
2059
  async function runSend(options) {
2035
2060
  const messageBody = required(options.message ?? options.request, "--message is required");
2061
+ if (options.agentHardTimeoutMinutes !== undefined) {
2062
+ positiveMinutes(options.agentHardTimeoutMinutes, "--agent-hard-timeout-minutes");
2063
+ }
2036
2064
  cleanupIdleConversations(storeDirFromOptions(options), options);
2037
2065
  const terminalConversation = await resolveTerminalConversationFromOptions(options);
2038
2066
  if (terminalConversation) {
@@ -2523,17 +2551,31 @@ async function runApprove(options) {
2523
2551
  const nativeTakeoverForUpdate = isRecord(conversation.native_session_takeover)
2524
2552
  ? { ...conversation.native_session_takeover }
2525
2553
  : {};
2554
+ const approvalResolvedAt = new Date().toISOString();
2555
+ const agentTimeoutMinutes = Number(options.agentTimeoutMinutes ??
2556
+ nativeTakeoverForUpdate.terminal_bridge_inactivity_timeout_minutes ??
2557
+ DEFAULT_AGENT_TIMEOUT_MINUTES);
2558
+ const agentHardTimeoutMinutes = positiveMinutes(options.agentHardTimeoutMinutes ??
2559
+ nativeTakeoverForUpdate.terminal_bridge_hard_timeout_minutes ??
2560
+ DEFAULT_AGENT_HARD_TIMEOUT_MINUTES, "--agent-hard-timeout-minutes");
2526
2561
  const nextNativeTakeover = {
2527
2562
  ...nativeTakeoverForUpdate,
2528
2563
  terminal_bridge_approval: undefined,
2529
- terminal_bridge_approval_resolved_at: new Date().toISOString()
2564
+ terminal_bridge_approval_resolved_at: approvalResolvedAt,
2565
+ terminal_bridge_monitor_started_at: approvalResolvedAt,
2566
+ terminal_bridge_last_activity_at: approvalResolvedAt,
2567
+ terminal_bridge_last_activity_reason: "approval resolved",
2568
+ terminal_bridge_inactivity_timeout_minutes: agentTimeoutMinutes,
2569
+ terminal_bridge_hard_timeout_minutes: agentHardTimeoutMinutes,
2570
+ terminal_bridge_inactivity_deadline_at: deadlineAt(approvalResolvedAt, agentTimeoutMinutes),
2571
+ terminal_bridge_hard_deadline_at: deadlineAt(stringValue(nativeTakeoverForUpdate.terminal_bridge_started_at) ?? approvalResolvedAt, agentHardTimeoutMinutes)
2530
2572
  };
2531
2573
  delete nextNativeTakeover.terminal_bridge_approval;
2532
2574
  const nextConversation = {
2533
2575
  ...conversation,
2534
2576
  status: terminalBridgeEnabled(conversation) ? "waiting_for_agent" : conversation.status,
2535
2577
  native_session_takeover: nextNativeTakeover,
2536
- updated_at: new Date().toISOString()
2578
+ updated_at: approvalResolvedAt
2537
2579
  };
2538
2580
  saveState(statePath, nextConversation);
2539
2581
  const bridgeMonitor = startTerminalBridgeMonitorForConversation({
@@ -2550,7 +2592,8 @@ async function runApprove(options) {
2550
2592
  pid: bridgeMonitor.pid ?? null,
2551
2593
  terminal_control: terminalControl,
2552
2594
  reason: "approval_resolved",
2553
- agent_timeout_minutes: Number(options.agentTimeoutMinutes ?? DEFAULT_AGENT_TIMEOUT_MINUTES)
2595
+ agent_timeout_minutes: agentTimeoutMinutes,
2596
+ agent_hard_timeout_minutes: agentHardTimeoutMinutes
2554
2597
  });
2555
2598
  runtimeLog("info", "terminal_bridge_monitor_launch", {
2556
2599
  conversation_id: conversation.conversation_id,
@@ -2640,10 +2683,32 @@ async function runTerminalConversationSend({ options, conversationId, messageBod
2640
2683
  })
2641
2684
  });
2642
2685
  }
2643
- async function runTerminalControlSend({ options, conversation, nextConversation, statePath, logPath, executor, message, terminalControl, needsNativeTakeoverBootstrap }) {
2644
- const provider = createTerminalControlProvider(options);
2686
+ async function runTerminalControlSend({ options, conversation, nextConversation, statePath, logPath, executor, message, terminalControl, needsNativeTakeoverBootstrap, terminalSendLockHeld = false }) {
2645
2687
  const bridge = terminalBridgeEnabled(conversation);
2688
+ if (bridge && !terminalSendLockHeld) {
2689
+ const releaseTerminalLock = acquireFileLock(terminalBridgeSendLockPath(storeDirFromOptions(options), terminalControl), { timeoutMs: 30000 });
2690
+ try {
2691
+ return await runTerminalControlSend({
2692
+ options,
2693
+ conversation,
2694
+ nextConversation,
2695
+ statePath,
2696
+ logPath,
2697
+ executor,
2698
+ message,
2699
+ terminalControl,
2700
+ needsNativeTakeoverBootstrap,
2701
+ terminalSendLockHeld: true
2702
+ });
2703
+ }
2704
+ finally {
2705
+ releaseTerminalLock();
2706
+ }
2707
+ }
2708
+ const provider = createTerminalControlProvider(options);
2646
2709
  const bridgeStartedAt = new Date().toISOString();
2710
+ const agentTimeoutMinutes = Number(options.agentTimeoutMinutes ?? DEFAULT_AGENT_TIMEOUT_MINUTES);
2711
+ const agentHardTimeoutMinutes = positiveMinutes(options.agentHardTimeoutMinutes ?? DEFAULT_AGENT_HARD_TIMEOUT_MINUTES, "--agent-hard-timeout-minutes");
2647
2712
  const terminalPayload = needsNativeTakeoverBootstrap && !bridge
2648
2713
  ? terminalSubmissionPayload(buildAgentSendPayload({
2649
2714
  conversation,
@@ -2654,12 +2719,32 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
2654
2719
  forkTakeover: undefined
2655
2720
  }))
2656
2721
  : terminalSubmissionPayload(String(message.body ?? ""));
2722
+ let preSendScreenFingerprint;
2723
+ if (bridge) {
2724
+ try {
2725
+ const screen = await provider.capture(terminalControl.target, {
2726
+ scrollbackLines: Number(options.scrollbackLines ?? 120),
2727
+ socketPath: terminalControl.socketPath
2728
+ });
2729
+ preSendScreenFingerprint = terminalBridgeScreenFingerprint(screenExcerpt(screen));
2730
+ }
2731
+ catch {
2732
+ // Delivery can still succeed when capture is unavailable; prompt matching remains the fallback boundary.
2733
+ }
2734
+ }
2657
2735
  await provider.sendText(terminalControl.target, terminalPayload, {
2658
2736
  socketPath: terminalControl.socketPath
2659
2737
  });
2660
2738
  await provider.sendKeys(terminalControl.target, ["C-m"], {
2661
2739
  socketPath: terminalControl.socketPath
2662
2740
  });
2741
+ const supersededConversationIds = bridge
2742
+ ? supersedeTerminalBridgeConversations({
2743
+ storeDir: storeDirFromOptions(options),
2744
+ terminalControl,
2745
+ replacementConversationId: conversation.conversation_id
2746
+ })
2747
+ : [];
2663
2748
  appendEvent(logPath, {
2664
2749
  ts: new Date().toISOString(),
2665
2750
  conversation_id: conversation.conversation_id,
@@ -2667,20 +2752,25 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
2667
2752
  executor,
2668
2753
  terminal_control: terminalControl,
2669
2754
  message: textSummary(message.body),
2670
- payload: textSummary(terminalPayload)
2755
+ payload: textSummary(terminalPayload),
2756
+ superseded_conversation_ids: supersededConversationIds
2671
2757
  });
2672
2758
  runtimeLog("info", "terminal_message_send", {
2673
2759
  conversation_id: conversation.conversation_id,
2674
2760
  agent: executor.kind,
2675
2761
  terminal_target: terminalControl.target,
2676
2762
  message: textSummary(message.body),
2677
- payload: textSummary(terminalPayload)
2763
+ payload: textSummary(terminalPayload),
2764
+ superseded_conversation_ids: supersededConversationIds
2678
2765
  });
2679
2766
  const bridgeConversation = bridge
2680
2767
  ? withTerminalBridgeState({
2681
2768
  conversation: nextConversation,
2682
2769
  message,
2683
- startedAt: bridgeStartedAt
2770
+ startedAt: bridgeStartedAt,
2771
+ agentTimeoutMinutes,
2772
+ agentHardTimeoutMinutes,
2773
+ preSendScreenFingerprint
2684
2774
  })
2685
2775
  : nextConversation;
2686
2776
  const deliveredConversation = markTakeoverBootstrapped({
@@ -2707,7 +2797,8 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
2707
2797
  event: "terminal_bridge_monitor_launch",
2708
2798
  pid: bridgeMonitor.pid ?? null,
2709
2799
  terminal_control: terminalControl,
2710
- agent_timeout_minutes: Number(options.agentTimeoutMinutes ?? DEFAULT_AGENT_TIMEOUT_MINUTES)
2800
+ agent_timeout_minutes: agentTimeoutMinutes,
2801
+ agent_hard_timeout_minutes: agentHardTimeoutMinutes
2711
2802
  });
2712
2803
  runtimeLog("info", "terminal_bridge_monitor_launch", {
2713
2804
  conversation_id: deliveredConversation.conversation_id,
@@ -2844,6 +2935,72 @@ function createManagedTerminalConversationFromRawId({ options, conversationId, m
2844
2935
  message
2845
2936
  };
2846
2937
  }
2938
+ function supersedeTerminalBridgeConversations({ storeDir, terminalControl, replacementConversationId }) {
2939
+ const activeStatuses = new Set([
2940
+ "created",
2941
+ "running",
2942
+ "waiting_for_agent",
2943
+ "waiting_for_openclaw",
2944
+ "stalled",
2945
+ "cancelling"
2946
+ ]);
2947
+ const superseded = [];
2948
+ for (const candidate of listConversations(storeDir)) {
2949
+ if (candidate.conversation_id === replacementConversationId || !activeStatuses.has(candidate.status)) {
2950
+ continue;
2951
+ }
2952
+ const candidateTakeover = isRecord(candidate.native_session_takeover)
2953
+ ? candidate.native_session_takeover
2954
+ : undefined;
2955
+ const candidateControl = terminalControlFromTakeover(candidateTakeover);
2956
+ if (candidateTakeover?.["terminal_bridge"] !== true ||
2957
+ candidateControl?.target !== terminalControl.target ||
2958
+ candidateControl?.socketPath !== terminalControl.socketPath) {
2959
+ continue;
2960
+ }
2961
+ const candidateStatePath = stringValue(candidate.state_path);
2962
+ if (!candidateStatePath) {
2963
+ continue;
2964
+ }
2965
+ const releaseLock = acquireFileLock(`${candidateStatePath}.lock`);
2966
+ try {
2967
+ const current = loadState(candidateStatePath);
2968
+ if (!activeStatuses.has(current.status)) {
2969
+ continue;
2970
+ }
2971
+ const currentTakeover = isRecord(current.native_session_takeover)
2972
+ ? current.native_session_takeover
2973
+ : undefined;
2974
+ const currentControl = terminalControlFromTakeover(currentTakeover);
2975
+ if (currentTakeover?.["terminal_bridge"] !== true ||
2976
+ currentControl?.target !== terminalControl.target ||
2977
+ currentControl?.socketPath !== terminalControl.socketPath) {
2978
+ continue;
2979
+ }
2980
+ const now = new Date().toISOString();
2981
+ saveState(candidateStatePath, {
2982
+ ...current,
2983
+ status: "closed",
2984
+ closed_at: now,
2985
+ close_reason: "terminal bridge superseded by a newer task on the same terminal",
2986
+ superseded_by_conversation_id: replacementConversationId,
2987
+ updated_at: now
2988
+ });
2989
+ appendEvent(logPathForStatePath(candidateStatePath), {
2990
+ ts: now,
2991
+ conversation_id: current.conversation_id,
2992
+ event: "terminal_bridge_superseded",
2993
+ terminal_control: terminalControl,
2994
+ replacement_conversation_id: replacementConversationId
2995
+ });
2996
+ superseded.push(current.conversation_id);
2997
+ }
2998
+ finally {
2999
+ releaseLock();
3000
+ }
3001
+ }
3002
+ return superseded;
3003
+ }
2847
3004
  function runNativeCodexResumeSend({ options, conversation, nextConversation, statePath, logPath, executor, executorEnv, message, payload, nativeTakeover, needsNativeTakeoverBootstrap }) {
2848
3005
  const codexPath = resolveExecutable("codex");
2849
3006
  const nativeSessionId = String(nativeTakeover["native_session_id"]);
@@ -3225,6 +3382,113 @@ function markConversationNeedsRecovery({ conversation, statePath, logPath, execu
3225
3382
  budget: budgetAction(nextConversation)
3226
3383
  };
3227
3384
  }
3385
+ async function runRenew(options) {
3386
+ const { conversation, statePath, logPath } = loadConversationFromOptions(options);
3387
+ if (conversation.status === "closed") {
3388
+ throw new Error(`cannot renew ${conversation.conversation_id}; conversation is closed`);
3389
+ }
3390
+ if (conversation.status !== "stalled") {
3391
+ throw new Error(`cannot renew ${conversation.conversation_id}; conversation is ${conversation.status}, not stalled`);
3392
+ }
3393
+ const nativeTakeover = isRecord(conversation.native_session_takeover)
3394
+ ? conversation.native_session_takeover
3395
+ : undefined;
3396
+ const terminalControl = terminalControlFromTakeover(nativeTakeover);
3397
+ if (!terminalControl || nativeTakeover?.["terminal_bridge"] !== true) {
3398
+ throw new Error(`cannot renew ${conversation.conversation_id}; conversation is not a terminal bridge task`);
3399
+ }
3400
+ const panes = await createTerminalControlProvider(options).listPanes();
3401
+ const terminalExists = panes.some((pane) => pane.target === terminalControl.target &&
3402
+ (terminalControl.socketPath === undefined || pane.socketPath === terminalControl.socketPath));
3403
+ if (!terminalExists) {
3404
+ throw new Error(`cannot renew ${conversation.conversation_id}; terminal ${terminalControl.target} is no longer available`);
3405
+ }
3406
+ const inactivityTimeoutMinutes = positiveMinutes(options.minutes ??
3407
+ options.agentTimeoutMinutes ??
3408
+ nativeTakeover?.["terminal_bridge_inactivity_timeout_minutes"] ??
3409
+ DEFAULT_AGENT_TIMEOUT_MINUTES, "--minutes");
3410
+ const hardTimeoutMinutes = positiveMinutes(nativeTakeover?.["terminal_bridge_hard_timeout_minutes"] ??
3411
+ DEFAULT_AGENT_HARD_TIMEOUT_MINUTES, "--agent-hard-timeout-minutes");
3412
+ const startedAt = stringValue(nativeTakeover?.["terminal_bridge_started_at"]);
3413
+ const startedAtMs = startedAt ? Date.parse(startedAt) : NaN;
3414
+ if (Number.isFinite(startedAtMs) && Date.now() - startedAtMs >= hardTimeoutMinutes * 60 * 1000) {
3415
+ throw new Error(`cannot renew ${conversation.conversation_id}; terminal bridge hard lifetime of ${hardTimeoutMinutes} minutes has elapsed`);
3416
+ }
3417
+ const now = new Date().toISOString();
3418
+ const renewed = {
3419
+ ...conversation,
3420
+ status: "waiting_for_agent",
3421
+ native_session_takeover: {
3422
+ ...nativeTakeover,
3423
+ terminal_bridge_monitor_started_at: now,
3424
+ terminal_bridge_last_activity_at: now,
3425
+ terminal_bridge_inactivity_timeout_minutes: inactivityTimeoutMinutes,
3426
+ terminal_bridge_hard_timeout_minutes: hardTimeoutMinutes,
3427
+ terminal_bridge_inactivity_deadline_at: deadlineAt(now, inactivityTimeoutMinutes),
3428
+ terminal_bridge_hard_deadline_at: deadlineAt(startedAt ?? now, hardTimeoutMinutes),
3429
+ terminal_bridge_renewed_at: now
3430
+ },
3431
+ updated_at: now
3432
+ };
3433
+ Reflect.deleteProperty(renewed, "stalled_at");
3434
+ Reflect.deleteProperty(renewed, "stalled_reason");
3435
+ Reflect.deleteProperty(renewed, "stalled_notification_sent_at");
3436
+ Reflect.deleteProperty(renewed, "stalled_notification_message_id");
3437
+ saveState(statePath, renewed);
3438
+ appendEvent(logPath, {
3439
+ ts: now,
3440
+ conversation_id: conversation.conversation_id,
3441
+ event: "terminal_bridge_renewed",
3442
+ previous_status: conversation.status,
3443
+ terminal_control: terminalControl,
3444
+ agent_timeout_minutes: inactivityTimeoutMinutes,
3445
+ agent_hard_timeout_minutes: hardTimeoutMinutes,
3446
+ last_activity_at: now
3447
+ });
3448
+ runtimeLog("info", "terminal_bridge_renewed", {
3449
+ conversation_id: conversation.conversation_id,
3450
+ terminal_target: terminalControl.target,
3451
+ agent_timeout_minutes: inactivityTimeoutMinutes,
3452
+ agent_hard_timeout_minutes: hardTimeoutMinutes
3453
+ });
3454
+ const monitor = startTerminalBridgeMonitorForConversation({
3455
+ conversation: renewed,
3456
+ statePath,
3457
+ logPath,
3458
+ options: {
3459
+ ...options,
3460
+ agentTimeoutMinutes: inactivityTimeoutMinutes,
3461
+ agentHardTimeoutMinutes: hardTimeoutMinutes
3462
+ }
3463
+ });
3464
+ if (monitor) {
3465
+ appendEvent(logPath, {
3466
+ ts: new Date().toISOString(),
3467
+ conversation_id: conversation.conversation_id,
3468
+ event: "terminal_bridge_monitor_launch",
3469
+ pid: monitor.pid ?? null,
3470
+ terminal_control: terminalControl,
3471
+ reason: "renewal",
3472
+ agent_timeout_minutes: inactivityTimeoutMinutes,
3473
+ agent_hard_timeout_minutes: hardTimeoutMinutes
3474
+ });
3475
+ }
3476
+ printJson({
3477
+ conversation: renewed,
3478
+ renewed: true,
3479
+ terminal_control: terminalControl,
3480
+ agent_timeout_minutes: inactivityTimeoutMinutes,
3481
+ agent_hard_timeout_minutes: hardTimeoutMinutes,
3482
+ monitor_pid: monitor?.pid ?? null
3483
+ });
3484
+ }
3485
+ function positiveMinutes(value, optionName) {
3486
+ const parsed = Number(value);
3487
+ if (!Number.isFinite(parsed) || parsed <= 0) {
3488
+ throw new Error(`${optionName} must be a positive number`);
3489
+ }
3490
+ return parsed;
3491
+ }
3228
3492
  async function runCancel(options) {
3229
3493
  cleanupIdleConversations(storeDirFromOptions(options), options);
3230
3494
  const terminalConversation = await resolveTerminalConversationFromOptions(options);
@@ -3694,9 +3958,27 @@ async function runMonitor(options) {
3694
3958
  async function runTerminalBridgeMonitor(options) {
3695
3959
  const statePath = expandHome(required(options.state, "--state is required"));
3696
3960
  const logPath = expandHome(options.log ?? logPathForStatePath(statePath));
3697
- const timeoutMinutes = Number(options.agentTimeoutMinutes ?? DEFAULT_AGENT_TIMEOUT_MINUTES);
3698
3961
  const pollIntervalMs = Math.max(50, Number(options.pollIntervalMs ?? DEFAULT_MONITOR_POLL_INTERVAL_MS));
3699
3962
  let conversation = loadState(statePath);
3963
+ const initialNativeTakeover = isRecord(conversation.native_session_takeover)
3964
+ ? conversation.native_session_takeover
3965
+ : undefined;
3966
+ const timeoutMinutes = Number(options.agentTimeoutMinutes ??
3967
+ initialNativeTakeover?.["terminal_bridge_inactivity_timeout_minutes"] ??
3968
+ DEFAULT_AGENT_TIMEOUT_MINUTES);
3969
+ const hardTimeoutMinutes = positiveMinutes(options.agentHardTimeoutMinutes ??
3970
+ initialNativeTakeover?.["terminal_bridge_hard_timeout_minutes"] ??
3971
+ DEFAULT_AGENT_HARD_TIMEOUT_MINUTES, "--agent-hard-timeout-minutes");
3972
+ const monitorStartedAtMs = Date.now();
3973
+ const monitorMessageId = stringValue(initialNativeTakeover?.["terminal_bridge_message_id"]);
3974
+ const taskStartedAtMs = validTimestampMs(initialNativeTakeover?.["terminal_bridge_started_at"]) ?? monitorStartedAtMs;
3975
+ let lastActivityAtMs = validTimestampMs(initialNativeTakeover?.["terminal_bridge_last_activity_at"]) ?? taskStartedAtMs;
3976
+ let lastPersistedActivityAtMs = lastActivityAtMs;
3977
+ const activityPersistIntervalMs = terminalBridgeActivityPersistIntervalMs(timeoutMinutes, pollIntervalMs);
3978
+ const preSendScreenFingerprint = stringValue(initialNativeTakeover?.["terminal_bridge_pre_send_screen_fingerprint"]);
3979
+ let previousScreenFingerprint = preSendScreenFingerprint;
3980
+ let previousRolloutFingerprint;
3981
+ let persistedActivityReason = stringValue(initialNativeTakeover?.["terminal_bridge_last_activity_reason"]);
3700
3982
  const executor = executorForConversation(conversation);
3701
3983
  appendEvent(logPath, {
3702
3984
  ts: new Date().toISOString(),
@@ -3704,13 +3986,23 @@ async function runTerminalBridgeMonitor(options) {
3704
3986
  event: "terminal_bridge_monitor_started",
3705
3987
  executor,
3706
3988
  agent_timeout_minutes: timeoutMinutes,
3707
- poll_interval_ms: pollIntervalMs
3989
+ agent_hard_timeout_minutes: hardTimeoutMinutes,
3990
+ poll_interval_ms: pollIntervalMs,
3991
+ task_started_at: new Date(taskStartedAtMs).toISOString(),
3992
+ last_activity_at: new Date(lastActivityAtMs).toISOString(),
3993
+ inactivity_deadline_at: timeoutMinutes > 0
3994
+ ? new Date(lastActivityAtMs + timeoutMinutes * 60 * 1000).toISOString()
3995
+ : null,
3996
+ hard_deadline_at: hardTimeoutMinutes > 0
3997
+ ? new Date(taskStartedAtMs + hardTimeoutMinutes * 60 * 1000).toISOString()
3998
+ : null
3708
3999
  });
3709
4000
  runtimeLog("info", "terminal_bridge_monitor_started", {
3710
4001
  conversation_id: conversation.conversation_id,
3711
4002
  agent: executor.kind,
3712
4003
  executor_session: executor.session,
3713
- agent_timeout_minutes: timeoutMinutes
4004
+ agent_timeout_minutes: timeoutMinutes,
4005
+ agent_hard_timeout_minutes: hardTimeoutMinutes
3714
4006
  });
3715
4007
  let idleCompletionFingerprint;
3716
4008
  while (true) {
@@ -3733,6 +4025,24 @@ async function runTerminalBridgeMonitor(options) {
3733
4025
  const nativeTakeover = isRecord(conversation.native_session_takeover)
3734
4026
  ? conversation.native_session_takeover
3735
4027
  : undefined;
4028
+ const currentMessageId = stringValue(nativeTakeover?.["terminal_bridge_message_id"]);
4029
+ if (monitorMessageId && currentMessageId !== monitorMessageId) {
4030
+ appendEvent(logPath, {
4031
+ ts: new Date().toISOString(),
4032
+ conversation_id: conversation.conversation_id,
4033
+ event: "terminal_bridge_monitor_superseded",
4034
+ monitor_message_id: monitorMessageId,
4035
+ current_message_id: currentMessageId
4036
+ });
4037
+ printJson({
4038
+ conversation,
4039
+ monitored: true,
4040
+ terminal_bridge: true,
4041
+ completed: false,
4042
+ reason: "terminal_bridge_task_replaced"
4043
+ });
4044
+ return;
4045
+ }
3736
4046
  const terminalControl = terminalControlFromTakeover(nativeTakeover);
3737
4047
  if (!terminalControl || nativeTakeover?.["terminal_bridge"] !== true) {
3738
4048
  const stalledConversation = markConversationStalled({
@@ -3841,26 +4151,91 @@ async function runTerminalBridgeMonitor(options) {
3841
4151
  });
3842
4152
  return;
3843
4153
  }
4154
+ const screenFingerprint = terminalBridgeScreenFingerprint(terminalStatus?.screen?.excerpt);
4155
+ const screenChanged = previousScreenFingerprint !== undefined &&
4156
+ screenFingerprint !== undefined &&
4157
+ screenFingerprint !== previousScreenFingerprint;
4158
+ const screenChangedSinceSend = preSendScreenFingerprint !== undefined &&
4159
+ screenFingerprint !== undefined &&
4160
+ screenFingerprint !== preSendScreenFingerprint;
4161
+ previousScreenFingerprint = screenFingerprint;
3844
4162
  const contextMatch = await terminalBridgeCodexContext({
3845
4163
  conversation,
3846
4164
  nativeTakeover,
3847
4165
  options
3848
4166
  });
3849
4167
  const startedAt = stringValue(nativeTakeover?.["terminal_bridge_started_at"]);
4168
+ const rolloutCompletion = contextMatch?.context
4169
+ ? latestCompletedRolloutTurn({
4170
+ context: contextMatch.context,
4171
+ conversation,
4172
+ nativeTakeover,
4173
+ startedAt
4174
+ })
4175
+ : undefined;
3850
4176
  const assistantMessage = contextMatch?.context
3851
4177
  ? latestAssistantAfter(contextMatch.context, startedAt)
3852
4178
  : undefined;
4179
+ const rolloutFingerprint = rolloutCompletion || assistantMessage
4180
+ ? terminalBridgeActivityFingerprint(JSON.stringify({
4181
+ text: rolloutCompletion?.text ?? assistantMessage?.text,
4182
+ timestamp: rolloutCompletion?.timestamp ?? assistantMessage?.timestamp,
4183
+ turn_id: rolloutCompletion?.turnId
4184
+ }))
4185
+ : undefined;
4186
+ const rolloutChanged = rolloutFingerprint !== undefined && rolloutFingerprint !== previousRolloutFingerprint;
4187
+ previousRolloutFingerprint = rolloutFingerprint;
4188
+ const activityReasons = [
4189
+ terminalStatus.activity_state === "working" ? terminalStatus.activity_reason : undefined,
4190
+ screenChanged ? "terminal screen changed" : undefined,
4191
+ rolloutChanged ? "Codex rollout assistant output changed" : undefined
4192
+ ].filter((value) => Boolean(value));
4193
+ if (activityReasons.length > 0) {
4194
+ const observedAtMs = Date.now();
4195
+ lastActivityAtMs = observedAtMs;
4196
+ const activityReason = activityReasons.join("; ");
4197
+ if (persistedActivityReason === undefined ||
4198
+ observedAtMs - lastPersistedActivityAtMs >= activityPersistIntervalMs) {
4199
+ conversation = persistTerminalBridgeActivity({
4200
+ conversation,
4201
+ statePath,
4202
+ logPath,
4203
+ observedAtMs,
4204
+ reason: activityReason,
4205
+ activityState: terminalStatus.activity_state,
4206
+ timeoutMinutes,
4207
+ hardTimeoutMinutes
4208
+ });
4209
+ lastPersistedActivityAtMs = observedAtMs;
4210
+ persistedActivityReason = activityReason;
4211
+ if (!isWaitingForAgent(conversation.status)) {
4212
+ continue;
4213
+ }
4214
+ }
4215
+ }
3853
4216
  const terminalIdle = terminalStatus.activity_state === "idle";
3854
- const screenMessage = !assistantMessage && terminalIdle
3855
- ? terminalBridgeScreenMessage({ conversation, terminalStatus })
4217
+ const screenMessage = !rolloutCompletion && !assistantMessage && terminalIdle
4218
+ ? terminalBridgeScreenMessage({
4219
+ conversation,
4220
+ nativeTakeover,
4221
+ terminalStatus,
4222
+ screenChangedSinceSend
4223
+ })
3856
4224
  : undefined;
3857
- const completion = terminalIdle ? assistantMessage ?? screenMessage : undefined;
4225
+ const completion = rolloutCompletion ?? (terminalIdle ? assistantMessage ?? screenMessage : undefined);
4226
+ const completionMatch = rolloutCompletion
4227
+ ? "rollout_task_complete"
4228
+ : assistantMessage
4229
+ ? contextMatch?.match
4230
+ : "terminal_screen";
3858
4231
  const completionFingerprint = completion
3859
4232
  ? createHash("sha256")
3860
4233
  .update(JSON.stringify({
3861
4234
  text: completion.text,
3862
4235
  timestamp: completion.timestamp,
3863
- match: assistantMessage ? contextMatch?.match : "terminal_screen"
4236
+ match: completionMatch,
4237
+ turn_id: rolloutCompletion?.turnId,
4238
+ message_id: currentMessageId
3864
4239
  }))
3865
4240
  .digest("hex")
3866
4241
  : undefined;
@@ -3872,9 +4247,11 @@ async function runTerminalBridgeMonitor(options) {
3872
4247
  conversation_id: conversation.conversation_id,
3873
4248
  event: "terminal_bridge_completion_detected",
3874
4249
  terminal_control: terminalControl,
3875
- match: assistantMessage ? contextMatch?.match : "terminal_screen",
4250
+ match: completionMatch,
3876
4251
  codex_session: contextMatch?.context.source,
3877
- assistant_timestamp: completion?.timestamp
4252
+ assistant_timestamp: completion?.timestamp,
4253
+ rollout_turn_id: rolloutCompletion?.turnId,
4254
+ terminal_bridge_message_id: currentMessageId
3878
4255
  });
3879
4256
  const callbackMessage = createMessage({
3880
4257
  conversation,
@@ -3887,9 +4264,11 @@ async function runTerminalBridgeMonitor(options) {
3887
4264
  source: "terminal_bridge",
3888
4265
  terminal_control: terminalControl,
3889
4266
  codex_session: contextMatch?.context.source,
3890
- confidence: assistantMessage ? contextMatch?.confidence : "screen_only",
3891
- match: assistantMessage ? contextMatch?.match : "terminal_screen",
3892
- assistant_timestamp: completion?.timestamp
4267
+ confidence: rolloutCompletion || assistantMessage ? contextMatch?.confidence : "screen_only",
4268
+ match: completionMatch,
4269
+ assistant_timestamp: completion?.timestamp,
4270
+ rollout_turn_id: rolloutCompletion?.turnId,
4271
+ terminal_bridge_message_id: currentMessageId
3893
4272
  }
3894
4273
  });
3895
4274
  runLockedCallback({
@@ -3907,18 +4286,59 @@ async function runTerminalBridgeMonitor(options) {
3907
4286
  });
3908
4287
  return;
3909
4288
  }
4289
+ // A concrete approval or completion observed on this poll wins over a timeout boundary.
4290
+ const nowMs = Date.now();
4291
+ if (Number.isFinite(hardTimeoutMinutes) &&
4292
+ hardTimeoutMinutes > 0 &&
4293
+ nowMs - taskStartedAtMs >= hardTimeoutMinutes * 60 * 1000) {
4294
+ appendEvent(logPath, {
4295
+ ts: new Date(nowMs).toISOString(),
4296
+ conversation_id: conversation.conversation_id,
4297
+ event: "terminal_bridge_hard_timeout_reached",
4298
+ terminal_control: terminalControl,
4299
+ task_started_at: new Date(taskStartedAtMs).toISOString(),
4300
+ hard_deadline_at: new Date(taskStartedAtMs + hardTimeoutMinutes * 60 * 1000).toISOString(),
4301
+ agent_hard_timeout_minutes: hardTimeoutMinutes,
4302
+ last_activity_at: new Date(lastActivityAtMs).toISOString(),
4303
+ terminal_activity_state: terminalStatus.activity_state
4304
+ });
4305
+ const stalledConversation = markConversationStalled({
4306
+ statePath,
4307
+ logPath,
4308
+ reason: `terminal bridge reached its hard lifetime of ${hardTimeoutMinutes} minutes`,
4309
+ detail: {
4310
+ terminal_bridge: true,
4311
+ terminal_control: terminalControl,
4312
+ task_started_at: new Date(taskStartedAtMs).toISOString(),
4313
+ last_activity_at: new Date(lastActivityAtMs).toISOString(),
4314
+ agent_hard_timeout_minutes: hardTimeoutMinutes,
4315
+ terminal_activity_state: terminalStatus.activity_state
4316
+ }
4317
+ });
4318
+ printJson({
4319
+ conversation: stalledConversation,
4320
+ monitored: true,
4321
+ terminal_bridge: true,
4322
+ stalled: true,
4323
+ hard_timeout: true,
4324
+ reason: stalledConversation?.stalled_reason
4325
+ });
4326
+ return;
4327
+ }
3910
4328
  if (Number.isFinite(timeoutMinutes) && timeoutMinutes > 0) {
3911
- const updatedAtMs = Date.parse(String(conversation.updated_at ?? conversation.created_at));
3912
- if (Number.isFinite(updatedAtMs) && Date.now() - updatedAtMs >= timeoutMinutes * 60 * 1000) {
4329
+ if (nowMs - lastActivityAtMs >= timeoutMinutes * 60 * 1000) {
3913
4330
  const stalledConversation = markConversationStalled({
3914
4331
  statePath,
3915
4332
  logPath,
3916
- reason: `terminal bridge did not observe completion after ${timeoutMinutes} minutes`,
4333
+ reason: `terminal bridge observed no activity for ${timeoutMinutes} minutes`,
3917
4334
  detail: {
3918
4335
  terminal_bridge: true,
3919
4336
  terminal_control: terminalControl,
3920
4337
  match: contextMatch?.match,
3921
- terminal_activity_state: terminalStatus.activity_state
4338
+ terminal_activity_state: terminalStatus.activity_state,
4339
+ last_activity_at: new Date(lastActivityAtMs).toISOString(),
4340
+ inactivity_deadline_at: new Date(lastActivityAtMs + timeoutMinutes * 60 * 1000).toISOString(),
4341
+ agent_timeout_minutes: timeoutMinutes
3922
4342
  }
3923
4343
  });
3924
4344
  printJson({
@@ -3934,6 +4354,108 @@ async function runTerminalBridgeMonitor(options) {
3934
4354
  sleepSync(pollIntervalMs);
3935
4355
  }
3936
4356
  }
4357
+ function validTimestampMs(value) {
4358
+ const parsed = Date.parse(String(value ?? ""));
4359
+ return Number.isFinite(parsed) ? parsed : undefined;
4360
+ }
4361
+ function deadlineAt(startedAt, timeoutMinutes) {
4362
+ const startedAtMs = validTimestampMs(startedAt);
4363
+ return startedAtMs !== undefined && Number.isFinite(timeoutMinutes) && timeoutMinutes > 0
4364
+ ? new Date(startedAtMs + timeoutMinutes * 60 * 1000).toISOString()
4365
+ : undefined;
4366
+ }
4367
+ function terminalBridgeActivityFingerprint(value) {
4368
+ const text = stringValue(value);
4369
+ return text ? createHash("sha256").update(text).digest("hex") : undefined;
4370
+ }
4371
+ function terminalBridgeScreenFingerprint(value) {
4372
+ return typeof value === "string"
4373
+ ? createHash("sha256").update(value).digest("hex")
4374
+ : undefined;
4375
+ }
4376
+ function terminalBridgeSendLockPath(storeDir, terminalControl) {
4377
+ const terminalKey = createHash("sha256")
4378
+ .update(JSON.stringify({
4379
+ target: terminalControl.target,
4380
+ socket_path: terminalControl.socketPath
4381
+ }))
4382
+ .digest("hex")
4383
+ .slice(0, 20);
4384
+ return path.join(storeDir, `.terminal-bridge-send-${terminalKey}.lock`);
4385
+ }
4386
+ function terminalBridgeRequestFingerprint(value) {
4387
+ const text = String(value ?? "").replace(/\s+/gu, " ").trim();
4388
+ return text ? createHash("sha256").update(text).digest("hex") : undefined;
4389
+ }
4390
+ function terminalBridgeActivityPersistIntervalMs(timeoutMinutes, pollIntervalMs) {
4391
+ if (!Number.isFinite(timeoutMinutes) || timeoutMinutes <= 0) {
4392
+ return 5 * 60 * 1000;
4393
+ }
4394
+ return Math.max(pollIntervalMs, Math.min(timeoutMinutes * 30 * 1000, 5 * 60 * 1000));
4395
+ }
4396
+ function persistTerminalBridgeActivity({ conversation, statePath, logPath, observedAtMs, reason, activityState, timeoutMinutes, hardTimeoutMinutes }) {
4397
+ const releaseLock = acquireFileLock(`${statePath}.lock`);
4398
+ try {
4399
+ const currentConversation = loadState(statePath);
4400
+ if (!isWaitingForAgent(currentConversation.status)) {
4401
+ return currentConversation;
4402
+ }
4403
+ const expectedNativeTakeover = isRecord(conversation.native_session_takeover)
4404
+ ? conversation.native_session_takeover
4405
+ : {};
4406
+ const nativeTakeover = isRecord(currentConversation.native_session_takeover)
4407
+ ? currentConversation.native_session_takeover
4408
+ : {};
4409
+ if (nativeTakeover["terminal_bridge_message_id"] !==
4410
+ expectedNativeTakeover["terminal_bridge_message_id"]) {
4411
+ return currentConversation;
4412
+ }
4413
+ const previousActivityAtMs = validTimestampMs(nativeTakeover["terminal_bridge_last_activity_at"]);
4414
+ const observedAt = new Date(observedAtMs).toISOString();
4415
+ const inactivityDeadlineAt = Number.isFinite(timeoutMinutes) && timeoutMinutes > 0
4416
+ ? new Date(observedAtMs + timeoutMinutes * 60 * 1000).toISOString()
4417
+ : undefined;
4418
+ const nextConversation = {
4419
+ ...currentConversation,
4420
+ native_session_takeover: {
4421
+ ...nativeTakeover,
4422
+ terminal_bridge_last_activity_at: observedAt,
4423
+ terminal_bridge_last_activity_reason: reason,
4424
+ terminal_bridge_inactivity_deadline_at: inactivityDeadlineAt,
4425
+ terminal_bridge_inactivity_timeout_minutes: timeoutMinutes,
4426
+ terminal_bridge_hard_timeout_minutes: hardTimeoutMinutes
4427
+ },
4428
+ updated_at: observedAt
4429
+ };
4430
+ saveState(statePath, nextConversation);
4431
+ appendEvent(logPath, {
4432
+ ts: observedAt,
4433
+ conversation_id: currentConversation.conversation_id,
4434
+ event: "terminal_bridge_activity_observed",
4435
+ reason,
4436
+ last_activity_at: observedAt,
4437
+ terminal_activity_state: activityState
4438
+ });
4439
+ if (inactivityDeadlineAt) {
4440
+ appendEvent(logPath, {
4441
+ ts: observedAt,
4442
+ conversation_id: currentConversation.conversation_id,
4443
+ event: "terminal_bridge_inactivity_deadline_extended",
4444
+ reason,
4445
+ previous_last_activity_at: previousActivityAtMs === undefined
4446
+ ? null
4447
+ : new Date(previousActivityAtMs).toISOString(),
4448
+ last_activity_at: observedAt,
4449
+ inactivity_deadline_at: inactivityDeadlineAt,
4450
+ agent_timeout_minutes: timeoutMinutes
4451
+ });
4452
+ }
4453
+ return nextConversation;
4454
+ }
4455
+ finally {
4456
+ releaseLock();
4457
+ }
4458
+ }
3937
4459
  function terminalBridgeApprovalCandidate({ executor, terminalControl, terminalStatus, fingerprint }) {
3938
4460
  const approval = isRecord(terminalStatus?.approval_state) ? terminalStatus.approval_state : {};
3939
4461
  if (approval.approvable !== true) {
@@ -4019,22 +4541,59 @@ function latestAssistantAfter(context, startedAt) {
4019
4541
  return Number.isFinite(messageTime) && messageTime >= Number(threshold);
4020
4542
  });
4021
4543
  }
4022
- function terminalBridgeScreenMessage({ conversation, terminalStatus }) {
4544
+ function latestCompletedRolloutTurn({ context, conversation, nativeTakeover, startedAt }) {
4545
+ const threshold = validTimestampMs(startedAt);
4546
+ const expectedRequestHash = stringValue(nativeTakeover?.["terminal_bridge_request_hash"]) ??
4547
+ terminalBridgeRequestFingerprint(nativeTakeover?.["terminal_bridge_request_text"] ?? conversation.user_request);
4548
+ if (threshold === undefined || !expectedRequestHash) {
4549
+ return undefined;
4550
+ }
4551
+ const turn = [...(context.turns ?? [])]
4552
+ .reverse()
4553
+ .find((candidate) => {
4554
+ const userTimestamp = validTimestampMs(candidate.userTimestamp);
4555
+ const completedAt = validTimestampMs(candidate.completedAt);
4556
+ return candidate.userTextHash === expectedRequestHash &&
4557
+ userTimestamp !== undefined &&
4558
+ completedAt !== undefined &&
4559
+ userTimestamp >= threshold &&
4560
+ completedAt >= userTimestamp &&
4561
+ Boolean(candidate.lastAssistantMessage);
4562
+ });
4563
+ if (!turn?.lastAssistantMessage) {
4564
+ return undefined;
4565
+ }
4566
+ return {
4567
+ role: "assistant",
4568
+ text: turn.lastAssistantMessage,
4569
+ timestamp: turn.completedAt,
4570
+ turnId: turn.turnId,
4571
+ userTimestamp: turn.userTimestamp
4572
+ };
4573
+ }
4574
+ function terminalBridgeScreenMessage({ conversation, nativeTakeover, terminalStatus, screenChangedSinceSend }) {
4023
4575
  const excerpt = stringValue(terminalStatus?.screen?.excerpt);
4024
4576
  if (!excerpt) {
4025
4577
  return undefined;
4026
4578
  }
4027
- const request = String(conversation.user_request ?? "").trim();
4028
- const promptIndex = request ? excerpt.lastIndexOf(request) : -1;
4029
- const afterPrompt = promptIndex >= 0
4030
- ? excerpt.slice(promptIndex + request.length)
4031
- : excerpt;
4032
- const completionBoundary = terminalBridgeCompletionBoundary(afterPrompt);
4033
- const completionText = completionBoundary === undefined
4034
- ? afterPrompt
4035
- : afterPrompt.slice(0, completionBoundary);
4579
+ const request = String(nativeTakeover?.["terminal_bridge_request_text"] ?? conversation.user_request ?? "").trim();
4580
+ const promptEnd = request ? whitespaceInsensitiveMatchEnd(excerpt, request) : undefined;
4581
+ const afterPrompt = promptEnd === undefined ? undefined : excerpt.slice(promptEnd);
4582
+ const completionBoundary = afterPrompt === undefined
4583
+ ? undefined
4584
+ : terminalBridgeCompletionBoundary(afterPrompt);
4585
+ const completionText = afterPrompt === undefined
4586
+ ? screenChangedSinceSend
4587
+ ? terminalBridgeLatestCompletedSegment(excerpt)
4588
+ : undefined
4589
+ : completionBoundary === undefined
4590
+ ? afterPrompt
4591
+ : afterPrompt.slice(0, completionBoundary);
4592
+ if (completionText === undefined) {
4593
+ return undefined;
4594
+ }
4036
4595
  const cleaned = cleanTerminalBridgeScreenText(completionText);
4037
- const hasCompletionEvidence = completionBoundary !== undefined || /[•└]/u.test(cleaned ?? "");
4596
+ const hasCompletionEvidence = promptEnd === undefined || completionBoundary !== undefined || /[•└]/u.test(cleaned ?? "");
4038
4597
  if (!cleaned || cleaned.length < 40 || !hasCompletionEvidence) {
4039
4598
  return undefined;
4040
4599
  }
@@ -4044,6 +4603,51 @@ function terminalBridgeScreenMessage({ conversation, terminalStatus }) {
4044
4603
  timestamp: undefined
4045
4604
  };
4046
4605
  }
4606
+ function whitespaceInsensitiveMatchEnd(text, expected) {
4607
+ const normalizedExpected = expected.replace(/\s/gu, "");
4608
+ if (!normalizedExpected) {
4609
+ return undefined;
4610
+ }
4611
+ let normalizedText = "";
4612
+ const sourceEnds = [];
4613
+ for (let index = 0; index < text.length;) {
4614
+ const codePoint = text.codePointAt(index);
4615
+ if (codePoint === undefined) {
4616
+ break;
4617
+ }
4618
+ const character = String.fromCodePoint(codePoint);
4619
+ if (!/\s/u.test(character)) {
4620
+ normalizedText += character;
4621
+ for (let codeUnit = 0; codeUnit < character.length; codeUnit += 1) {
4622
+ sourceEnds.push(index + character.length);
4623
+ }
4624
+ }
4625
+ index += character.length;
4626
+ }
4627
+ const matchIndex = normalizedText.lastIndexOf(normalizedExpected);
4628
+ if (matchIndex < 0) {
4629
+ return undefined;
4630
+ }
4631
+ return sourceEnds[matchIndex + normalizedExpected.length - 1];
4632
+ }
4633
+ function terminalBridgeLatestCompletedSegment(text) {
4634
+ const matches = [...text.matchAll(/^[ \t]*[─━-]+\s+Worked for\b.*$/gmu)];
4635
+ const completion = matches.at(-1);
4636
+ if (completion?.index === undefined) {
4637
+ return undefined;
4638
+ }
4639
+ const previousCompletion = matches.at(-2);
4640
+ let start = previousCompletion?.index === undefined
4641
+ ? 0
4642
+ : previousCompletion.index + previousCompletion[0].length;
4643
+ const beforeCompletion = text.slice(0, completion.index);
4644
+ const prompts = [...beforeCompletion.matchAll(/^[ \t]*›(?:\s|$).*$/gmu)];
4645
+ const latestPrompt = prompts.at(-1);
4646
+ if (latestPrompt?.index !== undefined && latestPrompt.index >= start) {
4647
+ start = latestPrompt.index + latestPrompt[0].length;
4648
+ }
4649
+ return text.slice(start, completion.index);
4650
+ }
4047
4651
  function terminalBridgeCompletionBoundary(text) {
4048
4652
  const matches = [...text.matchAll(/^[ \t]*[─━-]+\s+Worked for\b.*$/gmu)];
4049
4653
  return matches.at(-1)?.index;
@@ -4417,7 +5021,7 @@ function acquireFileLock(lockPath, { timeoutMs = 5000, retryMs = 50 } = {}) {
4417
5021
  throw error;
4418
5022
  }
4419
5023
  if (Date.now() - started >= timeoutMs) {
4420
- throw new Error(`timed out waiting for callback lock: ${lockPath}`);
5024
+ throw new Error(`timed out waiting for file lock: ${lockPath}`);
4421
5025
  }
4422
5026
  sleepSync(retryMs);
4423
5027
  }
@@ -4955,6 +5559,7 @@ function markConversationStalled({ statePath, logPath, reason, detail = {} }) {
4955
5559
  }
4956
5560
  const now = new Date().toISOString();
4957
5561
  const executor = executorForConversation(conversation);
5562
+ const terminalBridge = terminalBridgeEnabled(conversation);
4958
5563
  const shouldNotify = Boolean(conversation.gateway_method && !conversation.stalled_notification_sent_at);
4959
5564
  stalledMessage = shouldNotify
4960
5565
  ? createMessage({
@@ -4968,7 +5573,9 @@ function markConversationStalled({ statePath, logPath, reason, detail = {} }) {
4968
5573
  "",
4969
5574
  `Conversation: ${conversation.conversation_id}`,
4970
5575
  `Session: ${executor.session}`,
4971
- "Use `AKK status` for details, `AKK send` to retry/follow up, or `AKK close` to close it."
5576
+ terminalBridge
5577
+ ? `Use \`AKK status ${conversation.conversation_id}\` for details, \`AKK renew ${conversation.conversation_id}\` to resume monitoring without sending another task, or \`AKK close ${conversation.conversation_id}\` to close it.`
5578
+ : "Use `AKK status` for details, `AKK send` to retry/follow up, or `AKK close` to close it."
4972
5579
  ].join("\n")
4973
5580
  })
4974
5581
  : undefined;
@@ -5686,9 +6293,10 @@ function usage() {
5686
6293
  agent-knock-knock list [--store-dir <dir>] [--agent ${agentList}] [--status <status>] [--all] [--managed-only] [--no-approval-scan] [--terminal-debug]
5687
6294
  agent-knock-knock status --conversation <id> [--store-dir <dir>] [--trace]
5688
6295
  agent-knock-knock describe --conversation <id> [--store-dir <dir>]
5689
- agent-knock-knock send --conversation <id> --message <text> [--type answer|task|control] [--all-proxy <url>] [--agent-timeout-minutes <minutes>]
6296
+ agent-knock-knock send --conversation <id> --message <text> [--type answer|task|control] [--all-proxy <url>] [--agent-timeout-minutes <minutes>] [--agent-hard-timeout-minutes <minutes>]
5690
6297
  agent-knock-knock approve --conversation <id>
5691
6298
  agent-knock-knock cancel --conversation <id> [--all-proxy <url>]
6299
+ agent-knock-knock renew --conversation <id> [--minutes <inactivity-minutes>]
5692
6300
  agent-knock-knock recover --conversation <id> [--session <name>] [--all-proxy <url>]
5693
6301
  agent-knock-knock close --conversation <id> [--reason <text>]
5694
6302
  agent-knock-knock install-openclaw [--openclaw-bin <path>] [--skill-path <path>] [--skill-only] [--no-restart]