@aragon-agent/cli 0.6.38 → 0.6.40

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 (209) hide show
  1. package/CHANGELOG.md +263 -0
  2. package/README.md +544 -11
  3. package/dist/agent/controller.d.ts +245 -10
  4. package/dist/agent/controller.js +367 -22
  5. package/dist/agent/model-profile-settings.js +12 -1
  6. package/dist/agent/plan-prompt.d.ts +1 -3
  7. package/dist/agent/plan-prompt.js +13 -7
  8. package/dist/agent/reducer.d.ts +70 -2
  9. package/dist/agent/reducer.js +59 -6
  10. package/dist/agent/skill-triage-prompt.d.ts +42 -0
  11. package/dist/agent/skill-triage-prompt.js +74 -0
  12. package/dist/agent/system-prompt.d.ts +28 -2
  13. package/dist/agent/system-prompt.js +12 -4
  14. package/dist/cli.js +106 -32
  15. package/dist/commands/builtins.js +24 -3
  16. package/dist/commands/keys-command.d.ts +23 -0
  17. package/dist/commands/keys-command.js +38 -0
  18. package/dist/commands/registry.d.ts +39 -0
  19. package/dist/commands/skills.js +86 -1
  20. package/dist/commands/subagent-command.d.ts +25 -0
  21. package/dist/commands/subagent-command.js +242 -0
  22. package/dist/commands/task-command.d.ts +39 -0
  23. package/dist/commands/task-command.js +102 -0
  24. package/dist/compaction/command.js +16 -3
  25. package/dist/compaction/compactor.d.ts +25 -0
  26. package/dist/compaction/compactor.js +111 -10
  27. package/dist/compaction/digest.d.ts +24 -1
  28. package/dist/compaction/digest.js +139 -17
  29. package/dist/compaction/limits.d.ts +21 -0
  30. package/dist/compaction/limits.js +21 -0
  31. package/dist/compaction/reason-text.d.ts +36 -0
  32. package/dist/compaction/reason-text.js +78 -0
  33. package/dist/compaction/types.d.ts +82 -14
  34. package/dist/compaction/wiring.d.ts +13 -8
  35. package/dist/compaction/wiring.js +1 -0
  36. package/dist/config/cli-commands.d.ts +23 -0
  37. package/dist/config/cli-commands.js +36 -0
  38. package/dist/config/env.js +17 -4
  39. package/dist/config/load.js +55 -3
  40. package/dist/config/schema.d.ts +125 -10
  41. package/dist/config/schema.js +138 -14
  42. package/dist/config/store.js +23 -3
  43. package/dist/diagnostics/doctor.d.ts +9 -0
  44. package/dist/diagnostics/doctor.js +34 -0
  45. package/dist/diagnostics/info.d.ts +71 -0
  46. package/dist/diagnostics/info.js +91 -5
  47. package/dist/exec/bridge/caller-tools.d.ts +96 -0
  48. package/dist/exec/bridge/caller-tools.js +213 -0
  49. package/dist/exec/bridge/decisions.d.ts +78 -0
  50. package/dist/exec/bridge/decisions.js +116 -0
  51. package/dist/exec/bridge/human-answers.d.ts +38 -0
  52. package/dist/exec/bridge/human-answers.js +164 -0
  53. package/dist/exec/bridge/limits.d.ts +68 -0
  54. package/dist/exec/bridge/limits.js +68 -0
  55. package/dist/exec/bridge/ndjson.d.ts +17 -0
  56. package/dist/exec/bridge/ndjson.js +83 -0
  57. package/dist/exec/bridge/pending.d.ts +70 -0
  58. package/dist/exec/bridge/pending.js +132 -0
  59. package/dist/exec/bridge/protocol.d.ts +346 -0
  60. package/dist/exec/bridge/protocol.js +256 -0
  61. package/dist/exec/bridge/server.d.ts +142 -0
  62. package/dist/exec/bridge/server.js +502 -0
  63. package/dist/exec/bridge/source.d.ts +58 -0
  64. package/dist/exec/bridge/source.js +145 -0
  65. package/dist/exec/bridge/writer.d.ts +26 -0
  66. package/dist/exec/bridge/writer.js +156 -0
  67. package/dist/exec/cli-commands.js +30 -2
  68. package/dist/exec/emitter.d.ts +16 -2
  69. package/dist/exec/emitter.js +26 -6
  70. package/dist/exec/events.d.ts +465 -10
  71. package/dist/exec/events.js +47 -1
  72. package/dist/exec/hooks/dispatch.d.ts +76 -0
  73. package/dist/exec/hooks/dispatch.js +169 -0
  74. package/dist/exec/hooks/index.d.ts +83 -0
  75. package/dist/exec/hooks/index.js +205 -0
  76. package/dist/exec/hooks/limits.d.ts +56 -0
  77. package/dist/exec/hooks/limits.js +49 -0
  78. package/dist/exec/hooks/registry.d.ts +49 -0
  79. package/dist/exec/hooks/registry.js +91 -0
  80. package/dist/exec/hooks/tool-wrapper.d.ts +94 -0
  81. package/dist/exec/hooks/tool-wrapper.js +154 -0
  82. package/dist/exec/hooks/turn.d.ts +61 -0
  83. package/dist/exec/hooks/turn.js +59 -0
  84. package/dist/exec/hooks/validate.d.ts +85 -0
  85. package/dist/exec/hooks/validate.js +310 -0
  86. package/dist/exec/index.d.ts +130 -4
  87. package/dist/exec/index.js +501 -10
  88. package/dist/exec/options.d.ts +124 -2
  89. package/dist/exec/options.js +413 -6
  90. package/dist/exec/permission.d.ts +9 -1
  91. package/dist/exec/permission.js +7 -1
  92. package/dist/exec/request/limits.d.ts +51 -0
  93. package/dist/exec/request/limits.js +49 -0
  94. package/dist/exec/request/scope.d.ts +106 -0
  95. package/dist/exec/request/scope.js +149 -0
  96. package/dist/exec/result/limits.d.ts +74 -0
  97. package/dist/exec/result/limits.js +72 -0
  98. package/dist/exec/result/prompt.d.ts +28 -0
  99. package/dist/exec/result/prompt.js +74 -0
  100. package/dist/exec/result/schema.d.ts +99 -0
  101. package/dist/exec/result/schema.js +464 -0
  102. package/dist/exec/result/sink.d.ts +56 -0
  103. package/dist/exec/result/sink.js +50 -0
  104. package/dist/exec/result/tool.d.ts +37 -0
  105. package/dist/exec/result/tool.js +118 -0
  106. package/dist/exec/runner.d.ts +395 -1
  107. package/dist/exec/runner.js +860 -8
  108. package/dist/exec/threads/aggregate.d.ts +34 -0
  109. package/dist/exec/threads/aggregate.js +30 -0
  110. package/dist/exec/threads/capacity.d.ts +23 -0
  111. package/dist/exec/threads/capacity.js +38 -0
  112. package/dist/exec/threads/dispatch-source.d.ts +60 -0
  113. package/dist/exec/threads/dispatch-source.js +106 -0
  114. package/dist/exec/threads/execute-threads.d.ts +53 -0
  115. package/dist/exec/threads/execute-threads.js +234 -0
  116. package/dist/exec/threads/gate.d.ts +42 -0
  117. package/dist/exec/threads/gate.js +81 -0
  118. package/dist/exec/threads/identity.d.ts +36 -0
  119. package/dist/exec/threads/identity.js +67 -0
  120. package/dist/exec/threads/interrupt.d.ts +29 -0
  121. package/dist/exec/threads/interrupt.js +79 -0
  122. package/dist/exec/threads/limits.d.ts +58 -0
  123. package/dist/exec/threads/limits.js +35 -0
  124. package/dist/exec/threads/outcome.d.ts +47 -0
  125. package/dist/exec/threads/outcome.js +26 -0
  126. package/dist/exec/threads/registry.d.ts +81 -0
  127. package/dist/exec/threads/registry.js +427 -0
  128. package/dist/exec/threads/runtime.d.ts +107 -0
  129. package/dist/exec/threads/runtime.js +523 -0
  130. package/dist/exec/threads/thread-source.d.ts +20 -0
  131. package/dist/exec/threads/thread-source.js +134 -0
  132. package/dist/fast/limits.d.ts +54 -0
  133. package/dist/fast/limits.js +54 -0
  134. package/dist/fast/summary-call.d.ts +72 -0
  135. package/dist/fast/summary-call.js +133 -0
  136. package/dist/fast/summary-prompt.d.ts +106 -0
  137. package/dist/fast/summary-prompt.js +320 -0
  138. package/dist/fast/task-summary-types.d.ts +66 -0
  139. package/dist/fast/task-summary-types.js +17 -0
  140. package/dist/logging/logger.d.ts +1 -1
  141. package/dist/proc/limits.d.ts +7 -0
  142. package/dist/proc/limits.js +7 -0
  143. package/dist/proc/log-mirror.d.ts +21 -4
  144. package/dist/proc/log-mirror.js +29 -7
  145. package/dist/proc/output-streams.d.ts +50 -0
  146. package/dist/proc/output-streams.js +54 -0
  147. package/dist/skills/service.d.ts +41 -0
  148. package/dist/skills/service.js +62 -0
  149. package/dist/skills/triage.d.ts +194 -0
  150. package/dist/skills/triage.js +393 -0
  151. package/dist/team/forced-dispatch.d.ts +95 -0
  152. package/dist/team/forced-dispatch.js +208 -0
  153. package/dist/team/limits.d.ts +21 -1
  154. package/dist/team/limits.js +21 -1
  155. package/dist/team/normalize.d.ts +15 -0
  156. package/dist/team/normalize.js +21 -4
  157. package/dist/team/prompt.d.ts +6 -3
  158. package/dist/team/prompt.js +9 -5
  159. package/dist/team/runtime.d.ts +9 -0
  160. package/dist/team/runtime.js +8 -0
  161. package/dist/team/subagent.d.ts +15 -0
  162. package/dist/team/subagent.js +45 -1
  163. package/dist/team/task-tool.js +11 -7
  164. package/dist/tools/bash-tool.d.ts +10 -0
  165. package/dist/tools/bash-tool.js +71 -6
  166. package/dist/tools/human-input.d.ts +35 -2
  167. package/dist/tools/human-input.js +38 -0
  168. package/dist/tools/index.d.ts +93 -1
  169. package/dist/tools/index.js +112 -3
  170. package/dist/tools/plan-tools.d.ts +20 -12
  171. package/dist/tools/plan-tools.js +24 -17
  172. package/dist/tools/tool-gate.d.ts +100 -0
  173. package/dist/tools/tool-gate.js +87 -0
  174. package/dist/ui/App.js +238 -12
  175. package/dist/ui/Header.d.ts +28 -16
  176. package/dist/ui/Header.js +21 -19
  177. package/dist/ui/StatusBar.d.ts +1 -0
  178. package/dist/ui/StatusBar.js +1 -0
  179. package/dist/ui/Transcript.js +1 -1
  180. package/dist/ui/entries/CompactionCard.d.ts +11 -1
  181. package/dist/ui/entries/CompactionCard.js +65 -4
  182. package/dist/ui/interaction-hints.js +6 -0
  183. package/dist/ui/layout/OverlayFrame.d.ts +18 -0
  184. package/dist/ui/layout/OverlayFrame.js +19 -4
  185. package/dist/ui/layout/header-hints.d.ts +55 -62
  186. package/dist/ui/layout/header-hints.js +59 -166
  187. package/dist/ui/layout/header-left.d.ts +115 -0
  188. package/dist/ui/layout/header-left.js +206 -0
  189. package/dist/ui/layout/shortcut-panel-layout.d.ts +191 -0
  190. package/dist/ui/layout/shortcut-panel-layout.js +321 -0
  191. package/dist/ui/layout/status-detail-layout.js +5 -1
  192. package/dist/ui/layout/status-layout.d.ts +10 -0
  193. package/dist/ui/layout/status-layout.js +18 -1
  194. package/dist/ui/layout/task-summary-cell.d.ts +61 -0
  195. package/dist/ui/layout/task-summary-cell.js +65 -0
  196. package/dist/ui/overlays/HelpOverlay.d.ts +12 -0
  197. package/dist/ui/overlays/HelpOverlay.js +29 -2
  198. package/dist/ui/overlays/SettingsScreen.js +29 -5
  199. package/dist/ui/overlays/ShortcutPanel.d.ts +39 -0
  200. package/dist/ui/overlays/ShortcutPanel.js +106 -0
  201. package/dist/ui/overlays/TeamFanoutPage.d.ts +35 -0
  202. package/dist/ui/overlays/TeamFanoutPage.js +78 -0
  203. package/dist/ui/overlays/model-profile-state.d.ts +19 -1
  204. package/dist/ui/overlays/model-profile-state.js +12 -0
  205. package/dist/ui/overlays/team-fanout-state.d.ts +73 -0
  206. package/dist/ui/overlays/team-fanout-state.js +97 -0
  207. package/dist/ui/shortcuts.d.ts +81 -0
  208. package/dist/ui/shortcuts.js +221 -0
  209. package/package.json +2 -2
