@ian-pascoe/pi-advisor 0.4.0 → 0.6.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.
package/src/index.ts CHANGED
@@ -1,7 +1,11 @@
1
1
  import * as piSdk from "@earendil-works/pi-coding-agent";
2
- import type { AgentSession, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type {
3
+ AgentSession,
4
+ ExtensionAPI,
5
+ ExtensionContext,
6
+ ExtensionUIContext,
7
+ } from "@earendil-works/pi-coding-agent";
3
8
  import { isDeepStrictEqual } from "node:util";
4
- import { Text } from "@earendil-works/pi-tui";
5
9
  import { Type } from "typebox";
6
10
  import { Value } from "typebox/value";
7
11
  import { discoverPiAgentSession } from "@ian-pascoe/pi-utils/pi-agent-session-discovery";
@@ -13,8 +17,27 @@ import {
13
17
  writeAdvisorSettings,
14
18
  type AdvisorLayers,
15
19
  type AdvisorChange,
20
+ type AdvisorAppliedChange,
16
21
  type AdvisorConfig,
22
+ type AdvisorSettingScope,
17
23
  } from "./advisor-settings.js";
24
+ import {
25
+ AdvisorSettingsMenu,
26
+ type AdvisorMenuHost,
27
+ type AdvisorScopedOptions,
28
+ } from "./advisor-menu.js";
29
+ import {
30
+ advisorFooterText,
31
+ advisorStatusHeadline,
32
+ renderAdvisorAskCall,
33
+ renderAdvisorAskResult,
34
+ renderAdvisorChildEntry,
35
+ renderAdvisorIntervention,
36
+ renderAdvisorStatus,
37
+ type AdvisorActivity,
38
+ type AdvisorRenderTheme,
39
+ type AdvisorStatusEntry,
40
+ } from "./advisor-rendering.js";
18
41
  import { completeAdvisorCommandArguments, parseAdvisorCommand } from "./advisor-command.js";
19
42
  import { AdvisorObserver } from "./advisor-observer.js";
20
43
  import { isAdvisorSession, type AdvisorResourceInputs } from "./advisor-session.js";
@@ -46,6 +69,9 @@ const childRequestSchema = Type.Object({
46
69
  /** Review an observed session without changing its tools or standing instructions. */
47
70
  export default function advisor(pi: ExtensionAPI): void {
48
71
  let warn: (message: string) => void = () => {};
72
+ let footer: ExtensionUIContext | undefined;
73
+ /** The open settings menu, refreshed on state changes and closed on session changes. */
74
+ let activeMenu: { refresh: () => void; close: () => void } | undefined;
49
75
  let observed: AgentSession | undefined;
50
76
  let error: string | undefined;
51
77
  let layers: AdvisorLayers = { global: {}, project: {} };
@@ -60,10 +86,31 @@ export default function advisor(pi: ExtensionAPI): void {
60
86
  attachChild,
61
87
  );
62
88
 
63
- pi.registerEntryRenderer(
64
- "pi-advisor-child",
65
- (entry) => new Text(`Advisor for Child Agent\n${JSON.stringify(entry.data, null, 2)}`, 0, 0),
89
+ pi.registerEntryRenderer("pi-advisor-child", (entry, { expanded }, theme) =>
90
+ renderAdvisorChildEntry(entry.data, expanded, theme),
91
+ );
92
+ pi.registerEntryRenderer("pi-advisor-status", (entry, { expanded }, theme) =>
93
+ renderAdvisorStatus(entry.data, expanded, theme),
66
94
  );
95
+ pi.registerMessageRenderer("pi-advisor", (message, options, theme) =>
96
+ renderAdvisorIntervention(message.details, options, theme),
97
+ );
98
+
99
+ /** Show the current Advisor state in the open settings menu and the footer. */
100
+ function publishState(): void {
101
+ activeMenu?.refresh();
102
+ if (!footer) return;
103
+ const root: AdvisorActivity | undefined =
104
+ observer?.status ?? (observed && error ? { state: "paused", backlog: 0 } : undefined);
105
+ const watched = [...children.values()].flatMap((child) =>
106
+ child.observer ? [child.observer.status] : [],
107
+ );
108
+ try {
109
+ footer.setStatus("advisor", advisorFooterText(root, watched, footer.theme));
110
+ } catch {
111
+ // A replaced session's UI is stale; status entries remain authoritative.
112
+ }
113
+ }
67
114
 
68
115
  function setAskToolAvailable(available: boolean): void {
69
116
  if (available && !askToolRegistered) {
@@ -80,6 +127,13 @@ export default function advisor(pi: ExtensionAPI): void {
80
127
  openWorldHint: false,
81
128
  },
82
129
  executionMode: "sequential",
130
+ renderCall: (args, theme, context) => renderAdvisorAskCall(args, context.expanded, theme),
131
+ renderResult: (result, options, theme, context) =>
132
+ renderAdvisorAskResult(
133
+ result.content.map((item) => (item.type === "text" ? item.text : "")).join(""),
134
+ { expanded: options.expanded, isPartial: options.isPartial, isError: context.isError },
135
+ theme,
136
+ ),
83
137
  execute: async (_id, { message }, signal) => {
84
138
  try {
85
139
  const current = observer;
@@ -108,12 +162,8 @@ export default function advisor(pi: ExtensionAPI): void {
108
162
  );
109
163
  }
110
164
 
111
- pi.registerEntryRenderer(
112
- "pi-advisor-status",
113
- (entry) => new Text(`Advisor\n${JSON.stringify(entry.data, null, 2)}`, 0, 0),
114
- );
115
-
116
165
  pi.on("session_start", async (_event, ctx) => {
166
+ activeMenu?.close();
117
167
  const stamp = ++generation;
118
168
  const previous = observer;
119
169
  observer = undefined;
@@ -143,6 +193,7 @@ export default function advisor(pi: ExtensionAPI): void {
143
193
  return;
144
194
  }
145
195
  const { ui } = ctx;
196
+ footer = ctx.hasUI ? ui : undefined;
146
197
  warn = (message) => {
147
198
  try {
148
199
  ui.notify(message, "warning");
@@ -161,16 +212,17 @@ export default function advisor(pi: ExtensionAPI): void {
161
212
  }
162
213
  });
163
214
  pi.on("session_tree", async (_event, ctx) => {
215
+ activeMenu?.close();
164
216
  generation++;
165
217
  observer?.reset();
166
218
  for (const child of children.values()) child.observer?.reset();
167
219
  await refresh(ctx);
168
220
  });
169
- pi.on("session_compact", () => observer?.reset());
170
221
  pi.on("model_select", () => observer?.reset());
171
222
  pi.on("before_agent_start", () => observer?.beforeTask());
172
223
  pi.on("agent_settled", () => observer?.settled());
173
224
  pi.on("session_shutdown", async () => {
225
+ activeMenu?.close();
174
226
  generation++;
175
227
  observed = undefined;
176
228
  unsubscribe?.();
@@ -181,6 +233,8 @@ export default function advisor(pi: ExtensionAPI): void {
181
233
  ]);
182
234
  observer = undefined;
183
235
  children.clear();
236
+ publishState();
237
+ footer = undefined;
184
238
  });
185
239
 
186
240
  function configureChild(
@@ -202,6 +256,7 @@ export default function advisor(pi: ExtensionAPI): void {
202
256
  });
203
257
  warn(`Advisor for ${child.agentId} paused: ${message}`);
204
258
  },
259
+ onStateChange: publishState,
205
260
  onIntervention: (finding) => {
206
261
  if (children.get(session) === child)
207
262
  pi.appendEntry("pi-advisor-child", { agentId: child.agentId, ...finding });
@@ -254,6 +309,8 @@ export default function advisor(pi: ExtensionAPI): void {
254
309
  } catch (cause) {
255
310
  error = cause instanceof Error ? cause.message : String(cause);
256
311
  }
312
+ // A new child observer reports no state change until it reviews; show it now.
313
+ publishState();
257
314
  payload.attach({
258
315
  beginTurn: () => child.observer?.beforeTask(),
259
316
  finishTurn: async () => {
@@ -284,6 +341,7 @@ export default function advisor(pi: ExtensionAPI): void {
284
341
  pi.appendEntry("pi-advisor-status", { state: "paused", error: message });
285
342
  warn(`Advisor paused: ${message}`);
286
343
  },
344
+ onStateChange: publishState,
287
345
  },
288
346
  );
289
347
  for (const [session, child] of children) configureChild(session, child, settings);
@@ -298,25 +356,45 @@ export default function advisor(pi: ExtensionAPI): void {
298
356
  observer = undefined;
299
357
  for (const child of children.values()) child.observer = undefined;
300
358
  }
359
+ publishState();
360
+ }
361
+
362
+ /** Current effective settings and live Advisor state, as recorded in status entries. */
363
+ function statusData(subject: AgentSession): AdvisorStatusEntry {
364
+ const resolved = readAdvisorSettings(subject, layers);
365
+ const live = observer?.status;
366
+ return {
367
+ state: live?.state ?? (resolved.settings.enabled ? "armed" : "disabled"),
368
+ ...resolved,
369
+ backlog: live?.backlog ?? 0,
370
+ effectiveModel: live?.effectiveModel ?? null,
371
+ effectiveThinkingLevel: live?.effectiveThinkingLevel ?? null,
372
+ usage: live?.usage ?? null,
373
+ cost: live?.cost ?? null,
374
+ reviewCost: live?.reviewCost ?? null,
375
+ deferredFindings: live?.deferredFindings ?? 0,
376
+ droppedFindings: live?.droppedFindings ?? {
377
+ overNitCap: 0,
378
+ unsupported: 0,
379
+ superseded: 0,
380
+ invalidReviews: 0,
381
+ },
382
+ unavailableTools: live?.unavailableTools ?? null,
383
+ children: [...children.values()].map((child) => ({
384
+ agentId: child.agentId,
385
+ ...child.observer?.status,
386
+ })),
387
+ lastError: live?.lastError ?? null,
388
+ error: error ?? live?.lastError ?? null,
389
+ };
301
390
  }
302
391
 
303
- function status(ctx: ExtensionContext): void {
392
+ function status(ctx: ExtensionContext, changes: readonly AdvisorAppliedChange[] = []): void {
304
393
  try {
305
394
  if (!observed) throw new Error(error ?? "Advisor session is unavailable");
306
- const resolved = readAdvisorSettings(observed, layers);
307
- pi.appendEntry("pi-advisor-status", {
308
- state: resolved.settings.enabled ? "armed" : "disabled",
309
- ...resolved,
310
- backlog: 0,
311
- usage: null,
312
- cost: null,
313
- ...observer?.status,
314
- children: [...children.values()].map((child) => ({
315
- agentId: child.agentId,
316
- ...child.observer?.status,
317
- })),
318
- error: error ?? observer?.status.lastError ?? null,
319
- });
395
+ const entry = statusData(observed);
396
+ if (changes.length) entry.changes = [...changes];
397
+ pi.appendEntry("pi-advisor-status", entry);
320
398
  } catch (cause) {
321
399
  error = cause instanceof Error ? cause.message : String(cause);
322
400
  pi.appendEntry("pi-advisor-status", { state: "paused", error, usage: null, cost: null });
@@ -324,8 +402,116 @@ export default function advisor(pi: ExtensionAPI): void {
324
402
  if (error) ctx.ui.notify(`Advisor: ${error}`, "error");
325
403
  }
326
404
 
405
+ /** Persist one validated change at its scope and reconfigure; undefined if superseded. */
406
+ async function applyChange(
407
+ ctx: ExtensionContext,
408
+ subject: AgentSession,
409
+ scope: AdvisorSettingScope,
410
+ change: AdvisorChange,
411
+ isCurrent: () => boolean,
412
+ ): Promise<AdvisorAppliedChange | undefined> {
413
+ if (scope === "session") {
414
+ const overrides = readAdvisorOverrides(subject.sessionManager);
415
+ if (change.action === "inherit") delete overrides[change.key];
416
+ else Object.assign(overrides, change.patch);
417
+ pi.appendEntry("pi-advisor-settings", { version: 1, overrides });
418
+ } else {
419
+ const updated = await writeAdvisorSettings(subject.settingsManager, scope, change, isCurrent);
420
+ if (!isCurrent() || !updated) return undefined;
421
+ layers[scope] = updated;
422
+ }
423
+ error = undefined;
424
+ await refresh(ctx);
425
+ return { scope, key: change.key, options: change.action === "inherit" ? {} : change.patch };
426
+ }
427
+
428
+ /** The settings menu's view of this extension's settings authority. */
429
+ function menuHost(
430
+ ctx: ExtensionContext,
431
+ subject: AgentSession,
432
+ theme: AdvisorRenderTheme,
433
+ applied: AdvisorAppliedChange[],
434
+ ): AdvisorMenuHost {
435
+ return {
436
+ view() {
437
+ const data = statusData(subject);
438
+ const authored: Partial<AdvisorScopedOptions> = {
439
+ session: readAdvisorOverrides(subject.sessionManager),
440
+ };
441
+ if (!(layers.project instanceof Error)) authored.project = layers.project;
442
+ if (!(layers.global instanceof Error)) authored.global = layers.global;
443
+ return {
444
+ headline: advisorStatusHeadline(data, theme),
445
+ paused: data.state === "paused",
446
+ scopes: subject.settingsManager.isProjectTrusted()
447
+ ? ["session", "project", "global"]
448
+ : ["session", "global"],
449
+ settings: data.settings ?? {},
450
+ sources: data.sources ?? {},
451
+ authored,
452
+ models: ctx.modelRegistry.getAvailable().map((model) => `${model.provider}/${model.id}`),
453
+ tools: pi
454
+ .getAllTools()
455
+ .map((tool) => tool.name)
456
+ .filter((name) => name !== askToolName),
457
+ };
458
+ },
459
+ async apply(scope, change) {
460
+ const stamp = ++generation;
461
+ const result = await applyChange(
462
+ ctx,
463
+ subject,
464
+ scope,
465
+ change,
466
+ () => generation === stamp && observed === subject,
467
+ );
468
+ if (result) applied.push(result);
469
+ },
470
+ async resume() {
471
+ error = undefined;
472
+ await refresh(ctx);
473
+ },
474
+ };
475
+ }
476
+
477
+ /** Native settings list in the editor area, as `/settings` does; one status entry on close. */
478
+ async function openMenu(ctx: ExtensionContext, subject: AgentSession): Promise<void> {
479
+ const applied: AdvisorAppliedChange[] = [];
480
+ let discarded = false;
481
+ let menu: AdvisorSettingsMenu | undefined;
482
+ await ctx.ui.custom<void>((tui, theme, keybindings, done) => {
483
+ const opened = new AdvisorSettingsMenu(
484
+ menuHost(ctx, subject, theme, applied),
485
+ {
486
+ tui,
487
+ keybindings,
488
+ theme,
489
+ externalEditorCommand: subject.settingsManager.getExternalEditorCommand(),
490
+ },
491
+ () => done(),
492
+ );
493
+ menu = opened;
494
+ activeMenu = {
495
+ refresh: () => {
496
+ opened.refresh();
497
+ tui.requestRender();
498
+ },
499
+ close: () => {
500
+ discarded = true;
501
+ activeMenu = undefined;
502
+ done();
503
+ },
504
+ };
505
+ return opened;
506
+ });
507
+ activeMenu = undefined;
508
+ // Edits started before closing are still recorded once they settle.
509
+ await menu?.settled();
510
+ if (!discarded && observed === subject && applied.length) status(ctx, applied);
511
+ }
512
+
327
513
  pi.registerCommand("advisor", {
328
- description: "Advisor on, off, inherit, scoped settings, prompt editor, or status",
514
+ description: "Advisor settings menu, or on, off, inherit, set, prompt, and status",
329
515
  getArgumentCompletions: completeAdvisorCommandArguments,
330
516
  async handler(args, ctx) {
331
517
  if (privateSession) {
@@ -338,10 +524,17 @@ export default function advisor(pi: ExtensionAPI): void {
338
524
  const subject = observed;
339
525
  let stamp = generation;
340
526
  const isCurrent = () => generation === stamp && observed === subject;
527
+ let applied: AdvisorAppliedChange | undefined;
341
528
  try {
342
529
  if (!subject) throw new Error(error ?? "Advisor session is unavailable");
343
530
  const command = parseAdvisorCommand(args);
344
- if (command.action !== "status") {
531
+ if (command.action === "menu" && ctx.mode === "tui") {
532
+ // Opening fails closed to the status entry when settings cannot be read.
533
+ statusData(subject);
534
+ await openMenu(ctx, subject);
535
+ return;
536
+ }
537
+ if (command.action !== "status" && command.action !== "menu") {
345
538
  stamp = ++generation;
346
539
  let change: AdvisorChange;
347
540
  if (command.action === "prompt") {
@@ -354,32 +547,17 @@ export default function advisor(pi: ExtensionAPI): void {
354
547
  if (!isCurrent() || edited === undefined) return;
355
548
  change = {
356
549
  action: "set",
550
+ key: "prompt",
357
551
  patch: parseAdvisorOptions({ prompt: edited }, command.scope),
358
552
  };
359
553
  } else change = command;
360
- if (command.scope === "session") {
361
- const overrides = readAdvisorOverrides(subject.sessionManager);
362
- if (change.action === "inherit") delete overrides[change.key];
363
- else Object.assign(overrides, change.patch);
364
- pi.appendEntry("pi-advisor-settings", { version: 1, overrides });
365
- } else {
366
- const updated = await writeAdvisorSettings(
367
- subject.settingsManager,
368
- command.scope,
369
- change,
370
- isCurrent,
371
- );
372
- if (!isCurrent() || !updated) return;
373
- layers[command.scope] = updated;
374
- }
375
- error = undefined;
376
- await refresh(ctx);
554
+ applied = await applyChange(ctx, subject, command.scope, change, isCurrent);
377
555
  }
378
556
  } catch (cause) {
379
557
  if (!isCurrent()) return;
380
558
  error = cause instanceof Error ? cause.message : String(cause);
381
559
  }
382
- if (isCurrent()) status(ctx);
560
+ if (isCurrent()) status(ctx, applied ? [applied] : []);
383
561
  },
384
562
  });
385
563
  }