pi-subagents-lite 1.0.2 → 1.2.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.
@@ -8,22 +8,25 @@
8
8
  import type { ExtensionContext, ToolCallEvent } from "@earendil-works/pi-coding-agent";
9
9
 
10
10
  import type { AgentRecord } from "./types.js";
11
+ import { SHORT_ID_LENGTH } from "./types.js";
11
12
  import type { SpawnOptions as AgentManagerSpawnOptions } from "./agent-manager.js";
12
13
  import type { AgentActivity } from "./ui/agent-widget.js";
13
14
  import { resolveType, getAgentConfig, discoverNewAgents } from "./agent-types.js";
14
15
  import { resolveModel } from "./model-precedence.js";
15
16
  import { addUsage, getLifetimeTotal, getSessionContextPercent, type LifetimeUsage } from "./usage.js";
17
+ import { validateWorktreePath } from "./worktree-validator.js";
16
18
 
17
- // Shared state imported from index.ts
19
+ // Shared state imported from state.ts
18
20
  import { parseModelKey, findModelInRegistry, parseThinkingLevel } from "./utils.js";
19
21
  import {
20
22
  __config,
21
23
  sessionOverrides,
22
- manager,
23
24
  piInstance,
24
25
  agentActivity,
25
- widget,
26
- } from "./index.js";
26
+ getManager,
27
+ getWidget,
28
+ sessionCtx,
29
+ } from "./state.js";
27
30
 
28
31
  // ============================================================================
29
32
  // Module-level state
