@aragon-agent/cli 0.6.46 → 0.6.47

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.
Files changed (49) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +53 -6
  3. package/dist/agent/coalesce.d.ts +4 -0
  4. package/dist/agent/coalesce.js +13 -0
  5. package/dist/agent/controller.d.ts +21 -0
  6. package/dist/agent/controller.js +56 -3
  7. package/dist/agent/reducer.d.ts +20 -0
  8. package/dist/agent/reducer.js +31 -2
  9. package/dist/agent/user-attention.d.ts +17 -0
  10. package/dist/agent/user-attention.js +32 -0
  11. package/dist/cli.js +2 -3
  12. package/dist/config/load.js +4 -25
  13. package/dist/config/schema.d.ts +4 -4
  14. package/dist/config/schema.js +7 -1
  15. package/dist/proc/limits.d.ts +27 -13
  16. package/dist/proc/limits.js +27 -13
  17. package/dist/proc/progress.d.ts +7 -0
  18. package/dist/proc/progress.js +35 -7
  19. package/dist/proc/prompt.js +7 -4
  20. package/dist/proc/supervisor.js +7 -1
  21. package/dist/proc/types.d.ts +6 -0
  22. package/dist/proc/wait-plan.d.ts +39 -0
  23. package/dist/proc/wait-plan.js +77 -0
  24. package/dist/proc/wait-view.d.ts +65 -0
  25. package/dist/proc/wait-view.js +66 -0
  26. package/dist/proc/wait.d.ts +36 -3
  27. package/dist/proc/wait.js +67 -4
  28. package/dist/session/checkpoint.js +2 -0
  29. package/dist/session/persist.js +19 -2
  30. package/dist/session/validate-session.js +3 -0
  31. package/dist/tools/job-tools.d.ts +13 -2
  32. package/dist/tools/job-tools.js +156 -33
  33. package/dist/ui/ActivityLine.js +5 -2
  34. package/dist/ui/App.js +34 -0
  35. package/dist/ui/StatusBar.d.ts +1 -0
  36. package/dist/ui/Transcript.js +7 -0
  37. package/dist/ui/entries/ToolCard.js +11 -2
  38. package/dist/ui/entries/WaitCard.d.ts +37 -0
  39. package/dist/ui/entries/WaitCard.js +92 -0
  40. package/dist/ui/interaction-copy.d.ts +2 -0
  41. package/dist/ui/interaction-copy.js +3 -0
  42. package/dist/ui/layout/status-layout.d.ts +1 -0
  43. package/dist/ui/layout/status-layout.js +10 -1
  44. package/dist/ui/layout/virtual-window.js +7 -1
  45. package/dist/ui/layout/wait-bar.d.ts +34 -0
  46. package/dist/ui/layout/wait-bar.js +48 -0
  47. package/dist/ui/run-status-row.d.ts +4 -2
  48. package/dist/ui/scrollback/live-preview.js +12 -0
  49. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -8,6 +8,21 @@ were written.
8
8
  ## Unreleased
9
9
 
10
10
  ### Added
