@aragon-agent/cli 0.6.1 → 0.6.3

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 (127) hide show
  1. package/CHANGELOG.md +216 -0
  2. package/README.md +241 -18
  3. package/dist/agent/agent-mode.d.ts +39 -0
  4. package/dist/agent/agent-mode.js +36 -0
  5. package/dist/agent/controller.d.ts +74 -1
  6. package/dist/agent/controller.js +200 -1
  7. package/dist/agent/headless.d.ts +8 -0
  8. package/dist/agent/headless.js +47 -0
  9. package/dist/agent/reducer.d.ts +120 -0
  10. package/dist/agent/reducer.js +201 -2
  11. package/dist/cli.js +297 -6
  12. package/dist/commands/builtins.js +73 -23
  13. package/dist/commands/registry.d.ts +27 -0
  14. package/dist/compaction/archive.d.ts +175 -0
  15. package/dist/compaction/archive.js +267 -0
  16. package/dist/compaction/child.d.ts +97 -0
  17. package/dist/compaction/child.js +113 -0
  18. package/dist/compaction/command.d.ts +41 -0
  19. package/dist/compaction/command.js +342 -0
  20. package/dist/compaction/compactor.d.ts +268 -0
  21. package/dist/compaction/compactor.js +689 -0
  22. package/dist/compaction/digest.d.ts +62 -0
  23. package/dist/compaction/digest.js +164 -0
  24. package/dist/compaction/limits.d.ts +165 -0
  25. package/dist/compaction/limits.js +172 -0
  26. package/dist/compaction/pressure.d.ts +135 -0
  27. package/dist/compaction/pressure.js +167 -0
  28. package/dist/compaction/summarize-call.d.ts +70 -0
  29. package/dist/compaction/summarize-call.js +86 -0
  30. package/dist/compaction/summary-prompt.d.ts +85 -0
  31. package/dist/compaction/summary-prompt.js +197 -0
  32. package/dist/compaction/tail-budget.d.ts +52 -0
  33. package/dist/compaction/tail-budget.js +67 -0
  34. package/dist/compaction/types.d.ts +179 -0
  35. package/dist/compaction/types.js +22 -0
  36. package/dist/compaction/wiring.d.ts +255 -0
  37. package/dist/compaction/wiring.js +556 -0
  38. package/dist/config/app-paths.d.ts +14 -0
  39. package/dist/config/app-paths.js +16 -0
  40. package/dist/config/cli-commands.d.ts +41 -0
  41. package/dist/config/cli-commands.js +77 -1
  42. package/dist/config/env.d.ts +4 -1
  43. package/dist/config/env.js +92 -2
  44. package/dist/config/load.d.ts +31 -0
  45. package/dist/config/load.js +63 -1
  46. package/dist/config/schema.d.ts +159 -0
  47. package/dist/config/schema.js +137 -0
  48. package/dist/config/store.js +17 -1
  49. package/dist/config/ui-state.d.ts +44 -6
  50. package/dist/config/ui-state.js +21 -12
  51. package/dist/diagnostics/doctor.js +41 -1
  52. package/dist/exec/events.d.ts +50 -1
  53. package/dist/exec/index.js +8 -4
  54. package/dist/exec/runner.js +44 -2
  55. package/dist/input/mouse-events.d.ts +31 -5
  56. package/dist/input/mouse-events.js +86 -27
  57. package/dist/input/stdin-mouse-filter.d.ts +8 -3
  58. package/dist/logging/install.d.ts +51 -1
  59. package/dist/logging/install.js +28 -0
  60. package/dist/logging/logger.d.ts +1 -1
  61. package/dist/session/cli-commands.js +23 -1
  62. package/dist/session/persist.js +19 -0
  63. package/dist/session/store.d.ts +18 -0
  64. package/dist/session/store.js +59 -2
  65. package/dist/team/report.js +7 -1
  66. package/dist/team/runtime.d.ts +10 -0
  67. package/dist/team/runtime.js +7 -0
  68. package/dist/team/subagent.d.ts +31 -1
  69. package/dist/team/subagent.js +42 -15
  70. package/dist/team/types.d.ts +8 -0
  71. package/dist/ui/ActivityLine.d.ts +19 -1
  72. package/dist/ui/ActivityLine.js +10 -2
  73. package/dist/ui/App.d.ts +48 -1
  74. package/dist/ui/App.js +317 -17
  75. package/dist/ui/Composer.d.ts +26 -1
  76. package/dist/ui/Composer.js +6 -6
  77. package/dist/ui/PromptInput.d.ts +34 -1
  78. package/dist/ui/PromptInput.js +54 -3
  79. package/dist/ui/StatusBar.d.ts +40 -0
  80. package/dist/ui/StatusBar.js +13 -4
  81. package/dist/ui/TeamPanel.js +7 -1
  82. package/dist/ui/Transcript.d.ts +21 -1
  83. package/dist/ui/Transcript.js +32 -2
  84. package/dist/ui/clipboard.d.ts +52 -0
  85. package/dist/ui/clipboard.js +91 -0
  86. package/dist/ui/entries/CompactionCard.d.ts +75 -0
  87. package/dist/ui/entries/CompactionCard.js +158 -0
  88. package/dist/ui/frame-differ.d.ts +46 -0
  89. package/dist/ui/frame-differ.js +94 -10
  90. package/dist/ui/gauge.d.ts +16 -1
  91. package/dist/ui/gauge.js +25 -6
  92. package/dist/ui/glyphs.d.ts +11 -0
  93. package/dist/ui/glyphs.js +2 -0
  94. package/dist/ui/layout/ScrollViewport.d.ts +43 -6
  95. package/dist/ui/layout/ScrollViewport.js +128 -21
  96. package/dist/ui/layout/budget.d.ts +10 -3
  97. package/dist/ui/layout/budget.js +10 -3
  98. package/dist/ui/layout/follow-state.d.ts +137 -0
  99. package/dist/ui/layout/follow-state.js +150 -0
  100. package/dist/ui/layout/virtual-window.d.ts +11 -0
  101. package/dist/ui/layout/virtual-window.js +34 -0
  102. package/dist/ui/overlays/HelpOverlay.js +18 -2
  103. package/dist/ui/overlays/SettingsScreen.d.ts +47 -1
  104. package/dist/ui/overlays/SettingsScreen.js +60 -1
  105. package/dist/ui/palettes.d.ts +16 -0
  106. package/dist/ui/palettes.js +6 -0
  107. package/dist/ui/screen.d.ts +22 -0
  108. package/dist/ui/screen.js +70 -8
  109. package/dist/ui/selection/highlight.d.ts +39 -0
  110. package/dist/ui/selection/highlight.js +64 -0
  111. package/dist/ui/selection/screen-mirror.d.ts +24 -0
  112. package/dist/ui/selection/screen-mirror.js +35 -0
  113. package/dist/ui/selection/selection-controller.d.ts +88 -0
  114. package/dist/ui/selection/selection-controller.js +282 -0
  115. package/dist/ui/selection/selection.d.ts +90 -0
  116. package/dist/ui/selection/selection.js +223 -0
  117. package/dist/ui/stdout-frame-writer.d.ts +33 -0
  118. package/dist/ui/stdout-frame-writer.js +15 -0
  119. package/dist/ui/theme.d.ts +11 -0
  120. package/dist/ui/theme.js +2 -0
  121. package/dist/ui/transcript-text.js +23 -0
  122. package/dist/ui/use-startup-notices.d.ts +62 -5
  123. package/dist/ui/use-startup-notices.js +81 -17
  124. package/dist/ui/use-wheel-routing.js +8 -0
  125. package/dist/ui/win-vt-force.d.ts +136 -0
  126. package/dist/ui/win-vt-force.js +237 -0
  127. package/package.json +4 -2
