@songsid/agend 2.1.4-beta.12 → 2.1.4-beta.14

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/daemon.d.ts CHANGED
@@ -11,6 +11,8 @@ export declare function buildInstructionReloadNotice(binaryName: string, instanc
11
11
  export declare const DEFAULT_STUCK_TIMEOUT_MS: number;
12
12
  export declare const DEFAULT_STATE_IDLE_DEBOUNCE_MS = 2000;
13
13
  export declare const DEFAULT_STATE_SAFETY_SWEEP_MS = 60000;
14
+ /** A foreground server/port-forward should hand control back or be acknowledged. */
15
+ export declare const DEFAULT_BLOCKING_PROCESS_GRACE_MS: number;
14
16
  /**
15
17
  * Whether two working directories belong to the same project, so a fleet-scoped
16
18
  * decision recorded in one reaches the other. Covers the worktree/checkout
@@ -142,10 +144,13 @@ export declare class PaneStateMachine {
142
144
  * working" and a finished turn would not be seen as idle until the next 60s
143
145
  * safety sweep. Output timestamps are the honest liveness signal here, and
144
146
  * the caller has them.
147
+ * @param opts.forceBusy the backend has positively identified a still-running
148
+ * foreground tool even though its TUI keeps an old ready footer visible.
145
149
  */
146
150
  observe(pane: string, now?: number, opts?: {
147
151
  settled?: boolean;
148
152
  changeAt?: number;
153
+ forceBusy?: boolean;
149
154
  }): InstanceStateSnapshot;
150
155
  /** Record pane motion from tmux control mode without capturing pane content. */
151
156
  recordOutput(now?: number): InstanceStateSnapshot;
@@ -258,6 +263,30 @@ export declare class InteractivePromptDetector {
258
263
  observe(pane: string, now?: number, outputAt?: number): InteractivePromptDetection | null;
259
264
  reset(): void;
260
265
  }
266
+ export interface BlockingProcessDetection {
267
+ activity: string;
268
+ evidence: string;
269
+ blockedForMs: number;
270
+ }
271
+ /**
272
+ * Detect a foreground shell tool which has become a long-lived server.
273
+ *
274
+ * Unlike the generic pane-stuck clock, this clock deliberately survives new
275
+ * output: access logs and `Handling connection` lines are exactly why a
276
+ * foreground server can remain blocked forever without ever looking silent.
277
+ * Once a server marker is seen, the still-running tool identity is the stable
278
+ * signal; the marker may scroll out of the 32-row pane.
279
+ */
280
+ export declare class BlockingProcessDetector {
281
+ private readonly graceMs;
282
+ private activity;
283
+ private evidence;
284
+ private detectedAt;
285
+ private notified;
286
+ constructor(graceMs?: number);
287
+ observe(pane: string, activity: string | null, now?: number): BlockingProcessDetection | null;
288
+ reset(): void;
289
+ }
261
290
  export declare class Daemon extends EventEmitter {
262
291
  private name;
263
292
  private config;
@@ -376,6 +405,7 @@ export declare class Daemon extends EventEmitter {
376
405
  private lastProgressBroadcastAt;
377
406
  private errorMonitorTimer;
378
407
  private readonly interactivePromptDetector;
408
+ private readonly blockingProcessDetector;
379
409
  /** Same 5-min gate the error monitor uses, so a dead MCP server alerts once. */
380
410
  private static readonly MCP_DEATH_COOLDOWN_MS;
381
411
  private lastMcpDeathNotifiedAt;
@@ -554,9 +584,9 @@ export declare class Daemon extends EventEmitter {
554
584
  * Tell the fleet manager what this instance is doing right now, for the live
555
585
  * progress line on the cancel button.
556
586
  *
557
- * Purely cosmetic — nothing decides anything from it, so it is fine that only
558
- * backends with a transcript feed report at all (claude-code today). For the
559
- * rest the progress line keeps showing just elapsed time.
587
+ * Primarily cosmetic. The foreground-process detector also uses a current
588
+ * shell activity as one of several positive signals, but never makes a state
589
+ * decision from an arbitrary activity label alone.
560
590
  *
561
591
  * Repeats are dropped: the ticker only edits the channel message when the text
562
592
  * changes, and a stream of identical broadcasts would defeat that.
package/dist/daemon.js CHANGED
@@ -47,6 +47,8 @@ export function buildInstructionReloadNotice(binaryName, instanceName, instanceD
47
47
  export const DEFAULT_STUCK_TIMEOUT_MS = 10 * 60_000;
48
48
  export const DEFAULT_STATE_IDLE_DEBOUNCE_MS = 2_000;
49
49
  export const DEFAULT_STATE_SAFETY_SWEEP_MS = 60_000;
50
+ /** A foreground server/port-forward should hand control back or be acknowledged. */
51
+ export const DEFAULT_BLOCKING_PROCESS_GRACE_MS = 2 * 60_000;
50
52
  const LAST_INBOUND_FILE = "last-inbound-at";
51
53
  /** Minimum gap between "health check is failing" notifications for one instance. */
52
54
  const HEALTH_ERROR_NOTIFY_INTERVAL_MS = 10 * 60_000;
@@ -281,9 +283,11 @@ export class PaneStateMachine {
281
283
  * working" and a finished turn would not be seen as idle until the next 60s
282
284
  * safety sweep. Output timestamps are the honest liveness signal here, and
283
285
  * the caller has them.
286
+ * @param opts.forceBusy the backend has positively identified a still-running
287
+ * foreground tool even though its TUI keeps an old ready footer visible.
284
288
  */
285
289
  observe(pane, now = Date.now(), opts = {}) {
286
- const { settled = false, changeAt = now } = opts;
290
+ const { settled = false, changeAt = now, forceBusy = false } = opts;
287
291
  const paneHash = createHash("sha256").update(pane).digest("hex");
288
292
  const firstObservation = this.lastPaneHash === null;
289
293
  const paneChanged = this.lastPaneHash !== paneHash;
@@ -292,7 +296,10 @@ export class PaneStateMachine {
292
296
  this.lastPaneChangeAt = changeAt;
293
297
  }
294
298
  this.lastObservedAt = now;
295
- const ready = this.isReady(pane);
299
+ // Some TUIs keep their ready footer visible while a foreground tool owns
300
+ // stdin. Backends can positively identify that tool from the pane; in that
301
+ // case the old prompt must not clear pending work or retire Cancel.
302
+ const ready = !forceBusy && this.isReady(pane);
296
303
  const nextState = firstObservation
297
304
  ? ready ? "idle" : "working"
298
305
  : paneChanged && !settled
@@ -593,6 +600,69 @@ export class InteractivePromptDetector {
593
600
  this.notifiedSignature = null;
594
601
  }
595
602
  }
603
+ const BLOCKING_PROCESS_PATTERNS = [
604
+ // kubectl port-forward (including a backgrounded child whose inherited
605
+ // stdout keeps the parent shell tool open).
606
+ /^\s*(?:Forwarding from\s+(?:127\.0\.0\.1|localhost|\[::1\]):\d+\s+->\s+\d+|Handling connection for\s+\d+)\s*$/im,
607
+ // Common development servers. Keep these line-shaped and require an address
608
+ // or port so prose such as "the server is running" does not arm the detector.
609
+ /^\s*(?:(?:INFO:\s*)?Uvicorn running on|Serving HTTP on|Listening on|Server (?:is )?(?:listening|running) (?:at|on)|Application startup complete[^\n]*(?:port|https?:\/\/))[^\n]*(?:https?:\/\/|\b(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::\]|port)\b)[^\n]*$/im,
610
+ ];
611
+ /**
612
+ * Detect a foreground shell tool which has become a long-lived server.
613
+ *
614
+ * Unlike the generic pane-stuck clock, this clock deliberately survives new
615
+ * output: access logs and `Handling connection` lines are exactly why a
616
+ * foreground server can remain blocked forever without ever looking silent.
617
+ * Once a server marker is seen, the still-running tool identity is the stable
618
+ * signal; the marker may scroll out of the 32-row pane.
619
+ */
620
+ export class BlockingProcessDetector {
621
+ graceMs;
622
+ activity = null;
623
+ evidence = null;
624
+ detectedAt = 0;
625
+ notified = false;
626
+ constructor(graceMs = DEFAULT_BLOCKING_PROCESS_GRACE_MS) {
627
+ this.graceMs = graceMs;
628
+ }
629
+ observe(pane, activity, now = Date.now()) {
630
+ const shellActivity = activity && /^(?:shell|bash|exec_command|terminal|command)(?::|$)/i.test(activity);
631
+ if (!shellActivity) {
632
+ this.reset();
633
+ return null;
634
+ }
635
+ if (activity !== this.activity) {
636
+ this.reset();
637
+ this.activity = activity;
638
+ }
639
+ if (!this.evidence) {
640
+ const tail = sanitizePaneTail(pane, 40).join("\n");
641
+ for (const pattern of BLOCKING_PROCESS_PATTERNS) {
642
+ const match = tail.match(pattern);
643
+ if (!match)
644
+ continue;
645
+ this.evidence = match[0].trim().slice(0, 200);
646
+ this.detectedAt = now;
647
+ break;
648
+ }
649
+ }
650
+ if (!this.evidence || this.notified || now - this.detectedAt < this.graceMs)
651
+ return null;
652
+ this.notified = true;
653
+ return {
654
+ activity,
655
+ evidence: this.evidence,
656
+ blockedForMs: now - this.detectedAt,
657
+ };
658
+ }
659
+ reset() {
660
+ this.activity = null;
661
+ this.evidence = null;
662
+ this.detectedAt = 0;
663
+ this.notified = false;
664
+ }
665
+ }
596
666
  export class Daemon extends EventEmitter {
597
667
  name;
598
668
  config;
@@ -727,6 +797,7 @@ export class Daemon extends EventEmitter {
727
797
  // PTY error pattern monitoring
728
798
  errorMonitorTimer = null;
729
799
  interactivePromptDetector = new InteractivePromptDetector();
800
+ blockingProcessDetector = new BlockingProcessDetector();
730
801
  /** Same 5-min gate the error monitor uses, so a dead MCP server alerts once. */
731
802
  static MCP_DEATH_COOLDOWN_MS = 5 * 60_000;
732
803
  lastMcpDeathNotifiedAt = 0;
@@ -1606,6 +1677,25 @@ export class Daemon extends EventEmitter {
1606
1677
  // A prompt is not an error and must not enter the PTY recovery gate.
1607
1678
  // Continue scanning real errors in this same snapshot.
1608
1679
  }
1680
+ // If a backend can derive activity from this exact pane, trust its
1681
+ // explicit null (tool completed) instead of falling back to a possibly
1682
+ // stale transcript activity from the previous scan.
1683
+ const paneActivity = this.backend?.getPaneActivity
1684
+ ? this.backend.getPaneActivity(pane)
1685
+ : this.currentActivity;
1686
+ const hasPendingWork = this.pendingWork.hasPendingWork();
1687
+ if (!hasPendingWork)
1688
+ this.blockingProcessDetector.reset();
1689
+ const blockingProcess = hasPendingWork
1690
+ ? this.blockingProcessDetector.observe(pane, paneActivity, Date.now())
1691
+ : null;
1692
+ if (blockingProcess) {
1693
+ // Reuse the hang-notification bridge: it offers explicit Restart/Wait
1694
+ // choices and, unlike pty_error, does not retire a still-useful Cancel
1695
+ // button merely because a foreground process owns stdin.
1696
+ this.logger.warn(blockingProcess, "Foreground process is blocking the agent input loop");
1697
+ this.hangDetector?.emit("hang", { unchangedForMs: blockingProcess.blockedForMs });
1698
+ }
1609
1699
  // Auto-dismiss runtime dialogs (e.g. Codex rate limit model switch)
1610
1700
  for (const dialog of dialogs) {
1611
1701
  if (!dialog.pattern.test(pane))
@@ -2253,6 +2343,7 @@ export class Daemon extends EventEmitter {
2253
2343
  // mismatched spinner frame was enough to report idle mid-turn.
2254
2344
  const settled = this.instanceStateLastOutputAt === 0
2255
2345
  || captureStartedAt - this.instanceStateLastOutputAt >= this.instanceStateIdleDebounceMs;
2346
+ const paneActivity = this.backend?.getPaneActivity?.(pane) ?? null;
2256
2347
  // The two times are genuinely different and both matter: the content
2257
2348
  // changed when tmux reported output (observedChangeAt), but idle/stuck are
2258
2349
  // decisions about *now*. The old double-observe expressed that by calling
@@ -2260,6 +2351,7 @@ export class Daemon extends EventEmitter {
2260
2351
  const snapshot = this.instanceStateMachine.observe(pane, Date.now(), {
2261
2352
  settled,
2262
2353
  changeAt: observedChangeAt,
2354
+ forceBusy: paneActivity !== null,
2263
2355
  });
2264
2356
  this.applyInstanceStateSnapshot(snapshot, pane);
2265
2357
  // Backends without a transcript feed can still say what they are doing, if
@@ -2267,7 +2359,7 @@ export class Daemon extends EventEmitter {
2267
2359
  // extra tmux call. Cadence is the capture cadence (idle debounce + the 60s
2268
2360
  // safety sweep), which matches the progress ticker's own 60s interval.
2269
2361
  if (this.backend?.getPaneActivity) {
2270
- this.publishActivity(snapshot.state === "idle" ? null : this.backend.getPaneActivity(pane));
2362
+ this.publishActivity(snapshot.state === "idle" ? null : paneActivity);
2271
2363
  }
2272
2364
  if (snapshot.state === "idle") {
2273
2365
  this.clearInstanceStateStuckTimer();
@@ -2397,6 +2489,7 @@ export class Daemon extends EventEmitter {
2397
2489
  // fire into a stopped daemon).
2398
2490
  this.clearMcpRestartRequest();
2399
2491
  this.interactivePromptDetector.reset();
2492
+ this.blockingProcessDetector.reset();
2400
2493
  this.stopInstanceStateMonitor();
2401
2494
  this.transcriptMonitor?.stop();
2402
2495
  this.guardian?.stop();
@@ -2422,9 +2515,9 @@ export class Daemon extends EventEmitter {
2422
2515
  * Tell the fleet manager what this instance is doing right now, for the live
2423
2516
  * progress line on the cancel button.
2424
2517
  *
2425
- * Purely cosmetic — nothing decides anything from it, so it is fine that only
2426
- * backends with a transcript feed report at all (claude-code today). For the
2427
- * rest the progress line keeps showing just elapsed time.
2518
+ * Primarily cosmetic. The foreground-process detector also uses a current
2519
+ * shell activity as one of several positive signals, but never makes a state
2520
+ * decision from an arbitrary activity label alone.
2428
2521
  *
2429
2522
  * Repeats are dropped: the ticker only edits the channel message when the text
2430
2523
  * changes, and a stream of identical broadcasts would defeat that.