@ian-pascoe/pi-advisor 0.5.0 → 0.7.0

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.
@@ -1,17 +1,35 @@
1
1
  import {
2
2
  getMarkdownTheme,
3
- keyHint,
4
3
  type MessageRenderOptions,
5
4
  type Theme,
6
5
  type ThemeColor,
7
6
  } from "@earendil-works/pi-coding-agent";
8
- import { Box, Container, Markdown, Text, type Component } from "@earendil-works/pi-tui";
7
+ import { Container, Markdown, Spacer, Text, type Component } from "@earendil-works/pi-tui";
8
+ import {
9
+ callDurationFooter,
10
+ COLLAPSED_LINES,
11
+ appendDurationFooter,
12
+ CollapsedPreview,
13
+ customMessageBox,
14
+ footerStatus,
15
+ joinInline,
16
+ previewBody,
17
+ statusMark,
18
+ summaryExpandHint,
19
+ toolHeader,
20
+ treePrefix,
21
+ type DurationContext,
22
+ type StatusKind,
23
+ } from "@ian-pascoe/pi-utils/ui";
9
24
  import { Type, type Static } from "typebox";
10
25
  import { Value } from "typebox/value";
11
26
  import {
12
27
  advisorFindingSchema,
28
+ advisorDroppedFindingsSchema,
29
+ advisorReviewCostSchema,
13
30
  advisorSeveritySchema,
14
31
  advisorStateSchema,
32
+ type AdvisorReviewCost,
15
33
  type AdvisorSeverity,
16
34
  type AdvisorState,
17
35
  } from "./advisor-contract.js";
