@aragon-agent/cli 0.6.45 → 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 (71) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +104 -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/invocation-card.d.ts +77 -0
  8. package/dist/agent/invocation-card.js +169 -0
  9. package/dist/agent/prompt-options.d.ts +6 -0
  10. package/dist/agent/queued-messages.d.ts +3 -0
  11. package/dist/agent/queued-messages.js +4 -1
  12. package/dist/agent/reducer.d.ts +25 -0
  13. package/dist/agent/reducer.js +43 -5
  14. package/dist/agent/user-attention.d.ts +17 -0
  15. package/dist/agent/user-attention.js +32 -0
  16. package/dist/cli.js +2 -3
  17. package/dist/commands/builtins.js +12 -0
  18. package/dist/commands/governance-command.js +13 -1
  19. package/dist/commands/index-build.d.ts +8 -0
  20. package/dist/commands/index-build.js +11 -0
  21. package/dist/commands/registry.d.ts +23 -0
  22. package/dist/commands/registry.js +20 -7
  23. package/dist/commands/skills.js +36 -2
  24. package/dist/commands/subagent-command.js +3 -1
  25. package/dist/commands/team-command.d.ts +6 -0
  26. package/dist/commands/team-command.js +40 -3
  27. package/dist/config/load.js +4 -25
  28. package/dist/config/schema.d.ts +4 -4
  29. package/dist/config/schema.js +7 -1
  30. package/dist/proc/limits.d.ts +27 -13
  31. package/dist/proc/limits.js +27 -13
  32. package/dist/proc/progress.d.ts +7 -0
  33. package/dist/proc/progress.js +35 -7
  34. package/dist/proc/prompt.js +7 -4
  35. package/dist/proc/supervisor.js +7 -1
  36. package/dist/proc/types.d.ts +6 -0
  37. package/dist/proc/wait-plan.d.ts +39 -0
  38. package/dist/proc/wait-plan.js +77 -0
  39. package/dist/proc/wait-view.d.ts +65 -0
  40. package/dist/proc/wait-view.js +66 -0
  41. package/dist/proc/wait.d.ts +36 -3
  42. package/dist/proc/wait.js +67 -4
  43. package/dist/session/checkpoint.js +2 -0
  44. package/dist/session/persist.js +29 -2
  45. package/dist/session/validate-session.js +7 -2
  46. package/dist/team/forced-mode.d.ts +19 -0
  47. package/dist/team/forced-mode.js +42 -20
  48. package/dist/tools/job-tools.d.ts +13 -2
  49. package/dist/tools/job-tools.js +156 -33
  50. package/dist/ui/ActivityLine.js +5 -2
  51. package/dist/ui/App.js +59 -6
  52. package/dist/ui/StatusBar.d.ts +1 -0
  53. package/dist/ui/Transcript.js +11 -1
  54. package/dist/ui/entries/InvocationCard.d.ts +20 -0
  55. package/dist/ui/entries/InvocationCard.js +32 -0
  56. package/dist/ui/entries/ToolCard.js +11 -2
  57. package/dist/ui/entries/WaitCard.d.ts +37 -0
  58. package/dist/ui/entries/WaitCard.js +92 -0
  59. package/dist/ui/interaction-copy.d.ts +2 -0
  60. package/dist/ui/interaction-copy.js +3 -0
  61. package/dist/ui/layout/invocation-card.d.ts +41 -0
  62. package/dist/ui/layout/invocation-card.js +91 -0
  63. package/dist/ui/layout/status-layout.d.ts +1 -0
  64. package/dist/ui/layout/status-layout.js +10 -1
  65. package/dist/ui/layout/virtual-window.js +18 -2
  66. package/dist/ui/layout/wait-bar.d.ts +34 -0
  67. package/dist/ui/layout/wait-bar.js +48 -0
  68. package/dist/ui/run-status-row.d.ts +4 -2
  69. package/dist/ui/scrollback/live-preview.js +12 -0
  70. package/dist/ui/transcript-text.js +4 -1
  71. 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. |
@@ -3013,6 +3060,57 @@ declares no placeholder, the arguments are appended under an `## Arguments`
3013
3060
  heading instead. Where a skill's name collides with a built-in command, the
3014
3061
  **built-in wins** and the skill is reachable as `/skill:<name>`.
3015
3062
 