@@ -15,8 +15,10 @@
15
15
  * module exists so `stdin-mouse-filter.ts` can remove every byte of every
16
16
  * report before Ink ever sees the chunk (invariant I-1).
17
17
  *
18
- * Non-wheel reports (press / release / drag) are recognized and DISCARDED
19
- * rather than passed through, for the same reason.
18
+ * Every mouse report is CONSUMED here whatever it turns out to be; what changed
19
+ * with tui-selection-and-scroll-follow is that press / drag / release are now
20
+ * EMITTED as well as consumed, so `SelectionController` can build a selection
21
+ * out of them (§4.4.2). They were previously recognized and thrown away.
20
22
  */
21
23
  /**
22
24
  * Ceiling on the held-back tail. A malformed sequence that looks like a prefix
@@ -38,34 +40,80 @@ const SGR_PREFIX = /^\x1b\[<[\d;]*$/;
38
40
  export function isMousePrefix(s) {
39
41
  return /^\x1B(\[(<[\d;]*|M[\s\S]{0,2})?)?$/.test(s);
40
42
  }
43
+ function modifiers(b) {
44
+ return { shift: (b & 4) !== 0, alt: (b & 8) !== 0, ctrl: (b & 16) !== 0 };
45
+ }
41
46
  /**
42
- * Decode an SGR button byte into a wheel event, or `null` when the report is
43
- * not a wheel notch we act on.
47
+ * Decode one report, or `null` when it is a report we consume without acting on.
44
48
  *
45
49
  * THE ORDER OF THESE TESTS IS THE SPECIFICATION, not a presentation choice.
46
50
  * Button 66 (wheel-left) has `b & 1 === 0` and would decode as `'up'`, so the
47
- * horizontal test has to precede the direction decode.
51
+ * horizontal test has to precede the direction decode; and `b & 32` (motion) has
52
+ * to precede the `final` test, because a drag is reported with `M` exactly as a
53
+ * press is.
54
+ *
55
+ * ═══════════════════════════════════════════════════════════════════════════
56
+ * I-11 — BUTTON EVENTS COME FROM THE SGR PATH ONLY. X10 stays wheel-only, which
57
+ * is exactly the contract it has today.
58
+ *
59
+ * `matchX10` reconstructs a legacy report and hard-codes `final = 'M'`, because
60
+ * X10 HAS NO SEPARATE RELEASE FORM AT ALL — it encodes a release as button 3 of
61
+ * a press. Fed through the table below, an X10 release would become a `press`
62
+ * that is never followed by a `release`, whose `button` is 3 while the type says
63
+ * `0 | 1 | 2`, and whose consequence is a `hold` that is set and never cleared:
64
+ * content anchoring never lets go, the resume timer's third condition never
65
+ * passes, and the transcript is frozen for the rest of the session with nothing
66
+ * raised anywhere.
67
+ *
68
+ * This costs nothing real (D-16): we never REQUEST X10 — `?1006h` is over a
69
+ * decade old and every terminal that implements `?1000` implements it — and a
70
+ * terminal that ignored the SGR request also has no usable drag coordinates past
71
+ * column 223 (R-2). X10 exists in this parser only so a stray report cannot
72
+ * reach the user's draft.
73
+ * ═══════════════════════════════════════════════════════════════════════════
48
74
  */
