@scotthuang/agent-knock-knock 0.2.41 → 0.2.43

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