@ian-pascoe/pi-advisor 0.4.0 → 0.5.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.
@@ -3,8 +3,14 @@ import * as piAi from "@earendil-works/pi-ai";
3
3
  import type { Context, ImageContent } from "@earendil-works/pi-ai";
4
4
  import * as piSdk from "@earendil-works/pi-coding-agent";
5
5
  import type { AgentSession, AgentSessionRuntime } from "@earendil-works/pi-coding-agent";
6
- import { Type, type Static } from "typebox";
6
+ import { Type } from "typebox";
7
7
  import { Value } from "typebox/value";
8
+ import {
9
+ advisorFindingSchema,
10
+ type AdvisorFinding,
11
+ type AdvisorObserverState,
12
+ type AdvisorSeverity,
13
+ } from "./advisor-contract.js";
8
14
  import type { AdvisorConfig } from "./advisor-settings.js";
9
15
  import {
10
16
  createAdvisorSession,
@@ -15,29 +21,14 @@ import {
15
21
 
16
22
  /** Delivery authority supplied by the native session owner. */
17
23
  export type AdvisorMode = "interactive" | "headless-root" | "owned-child";
18
- export type AdvisorSeverity = "nit" | "concern" | "blocker";
19
24
  /** Owner-supplied recreation inputs and native UI/delivery surfaces. */
20
25
  export interface AdvisorObserverOptions {
21
26
  resourceInputs?: AdvisorResourceInputs;
22
- onIntervention?: (finding: {
23
- severity: AdvisorSeverity;
24
- message: string;
25
- }) => void | Promise<void>;
27
+ onIntervention?: (finding: AdvisorFinding) => void | Promise<void>;
26
28
  onError?: (message: string) => void;
29
+ /** Called after `status` state or backlog may have changed. */
30
+ onStateChange?: () => void;
27
31
  }
28
- const severitySchema = Type.Union([
29
- Type.Literal("nit"),
30
- Type.Literal("concern"),
31
- Type.Literal("blocker"),
32
- ]);
33
- const findingSchema = Type.Object(
34
- {
35
- severity: severitySchema,
36
- message: Type.String({ minLength: 1, maxLength: 4000 }),
37
- },
38
- { additionalProperties: false },
39
- );
40
- type Finding = Static<typeof findingSchema>;
41
32
  const legacyReportSchema = Type.Object(
42
33
  {
43
34
  severity: Type.Union([Type.Literal("none"), Type.Literal("concern"), Type.Literal("blocker")]),
@@ -48,7 +39,7 @@ const legacyReportSchema = Type.Object(
48
39
  const adviceMessageSchema = Type.Object({
49
40
  role: Type.Literal("custom"),
50
41
  customType: Type.Literal("pi-advisor"),
51
- details: findingSchema,
42
+ details: advisorFindingSchema,
52
43
  });
53
44
  const pendingQueuesSchema = Type.Object({
54
45
  agent: Type.Object({ steeringQueue: Type.Object({ messages: Type.Array(Type.Unknown()) }) }),
@@ -56,7 +47,7 @@ const pendingQueuesSchema = Type.Object({
56
47
  });
57
48
 
58
49
  // oxlint-disable-next-line anti-slop/no-unknown-parameters -- SAFETY: Pi has no selective queue-removal API. Validate its native queue data and remove only exact owned finding identities, never unrelated messages or journal entries.
59
- function retractFindings(session: unknown, findings: ReadonlySet<Finding>): void {
50
+ function retractFindings(session: unknown, findings: ReadonlySet<AdvisorFinding>): void {
60
51
  if (!findings.size) return;
61
52
  if (!Value.Check(pendingQueuesSchema, session))
62
53
  throw new Error("Unsupported Advisor SDK: native pending-message queues are unavailable");
@@ -86,7 +77,7 @@ interface OperationBase {
86
77
  }
87
78
  interface Review extends OperationBase {
88
79
  kind: "review";
89
- findings?: Finding[];
80
+ findings?: AdvisorFinding[];
90
81
  }
91
82
  interface Consultation extends OperationBase {
92
83
  kind: "consultation";
@@ -114,8 +105,8 @@ const severityRank = { nit: 1, concern: 2, blocker: 3 } as const satisfies Recor
114
105
  >;
115
106
 
116
107
  /** Keep report order while replacing same-message findings with their highest severity. */
117
- function selectFindings(findings: readonly Finding[], limit: number): Finding[] {
118
- const distinct = new Map<string, Finding>();
108
+ function selectFindings(findings: readonly AdvisorFinding[], limit: number): AdvisorFinding[] {
109
+ const distinct = new Map<string, AdvisorFinding>();
119
110
  for (const finding of findings) {
120
111
  const key = normalized(finding.message);
121
112
  const previous = distinct.get(key);
@@ -168,7 +159,7 @@ export class AdvisorObserver {
168
159
  private snapshot: Context | undefined;
169
160
  private supplied: Context | undefined;
170
161
  private suppliedBoundary: ReturnType<typeof observationBoundary> | undefined;
171
- private readonly pendingFindings = new Map<Finding, Review>();
162
+ private readonly pendingFindings = new Map<AdvisorFinding, Review>();
172
163
  private completed = 0;
173
164
  private reviewed = 0;
174
165
  private running: Promise<void> | undefined;
@@ -181,7 +172,7 @@ export class AdvisorObserver {
181
172
  private error: string | undefined;
182
173
  private closed = false;
183
174
  private disposing: Promise<void> | undefined;
184
- private deferred: Finding[] = [];
175
+ private deferred: AdvisorFinding[] = [];
185
176
  private readonly delivered = new Map<string, number>();
186
177
  private lastConcern = -3;
187
178
  private unsafeEnding = false;
@@ -252,6 +243,7 @@ export class AdvisorObserver {
252
243
  this.unsafeEnding = true;
253
244
  this.completed++;
254
245
  this.start();
246
+ this.changed();
255
247
  if (this.config.catchUpThreshold !== "off")
256
248
  await this.wait(this.config.catchUpThreshold, signal);
257
249
  // Selection/branch changes may occur during that awaited barrier.
@@ -276,16 +268,17 @@ export class AdvisorObserver {
276
268
  (rate) => rate.input > 0 || rate.output > 0 || rate.cacheRead > 0 || rate.cacheWrite > 0,
277
269
  );
278
270
  const knownCost = stats && (stats.cost > 0 || (stats.tokens.total > 0 && priced));
271
+ const state: AdvisorObserverState = !this.config.enabled
272
+ ? "disabled"
273
+ : this.error
274
+ ? "paused"
275
+ : this.consulting
276
+ ? "consulting"
277
+ : this.running
278
+ ? "reviewing"
279
+ : "armed";
279
280
  return {
280
- state: !this.config.enabled
281
- ? "disabled"
282
- : this.error
283
- ? "paused"
284
- : this.consulting
285
- ? "consulting"
286
- : this.running
287
- ? "reviewing"
288
- : "armed",
281
+ state,
289
282
  backlog: this.completed - this.reviewed,
290
283
  effectiveModel,
291
284
  effectiveThinkingLevel:
@@ -303,8 +296,17 @@ export class AdvisorObserver {
303
296
  };
304
297
  }
305
298
 
299
+ /** Status observers are UI; their failures must not disturb review work. */
300
+ private changed(): void {
301
+ try {
302
+ this.options.onStateChange?.();
303
+ } catch {
304
+ // A stale UI cannot be updated; `/advisor status` remains authoritative.
305
+ }
306
+ }
306
307
  private fail(message: string): void {
307
308
  this.error = message;
309
+ this.changed();
308
310
  try {
309
311
  this.options.onError?.(message);
310
312
  } catch (cause) {
@@ -324,7 +326,7 @@ export class AdvisorObserver {
324
326
  if (
325
327
  entry.type === "custom_message" &&
326
328
  entry.customType === "pi-advisor" &&
327
- Value.Check(findingSchema, entry.details)
329
+ Value.Check(advisorFindingSchema, entry.details)
328
330
  ) {
329
331
  const key = normalized(entry.details.message);
330
332
  this.delivered.set(
@@ -339,7 +341,7 @@ export class AdvisorObserver {
339
341
  this.lastConcern = this.completed - (turnsSinceConcern ?? 3);
340
342
  }
341
343
  private retractInvalidFindings(): void {
342
- const stale = new Set<Finding>();
344
+ const stale = new Set<AdvisorFinding>();
343
345
  for (const [finding, review] of this.pendingFindings) {
344
346
  if (!this.current(review)) {
345
347
  stale.add(finding);
@@ -400,8 +402,10 @@ export class AdvisorObserver {
400
402
  this.active = undefined;
401
403
  if (!this.error) this.start();
402
404
  this.scheduleCorrection();
405
+ this.changed();
403
406
  });
404
407
  this.running = operation;
408
+ this.changed();
405
409
  }
406
410
 
407
411
  private async review(review: Review): Promise<void> {
@@ -454,7 +458,7 @@ export class AdvisorObserver {
454
458
  const runtimeEpoch = operation.epoch;
455
459
  const reportSchema = Type.Object(
456
460
  {
457
- findings: Type.Array(findingSchema, {
461
+ findings: Type.Array(advisorFindingSchema, {
458
462
  maxItems: 32,
459
463
  description: `Up to ${this.config.maxFindingsPerReview} distinct findings in priority order; use an empty array when there are none`,
460
464
  }),
@@ -651,6 +655,7 @@ export class AdvisorObserver {
651
655
  };
652
656
  this.active = consultation;
653
657
  this.consulting = true;
658
+ this.changed();
654
659
  let callerCancelled = false;
655
660
  const cancelFromCaller = () => {
656
661
  callerCancelled = true;
@@ -697,6 +702,7 @@ export class AdvisorObserver {
697
702
  }
698
703
  if (this.active === consultation) this.active = undefined;
699
704
  this.consulting = false;
705
+ this.changed();
700
706
  }
701
707
  }
702
708
 
@@ -737,7 +743,7 @@ export class AdvisorObserver {
737
743
  }
738
744
  }
739
745
 
740
- private async deliver(findings: readonly Finding[], review: Review): Promise<void> {
746
+ private async deliver(findings: readonly AdvisorFinding[], review: Review): Promise<void> {
741
747
  const fresh = findings.filter((finding) => {
742
748
  const deliveredRank = this.delivered.get(normalized(finding.message)) ?? 0;
743
749
  return severityRank[finding.severity] > deliveredRank;
@@ -906,6 +912,7 @@ export class AdvisorObserver {
906
912
  this.runtime = undefined;
907
913
  this.closeRuntime(runtime);
908
914
  }
915
+ this.changed();
909
916
  }
910
917
  /** Owner cancellation invalidates private work without touching observed execution. */
911
918
  async abort(): Promise<void> {
@@ -0,0 +1,368 @@
1
+ import {
2
+ getMarkdownTheme,
3
+ keyHint,
4
+ type MessageRenderOptions,
5
+ type Theme,
6
+ type ThemeColor,
7
+ } from "@earendil-works/pi-coding-agent";
8
+ import { Box, Container, Markdown, Text, type Component } from "@earendil-works/pi-tui";
9
+ import { Type, type Static } from "typebox";
10
+ import { Value } from "typebox/value";
11
+ import {
12
+ advisorFindingSchema,
13
+ advisorSeveritySchema,
14
+ advisorStateSchema,
15
+ type AdvisorSeverity,
16
+ type AdvisorState,
17
+ } from "./advisor-contract.js";
18
+ import {
19
+ advisorAppliedChangeSchema,
20
+ advisorOptionKeys,
21
+ advisorOptionsSchema,
22
+ advisorSettingSourceSchema,
23
+ type AdvisorOptions,
24
+ } from "./advisor-settings.js";
25
+
26
+ /** Theme operations used by Advisor transcript and footer renderers. */
27
+ export type AdvisorRenderTheme = Pick<Theme, "fg" | "bg" | "bold">;
28
+
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;
35
+
36
+ const nullableString = Type.Union([Type.String(), Type.Null()]);
37
+ /** The minimum every historical status entry recorded. */
38
+ const minimalStatusSchema = Type.Object({
39
+ state: advisorStateSchema,
40
+ error: Type.Optional(nullableString),
41
+ });
42
+ const statusSchema = Type.Object({
43
+ state: advisorStateSchema,
44
+ changes: Type.Optional(Type.Array(advisorAppliedChangeSchema)),
45
+ settings: Type.Optional(advisorOptionsSchema),
46
+ sources: Type.Optional(Type.Record(Type.String(), advisorSettingSourceSchema)),
47
+ backlog: Type.Optional(Type.Number()),
48
+ effectiveModel: Type.Optional(nullableString),
49
+ effectiveThinkingLevel: Type.Optional(nullableString),
50
+ usage: Type.Optional(Type.Union([Type.Object({ total: Type.Number() }), Type.Null()])),
51
+ cost: Type.Optional(Type.Union([Type.Number(), Type.Null()])),
52
+ unavailableTools: Type.Optional(Type.Union([Type.Array(Type.String()), Type.Null()])),
53
+ children: Type.Optional(
54
+ Type.Array(
55
+ Type.Object({
56
+ agentId: Type.String(),
57
+ state: Type.Optional(advisorStateSchema),
58
+ backlog: Type.Optional(Type.Number()),
59
+ }),
60
+ ),
61
+ ),
62
+ error: Type.Optional(nullableString),
63
+ lastError: Type.Optional(nullableString),
64
+ });
65
+ /** Data recorded in a `pi-advisor-status` entry. */
66
+ 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 }>;
75
+ const promptPreviewWidth = 40;
76
+
77
+ function expandHint(theme: Pick<Theme, "fg">): string {
78
+ return theme.fg("dim", `… ${keyHint("app.tools.expand", "to expand")}`);
79
+ }
80
+
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. */
98
+ export function renderAdvisorIntervention(
99
+ // oxlint-disable-next-line anti-slop/no-unknown-parameters -- Journaled custom-message details are validated by the finding schema below.
100
+ details: unknown,
101
+ options: MessageRenderOptions,
102
+ theme: AdvisorRenderTheme,
103
+ agentId?: string,
104
+ ): Component | undefined {
105
+ 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))
120
+ : body,
121
+ );
122
+ const box = new Box(options.outputPad, 1, (text) => theme.bg("customMessageBg", text));
123
+ box.addChild(container);
124
+ return box;
125
+ }
126
+
127
+ const childFindingSchema = Type.Object({
128
+ agentId: Type.String(),
129
+ severity: advisorSeveritySchema,
130
+ message: Type.String(),
131
+ });
132
+ const childStateSchema = Type.Object({
133
+ agentId: Type.String(),
134
+ state: advisorStateSchema,
135
+ error: Type.Optional(Type.String()),
136
+ });
137
+
138
+ /** A Child Agent's Intervention or pause, attributed to that child. */
139
+ export function renderAdvisorChildEntry(
140
+ // oxlint-disable-next-line anti-slop/no-unknown-parameters -- Journaled entry data is validated below; anything else renders raw.
141
+ data: unknown,
142
+ expanded: boolean,
143
+ theme: AdvisorRenderTheme,
144
+ ): Component {
145
+ if (Value.Check(childFindingSchema, data)) {
146
+ const finding = { severity: data.severity, message: data.message };
147
+ const rendered = renderAdvisorIntervention(
148
+ finding,
149
+ { expanded, outputPad: 0 },
150
+ theme,
151
+ data.agentId,
152
+ );
153
+ if (rendered) return rendered;
154
+ }
155
+ 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);
158
+ }
159
+ return new Text(`Advisor for Child Agent\n${JSON.stringify(data, null, 2)}`, 0, 0);
160
+ }
161
+
162
+ const collapsedAnswerLines = 8;
163
+
164
+ /** `advisor_ask` call: the question, first line only while collapsed. */
165
+ export function renderAdvisorAskCall(
166
+ args: { message: string },
167
+ expanded: boolean,
168
+ theme: AdvisorRenderTheme,
169
+ ): 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);
172
+ }
173
+
174
+ /** `advisor_ask` result: a Markdown answer, previewed while collapsed. */
175
+ export function renderAdvisorAskResult(
176
+ answer: string,
177
+ options: { expanded: boolean; isPartial: boolean; isError: boolean },
178
+ theme: AdvisorRenderTheme,
179
+ ): 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));
184
+ }
185
+
186
+ function formatTokens(total: number): string {
187
+ if (total < 1_000) return String(total);
188
+ if (total < 1_000_000) return `${(total / 1_000).toFixed(1)}k`;
189
+ return `${(total / 1_000_000).toFixed(1)}M`;
190
+ }
191
+
192
+ function formatCost(cost: number | null | undefined): string {
193
+ if (cost === null || cost === undefined) return "cost unknown";
194
+ return `cost $${cost.toFixed(cost < 0.01 ? 4 : 2)}`;
195
+ }
196
+
197
+ /** Human-readable option value; an absent value inherits. */
198
+ export function formatAdvisorOption(options: AdvisorOptions, key: keyof AdvisorOptions): string {
199
+ switch (key) {
200
+ case "prompt": {
201
+ if (options.prompt === undefined) return "inherit";
202
+ const first = options.prompt.split("\n", 1)[0] ?? "";
203
+ const preview =
204
+ first.length > promptPreviewWidth ? `${first.slice(0, promptPreviewWidth - 1)}…` : first;
205
+ return `${preview} (${options.prompt.length} chars)`;
206
+ }
207
+ case "allowedTools":
208
+ if (options.allowedTools === undefined) return "inherit";
209
+ return options.allowedTools.length ? options.allowedTools.join(", ") : "none";
210
+ case "reviewTimeoutMs":
211
+ if (options.reviewTimeoutMs === undefined) return "inherit";
212
+ return options.reviewTimeoutMs < 1_000
213
+ ? `${options.reviewTimeoutMs}ms`
214
+ : `${options.reviewTimeoutMs / 1_000}s`;
215
+ default: {
216
+ const value = options[key];
217
+ return value === undefined ? "inherit" : String(value);
218
+ }
219
+ }
220
+ }
221
+
222
+ function badge(state: AdvisorState, theme: AdvisorRenderTheme): string {
223
+ const style = stateBadge[state];
224
+ return theme.fg(style.color, `${style.symbol} ${state}`);
225
+ }
226
+
227
+ function joinDefined(parts: ReadonlyArray<string | undefined>, separator: string): string {
228
+ return parts.filter((part) => part !== undefined).join(separator);
229
+ }
230
+
231
+ function stateLine(entry: AdvisorStatusEntry, theme: AdvisorRenderTheme): string {
232
+ 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
+ );
244
+ }
245
+
246
+ /** Live state line plus any error, as shown at the top of the settings menu. */
247
+ export function advisorStatusHeadline(
248
+ entry: AdvisorStatusEntry,
249
+ theme: AdvisorRenderTheme,
250
+ ): string[] {
251
+ const error = entry.error ?? entry.lastError;
252
+ return [stateLine(entry, theme), ...(error ? [theme.fg("error", `✖ ${error}`)] : [])];
253
+ }
254
+
255
+ function summaryLines(entry: AdvisorStatusEntry, theme: AdvisorRenderTheme): string[] {
256
+ const lines: string[] = [];
257
+ for (const { scope, key, options } of entry.changes ?? []) {
258
+ lines.push(
259
+ `${theme.fg("success", "✓")} ${key} → ${formatAdvisorOption(options, key)} ${theme.fg("dim", `[${scope}]`)}`,
260
+ );
261
+ }
262
+ lines.push(stateLine(entry, theme));
263
+ const children = entry.children?.length ?? 0;
264
+ const activity = joinDefined(
265
+ [
266
+ entry.usage ? `tokens ${formatTokens(entry.usage.total)}` : undefined,
267
+ entry.usage ? formatCost(entry.cost) : undefined,
268
+ children ? `${children} ${children === 1 ? "child" : "children"}` : undefined,
269
+ ],
270
+ " · ",
271
+ );
272
+ if (activity) lines.push(theme.fg("muted", activity));
273
+ if (entry.unavailableTools?.length)
274
+ lines.push(theme.fg("warning", `⚠ unavailable tools: ${entry.unavailableTools.join(", ")}`));
275
+ const error = entry.error ?? entry.lastError;
276
+ if (error) lines.push(theme.fg("error", `✖ ${error}`));
277
+ return lines;
278
+ }
279
+
280
+ function detailLines(entry: AdvisorStatusEntry, theme: AdvisorRenderTheme): string[] {
281
+ const lines: string[] = [];
282
+ if (entry.settings) {
283
+ const settings = entry.settings;
284
+ const width = Math.max(...advisorOptionKeys.map((key) => key.length));
285
+ lines.push("");
286
+ for (const key of advisorOptionKeys) {
287
+ const source = entry.sources?.[key] ?? "default";
288
+ lines.push(
289
+ ` ${key.padEnd(width)} ${formatAdvisorOption(settings, key)} ${theme.fg(source === "default" ? "dim" : "accent", `[${source}]`)}`,
290
+ );
291
+ }
292
+ }
293
+ if (entry.children?.length) lines.push("");
294
+ for (const child of entry.children ?? [])
295
+ lines.push(
296
+ joinDefined(
297
+ [
298
+ ` ${theme.fg("accent", `↳ ${child.agentId}`)}`,
299
+ child.state ? badge(child.state, theme) : undefined,
300
+ child.backlog ? `backlog ${child.backlog}` : undefined,
301
+ ],
302
+ " ",
303
+ ),
304
+ );
305
+ return lines;
306
+ }
307
+
308
+ /** Drop top-level fields that no longer match, so older entries keep what they can show. */
309
+ function salvageStatus(data: Static<typeof minimalStatusSchema>): AdvisorStatusEntry {
310
+ // Runtime copy keeps every recorded field; only mismatching ones are removed below.
311
+ const candidate = structuredClone(data);
312
+ for (const issue of Value.Errors(statusSchema, candidate)) {
313
+ const field = issue.instancePath.split("/")[1];
314
+ if (field && field !== "state") Reflect.deleteProperty(candidate, field);
315
+ }
316
+ return Value.Check(statusSchema, candidate)
317
+ ? candidate
318
+ : { state: data.state, error: data.error ?? null };
319
+ }
320
+
321
+ /** Status snapshot: a compact summary, with every setting and child when expanded. */
322
+ export function renderAdvisorStatus(
323
+ // 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
+ data: unknown,
325
+ expanded: boolean,
326
+ theme: AdvisorRenderTheme,
327
+ ): Component {
328
+ if (!Value.Check(minimalStatusSchema, data))
329
+ return new Text(`Advisor\n${JSON.stringify(data, null, 2)}`, 0, 0);
330
+ const entry = salvageStatus(data);
331
+ const lines = summaryLines(entry, theme);
332
+ 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);
336
+ }
337
+
338
+ /** The live fields the footer summarizes for one watched agent. */
339
+ export interface AdvisorActivity {
340
+ state: AdvisorState;
341
+ backlog: number;
342
+ }
343
+
344
+ function count(amount: number, what: string): string {
345
+ return `${amount} ${amount === 1 ? "child" : "children"} ${what}`;
346
+ }
347
+
348
+ /** Compact footer status; `undefined` clears it. Pause reasons stay in `/advisor status`. */
349
+ export function advisorFooterText(
350
+ root: AdvisorActivity | undefined,
351
+ children: readonly AdvisorActivity[],
352
+ theme: Pick<Theme, "fg">,
353
+ ): string | undefined {
354
+ 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
+ }
361
+ // Consultations come only from the main agent, so a child segment shows Reviews and pauses.
362
+ const busy = children.filter((child) => child.state === "reviewing").length;
363
+ const paused = children.filter((child) => child.state === "paused").length;
364
+ 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(" · ")}`);
368
+ }
@@ -7,7 +7,8 @@ import { Type, type Static } from "typebox";
7
7
  import { Value } from "typebox/value";
8
8
 
9
9
  const positiveInteger = Type.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER });
10
- const optionsSchema = Type.Object(
10
+ /** Authored Advisor options; every key is optional so absent values inherit. */
11
+ export const advisorOptionsSchema = Type.Object(
11
12
  {
12
13
  enabled: Type.Optional(Type.Boolean()),
13
14
  includeSubagents: Type.Optional(Type.Boolean()),
@@ -36,15 +37,26 @@ const optionsSchema = Type.Object(
36
37
  { additionalProperties: false },
37
38
  );
38
39
 
39
- export const advisorOptionKeys = Object.keys(optionsSchema.properties);
40
+ export const advisorOptionKeys = Object.keys(advisorOptionsSchema.properties).map(advisorOptionKey);
40
41
 
41
42
  /** Authored options; absent values inherit rather than disabling their setting. */
42
- export type AdvisorOptions = Static<typeof optionsSchema>;
43
+ export type AdvisorOptions = Static<typeof advisorOptionsSchema>;
43
44
  /** Fully defaulted options; absent model/thinking follows the Observed Agent. */
44
45
  export type AdvisorConfig = Required<Omit<AdvisorOptions, "model" | "thinkingLevel">> &
45
46
  Pick<AdvisorOptions, "model" | "thinkingLevel">;
47
+ /** Native settings/session layer an authored change is written to. */
48
+ export const advisorSettingScopeSchema = Type.Union([
49
+ Type.Literal("session"),
50
+ Type.Literal("global"),
51
+ Type.Literal("project"),
52
+ ]);
53
+ export type AdvisorSettingScope = Static<typeof advisorSettingScopeSchema>;
46
54
  /** Native settings/session layer supplying an effective option. */
47
- export type AdvisorSettingSource = "default" | "global" | "project" | "session";
55
+ export const advisorSettingSourceSchema = Type.Union([
56
+ Type.Literal("default"),
57
+ advisorSettingScopeSchema,
58
+ ]);
59
+ export type AdvisorSettingSource = Static<typeof advisorSettingSourceSchema>;
48
60
 
49
61
  const defaults = {
50
62
  enabled: false,
@@ -62,7 +74,7 @@ const defaults = {
62
74
  const sessionSchema = Type.Object(
63
75
  {
64
76
  version: Type.Literal(1),
65
- overrides: optionsSchema,
77
+ overrides: advisorOptionsSchema,
66
78
  },
67
79
  { additionalProperties: false },
68
80
  );
@@ -70,8 +82,8 @@ const sessionSchema = Type.Object(
70
82
  // oxlint-disable-next-line anti-slop/no-unknown-parameters -- SAFETY: Native settings contain arbitrary authored JSON; this schema validates it before use. SDK command tests exercise the boundary.
71
83
  export function parseAdvisorOptions(value: unknown, source: AdvisorSettingSource): AdvisorOptions {
72
84
  if (value === undefined) return {};
73
- if (!Value.Check(optionsSchema, value)) {
74
- const issue = Value.Errors(optionsSchema, value)[0];
85
+ if (!Value.Check(advisorOptionsSchema, value)) {
86
+ const issue = Value.Errors(advisorOptionsSchema, value)[0];
75
87
  throw new Error(
76
88
  `Invalid ${source} Advisor settings${issue?.instancePath ?? ""}: ${issue?.message ?? "invalid options"}`,
77
89
  );
@@ -81,7 +93,7 @@ export function parseAdvisorOptions(value: unknown, source: AdvisorSettingSource
81
93
 
82
94
  /** Reject inherited or unknown property names before applying an authored change. */
83
95
  export function advisorOptionKey(input: string): keyof AdvisorOptions {
84
- if (!Value.Check(Type.KeyOf(optionsSchema), input))
96
+ if (!Value.Check(Type.KeyOf(advisorOptionsSchema), input))
85
97
  throw new Error(`Unknown Advisor option: ${input}`);
86
98
  return input;
87
99
  }
@@ -100,8 +112,15 @@ export function readAdvisorOverrides(manager: Pick<SessionManager, "getBranch">)
100
112
  export type AdvisorLayers = { global: AdvisorOptions | Error; project: AdvisorOptions | Error };
101
113
  /** A validated authored mutation, independent of its persistence scope. */
102
114
  export type AdvisorChange =
103
- | { action: "set"; patch: AdvisorOptions }
115
+ | { action: "set"; key: keyof AdvisorOptions; patch: AdvisorOptions }
104
116
  | { action: "inherit"; key: keyof AdvisorOptions };
117
+ /** An applied change as recorded for confirmation; `options` is empty when the key inherits. */
118
+ export const advisorAppliedChangeSchema = Type.Object({
119
+ scope: advisorSettingScopeSchema,
120
+ key: Type.KeyOf(advisorOptionsSchema),
121
+ options: advisorOptionsSchema,
122
+ });
123
+ export type AdvisorAppliedChange = Static<typeof advisorAppliedChangeSchema>;
105
124
 
106
125
  /** Read Pi's stored layers, preserving configuration failures until corrected. */
107
126
  export function readAdvisorLayers(manager: SettingsManager): AdvisorLayers {
@@ -142,10 +161,7 @@ export function readAdvisorSettings(
142
161
  ];
143
162
  const settings: AdvisorConfig = structuredClone(defaults);
144
163
  const sources: Record<string, AdvisorSettingSource> = Object.fromEntries(
145
- Object.keys(optionsSchema.properties).map((key): [string, AdvisorSettingSource] => [
146
- key,
147
- "default",
148
- ]),
164
+ advisorOptionKeys.map((key): [string, AdvisorSettingSource] => [key, "default"]),
149
165
  );
150
166
  for (const [source, layer] of layers) {
151
167
  Object.assign(settings, layer);