@ian-pascoe/pi-advisor 0.3.5 → 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
  }),
@@ -466,6 +470,12 @@ export class AdvisorObserver {
466
470
  label: "Advisor report",
467
471
  description: `Finish the Review with up to ${this.config.maxFindingsPerReview} concise, actionable findings. Prioritize blockers, then concerns, then worthwhile nits.`,
468
472
  parameters: reportSchema,
473
+ annotations: {
474
+ readOnlyHint: false,
475
+ destructiveHint: false,
476
+ idempotentHint: false,
477
+ openWorldHint: false,
478
+ },
469
479
  prepareArguments: (arguments_) => {
470
480
  if (Value.Check(reportSchema, arguments_)) return arguments_;
471
481
  if (!Value.Check(legacyReportSchema, arguments_))
@@ -645,6 +655,7 @@ export class AdvisorObserver {
645
655
  };
646
656
  this.active = consultation;
647
657
  this.consulting = true;
658
+ this.changed();
648
659
  let callerCancelled = false;
649
660
  const cancelFromCaller = () => {
650
661
  callerCancelled = true;
@@ -691,6 +702,7 @@ export class AdvisorObserver {
691
702
  }
692
703
  if (this.active === consultation) this.active = undefined;
693
704
  this.consulting = false;
705
+ this.changed();
694
706
  }
695
707
  }
696
708
 
@@ -731,7 +743,7 @@ export class AdvisorObserver {
731
743
  }
732
744
  }
733
745
 
734
- private async deliver(findings: readonly Finding[], review: Review): Promise<void> {
746
+ private async deliver(findings: readonly AdvisorFinding[], review: Review): Promise<void> {
735
747
  const fresh = findings.filter((finding) => {
736
748
  const deliveredRank = this.delivered.get(normalized(finding.message)) ?? 0;
737
749
  return severityRank[finding.severity] > deliveredRank;
@@ -900,6 +912,7 @@ export class AdvisorObserver {
900
912
  this.runtime = undefined;
901
913
  this.closeRuntime(runtime);
902
914
  }
915
+ this.changed();
903
916
  }
904
917
  /** Owner cancellation invalidates private work without touching observed execution. */
905
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
+ }
@@ -19,7 +19,6 @@ import type {
19
19
  ToolDefinition,
20
20
  } from "@earendil-works/pi-coding-agent";
21
21
  import type { AdvisorConfig } from "./advisor-settings.js";
22
- import { advisorRuntimeWarning, isCallable } from "./advisor-runtime.js";
23
22
 
24
23
  /** Explicit file-selection recipe supplied by the resource owner, never inferred from opaque loaders. */
25
24
  export interface AdvisorResourceInputs {
@@ -149,8 +148,6 @@ export async function createAdvisorSession(
149
148
  observed: AgentSession,
150
149
  options: AdvisorSessionOptions,
151
150
  ): Promise<AgentSessionRuntime> {
152
- const unsupported = advisorRuntimeWarning();
153
- if (unsupported) throw new Error(unsupported);
154
151
  options.signal?.throwIfAborted();
155
152
  const cancelled = Promise.withResolvers<never>();
156
153
  const abort = () =>
@@ -225,10 +222,6 @@ async function buildAdvisorSession(
225
222
  const factoryExport = builtinFactories.get(name);
226
223
  if (builtin && factoryExport) {
227
224
  const create = piSdk[factoryExport];
228
- if (!isCallable(create))
229
- throw new Error(
230
- `Unsupported Advisor resources: this Pi runtime does not export ${factoryExport} to recreate ${extension.path}`,
231
- );
232
225
  recreated.add(index);
233
226
  const codemodeModels = options.resourceInputs?.codemodeModels;
234
227
  builtins.push({
@@ -244,10 +237,6 @@ async function buildAdvisorSession(
244
237
  }
245
238
  // Pi ships a fresh file-backed llama.cpp factory but does not export it.
246
239
  if (builtin && name === "llama.cpp") {
247
- if (!isCallable(piSdk.getPackageDir))
248
- throw new Error(
249
- "Unsupported Advisor resources: this Pi runtime does not export getPackageDir to locate llama.cpp",
250
- );
251
240
  const path = join(piSdk.getPackageDir(), "dist", "extensions", "llama", "index.js");
252
241
  if (!existsSync(path))
253
242
  throw new Error(
@@ -458,6 +447,18 @@ async function buildAdvisorSession(
458
447
  };
459
448
  if (sessionStartEvent) sessionOptions.sessionStartEvent = sessionStartEvent;
460
449
  const created = await piSdk.createAgentSessionFromServices(sessionOptions);
450
+ // Pi's `tools` option is the ceiling and also activates (declares) every named tool. Keep
451
+ // `codemode` and `deferred` grants callable but undeclared, as Pi's own defaults do;
452
+ // later registrations follow the same rule, and extensions can still declare them.
453
+ const scriptOnly = new Set(
454
+ created.session
455
+ .getAllTools()
456
+ .filter(({ exposure }) => exposure === "codemode" || exposure === "deferred")
457
+ .map(({ name }) => name),
458
+ );
459
+ created.session.setActiveToolsByName(
460
+ created.session.getActiveToolNames().filter((name) => !scriptOnly.has(name)),
461
+ );
461
462
  return { ...created, services, diagnostics: services.diagnostics };
462
463
  };
463
464
  const runtime = await piSdk.createAgentSessionRuntime(factory, {