11
+ - **Long waits: `job_wait` now waits up to 20 minutes, and the wait looks like it.**
12
+ `timeoutSeconds` takes any integer from 5 to 1200 (it used to stop at 60, and at 150 with the
13
+ default `toolTimeoutMs`, because the tool ceiling silently pulled it down). `job_wait` gets its own
14
+ executor ceiling (1230 s) and pauses the idle watchdog for the whole wait, so `toolTimeoutMs` no
15
+ longer limits it and `jobs.waitSeconds` accepts 5-1200. **Typing a message ends the wait at once**
16
+ (result line `[woke] ...`, the job keeps running, no `Esc` needed); reviewer notes and completion
17
+ notices do not. While waiting, the tool card is replaced by a fixed five-row card - a progress bar
18
+ (the job's own progress when it prints any, otherwise the wait window), both clocks, the job's
19
+ newest output with "N s ago", a calm "not a failure" note after `jobs.stallSeconds` of silence, and
20
+ a hint that a message ends the wait - and the status bar reads `Waiting on t3 4m12s/10m00s`. New:
21
+ `job: "any"` (return on the first job to end), a `[next]` suggestion with a `timeoutSeconds` (and the
22
+ job's ETA) so the model prefers one long wait to many short ones, and `[note]` when a request is
23
+ clamped to the ceiling. No new config keys; `jobs.enabled=false` is byte-identical to before.
24
+ Honest limit: while a wait runs the idle check is off, so a stuck wait is ended by its own 20-minute
25
+ bound, the executor backstop, or you.
11
26
  - **Governance mode - a multi-agent "legion" collaboration posture.** `Shift+Tab` (and `Ctrl+N`)
12
27
  now cycle `BUILD -> PLAN -> GOVERN -> PLAN+GOVERN -> BUILD`; `/gov` goes to any of them
13
28
  directly. In `GOVERN` the main agent becomes an **Orchestrator** that cannot write files or run
package/README.md CHANGED
@@ -2068,13 +2068,60 @@ longer reads "not ready yet, still coming up" for its first minute.
2068
2068
  Two tools manage them:
2069
2069
 
2070
2070
  - `job_wait({ job, timeoutSeconds?, since? })` — block up to a bound (default 30s,
2071
- at most 60s) and return one status line, one progress line, at most six new
2072
- output rows and a cursor. It returns immediately if the job already finished.
2073
- This is the point of the feature: waiting 30 seconds used to cost six
2074
- `bash_output` calls, six model turns and six 200-row tails; it now costs one
2075
- call and about 150 tokens.
2071
+ **at most 20 minutes**: `timeoutSeconds` takes any integer from 5 to 1200) and
2072
+ return one status line, one progress line, at most six new output rows and a
2073
+ cursor. It returns immediately if the job already finished, and it also ends
2074
+ the moment **you send a message** (see below). `job` is an id, `"all"` (until
2075
+ every live job ends) or `"any"` (until the first one ends). This is the point
2076
+ of the feature: waiting 30 seconds used to cost six `bash_output` calls, six
2077
+ model turns and six 200-row tails; it now costs one call and about 150 tokens,
2078
+ and a 40-minute training run costs a handful of calls instead of dozens.
2076
2079
  - `job_list()` — one line per job: id, kind, status, elapsed, progress.
2077
2080
 
2081
+ ### Long waits
2082
+
2083
+ A wait can now last up to 20 minutes, so three things keep it safe and visible:
2084
+
2085
+ - **You can always interrupt it by typing.** Send a message while the agent is
2086
+ waiting and the wait ends at once; the result says `[woke] you sent a message...`,
2087
+ your message is delivered next, and the job keeps running. You do not need
2088
+ `Esc`, which would stop the whole run. (Automatic notes, such as a reviewer's
2089
+ or a completion notice, do not end a wait. If the agent had queued more tool
2090
+ calls in the same step, the engine skips them when your message arrives, as it
2091
+ does for any message sent mid-step.)
2092
+ - **The wait is drawn as a five-row card** instead of a bare "running":
2093
+
2094
+ ```
2095
+ ⠹ Waiting for t3 python train.py --epochs 10 4m12s of 10m00s
2096
+ │ ████████░░░░░░░░░░░░░░░░░░░░ 30% epoch 3/10 ETA 13m
2097
+ │ job running 42m04s · this wait ends in 5m48s, or sooner if the job finishes
2098
+ │ step 912/1000 loss 0.4098 3s ago
2099
+ │ Send a message to end the wait early · Esc x2 stops the whole run
2100
+ ```
2101
+
2102
+ The bar shows the job's own progress when it prints any, and otherwise the
2103
+ wait window itself (the words say which). After `jobs.stallSeconds` of silence
2104
+ the output row becomes a calm
2105
+ `quiet for 6m02s - the job is still alive; this is not a failure`. The status
2106
+ bar and activity line carry the same clock (`Waiting on t3 4m12s/10m00s`).
2107
+ The card is always five rows, uses ASCII glyphs on terminals without Unicode,
2108
+ and keeps its numbers moving under reduced motion. It disappears when the wait
2109
+ ends and leaves the usual one-line history row.
2110
+ - **The idle watchdog is paused for the length of the wait**, and `toolTimeoutMs`
2111
+ no longer limits it: `job_wait` gets its own executor ceiling (20 minutes plus
2112
+ 30 seconds). The honest cost is that a wait which is itself stuck is only
2113
+ caught by that ceiling and by you (`Esc x2`), not by the 3.5-minute idle check.
2114
+
2115
+ Results suggest the next wait: when the job reports an ETA or has been running for
2116
+ a while, the `[next]` line carries a `timeoutSeconds` worth using (for example
2117
+ `timeoutSeconds: 900 ... (ETA about 13m)`) so the agent prefers one long wait to
2118
+ many short ones. It is advice, never enforced. A request over 1200 seconds is
2119
+ clamped and says so (`[note] timeoutSeconds 3600 exceeds the 1200s ceiling`).
2120
+
2121
+ Cost note: a wait longer than your provider's prompt-cache lifetime (often five
2122
+ minutes) means the next request re-reads the context uncached. That is still far
2123
+ cheaper than the dozens of model turns short polling would have spent.
2124
+
2078
2125
  Nothing else tells the model a job finished. When one ends while it is busy, a
2079
2126
  `<job_notices>` block appears at the end of the next tool result:
2080
2127
 
@@ -2114,7 +2161,7 @@ to use it — `grep` and `read_file` already exist.
2114
2161
  | Key | Default | Meaning |
2115
2162
  |---|---|---|
2116
2163
  | `jobs.enabled` | `true` | Register `job_wait`/`job_list`, splice the prompt block. `false` restores the pre-feature build byte for byte. |
2117
- | `jobs.waitSeconds` | `30` | Default bound for one wait, 5–60. Also lowered automatically so a wait always fits inside the tool ceiling. |
2164
+ | `jobs.waitSeconds` | `30` | Default bound for one wait, 5–1200. No longer lowered by `toolTimeoutMs`. |
2118
2165
  | `jobs.stallSeconds` | `300` | Silence before `[stalled]`, 30–3600. |
2119
2166
  | `jobs.persistLog` | `true` | Mirror each job's output to disk. `false` creates no directory at all. |
2120
2167
  | `jobs.noticeSteer` | `true` | Whether a completion notice may interrupt a running turn. `false` keeps the notice for the next tool result or the next user turn — it is **not** a notification off switch. |
@@ -16,6 +16,10 @@
16
16
  * concatenating, and a differing id starts a new pending action because merging
17
17
  * across ids would paint one command's output onto another's card.
18
18
  *
