@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.
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,6 +212,7 @@ 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();
@@ -171,6 +223,7 @@ export default function advisor(pi: ExtensionAPI): void {
171
223
  pi.on("before_agent_start", () => observer?.beforeTask());
172
224
  pi.on("agent_settled", () => observer?.settled());
173
225
  pi.on("session_shutdown", async () => {
226
+ activeMenu?.close();
174
227
  generation++;
175
228
  observed = undefined;
176
229
  unsubscribe?.();
@@ -181,6 +234,8 @@ export default function advisor(pi: ExtensionAPI): void {
181
234
  ]);
182
235
  observer = undefined;
183
236
  children.clear();
237
+ publishState();
238
+ footer = undefined;
184
239
  });
185
240
 
186
241
  function configureChild(
@@ -202,6 +257,7 @@ export default function advisor(pi: ExtensionAPI): void {
202
257
  });
203
258
  warn(`Advisor for ${child.agentId} paused: ${message}`);
204
259
  },
260
+ onStateChange: publishState,
205
261
  onIntervention: (finding) => {
206
262
  if (children.get(session) === child)
207
263
  pi.appendEntry("pi-advisor-child", { agentId: child.agentId, ...finding });
@@ -254,6 +310,8 @@ export default function advisor(pi: ExtensionAPI): void {
254
310
  } catch (cause) {
255
311
  error = cause instanceof Error ? cause.message : String(cause);
256
312
  }
313
+ // A new child observer reports no state change until it reviews; show it now.
314
+ publishState();
257
315
  payload.attach({
258
316
  beginTurn: () => child.observer?.beforeTask(),
259
317
  finishTurn: async () => {
@@ -284,6 +342,7 @@ export default function advisor(pi: ExtensionAPI): void {
284
342
  pi.appendEntry("pi-advisor-status", { state: "paused", error: message });
285
343
  warn(`Advisor paused: ${message}`);
286
344
  },
345
+ onStateChange: publishState,
287
346
  },
288
347
  );
289
348
  for (const [session, child] of children) configureChild(session, child, settings);
@@ -298,25 +357,37 @@ export default function advisor(pi: ExtensionAPI): void {
298
357
  observer = undefined;
299
358
  for (const child of children.values()) child.observer = undefined;
300
359
  }
360
+ publishState();
301
361
  }
302
362
 
303
- function status(ctx: ExtensionContext): void {
363
+ /** Current effective settings and live Advisor state, as recorded in status entries. */
364
+ function statusData(subject: AgentSession): AdvisorStatusEntry {
365
+ const resolved = readAdvisorSettings(subject, layers);
366
+ const live = observer?.status;
367
+ return {
368
+ state: live?.state ?? (resolved.settings.enabled ? "armed" : "disabled"),
369
+ ...resolved,
370
+ backlog: live?.backlog ?? 0,
371
+ effectiveModel: live?.effectiveModel ?? null,
372
+ effectiveThinkingLevel: live?.effectiveThinkingLevel ?? null,
373
+ usage: live?.usage ?? null,
374
+ cost: live?.cost ?? null,
375
+ unavailableTools: live?.unavailableTools ?? null,
376
+ children: [...children.values()].map((child) => ({
377
+ agentId: child.agentId,
378
+ ...child.observer?.status,
379
+ })),
380
+ lastError: live?.lastError ?? null,
381
+ error: error ?? live?.lastError ?? null,
382
+ };
383
+ }
384
+
385
+ function status(ctx: ExtensionContext, changes: readonly AdvisorAppliedChange[] = []): void {
304
386
  try {
305
387
  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
- });
388
+ const entry = statusData(observed);
389
+ if (changes.length) entry.changes = [...changes];
390
+ pi.appendEntry("pi-advisor-status", entry);
320
391
  } catch (cause) {
321
392
  error = cause instanceof Error ? cause.message : String(cause);
322
393
  pi.appendEntry("pi-advisor-status", { state: "paused", error, usage: null, cost: null });
@@ -324,8 +395,116 @@ export default function advisor(pi: ExtensionAPI): void {
324
395
  if (error) ctx.ui.notify(`Advisor: ${error}`, "error");
325
396
  }
326
397
 
398
+ /** Persist one validated change at its scope and reconfigure; undefined if superseded. */
399
+ async function applyChange(
400
+ ctx: ExtensionContext,
401
+ subject: AgentSession,
402
+ scope: AdvisorSettingScope,
403
+ change: AdvisorChange,
404
+ isCurrent: () => boolean,
405
+ ): Promise<AdvisorAppliedChange | undefined> {
406
+ if (scope === "session") {
407
+ const overrides = readAdvisorOverrides(subject.sessionManager);
408
+ if (change.action === "inherit") delete overrides[change.key];
409
+ else Object.assign(overrides, change.patch);
410
+ pi.appendEntry("pi-advisor-settings", { version: 1, overrides });
411
+ } else {
412
+ const updated = await writeAdvisorSettings(subject.settingsManager, scope, change, isCurrent);
413
+ if (!isCurrent() || !updated) return undefined;
414
+ layers[scope] = updated;
415
+ }
416
+ error = undefined;
417
+ await refresh(ctx);
418
+ return { scope, key: change.key, options: change.action === "inherit" ? {} : change.patch };
419
+ }
420
+
421
+ /** The settings menu's view of this extension's settings authority. */
422
+ function menuHost(
423
+ ctx: ExtensionContext,
424
+ subject: AgentSession,
425
+ theme: AdvisorRenderTheme,
426
+ applied: AdvisorAppliedChange[],
427
+ ): AdvisorMenuHost {
428
+ return {
429
+ view() {
430
+ const data = statusData(subject);
431
+ const authored: Partial<AdvisorScopedOptions> = {
432
+ session: readAdvisorOverrides(subject.sessionManager),
433
+ };
434
+ if (!(layers.project instanceof Error)) authored.project = layers.project;
435
+ if (!(layers.global instanceof Error)) authored.global = layers.global;
436
+ return {
437
+ headline: advisorStatusHeadline(data, theme),
438
+ paused: data.state === "paused",
439
+ scopes: subject.settingsManager.isProjectTrusted()
440
+ ? ["session", "project", "global"]
441
+ : ["session", "global"],
442
+ settings: data.settings ?? {},
443
+ sources: data.sources ?? {},
444
+ authored,
445
+ models: ctx.modelRegistry.getAvailable().map((model) => `${model.provider}/${model.id}`),
446
+ tools: pi
447
+ .getAllTools()
448
+ .map((tool) => tool.name)
449
+ .filter((name) => name !== askToolName),
450
+ };
451
+ },
452
+ async apply(scope, change) {
453
+ const stamp = ++generation;
454
+ const result = await applyChange(
455
+ ctx,
456
+ subject,
457
+ scope,
458
+ change,
459
+ () => generation === stamp && observed === subject,
460
+ );
461
+ if (result) applied.push(result);
462
+ },
463
+ async resume() {
464
+ error = undefined;
465
+ await refresh(ctx);
466
+ },
467
+ };
468
+ }
469
+
470
+ /** Native settings list in the editor area, as `/settings` does; one status entry on close. */
471
+ async function openMenu(ctx: ExtensionContext, subject: AgentSession): Promise<void> {
472
+ const applied: AdvisorAppliedChange[] = [];
473
+ let discarded = false;
474
+ let menu: AdvisorSettingsMenu | undefined;
475
+ await ctx.ui.custom<void>((tui, theme, keybindings, done) => {
476
+ const opened = new AdvisorSettingsMenu(
477
+ menuHost(ctx, subject, theme, applied),
478
+ {
479
+ tui,
480
+ keybindings,
481
+ theme,
482
+ externalEditorCommand: subject.settingsManager.getExternalEditorCommand(),
483
+ },
484
+ () => done(),
485
+ );
486
+ menu = opened;
487
+ activeMenu = {
488
+ refresh: () => {
489
+ opened.refresh();
490
+ tui.requestRender();
491
+ },
492
+ close: () => {
493
+ discarded = true;
494
+ activeMenu = undefined;
495
+ done();
496
+ },
497
+ };
498
+ return opened;
499
+ });
500
+ activeMenu = undefined;
501
+ // Edits started before closing are still recorded once they settle.
502
+ await menu?.settled();
503
+ if (!discarded && observed === subject && applied.length) status(ctx, applied);
504
+ }
505
+
327
506
  pi.registerCommand("advisor", {
328
- description: "Advisor on, off, inherit, scoped settings, prompt editor, or status",
507
+ description: "Advisor settings menu, or on, off, inherit, set, prompt, and status",
329
508
  getArgumentCompletions: completeAdvisorCommandArguments,
330
509
  async handler(args, ctx) {
331
510
  if (privateSession) {
@@ -338,10 +517,17 @@ export default function advisor(pi: ExtensionAPI): void {
338
517
  const subject = observed;
339
518
  let stamp = generation;
340
519
  const isCurrent = () => generation === stamp && observed === subject;
520
+ let applied: AdvisorAppliedChange | undefined;
341
521
  try {
342
522
  if (!subject) throw new Error(error ?? "Advisor session is unavailable");
343
523
  const command = parseAdvisorCommand(args);
344
- if (command.action !== "status") {
524
+ if (command.action === "menu" && ctx.mode === "tui") {
525
+ // Opening fails closed to the status entry when settings cannot be read.
526
+ statusData(subject);
527
+ await openMenu(ctx, subject);
528
+ return;
529
+ }
530
+ if (command.action !== "status" && command.action !== "menu") {
345
531
  stamp = ++generation;
346
532
  let change: AdvisorChange;
347
533
  if (command.action === "prompt") {
@@ -354,32 +540,17 @@ export default function advisor(pi: ExtensionAPI): void {
354
540
  if (!isCurrent() || edited === undefined) return;
355
541
  change = {
356
542
  action: "set",
543
+ key: "prompt",
357
544
  patch: parseAdvisorOptions({ prompt: edited }, command.scope),
358
545
  };
359
546
  } 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);
547
+ applied = await applyChange(ctx, subject, command.scope, change, isCurrent);
377
548
  }
378
549
  } catch (cause) {
379
550
  if (!isCurrent()) return;
380
551
  error = cause instanceof Error ? cause.message : String(cause);
381
552
  }
382
- if (isCurrent()) status(ctx);
553
+ if (isCurrent()) status(ctx, applied ? [applied] : []);
383
554
  },
384
555
  });
385
556
  }