@@ -26,12 +44,15 @@ import {
26
44
  /** Theme operations used by Advisor transcript and footer renderers. */
27
45
  export type AdvisorRenderTheme = Pick<Theme, "fg" | "bg" | "bold">;
28
46
 
29
- const severityStyle = {
30
- nit: { symbol: "·", color: "muted" },
31
- concern: { symbol: "▲", color: "warning" },
32
- blocker: { symbol: "✖", color: "error" },
33
- } as const satisfies Record<AdvisorSeverity, { symbol: string; color: ThemeColor }>;
34
- const collapsedNitLines = 4;
47
+ /** The registered name of the consultation tool. */
48
+ export const advisorAskToolName = "advisor_ask";
49
+
50
+ /** Severity Labels: a coloured word, never a Status Mark. */
51
+ const severityColor = {
52
+ nit: "dim",
53
+ concern: "warning",
54
+ blocker: "error",
55
+ } as const satisfies Record<AdvisorSeverity, ThemeColor>;
35
56
 
36
57
  const nullableString = Type.Union([Type.String(), Type.Null()]);
37
58
  /** The minimum every historical status entry recorded. */
@@ -49,6 +70,12 @@ const statusSchema = Type.Object({
49
70
  effectiveThinkingLevel: Type.Optional(nullableString),
50
71
  usage: Type.Optional(Type.Union([Type.Object({ total: Type.Number() }), Type.Null()])),
51
72
  cost: Type.Optional(Type.Union([Type.Number(), Type.Null()])),
73
+ reviewCost: Type.Optional(Type.Union([advisorReviewCostSchema, Type.Null()])),
74
+ deferredFindings: Type.Optional(Type.Number()),
75
+ droppedFindings: Type.Optional(
76
+ // Entries recorded before a reason existed omit its count.
77
+ Type.Partial(advisorDroppedFindingsSchema),
78
+ ),
52
79
  unavailableTools: Type.Optional(Type.Union([Type.Array(Type.String()), Type.Null()])),
53
80
  children: Type.Optional(
54
81
  Type.Array(
@@ -56,6 +83,7 @@ const statusSchema = Type.Object({
56
83
  agentId: Type.String(),
57
84
  state: Type.Optional(advisorStateSchema),
58
85
  backlog: Type.Optional(Type.Number()),
86
+ reviewCost: Type.Optional(Type.Union([advisorReviewCostSchema, Type.Null()])),
59
87
  }),
60
88
  ),
61
89
  ),
@@ -64,37 +92,25 @@ const statusSchema = Type.Object({
64
92
  });
65
93
  /** Data recorded in a `pi-advisor-status` entry. */
66
94
  export type AdvisorStatusEntry = Static<typeof statusSchema>;
67
- const stateBadge = {
68
- disabled: { symbol: "○", color: "dim" },
69
- private: { symbol: "○", color: "dim" },
70
- armed: { symbol: "●", color: "success" },
71
- reviewing: { symbol: "●", color: "accent" },
72
- consulting: { symbol: "●", color: "accent" },
73
- paused: { symbol: "●", color: "error" },
74
- } as const satisfies Record<AdvisorState, { symbol: string; color: ThemeColor }>;
95
+ const stateMarkKind = {
96
+ disabled: "idle",
97
+ private: "idle",
98
+ armed: "active",
99
+ reviewing: "active",
100
+ consulting: "active",
101
+ paused: "warning",
102
+ } as const satisfies Record<AdvisorState, StatusKind>;
75
103
  const promptPreviewWidth = 40;
76
104
 
77
- function expandHint(theme: Pick<Theme, "fg">): string {
78
- return theme.fg("dim", `… ${keyHint("app.tools.expand", "to expand")}`);
79
- }
105
+ /** Styles a plain piece of text; messages use `customMessageText`, menus leave it as is. */
106
+ type Paint = (text: string) => string;
107
+ const unpainted: Paint = (text) => text;
108
+ const messagePaint =
109
+ (theme: Pick<Theme, "fg">): Paint =>
110
+ (text) =>
111
+ theme.fg("customMessageText", text);
80
112
 
81
- /** Render at most `limit` lines of a child, then an expansion hint. */
82
- class Clipped implements Component {
83
- constructor(
84
- private readonly child: Component,
85
- private readonly limit: number,
86
- private readonly hint: string,
87
- ) {}
88
- render(width: number): string[] {
89
- const lines = this.child.render(width);
90
- return lines.length <= this.limit ? lines : [...lines.slice(0, this.limit), this.hint];
91
- }
92
- invalidate(): void {
93
- this.child.invalidate();
94
- }
95
- }
96
-
97
- /** Severity-styled, attributed Intervention; invalid details fall back to Pi's renderer. */
113
+ /** Severity-labelled, attributed Intervention; invalid details fall back to Pi's renderer. */
98
114
  export function renderAdvisorIntervention(
99
115
  // oxlint-disable-next-line anti-slop/no-unknown-parameters -- Journaled custom-message details are validated by the finding schema below.
100
116
  details: unknown,
@@ -103,25 +119,21 @@ export function renderAdvisorIntervention(
103
119
  agentId?: string,
104
120
  ): Component | undefined {
105
121
  if (!Value.Check(advisorFindingSchema, details)) return undefined;
106
- const style = severityStyle[details.severity];
107
- const heading = joinDefined(
108
- [
109
- theme.fg(style.color, theme.bold(`${style.symbol} Advisor ${details.severity}`)),
110
- agentId ? theme.fg("accent", `↳ ${agentId}`) : undefined,
111
- ],
112
- " ",
113
- );
114
- const body: Component = new Markdown(details.message, 0, 0, getMarkdownTheme());
115
- const container = new Container();
116
- container.addChild(new Text(heading, 0, 0));
117
- container.addChild(
118
- details.severity === "nit" && !options.expanded
119
- ? new Clipped(body, collapsedNitLines, expandHint(theme))
122
+ const heading = joinInline(theme, [
123
+ theme.fg(severityColor[details.severity], details.severity),
124
+ agentId ? theme.fg("accent", agentId) : undefined,
125
+ ]);
126
+ const body = new Markdown(details.message, 0, 0, getMarkdownTheme(), {
127
+ color: messagePaint(theme),
128
+ });
129
+ return customMessageBox(theme, { outputPad: options.outputPad, source: "advisor", heading }, [
130
+ details.severity === "nit"
131
+ ? new CollapsedPreview(theme, body, {
132
+ limit: COLLAPSED_LINES.fallback,
133
+ expanded: options.expanded,
134
+ })
120
135
  : body,
121
- );
122
- const box = new Box(options.outputPad, 1, (text) => theme.bg("customMessageBg", text));
123
- box.addChild(container);
124
- return box;
136
+ ]);
125
137
  }
126
138
 
127
139
  const childFindingSchema = Type.Object({
@@ -135,52 +147,89 @@ const childStateSchema = Type.Object({
135
147
  error: Type.Optional(Type.String()),
136
148
  });
137
149
 
150
+ /** Pi's entry renderers get no `outputPad`, so entries use Pi's default of one column. */
151
+ const entryOutputPad = 1;
152
+
138
153
  /** A Child Agent's Intervention or pause, attributed to that child. */
139
154
  export function renderAdvisorChildEntry(
140
155
  // oxlint-disable-next-line anti-slop/no-unknown-parameters -- Journaled entry data is validated below; anything else renders raw.
141
156
  data: unknown,
142
157
  expanded: boolean,
143
158
  theme: AdvisorRenderTheme,
159
+ outputPad = entryOutputPad,
144
160
  ): Component {
145
161
  if (Value.Check(childFindingSchema, data)) {
146
162
  const finding = { severity: data.severity, message: data.message };
147
163
  const rendered = renderAdvisorIntervention(
148
164
  finding,
149
- { expanded, outputPad: 0 },
165
+ { expanded, outputPad },
150
166
  theme,
151
167
  data.agentId,
152
168
  );
153
169
  if (rendered) return rendered;
154
170
  }
155
171
  if (Value.Check(childStateSchema, data)) {
156
- const line = `Advisor ↳ ${data.agentId} ${data.state}${data.error ? `: ${data.error}` : ""}`;
157
- return new Text(data.error ? theme.fg("error", `✖ ${line}`) : theme.fg("dim", line), 0, 0);
172
+ const heading = joinInline(theme, [
173
+ theme.fg("accent", data.agentId),
174
+ badge(data.state, theme, messagePaint(theme)),
175
+ ]);
176
+ return customMessageBox(
177
+ theme,
178
+ { outputPad, source: "advisor", heading },
179
+ data.error ? [new Text(theme.fg("error", data.error), 0, 0)] : [],
180
+ );
158
181
  }
159
182
  return new Text(`Advisor for Child Agent\n${JSON.stringify(data, null, 2)}`, 0, 0);
160
183
  }
161
184
 
162
- const collapsedAnswerLines = 8;
163
-
164
185
  /** `advisor_ask` call: the question, first line only while collapsed. */
165
186
  export function renderAdvisorAskCall(
166
187
  args: { message: string },
167
188
  expanded: boolean,
168
189
  theme: AdvisorRenderTheme,
190
+ context: DurationContext,
169
191
  ): Component {
170
- const question = expanded ? args.message : (args.message.split("\n", 1)[0] ?? "");
171
- return new Text(`${theme.fg("toolTitle", theme.bold("Ask Advisor"))} ${question}`, 0, 0);
192
+ const [first = "", ...rest] = args.message.split("\n");
193
+ const hidden = rest.length > 0 && !expanded;
194
+ const heading = toolHeader(theme, advisorAskToolName, expanded ? undefined : first);
195
+ const container = new Container();
196
+ container.addChild(new Text(hidden ? `${heading}${summaryExpandHint(theme)}` : heading, 0, 0));
197
+ if (expanded) container.addChild(new Text(theme.fg("muted", `message: ${args.message}`), 0, 0));
198
+ container.addChild(callDurationFooter(theme, context));
199
+ return container;
172
200
  }
173
201
 
174
- /** `advisor_ask` result: a Markdown answer, previewed while collapsed. */
202
+ /** `advisor_ask` result: a Markdown answer, previewed while collapsed, then Pi's duration footer. */
175
203
  export function renderAdvisorAskResult(
176
204
  answer: string,
177
205
  options: { expanded: boolean; isPartial: boolean; isError: boolean },
178
206
  theme: AdvisorRenderTheme,
207
+ context: DurationContext,
179
208
  ): Component {
180
- if (options.isPartial) return new Text(theme.fg("dim", "Consulting…"), 0, 0);
181
- if (options.isError) return new Text(theme.fg("error", answer), 0, 0);
182
- const body = new Markdown(answer, 0, 0, getMarkdownTheme());
183
- return options.expanded ? body : new Clipped(body, collapsedAnswerLines, expandHint(theme));
209
+ const container = new Container();
210
+ if (answer) {
211
+ container.addChild(new Spacer(1));
212
+ if (options.isError) {
213
+ const lines = previewBody(theme, answer.split("\n"), {
214
+ limit: COLLAPSED_LINES.fallback,
215
+ expanded: options.expanded,
216
+ color: "error",
217
+ });
218
+ container.addChild(new Text(lines.join("\n"), 0, 0));
219
+ } else {
220
+ const body = new Markdown(answer, 0, 0, getMarkdownTheme(), {
221
+ color: (text) => theme.fg("toolOutput", text),
222
+ });
223
+ container.addChild(
224
+ new CollapsedPreview(theme, body, {
225
+ limit: COLLAPSED_LINES.fallback,
226
+ expanded: options.expanded,
227
+ }),
228
+ );
229
+ }
230
+ }
231
+ appendDurationFooter(container, theme, context, { isPartial: options.isPartial });
232
+ return container;
184
233
  }
185
234
 
186
235
  function formatTokens(total: number): string {
@@ -189,9 +238,41 @@ function formatTokens(total: number): string {
189
238
  return `${(total / 1_000_000).toFixed(1)}M`;
190
239
  }
191
240
 
241
+ function formatMoney(cost: number): string {
242
+ return `$${cost.toFixed(cost < 0.01 ? 4 : 2)}`;
243
+ }
244
+
192
245
  function formatCost(cost: number | null | undefined): string {
193
246
  if (cost === null || cost === undefined) return "cost unknown";
194
- return `cost $${cost.toFixed(cost < 0.01 ? 4 : 2)}`;
247
+ return `cost ${formatMoney(cost)}`;
248
+ }
249
+
250
+ /** Running Review total, then the last Review alone; unknown stays unknown. */
251
+ function formatReviewCost({ reviews, last, total }: AdvisorReviewCost) {
252
+ const amount = (cost: number | null) => (cost === null ? "cost unknown" : formatMoney(cost));
253
+ return `${reviews} ${reviews === 1 ? "Review" : "Reviews"} ${amount(total)} · last Review ${amount(last)}`;
254
+ }
255
+
256
+ const plural = (count: number, one: string, many: string) => `${count} ${count === 1 ? one : many}`;
257
+
258
+ /** Findings withheld for re-validation and findings dropped, when there are any. */
259
+ function findingCounts(entry: AdvisorStatusEntry): string[] {
260
+ const { deferredFindings: deferred, droppedFindings: dropped } = entry;
261
+ return [
262
+ deferred ? `${plural(deferred, "finding", "findings")} awaiting re-validation` : "",
263
+ dropped?.overNitCap
264
+ ? `${plural(dropped.overNitCap, "Nit", "Nits")} over the request cap dropped`
265
+ : "",
266
+ dropped?.unsupported
267
+ ? `${plural(dropped.unsupported, "finding", "findings")} without valid evidence dropped`
268
+ : "",
269
+ dropped?.superseded
270
+ ? `${plural(dropped.superseded, "Nit", "Nits")} from a superseded re-validating Review dropped`
271
+ : "",
272
+ dropped?.invalidReviews
273
+ ? `${plural(dropped.invalidReviews, "Review", "Reviews")} ended by invalid reports`
274
+ : "",
275
+ ].filter(Boolean);
195
276
  }
196
277
 
197
278
  /** Human-readable option value; an absent value inherits. */
@@ -201,7 +282,7 @@ export function formatAdvisorOption(options: AdvisorOptions, key: keyof AdvisorO
201
282
  if (options.prompt === undefined) return "inherit";
202
283
  const first = options.prompt.split("\n", 1)[0] ?? "";
203
284
  const preview =
204
- first.length > promptPreviewWidth ? `${first.slice(0, promptPreviewWidth - 1)}…` : first;
285
+ first.length > promptPreviewWidth ? `${first.slice(0, promptPreviewWidth - 3)}...` : first;
205
286
  return `${preview} (${options.prompt.length} chars)`;
206
287
  }
207
288
  case "allowedTools":
@@ -219,28 +300,39 @@ export function formatAdvisorOption(options: AdvisorOptions, key: keyof AdvisorO
219
300
  }
220
301
  }
221
302
 
222
- function badge(state: AdvisorState, theme: AdvisorRenderTheme): string {
223
- const style = stateBadge[state];
224
- return theme.fg(style.color, `${style.symbol} ${state}`);
303
+ /** The word shown for a state. Guardian says `on` when idle, so an armed Advisor does too; `armed` stays the stored state. */
304
+ function stateWord(state: AdvisorState): string {
305
+ return state === "armed" ? "on" : state;
306
+ }
307
+
308
+ function badge(state: AdvisorState, theme: AdvisorRenderTheme, paint: Paint): string {
309
+ return `${statusMark(theme, stateMarkKind[state])} ${paint(stateWord(state))}`;
225
310
  }
226
311
 
227
312
  function joinDefined(parts: ReadonlyArray<string | undefined>, separator: string): string {
228
313
  return parts.filter((part) => part !== undefined).join(separator);
229
314
  }
230
315
 
231
- function stateLine(entry: AdvisorStatusEntry, theme: AdvisorRenderTheme): string {
316
+ function stateLine(
317
+ entry: AdvisorStatusEntry,
318
+ theme: AdvisorRenderTheme,
319
+ label: string | undefined,
320
+ paint: Paint,
321
+ ): string {
232
322
  const inherited = entry.settings && entry.settings.model === undefined;
233
- return joinDefined(
234
- [
235
- `${theme.bold("Advisor")} ${badge(entry.state, theme)}`,
236
- entry.effectiveModel
237
- ? `${entry.effectiveModel}${inherited ? theme.fg("dim", " (inherited)") : ""}`
238
- : undefined,
239
- entry.effectiveThinkingLevel ?? undefined,
240
- entry.backlog ? `backlog ${entry.backlog}` : undefined,
241
- ],
242
- theme.fg("dim", " · "),
243
- );
323
+ const state = badge(entry.state, theme, paint);
324
+ return joinInline(theme, [
325
+ label ? `${label} ${state}` : state,
326
+ entry.effectiveModel
327
+ ? `${paint(entry.effectiveModel)}${inherited ? theme.fg("dim", " (inherited)") : ""}`
328
+ : undefined,
329
+ entry.effectiveThinkingLevel ? paint(entry.effectiveThinkingLevel) : undefined,
330
+ entry.backlog ? paint(`backlog ${entry.backlog}`) : undefined,
331
+ ]);
332
+ }
333
+
334
+ function errorLine(theme: AdvisorRenderTheme, error: string): string {
335
+ return `${statusMark(theme, "failed")} ${theme.fg("error", error)}`;
244
336
  }
245
337
 
246
338
  /** Live state line plus any error, as shown at the top of the settings menu. */
@@ -249,35 +341,45 @@ export function advisorStatusHeadline(
249
341
  theme: AdvisorRenderTheme,
250
342
  ): string[] {
251
343
  const error = entry.error ?? entry.lastError;
252
- return [stateLine(entry, theme), ...(error ? [theme.fg("error", `✖ ${error}`)] : [])];
344
+ return [
345
+ stateLine(entry, theme, theme.bold("Advisor"), unpainted),
346
+ ...(error ? [errorLine(theme, error)] : []),
347
+ ];
253
348
  }
254
349
 
350
+ /** The status entry's body below its `[advisor] ● on · model` label line. */
255
351
  function summaryLines(entry: AdvisorStatusEntry, theme: AdvisorRenderTheme): string[] {
352
+ const paint = messagePaint(theme);
256
353
  const lines: string[] = [];
257
354
  for (const { scope, key, options } of entry.changes ?? []) {
258
355
  lines.push(
259
- `${theme.fg("success", "✓")} ${key} → ${formatAdvisorOption(options, key)} ${theme.fg("dim", `[${scope}]`)}`,
356
+ `${statusMark(theme, "done")} ${paint(`${key} → ${formatAdvisorOption(options, key)}`)} ${theme.fg("dim", `[${scope}]`)}`,
260
357
  );
261
358
  }
262
- lines.push(stateLine(entry, theme));
263
359
  const children = entry.children?.length ?? 0;
264
360
  const activity = joinDefined(
265
361
  [
266
362
  entry.usage ? `tokens ${formatTokens(entry.usage.total)}` : undefined,
267
363
  entry.usage ? formatCost(entry.cost) : undefined,
364
+ entry.reviewCost?.reviews ? formatReviewCost(entry.reviewCost) : undefined,
268
365
  children ? `${children} ${children === 1 ? "child" : "children"}` : undefined,
269
366
  ],
270
367
  " · ",
271
368
  );
272
369
  if (activity) lines.push(theme.fg("muted", activity));
370
+ const counts = findingCounts(entry);
371
+ if (counts.length) lines.push(theme.fg("muted", counts.join(" · ")));
273
372
  if (entry.unavailableTools?.length)
274
- lines.push(theme.fg("warning", `⚠ unavailable tools: ${entry.unavailableTools.join(", ")}`));
373
+ lines.push(
374
+ `${statusMark(theme, "warning")} ${theme.fg("warning", `unavailable tools: ${entry.unavailableTools.join(", ")}`)}`,
375
+ );
275
376
  const error = entry.error ?? entry.lastError;
276
- if (error) lines.push(theme.fg("error", `✖ ${error}`));
377
+ if (error) lines.push(errorLine(theme, error));
277
378
  return lines;
278
379
  }
279
380
 
280
381
  function detailLines(entry: AdvisorStatusEntry, theme: AdvisorRenderTheme): string[] {
382
+ const paint = messagePaint(theme);
281
383
  const lines: string[] = [];
282
384
  if (entry.settings) {
283
385
  const settings = entry.settings;
@@ -286,21 +388,23 @@ function detailLines(entry: AdvisorStatusEntry, theme: AdvisorRenderTheme): stri
286
388
  for (const key of advisorOptionKeys) {
287
389
  const source = entry.sources?.[key] ?? "default";
288
390
  lines.push(
289
- ` ${key.padEnd(width)} ${formatAdvisorOption(settings, key)} ${theme.fg(source === "default" ? "dim" : "accent", `[${source}]`)}`,
391
+ ` ${paint(key.padEnd(width))} ${paint(formatAdvisorOption(settings, key))} ${theme.fg(source === "default" ? "dim" : "accent", `[${source}]`)}`,
290
392
  );
291
393
  }
292
394
  }
293
- if (entry.children?.length) lines.push("");
294
- for (const child of entry.children ?? [])
395
+ const children = entry.children ?? [];
396
+ if (children.length) lines.push("");
397
+ for (const [index, child] of children.entries())
295
398
  lines.push(
296
- joinDefined(
399
+ `${theme.fg("dim", treePrefix([], index === children.length - 1))}${joinDefined(
297
400
  [
298
- ` ${theme.fg("accent", `↳ ${child.agentId}`)}`,
299
- child.state ? badge(child.state, theme) : undefined,
300
- child.backlog ? `backlog ${child.backlog}` : undefined,
401
+ theme.fg("accent", child.agentId),
402
+ child.state ? badge(child.state, theme, paint) : undefined,
403
+ child.backlog ? paint(`backlog ${child.backlog}`) : undefined,
404
+ child.reviewCost?.reviews ? paint(formatReviewCost(child.reviewCost)) : undefined,
301
405
  ],
302
406
  " ",
303
- ),
407
+ )}`,
304
408
  );
305
409
  return lines;
306
410
  }
@@ -318,21 +422,34 @@ function salvageStatus(data: Static<typeof minimalStatusSchema>): AdvisorStatusE
318
422
  : { state: data.state, error: data.error ?? null };
319
423
  }
320
424
 
321
- /** Status snapshot: a compact summary, with every setting and child when expanded. */
425
+ /**
426
+ * Status snapshot in Pi's custom-message look: a ten-line summary, with every setting and child
427
+ * when expanded.
428
+ */
322
429
  export function renderAdvisorStatus(
323
430
  // oxlint-disable-next-line anti-slop/no-unknown-parameters -- Journaled entry data is validated below; older shapes keep their state and error, anything else renders raw.
324
431
  data: unknown,
325
432
  expanded: boolean,
326
433
  theme: AdvisorRenderTheme,
434
+ outputPad = entryOutputPad,
327
435
  ): Component {
328
436
  if (!Value.Check(minimalStatusSchema, data))
329
- return new Text(`Advisor\n${JSON.stringify(data, null, 2)}`, 0, 0);
437
+ return customMessageBox(theme, { outputPad, source: "advisor" }, [
438
+ new Text(JSON.stringify(data, null, 2), 0, 0),
439
+ ]);
330
440
  const entry = salvageStatus(data);
331
- const lines = summaryLines(entry, theme);
441
+ const summary = summaryLines(entry, theme);
332
442
  const details = detailLines(entry, theme);
333
- if (expanded) lines.push(...details);
334
- else if (details.length) lines.push(expandHint(theme));
335
- return new Text(lines.join("\n"), 0, 0);
443
+ const heading = stateLine(entry, theme, undefined, messagePaint(theme));
444
+ const rows = [...summary, ...details];
445
+ const body = new Text(rows.join("\n"), 0, 0);
446
+ return customMessageBox(
447
+ theme,
448
+ { outputPad, source: "advisor", heading },
449
+ rows.length > 0
450
+ ? [new CollapsedPreview(theme, body, { limit: COLLAPSED_LINES.fallback, expanded })]
451
+ : [],
452
+ );
336
453
  }
337
454
 
338
455
  /** The live fields the footer summarizes for one watched agent. */
@@ -345,24 +462,26 @@ function count(amount: number, what: string): string {
345
462
  return `${amount} ${amount === 1 ? "child" : "children"} ${what}`;
346
463
  }
347
464
 
348
- /** Compact footer status; `undefined` clears it. Pause reasons stay in `/advisor status`. */
465
+ /** Footer status entry; `undefined` clears it. Pause reasons stay in `/advisor status`. */
349
466
  export function advisorFooterText(
350
467
  root: AdvisorActivity | undefined,
351
468
  children: readonly AdvisorActivity[],
352
469
  theme: Pick<Theme, "fg">,
353
470
  ): string | undefined {
354
471
  if (!root || root.state === "disabled" || root.state === "private") return undefined;
355
- if (root.state === "paused") return theme.fg("error", "advisor: paused");
356
- const parts: string[] = [];
357
- if (root.state === "reviewing" || root.state === "consulting") {
358
- parts.push(root.state);
359
- if (root.backlog > 0) parts.push(`backlog ${root.backlog}`);
360
- }
472
+ if (root.state === "paused")
473
+ return footerStatus(theme, {
474
+ mark: "warning",
475
+ name: "advisor",
476
+ value: theme.fg("warning", "paused"),
477
+ });
478
+ const parts: string[] = [stateWord(root.state)];
479
+ if ((root.state === "reviewing" || root.state === "consulting") && root.backlog > 0)
480
+ parts.push(`backlog ${root.backlog}`);
361
481
  // Consultations come only from the main agent, so a child segment shows Reviews and pauses.
362
482
  const busy = children.filter((child) => child.state === "reviewing").length;
363
483
  const paused = children.filter((child) => child.state === "paused").length;
364
484
  if (busy) parts.push(count(busy, "reviewing"));
365
- if (paused) parts.push(theme.fg("error", count(paused, "paused")));
366
- if (!parts.length) return theme.fg("dim", "advisor");
367
- return theme.fg("accent", `advisor: ${parts.join(" · ")}`);
485
+ if (paused) parts.push(theme.fg("warning", count(paused, "paused")));
486
+ return footerStatus(theme, { mark: "active", name: "advisor", value: joinInline(theme, parts) });
368
487
  }
@@ -63,6 +63,13 @@ const registrationNames = ({ tools, commands, flags }: Extension) => [
63
63
  ...[...flags.keys()].map((name) => `flag:${name}`),
64
64
  ];
65
65
 
66
+ /** Context Management's private tools; an extension or session providing all three loads it. */
67
+ export const contextManagementTools = ["context_notes", "context_history", "context_rollover"];
68
+ /** Whether these tool names include all of Context Management's; it then owns compaction. */
69
+ export function providesContextManagement(tools: { has(name: string): boolean }): boolean {
70
+ return contextManagementTools.every((name) => tools.has(name));
71
+ }
72
+
66
73
  /** Private role is native journal state and is installed before session_start. */
67
74
  export function isAdvisorSession(manager: Pick<SessionManager, "getBranch">): boolean {
68
75
  return manager
@@ -256,12 +263,11 @@ async function buildAdvisorSession(
256
263
  const global = JSON.stringify(observed.settingsManager.getGlobalSettings());
257
264
  const project = JSON.stringify(observed.settingsManager.getProjectSettings());
258
265
  const config = structuredClone(options.config);
259
- const contextTools = ["context_notes", "context_history", "context_rollover"];
260
266
  const hasContextManagement = source
261
267
  .getExtensions()
262
- .extensions.some((extension) => contextTools.every((name) => extension.tools.has(name)));
268
+ .extensions.some((extension) => providesContextManagement(extension.tools));
263
269
  const missingContextGrants = hasContextManagement
264
- ? contextTools.filter((name) => !config.allowedTools.includes(name))
270
+ ? contextManagementTools.filter((name) => !config.allowedTools.includes(name))
265
271
  : [];
266
272
  if (missingContextGrants.length)
267
273
  throw new Error(
@@ -353,6 +359,11 @@ async function buildAdvisorSession(
353
359
  resourceLoaderOptions: {
354
360
  noExtensions: true,
355
361
  noContextFiles: true,
362
+ // Neither Reviews nor Consultations use skills; the catalogue would only bloat and
363
+ // destabilize the prompt. `noSkills` alone still admits skills that inherited extensions
364
+ // return from `resources_discover`, so also empty every skill load.
365
+ noSkills: true,
366
+ skillsOverride: () => ({ skills: [], diagnostics: [] }),
356
367
  additionalExtensionPaths: extensionPaths,
357
368
  extensionFactories: [
358
369
  ...builtins,