@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/dist/proc/wait.js CHANGED
@@ -23,6 +23,20 @@
23
23
  * nothing at all (the supervisor now emits `stopped` from it), and
24
24
  * `dispose()` clears its listener set without emitting; the self-timeout is
25
25
  * therefore a real second path, not belt-and-braces.
26
+ * 5. THE HEARTBEAT RE-CHECKS THE DEADLINE AGAINST `now()`, NOT AGAINST THE
27
+ * TIMER (extended-job-wait). A 20-minute wait spans a laptop lid: Node's
28
+ * monotonic `setTimeout` does not count the sleep, so a "10 minute" wait can
29
+ * run 40 minutes of wall clock. Each beat compares `now() - startedAt` with
30
+ * the budget and settles on the first beat after resume.
31
+ *
32
+ * THE WAKE SOURCE IS EDGE-TRIGGERED ONLY - there is no "is something pending"
33
+ * probe. The engine drains steering before EVERY tool execution
34
+ * (`core/src/engine/agent-loop.ts`), so input that arrived before this wait began
35
+ * has already been accepted; only input that arrives AFTER `subscribe()` needs to
36
+ * end the wait. Between the engine's decision to run `job_wait` and the
37
+ * `subscribe()` below there is no `await`, so no notification can fall into the
38
+ * gap. A level-triggered probe would add a stale-state hot loop (every wait
39
+ * returning at once) for no reachable gain.
26
40
  */
27
41
  import { isTerminalStatus } from './types.js';
28
42
  /** Read one bounded page per target; `read()` returns `undefined` for a vanished id. */
@@ -49,24 +63,41 @@ function allSettled(port, targets) {
49
63
  return snapshot === undefined || isTerminalStatus(snapshot.status);
50
64
  });
51
65
  }
66
+ /** Has any target ended? Same unknown-id rule as `allSettled`. */
67
+ function anySettled(port, targets) {
68
+ return targets.some((id) => {
69
+ const snapshot = port.get(id);
70
+ return snapshot === undefined || isTerminalStatus(snapshot.status);
71
+ });
72
+ }
52
73
  /**
53
- * Wait up to `timeoutMs` for every target to reach a terminal status.
74
+ * THE ONE PREDICATE for "is this wait over", shared by the entry fast path and the
75
+ * `finish` funnel. Using it in only one of them makes `mode: 'any'` subscribe and
76
+ * burn the whole timeout when a target had already ended.
77
+ */
78
+ function isDone(port, targets, mode) {
79
+ return mode === 'any' ? anySettled(port, targets) : allSettled(port, targets);
80
+ }
81
+ /**
82
+ * Wait up to `timeoutMs` for the targets to reach a terminal status.
54
83
  *
55
84
  * Resolves - never rejects - and always after at most `timeoutMs`. `now` is
56
85
  * injectable so a test can assert `waitedMs` without depending on wall clock.
57
86
  */
