@gotgenes/pi-subagents 6.1.0 → 6.3.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.
@@ -20,17 +20,15 @@ import {
20
20
  SPINNER,
21
21
  type UICtx,
22
22
  } from "../ui/agent-widget.js";
23
- import { addUsage, type LifetimeUsage } from "../usage.js";
23
+ import { subscribeUIObserver } from "../ui/ui-observer.js";
24
+ import type { LifetimeUsage } from "../usage.js";
24
25
  import { formatLifetimeTokens, textResult } from "./helpers.js";
25
26
 
26
27
  // ---- Agent-tool-specific helpers ----
27
28
 
28
- /**
29
- * Create an AgentActivity state and spawn callbacks for tracking tool usage.
30
- * Used by both foreground and background paths to avoid duplication.
31
- */
32
- export function createActivityTracker(maxTurns?: number, onStreamUpdate?: () => void) {
33
- const state: AgentActivity = {
29
+ /** Create a fresh AgentActivity state for tracking UI progress. */
30
+ function createAgentActivity(maxTurns?: number): AgentActivity {
31
+ return {
34
32
  activeTools: new Map(),
35
33
  toolUses: 0,
36
34
  turnCount: 1,
@@ -39,40 +37,6 @@ export function createActivityTracker(maxTurns?: number, onStreamUpdate?: () =>
39
37
  session: undefined,
40
38
  lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 },
41
39
  };
42
-
43
- const callbacks = {
44
- onToolActivity: (activity: { type: "start" | "end"; toolName: string }) => {
45
- if (activity.type === "start") {
46
- state.activeTools.set(activity.toolName + "_" + Date.now(), activity.toolName);
47
- } else {
48
- for (const [key, name] of state.activeTools) {
49
- if (name === activity.toolName) {
50
- state.activeTools.delete(key);
51
- break;
52
- }
53
- }
54
- state.toolUses++;
55
- }
56
- onStreamUpdate?.();
57
- },
58
- onTextDelta: (_delta: string, fullText: string) => {
59
- state.responseText = fullText;
60
- onStreamUpdate?.();
61
- },
62
- onTurnEnd: (turnCount: number) => {
63
- state.turnCount = turnCount;
64
- onStreamUpdate?.();
65
- },
66
- onSessionCreated: (session: any) => {
67
- state.session = session;
68
- },
69
- onAssistantUsage: (usage: { input: number; output: number; cacheWrite: number }) => {
70
- addUsage(state.lifetimeUsage, usage);
71
- onStreamUpdate?.();
72
- },
73
- };
74
-
75
- return { state, callbacks };
76
40
  }
77
41
 
78
42
  /** Parenthetical status note for completed agent result text. */
@@ -451,8 +415,7 @@ Guidelines:
451
415
 
452
416
  // Background execution
453
417
  if (runInBackground) {
454
- const { state: bgState, callbacks: bgCallbacks } =
455
- createActivityTracker(effectiveMaxTurns);
418
+ const bgState = createAgentActivity(effectiveMaxTurns);
456
419
 
457
420
  let id: string;
458
421
 
@@ -469,7 +432,10 @@ Guidelines:
469
432
  isBackground: true,
470
433
  isolation,
471
434
  invocation: agentInvocation,
472
- ...bgCallbacks,
435
+ onSessionCreated: (session: any) => {
436
+ bgState.session = session;
437
+ subscribeUIObserver(session, bgState);
438
+ },
473
439
  });
474
440
  } catch (err) {
475
441
  return textResult(err instanceof Error ? err.message : String(err));
@@ -521,6 +487,9 @@ Guidelines:
521
487
  const startedAt = Date.now();
522
488
  let fgId: string | undefined;
523
489
 
490
+ const fgState = createAgentActivity(effectiveMaxTurns);
491
+ let unsubUI: (() => void) | undefined;
492
+
524
493
  const streamUpdate = () => {
525
494
  const details: AgentDetails = {
526
495
  ...detailBase,
@@ -539,25 +508,6 @@ Guidelines:
539
508
  });
540
509
  };
541
510
 