3063
+ ### Invocation cards
3064
+
3065
+ A command that starts a task by submitting a long prompt on your behalf does not
3066
+ print that prompt. `/pdf-forms`, `/skill:<name>`, `/team <brief>`, `/todo continue`
3067
+ and `/gov resume` each leave one **card** in the transcript instead: what you
3068
+ started, a few facts, and your own words.
3069
+
3070
+ ```
3071
+ > skill . pdf-forms v1.2.0 . user . 31 KB . 4 files
3072
+ Fill the W-9 form in ./tax/w9.pdf with the values from data.json
3073
+
3074
+ > team 4 subagents . read-only
3075
+ Audit the auth module for session fixation and add regression tests
3076
+ Asked for 12; team.maxSubagents is 8.
3077
+
3078
+ > todo . continue 3 of 7 steps left
3079
+ ```
3080
+
3081
+ (`.` is `·` on a Unicode terminal.) The first row names the command and its
3082
+ facts and is shortened to the width of the window - facts go first, then the name.
3083
+ Your own words follow, capped at 8 lines / 480 characters with an honest
3084
+ `... +N chars not shown` tail. A line below it appears only when the command
3085
+ changed what you asked for (a fan-out above `team.maxSubagents`). `Ctrl+I` shows a
3086
+ `ctrl+i` fact and no input row: the sentence it sends the model is not something
3087
+ you said.
3088
+
3089
+ What changes and what does not:
3090
+
3091
+ - **The model receives exactly the bytes it did before.** Only the transcript
3092
+ entry is different; the skill body, the team directive and the continuation text
3093
+ are untouched.
3094
+ - The `Command:` echo and the old `Skill "x" loaded (...)` / `Forcing a fan-out...`
3095
+ notices are folded into the card, so one act is stated once.
3096
+ - Up-arrow history and the header task label hold the command you typed
3097
+ (`/skill:pdf-forms a.pdf`), not the prompt behind it. Canned turns the commands
3098
+ submit for you (`/team`, `/todo continue`, `/gov resume`) still stay out of
3099
+ history and do not trigger a task label, exactly as before.
3100
+ - A skill run now adds one history entry instead of two to the hint-fade counter.
3101
+ - Secrets in your arguments are redacted **before** anything is cut, so a key that
3102
+ straddles a cut cannot leave a readable prefix in the transcript, the session
3103
+ file or the exit replay.
3104
+ - The exit replay prints the command line plus one `[skill pdf-forms v1.2.0 - ...]`
3105
+ line. A very long brief is capped at 2000 characters there; the full text is in
3106
+ the up-arrow history and in the conversation itself.
3107
+ - Sessions written before cards existed load unchanged; a damaged card in a session
3108
+ file degrades to the plain command line instead of failing the load.
3109
+
3110
+ `ARAGON_NO_INVOCATION_CARDS=1` restores the previous behaviour completely (echo,
3111
+ notices and the full prompt in the transcript). It is an emergency switch, not a
3112
+ setting.
3113
+
3016
3114
  ### Where skills come from
3017
3115
 
3018
3116
  Four scopes, later ones overriding earlier ones by name:
@@ -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
  *
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Invocation cards (tui-invocation-cards): what a transcript shows INSTEAD of the
3
+ * system prompt a command submitted on the user's behalf.
4
+ *
5
+ * Pure and total: no IO, no React, no Ink, nothing but `import type`. ASCII ONLY -
6
+ * `src/agent/**` new files sit inside the `glyphs.test.ts` scan scope.
7
+ *
8
+ * ONE CONSTRUCTOR (I-3). `buildInvocationCard` is the only function that turns raw
9
+ * command-layer material into a card, `App.submitMessage` is its only caller, and
10
+ * `sanitizeInvocationCard` (the session-load path) shares its normaliser so a card
11
+ * written today and a card read back tomorrow cannot drift apart.
12
+ *
13
+ * ORDER IS REDACT -> NORMALISE -> LIMIT, and it is the load-bearing part (I-6). Cut
14
+ * first and a secret that straddles the cut leaves a prefix shorter than the
15
+ * redaction patterns' minimum length: the half-key sails through into the
16
+ * transcript, the session file and the exit replay.
17
+ */
18
+ export type InvocationVariant = 'skill' | 'team' | 'todo' | 'govern';
19
+ /** Everything one card needs; persisted, queued and rendered in this one shape. */
20
+ export interface InvocationCard {
21
+ readonly variant: InvocationVariant;
22
+ /** Skill name / 'continue' / 'resume'; '' for team. */
23
+ readonly name: string;
24
+ /** What the user typed (or its equivalent), redacted and capped; also `Entry.text`. */
25
+ readonly commandLine: string;
26
+ /** The user's own words (args / brief), redacted and trimmed; may be ''. */
27
+ readonly input: string;
28
+ /** Characters cut from `input`; 0 when nothing was cut. */
29
+ readonly inputHidden: number;
30
+ /** Fact fragments, each at most `factMaxChars`, at most `factsMax` of them. */
31
+ readonly facts: readonly string[];
32
+ /** One disclosure sentence. */
33
+ readonly note?: string;
34
+ }
35
+ /**
36
+ * The renderer, the height estimate and the loader all read THESE numbers (I-4).
37
+ * A card's height is a function of its data and these limits, so it is bounded:
38
+ * header + `inputMaxLines` + hidden tail + note.
39
+ */
40
+ export declare const INVOCATION_LIMITS: {
41
+ readonly commandLineMaxChars: 2000;
42
+ readonly inputMaxChars: 480;
43
+ readonly inputMaxLines: 8;
44
+ readonly nameMaxChars: 64;
45
+ readonly factsMax: 5;
46
+ readonly factMaxChars: 28;
47
+ readonly noteMaxChars: 140;
48
+ };
49
+ /**
50
+ * Raw material from the command layer: NOT redacted, NOT trimmed. Commands must not
51
+ * cut or scrub it themselves - both happen exactly once, in `buildInvocationCard`.
52
+ */
53
+ export interface InvocationCardInput {
54
+ variant: InvocationVariant;
55
+ name?: string;
56
+ commandLine: string;
57
+ input?: string;
58
+ facts?: readonly string[];
59
+ note?: string;
60
+ }
61
+ /**
62
+ * Total, never throws. `redact` is injected (the same closure `Command:` echo uses) so
63
+ * this module depends on nothing; the default is the identity for tests and the loader.
64
+ */
65
+ export declare function buildInvocationCard(input: InvocationCardInput, redact?: (text: string) => string): InvocationCard;
66
+ /**
67
+ * Session-load path: `null` for anything that is not a plausible card, so the caller
68
+ * drops the field and the entry degrades to a plain user message (I-7). Re-derives
69
+ * `inputHidden` from nothing - it trusts the stored count only when it is a sane integer.
70
+ */
71
+ export declare function sanitizeInvocationCard(value: unknown): InvocationCard | null;
72
+ /**
73
+ * Emergency kill switch (`ARAGON_NO_INVOCATION_CARDS=1`): every command and the App fall
74
+ * back to the pre-card behaviour. Read at call time, not import time, so a test (or a
75
+ * wrapper script) that sets the variable after load is honoured.
76
+ */
77
+ export declare function invocationCardsEnabled(env?: NodeJS.ProcessEnv): boolean;
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Invocation cards (tui-invocation-cards): what a transcript shows INSTEAD of the
3
+ * system prompt a command submitted on the user's behalf.
4
+ *
5
+ * Pure and total: no IO, no React, no Ink, nothing but `import type`. ASCII ONLY -
6
+ * `src/agent/**` new files sit inside the `glyphs.test.ts` scan scope.
7
+ *
8
+ * ONE CONSTRUCTOR (I-3). `buildInvocationCard` is the only function that turns raw
9
+ * command-layer material into a card, `App.submitMessage` is its only caller, and
10
+ * `sanitizeInvocationCard` (the session-load path) shares its normaliser so a card
11
+ * written today and a card read back tomorrow cannot drift apart.
12
+ *
13
+ * ORDER IS REDACT -> NORMALISE -> LIMIT, and it is the load-bearing part (I-6). Cut
14
+ * first and a secret that straddles the cut leaves a prefix shorter than the
15
+ * redaction patterns' minimum length: the half-key sails through into the
16
+ * transcript, the session file and the exit replay.
17
+ */
18
+ /**
19
+ * The renderer, the height estimate and the loader all read THESE numbers (I-4).
20
+ * A card's height is a function of its data and these limits, so it is bounded:
21
+ * header + `inputMaxLines` + hidden tail + note.
22
+ */
23
+ export const INVOCATION_LIMITS = {
24
+ commandLineMaxChars: 2000,
25
+ inputMaxChars: 480,
26
+ inputMaxLines: 8,
27
+ nameMaxChars: 64,
28
+ factsMax: 5,
29
+ factMaxChars: 28,
30
+ noteMaxChars: 140,
31
+ };
32
+ const VARIANTS = ['skill', 'team', 'todo', 'govern'];
33
+ // C0 except TAB (\x09) and LF (\x0a), then DEL and C1 (which includes the 8-bit CSI
34
+ // \x9b). Matches the range `QueueOverlay` strips, so the card is no more permissive.
35
+ // eslint-disable-next-line no-control-regex
36
+ const CONTROL_CHARS = /[\x00-\x08\x0b-\x1f\x7f-\x9f]/g;
37
+ const CAP_MARK = '...';
38
+ /** Cut at a code-point boundary: a surrogate pair is never split. */
39
+ function sliceCodePoints(text, count) {
40
+ if (count <= 0)
41
+ return '';
42
+ if (text.length <= count)
43
+ return text;
44
+ let end = count;
45
+ const last = text.charCodeAt(end - 1);
46
+ // A high surrogate at the cut would orphan its low half.
47
+ if (last >= 0xd800 && last <= 0xdbff)
48
+ end -= 1;
49
+ return text.slice(0, end);
50
+ }
51
+ function countCodePoints(text) {
52
+ let count = 0;
53
+ for (const _ of text)
54
+ count += 1;
55
+ return count;
56
+ }
57
+ /** CRLF and bare CR to LF (a bare CR returns Ink's cursor to column 0), then strip controls. */
58
+ function normalizeText(text) {
59
+ return text.replace(/\r\n?/g, '\n').replace(CONTROL_CHARS, '');
60
+ }
61
+ function capCommandLine(text) {
62
+ const max = INVOCATION_LIMITS.commandLineMaxChars;
63
+ if (text.length <= max)
64
+ return text;
65
+ return sliceCodePoints(text, max - CAP_MARK.length) + CAP_MARK;
66
+ }
67
+ function capInput(text) {
68
+ const trimmed = text.trim();
69
+ if (trimmed === '')
70
+ return { input: '', hidden: 0 };
71
+ const lines = trimmed.split('\n');
72
+ let kept = lines.length > INVOCATION_LIMITS.inputMaxLines
73
+ ? lines.slice(0, INVOCATION_LIMITS.inputMaxLines).join('\n') : trimmed;
74
+ kept = sliceCodePoints(kept, INVOCATION_LIMITS.inputMaxChars).trimEnd();
75
+ return { input: kept, hidden: countCodePoints(trimmed) - countCodePoints(kept) };
76
+ }
77
+ function capSingleLine(text, max) {
78
+ const flat = text.replace(/\s+/g, ' ').trim();
79
+ if (countCodePoints(flat) <= max)
80
+ return flat;
81
+ return sliceCodePoints(flat, max - CAP_MARK.length) + CAP_MARK;
82
+ }
83
+ function capFacts(facts) {
84
+ return facts
85
+ .map((fact) => capSingleLine(fact, INVOCATION_LIMITS.factMaxChars))
86
+ .filter((fact) => fact !== '')
87
+ .slice(0, INVOCATION_LIMITS.factsMax);
88
+ }
89
+ function capNote(note) {
90
+ if (note === undefined)
91
+ return undefined;
92
+ const capped = capSingleLine(note, INVOCATION_LIMITS.noteMaxChars);
93
+ return capped === '' ? undefined : capped;
94
+ }
95
+ /** Shared by the write path and the load path so the two cannot diverge. */
96
+ function assemble(parts) {
97
+ const { input, hidden } = capInput(normalizeText(parts.input));
98
+ const note = capNote(normalizeText(parts.note ?? ''));
99
+ return {
100
+ variant: parts.variant,
101
+ name: capSingleLine(normalizeText(parts.name), INVOCATION_LIMITS.nameMaxChars),
102
+ commandLine: capCommandLine(normalizeText(parts.commandLine).trim()),
103
+ input,
104
+ inputHidden: hidden,
105
+ facts: capFacts(parts.facts.map(normalizeText)),
106
+ ...(note !== undefined ? { note } : {}),
107
+ };
108
+ }
109
+ /**
110
+ * Total, never throws. `redact` is injected (the same closure `Command:` echo uses) so
111
+ * this module depends on nothing; the default is the identity for tests and the loader.
112
+ */
113
+ export function buildInvocationCard(input, redact = (text) => text) {
114
+ const scrub = (text) => {
115
+ if (text === undefined)
116
+ return '';
117
+ try {
118
+ return redact(text);
119
+ }
120
+ catch {
121
+ return '';
122
+ }
123
+ };
124
+ return assemble({
125
+ variant: input.variant,
126
+ name: scrub(input.name),
127
+ commandLine: scrub(input.commandLine),
128
+ input: scrub(input.input),
129
+ facts: (input.facts ?? []).map((fact) => scrub(fact)),
130
+ note: input.note === undefined ? undefined : scrub(input.note),
131
+ });
132
+ }
133
+ function asString(value) {
134
+ return typeof value === 'string' ? value : null;
135
+ }
136
+ /**
137
+ * Session-load path: `null` for anything that is not a plausible card, so the caller
138
+ * drops the field and the entry degrades to a plain user message (I-7). Re-derives
139
+ * `inputHidden` from nothing - it trusts the stored count only when it is a sane integer.
140
+ */
141
+ export function sanitizeInvocationCard(value) {
142
+ if (typeof value !== 'object' || value === null)
143
+ return null;
144
+ const raw = value;
145
+ const variant = VARIANTS.find((candidate) => candidate === raw.variant);
146
+ const commandLine = asString(raw.commandLine);
147
+ if (variant === undefined || commandLine === null)
148
+ return null;
149
+ const name = asString(raw.name) ?? '';
150
+ const input = asString(raw.input) ?? '';
151
+ const facts = Array.isArray(raw.facts)
152
+ ? raw.facts.filter((fact) => typeof fact === 'string') : [];
153
+ const note = asString(raw.note) ?? undefined;
154
+ const card = assemble({ variant, name, commandLine, input, facts, note });
155
+ // A stored card that was already capped must keep its recorded `inputHidden`; the
156
+ // re-cap above can only ever add to it.
157
+ const stored = raw.inputHidden;
158
+ const storedHidden = typeof stored === 'number' && Number.isInteger(stored) && stored > 0
159
+ ? Math.min(stored, 10_000_000) : 0;
160
+ return { ...card, inputHidden: Math.max(card.inputHidden, storedHidden) };
161
+ }
162
+ /**
163
+ * Emergency kill switch (`ARAGON_NO_INVOCATION_CARDS=1`): every command and the App fall
164
+ * back to the pre-card behaviour. Read at call time, not import time, so a test (or a
165
+ * wrapper script) that sets the variable after load is honoured.
166
+ */
167
+ export function invocationCardsEnabled(env = process.env) {
168
+ return env.ARAGON_NO_INVOCATION_CARDS !== '1';
169
+ }
@@ -1,3 +1,4 @@
1
+ import type { InvocationCardInput } from './invocation-card.js';
1
2
  /** Explicit TUI task boundary; omission retains legacy programmatic behavior. */