58
87
  export async function waitForJob(port, req, now = Date.now) {
59
88
  const { targets, signal } = req;
89
+ const mode = req.mode ?? 'all';
60
90
  const startedAt = now();
61
91
  // RULE 1. Nothing to wait for at all, or everything already ended: no
62
92
  // subscription, no timer, `waitedMs: 0`.
63
- if (targets.length === 0 || allSettled(port, targets)) {
93
+ if (targets.length === 0 || isDone(port, targets, mode)) {
64
94
  return {
65
95
  jobs: collect(port, targets),
66
96
  terminal: true,
67
97
  waitedMs: 0,
68
98
  timedOut: false,
69
99
  aborted: false,
100
+ woken: false,
70
101
  pages: readPages(port, targets, req.since),
71
102
  };
72
103
  }
@@ -79,6 +110,8 @@ export async function waitForJob(port, req, now = Date.now) {
79
110
  // from inside the listener instead of resolving the wait.
80
111
  let unsubscribe = () => { };
81
112
  let timer;
113
+ let beat;
114
+ let unwake = () => { };
82
115
  /** Assigned below; declared here so `finish` never reads an uninitialised binding. */
83
116
  let onAbort = () => { };
84
117
  // RULE 3: the one funnel. Idempotent, and it releases every reference before
@@ -88,15 +121,19 @@ export async function waitForJob(port, req, now = Date.now) {
88
121
  return;
89
122
  done = true;
90
123
  unsubscribe();
124
+ unwake();
91
125
  if (timer !== undefined)
92
126
  clearTimeout(timer);
127
+ if (beat !== undefined)
128
+ clearInterval(beat);
93
129
  signal?.removeEventListener('abort', onAbort);
94
130
  resolve({
95
131
  jobs: collect(port, targets),
96
- terminal: reason === 'terminal' || allSettled(port, targets),
132
+ terminal: reason === 'terminal' || isDone(port, targets, mode),
97
133
  waitedMs: now() - startedAt,
98
134
  timedOut: reason === 'timeout',
99
135
  aborted: reason === 'abort',
136
+ woken: reason === 'wake',
100
137
  pages: readPages(port, targets, req.since),
101
138
  });
102
139
  };
@@ -105,12 +142,38 @@ export async function waitForJob(port, req, now = Date.now) {
105
142
  return;
106
143
  if (!targets.includes(event.service.id))
107
144
  return;
108
- if (allSettled(port, targets))
145
+ if (isDone(port, targets, mode))
109
146
  finish('terminal');
110
147
  });
148
+ // The user spoke. Edge-triggered: only a notification after this line counts.
149
+ unwake = req.wakeOn?.subscribe(() => finish('wake')) ?? (() => { });
150
+ if (done) {
151
+ // A source that notifies synchronously inside `subscribe` already settled us.
152
+ unwake();
153
+ return;
154
+ }
111
155
  // RULE 2: `unref`, so a pending wait never keeps a quitting process alive.
112
156
  timer = setTimeout(() => finish('timeout'), Math.max(0, req.timeoutMs));
113
157
  timer.unref?.();
158
+ // RULE 5. Also the view's 1 Hz clock. A throwing `onBeat` is a renderer bug and
159
+ // must never kill a 20-minute wait.
160
+ if (req.heartbeat) {
161
+ const { everyMs, onBeat } = req.heartbeat;
162
+ beat = setInterval(() => {
163
+ const current = now();
164
+ if (current - startedAt >= req.timeoutMs) {
165
+ finish('timeout');
166
+ return;
167
+ }
168
+ try {
169
+ onBeat(current);
170
+ }
171
+ catch {
172
+ // Deliberately swallowed: see above.
173
+ }
174
+ }, Math.max(1, everyMs));
175
+ beat.unref?.();
176
+ }
114
177
  onAbort = () => finish('abort');
115
178
  // `{ once: true }` AND the explicit removal in `finish`: the option alone
116
179
  // leaves the listener registered on a signal that outlives this wait.
@@ -101,6 +101,8 @@ export function settleRunningToolCards(entries) {
101
101
  live: undefined,
102
102
  liveSeq: undefined,
103
103
  lastOutputAt: undefined,
104
+ wait: undefined,
105
+ waitSeq: undefined,
104
106
  };
105
107
  });
106
108
  return { entries: out, settled };
@@ -9,6 +9,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
9
9
  import { dirname, isAbsolute, join, resolve } from 'node:path';
10
10
  import { getSessionsDir } from '../config/store.js';
11
11
  import { stripEphemeralRuns } from '../team/settled.js';
12
+ import { sanitizeInvocationCard } from '../agent/invocation-card.js';
12
13
  import { validateSession } from './validate-session.js';
13
14
  import { validateCompactionIdentity, verifyCompactionIdentity, } from '../compaction/memory-identity.js';
14
15
  import { getLogger } from '../logging/logger.js';