542
- const { state: fgState, callbacks: fgCallbacks } = createActivityTracker(
543
- effectiveMaxTurns,
544
- streamUpdate,
545
- );
546
-
547
- // Wire session creation to register in widget
548
- const origOnSession = fgCallbacks.onSessionCreated;
549
- fgCallbacks.onSessionCreated = (session: any) => {
550
- origOnSession(session);
551
- for (const a of deps.manager.listAgents()) {
552
- if (a.session === session) {
553
- fgId = a.id;
554
- deps.agentActivity.set(a.id, fgState);
555
- deps.widget.ensureTimer();
556
- break;
557
- }
558
- }
559
- };
560
-
561
511
  // Animate spinner at ~80ms (smooth rotation through 10 braille frames)
562
512
  const spinnerInterval = setInterval(() => {
563
513
  spinnerFrame++;
@@ -584,15 +534,28 @@ Guidelines:
584
534
  signal,
585
535
  parentSessionFile: ctx.sessionManager.getSessionFile(),
586
536
  parentSessionId: ctx.sessionManager.getSessionId(),
587
- ...fgCallbacks,
537
+ onSessionCreated: (session: any) => {
538
+ fgState.session = session;
539
+ unsubUI = subscribeUIObserver(session, fgState, streamUpdate);
540
+ for (const a of deps.manager.listAgents()) {
541
+ if (a.session === session) {
542
+ fgId = a.id;
543
+ deps.agentActivity.set(a.id, fgState);
544
+ deps.widget.ensureTimer();
545
+ break;
546
+ }
547
+ }
548
+ },
588
549
  },
589
550
  );
590
551
  } catch (err) {
591
552
  clearInterval(spinnerInterval);
553
+ unsubUI?.();
592
554
  return textResult(err instanceof Error ? err.message : String(err));
593
555
  }
594
556
 
595
557
  clearInterval(spinnerInterval);
558
+ unsubUI?.();
596
559
 
597
560
  // Clean up foreground agent from widget