@@ -59,8 +62,9 @@ export function errorResult(text: string, details?: Record<string, unknown>) {
59
62
  /**
60
63
  * Create an AgentActivity state and spawn callbacks for tracking tool usage.
61
64
  * Used by both foreground and background paths to avoid duplication.
65
+ * Exported for use by the menu spawn flow.
62
66
  */
63
- function createActivityTracker(maxTurns?: number, onStreamUpdate?: () => void) {
67
+ export function createActivityTracker(maxTurns?: number, onStreamUpdate?: () => void) {
64
68
  const state: AgentActivity = {
65
69
  activeTools: new Map(),
66
70
  toolUses: 0,
@@ -103,6 +107,65 @@ function createActivityTracker(maxTurns?: number, onStreamUpdate?: () => void) {
103
107
  return { state, callbacks };
104
108
  }
105
109
 
110
+ // ============================================================================
111
+ // buildAgentDetails — consolidated stats/details construction
112
+ // ============================================================================
113
+
114
+ interface AgentDetailsOptions {
115
+ /** Include full stats (turns, tokens, context%, compactions, cost). Default: false. */
116
+ includeStats?: boolean;
117
+ /** Include status and outputFile. Default: false. */
118
+ includeStatus?: boolean;
119
+ /** Override the turnCount (e.g. from activity tracker). Default: record.turnCount. */
120
+ turnCount?: number;
121
+ }
122
+
123
+ /**
124
+ * Build a details Record from an AgentRecord, controlled by options.
125
+ *
126
+ * Always includes `type` and `description`. Optional groups:
127
+ * - `includeStatus`: adds `status`, `outputFile`
128
+ * - `includeStats`: adds turn/token/cost/context/compaction/model fields
129
+ *
130
+ * Consolidates the identical field-selection logic previously duplicated
131
+ * across emitIndividualNudge, executeSpawnForeground, and executeSpawnBackground.
132
+ */
133
+ export function buildAgentDetails(
134
+ record: AgentRecord,
135
+ options?: AgentDetailsOptions,
136
+ ): Record<string, unknown> {
137
+ const details: Record<string, unknown> = {
138
+ type: record.display.type,
139
+ description: record.display.description,
140
+ };
141
+
142
+ if (record.display.worktreePath) {
143
+ details.worktreePath = record.display.worktreePath;
144
+ }
145
+
146
+ if (options?.includeStatus) {
147
+ details.status = record.lifecycle.status;
148
+ details.outputFile = record.display.outputFile;
149
+ }
150
+
151
+ if (options?.includeStats) {
152
+ const totalTokens = getLifetimeTotal(record.stats.lifetimeUsage);
153
+ const elapsedMs = record.lifecycle.completedAt ? record.lifecycle.completedAt - record.lifecycle.startedAt : 0;
154
+
155
+ details.turnCount = options.turnCount ?? record.stats.turnCount;
156
+ details.maxTurns = record.stats.maxTurns;
157
+ details.toolUses = record.stats.toolUses;
158
+ details.tokens = totalTokens;
159
+ details.contextPercent = getSessionContextPercent(record.execution.session);
160
+ details.durationMs = elapsedMs;
161
+ details.compactions = record.stats.compactionCount;
162
+ details.modelName = record.display.invocation?.modelName;
163
+ details.cost = record.stats.lifetimeUsage.cost;
164
+ }
165
+
166
+ return details;
167
+ }
168
+
106
169
  // ============================================================================
107
170
  // Nudge scheduling — batch completion notifications within the hold window
108
171
  // ============================================================================
@@ -118,7 +181,7 @@ export function scheduleNudge(agentId: string): void {
118
181
  pendingNudges.clear();
119
182
 
120
183
  for (const id of batch) {
121
- emitIndividualNudge(id, manager?.getRecord(id));
184
+ emitIndividualNudge(id, getManager()?.getRecord(id));
122
185
  }
123
186
  }, NUDGE_DELAY_MS);
124
187
  }
@@ -126,31 +189,16 @@ export function scheduleNudge(agentId: string): void {
126
189
  function emitIndividualNudge(agentId: string, record?: AgentRecord): void {
127
190
  if (!record) return;
128
191
 
129
- const totalTokens = getLifetimeTotal(record.lifetimeUsage);
130
- const elapsedMs = record.completedAt
131
- ? record.completedAt - record.startedAt
132
- : 0;
133
-
134
- const details: Record<string, unknown> = {
135
- type: record.type,
136
- description: record.description,
137
- status: record.status,
138
- outputFile: record.outputFile,
139
- turnCount: record.turnCount ?? agentActivity.get(agentId)?.turnCount,
140
- maxTurns: record.maxTurns,
141
- toolUses: record.toolUses,
142
- tokens: totalTokens,
143
- cost: record.lifetimeUsage.cost,
144
- contextPercent: getSessionContextPercent(record.session),
145
- durationMs: elapsedMs,
146
- compactions: record.compactionCount,
147
- modelName: record.invocation?.modelName,
148
- };
192
+ const details = buildAgentDetails(record, {
193
+ includeStats: true,
194
+ includeStatus: true,
195
+ turnCount: record.stats.turnCount ?? agentActivity.get(agentId)?.turnCount,
196
+ });
149
197
 
150
198
  piInstance.sendMessage(
151
199
  {
152
200
  customType: "subagent-result",
153
- content: `[Subagent "${record.type}" completed]\n\n${record.result ?? ""}`,
201
+ content: `[Subagent "${record.display.type}" ${record.lifecycle.status}]\n\n${record.result ?? ""}`,
154
202
  details,
155
203
  display: true,
156
204
  },
@@ -172,11 +220,32 @@ export async function executeAgentTool(
172
220
  _onUpdate: ((update: any) => void) | undefined,
173
221
  ctx: ExtensionContext,
174
222
  ): Promise<any> {
223
+ // Validate worktree_path early — needed for on-demand agent discovery
224
+ const rawWorktreePath = params.worktree_path as string | undefined;
225
+ let validatedWorktreePath: string | undefined;
226
+ let worktreeLabel: string | undefined;
227
+ if (rawWorktreePath && rawWorktreePath.trim() !== "") {
228
+ try {
229
+ const parentCwd = sessionCtx?.cwd ?? ctx.cwd;
230
+ const validation = await validateWorktreePath(piInstance, rawWorktreePath, parentCwd);
231
+ if (!validation.ok) {
232
+ return errorResult(validation.error);
233
+ }
234
+ validatedWorktreePath = validation.resolvedPath;
235
+ worktreeLabel = validation.label;
236
+ } catch (err: unknown) {
237
+ const msg = err instanceof Error ? err.message : String(err);
238
+ return errorResult(`worktree_path validation failed: ${msg}`);
239
+ }
240
+ }
241
+
175
242
  const type = (params.agent as string) || "general-purpose";
176
243
  let resolvedType = resolveType(type);
177
244
  if (!resolvedType) {
178
- // Not found in registry — try scanning filesystem for agents added during the session
179
- await discoverNewAgents();
245
+ // Not found in registry — try scanning filesystem for agents added during the session.
246
+ // When worktree_path is set, also scan the worktree's .pi/agents/ directory.
247
+ const worktreeDir = validatedWorktreePath ? `${validatedWorktreePath}/.pi/agents` : undefined;
248
+ await discoverNewAgents(worktreeDir);
180
249
  resolvedType = resolveType(type);
181
250
  }
182
251
  if (!resolvedType) {
@@ -187,6 +256,7 @@ export async function executeAgentTool(
187
256
  const description = params.description as string;
188
257
  const runInBackground = params.run_in_background as boolean | undefined;
189
258
  const maxTurns = params.max_turns as number | undefined ?? getAgentConfig(resolvedType)?.maxTurns;
259
+
190
260
  const modelStr = params.model as string | undefined;
191
261
  const model = findModelInRegistry(modelStr, ctx.modelRegistry, ctx.model);
192
262
  const modelKey = model ? `${model.provider}/${model.id}` : undefined;
@@ -206,6 +276,8 @@ export async function executeAgentTool(
206
276
  modelKey,
207
277
  invocation: { modelName },
208
278
  graceTurns: __config.agent.graceTurns,
279
+ worktreePath: validatedWorktreePath,
280
+ worktreeLabel,
209
281
  };
210
282
 
211
283
  if (runInBackground || __config.agent.forceBackground) {
@@ -225,20 +297,20 @@ async function executeSpawnBackground(
225
297
  spawnOptions.maxTurns,
226
298
  );
227
299
 
228
- const agentId = manager.spawn(piInstance, ctx, resolvedType, prompt, {
300
+ const agentId = getManager().spawn(piInstance, ctx, resolvedType, prompt, {
229
301
  ...spawnOptions,
230
302
  isBackground: true,
231
303
  ...callbacks,
232
304
  });
233
305
  backgroundAgentIds.add(agentId);
234
306
  agentActivity.set(agentId, state);
235
- widget?.ensureTimer();
236
- widget?.update();
307
+ getWidget()?.ensureTimer();
308
+ getWidget()?.update();
237
309
 
238
- const record = manager.getRecord(agentId)!;
239
- const details: Record<string, unknown> = { type: resolvedType, description: spawnOptions.description };
310
+ const record = getManager().getRecord(agentId)!;
311
+ const details = buildAgentDetails(record);
240
312
  const suffix = `A notification will arrive when done - User asks you not to poll, check status or duplicate the delegated work.\n\nAgent ID: ${agentId}`;
241
- const label = record.status === "queued" ? "Agent queued" : "Agent running";
313
+ const label = record.lifecycle.status === "queued" ? "Agent queued" : "Agent running";
242
314
 
243
315
  return successResult(`[${label}] ${suffix}`, details);
244
316
  }
@@ -253,37 +325,27 @@ async function executeSpawnForeground(
253
325
  spawnOptions.maxTurns,
254
326
  );
255
327
 
256
- const fgId = manager.spawn(piInstance, ctx, resolvedType, prompt, {
328
+ const fgId = getManager().spawn(piInstance, ctx, resolvedType, prompt, {
257
329
  ...spawnOptions,
258
330
  ...fgCallbacks,
259
331
  isBackground: false,
260
332
  });
261
333
  agentActivity.set(fgId, fgState);
262
- widget?.ensureTimer();
334
+ getWidget()?.ensureTimer();
263
335
 
264
- const record = manager.getRecord(fgId)!;
265
- await record.promise;
336
+ const record = getManager().getRecord(fgId)!;
337
+ await record.execution.promise;
266
338
 
267
339
  agentActivity.delete(fgId);
268
- widget?.markFinished(fgId);
269
- widget?.update();
340
+ getWidget()?.markFinished(fgId);
341
+ getWidget()?.update();
270
342
 
271
- const elapsedMs = (record.completedAt ?? Date.now()) - record.startedAt;
272
- const totalTokens = getLifetimeTotal(record.lifetimeUsage);
273
- const stats: Record<string, unknown> = {
274
- type: resolvedType,
343
+ const stats = buildAgentDetails(record, {
344
+ includeStats: true,
275
345
  turnCount: fgState.turnCount,
276
- maxTurns: fgState.maxTurns,
277
- toolUses: record.toolUses,
278
- tokens: totalTokens,
279
- contextPercent: getSessionContextPercent(fgState.session),
280
- durationMs: elapsedMs,
281
- description: spawnOptions.description,
282
- compactions: record.compactionCount,
283
- modelName: record.invocation?.modelName,
284
- };
346
+ });
285
347
 
286
- if (record.status === "error") {
348
+ if (record.lifecycle.status === "error") {
287
349
  return errorResult(`Agent failed: ${record.error || "unknown error"}`, stats);
288
350
  }
289
351
 
@@ -291,8 +353,69 @@ async function executeSpawnForeground(
291
353
  }
292
354
 
293
355
  // ============================================================================
294
- // Tool_call listener inject model into Agent tool calls
356
+ // Running agents list helper (used by executeStopAgentTool)
357
+ // ============================================================================
358
+
359
+ /**
360
+ * Build a compact list of running (or queued) agents.
361
+ * Format: "type·short_id, type·short_id" — one line, easy for LLM to parse.
362
+ */
363
+ function formatRunningAgents(): string {
364
+ const agents = getManager().listAgents().filter(
365
+ (a) => a.lifecycle.status === "running" || a.lifecycle.status === "queued",
366
+ );
367
+
368
+ if (agents.length === 0) return "none";
369
+
370
+ return agents
371
+ .map((a) => `${a.display.type}·${a.id.slice(0, SHORT_ID_LENGTH)}`)
372
+ .join(", ");
373
+ }
374
+
295
375
  // ============================================================================
376
+ // StopAgent execute handler
377
+ // ============================================================================
378
+
379
+ export async function executeStopAgentTool(
380
+ _toolCallId: string,
381
+ params: Record<string, unknown>,
382
+ _signal: AbortSignal | undefined,
383
+ _onUpdate: ((update: any) => void) | undefined,
384
+ _ctx: ExtensionContext,
385
+ ): Promise<any> {
386
+ const agentId = params.agent_id as string | undefined;
387
+
388
+ if (!agentId) {
389
+ return errorResult("agent_id is required");
390
+ }
391
+
392
+ const record = getManager().getRecord(agentId);
393
+
394
+ if (!record) {
395
+ // Agent not found → return error + list of running agents
396
+ return errorResult(
397
+ `Agent ${agentId} not found. Running agents: ${formatRunningAgents()}`,
398
+ );
399
+ }
400
+
401
+ // Check if already in a terminal state (not running or queued)
402
+ if (record.lifecycle.status !== "running" && record.lifecycle.status !== "queued") {
403
+ return successResult(
404
+ `Agent ${agentId} is already ${record.lifecycle.status}. Running agents: ${formatRunningAgents()}`,
405
+ );
406
+ }
407
+
408
+ // Attempt to stop the running/queued agent
409
+ if (getManager().abort(agentId)) {
410
+ return successResult(`Stopped agent ${agentId.slice(0, SHORT_ID_LENGTH)}`);
411
+ }
412
+
413
+ return errorResult(`Failed to stop agent ${agentId}`);
414
+ }
415
+
416
+ // ============================================================================
417
+ // Tool_call listener — inject model into Agent tool calls
418
+ // =============================================================================
296
419
 
297
420
  export async function toolCallListener(
298
421
  event: ToolCallEvent,
package/src/types.ts CHANGED
@@ -50,39 +50,16 @@ export interface AgentConfig {
50
50
 
51
51
  export interface AgentRecord {
52
52
  id: string;
53
- type: SubagentType;
54
- description: string;
55
- status: "queued" | "running" | "completed" | "steered" | "aborted" | "stopped" | "error";
56
53
  result?: string;
57
54
  error?: string;
58
- toolUses: number;
59
- startedAt: number;
60
- completedAt?: number;
61
- session?: AgentSession;
62
- abortController?: AbortController;
63
- promise?: Promise<string>;
64
- /** Steering messages queued before the session was ready. */
65
- pendingSteers?: string[];
66
- /** The tool_use_id from the original Agent tool call. */
67
- toolCallId?: string;
68
- /** Path to the streaming output transcript file. */
69
- outputFile?: string;
70
- /** Cleanup function for the output file stream subscription. */
71
- outputCleanup?: () => void;
72
- /**
73
- * Lifetime usage breakdown, accumulated via `message_end` events. Survives
74
- * compaction. Total = input + output + cacheWrite + cost (cacheRead deliberately
75
- * excluded — see issue #38). Initialized to zeros at spawn.
76
- */
77
- lifetimeUsage: LifetimeUsage;
78
- /** Final turn count (set on completion). Used by widget after activity cleanup. */
79
- turnCount?: number;
80
- /** Max turns limit (from invocation or default). */
81
- maxTurns?: number;
82
- /** Number of times this agent's session has compacted. Initialized to 0 at spawn. */
83
- compactionCount: number;
84
- /** Resolved spawn params, captured for UI display. Fixed at spawn time. */
85
- invocation?: AgentInvocation;
55
+ /** Lifecycle state: status, timestamps. */
56
+ lifecycle: AgentLifecycle;
57
+ /** Display-oriented info: type, description, output file, invocation. */
58
+ display: AgentDisplayInfo;
59
+ /** Execution internals: session, abort controller, pending steers. */
60
+ execution: AgentExecutionState;
61
+ /** Accumulated statistics: usage, tool uses, turns. */
62
+ stats: AgentAccumulatedStats;
86
63
  }
87
64
 
88
65
  export interface AgentInvocation {
@@ -102,6 +79,30 @@ export interface EnvInfo {
102
79
  /** How many characters of agent ID to show in display. */
103
80
  export const SHORT_ID_LENGTH = 8;
104
81
 
82
+ /**
83
+ * Theme for terminal rendering — used by format.ts, renderer.ts, and UI widgets.
84
+ * Defined here (not in ui/agent-widget.ts) so non-UI modules can import it
85
+ * without depending on the UI layer.
86
+ */
87
+ export type Theme = {
88
+ fg(color: string, text: string): string;
89
+ bg(color: string, text: string): string;
90
+ bold(text: string): string;
91
+ italic?: (text: string) => string;
92
+ };
93
+
94
+ /** Non-model keys in config.agent — preserved when clearing all overrides. */
95
+ export const CONFIG_AGENT_NON_MODEL_KEYS = [
96
+ "default",
97
+ "forceBackground",
98
+ "graceTurns",
99
+ "showCost",
100
+ "widgetMaxLines",
101
+ "widgetMaxLinesCompact",
102
+ "widgetCompact",
103
+ "widgetShortcut",
104
+ ];
105
+
105
106
  /** Reason for a context compaction event. */
106
107
  export type CompactionReason = "manual" | "threshold" | "overflow";
107
108
 
@@ -111,4 +112,76 @@ export interface CompactionInfo {
111
112
  tokensBefore: number;
112
113
  }
113
114
 
115
+ // ---------------------------------------------------------------------------
116
+ // Sub-object interfaces for decomposed AgentRecord
117
+ // ---------------------------------------------------------------------------
118
+
119
+ /** Possible agent lifecycle statuses. */
120
+ export type AgentStatus = "queued" | "running" | "completed" | "steered" | "aborted" | "stopped" | "error";
121
+
122
+ /**
123
+ * Lifecycle state: when the agent started, completed, and its current status.
124
+ * Used by agent-manager (lifecycle control), menus (status display), widget (linger logic).
125
+ */
126
+ export interface AgentLifecycle {
127
+ status: AgentStatus;
128
+ startedAt: number;
129
+ completedAt?: number;
130
+ }
131
+
132
+ /**
133
+ * Display-oriented fields: type name, description, output file, invocation params.
134
+ * Used by widget (rendering), menus (listing), renderer (display).
135
+ */
136
+ export interface AgentDisplayInfo {
137
+ type: SubagentType;
138
+ description: string;
139
+ /** Path to the streaming output transcript file. */
140
+ outputFile?: string;
141
+ /** Resolved spawn params, captured for UI display. Fixed at spawn time. */
142
+ invocation?: AgentInvocation;
143
+ /** The tool_use_id from the original Agent tool call. */
144
+ toolCallId?: string;
145
+ /** Resolved absolute path of the worktree this agent is running in. */
146
+ worktreePath?: string;
147
+ /** Short display label for the worktree (e.g., "feature" or "feature/packages/web"). */
148
+ worktreeLabel?: string;
149
+ }
150
+
151
+ /**
152
+ * Execution internals: session handle, abort controller, pending steers.
153
+ * Used by agent-manager (session lifecycle), tool-execution (steering, nudge).
154
+ */
155
+ export interface AgentExecutionState {
156
+ session?: AgentSession;
157
+ abortController?: AbortController;
158
+ promise?: Promise<string>;
159
+ /** Steering messages queued before the session was ready. */
160
+ pendingSteers?: string[];
161
+ /** Cleanup function for the output file stream subscription. */
162
+ outputCleanup?: () => void;
163
+ }
164
+
165
+ /**
166
+ * Accumulated statistics: usage breakdown, tool uses, turn count.
167
+ * Used by widget (stats display), tool-execution (details building), menus (result viewer).
168
+ */
169
+ export interface AgentAccumulatedStats {
170
+ /**
171
+ * Lifetime usage breakdown, accumulated via `message_end` events. Survives
172
+ * compaction. Total = input + output + cacheWrite + cost (cacheRead deliberately
173
+ * excluded — see issue #38). Initialized to zeros at spawn.
174
+ */
175
+ lifetimeUsage: LifetimeUsage;
176
+ toolUses: number;
177
+ /** Final turn count (set on completion). Used by widget after activity cleanup. */
178
+ turnCount?: number;
179
+ /** Max turns limit (from invocation or default). */
180
+ maxTurns?: number;
181
+ /** Number of times this agent's session has compacted. Initialized to 0 at spawn. */
182
+ compactionCount: number;
183
+ /** Last-known context usage percentage (0–100), captured at completion. */
184
+ contextPercent?: number | null;
185
+ }
186
+
114
187