@@ -59,8 +60,20 @@ function defaultStamp() {
59
60
  */
60
61
  function stripLiveToolOutput(entries) {
61
62
  return entries.map((e) => e.kind === 'tool' &&
62
- (e.live !== undefined || e.liveSeq !== undefined || e.lastOutputAt !== undefined)
63
- ? { ...e, live: undefined, liveSeq: undefined, lastOutputAt: undefined }
63
+ (e.live !== undefined ||
64
+ e.liveSeq !== undefined ||
65
+ e.lastOutputAt !== undefined ||
66
+ e.wait !== undefined ||
67
+ e.waitSeq !== undefined)
68
+ ? {
69
+ ...e,
70
+ live: undefined,
71
+ liveSeq: undefined,
72
+ lastOutputAt: undefined,
73
+ // `wait` / `waitSeq` die with `live` (extended-job-wait I-9).
74
+ wait: undefined,
75
+ waitSeq: undefined,
76
+ }
64
77
  : e);
65
78
  }
66
79
  /**
@@ -145,6 +158,11 @@ export function saveSession(filePath, data) {
145
158
  */
146
159
  export function normalizeLoadedEntries(entries) {
147
160
  return entries.map((entry) => {
161
+ // A runtime-only wait view that reached disk anyway (an older or hand-edited
162
+ // file) describes a wait that died with the session: drop it (I-9).
163
+ if (entry.kind === 'tool' && (entry.wait !== undefined || entry.waitSeq !== undefined)) {
164
+ return { ...entry, wait: undefined, waitSeq: undefined };
165
+ }
148
166
  if (entry.kind === 'team' && entry.active) {
149
167
  return { ...entry, active: false, aborted: true };
150
168
  }
@@ -227,6 +245,15 @@ export function normalizeLoadedEntries(entries) {
227
245
  // THE ID IS KEPT: `restoreEntries` splices these positions into the
228
246
  // live transcript, and a fresh id here would collide with the next
229
247
  // `e<n>` the reducer allocates.
248
+ if (entry.kind === 'user' && 'invocation' in entry) {
249
+ // THE EIGHTH CLAUSE (tui-invocation-cards I-7). A session file is user-editable
250
+ // and `shape()` ignores undeclared fields, so a card arrives unchecked. Repair
251
+ // what can be repaired; a card that cannot be is dropped and the entry degrades
252
+ // to a plain user message - a corrupt card must never cost the session.
253
+ const { invocation: stored, ...plain } = entry;
254
+ const card = sanitizeInvocationCard(stored);
255
+ return card ? { ...plain, invocation: card } : plain;
256
+ }
230
257
  if (entry.kind === 'queued') {
231
258
  const downgraded = {
232
259
  id: entry.id,
@@ -145,14 +145,16 @@ const subagent = shape({
145
145
  retry: optional(shape({ attempt: number, maxRetries: number })),
146
146
  });
147
147
  const ENTRY_FIELDS = {
148
- user: { text: string },
148
+ // `invocation` is declared for self-description only: `shape()` ignores undeclared
149
+ // fields, and `normalizeLoadedEntries` repairs or drops a bad card (tui-invocation-cards).
150
+ user: { text: string, invocation: optional(() => { }) },
149
151
  // A GOVERNANCE CARD IS HISTORY, so it is validated LENIENTLY (governance-mode 10.4):
150
152
  // unknown fields are ignored (`shape` only reads the declared ones) and `card` is not
151
153
  // shape-checked here at all - `sanitizeGovernanceEntries` below repairs a card it can
152
154
  // and degrades one it cannot to a one-line notice, so a corrupt footnote never costs
153
155
  // the session. `at` is advisory.
154
156
  governance: { at: optional(number), card: () => { } },
155
- queued: { text: string, queueId: optional(nonempty) },
157
+ queued: { text: string, queueId: optional(nonempty), invocation: optional(() => { }) },
156
158
  notice: { text: string, level: enumeration('info', 'warn', 'error') },
157
159
  assistant: {
158
160
  text: string, thinking: optional(string), thinkingOpen: boolean, streaming: boolean,
@@ -164,6 +166,9 @@ const ENTRY_FIELDS = {
164
166
  status: enumeration('pending', 'running', 'done', 'error'), preview: optional(string),
165
167
  durationMs: optional(number), isError: optional(boolean), patch: optional(patch),
166
168
  live: optional(array(string)), liveSeq: optional(number), lastOutputAt: optional(number),
169
+ // Runtime-only (extended-job-wait I-9): never written, but tolerated on read so a
170
+ // session saved mid-wait by any path still resumes; `normalizeLoadedEntries` drops it.
171
+ wait: optional(object), waitSeq: optional(number),
167
172
  },
168
173
  team: { dispatchId: string, requested: number, runs: array(subagent), aborted: boolean,
169
174
  durationMs: optional(number), active: boolean },
@@ -106,6 +106,19 @@ export interface ForcedPlan {
106
106
  * can apply, because only it can read the live config.
107
107
  */
108
108
  export declare function planForced(args: SubagentCommandArgs, team: TeamConfig): ForcedPlan;
109
+ /**
110
+ * The parenthesised disclosure sentence of the plan notice - why the fan-out is not what
111
+ * the user typed - or `null` when nothing was narrowed.
112
+ *
113
+ * SHARED BY `planNotice` AND `forcedCardNote` (tui-invocation-cards RV-4): the notice and
114
+ * the transcript card must word the same fact the same way, so there is exactly one
115
+ * place that decides it.
116
+ *
117
+ * TWO CEILINGS, TWO SENTENCES. `forcedPartsMax` bounds what one argument may carry and
118
+ * `team.maxSubagents` bounds what may be dispatched; naming the wrong one sends the user
119
+ * to change a number that is not the reason.
120
+ */
121
+ export declare function clampDisclosure(planned: ForcedPlan, team: TeamConfig): string | null;
109
122
  /**
110
123
  * The one line that says what the NEXT turn will do - a plan, not a result.
111
124
  *
@@ -114,3 +127,9 @@ export declare function planForced(args: SubagentCommandArgs, team: TeamConfig):
114
127
  * said "4 subagents started" would be a promise the command cannot keep.
115
128
  */
116
129
  export declare function planNotice(planned: ForcedPlan, team: TeamConfig): string;
130
+ /**
131
+ * The card's `note`: the same disclosure, as a sentence. `undefined` when nothing was
132
+ * narrowed - the "Esc stops the turn" follow-up is an operating hint the status bar
133
+ * carries, not a fact about this invocation.
134
+ */
135
+ export declare function forcedCardNote(planned: ForcedPlan, team: TeamConfig): string | undefined;
@@ -123,34 +123,56 @@ export function planForced(args, team) {
123
123
  };
124
124
  }
125
125
  /**
126
- * The one line that says what the NEXT turn will do - a plan, not a result.
126
+ * The parenthesised disclosure sentence of the plan notice - why the fan-out is not what
127
+ * the user typed - or `null` when nothing was narrowed.
127
128
  *
128
- * IT IS PRINTED BEFORE THE TURN IS SUBMITTED and it never claims children
129
- * started: forcing is instruction-level for the first turn, and a notice that
130
- * said "4 subagents started" would be a promise the command cannot keep.
129
+ * SHARED BY `planNotice` AND `forcedCardNote` (tui-invocation-cards RV-4): the notice and
130
+ * the transcript card must word the same fact the same way, so there is exactly one
131
+ * place that decides it.
132
+ *
133
+ * TWO CEILINGS, TWO SENTENCES. `forcedPartsMax` bounds what one argument may carry and
134
+ * `team.maxSubagents` bounds what may be dispatched; naming the wrong one sends the user
135
+ * to change a number that is not the reason.
131
136
  */
132
- export function planNotice(planned, team) {
133
- const noun = planned.count === 1 ? 'subagent' : 'subagents';
134
- const head = `Forcing a fan-out of ${planned.count} ${noun}`;
135
- const followUp = `${head} (max ${team.maxSubagents}) - the next turn dispatches them. `
136
- + 'Esc stops the turn.';
137
- // TWO CEILINGS, TWO SENTENCES. `forcedPartsMax` bounds what one argument may
138
- // carry and `team.maxSubagents` bounds what may be dispatched; naming the wrong
139
- // one sends the user to change a number that is not the reason.
137
+ export function clampDisclosure(planned, team) {
140
138
  if (planned.parts.length > 0) {
141
139
  if (planned.parts.length < planned.partsAdmitted) {
142
- return `${head} (asked for ${planned.partsRequested} parts; `
143
- + `team.maxSubagents is ${team.maxSubagents}).`;
140
+ return `asked for ${planned.partsRequested} parts; team.maxSubagents is ${team.maxSubagents}`;
144
141
  }
145
142
  if (planned.partsAdmitted < planned.partsRequested) {
146
- return `${head} (asked for ${planned.partsRequested} parts, `
147
- + `the first ${planned.parts.length} are used).`;
143
+ return `asked for ${planned.partsRequested} parts, the first ${planned.parts.length} are used`;
148
144
  }
149
- return followUp;
145
+ return null;
150
146
  }
151
147
  if (planned.requested > planned.count) {
152
- return `${head} (asked for ${planned.requested}; `
153
- + `team.maxSubagents is ${team.maxSubagents}).`;
148
+ return `asked for ${planned.requested}; team.maxSubagents is ${team.maxSubagents}`;
154
149
  }
155
- return followUp;
150
+ return null;
151
+ }
152
+ /**
153
+ * The one line that says what the NEXT turn will do - a plan, not a result.
154
+ *
155
+ * IT IS PRINTED BEFORE THE TURN IS SUBMITTED and it never claims children
156
+ * started: forcing is instruction-level for the first turn, and a notice that
157
+ * said "4 subagents started" would be a promise the command cannot keep.
158
+ */
159
+ export function planNotice(planned, team) {
160
+ const noun = planned.count === 1 ? 'subagent' : 'subagents';
161
+ const head = `Forcing a fan-out of ${planned.count} ${noun}`;
162
+ const disclosure = clampDisclosure(planned, team);
163
+ if (disclosure !== null)
164
+ return `${head} (${disclosure}).`;
165
+ return `${head} (max ${team.maxSubagents}) - the next turn dispatches them. `
166
+ + 'Esc stops the turn.';
167
+ }
168
+ /**
169
+ * The card's `note`: the same disclosure, as a sentence. `undefined` when nothing was
170
+ * narrowed - the "Esc stops the turn" follow-up is an operating hint the status bar
171
+ * carries, not a fact about this invocation.
172
+ */
173
+ export function forcedCardNote(planned, team) {
174
+ const disclosure = clampDisclosure(planned, team);
175
+ if (disclosure === null)
176
+ return undefined;
177
+ return `${disclosure.charAt(0).toUpperCase()}${disclosure.slice(1)}.`;
156
178
  }
@@ -18,14 +18,14 @@
18
18
  import { type AgentTool } from '@aragon-agent/core';
19
19
  import { type JobNotice } from '../proc/notices.js';
20
20
  import type { JobKind, ProcSupervisorPort, ServiceStatus } from '../proc/types.js';
21
+ import { type WakeSource } from '../proc/wait.js';
22
+ import { type WaitListener } from '../proc/wait-view.js';
21
23
  export interface JobToolDeps {
22
24
  supervisor: ProcSupervisorPort;
23
25
  /** `cfg.jobs.waitSeconds`, read LIVE (same rule as `startupSettleMs`). */
24
26
  waitSeconds: () => number;
25
27
  /** `cfg.jobs.stallSeconds`, read LIVE; only the `[stalled]` sentence uses it. */
26
28
  stallSeconds: () => number;
27
- /** `cfg.toolTimeoutMs`, read LIVE: I-4's second convergence. */
28
- toolTimeoutMs: () => number;
29
29
  /** `PROC_LIMITS.maxConsecutiveWaits`. */
30
30
  maxConsecutiveWaits: number;
31
31
  /**
@@ -40,6 +40,17 @@ export interface JobToolDeps {
40
40
  kind: JobKind;
41
41
  status: ServiceStatus;
42
42
  } | undefined;
43
+ /** Live view of the wait (`begin` / 1 Hz `update` / `end`); absent for a sub-agent. */
44
+ onWait?: WaitListener;
45
+ /**
46
+ * Runs the whole wait with the idle watchdog paused (`controller.withPausedWatchdog`).
47
+ * Absent in a sub-agent or a fixture: the wait then runs unwrapped, as before.
48
+ */
49
+ holdWatchdog?: <T>(fn: () => Promise<T>) => Promise<T>;
50
+ /** Ends a wait when the user sends a message (edge-triggered; see `proc/wait.ts`). */
51
+ attention?: WakeSource;
52
+ /** Test seam; defaults to `Date.now`. */
53
+ now?: () => number;
43
54
  }
44
55
  export declare function makeJobWait(deps: JobToolDeps): AgentTool;
45
56
  export declare function makeJobList(deps: JobToolDeps): AgentTool;