19
+ * A FOURTH ARRIVED WITH THE LONG WAIT (extended-job-wait RV-6): `waitUpdate`
20
+ * carries the WHOLE view, so it merges exactly like the tool tail - same
21
+ * `toolCallId` keeps the last, a different id or any other action flushes first.
22
+ *
19
23
  * ONE PENDING SLOT, DISCRIMINATED, NEVER TWO (P1-5). A second, independent slot
20
24
  * for the object-shaped payload would let a chunk overtake a token and reorder
21
25
  * the two streams — exactly what I-L4-1 (`ui/render-governor.ts:14-19`) forbids:
@@ -16,6 +16,10 @@
16
16
  * concatenating, and a differing id starts a new pending action because merging
17
17
  * across ids would paint one command's output onto another's card.
18
18
  *
19
+ * A FOURTH ARRIVED WITH THE LONG WAIT (extended-job-wait RV-6): `waitUpdate`
20
+ * carries the WHOLE view, so it merges exactly like the tool tail - same
21
+ * `toolCallId` keeps the last, a different id or any other action flushes first.
22
+ *
19
23
  * ONE PENDING SLOT, DISCRIMINATED, NEVER TWO (P1-5). A second, independent slot
20
24
  * for the object-shaped payload would let a chunk overtake a token and reorder
21
25
  * the two streams — exactly what I-L4-1 (`ui/render-governor.ts:14-19`) forbids:
@@ -28,6 +32,9 @@ function isTextDelta(action) {
28
32
  function isToolOutputDelta(action) {
29
33
  return action.type === 'toolOutputDelta';
30
34
  }
35
+ function isWaitUpdate(action) {
36
+ return action.type === 'waitUpdate';
37
+ }
31
38
  /** Merge consecutive same-kind deltas; flush on kind change or a non-delta. */
32
39
  export function mergeDeltas(actions) {
33
40
  const out = [];
@@ -70,6 +77,12 @@ export function mergeDeltas(actions) {
70
77
  // increment, so merging means replacing (D-30).
71
78
  pending = { kind: 'tool', action };
72
79
  }
80
+ else if (isWaitUpdate(action)) {
81
+ const merges = prev !== null && prev.kind === 'wait' && prev.action.toolCallId === action.toolCallId;
82
+ if (!merges)
83
+ flush();
84
+ pending = { kind: 'wait', action };
85
+ }
73
86
  else {
74
87
  flush();
75
88
  out.push(action);
@@ -28,6 +28,7 @@ import type { SubagentModelResolution } from '../team/model.js';
28
28
  import type { ForcedTeamState } from '../team/forced-mode.js';
29
29
  import type { TeamEvent, TeamSnapshot } from '../team/types.js';
30
30
  import { ProcSupervisor } from '../proc/supervisor.js';
31
+ import type { WaitEvent } from '../proc/wait-view.js';
31
32
  import type { ProcEventListener, ServiceSnapshot } from '../proc/types.js';
32
33
  import { type FastStatus } from '../fast/wiring.js';
33
34
  import type { FastEventListener, FastSnapshot } from '../fast/types.js';
@@ -350,6 +351,10 @@ export declare class AgentController {
350
351
  */
351
352
  private readonly toolOutputs;
352
353
  private readonly outputListeners;
354
+ /** `job_wait` live-view listeners (extended-job-wait); see `subscribeWait`. */
355
+ private readonly waitListeners;
356
+ /** "The user just spoke": ends a running `job_wait` early. */
357
+ private readonly userAttention;
353
358
  private readonly fast;
354
359
  private readonly compaction;
355
360
  private settingsRevision;
@@ -768,6 +773,12 @@ export declare class AgentController {
768
773
  * rather than a counter, so a nested call's `finally` re-arms the watchdog
769
774
  * while the outer wait is still going. That is why a child's `--confirm`
770
775
  * dialog does NOT route through this (see the constructor's `humanQueue`).
776
+ *
777
+ * `job_wait` ALSO DEPENDS ON THAT (extended-job-wait I-3): it holds the pause for
778
+ * up to 20 minutes and is safe only because the engine runs a batch's tools
779
+ * one at a time. If tool execution ever becomes parallel, make this a COUNTER
780
+ * first - otherwise the first tool to finish re-arms the watchdog under a
781
+ * still-waiting sibling, which then kills the run at the idle timeout.
771
782
  */
772
783
  withPausedWatchdog<T>(fn: () => Promise<T>): Promise<T>;
773
784
  /** @see withPausedWatchdog — the name the plan tools and `--confirm` use. */
@@ -1026,6 +1037,16 @@ export declare class AgentController {
1026
1037
  * `warn` because a listener that throws once throws on every chunk.
1027
1038
  */
1028
1039
  private emitToolOutput;
1040
+ /**
1041
+ * Subscribe to `job_wait`'s live view (`begin` / 1 Hz `update` / `end`).
1042
+ *
1043
+ * NOT GATED ON `liveToolOutput` (extended-job-wait D-9): that switch bounds a
1044
+ * high-frequency byte stream; this channel is capped at 1 Hz, and switching it
1045
+ * off would bring back the 20-minute black box.
1046
+ */
1047
+ subscribeWait(listener: (event: WaitEvent) => void): () => void;
1048
+ /** Fan out one wait event; a throwing listener is logged and skipped (I-6). */
1049
+ private emitWait;
1029
1050
  /**
1030
1051
  * Subscribe to the CLI-local live tool-output stream (§3.1.3).
1031
1052
  *
@@ -27,7 +27,8 @@ import { buildForcedTeamBlock } from '../team/forced-prompt.js';
27
27
  import { buildFastBlock } from '../fast/prompt.js';
28
28
  import { ProcSupervisor } from '../proc/supervisor.js';
29
29
  import { buildBackgroundJobsBlock, buildBackgroundServicesBlock } from '../proc/prompt.js';
30
- import { PROC_LIMITS } from '../proc/limits.js';
30
+ import { JOB_WAIT_TOOL_CEILING_MS, PROC_LIMITS } from '../proc/limits.js';
31
+ import { createUserAttention } from './user-attention.js';
31
32
  import { JobLogMirror } from '../proc/log-mirror.js';
32
33
  import { JobNoticeQueue } from '../proc/notices.js';
33
34
  import { makeBashKill, makeBashOutput } from '../tools/proc-tools.js';
@@ -261,6 +262,10 @@ export class AgentController {
261
262
  */
262
263
  toolOutputs;
263
264
  outputListeners = new Set();
265
+ /** `job_wait` live-view listeners (extended-job-wait); see `subscribeWait`. */
266
+ waitListeners = new Set();
267
+ /** "The user just spoke": ends a running `job_wait` early. */
268
+ userAttention = createUserAttention();
264
269
  // -----------------------------------------------------------------------
265
270
  // Fast model tier (fast-model-tier §3.1 / §3.3)
266
271
  //
@@ -735,15 +740,19 @@ export class AgentController {
735
740
  supervisor: this.procs,
736
741
  waitSeconds: () => this.config.jobs.waitSeconds,
737
742
  stallSeconds: () => this.config.jobs.stallSeconds,
738
- toolTimeoutMs: () => this.config.toolTimeoutMs,
739
743
  maxConsecutiveWaits: PROC_LIMITS.maxConsecutiveWaits,
740
744
  terminalRecord: (id) => this.jobRelay?.terminalRecord(id),
745
+ // THE THREE LONG-WAIT PORTS (extended-job-wait). `holdWatchdog` wraps the
746
+ // WHOLE wait because tools run one at a time (I-3); `attention` is
747
+ // subscribe-only (RV-1); the view listener never throws back (I-6).
748
+ onWait: (event) => this.emitWait(event),
749
+ holdWatchdog: (fn) => this.withPausedWatchdog(fn),
750
+ attention: { subscribe: (listener) => this.userAttention.subscribe(listener) },
741
751
  }),
742
752
  makeJobList({
743
753
  supervisor: this.procs,
744
754
  waitSeconds: () => this.config.jobs.waitSeconds,
745
755
  stallSeconds: () => this.config.jobs.stallSeconds,
746
- toolTimeoutMs: () => this.config.toolTimeoutMs,
747
756
  maxConsecutiveWaits: PROC_LIMITS.maxConsecutiveWaits,
748
757
  terminalRecord: (id) => this.jobRelay?.terminalRecord(id),
749
758
  }),
@@ -961,6 +970,13 @@ export class AgentController {
961
970
  // outlived a mid-session `Settings > Team` switch to the supervisor
962
971
  // and killed the dispatch the supervisor was meant to look after.
963
972
  ...(this.teamRegistered ? { task: 0 } : {}),
973
+ // THE EXECUTOR'S OWN CLOCK FOR A LONG WAIT (extended-job-wait I-2 / I-4').
974
+ // Without it `job_wait` falls onto `toolTimeoutMs` (180 s) and a 20-minute
975
+ // wait dies at three; with `0` the wait would have no independent backstop
976
+ // while the watchdog is paused. A finite value derived from the one
977
+ // authority, `PROC_LIMITS.waitMaxSeconds`, is both. `jobsRegistered` is
978
+ // assigned in the constructor before this table is built.
979
+ ...(this.jobsRegistered ? { job_wait: JOB_WAIT_TOOL_CEILING_MS } : {}),
964
980
  // P0 (governance-mode D-21 / RV-0): `gov_dispatch` runs a whole governed stage,
965
981
  // routinely longer than `toolTimeoutMs`, and it listens to `ctx.signal` (it hands
966
982
  // it to `TeamRuntime.dispatch`), which is what makes `0` safe. Registered in the SAME
@@ -1587,6 +1603,10 @@ export class AgentController {
1587
1603
  this.pendingUserSteering.delete(id);
1588
1604
  throw error;
1589
1605
  }
1606
+ // AFTER a successful steer, and ONLY for user input (I-7): a reviewer or a job
1607
+ // notice must not cut short the wait the user chose.
1608
+ if (user)
1609
+ this.userAttention.notify();
1590
1610
  return id;
1591
1611
  }
1592
1612
  followUp(text) {
@@ -1948,6 +1968,12 @@ export class AgentController {
1948
1968
  * rather than a counter, so a nested call's `finally` re-arms the watchdog
1949
1969
  * while the outer wait is still going. That is why a child's `--confirm`
1950
1970
  * dialog does NOT route through this (see the constructor's `humanQueue`).
1971
+ *
1972
+ * `job_wait` ALSO DEPENDS ON THAT (extended-job-wait I-3): it holds the pause for
1973
+ * up to 20 minutes and is safe only because the engine runs a batch's tools
1974
+ * one at a time. If tool execution ever becomes parallel, make this a COUNTER
1975
+ * first - otherwise the first tool to finish re-arms the watchdog under a
1976
+ * still-waiting sibling, which then kills the run at the idle timeout.
1951
1977
  */
1952
1978
  async withPausedWatchdog(fn) {
1953
1979
  this.agent.pauseIdleWatchdog();
@@ -2501,6 +2527,33 @@ export class AgentController {
2501
2527
  }
2502
2528
  }
2503
2529
  }
2530
+ /**
2531
+ * Subscribe to `job_wait`'s live view (`begin` / 1 Hz `update` / `end`).
2532
+ *
2533
+ * NOT GATED ON `liveToolOutput` (extended-job-wait D-9): that switch bounds a
2534
+ * high-frequency byte stream; this channel is capped at 1 Hz, and switching it
2535
+ * off would bring back the 20-minute black box.
2536
+ */
2537
+ subscribeWait(listener) {
2538
+ this.waitListeners.add(listener);
2539
+ return () => {
2540
+ this.waitListeners.delete(listener);
2541
+ };
2542
+ }
2543
+ /** Fan out one wait event; a throwing listener is logged and skipped (I-6). */
2544
+ emitWait(event) {
2545
+ for (const listener of this.waitListeners) {
2546
+ try {
2547
+ listener(event);
2548
+ }
2549
+ catch (err) {
2550
+ getLogger().debug('tool', 'wait_listener_threw', {
2551
+ toolCallId: event.toolCallId,
2552
+ error: err instanceof Error ? err.message : String(err),
2553
+ });
2554
+ }
2555
+ }
2556
+ }
2504
2557
  /**
2505
2558
  * Subscribe to the CLI-local live tool-output stream (§3.1.3).
2506
2559
  *
@@ -19,6 +19,7 @@ import type { FastReview, FastSnapshot } from '../fast/types.js';
19
19
  import type { CompactionMode, CompactionPhase, CompactionRecord, CompactionSnapshot, CompactionUiTrigger, ContextUsageSnapshot } from '../compaction/types.js';
20
20
  import type { AcceptanceSnapshot, TodoItem, TodoSnapshot } from '../todo/types.js';
21
21
  import type { JobKind, ServiceSnapshot, ServiceStatus } from '../proc/types.js';
22
+ import type { WaitView } from '../proc/wait-view.js';
22
23
  import type { RetryPhase } from './retry-view.js';
23
24
  import type { FilePatch } from '../tools/patch.js';
24
25
  import type { TaskSummaryView } from '../fast/task-summary-types.js';
@@ -140,6 +141,16 @@ export type Entry = {
140
141
  liveSeq?: number;
141
142
  /** Epoch ms of the last output row, for the stall row (§3.3.4). */
142
143
  lastOutputAt?: number;
144
+ /**
145
+ * The live view of a running `job_wait` (extended-job-wait §3.8.1), refreshed
146
+ * at 1 Hz. RUNTIME ONLY, and it lives and dies with `live` (I-9): cleared by
147
+ * `toolExecEnd` AND by every idle path through `releaseLiveTails`, and never
148
+ * persisted. `job_wait` never streams, so `live` is always empty for it - which
149
+ * is exactly why the idle clean-up cannot key off `live` alone.
150
+ */
151
+ wait?: WaitView;
152
+ /** Monotonic counter: the revision term that invalidates the render cache. */
153
+ waitSeq?: number;
143
154
  } | {
144
155
  id: string;
145
156
  kind: 'notice';
@@ -580,6 +591,15 @@ export type ViewAction = {
580
591
  toolCallId: string;
581
592
  rows: readonly string[];
582
593
  at: number;
594
+ }
595
+ /**
596
+ * `job_wait`'s 1 Hz view (extended-job-wait). Carries the WHOLE view, so the
597
+ * coalescer keeps only the last per `toolCallId`, like `toolOutputDelta`.
598
+ */
599
+ | {
600
+ type: 'waitUpdate';
601
+ toolCallId: string;
602
+ view: WaitView;
583
603
  } | {
584
604
  type: 'turnEnd';
585
605
  usage: TokenUsage;
@@ -400,12 +400,22 @@ function settleRetryCard(state, entries, phase) {
400
400
  function releaseLiveTails(entries) {
401
401
  let touched = false;
402
402
  const next = entries.map((e) => {
403
- if (e.kind !== 'tool' || !e.live)
403
+ // `!e.live && !e.wait`, NOT `!e.live` (extended-job-wait RV-2): a `job_wait`
404
+ // never streams, so its `live` is always empty and the old guard would skip it,
405
+ // leaving a wait card that says "running" in the transcript forever.
406
+ if (e.kind !== 'tool' || (!e.live && !e.wait))
404
407
  return e;
405
408
  if (e.status !== 'running' && e.status !== 'pending')
406
409
  return e;
407
410
  touched = true;
408
- return { ...e, live: undefined, lastOutputAt: undefined, liveSeq: (e.liveSeq ?? 0) + 1 };
411
+ return {
412
+ ...e,
413
+ live: undefined,
414
+ lastOutputAt: undefined,
415
+ liveSeq: (e.liveSeq ?? 0) + 1,
416
+ wait: undefined,
417
+ waitSeq: (e.waitSeq ?? 0) + 1,
418
+ };
409
419
  });
410
420
  return touched ? next : entries;
411
421
  }
@@ -669,6 +679,23 @@ export function viewReducer(state, action) {
669
679
  : e),
670
680
  };
671
681
  }
682
+ /**
683
+ * ONLY A RUNNING, MATCHING ENTRY. `begin` travels from the controller and can
684
+ * beat `tool_execution_start` through the agent-event pipe, so a miss is
685
+ * normal and is dropped on purpose: the next 1 Hz beat repairs it (RV-5).
686
+ */
687
+ case 'waitUpdate': {
688
+ const id = findToolEntryId(state, action.toolCallId);
689
+ if (!id)
690
+ return state;
691
+ const target = state.entries.find((e) => e.id === id);
692
+ if (!target || target.kind !== 'tool' || target.status !== 'running')
693
+ return state;
694
+ return {
695
+ ...state,
696
+ entries: mapEntry(state.entries, id, (e) => e.kind === 'tool' ? { ...e, wait: action.view, waitSeq: (e.waitSeq ?? 0) + 1 } : e),
697
+ };
698
+ }
672
699
  case 'toolExecEnd': {
673
700
  const id = findToolEntryId(state, action.toolCallId);
674
701
  if (!id)
@@ -689,6 +716,8 @@ export function viewReducer(state, action) {
689
716
  // `bashBadge`'s last-line parse (`ToolPreview.tsx:35-54`).
690
717
  live: undefined,
691
718
  lastOutputAt: undefined,
719
+ wait: undefined,
720
+ waitSeq: undefined,
692
721
  }
693
722
  : e),
694
723
  };
@@ -0,0 +1,17 @@
1
+ /**
2
+ * "The user just spoke" as a subscribable edge (extended-job-wait §3.4).
3
+ *
4
+ * ASCII ONLY: `src/agent/**` is inside the glyph scanner's scope.
5
+ *
6
+ * EDGE-TRIGGERED ON PURPOSE: there is no "is anything pending" probe. A wait that
7
+ * could be woken by pre-existing state would return at once whenever that state
8
+ * went stale, and a model that gets an instant return from every `job_wait` burns
9
+ * tokens in a hot loop. Only a `notify()` that happens after `subscribe()` counts.
10
+ */
11
+ export interface UserAttention {
12
+ /** Announce one new user input to every current subscriber. */
13
+ notify(): void;
14
+ /** Register a listener; returns its unsubscribe. */
15
+ subscribe(listener: () => void): () => void;
16
+ }
17
+ export declare function createUserAttention(): UserAttention;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * "The user just spoke" as a subscribable edge (extended-job-wait §3.4).
3
+ *
4
+ * ASCII ONLY: `src/agent/**` is inside the glyph scanner's scope.
5
+ *
6
+ * EDGE-TRIGGERED ON PURPOSE: there is no "is anything pending" probe. A wait that
7
+ * could be woken by pre-existing state would return at once whenever that state
8
+ * went stale, and a model that gets an instant return from every `job_wait` burns
9
+ * tokens in a hot loop. Only a `notify()` that happens after `subscribe()` counts.
10
+ */
11
+ export function createUserAttention() {
12
+ const listeners = new Set();
13
+ return {
14
+ notify() {
15
+ // Snapshot first: a listener may unsubscribe itself while we iterate.
16
+ for (const listener of [...listeners]) {
17
+ try {
18
+ listener();
19
+ }
20
+ catch {
21
+ // One broken listener must not deprive the others of the wake-up.
22
+ }
23
+ }
24
+ },
25
+ subscribe(listener) {
26
+ listeners.add(listener);
27
+ return () => {
28
+ listeners.delete(listener);
29
+ };
30
+ },
31
+ };
32
+ }
package/dist/cli.js CHANGED
@@ -1765,9 +1765,8 @@ function runConfigSetValue(key, value) {
1765
1765
  const jobsPatch = applyJobsConfigSet(key, value);
1766
1766
  if (jobsPatch) {
1767
1767
  // Echoed from the STORED value, so `config set jobs.waitSeconds 1` prints 5
1768
- // and `jobs.waitSeconds 9999` prints 60 — the rule every section above
1769
- // states. It matters here too: `waitSeconds` is ALSO converged against the
1770
- // tool ceiling by I-4, so the stored value is the only honest answer.
1768
+ // and `jobs.waitSeconds 9999` prints 1200 — the rule every section above
1769
+ // states.
1771
1770
  const merged = updatePersistedConfig(jobsPatch);
1772
1771
  const field = key.slice('jobs.'.length);
1773
1772
  process.stdout.write(`Set ${key} = ${String(merged.jobs[field])}\n`);
@@ -6,7 +6,6 @@
6
6
  * legitimately long single tool run can never be watchdog-killed.
7
7
  */
8
8
  import process from 'node:process';
9
- import { JOB_WAIT_MARGIN_MS } from '../proc/limits.js';
10
9
  import { DEFAULT_CONFIG, DEFAULT_SCROLL_MODE, IDLE_TIMEOUT_MARGIN_MS, clampDensity, clampHumanTimeout, clampCompactionConfig, clampLogConfig, clampFastConfig, clampRetryConfig, clampSkillsConfig, clampTeamConfig, clampTodoConfig, clampBashConfig, clampJobsConfig, clampPromptsConfig, clampTaskSummaryConfig, clampUpdateConfig, clampTheme, clampThinkingLevel, clampContextWindow, clampMaxTokens, clampMaxRenderInterval, clampScrollMode, clampScrollResumeMs, clampTranscriptRetain, clampTranscriptWindow, coercePositiveInt, isAutoToken, isSkillsToolPolicyMode, legacyTeamCeilingPatch, legacyTeamFanoutPatch, LEGACY_TEAM_FANOUT_DEFAULTS, parseThresholdInput, } from './schema.js';
11
10
  import { loadDotenv, readEnvConfig, readEnvDisabledSkills } from './env.js';
12
11
  import { readConfigFile, updatePersistedConfig } from './store.js';
@@ -622,32 +621,14 @@ export function loadConfig(flags = {}, options = {}) {
622
621
  DEFAULT_CONFIG.planModeDefault;
623
622
  const planModeHumanTimeoutMs = clampHumanTimeout(file.planModeHumanTimeoutMs, DEFAULT_CONFIG.planModeHumanTimeoutMs);
624
623
  const toolTimeoutMs = coercePositiveInt(pick(flags.toolTimeout, file.toolTimeoutMs), DEFAULT_CONFIG.toolTimeoutMs);
625
- // THE `jobs` SECTION IS RESOLVED HERE, ABOVE THE TIMEOUT BLOCK, and the
626
- // position is the whole of RV-P1-2. The design's first draft put the I-4 clamp
627
- // next to the `idleTimeoutMs` invariant below, but `jobs` is parsed inside the
628
- // big config literal a hundred lines further down — a reference to it here would
629
- // be a use-before-declaration, and "resolve it later and patch the object" is
630
- // how the invariant gets silently skipped. Resolving it first makes the clamp
631
- // impossible to omit.
624
+ // `jobs.waitSeconds` is NOT converged against `toolTimeoutMs` (extended-job-wait
625
+ // I-4'): `job_wait` has its own executor ceiling and pauses the idle watchdog,
626
+ // so the section is resolved once, as-is.
632
627
  const jobs = resolveJobsConfig(env.partial, file);
633
628
  let idleTimeoutMs = coercePositiveInt(pick(flags.idleTimeout, file.idleTimeoutMs), DEFAULT_CONFIG.idleTimeoutMs);
634
629
  // Timeout invariant (R1): idle must be strictly greater than the tool ceiling
635
630
  // so a long single tool run is never aborted by the idle watchdog.
636
631
  idleTimeoutMs = Math.max(idleTimeoutMs, toolTimeoutMs + IDLE_TIMEOUT_MARGIN_MS);
637
- // INVARIANT I-4 (long-task-jobs): a wait must fit inside the tool call that
638
- // hosts it, `jobs.waitSeconds * 1000 + JOB_WAIT_MARGIN_MS <= toolTimeoutMs`.
639
- //
640
- // `Math.min`, AND THE DIRECTION IS THE POINT. The invariant above RAISES the
641
- // idle window to accommodate the tool; this one LOWERS the wait to accommodate
642
- // the tool. Copying `Math.max` here would produce a wait that outlives its own
643
- // tool call — which is exactly the `Command timed out` failure the feature
644
- // exists to remove, re-created by its own config check.
645
- //
646
- // With an operator-set ceiling below the margin the floor of 1s applies rather
647
- // than a negative wait: the invariant wins over the minimum, because a wait
648
- // longer than its host is worse than a short wait.
649
- const waitCeilingSeconds = Math.floor((toolTimeoutMs - JOB_WAIT_MARGIN_MS) / 1000);
650
- jobs.waitSeconds = Math.max(1, Math.min(jobs.waitSeconds, waitCeilingSeconds));
651
632
  // Resolve API keys: config-file key wins over the provider env var.
652
633
  const fileKeys = file.apiKeys ?? {};
653
634
  const apiKeys = {};
@@ -798,9 +779,7 @@ export function loadConfig(flags = {}, options = {}) {
798
779
  update: resolveUpdateConfig(flags, env.partial, file),
799
780
  compaction: resolveCompactionConfig(flags, env.partial, file),
800
781
  bash: resolveBashConfig(env.partial, file),
801
- // The already-resolved-and-converged section from the timeout block above.
802
- // NOT `resolveJobsConfig(...)` a second time: that would re-apply the
803
- // un-converged `waitSeconds` and silently undo I-4.
782
+ // The already-resolved section from the top of this function.
804
783
  jobs,
805
784
  // The quick-prompt library's three policy scalars (quick-prompt-library D-19).
806
785
  // Resolved through the SAME clamp the read and write paths use, so an env-only
@@ -878,11 +878,11 @@ export interface JobsConfig {
878
878
  */
879
879
  enabled: boolean;
880
880
  /**
881
- * Seconds one `job_wait` blocks by default. Default 30, range 5..60.
881
+ * Seconds one `job_wait` blocks by default. Default 30, range 5..1200.
882
882
  *
883
- * ALSO CONVERGED AGAINST THE TOOL CEILING at load time and again at call time
884
- * (I-4): a wait that outlives the tool call hosting it would turn every long
885
- * job into `Command timed out`, which is the defect this feature removes.
883
+ * NOT CONVERGED AGAINST `toolTimeoutMs` ANY MORE (extended-job-wait I-4'): the
884
+ * executor gives `job_wait` its own ceiling and the idle watchdog is paused for
885
+ * the whole wait, so a long default no longer risks `Command timed out`.
886
886
  */
887
887
  waitSeconds: number;
888
888
  /**
@@ -8,6 +8,7 @@
8
8
  import { isAbsolute } from 'node:path';
9
9
  import { ABSOLUTE_MAX_OUTPUT_TOKENS, DEFAULT_MAX_OUTPUT_TOKENS, DEFAULT_RETRY_POLICY, MIN_MAX_OUTPUT_TOKENS, RETRY_LIMITS, } from '@aragon-agent/core';
10
10
  import { clampLogLevel } from '../logging/levels.js';
11
+ import { PROC_LIMITS } from '../proc/limits.js';
11
12
  import { TEAM_LIMITS } from '../team/limits.js';
12
13
  import { DEFAULT_GOVERNANCE_CONFIG } from './governance-config.js';
13
14
  // Governance mode (governance-mode 11.1): defined in its own file so this one does not grow.
@@ -1013,7 +1014,12 @@ export const DEFAULT_JOBS_CONFIG = {
1013
1014
  persistLog: true,
1014
1015
  noticeSteer: true,
1015
1016
  };
1016
- const WAIT_SECONDS_RANGE = { min: 5, max: 60 };
1017
+ // DERIVED, NOT RESTATED (extended-job-wait I-2): a second copy of the ceiling is
1018
+ // how the config layer and the tool layer end up disagreeing without an error.
1019
+ const WAIT_SECONDS_RANGE = {
1020
+ min: PROC_LIMITS.waitMinSeconds,
1021
+ max: PROC_LIMITS.waitMaxSeconds,
1022
+ };
1017
1023
  const STALL_SECONDS_RANGE = { min: 30, max: 3600 };
1018
1024
  /**
1019
1025
  * THE single gate for every `jobs` read AND write, mirroring `clampBashConfig`
@@ -132,9 +132,26 @@ export declare const PROC_LIMITS: {
132
132
  * instead of a seventh `bash_output` poll.
133
133
  */
134
134
  readonly waitTailRows: 6;
135
- /** Seconds a single wait may block, as a STRUCTURAL floor and ceiling (`jobs.waitSeconds` is clamped into this range). */
135
+ /**
136
+ * Seconds a single wait may block, as a STRUCTURAL floor and ceiling (`jobs.waitSeconds` is clamped into this range).
137
+ *
138
+ * THE ONE AUTHORITY FOR THE CEILING (extended-job-wait I-2): the executor
139
+ * override (`JOB_WAIT_TOOL_CEILING_MS`), `WAIT_SECONDS_RANGE.max`, the tool
140
+ * description and the prompt block all derive from, or are asserted equal to,
141
+ * `waitMaxSeconds`. Changing it here and missing another reader is how a wait
142
+ * is silently cut short by a layer nobody remembered.
143
+ */
136
144
  readonly waitMinSeconds: 5;
137
- readonly waitMaxSeconds: 60;
145
+ readonly waitMaxSeconds: 1200;
146
+ /**
147
+ * Cadence of the in-wait heartbeat, in ms.
148
+ *
149
+ * ONE HZ because the reader needs "a few seconds ago" precision, not a stream:
150
+ * subscribing to the supervisor's 100 ms output coalescing would mean ~12000
151
+ * pointless view updates across a 20-minute wait. The same beat also carries the
152
+ * wall-clock deadline re-check (see `proc/wait.ts`).
153
+ */
154
+ readonly waitHeartbeatMs: 1000;
138
155
  /**
139
156
  * Characters one extracted progress line may occupy.
140
157
  *
@@ -183,18 +200,15 @@ export declare const BACKGROUND_SERVICES_BLOCK_VERSION = "v1";
183
200
  * (`bash.background` and `jobs.enabled`), so one version number covering both
184
201
  * would make "which block changed" unanswerable from a report.
185
202
  */
186
- export declare const JOBS_BLOCK_VERSION = "v1-2026-10";
203
+ export declare const JOBS_BLOCK_VERSION = "v2-2026-10";
187
204
  /**
188
- * Head-room a single wait leaves below the tool ceiling (I-4).
189
- *
190
- * `jobs.waitSeconds * 1000 + JOB_WAIT_MARGIN_MS <= toolTimeoutMs` is the whole
191
- * reason a wait never needs to pause the idle watchdog (D-3): a 60 s wait inside
192
- * a 180 s tool call in a 210 s idle window cannot be mistaken for a hang.
205
+ * Head-room the executor's own `job_wait` timer leaves above the longest wait.
193
206
  *
194
- * IT LIVES HERE RATHER THAN IN `config/schema.ts`, where the design document put
195
- * it, because the TOOL layer needs the same number for its call-time convergence
196
- * (AC-14) and nothing in `tools/` may import `config/` - a tool takes its policy
197
- * through closures. One constant, two readers, no second copy to drift. Recorded
198
- * in `docs/plans/long-task-jobs/spec.md` under `## 实施过程发现的方案缺陷` (I-2).
207
+ * It is a SECOND, INDEPENDENT clock, not a coupling to `toolTimeoutMs`
208
+ * (extended-job-wait I-4'): the idle watchdog is paused for the whole wait, so
209
+ * when `waitForJob` itself misbehaves this is the only thing left that can end
210
+ * the call. `toolTimeoutMs` no longer constrains a wait at all.
199
211
  */
200
212
  export declare const JOB_WAIT_MARGIN_MS = 30000;
213
+ /** The executor ceiling for `job_wait`: the longest wait plus its margin (1230 s). */
214
+ export declare const JOB_WAIT_TOOL_CEILING_MS: number;