2
3
  export interface PromptOptions {
3
4
  todoPolicy?: 'new-task' | 'continue';
@@ -5,6 +6,11 @@ export interface PromptOptions {
5
6
  /** History recording is independent of whether this message continues a plan. */
6
7
  export interface SubmitMessageOptions extends PromptOptions {
7
8
  userInitiated?: boolean;
9
+ /**
10
+ * Raw material for the transcript card. `submitMessage` builds the card exactly once
11
+ * (redact -> normalise -> limit); the payload sent to the model is unaffected.
12
+ */
13
+ invocation?: InvocationCardInput;
8
14
  }
9
15
  /** Startup outcome only; engine events still describe the result of the run. */
10
16
  export type PromptOutcome = {
@@ -1,8 +1,11 @@
1
1
  import type { Entry } from './reducer.js';
2
+ import type { InvocationCard } from './invocation-card.js';
2
3
  /** Full user text retained until Core acknowledges its opaque ID. */
3
4
  export interface PendingSteering {
4
5
  readonly queueId: string;
5
6
  readonly text: string;
7
+ /** Carried so the `queued -> user` rewrite keeps the card (tui-invocation-cards). */
8
+ readonly invocation?: InvocationCard;
6
9
  }
7
10
  /** Hide only active pending entries, using the same projection for measurement and paint. */
8
11
  export declare function selectTranscriptEntries(entries: Entry[], pending: readonly PendingSteering[]): Entry[];
@@ -13,7 +13,10 @@ export function reconcileSteeringReceipt(options) {
13
13
  if (!ids.size)
14
14
  return { entries: options.entries, pending: [...options.pending], changed: false };
15
15
  const entries = mergePendingEntries(options.entries, options.pending).map((entry) => entry.kind === 'queued' && ids.has(entry.queueId ?? '')
16
- ? { id: entry.id, kind: 'user', text: entry.text } : entry);
16
+ ? {
17
+ id: entry.id, kind: 'user', text: entry.text,
18
+ ...(entry.invocation ? { invocation: entry.invocation } : {}),
19
+ } : entry);
17
20
  return { entries, pending: options.pending.filter((item) => !ids.has(item.queueId)), changed: true };
18
21
  }
19
22
  /** Supplement trimmed/cleared history for save and exit without deduplicating text. */