49
- function decodeWheel(b, x, y, final) {
50
- // 1. Not a wheel report at all (press / release / drag) — consume, emit nothing.
51
- if ((b & 64) === 0)
52
- return null;
53
- // 2. Horizontal wheel (66 = left, 67 = right). Consumed and dropped (non-goal).
54
- if ((b & 3) >= 2)
55
- return null;
56
- // 3. Wheel buttons are press-only; a terminal that ever sent a release for one
57
- // must not produce a second step for the same physical notch.
58
- if (final !== 'M')
75
+ function decodeReport(b, x, y, final, encoding) {
76
+ // 1. Wheel. Buttons 64-67 in both encodings.
77
+ if ((b & 64) !== 0) {
78
+ // Horizontal wheel (66 = left, 67 = right). Consumed and dropped (non-goal).
79
+ if ((b & 3) >= 2)
80
+ return null;
81
+ // Wheel buttons are press-only; a terminal that ever sent a release for one
82
+ // must not produce a second step for the same physical notch.
83
+ if (final !== 'M')
84
+ return null;
85
+ return { kind: 'wheel', dir: (b & 1) === 1 ? 'down' : 'up', x, y, ...modifiers(b) };
86
+ }
87
+ if (encoding !== 'sgr')
88
+ return null; // I-11
89
+ // 2. Motion while a button is held (`?1002h`). `3` is "no button", which
90
+ // `?1002` should never send and `?1003` sends constantly — dropping it is
91
+ // what keeps an any-event terminal from painting a selection with no button
92
+ // down at all.
93
+ if ((b & 32) !== 0) {
94
+ const button = b & 3;
95
+ if (button === 3)
96
+ return null;
97
+ return { kind: 'drag', button: button, x, y, ...modifiers(b) };
98
+ }
99
+ // 3. Release. In SGR the final byte carries the transition, so this is
100
+ // unambiguous — unlike X10, which is why step 1's guard exists.
101
+ //
102
+ // A release whose button field reads `3` is a terminal saying "some button
103
+ // came up" without naming it. It is mapped to button 0 rather than dropped:
104
+ // the ONE thing a release must always do is let go of `hold`, and dropping
105
+ // it would leave the viewport frozen — the exact failure I-11 is about. A
106
+ // spurious button-0 release is harmless, because a selection can only exist
107
+ // if button 0 was pressed in the first place.
108
+ if (final === 'm') {
109
+ const button = b & 3;
110
+ return { kind: 'release', button: (button === 3 ? 0 : button), x, y, ...modifiers(b) };
111
+ }
112
+ // 4. Press.
113
+ const button = b & 3;
114
+ if (button === 3)
59
115
  return null;
60
- return {
61
- kind: 'wheel',
62
- dir: (b & 1) === 1 ? 'down' : 'up',
63
- x,
64
- y,
65
- shift: (b & 4) !== 0,
66
- alt: (b & 8) !== 0,
67
- ctrl: (b & 16) !== 0,
68
- };
116
+ return { kind: 'press', button: button, x, y, ...modifiers(b) };
69
117
  }
70
118
  /**
71
119
  * X10 legacy reports (`\x1b[M` + three chars, each `code - 32`).
@@ -84,7 +132,13 @@ function matchX10(s) {
84
132
  if (codes.some((c) => c < 32 || c > 255))
85
133
  return { kind: 'complete', length: 6, event: null };
86
134
  const [b, x, y] = codes;
87
- return { kind: 'complete', length: 6, event: decodeWheel(b - 32, x - 32, y - 32, 'M') };
135
+ return {
136
+ kind: 'complete',
137
+ length: 6,
138
+ // `'x10'` is what makes I-11 a property of the code rather than of somebody's
139
+ // memory: this encoding yields wheel events or nothing, ever.
140
+ event: decodeReport(b - 32, x - 32, y - 32, 'M', 'x10'),
141
+ };
88
142
  }
89
143
  /** Classify the escape sequence starting at `s[0] === ESC`. */
90
144
  function matchReport(s) {
@@ -100,7 +154,7 @@ function matchReport(s) {
100
154
  const b = Number(m[1]);
101
155
  const x = Number(m[2]);
102
156
  const y = Number(m[3]);
103
- return { kind: 'complete', length: m[0].length, event: decodeWheel(b, x, y, m[4]) };
157
+ return { kind: 'complete', length: m[0].length, event: decodeReport(b, x, y, m[4], 'sgr') };
104
158
  }
105
159
  return SGR_PREFIX.test(s) ? { kind: 'partial' } : { kind: 'none' };
106
160
  }
@@ -109,12 +163,17 @@ function matchReport(s) {
109
163
  return { kind: 'none' };
110
164
  }
111
165
  /**
112
- * Split a decoded stdin chunk into wheel events, passthrough text, and a
166
+ * Split a decoded stdin chunk into mouse events, passthrough text, and a
113
167
  * trailing prefix to carry into the next chunk.
114
168
  *
115
169
  * The caller is expected to feed `pending + nextChunk` back in; ConPTY does
116
170
  * split writes mid-sequence, and a report torn across two chunks would
117
171
  * otherwise leak its tail into the composer (R-6).
172
+ *
173
+ * ONE CHANNEL, NOT TWO (§4.4.2). Widening this list to carry buttons alongside
174
+ * wheel notches is what keeps ARRIVAL ORDER intact between them; a second
175
+ * channel would let a press overtake the notch that preceded it, and the
176
+ * selection would then be anchored against rows that had already scrolled.
118
177
  */
119
178
  export function splitMouseEvents(buffer) {
120
179
  const events = [];
@@ -18,10 +18,15 @@
18
18
  * than the bug this feature fixes. `cli.tsx::runInteractive` is the only caller
19
19
  * and passes `filter !== null` to `enterAltScreen`.
20
20
  */
21
- import { type WheelEvent } from './mouse-events.js';
21
+ import { type MouseEvent } from './mouse-events.js';
22
22
  export interface MouseSource {
23
- /** Returns an unsubscribe function. Multiple subscribers are allowed. */
24
- subscribe(listener: (event: WheelEvent) => void): () => void;
23
+ /**
24
+ * Returns an unsubscribe function. Multiple subscribers are allowed — the
25
+ * wheel router and (since tui-selection-and-scroll-follow) the selection
26
+ * controller both take this one channel, and each ignores the kinds it does
27
+ * not own.
28
+ */
29
+ subscribe(listener: (event: MouseEvent) => void): () => void;
25
30
  }
26
31
  export interface MouseFilter {
27
32
  /** Hand this to Ink: `render(<App/>, { stdin: filter.stdin })`. */
@@ -129,7 +129,17 @@ export interface AgentEventSource {
129
129
  thinkingLevel: string;
130
130
  };
131
131
  }
132
- /** The nine members of the core `AgentEvent` union, as this module reads them. */
132
+ /**
133
+ * The ELEVEN members of the core `AgentEvent` union, as this module reads them.
134
+ *
135
+ * THIS UNION MUST STAY A SUPERSET OF CORE'S, and the failure when it does not is
136
+ * a COMPILE error rather than a silent one (implementation finding IF-3): the
137
+ * `AgentEventSource` interface above takes a listener of THIS type, so a core
138
+ * union member missing here makes `AgentController` structurally incompatible
139
+ * with `attachAgentEvents` and every call site fails to type-check. That is the
140
+ * right failure mode, but it means adding an event to core is also a change
141
+ * here — the two `compaction_*` members below arrived that way.
142
+ */
133
143
  export type AgentLogEvent = {
134
144
  type: 'agent_start';
135
145
  } | {
@@ -183,6 +193,46 @@ export type AgentLogEvent = {
183
193
  duration?: number;
184
194
  error?: string;
185
195
  output?: string;
196
+ }
197
+ /**
198
+ * Context compaction (context-auto-compaction §5.1).
199
+ *
200
+ * RECORDED UNDER THE `agent` SCOPE HERE, not `compaction`. This module logs the
201
+ * LEAD'S LIFECYCLE, and "the run paused to compact" is part of that story — a
202
+ * support reader following a run's `agent` records must not find an unexplained
203
+ * gap where a 40-second summarization was. The `compaction` scope carries the
204
+ * subsystem's own detail (`compaction/compactor.ts`), which is a different
205
+ * question.
206
+ *
207
+ * NO `summary` FIELD IS READ, ever: it can be tens of thousands of characters,
208
+ * and this file's `message_update` case already records why a log larger than
209
+ * the conversation is not a log.
210
+ */
211
+ | {
212
+ type: 'compaction_start';
213
+ trigger?: string;
214
+ messageCount?: number;
215
+ } | {
216
+ type: 'compaction_end';
217
+ applied?: boolean;
218
+ mode?: string;
219
+ reason?: string;
220
+ messagesBefore?: number;
221
+ messagesAfter?: number;
222
+ droppedMessages?: number;
223
+ /**
224
+ * The tail-relief report (context-auto-compaction-hardening W2).
225
+ *
226
+ * THIS UNION MUST STAY A SUPERSET OF CORE'S (IF-3). A field added to
227
+ * `CompactionEndEvent` and not mirrored here does not fail the build — the
228
+ * structural type simply ignores it — so the only trace of a degradation
229
+ * the user is entitled to know about would be missing from the log.
230
+ */
231
+ tailRelief?: {
232
+ messages: number;
233
+ charsRemoved: number;
234
+ };
235
+ durationMs?: number;
186
236
  };
187
237
  /**
188
238
  * Subscribe to the agent's events and record them.
@@ -273,6 +273,34 @@ export function attachAgentEvents(logger, controller) {
273
273
  ms: turnStartedAt > 0 ? Date.now() - turnStartedAt : 0,
274
274
  });
275
275
  break;
276
+ // Context compaction (context-auto-compaction §5.1). At `info`, like
277
+ // `turn_end`, so a run's `agent` records have no unexplained 40-second gap
278
+ // where a summarization was.
279
+ case 'compaction_start':
280
+ logger.info('agent', 'compaction_start', {
281
+ trigger: event.trigger,
282
+ messages: event.messageCount,
283
+ });
284
+ break;
285
+ case 'compaction_end':
286
+ // `applied: false` is a WARN, not an info: it means the run is still at
287
+ // the occupancy that triggered this and is about to send anyway.
288
+ logger[event.applied ? 'info' : 'warn']('agent', 'compaction_end', {
289
+ applied: event.applied,
290
+ mode: event.mode,
291
+ reason: event.reason,
292
+ before: event.messagesBefore,
293
+ after: event.messagesAfter,
294
+ dropped: event.droppedMessages,
295
+ ...(event.tailRelief
296
+ ? {
297
+ clipped: event.tailRelief.messages,
298
+ clippedChars: event.tailRelief.charsRemoved,
299
+ }
300
+ : {}),
301
+ ms: event.durationMs,
302
+ });
303
+ break;
276
304
  case 'message_update': {
277
305
  // API retry (llm-api-retry-backoff §6.8), ABOVE the `trace` line so a
278
306
  // three-minute pause is in the log at the DEFAULT level. Redaction is
@@ -23,7 +23,7 @@
23
23
  import type { LogConfig } from '../config/schema.js';
24
24
  import { type LogLevelName } from './levels.js';
25
25
  /** Closed set, not free text: a typo'd scope is a record nobody can filter on. */
26
- export type LogScope = 'cli' | 'config' | 'agent' | 'tool' | 'skills' | 'llm' | 'migrate' | 'log' | 'fast' | 'history' | 'update';
26
+ export type LogScope = 'cli' | 'config' | 'agent' | 'tool' | 'skills' | 'llm' | 'migrate' | 'log' | 'fast' | 'history' | 'update' | 'compaction';
27
27
  export interface LogRecord {
28
28
  ts: string;
29
29
  lv: LogLevelName;
@@ -16,7 +16,17 @@
16
16
  * should make on its own.
17
17
  */
18
18
  import process from 'node:process';
19
- import { listSessions, pruneSessions, readSessionFile, removeSession, sessionPathFor, sessionsDir, } from './store.js';
19
+ import { isValidSessionId, listSessions, pruneSessions, readSessionFile, removeSession, sessionPathFor, sessionsDir, } from './store.js';
20
+ /**
21
+ * One wording for one rule, shared by `show` and `rm`.
22
+ *
23
+ * It is deliberately the same sentence `--session-id` produces: a caller who
24
+ * meets the rule once should recognise it everywhere, and two spellings of the
25
+ * same refusal read as two different rules.
26
+ */
27
+ const INVALID_ID_MESSAGE = (id) => `Invalid session id "${id}". Use 1-64 characters matching ` +
28
+ '[A-Za-z0-9][A-Za-z0-9._-]* (no "/", "\\", ".." or a leading dot). ' +
29
+ 'Pass a path to `aragon exec --resume` if you mean a file elsewhere.';
20
30
  const SUBCOMMANDS = ['list', 'show', 'rm', 'prune', 'path'];
21
31
  export function registerSessionsCommand(program) {
22
32
  program
@@ -74,6 +84,10 @@ function runShow(argument, opts) {
74
84
  process.stderr.write('Usage: aragon sessions show <id> [--json]\n');
75
85
  return 2;
76
86
  }
87
+ if (!isValidSessionId(argument)) {
88
+ process.stderr.write(`${INVALID_ID_MESSAGE(argument)}\n`);
89
+ return 2;
90
+ }
77
91
  const path = sessionPathFor(argument);
78
92
  const session = readSessionFile(path);
79
93
  if (!session) {
@@ -112,6 +126,14 @@ function runRemove(argument, opts) {
112
126
  process.stderr.write('Refusing to delete without --yes.\n');
113
127
  return 2;
114
128
  }
129
+ // BEFORE `--yes` IS HONOURED AS PERMISSION TO DELETE. `sessions rm` is
130
+ // documented to accept any file IN THE SESSIONS DIRECTORY, because the user
131
+ // named it - not any file anywhere, which is what an unvalidated id joined to
132
+ // that directory would reach (R-7).
133
+ if (!isValidSessionId(argument)) {
134
+ process.stderr.write(`${INVALID_ID_MESSAGE(argument)}\n`);
135
+ return 2;
136
+ }
115
137
  if (!removeSession(argument)) {
116
138
  process.stderr.write(`session_not_found: no session "${argument}".\n`);
117
139
  return 2;
@@ -139,6 +139,25 @@ export function normalizeLoadedEntries(entries) {
139
139
  detail: 'interrupted (session resumed)',
140
140
  };
141
141
  }
142
+ // THE FIFTH CLAUSE (context-auto-compaction §5.5 / C-8 / AC-19). A compaction
143
+ // card saved while its summarization was open resumes claiming to be running
144
+ // while nothing is, and — the part that actually costs — it never settles, so
145
+ // `Transcript`'s MONOTONIC boundary never advances past it and every frame for
146
+ // the rest of the session re-renders the whole tail.
147
+ //
148
+ // `applied: false` is also simply TRUE: whatever the call was going to
149
+ // return died with the process, and the history on disk is the PRE-compaction
150
+ // one. A card that claimed otherwise would tell the user their session had
151
+ // been compacted when it had not.
152
+ if (entry.kind === 'compaction' && entry.live) {
153
+ return {
154
+ ...entry,
155
+ live: false,
156
+ applied: false,
157
+ mode: 'none',
158
+ reason: 'interrupted (session resumed)',
159
+ };
160
+ }
142
161
  return entry;
143
162
  });
144
163
  }
@@ -54,6 +54,24 @@ export interface SessionListEntry {
54
54
  }
55
55
  export declare function sessionsDir(): string;
56
56
  export declare function sessionPathFor(id: string): string;
57
+ /**
58
+ * The id a RESUMED session writes back under.
59
+ *
60
+ * `--resume` accepts `<id|path>`, so the value the caller typed is NOT usable as
61
+ * an id: `--resume ./sessions/hand-saved.json` would otherwise make the path
62
+ * itself the id, and every later `sessionPathFor` / lock call would build a
63
+ * filename out of it - outside the sessions directory whenever the path was
64
+ * relative. That is the same write primitive `--session-id` is validated to
65
+ * prevent, arriving through the flag next to it. `meta.id` is no safer: it comes
66
+ * out of a file, and files are inputs.
67
+ *
68
+ * The order is the one that keeps every legitimate resume landing where the user
69
+ * expects: an exec-written session names itself, a TUI `/save` is named by its
70
+ * file, and anything else gets a fresh id rather than a guess - reported back in
71
+ * `system.init.sessionId` and `result.sessionId`, so the caller is never left
72
+ * wondering which session it just wrote.
73
+ */
74
+ export declare function resumedSessionId(path: string, saved: SavedSession): string;
57
75
  /** Read one file, or `null` for anything unreadable. NEVER THROWS. */
58
76
  export declare function readSessionFile(path: string): SavedSession | null;
59
77
  /**
@@ -53,8 +53,58 @@ const POINTER_FILE = '.last.json';
53
53
  export function sessionsDir() {
54
54
  return getSessionsDir();
55
55
  }
56
+ /**
57
+ * THE CHOKE POINT. Every string that becomes a session filename passes here.
58
+ *
59
+ * `isValidSessionId` is applied HERE rather than only at `--session-id`, because
60
+ * that flag is not the only way a caller-supplied string reaches this function:
61
+ * `--resume <id|path>` derives an id from the resumed FILE, `sessions rm` /
62
+ * `sessions show` take one straight from argv, and a session file can carry any
63
+ * `meta.id` at all. Guarding each caller instead would mean the boundary holds
64
+ * only for the callers someone remembered - and `join()` silently normalises
65
+ * `../..` away, so an escape leaves no trace to notice later. Validating at the
66
+ * single point that builds the path is what makes R-7 a property of the module
67
+ * rather than a habit of its callers.
68
+ *
69
+ * THROWS rather than coercing to a "safe" name: a caller who asked for one file
70
+ * and silently got another is a worse failure than a loud one, and every caller
71
+ * that can be handed untrusted input validates first and reports exit 2 itself.
72
+ */
73
+ function assertSessionId(id) {
74
+ if (!isValidSessionId(id)) {
75
+ throw new Error(`Invalid session id "${id}": expected 1-64 characters matching ` +
76
+ '[A-Za-z0-9][A-Za-z0-9._-]* (no "/", "\\", ".." or a leading dot).');
77
+ }
78
+ return id;
79
+ }
56
80
  export function sessionPathFor(id) {
57
- return join(getSessionsDir(), `${id}.json`);
81
+ return join(getSessionsDir(), `${assertSessionId(id)}.json`);
82
+ }
83
+ /**
84
+ * The id a RESUMED session writes back under.
85
+ *
86
+ * `--resume` accepts `<id|path>`, so the value the caller typed is NOT usable as
87
+ * an id: `--resume ./sessions/hand-saved.json` would otherwise make the path
88
+ * itself the id, and every later `sessionPathFor` / lock call would build a
89
+ * filename out of it - outside the sessions directory whenever the path was
90
+ * relative. That is the same write primitive `--session-id` is validated to
91
+ * prevent, arriving through the flag next to it. `meta.id` is no safer: it comes
92
+ * out of a file, and files are inputs.
93
+ *
94
+ * The order is the one that keeps every legitimate resume landing where the user
95
+ * expects: an exec-written session names itself, a TUI `/save` is named by its
96
+ * file, and anything else gets a fresh id rather than a guess - reported back in
97
+ * `system.init.sessionId` and `result.sessionId`, so the caller is never left
98
+ * wondering which session it just wrote.
99
+ */
100
+ export function resumedSessionId(path, saved) {
101
+ const declared = saved.meta?.id;
102
+ if (typeof declared === 'string' && isValidSessionId(declared))
103
+ return declared;
104
+ const base = basenameId(path);
105
+ if (isValidSessionId(base))
106
+ return base;
107
+ return randomUUID();
58
108
  }
59
109
  function ensureDir() {
60
110
  const dir = getSessionsDir();
@@ -117,6 +167,11 @@ export function writeSession(input) {
117
167
  return path;
118
168
  }
119
169
  export function removeSession(id) {
170
+ // An id that cannot name a session in this directory has not "failed to be
171
+ // deleted" - there was never a file of that name to delete. `sessions rm`
172
+ // validates first and reports the reason, so this stays a boolean.
173
+ if (!isValidSessionId(id))
174
+ return false;
120
175
  const path = sessionPathFor(id);
121
176
  try {
122
177
  unlinkSync(path);
@@ -329,7 +384,9 @@ export function tryAcquireSessionLock(id, opts = {}) {
329
384
  const now = opts.now ?? Date.now;
330
385
  const isAlive = opts.isProcessAlive ?? defaultIsProcessAlive;
331
386
  ensureDir();
332
- const path = join(getSessionsDir(), `${id}.lock`);
387
+ // Same boundary as `sessionPathFor` - a lock is a file in this directory too,
388
+ // and an id that may not name a session may not name its lock either.
389
+ const path = join(getSessionsDir(), `${assertSessionId(id)}.lock`);
333
390
  const payload = {
334
391
  pid: process.pid,
335
392
  host: hostname(),
@@ -143,9 +143,15 @@ function buildSection(run) {
143
143
  // bytes, and it is the only place the lead learns which of its children were
144
144
  // cheap — the fact the whole second cost line is about.
145
145
  const tier = run.tier === 'fast' ? ' [fast]' : '';
146
+ // `compacted N` in the same position `retried Nx` uses, and for the same
147
+ // reason (context-auto-compaction-hardening §3.4.4 / W3): this is the ONLY
148
+ // place the lead learns that a child's own history was summarized mid-task, so
149
+ // a summary that reads thinner than the turn count suggests has a stated cause
150
+ // rather than an unexplained one. Absent when it never happened.
151
+ const compacted = run.compactions ? `, compacted ${run.compactions}` : '';
146
152
  const head = [
147
153
  `### ${run.label}${tier} "${run.description}" ${statusBadge(run)} ${elapsed(run)}, ` +
148
- `${run.turns} turns, ${run.toolCalls} tools${retried}`,
154
+ `${run.turns} turns, ${run.toolCalls} tools${retried}${compacted}`,
149
155
  ];
150
156
  if (run.filesTouched.length > 0)
151
157
  head.push(`files: ${run.filesTouched.join(', ')}`);
@@ -60,6 +60,16 @@ export interface TeamRuntimeDeps {
60
60
  * is the pre-feature behaviour.
61
61
  */
62
62
  resolveTier?: SubagentDeps['resolveTier'];
63
+ /**
64
+ * Per-child context compaction (context-auto-compaction-hardening §3.4 / W3).
65
+ *
66
+ * Passed straight through to `SubagentDeps`; absent means children get none,
67
+ * which is the pre-hardening behaviour exactly. `AgentController` supplies it
68
+ * from `CompactionWiring.childFactory()`, and the closure returns `undefined`
69
+ * when compaction is unregistered or `compaction.subagents` is false - which is
70
+ * what lets `subagent.ts` spread nothing at all.
71
+ */
72
+ contextManagerFor?: SubagentDeps['contextManagerFor'];
63
73
  }
64
74
  export declare class TeamRuntime {
65
75
  private readonly deps;
@@ -132,6 +132,13 @@ export class TeamRuntime {
132
132
  ...(this.deps.humanQueue ? { humanQueue: this.deps.humanQueue } : {}),
133
133
  agentFactory: this.deps.agentFactory ?? defaultSubagentFactory,
134
134
  ...(this.deps.resolveTier ? { resolveTier: this.deps.resolveTier } : {}),
135
+ // Site two of two, and the omission is silent in the same way the policy
136
+ // one above is: declaring it on `TeamRuntimeDeps` and forgetting this line
137
+ // type-checks and forwards nothing, so every child would quietly run with
138
+ // no compaction while the config key says it has one.
139
+ ...(this.deps.contextManagerFor
140
+ ? { contextManagerFor: this.deps.contextManagerFor }
141
+ : {}),
135
142
  };
136
143
  // Hoisted into a factory so a retry can build a fresh child with IDENTICAL
137
144
  // wiring (F-4). Replacing the child rather than re-prompting it is the only
@@ -10,8 +10,9 @@
10
10
  * them (§3.11 / R-6). What a child does NOT get is spelled out below, and each
11
11
  * omission has a reason that is a correctness boundary rather than caution.
12
12
  */
13
- import { type AgentConfig, type AgentEvent, type AgentTool, type ModelRef, type ProviderRegistry, type SkillRegistry, type ThinkingLevel, type TokenUsage, type ToolPolicyDecision, type ToolPolicyVerdict } from '@aragon-agent/core';
13
+ import { type AgentConfig, type AgentEvent, type AgentTool, type Message, type ModelRef, type ProviderRegistry, type SkillRegistry, type ThinkingLevel, type TokenUsage, type ToolPolicyDecision, type ToolPolicyVerdict } from '@aragon-agent/core';
14
14
  import type { AgentMode } from '../agent/agent-mode.js';
15
+ import type { ChildContextManagerProvider } from '../compaction/child.js';
15
16
  import type { CliConfig } from '../config/schema.js';
16
17
  import type { ToolPermission } from '../exec/permission.js';
17
18
  import type { FastTierName } from '../fast/types.js';
@@ -29,6 +30,24 @@ export interface SubagentAgentLike extends WatchdogPausable {
29
30
  prompt(text: string): Promise<void>;
30
31
  abort(): void;
31
32
  subscribe(listener: (event: AgentEvent) => void): () => void;
33
+ /**
34
+ * The child's live history, for its own context manager
35
+ * (context-auto-compaction-hardening §3.4.3 / W3).
36
+ *
37
+ * NARROWED ON PURPOSE. `Agent` satisfies this with its full `AgentState`
38
+ * getter and a stub satisfies it with `{ messages: [] }`. Widening it to
39
+ * `AgentState` would make every test stub fabricate a systemPrompt, a model and
40
+ * a phase to compile, for fields none of them use.
41
+ *
42
+ * A PROPERTY READ, NOT A METHOD CALL, AND THAT IS NOT A STYLE CHOICE: core's
43
+ * `Agent` has NO `getMessages()`. It exposes `get state(): AgentState`;
44
+ * `AgentController.getMessages()` is the CONTROLLER's own one-line forwarder
45
+ * over `this.agent.state.messages`. Adding a method to the engine to serve a
46
+ * host-side convenience would move the core export surface for nothing.
47
+ */
48
+ readonly state: {
49
+ readonly messages: readonly Message[];
50
+ };
32
51
  }
33
52
  export type SubagentAgentFactory = (config: AgentConfig) => SubagentAgentLike;
34
53
  /** The default factory: a real `Agent` from `@aragon-agent/core`. */
@@ -91,6 +110,17 @@ export interface SubagentDeps {
91
110
  ref: ModelRef;
92
111
  thinkingLevel: ThinkingLevel;
93
112
  };
113
+ /**
114
+ * Build this child its own context manager
115
+ * (context-auto-compaction-hardening §3.4.3 / W3).
116
+ *
117
+ * ABSENT means children get none, and `subagent.ts` then spreads NO
118
+ * `contextManager` key at all - so the child's loop gate tests a genuinely
119
+ * undefined field and a `compaction.subagents: false` session is byte-identical
120
+ * to round 1 (AC-H10). `AgentController` supplies it from
121
+ * `CompactionWiring.childFactory()`, which returns `null` when the key is off.
122
+ */
123
+ contextManagerFor?: ChildContextManagerProvider;
94
124
  }
95
125
  export interface SubagentHooks {
96
126
  /** Coalesced at the source (§5.2); phase transitions always pass. */
@@ -183,22 +183,40 @@ export function createSubagent(spec, deps, hooks) {
183
183
  thinkingLevel: deps.config.thinkingLevel,
184
184
  };
185
185
  const model = resolved.ref;
186
- const agent = deps.agentFactory({
187
- systemPrompt: buildSystemPrompt({
188
- cwd: deps.getCwd(),
189
- tools,
190
- skillsBlock: deps.alwaysBlock ?? '',
191
- agentMode: spec.readOnly ? 'plan' : deps.getMode(),
192
- // A child has no overlay, so the plan block must describe the headless
193
- // shape: write the plan as the final message rather than calling tools it
194
- // does not have.
195
- planInteractive: false,
196
- subagentBlock: buildSubagentBlock({
197
- label: spec.label,
198
- description: spec.description,
199
- peers: deps.bus.peersOf(spec.label),
200
- }),
186
+ // HOISTED TO A LOCAL, AND THAT IS WHAT MAKES W3 POSSIBLE AT ALL. This was built
187
+ // INLINE inside the `agentFactory({...})` argument, so there was nothing for the
188
+ // child manager's `getSystemPrompt` accessor to close over - and the accessor
189
+ // cannot read it back off the agent, because `SubagentAgentLike` is narrowed to
190
+ // `state.messages` on purpose.
191
+ const systemPrompt = buildSystemPrompt({
192
+ cwd: deps.getCwd(),
193
+ tools,
194
+ skillsBlock: deps.alwaysBlock ?? '',
195
+ agentMode: spec.readOnly ? 'plan' : deps.getMode(),
196
+ // A child has no overlay, so the plan block must describe the headless
197
+ // shape: write the plan as the final message rather than calling tools it
198
+ // does not have.
199
+ planInteractive: false,
200
+ subagentBlock: buildSubagentBlock({
201
+ label: spec.label,
202
+ description: spec.description,
203
+ peers: deps.bus.peersOf(spec.label),
201
204
  }),
205
+ });
206
+ // LAZY ACCESSORS OVER `agentRef`, WHICH IS ASSIGNED AFTER THIS RETURNS. A
207
+ // non-lazy read here is `undefined` at construction and the child's estimate
208
+ // branch would silently measure an empty history - CR-1 one module over.
209
+ const contextManager = deps.contextManagerFor?.({
210
+ label: spec.label,
211
+ model,
212
+ getMessages: () => agentRef?.state.messages ?? [],
213
+ getSystemPrompt: () => systemPrompt,
214
+ onCompacted: () => {
215
+ run.compactions = (run.compactions ?? 0) + 1;
216
+ },
217
+ });
218
+ const agent = deps.agentFactory({
219
+ systemPrompt,
202
220
  model,
203
221
  tools,
204
222
  thinkingLevel: resolved.thinkingLevel,
@@ -217,6 +235,11 @@ export function createSubagent(spec, deps, hooks) {
217
235
  // explicitly.
218
236
  idleTimeout: deps.config.idleTimeoutMs,
219
237
  },
238
+ // SPREAD CONDITIONALLY, never `contextManager: deps.contextManagerFor?.(...)`.
239
+ // With the feature off the options bag has no such key, so the loop's gate
240
+ // tests a field that is genuinely `undefined` rather than a property holding
241
+ // one - the identical argument `AgentController` makes for the lead (AC-H10).
242
+ ...(contextManager ? { contextManager } : {}),
220
243
  });
221
244
  agentRef = agent;
222
245
  let lastEmit = 0;
@@ -289,6 +312,10 @@ export function createSubagent(spec, deps, hooks) {
289
312
  outputTokens: run.usage.outputTokens + event.usage.outputTokens,
290
313
  };
291
314
  hooks.onUsage(spec.label, event.usage);
315
+ // W1's delta needs the history LENGTH the measurement covered, and the
316
+ // port carries no turn hook. `turn_end` is emitted BEFORE the assistant
317
+ // push, so this is exactly the history the reported `usage` describes.
318
+ contextManager?.onTurnEnd(event.usage, agentRef?.state.messages ?? [], systemPrompt);
292
319
  const text = assistantText(event.message);
293
320
  // The LAST non-empty assistant text wins, which is exactly the child's
294
321
  // final message once the loop stops calling tools. Capturing it here