@@ -0,0 +1,320 @@
1
+ /**
2
+ * The task-summary prompt, its output normalisation, the language check and the
3
+ * deterministic floor (tui-shortcut-drawer-and-task-summary §4.4 / §6.2).
4
+ *
5
+ * ASCII ONLY: `src/fast/**` is inside the glyph scanner's scope. Every string in
6
+ * this file is English prose that goes to the model or into a code branch; the
7
+ * only non-ASCII the runtime ever sees arrives from the USER's message, which is
8
+ * data, not a literal.
9
+ *
10
+ * THE FLOOR NEVER LIES (D-9). Nine failure modes converge on one function,
11
+ * `localTaskSummary`, which returns a normalised prefix of the user's own words.
12
+ * That is the one outcome that cannot be wrong: the language matches by
13
+ * construction, nothing is invented, and the header cell is never empty. A model
14
+ * label that disagrees with the user's language is treated as a failure rather
15
+ * than displayed, because a Chinese task shown to a Chinese user as English is
16
+ * worse than their own sentence clipped.
17
+ */
18
+ import stringWidth from 'string-width';
19
+ import { TASK_SUMMARY_LIMITS, TASK_SUMMARY_PROMPT_VERSION } from './limits.js';
20
+ /**
21
+ * The system prompt. ONE template, versioned by `TASK_SUMMARY_PROMPT_VERSION` so
22
+ * a behaviour report can be tied to a wording revision with one grep.
23
+ *
24
+ * THE RULES ARE SHORT ON PURPOSE: a summary model that has to reason about its
25
+ * instructions spends the whole 64-token budget on them. Every line here
26
+ * corresponds to one branch in `interpretSummary`/`normalizeTaskSummary`, so
27
+ * nothing is asked for that the code cannot check.
28
+ */
29
+ function buildSystemPrompt() {
30
+ return [
31
+ "You write a one-line label for the user's task, shown in a terminal header.",
32
+ `(prompt ${TASK_SUMMARY_PROMPT_VERSION})`,
33
+ '',
34
+ 'Rules:',
35
+ '- Reply with the label ONLY. No quotes, no markdown, no trailing punctuation.',
36
+ "- Use the SAME LANGUAGE as the user's message.",
37
+ '- At most 6 words in space-separated languages; at most 12 characters for',
38
+ ' Chinese/Japanese/Korean.',
39
+ '- Describe what the user wants done, not how it will be done.',
40
+ '- Never invent details that are not in the message.',
41
+ ].join('\n');
42
+ }
43
+ /**
44
+ * Clip the anchor to `anchorMaxChars` and wrap it in `<task>`.
45
+ *
46
+ * THE WRAPPER IS NOT DECORATION: without it a prompt that says "ignore the above
47
+ * and reply OK" is indistinguishable from an instruction, and the one thing this
48
+ * feature must never do is turn a user's message into a second instruction
49
+ * channel for its own label. The tag tells the model where the data starts and
50
+ * ends.
51
+ */
52
+ function buildUserMessage(anchor) {
53
+ const clipped = anchor.slice(0, TASK_SUMMARY_LIMITS.anchorMaxChars);
54
+ return `<task>\n${clipped}\n</task>`;
55
+ }
56
+ /**
57
+ * The request one summary sends. No tools, no thinking, one bounded message.
58
+ *
59
+ * `modelLimits` is deliberately NOT set: the fast path uses the same output-cap
60
+ * authority as every other call (`resolveOutputTokens`), and hand-setting a
61
+ * second ceiling here is how the two would drift.
62
+ */
63
+ export function buildTaskSummaryRequest(input) {
64
+ return {
65
+ model: input.modelId,
66
+ ...(input.baseUrl ? { baseUrl: input.baseUrl } : {}),
67
+ apiKey: input.apiKey,
68
+ systemPrompt: buildSystemPrompt(),
69
+ messages: [{ role: 'user', content: buildUserMessage(input.anchor), timestamp: input.now }],
70
+ maxTokens: TASK_SUMMARY_LIMITS.outputTokens,
71
+ // PAIRED WITH `temperature: 0`, exactly as `review-call.ts` records: the
72
+ // Anthropic adapter deletes `temperature` whenever a thinking budget is set,
73
+ // so these two are one choice and not two.
74
+ thinkingLevel: 'off',
75
+ temperature: 0,
76
+ ...(input.signal ? { signal: input.signal } : {}),
77
+ };
78
+ }
79
+ /** ESC plus one CSI sequence (colours, cursor moves, OSC titles end with BEL). */
80
+ const ANSI_PATTERN = /\u001b\[[0-9;?]*[ -/]*[@-~]|\u001b\][^\u0007]*\u0007/g;
81
+ /** A leading label prefix: `Label:`, `Task:`, `Summary:` in any case. */
82
+ const LABEL_PREFIX = /^(?:label|task|summary)\s*[:\-]\s*/i;
83
+ /** Characters that mark "this is the whole label", never part of it. */
84
+ const WRAPPERS = ['"', "'", '`', '<task>', '</task>', '**'];
85
+ /** SCRIPT-AWARE LETTER COUNTS. One pass, no regex per script, so `dominantScript` is cheap. */
86
+ const CJK_RANGES = [
87
+ [0x3040, 0x30ff], // Hiragana + Katakana
88
+ [0x3400, 0x4dbf], // CJK Extension A
89
+ [0x4e00, 0x9fff], // CJK Unified Ideographs
90
+ [0xac00, 0xd7af], // Hangul syllables
91
+ [0xf900, 0xfaff], // CJK compatibility ideographs
92
+ [0xff66, 0xff9d], // Halfwidth katakana
93
+ ];
94
+ function isCjkCodePoint(code) {
95
+ return CJK_RANGES.some(([lo, hi]) => code >= lo && code <= hi);
96
+ }
97
+ function isLatinLetter(code) {
98
+ return (code >= 0x41 && code <= 0x5a)
99
+ || (code >= 0x61 && code <= 0x7a)
100
+ // Latin-1 supplement, Latin Extended-A/B: accented European text is Latin.
101
+ || (code >= 0xc0 && code <= 0x24f && code !== 0xd7 && code !== 0xf7);
102
+ }
103
+ /**
104
+ * `cjk` | `latin` | `other`, by LETTER majority.
105
+ *
106
+ * DIGITS AND PUNCTUATION ARE NOT LETTERS (the spec's wording) - a message that is
107
+ * only `pwd`'s output would otherwise be classified by its spaces. `other` is a
108
+ * real answer, not a failure: it means "the language rule cannot be checked",
109
+ * and the caller treats it as "no opinion" instead of a mismatch.
110
+ */
111
+ export function dominantScript(text) {
112
+ let cjk = 0;
113
+ let latin = 0;
114
+ for (const char of text) {
115
+ const code = char.codePointAt(0) ?? 0;
116
+ if (isCjkCodePoint(code))
117
+ cjk += 1;
118
+ else if (isLatinLetter(code))
119
+ latin += 1;
120
+ }
121
+ const letters = cjk + latin;
122
+ if (letters === 0)
123
+ return 'other';
124
+ if (cjk / letters >= 0.5)
125
+ return 'cjk';
126
+ if (latin / letters >= 0.5)
127
+ return 'latin';
128
+ return 'other';
129
+ }
130
+ /** Count grapheme-ish clusters: one per code point, which is what the prompt says. */
131
+ function graphemes(text) {
132
+ return Array.from(text);
133
+ }
134
+ /**
135
+ * Clean one raw model reply into a displayable label.
136
+ *
137
+ * THE FIVE STEPS ARE SEPARATE AND IN THIS ORDER, so each is independently
138
+ * assertable (v2 P1-8):
139
+ *
140
+ * 1. strip ANSI escapes, then every remaining control character;
141
+ * 2. collapse every whitespace run (newlines included) to ONE space and trim;
142
+ * 3. strip a leading label prefix and any wrapping quotes / backticks / markers;
143
+ * 4. strip trailing punctuation, ASCII and CJK full-width;
144
+ * 5. cap by script - words to `summaryMaxWords` OR graphemes to
145
+ * `cjkMaxGraphemes` - then hard-cap characters at `summaryMaxChars`.
146
+ *
147
+ * Returns `null` when nothing survives; the caller reports that as `bad-output`
148
+ * and falls back. A refusal ("I cannot help with that") normalises to a non-empty
149
+ * string, so the LANGUAGE check is what rejects it - which is why step 5 runs
150
+ * before step 6 in `interpretSummary` rather than here.
151
+ */
152
+ export function normalizeTaskSummary(raw) {
153
+ if (typeof raw !== 'string')
154
+ return null;
155
+ // 1. ANSI first: an escape sequence contains '[', ';' and a letter, so
156
+ // stripping it later would leave parts of it behind as "words".
157
+ let text = raw.replace(ANSI_PATTERN, '');
158
+ // eslint-disable-next-line no-control-regex
159
+ text = text.replace(/[\u0000-\u001f\u007f]/g, ' ');
160
+ // 2. One line, no runs.
161
+ text = text.replace(/\s+/g, ' ').trim();
162
+ // 3. Prefix, then wrappers (a model may answer `"Task: fix the build"`).
163
+ text = text.replace(LABEL_PREFIX, '');
164
+ for (const wrapper of WRAPPERS) {
165
+ text = stripWrapper(text, wrapper);
166
+ }
167
+ text = text.trim().replace(/\s+/g, ' ');
168
+ // 4. Trailing punctuation, ASCII and the full-width forms the prompt forbids.
169
+ text = text.replace(/[.,;:!?]+$/u, '');
170
+ text = text.replace(/[\u3002\uff0c\uff1b\uff1a\uff01\uff1f\u3001]+$/gu, '');
171
+ text = text.trim();
172
+ if (text.length === 0)
173
+ return null;
174
+ // 5. Script-aware cap, then the character ceiling.
175
+ const span = dominantScript(text);
176
+ if (span === 'cjk') {
177
+ const chars = graphemes(text);
178
+ if (chars.length > TASK_SUMMARY_LIMITS.cjkMaxGraphemes) {
179
+ text = chars.slice(0, TASK_SUMMARY_LIMITS.cjkMaxGraphemes).join('');
180
+ }
181
+ }
182
+ else {
183
+ const words = text.split(' ');
184
+ if (words.length > TASK_SUMMARY_LIMITS.summaryMaxWords) {
185
+ text = words.slice(0, TASK_SUMMARY_LIMITS.summaryMaxWords).join(' ');
186
+ }
187
+ }
188
+ if (text.length > TASK_SUMMARY_LIMITS.summaryMaxChars) {
189
+ text = text.slice(0, TASK_SUMMARY_LIMITS.summaryMaxChars);
190
+ }
191
+ text = text.trim();
192
+ return text.length === 0 ? null : text;
193
+ }
194
+ /** Remove one wrapper character/sequence from both ends when present. */
195
+ function stripWrapper(text, wrapper) {
196
+ let out = text;
197
+ if (out.startsWith(wrapper))
198
+ out = out.slice(wrapper.length);
199
+ if (out.endsWith(wrapper))
200
+ out = out.slice(0, out.length - wrapper.length);
201
+ return out.trim();
202
+ }
203
+ /**
204
+ * The deterministic floor: a normalised verbatim prefix of the anchor.
205
+ *
206
+ * WHY IT IS SCRIPT-AWARE: a CJK sentence has no spaces, so a word-based clip
207
+ * would keep the first 8 characters and add an ellipsis - unreadable. CJK is
208
+ * clipped by graphemes instead, which is the same unit the prompt constrains.
209
+ *
210
+ * THE ELLIPSIS IS PASSED IN (`glyphs.ellipsis`) because this module may not
211
+ * spell a non-ASCII glyph (the scanner's rule); the caller supplies the one the
212
+ * terminal can actually render.
213
+ */
214
+ export function localTaskSummary(anchor, span, ellipsis) {
215
+ const flat = anchor.replace(/\s+/g, ' ').trim();
216
+ if (flat.length === 0)
217
+ return null;
218
+ const chars = graphemes(flat);
219
+ // CJK: `cjkMaxGraphemes` clusters, which is what a reader can scan in a header.
220
+ if (span === 'cjk') {
221
+ if (chars.length <= TASK_SUMMARY_LIMITS.cjkMaxGraphemes)
222
+ return flat;
223
+ return chars.slice(0, TASK_SUMMARY_LIMITS.cjkMaxGraphemes).join('') + ellipsis;
224
+ }
225
+ const words = flat.split(' ');
226
+ if (words.length <= TASK_SUMMARY_LIMITS.summaryMaxWords) {
227
+ const clipped = chars.length <= TASK_SUMMARY_LIMITS.summaryMaxChars
228
+ ? flat
229
+ : chars.slice(0, TASK_SUMMARY_LIMITS.summaryMaxChars).join('').trimEnd();
230
+ return clipped;
231
+ }
232
+ return words.slice(0, TASK_SUMMARY_LIMITS.summaryMaxWords).join(' ') + ellipsis;
233
+ }
234
+ /** The label as the reducer stores it: never wider than `maxCells`. */
235
+ function fitLabel(text, ellipsis) {
236
+ if (stringWidth(text) <= TASK_SUMMARY_LIMITS.maxCells)
237
+ return text;
238
+ const mark = stringWidth(ellipsis) < TASK_SUMMARY_LIMITS.maxCells ? ellipsis : '';
239
+ const room = TASK_SUMMARY_LIMITS.maxCells - stringWidth(mark);
240
+ let out = '';
241
+ let used = 0;
242
+ for (const char of graphemes(text)) {
243
+ const width = stringWidth(char);
244
+ if (used + width > room)
245
+ break;
246
+ out += char;
247
+ used += width;
248
+ }
249
+ return `${out}${mark}`;
250
+ }
251
+ /** Build the fallback view, or `null` when the user switched the floor off. */
252
+ export function fallbackView(input) {
253
+ if (!input.fallbackEnabled)
254
+ return { view: null };
255
+ const span = dominantScript(input.anchor);
256
+ const local = localTaskSummary(input.anchor, span, input.ellipsis);
257
+ if (local === null)
258
+ return { view: null };
259
+ return {
260
+ view: {
261
+ text: fitLabel(local, input.ellipsis),
262
+ tone: 'fallback',
263
+ reason: input.reason,
264
+ script: span,
265
+ at: input.now,
266
+ by: '',
267
+ },
268
+ };
269
+ }
270
+ /**
271
+ * Assemble the outcome from raw model text, applying D-9.
272
+ *
273
+ * ORDER MATTERS: normalise, then compare scripts, then decide. A model label in
274
+ * the wrong language is a FAILURE (`language-mismatch`), not something to
275
+ * display with a caveat - the fallback is the same length, in the right
276
+ * language, and true by construction.
277
+ *
278
+ * AND IT IS NOT RETRIED. A second call costs money and 1-3 seconds to reach an
279
+ * answer the fallback already gives correctly.
280
+ */
281
+ export function interpretSummary(input) {
282
+ const normalised = normalizeTaskSummary(input.raw);
283
+ if (normalised === null) {
284
+ return fallbackView({ ...input, reason: 'bad-output' });
285
+ }
286
+ const anchorSpan = dominantScript(input.anchor);
287
+ const summarySpan = dominantScript(normalised);
288
+ // `other` NEVER MISMATCHES: a label with no letters at all cannot be in the
289
+ // wrong language, and refusing it would be a complaint the user cannot act on.
290
+ const mismatched = summarySpan !== 'other'
291
+ && anchorSpan !== 'other'
292
+ && summarySpan !== anchorSpan;
293
+ if (mismatched) {
294
+ return fallbackView({ ...input, reason: 'language-mismatch' });
295
+ }
296
+ return {
297
+ view: {
298
+ text: fitLabel(normalised, input.ellipsis),
299
+ tone: 'model',
300
+ reason: null,
301
+ script: anchorSpan,
302
+ at: input.now,
303
+ by: input.by,
304
+ },
305
+ };
306
+ }
307
+ /** The assistant text of a completion, or `''` for a tool-only answer. */
308
+ export function summaryText(message) {
309
+ if (!message || !Array.isArray(message.content))
310
+ return '';
311
+ return message.content
312
+ .filter((block) => block.type === 'text')
313
+ .map((block) => block.text)
314
+ .join('')
315
+ .trim();
316
+ }
317
+ /** One model identity string, as the drawer's status line prints it. */
318
+ export function summaryBy(providerId, modelId) {
319
+ return `${providerId}:${modelId}`;
320
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * The task-summary types (tui-shortcut-drawer-and-task-summary §6.1).
3
+ *
4
+ * ASCII ONLY: `src/fast/**` is inside the glyph scanner's scope.
5
+ *
6
+ * WHY THIS FILE EXISTS AT ALL (v2 P1-3). `fast/summary-call.ts` and
7
+ * `fast/summary-prompt.ts` produce and consume these shapes, and the header cell,
8
+ * the reducer and the controller only display them. Declaring them in
9
+ * `agent/controller.ts` - the v1 plan - would make `fast/` import `agent/` while
10
+ * `agent/` imports `fast/`, and `.claude-index/config.md` already records one
11
+ * real cycle in this package (`agent <-> compaction`). One more is not a style
12
+ * question: a cycle makes the import graph unusable for ordering, and it is
13
+ * exactly what `shortcut-table.test.ts` scans for. So the types live DOWN here,
14
+ * in the lowest layer that needs them, and `agent/**` reaches them with
15
+ * `import type` only.
16
+ */
17
+ /** How the label was produced. `fallback` is the user's own words, never a guess. */
18
+ export type TaskSummaryTone = 'model' | 'fallback';
19
+ /**
20
+ * The dominant character class of a string, by letter majority.
21
+ *
22
+ * A NAMED UNION rather than the free-form `Script` v1 mentioned (and never
23
+ * defined, P1-8). Only three answers are actionable: the label must be CJK, must
24
+ * be Latin, or is neither (a prompt in Cyrillic, or only punctuation).
25
+ */
26
+ export type TaskSummarySpan = 'cjk' | 'latin' | 'other';
27
+ /**
28
+ * Why a label is what it is.
29
+ *
30
+ * THE FIRST NINE VALUES ALL MEAN "the model did not give us a label" and every
31
+ * one of them converges on the SAME deterministic floor (D-9); they are kept
32
+ * apart only so the drawer and `/task` can say which one happened. `empty` is
33
+ * the tenth and means there was no floor to show either (fallback disabled).
34
+ */
35
+ export type TaskSummaryReason = 'tier-off' | 'no-model' | 'no-adapter' | 'no-key' | 'timeout' | 'aborted' | 'error' | 'bad-output' | 'language-mismatch' | 'empty';
36
+ /**
37
+ * The one task label a session has, as the header and the drawer read it.
38
+ *
39
+ * A PURE VIEW: it is never persisted, never enters the conversation history and
40
+ * never touches `config.json` (§0-2). Everything a surface needs to explain the
41
+ * label without a second lookup is on this record.
42
+ */
43
+ export interface TaskSummaryView {
44
+ /** Normalised, width-safe, at most `TASK_SUMMARY_LIMITS.maxCells` columns. */
45
+ text: string;
46
+ tone: TaskSummaryTone;
47
+ /** `null` exactly when `tone === 'model'`. */
48
+ reason: TaskSummaryReason | null;
49
+ /** The anchor's dominant script, so the drawer can disclose a mismatch. */
50
+ script: TaskSummarySpan;
51
+ /** Epoch ms of the attempt. The drawer renders `12s ago` from it, no clock of its own. */
52
+ at: number;
53
+ /** `provider:model` that produced it; `''` for a fallback. */
54
+ by: string;
55
+ }
56
+ /**
57
+ * What one attempt produced.
58
+ *
59
+ * `view: null` happens in exactly one case: the call failed AND
60
+ * `taskSummary.fallback` is false. That is a deliberate "show nothing" rather
61
+ * than an empty string, because an empty header cell would read as a broken
62
+ * feature (D-9).
63
+ */
64
+ export interface TaskSummaryOutcome {
65
+ view: TaskSummaryView | null;
66
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The task-summary types (tui-shortcut-drawer-and-task-summary §6.1).
3
+ *
4
+ * ASCII ONLY: `src/fast/**` is inside the glyph scanner's scope.
5
+ *
6
+ * WHY THIS FILE EXISTS AT ALL (v2 P1-3). `fast/summary-call.ts` and
7
+ * `fast/summary-prompt.ts` produce and consume these shapes, and the header cell,
8
+ * the reducer and the controller only display them. Declaring them in
9
+ * `agent/controller.ts` - the v1 plan - would make `fast/` import `agent/` while
10
+ * `agent/` imports `fast/`, and `.claude-index/config.md` already records one
11
+ * real cycle in this package (`agent <-> compaction`). One more is not a style
12
+ * question: a cycle makes the import graph unusable for ordering, and it is
13
+ * exactly what `shortcut-table.test.ts` scans for. So the types live DOWN here,
14
+ * in the lowest layer that needs them, and `agent/**` reaches them with
15
+ * `import type` only.
16
+ */
17
+ export {};
@@ -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' | 'compaction' | 'session' | 'branch';
26
+ export type LogScope = 'cli' | 'config' | 'agent' | 'tool' | 'skills' | 'llm' | 'migrate' | 'log' | 'fast' | 'history' | 'update' | 'compaction' | 'session' | 'branch' | 'bridge';
27
27
  export interface LogRecord {
28
28
  ts: string;
29
29
  lv: LogLevelName;
@@ -55,6 +55,13 @@ export declare const PROC_LIMITS: {
55
55
  * and a detached grandchild that inherited the pipe keeps it open after the
56
56
  * shell is long gone — so a tool that settles on `'close'` alone has a path
57
57
  * that never settles at all.
58
+ *
59
+ * IT IS A BOUND, NOT A VERDICT. Expiry says "stop waiting"; it does NOT say
60
+ * the pipe was held, and the note is only printed when the streams are
61
+ * observed still open at that moment (`proc/output-streams.ts` records the
62
+ * release-gate failure a timer-based verdict caused). The ordinary path does
63
+ * not even reach this bound: two ended streams after `'exit'` settle the call
64
+ * with the byte-identical body, one notification earlier.
58
65
  */
59
66
  readonly drainMs: 250;
60
67
  /**
@@ -55,6 +55,13 @@ export const PROC_LIMITS = {
55
55
  * and a detached grandchild that inherited the pipe keeps it open after the
56
56
  * shell is long gone — so a tool that settles on `'close'` alone has a path
57
57
  * that never settles at all.
58
+ *
59
+ * IT IS A BOUND, NOT A VERDICT. Expiry says "stop waiting"; it does NOT say
60
+ * the pipe was held, and the note is only printed when the streams are
61
+ * observed still open at that moment (`proc/output-streams.ts` records the
62
+ * release-gate failure a timer-based verdict caused). The ordinary path does
63
+ * not even reach this bound: two ended streams after `'exit'` settle the call
64
+ * with the byte-identical body, one notification earlier.
58
65
  */
59
66
  drainMs: 250,
60
67
  /**
@@ -9,7 +9,7 @@
9
9
  * `read_file` and `grep`): it is the full log on disk plus the PATH in the
10
10
  * truncation note.
11
11
  *
12
- * THREE RULES, each of which is about not becoming a new failure mode:
12
+ * THREE RULES PLUS A SCOPE, each of which is about not becoming a new failure mode:
13
13
  *
14
14
  * - ASYNC AND COALESCED. `appendFile` with a <=4 writes/second buffer, so a
15
15
  * webpack build emitting thousands of lines cannot turn logging into the
@@ -19,19 +19,36 @@
19
19
  * - FAILURES ARE DEBUG LOGS, NEVER THROWS. A full disk must not kill a training
20
20
  * run; the ring, the tools and the job state machine do not depend on this
21
21
  * module at all.
22
+ * - SCOPED TO ITS OWN ROOT. The retention sweep deletes session directories under
23
+ * the jobs root the CALLER names, never under a parent derived from it: a
24
+ * derived parent is other people's directory the moment the mirror is handed a
25
+ * scratch path.
22
26
  */
23
27
  export interface JobLogMirrorOptions {
24
28
  /** `${aragonHome}/jobs/${sessionTag}`. A closure so a test can hand in a temp dir. */
25
29
  root: () => string;
30
+ /**
31
+ * THE DIRECTORY WHOSE SESSION SUBDIRECTORIES MAY BE SWEPT - passed, never derived.
32
+ *
33
+ * `dirname(root())` used to stand in for this, and it was wrong in the only way that
34
+ * matters: for any caller whose `root()` does not sit exactly one level below the jobs
35
+ * directory (a scratch dir in a test is the OS temp root's child) it handed the sweep a
36
+ * directory full of OTHER people's data and deleted everything but the newest few
37
+ * entries. A release run paid 40 s for that sweep and lost the temp directories of the
38
+ * test files running beside it. Omitted means NO sweep at all: growth is bounded only
39
+ * for callers that name the root they own.
40
+ */
41
+ jobsRoot?: () => string;
26
42
  maxBytes?: number;
27
43
  flushMs?: number;
28
44
  }
29
45
  /**
30
46
  * Delete all but the newest `PROC_LIMITS.mirrorRetainDirs` session directories.
31
47
  *
32
- * BEST EFFORT AND SILENT. This runs once per process at first use, and its whole
33
- * job is to stop `~/.aragon-agent/jobs/` from growing without bound; a directory
34
- * that cannot be read is not a reason to refuse to start.
48
+ * BEST EFFORT AND SILENT. This runs once per mirror at first use, and its whole job is to
49
+ * stop `~/.aragon-agent/jobs/` from growing without bound; a directory that cannot be read
50
+ * is not a reason to refuse to start. `jobsRoot` MUST be the directory this module owns -
51
+ * see `JobLogMirrorOptions.jobsRoot` for what deriving it cost.
35
52
  */
36
53
  export declare function pruneJobLogSessions(jobsRoot: string): void;
37
54
  export declare class JobLogMirror {
@@ -9,7 +9,7 @@
9
9
  * `read_file` and `grep`): it is the full log on disk plus the PATH in the
10
10
  * truncation note.
11
11
  *
12
- * THREE RULES, each of which is about not becoming a new failure mode:
12
+ * THREE RULES PLUS A SCOPE, each of which is about not becoming a new failure mode:
13
13
  *
14
14
  * - ASYNC AND COALESCED. `appendFile` with a <=4 writes/second buffer, so a
15
15
  * webpack build emitting thousands of lines cannot turn logging into the
@@ -19,18 +19,34 @@
19
19
  * - FAILURES ARE DEBUG LOGS, NEVER THROWS. A full disk must not kill a training
20
20
  * run; the ring, the tools and the job state machine do not depend on this
21
21
  * module at all.
22
+ * - SCOPED TO ITS OWN ROOT. The retention sweep deletes session directories under
23
+ * the jobs root the CALLER names, never under a parent derived from it: a
24
+ * derived parent is other people's directory the moment the mirror is handed a
25
+ * scratch path.
22
26
  */
23
27
  import { appendFile, appendFileSync, mkdirSync, readdirSync, rmSync, statSync } from 'node:fs';
24
28
  import { existsSync } from 'node:fs';
25
29
  import { dirname, join } from 'node:path';
26
30
  import { getLogger } from '../logging/logger.js';
27
31
  import { PROC_LIMITS } from './limits.js';
32
+ /**
33
+ * True when `path` holds nothing but `<jobId>.log` files, or nothing at all.
34
+ *
35
+ * DEFENSE IN DEPTH, NOT THE PRIMARY GUARD. The primary guard is that the caller names
36
+ * the jobs root instead of having it derived. This one means a sweep handed a directory
37
+ * that is not a mirror root is a no-op rather than a data loss - an empty directory and a
38
+ * directory of job logs are the only two shapes this module ever creates.
39
+ */
40
+ function isMirrorDirectory(path) {
41
+ return readdirSync(path, { withFileTypes: true }).every((entry) => entry.isFile() && entry.name.endsWith('.log'));
42
+ }
28
43
  /**
29
44
  * Delete all but the newest `PROC_LIMITS.mirrorRetainDirs` session directories.
30
45
  *
31
- * BEST EFFORT AND SILENT. This runs once per process at first use, and its whole
32
- * job is to stop `~/.aragon-agent/jobs/` from growing without bound; a directory
33
- * that cannot be read is not a reason to refuse to start.
46
+ * BEST EFFORT AND SILENT. This runs once per mirror at first use, and its whole job is to
47
+ * stop `~/.aragon-agent/jobs/` from growing without bound; a directory that cannot be read
48
+ * is not a reason to refuse to start. `jobsRoot` MUST be the directory this module owns -
49
+ * see `JobLogMirrorOptions.jobsRoot` for what deriving it cost.
34
50
  */
35
51
  export function pruneJobLogSessions(jobsRoot) {
36
52
  try {
@@ -41,14 +57,16 @@ export function pruneJobLogSessions(jobsRoot) {
41
57
  .map((entry) => {
42
58
  const path = join(jobsRoot, entry.name);
43
59
  try {
44
- return { path, mtime: statSync(path).mtimeMs };
60
+ return { path, mtime: statSync(path).mtimeMs, prunable: isMirrorDirectory(path) };
45
61
  }
46
62
  catch {
47
- return { path, mtime: 0 };
63
+ return { path, mtime: 0, prunable: false };
48
64
  }
49
65
  })
50
66
  .sort((a, b) => b.mtime - a.mtime);
51
67
  for (const stale of dirs.slice(PROC_LIMITS.mirrorRetainDirs)) {
68
+ if (!stale.prunable)
69
+ continue;
52
70
  try {
53
71
  rmSync(stale.path, { recursive: true, force: true });
54
72
  }
@@ -107,7 +125,11 @@ export class JobLogMirror {
107
125
  this.files.set(jobId, { path, bytes, capped: bytes >= this.maxBytes() });
108
126
  if (!this.pruned) {
109
127
  this.pruned = true;
110
- pruneJobLogSessions(dirname(this.opts.root()));
128
+ // ONLY THE ROOT THE CALLER NAMED. See `JobLogMirrorOptions.jobsRoot` for why
129
+ // `dirname(root())` is not a substitute for it.
130
+ const jobsRoot = this.opts.jobsRoot?.();
131
+ if (jobsRoot !== undefined)
132
+ pruneJobLogSessions(jobsRoot);
111
133
  }
112
134
  }
113
135
  catch (err) {
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Ask the child's output streams whether anything is still arriving (W1,
3
+ * defence 1).
4
+ *
5
+ * WHY THIS EXISTS AT ALL, AND THE RELEASE-GATE FAILURE THAT PROVED IT.
6
+ *
7
+ * `bash` settles on `'close'`, which fires only once every stdio stream is
8
+ * closed - and a detached grandchild that inherited the pipe keeps it open after
9
+ * the shell is long gone, so the promise had a path that never settled. The
10
+ * drain window (`PROC_LIMITS.drainMs`) closes that: a quarter of a second after
11
+ * `'exit'`, settle anyway and stamp
12
+ *
13
+ * [note] the command exited but a background child is still holding its
14
+ * output stream.
15
+ *
16
+ * The first build decided that sentence by TIMER EXPIRY - whatever had not
17
+ * settled when the window closed was called a held pipe. That is a claim about
18
+ * STATE inferred from a clock, and under load the two disagree. The release gate
19
+ * caught it on `tool-output-store.test.ts` AC-30 ("`bash` returns the same bytes
20
+ * with and without a recorder"): the second call of `node -e
21
+ * "process.stdout.write(...)"` returned the note for a child that had printed
22
+ * two lines and exited immediately. The pipe was closed microseconds after
23
+ * `'exit'`; the event loop was busy enough (four workers, 400 files, ~200 s of
24
+ * transforms behind it) that the timers phase came round before libuv delivered
25
+ * the close notification, so a 250 ms timer won a race it was never meant to
26
+ * win - and the model was told a background child was holding its output stream
27
+ * when nothing was.
28
+ *
29
+ * Hence: READ THE STREAMS, DO NOT GUESS FROM THE CLOCK. A `Readable` has ended
30
+ * once it has emitted `'end'` (`readableEnded`) or been destroyed. While either
31
+ * child descriptor is still open the sentence above is literally true; the
32
+ * moment both have ended it is false no matter what a timer says. Callers use
33
+ * this to decide the WORDING (note or not) and, together with the `'end'`
34
+ * events, to settle the ordinary path without waiting for `'close'` at all.
35
+ *
36
+ * ASCII ONLY: `src/proc/**` is inside the glyph scanner's scope.
37
+ */
38
+ import type { Readable } from 'node:stream';
39
+ /**
40
+ * Whether one child descriptor can still deliver bytes.
41
+ *
42
+ * A stream that has been destroyed is finished even when `readableEnded` is
43
+ * still false - that is the state a killed child's pipes end up in.
44
+ */
45
+ export declare function isOutputStreamOpen(stream: Readable | null | undefined): boolean;
46
+ /** Whether EITHER child descriptor can still deliver bytes. */
47
+ export declare function hasOpenOutputStreams(streams: {
48
+ readonly stdout: Readable | null;
49
+ readonly stderr: Readable | null;
50
+ }): boolean;