@gotgenes/pi-subagents 6.9.0 → 6.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,23 @@
1
1
  import { debugLog } from "./debug.js";
2
- import type { AgentRecord, NotificationDetails } from "./types.js";
2
+ import type { AgentRecord } from "./types.js";
3
3
  import type { AgentActivityTracker } from "./ui/agent-activity-tracker.js";
4
4
  import { getLifetimeTotal, getSessionContextPercent } from "./usage.js";
5
5
 
6
+ /** Details attached to custom notification messages for visual rendering. */
7
+ export interface NotificationDetails {
8
+ id: string;
9
+ description: string;
10
+ status: string;
11
+ toolUses: number;
12
+ turnCount: number;
13
+ maxTurns?: number;
14
+ totalTokens: number;
15
+ durationMs: number;
16
+ outputFile?: string;
17
+ error?: string;
18
+ resultPreview: string;
19
+ }
20
+
6
21
  // ---- Pure helpers (exported for unit testing) ----
7
22
 
8
23
  /** Escape XML special characters to prevent injection in structured notifications. */
@@ -129,23 +144,43 @@ export interface NotificationSystem {
129
144
 
130
145
  const NUDGE_HOLD_MS = 200;
131
146
 
132
- export function createNotificationSystem(deps: NotificationDeps): NotificationSystem {
133
- const pendingNudges = new Map<string, ReturnType<typeof setTimeout>>();
147
+ export class NotificationManager implements NotificationSystem {
148
+ private pendingNudges = new Map<string, ReturnType<typeof setTimeout>>();
134
149
 
135
- function cancelNudge(key: string) {
136
- const timer = pendingNudges.get(key);
150
+ constructor(private deps: NotificationDeps) {}
151
+
152
+ cancelNudge(key: string): void {
153
+ const timer = this.pendingNudges.get(key);
137
154
  if (timer != null) {
138
155
  clearTimeout(timer);
139
- pendingNudges.delete(key);
156
+ this.pendingNudges.delete(key);
140
157
  }
141
158
  }
142
159
 
143
- function scheduleNudge(key: string, send: () => void, delay = NUDGE_HOLD_MS) {
144
- cancelNudge(key);
145
- pendingNudges.set(
160
+ sendCompletion(record: AgentRecord): void {
161
+ this.deps.agentActivity.delete(record.id);
162
+ this.deps.markFinished(record.id);
163
+ this.scheduleNudge(record.id, () => this.emitIndividualNudge(record));
164
+ this.deps.updateWidget();
165
+ }
166
+
167
+ cleanupCompleted(id: string): void {
168
+ this.deps.agentActivity.delete(id);
169
+ this.deps.markFinished(id);
170
+ this.deps.updateWidget();
171
+ }
172
+
173
+ dispose(): void {
174
+ for (const timer of this.pendingNudges.values()) clearTimeout(timer);
175
+ this.pendingNudges.clear();
176
+ }
177
+
178
+ private scheduleNudge(key: string, send: () => void, delay = NUDGE_HOLD_MS): void {
179
+ this.cancelNudge(key);
180
+ this.pendingNudges.set(
146
181
  key,
147
182
  setTimeout(() => {
148
- pendingNudges.delete(key);
183
+ this.pendingNudges.delete(key);
149
184
  try {
150
185
  send();
151
186
  } catch (err) {
@@ -155,41 +190,21 @@ export function createNotificationSystem(deps: NotificationDeps): NotificationSy
155
190
  );
156
191
  }
157
192
 
158
- function emitIndividualNudge(record: AgentRecord) {
193
+ private emitIndividualNudge(record: AgentRecord): void {
159
194
  if (record.notification?.resultConsumed) return;
160
195
 
161
196
  const notification = formatTaskNotification(record, 500);
162
197
  const outputFile = record.execution?.outputFile;
163
198
  const footer = outputFile ? `\nFull transcript available at: ${outputFile}` : "";
164
199
 
165
- deps.sendMessage(
200
+ this.deps.sendMessage(
166
201
  {
167
202
  customType: "subagent-notification",
168
203
  content: notification + footer,
169
204
  display: true,
170
- details: buildNotificationDetails(record, 500, deps.agentActivity.get(record.id)),
205
+ details: buildNotificationDetails(record, 500, this.deps.agentActivity.get(record.id)),
171
206
  },
172
207
  { deliverAs: "followUp", triggerTurn: true },
173
208
  );
174
209
  }
175
-
176
- function sendCompletion(record: AgentRecord) {
177
- deps.agentActivity.delete(record.id);
178
- deps.markFinished(record.id);
179
- scheduleNudge(record.id, () => emitIndividualNudge(record));
180
- deps.updateWidget();
181
- }
182
-
183
- function cleanupCompleted(id: string) {
184
- deps.agentActivity.delete(id);
185
- deps.markFinished(id);
186
- deps.updateWidget();
187
- }
188
-
189
- function dispose() {
190
- for (const timer of pendingNudges.values()) clearTimeout(timer);
191
- pendingNudges.clear();
192
- }
193
-
194
- return { cancelNudge, sendCompletion, cleanupCompleted, dispose };
195
210
  }
@@ -4,7 +4,26 @@
4
4
 
5
5
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
6
6
  import { buildParentContext } from "./context.js";
7
- import type { ParentSnapshot } from "./types.js";
7
+
8
+ /**
9
+ * Plain data snapshot of the parent session state captured at spawn time.
10
+ * Replaces live `ExtensionContext` references so queued agents don't read stale state.
11
+ */
12
+ export interface ParentSnapshot {
13
+ /** Parent working directory. */
14
+ cwd: string;
15
+ /** Parent's effective system prompt (for append-mode agents). */
16
+ systemPrompt: string;
17
+ /** Parent's current model instance (fallback when agent config has no model). */
18
+ model: unknown;
19
+ /** Model registry for resolving config.model strings and creating sessions. */
20
+ modelRegistry: {
21
+ find(provider: string, modelId: string): unknown;
22
+ getAvailable?(): Array<{ provider: string; id: string }>;
23
+ };
24
+ /** Pre-built parent conversation text (when inheritContext was requested). */
25
+ parentContext?: string;
26
+ }
8
27
 
9
28
  /**
10
29
  * Build an immutable snapshot of the parent session state.
package/src/prompts.ts CHANGED
@@ -2,7 +2,8 @@
2
2
  * prompts.ts — System prompt builder for agents.
3
3
  */
4
4
 
5
- import type { AgentConfig, EnvInfo } from "./types.js";
5
+ import type { EnvInfo } from "./env.js";
6
+ import type { AgentPromptConfig } from "./types.js";
6
7
 
7
8
  /** Extra sections to inject into the system prompt (memory, skills, etc.). */
8
9
  export interface PromptExtras {
@@ -27,7 +28,7 @@ export interface PromptExtras {
27
28
  * @param extras Optional extra sections to inject (memory, preloaded skills).
28
29
  */
29
30
  export function buildAgentPrompt(
30
- config: AgentConfig,
31
+ config: AgentPromptConfig,
31
32
  cwd: string,
32
33
  env: EnvInfo,
33
34
  parentSystemPrompt?: string,
package/src/renderer.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Text } from "@earendil-works/pi-tui";
2
- import type { NotificationDetails } from "./types.js";
2
+ import type { NotificationDetails } from "./notification.js";
3
3
  import { formatMs, formatTokens, formatTurns } from "./ui/agent-widget.js";
4
4
 
5
5
  /** Narrow theme interface — only the methods the renderer actually calls. */
@@ -15,10 +15,11 @@ import {
15
15
  getMemoryToolNames,
16
16
  getReadOnlyMemoryToolNames,
17
17
  } from "./agent-types.js";
18
+ import type { EnvInfo } from "./env.js";
18
19
  import { buildMemoryBlock, buildReadOnlyMemoryBlock } from "./memory.js";
19
20
  import { buildAgentPrompt, type PromptExtras } from "./prompts.js";
20
21
  import { preloadSkills } from "./skill-loader.js";
21
- import type { EnvInfo, SubagentType, ThinkingLevel } from "./types.js";
22
+ import type { SubagentType, ThinkingLevel } from "./types.js";
22
23
 
23
24
  // ── Public interfaces ────────────────────────────────────────────────────────
24
25
 
@@ -7,13 +7,11 @@ import { AgentTypeRegistry } from "../agent-types.js";
7
7
  import { resolveAgentInvocationConfig } from "../invocation-config.js";
8
8
  import { resolveInvocationModel } from "../model-resolver.js";
9
9
 
10
- import { NotificationState } from "../notification-state.js";
11
10
  import type { AgentInvocation, AgentRecord, SubagentType } from "../types.js";
12
11
  import { AgentActivityTracker } from "../ui/agent-activity-tracker.js";
13
12
  import {
14
13
  type AgentDetails,
15
14
  buildInvocationTags,
16
- describeActivity,
17
15
  formatMs,
18
16
  formatTurns,
19
17
  getDisplayName,
@@ -21,55 +19,9 @@ import {
21
19
  SPINNER,
22
20
  type UICtx,
23
21
  } from "../ui/agent-widget.js";
24
- import { subscribeUIObserver } from "../ui/ui-observer.js";
25
- import type { LifetimeUsage } from "../usage.js";
26
- import { buildTypeListText, formatLifetimeTokens, textResult } from "./helpers.js";
27
-
28
- // ---- Agent-tool-specific helpers ----
29
-
30
- /** Parenthetical status note for completed agent result text. */
31
- export function getStatusNote(status: string): string {
32
- switch (status) {
33
- case "aborted":
34
- return " (aborted — max turns exceeded, output may be incomplete)";
35
- case "steered":
36
- return " (wrapped up — reached turn limit)";
37
- case "stopped":
38
- return " (stopped by user)";
39
- default:
40
- return "";
41
- }
42
- }
43
-
44
- /** Build AgentDetails from a base + record-specific fields. */
45
- export function buildDetails(
46
- base: Pick<AgentDetails, "displayName" | "description" | "subagentType" | "modelName" | "tags">,
47
- record: {
48
- toolUses: number;
49
- startedAt: number;
50
- completedAt?: number;
51
- status: string;
52
- error?: string;
53
- id?: string;
54
- session?: any;
55
- lifetimeUsage: LifetimeUsage;
56
- },
57
- activity?: AgentActivityTracker,
58
- overrides?: Partial<AgentDetails>,
59
- ): AgentDetails {
60
- return {
61
- ...base,
62
- toolUses: record.toolUses,
63
- tokens: formatLifetimeTokens(record),
64
- turnCount: activity?.turnCount,
65
- maxTurns: activity?.maxTurns,
66
- durationMs: (record.completedAt ?? Date.now()) - record.startedAt,
67
- status: record.status as AgentDetails["status"],
68
- agentId: record.id,
69
- error: record.error,
70
- ...overrides,
71
- };
72
- }
22
+ import { spawnBackground } from "./background-spawner.js";
23
+ import { runForeground } from "./foreground-runner.js";
24
+ import { buildDetails, buildTypeListText, formatLifetimeTokens, getStatusNote, textResult } from "./helpers.js";
73
25
 
74
26
  // ---- Deps interface ----
75
27
 
@@ -80,7 +32,6 @@ export interface AgentToolManager {
80
32
  resume: (id: string, prompt: string, signal: AbortSignal) => Promise<AgentRecord | undefined>;
81
33
  getRecord: (id: string) => AgentRecord | undefined;
82
34
  getMaxConcurrent: () => number;
83
- listAgents: () => AgentRecord[];
84
35
  }
85
36
 
86
37
  /** Narrow widget interface — only the methods the Agent tool calls. */
@@ -412,167 +363,48 @@ Guidelines:
412
363
 
413
364
  // Background execution
414
365
  if (runInBackground) {
415
- const bgState = new AgentActivityTracker(effectiveMaxTurns);
416
-
417
- let id: string;
418
-
419
- try {
420
- id = deps.manager.spawn(ctx, subagentType, params.prompt as string, {
421
- parentSessionFile: ctx.sessionManager.getSessionFile(),
422
- parentSessionId: ctx.sessionManager.getSessionId(),
366
+ return spawnBackground(
367
+ { manager: deps.manager, widget: deps.widget, agentActivity: deps.agentActivity },
368
+ {
369
+ ctx,
370
+ subagentType,
371
+ prompt: params.prompt as string,
423
372
  description: params.description as string,
373
+ displayName,
374
+ toolCallId,
375
+ detailBase,
424
376
  model,
425
- maxTurns: effectiveMaxTurns,
377
+ effectiveMaxTurns,
426
378
  isolated,
427
379
  inheritContext,
428
- thinkingLevel: thinking,
429
- isBackground: true,
380
+ thinking,
430
381
  isolation,
431
- invocation: agentInvocation,
432
- onSessionCreated: (session: any) => {
433
- bgState.setSession(session);
434
- subscribeUIObserver(session, bgState);
435
- },
436
- });
437
- } catch (err) {
438
- return textResult(err instanceof Error ? err.message : String(err));
439
- }
440
-
441
- const record = deps.manager.getRecord(id);
442
- if (record) {
443
- // Born complete: notification-state object owns toolCallId + resultConsumed.
444
- record.notification = new NotificationState(toolCallId);
445
- }
446
-
447
- deps.agentActivity.set(id, bgState);
448
- deps.widget.ensureTimer();
449
- deps.widget.update();
450
-
451
- const isQueued = record?.status === "queued";
452
- return textResult(
453
- `Agent ${isQueued ? "queued" : "started"} in background.\n` +
454
- `Agent ID: ${id}\n` +
455
- `Type: ${displayName}\n` +
456
- `Description: ${params.description}\n` +
457
- (record?.execution?.outputFile ? `Output file: ${record.execution.outputFile}\n` : "") +
458
- (isQueued
459
- ? `Position: queued (max ${deps.manager.getMaxConcurrent()} concurrent)\n`
460
- : "") +
461
- `\nYou will be notified when this agent completes.\n` +
462
- `Use get_subagent_result to retrieve full results, or steer_subagent to send it messages.\n` +
463
- `Do not duplicate this agent's work.`,
464
- {
465
- ...detailBase,
466
- toolUses: 0,
467
- tokens: "",
468
- durationMs: 0,
469
- status: "background" as const,
470
- agentId: id,
382
+ agentInvocation,
471
383
  },
472
384
  );
473
385
  }
474
386
 
475
387
  // Foreground (synchronous) execution — stream progress via onUpdate
476
- let spinnerFrame = 0;
477
- const startedAt = Date.now();
478
- let fgId: string | undefined;
479
-
480
- const fgState = new AgentActivityTracker(effectiveMaxTurns);
481
- let unsubUI: (() => void) | undefined;
482
-
483
- const streamUpdate = () => {
484
- const details: AgentDetails = {
485
- ...detailBase,
486
- toolUses: fgState.toolUses,
487
- tokens: formatLifetimeTokens(fgState),
488
- turnCount: fgState.turnCount,
489
- maxTurns: fgState.maxTurns,
490
- durationMs: Date.now() - startedAt,
491
- status: "running",
492
- activity: describeActivity(fgState.activeTools, fgState.responseText),
493
- spinnerFrame: spinnerFrame % SPINNER.length,
494
- };
495
- onUpdate?.({
496
- content: [{ type: "text", text: `${fgState.toolUses} tool uses...` }],
497
- details: details as any,
498
- });
499
- };
500
-
501
- // Animate spinner at ~80ms (smooth rotation through 10 braille frames)
502
- const spinnerInterval = setInterval(() => {
503
- spinnerFrame++;
504
- streamUpdate();
505
- }, 80);
506
-
507
- streamUpdate();
508
-
509
- let record: AgentRecord;
510
- try {
511
- record = await deps.manager.spawnAndWait(
388
+ return runForeground(
389
+ { manager: deps.manager, widget: deps.widget, agentActivity: deps.agentActivity },
390
+ {
512
391
  ctx,
513
392
  subagentType,
514
- params.prompt as string,
515
- {
516
- description: params.description as string,
517
- model,
518
- maxTurns: effectiveMaxTurns,
519
- isolated,
520
- inheritContext,
521
- thinkingLevel: thinking,
522
- isolation,
523
- invocation: agentInvocation,
524
- signal,
525
- parentSessionFile: ctx.sessionManager.getSessionFile(),
526
- parentSessionId: ctx.sessionManager.getSessionId(),
527
- onSessionCreated: (session: any) => {
528
- fgState.setSession(session);
529
- unsubUI = subscribeUIObserver(session, fgState, streamUpdate);
530
- for (const a of deps.manager.listAgents()) {
531
- if (a.execution?.session === session) {
532
- fgId = a.id;
533
- deps.agentActivity.set(a.id, fgState);
534
- deps.widget.ensureTimer();
535
- break;
536
- }
537
- }
538
- },
539
- },
540
- );
541
- } catch (err) {
542
- clearInterval(spinnerInterval);
543
- unsubUI?.();
544
- return textResult(err instanceof Error ? err.message : String(err));
545
- }
546
-
547
- clearInterval(spinnerInterval);
548
- unsubUI?.();
549
-
550
- // Clean up foreground agent from widget
551
- if (fgId) {
552
- deps.agentActivity.delete(fgId);
553
- deps.widget.markFinished(fgId);
554
- }
555
-
556
- // Get final token count
557
- const tokenText = formatLifetimeTokens(fgState);
558
-
559
- const details = buildDetails(detailBase, record, fgState, { tokens: tokenText });
560
-
561
- const fallbackNote = fellBack
562
- ? `Note: Unknown agent type "${rawType}" — using general-purpose.\n\n`
563
- : "";
564
-
565
- if (record.status === "error") {
566
- return textResult(`${fallbackNote}Agent failed: ${record.error}`, details);
567
- }
568
-
569
- const durationMs = (record.completedAt ?? Date.now()) - record.startedAt;
570
- const statsParts = [`${record.toolUses} tool uses`];
571
- if (tokenText) statsParts.push(tokenText);
572
- return textResult(
573
- `${fallbackNote}Agent completed in ${formatMs(durationMs)} (${statsParts.join(", ")})${getStatusNote(record.status)}.\n\n` +
574
- (record.result?.trim() || "No output."),
575
- details,
393
+ prompt: params.prompt as string,
394
+ description: params.description as string,
395
+ detailBase,
396
+ rawType,
397
+ fellBack,
398
+ model,
399
+ effectiveMaxTurns,
400
+ isolated,
401
+ inheritContext,
402
+ thinking,
403
+ isolation,
404
+ agentInvocation,
405
+ },
406
+ signal,
407
+ onUpdate,
576
408
  );
577
409
  },
578
410
  };
@@ -0,0 +1,116 @@
1
+ import type { Model } from "@earendil-works/pi-ai";
2
+ import type { AgentSpawnConfig } from "../agent-manager.js";
3
+ import type { AgentInvocation, AgentRecord, IsolationMode, ThinkingLevel } from "../types.js";
4
+ import { AgentActivityTracker } from "../ui/agent-activity-tracker.js";
5
+ import type { AgentDetails } from "../ui/agent-widget.js";
6
+ import { subscribeUIObserver } from "../ui/ui-observer.js";
7
+ import type { AgentActivityAccess } from "./agent-tool.js";
8
+ import { textResult } from "./helpers.js";
9
+
10
+ /** Narrow manager interface for the background spawner. */
11
+ export interface BackgroundManagerDeps {
12
+ spawn(ctx: any, type: string, prompt: string, opts: AgentSpawnConfig): string;
13
+ getRecord(id: string): AgentRecord | undefined;
14
+ getMaxConcurrent(): number;
15
+ }
16
+
17
+ /** Narrow widget interface for the background spawner. */
18
+ export interface BackgroundWidgetDeps {
19
+ ensureTimer(): void;
20
+ update(): void;
21
+ }
22
+
23
+ /** Injected collaborators for spawnBackground. */
24
+ export interface BackgroundDeps {
25
+ manager: BackgroundManagerDeps;
26
+ widget: BackgroundWidgetDeps;
27
+ agentActivity: AgentActivityAccess;
28
+ }
29
+
30
+ /** All values the background spawner needs, bundled from shared execute setup. */
31
+ export interface BackgroundParams {
32
+ ctx: {
33
+ sessionManager: {
34
+ getSessionFile(): string;
35
+ getSessionId(): string;
36
+ };
37
+ };
38
+ subagentType: string;
39
+ prompt: string;
40
+ description: string;
41
+ displayName: string;
42
+ toolCallId: string;
43
+ detailBase: Pick<AgentDetails, "displayName" | "description" | "subagentType" | "modelName" | "tags">;
44
+ model: Model<any> | undefined;
45
+ effectiveMaxTurns: number | undefined;
46
+ isolated: boolean | undefined;
47
+ inheritContext: boolean | undefined;
48
+ thinking: ThinkingLevel | undefined;
49
+ isolation: IsolationMode | undefined;
50
+ agentInvocation: AgentInvocation;
51
+ }
52
+
53
+ /**
54
+ * Spawn a background agent and return the tool result immediately.
55
+ * Owns: activity tracker creation, UI observer subscription, activity map
56
+ * registration, widget update, and launch message formatting.
57
+ */
58
+ export function spawnBackground(
59
+ deps: BackgroundDeps,
60
+ params: BackgroundParams,
61
+ ) {
62
+ const bgState = new AgentActivityTracker(params.effectiveMaxTurns);
63
+
64
+ let id: string;
65
+ try {
66
+ id = deps.manager.spawn(params.ctx, params.subagentType, params.prompt, {
67
+ parentSessionFile: params.ctx.sessionManager.getSessionFile(),
68
+ parentSessionId: params.ctx.sessionManager.getSessionId(),
69
+ description: params.description,
70
+ model: params.model,
71
+ maxTurns: params.effectiveMaxTurns,
72
+ isolated: params.isolated,
73
+ inheritContext: params.inheritContext,
74
+ thinkingLevel: params.thinking,
75
+ isBackground: true,
76
+ isolation: params.isolation,
77
+ invocation: params.agentInvocation,
78
+ toolCallId: params.toolCallId,
79
+ onSessionCreated: (session) => {
80
+ bgState.setSession(session);
81
+ subscribeUIObserver(session, bgState);
82
+ },
83
+ });
84
+ } catch (err) {
85
+ return textResult(err instanceof Error ? err.message : String(err));
86
+ }
87
+
88
+ const record = deps.manager.getRecord(id);
89
+
90
+ deps.agentActivity.set(id, bgState);
91
+ deps.widget.ensureTimer();
92
+ deps.widget.update();
93
+
94
+ const isQueued = record?.status === "queued";
95
+ return textResult(
96
+ `Agent ${isQueued ? "queued" : "started"} in background.\n` +
97
+ `Agent ID: ${id}\n` +
98
+ `Type: ${params.displayName}\n` +
99
+ `Description: ${params.description}\n` +
100
+ (record?.execution?.outputFile ? `Output file: ${record.execution.outputFile}\n` : "") +
101
+ (isQueued
102
+ ? `Position: queued (max ${deps.manager.getMaxConcurrent()} concurrent)\n`
103
+ : "") +
104
+ `\nYou will be notified when this agent completes.\n` +
105
+ `Use get_subagent_result to retrieve full results, or steer_subagent to send it messages.\n` +
106
+ `Do not duplicate this agent's work.`,
107
+ {
108
+ ...params.detailBase,
109
+ toolUses: 0,
110
+ tokens: "",
111
+ durationMs: 0,
112
+ status: "background" as const,
113
+ agentId: id,
114
+ },
115
+ );
116
+ }