598
561
  if (fgId) {
package/src/types.ts CHANGED
@@ -83,8 +83,38 @@ export interface NotificationDetails {
83
83
 
84
84
  }
85
85
 
86
+ /**
87
+ * Plain data snapshot of the parent session state captured at spawn time.
88
+ * Replaces live `ExtensionContext` references so queued agents don't read stale state.
89
+ */
90
+ export interface ParentSnapshot {
91
+ /** Parent working directory. */
92
+ cwd: string;
93
+ /** Parent's effective system prompt (for append-mode agents). */
94
+ systemPrompt: string;
95
+ /** Parent's current model instance (fallback when agent config has no model). */
96
+ model: unknown;
97
+ /** Model registry for resolving config.model strings and creating sessions. */
98
+ modelRegistry: {
99
+ find(provider: string, modelId: string): unknown;
100
+ getAvailable?(): Array<{ provider: string; id: string }>;
101
+ };
102
+ /** Pre-built parent conversation text (when inheritContext was requested). */
103
+ parentContext?: string;
104
+ }
105
+
86
106
  export interface EnvInfo {
87
107
  isGitRepo: boolean;
88
108
  branch: string;
89
109
  platform: string;
90
110
  }
111
+
112
+ /**
113
+ * Narrow shell-exec callback replacing `ExtensionAPI` in `detectEnv()`.
114
+ * Matches the shape of `pi.exec()` without carrying an SDK dependency.
115
+ */
116
+ export type ShellExec = (
117
+ command: string,
118
+ args: string[],
119
+ options?: { cwd?: string; timeout?: number },
120
+ ) => Promise<{ stdout: string; stderr: string; code: number }>;
@@ -1,7 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
 
4
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
5
5
  import type { SpawnOptions } from "../agent-manager.js";
6
6
  import {
7
7
  BUILTIN_TOOL_NAMES,
@@ -21,7 +21,7 @@ export interface AgentMenuManager {
21
21
  listAgents: () => AgentRecord[];
22
22
  getRecord: (id: string) => AgentRecord | undefined;
23
23
  /** Used by generate wizard to spawn an agent that writes the .md file. */
24
- spawnAndWait: (pi: ExtensionAPI | null, ctx: ExtensionContext, type: string, prompt: string, opts: Omit<SpawnOptions, "isBackground">) => Promise<AgentRecord>;
24
+ spawnAndWait: (ctx: ExtensionContext, type: string, prompt: string, opts: Omit<SpawnOptions, "isBackground">) => Promise<AgentRecord>;
25
25
  getMaxConcurrent: () => number;
26
26
  setMaxConcurrent: (n: number) => void;
27
27
  }
@@ -487,7 +487,6 @@ Guidelines for choosing settings:
487
487
  Write the file using the write tool. Only write the file, nothing else.`;
488
488
 
489
489
  const record = await deps.manager.spawnAndWait(
490
- null,
491
490
  ctx,
492
491
  "general-purpose",
493
492
  generatePrompt,
@@ -0,0 +1,83 @@
1
+ /**
2
+ * ui-observer.ts — Subscribes to session events and updates AgentActivity state.
3
+ *
4
+ * Replaces the callback-based createActivityTracker pattern with a direct
5
+ * session subscription for streaming UI state (active tools, response text,
6
+ * turn count, lifetime usage).
7
+ */
8
+
9
+ import { addUsage } from "../usage.js";
10
+ import type { AgentActivity } from "./agent-widget.js";
11
+
12
+ /** Narrow session interface — only the subscribe method needed by the observer. */
13
+ interface SubscribableSession {
14
+ subscribe(fn: (event: any) => void): () => void;
15
+ }
16
+
17
+ /**
18
+ * Subscribe to session events and stream UI state into an AgentActivity object.
19
+ *
20
+ * Handles:
21
+ * - `tool_execution_start` → add to `state.activeTools`
22
+ * - `tool_execution_end` → remove from `state.activeTools`, `state.toolUses++`
23
+ * - `message_start` → reset `state.responseText`
24
+ * - `message_update` (text_delta) → append to `state.responseText`
25
+ * - `turn_end` → `state.turnCount++`
26
+ * - `message_end` (assistant, with usage) → `addUsage(state.lifetimeUsage, …)`
27
+ *
28
+ * Calls `onUpdate?.()` after each state mutation to trigger re-renders.
29
+ *
30
+ * @returns An unsubscribe function.
31
+ */
32
+ export function subscribeUIObserver(
33
+ session: SubscribableSession,
34
+ state: AgentActivity,
35
+ onUpdate?: () => void,
36
+ ): () => void {
37
+ return session.subscribe((event: any) => {
38
+ if (event.type === "tool_execution_start") {
39
+ state.activeTools.set(event.toolName + "_" + Date.now(), event.toolName);
40
+ onUpdate?.();
41
+ }
42
+
43
+ if (event.type === "tool_execution_end") {
44
+ for (const [key, name] of state.activeTools) {
45
+ if (name === event.toolName) {
46
+ state.activeTools.delete(key);
47
+ break;
48
+ }
49
+ }
50
+ state.toolUses++;
51
+ onUpdate?.();
52
+ }
53
+
54
+ if (event.type === "message_start") {
55
+ state.responseText = "";
56
+ }
57
+
58
+ if (
59
+ event.type === "message_update" &&
60
+ event.assistantMessageEvent?.type === "text_delta"
61
+ ) {
62
+ state.responseText += event.assistantMessageEvent.delta;
63
+ onUpdate?.();
64
+ }
65
+
66
+ if (event.type === "turn_end") {
67
+ state.turnCount++;
68
+ onUpdate?.();
69
+ }
70
+
71
+ if (event.type === "message_end" && event.message?.role === "assistant") {
72
+ const u = event.message.usage;
73
+ if (u) {
74
+ addUsage(state.lifetimeUsage, {
75
+ input: u.input ?? 0,
76
+ output: u.output ?? 0,
77
+ cacheWrite: u.cacheWrite ?? 0,
78
+ });
79
+ onUpdate?.();
80
+ }
81
+ }
82
+ });
83
+ }