pi-subagents-lite 1.4.1 → 1.4.3

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/README.md CHANGED
@@ -35,7 +35,7 @@ Names like `Agent`, `StopAgent`, `AgentStatus`, `run_in_background`, `worktree_p
35
35
  - **Live widget** — persistent status bar with running/completed agents, full and compact modes
36
36
  - **Result viewer** — fullscreen markdown with stats
37
37
  - **Worktrees** — run agents in a git worktree via `worktree_path`
38
- - **Output logs** — `tail -f` friendly, ISO-timestamped
38
+ - **Output logs** — `tail -f` friendly, ISO-timestamped with configurable thinking buffer (OFF, 80, 200, 500, 1000 chars). Flush rounds to sentence boundaries.
39
39
 
40
40
  ## Install
41
41
 
@@ -112,7 +112,7 @@ Stop a running agent by ID.
112
112
  |---|---|---|
113
113
  | `agent_id` | ✅ | The agent ID returned by `Agent` at spawn |
114
114
 
115
- IDs come from the `Agent` result, the `StopAgent` error (lists all running IDs), or `/agents` → **Running agents**.
115
+ IDs come from the `Agent` result, the `StopAgent` error (lists all running IDs), or `/agents` → **Running agents**. Display format is `id (type)` (e.g. `a1b2c3 (Explore)`).
116
116
 
117
117
  ### `AgentStatus`
118
118
 
@@ -244,13 +244,13 @@ When `includeContextFiles` is `true` (default), AGENTS.md files from the project
244
244
  Management menu with four sections:
245
245
 
246
246
  - **Running agents** — status and description; per-agent actions (view snapshot, result, error; steer; stop) and bulk stop
247
- - **Spawn agent** — manually spawn without the LLM. Pick a type, enter a prompt, tune options (model, thinking, max turns, max tokens, grace turns, background), then spawn. Options pre-fill from agent config.
247
+ - **Spawn agent** — manually spawn without the LLM. Pick a type (with search), enter a prompt, tune options (model, thinking, max turns, max tokens, grace turns, background), then spawn. Options pre-fill from agent config.
248
248
  - **Settings**
249
249
  - **Model settings** — global default, per-type overrides, session overrides, clear all
250
250
  - **Spawn options** — force background, grace turns, default max turns, default thinking, disable default agents
251
251
  - **System prompt** — mode, custom prompt file, include AGENTS.md, load skills/extensions implicitly
252
- - **Concurrency** — default limit, per-provider and per-model slots, reset to defaults
253
- - **Widget settings** — force compact, max lines, description length, ctrl+o shortcut, usage stats (toggle tools, turns, input/output tokens, context %, cost, time)
252
+ - **Concurrency** — default limit, per-provider and per-model slots (with search), reset to defaults
253
+ - **Widget settings** — force compact, max lines, description length, thinking buffer size, ctrl+o shortcut, usage stats (toggle tools, turns, input/output tokens, context %, cost, time)
254
254
 
255
255
  ## Interface
256
256
 
@@ -333,6 +333,7 @@ With **Cost display** ON, stats show dollar cost (`✓ Builder·2🛠 ·5⟳ ·
333
333
  | `widgetDescLengthCompact` | `30` | Max description length in compact mode. |
334
334
  | `widgetCompact` | `false` | Force compact mode regardless of ctrl+o state. |
335
335
  | `widgetShortcut` | `false` | When ON, ctrl+o (tool expansion toggle) syncs with widget compact mode. When OFF, compact is manual via `widgetCompact`. |
336
+ | `outputThinkingBufferSize` | `200` | Thinking buffer ring size in chars. `0` = OFF. Flushes to output log at sentence boundaries. |
336
337
 
337
338
  ### Stats visibility
338
339
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-subagents-lite",
3
- "version": "1.4.1",
3
+ "version": "1.4.3",
4
4
  "description": "Lightweight sub-agents for pi — spawn specialized agents with isolated sessions, tools, and models.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -28,11 +28,12 @@
28
28
  "url": "https://github.com/AlexParamonov/pi-subagents-lite/issues"
29
29
  },
30
30
  "peerDependencies": {
31
- "@earendil-works/pi-ai": ">=0.74.0",
32
- "@earendil-works/pi-coding-agent": ">=0.74.0",
33
- "@earendil-works/pi-tui": ">=0.74.0"
31
+ "@earendil-works/pi-ai": "^0.80.1",
32
+ "@earendil-works/pi-coding-agent": "^0.80.1",
33
+ "@earendil-works/pi-tui": "^0.80.1"
34
34
  },
35
35
  "dependencies": {
36
+ "@earendil-works/pi-agent-core": "^0.80.1",
36
37
  "@sinclair/typebox": "^0.34.49"
37
38
  },
38
39
  "files": [
@@ -257,7 +257,6 @@ function compactDefined<T extends Record<string, unknown>>(obj: T): Partial<T> {
257
257
  */
258
258
  export function parseAgentFile(
259
259
  content: string,
260
- _filename: string,
261
260
  source: "user" | "project",
262
261
  ): AgentConfigFromMd {
263
262
  const { frontmatter, body } = parseFrontmatter(content);
@@ -310,7 +309,7 @@ export async function scanAgentFilesInDir(
310
309
  const filePath = path.join(dirPath, entry.name);
311
310
  try {
312
311
  const content = await fs.promises.readFile(filePath, "utf-8");
313
- const info = parseAgentFile(content, entry.name, source);
312
+ const info = parseAgentFile(content, source);
314
313
  if (info.name) {
315
314
  agents.push(info);
316
315
  }
@@ -98,6 +98,7 @@ export class AgentManager {
98
98
  onComplete?: OnAgentComplete,
99
99
  concurrency?: ConcurrencyConfig,
100
100
  onStart?: OnAgentStart,
101
+ private bufferSize: number = 0,
101
102
  ) {
102
103
  this.onComplete = onComplete;
103
104
  this.onStart = onStart;
@@ -258,7 +259,7 @@ export class AgentManager {
258
259
  record.lifecycle.startedAt = Date.now();
259
260
 
260
261
  // Create output log for this agent (creates file + writes [USER] entry)
261
- record.execution.outputLog = new AgentOutputLog(id, prompt);
262
+ record.execution.outputLog = new AgentOutputLog(id, prompt, undefined, this.bufferSize);
262
263
  record.display.outputFile = record.execution.outputLog.path;
263
264
 
264
265
  this.onStart?.(record);
@@ -21,21 +21,14 @@ import {
21
21
  import { getAgentConfig, getConfig, getToolNamesForType, resolveVisibleTools } from "./agent-types.js";
22
22
  import { extractText } from "../prompt/context.js";
23
23
  import type { LifetimeUsage } from "./usage.js";
24
- import { findModelInRegistry } from "../utils.js";
24
+ import { findModelInRegistry, GIT_EXEC_TIMEOUT_MS } from "../utils.js";
25
25
  import { DEFAULT_AGENTS } from "./default-agents.js";
26
26
  import { buildAgentPrompt, type PromptExtras } from "../prompt/prompts.js";
27
27
  import { preloadSkills, loadSkillMeta, type SkillMeta } from "../prompt/skill-loader.js";
28
28
  import { type EnvInfo, type RunCallbacks, type RunTunables, SHORT_ID_LENGTH } from "../types.js";
29
29
  import type { SubagentType, SystemPromptMode } from "./types.js";
30
30
  import { getStore } from "../shell.js";
31
- import { DEFAULT_GRACE_TURNS } from "../config/config-io.js";
32
-
33
- /** Path to custom prompt file. Exported for use in menus.ts. */
34
- export const CUSTOM_PROMPT_PATH = path.join(process.env.HOME || "", ".pi", "agent", "subagents-lite-prompt.md");
35
-
36
-
37
- /** Timeout for quick git commands (branch detection, repo check). */
38
- const GIT_EXEC_TIMEOUT_MS = 5000;
31
+ import { DEFAULT_GRACE_TURNS, CUSTOM_PROMPT_PATH } from "../config/config-io.js";
39
32
 
40
33
  /** Normalize max turns. undefined or 0 = unlimited, otherwise minimum 1. */
41
34
  function normalizeMaxTurns(n: number | undefined): number | undefined {
@@ -12,11 +12,11 @@ import { SHORT_ID_LENGTH } from "../types.js";
12
12
  import { getManager } from "../shell.js";
13
13
 
14
14
  /**
15
- * Format a single agent record as "type·short_id·status".
15
+ * Format a single agent record as "short_id (type) status".
16
16
  */
17
17
  function formatAgent(record: AgentRecord): string {
18
18
  const shortId = record.id.slice(0, SHORT_ID_LENGTH);
19
- return `${record.display.type}·${shortId}·${record.lifecycle.status}`;
19
+ return `${shortId} (${record.display.type}) ${record.lifecycle.status}`;
20
20
  }
21
21
 
22
22
  /**
@@ -66,6 +66,15 @@ export function setAgentScanDirs(userDir: string, projectDir: string): void {
66
66
  projectAgentDir = projectDir;
67
67
  }
68
68
 
69
+ /** Scan user and project agent directories, merge with defaults. Returns the merged Map. */
70
+ async function scanAndMerge(options?: { disableDefaultAgents?: boolean }): Promise<Map<string, AgentConfig>> {
71
+ const [userAgents, projectAgents] = await Promise.all([
72
+ scanAgentFilesInDir(userAgentDir, "user"),
73
+ scanAgentFilesInDir(projectAgentDir, "project"),
74
+ ]);
75
+ const defaults = options?.disableDefaultAgents ? new Map<string, AgentConfig>() : DEFAULT_AGENTS;
76
+ return mergeAgents(defaults, userAgents, projectAgents);
77
+ }
69
78
  /**
70
79
  * Scan the known agent directories and register any newly discovered agents
71
80
  * that aren't already in the registry. Returns the number of new agents added.
@@ -77,13 +86,7 @@ export function setAgentScanDirs(userDir: string, projectDir: string): void {
77
86
  * @param options - Optional settings. disableDefaultAgents skips DEFAULT_AGENTS in the merge.
78
87
  */
79
88
  export async function discoverNewAgents(worktreeDir?: string, options?: { disableDefaultAgents?: boolean }): Promise<number> {
80
- const [userAgents, projectAgents] = await Promise.all([
81
- scanAgentFilesInDir(userAgentDir, "user"),
82
- scanAgentFilesInDir(projectAgentDir, "project"),
83
- ]);
84
-
85
- const defaults = options?.disableDefaultAgents ? new Map<string, AgentConfig>() : DEFAULT_AGENTS;
86
- const merged = mergeAgents(defaults, userAgents, projectAgents);
89
+ const merged = await scanAndMerge(options);
87
90
 
88
91
  let count = 0;
89
92
  for (const [name, config] of merged) {
@@ -96,8 +99,6 @@ export async function discoverNewAgents(worktreeDir?: string, options?: { disabl
96
99
  // Scan worktree-local agents (only when worktreeDir is provided)
97
100
  if (worktreeDir) {
98
101
  const worktreeAgents = await scanAgentFilesInDir(worktreeDir, "project");
99
- // Use mergeAgents to convert AgentConfigFromMd to AgentConfig (applies fromMd
100
- // and BASE_DEFAULTS), then add only names not already in the registry.
101
102
  const wtMerged = mergeAgents(new Map(), [], worktreeAgents);
102
103
  for (const [name, config] of wtMerged) {
103
104
  if (!agents.has(name)) {
@@ -302,22 +303,23 @@ function applyGlobalDefaults(
302
303
  };
303
304
  }
304
305
 
306
+ /** Find the first non-hidden config: resolved type, then general-purpose, then undefined. */
307
+ function findActiveConfig(type: string): AgentConfig | undefined {
308
+ const key = resolveType(type);
309
+ const config = key ? agents.get(key) : undefined;
310
+ if (config?.hidden !== true) return config;
311
+ return agents.get("general-purpose");
312
+ }
313
+
305
314
  /** Get config for a type (case-insensitive). Falls back to general-purpose. */
306
315
  export function getConfig(
307
316
  type: string,
308
317
  loadSkillsImplicitly: boolean = true,
309
318
  loadExtensionsImplicitly: boolean = true,
310
319
  ): ResolvedAgentConfig {
311
- const resolvedKey = resolveType(type);
312
- const config = resolvedKey ? agents.get(resolvedKey) : undefined;
313
-
314
- // If config exists and is not hidden, use it; otherwise fall back to general-purpose
315
- const activeConfig = config?.hidden !== true
316
- ? config
317
- : agents.get("general-purpose");
318
-
319
- if (activeConfig && activeConfig.hidden !== true) {
320
- const { skills, extensions, ...rest } = activeConfig;
320
+ const config = findActiveConfig(type);
321
+ if (config) {
322
+ const { skills, extensions, ...rest } = config;
321
323
  const defaults = applyGlobalDefaults(skills, extensions, loadSkillsImplicitly, loadExtensionsImplicitly);
322
324
  return {
323
325
  displayName: rest.displayName ?? rest.name,
@@ -328,7 +330,7 @@ export function getConfig(
328
330
  };
329
331
  }
330
332
 
331
- // Absolute fallback — general-purpose was hidden or missing
333
+ // Absolute fallback — no config found at all
332
334
  const defaults = applyGlobalDefaults(undefined, undefined, loadSkillsImplicitly, loadExtensionsImplicitly);
333
335
  return {
334
336
  displayName: "Agent",
@@ -12,6 +12,26 @@ import type { AgentSession, AgentSessionEvent } from "@earendil-works/pi-coding-
12
12
  import { formatTokens } from "./usage.js";
13
13
  import { summarizeToolArgs } from "../ui/format.js";
14
14
 
15
+
16
+ /** Find the last sentence boundary in text. Returns the index of the
17
+ * terminal punctuation character, or -1 if none found. */
18
+ function findLastSentenceBoundary(text: string): number {
19
+ // Search backward for the most recent sentence-ending punctuation
20
+ for (let i = text.length - 1; i >= 0; i--) {
21
+ const ch = text[i];
22
+ if ([".", "!", "?", ",", "\n"].includes(ch)) {
23
+ return i;
24
+ }
25
+ }
26
+ return -1;
27
+ }
28
+
29
+ /** Format the [DONE] summary line with final stats. */
30
+ function formatDoneLine(stats: { turnCount: number; toolUseCount: number; totalTokens: number; cost: number }): string {
31
+ const tokensStr = `${formatTokens(stats.totalTokens)} tokens`;
32
+ const costStr = `$${stats.cost.toFixed(3)}`;
33
+ return `${timestamp()} [DONE] ${stats.turnCount} turns, ${stats.toolUseCount} tool uses, ${tokensStr}, ${costStr}\n`;
34
+ }
15
35
  /** Max content length for full tool result display — longer results get a summary line. */
16
36
  const MAX_TOOL_RESULT_DISPLAY_LENGTH = 500;
17
37
 
@@ -111,12 +131,14 @@ function formatToolResult(toolName: string, content: ReadonlyArray<Record<string
111
131
  function formatMessageLine(
112
132
  role: "ASSISTANT" | "TOOL" | "USER",
113
133
  content: string | ReadonlyArray<Record<string, unknown>> | undefined,
134
+ skipThinkingCount: number = 0,
114
135
  ): string {
115
136
  if (typeof content === "string") {
116
137
  return splitAndPrefix(content, role);
117
138
  }
118
139
 
119
140
  if (Array.isArray(content)) {
141
+ let thinkingSkipped = 0;
120
142
  return content
121
143
  .map((item) => {
122
144
  if (item.type === "text" && typeof item.text === "string") {
@@ -126,6 +148,10 @@ function formatMessageLine(
126
148
  return formatToolItem(item);
127
149
  }
128
150
  if (item.type === "thinking" && typeof item.thinking === "string") {
151
+ if (thinkingSkipped < skipThinkingCount) {
152
+ thinkingSkipped++;
153
+ return ""; // Already streamed, skip
154
+ }
129
155
  const text = item.redacted ? "[redacted]" : item.thinking;
130
156
  return splitAndPrefix(text, "THINKING");
131
157
  }
@@ -136,7 +162,6 @@ function formatMessageLine(
136
162
 
137
163
  return "";
138
164
  }
139
-
140
165
  /**
141
166
  * Subscribe to session events and flush new messages to the output file
142
167
  * on each turn_end. Returns a cleanup function that writes the DONE line
@@ -148,15 +173,28 @@ export function streamToOutputFile(
148
173
  session: AgentSession,
149
174
  path: string,
150
175
  stats?: { turnCount: number; toolUseCount: number; totalTokens: number; cost: number },
176
+ bufferSize: number = 0,
151
177
  ): () => void {
152
178
  let writtenCount = 1; // initial user prompt already written
179
+ let thinkingBuffer = "";
180
+ let streamedThinkingBlocks = 0; // thinking blocks written live; skipped in the final flush
181
+ let streamedThinkingChars = 0; // track total chars streamed for deduplication
182
+ let thinkingBlockInProgress = false; // true between thinking_start and thinking_end
183
+
184
+ const flushThinkingBuffer = () => {
185
+ if (thinkingBuffer.length > 0) {
186
+ safeAppend(path, `${timestamp()} [THINKING] ${thinkingBuffer}\n`);
187
+ streamedThinkingChars += thinkingBuffer.length;
188
+ thinkingBuffer = "";
189
+ }
190
+ };
153
191
 
154
192
  const flush = () => {
155
193
  const messages = session.messages;
156
194
  while (writtenCount < messages.length) {
157
195
  const msg = messages[writtenCount];
158
196
  if (msg.role === "assistant") {
159
- const lines = formatMessageLine("ASSISTANT", msg.content as any);
197
+ const lines = formatMessageLine("ASSISTANT", msg.content as any, streamedThinkingBlocks);
160
198
  if (lines) safeAppend(path, lines);
161
199
  } else if (msg.role === "user") {
162
200
  const text = extractUserText(msg.content as any);
@@ -176,18 +214,60 @@ export function streamToOutputFile(
176
214
  };
177
215
 
178
216
  const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
179
- if (event.type === "turn_end") flush();
217
+ if (event.type === "turn_end") {
218
+ flushThinkingBuffer();
219
+ // If thinking_end never fired, treat this as if it did to avoid duplicates
220
+ if (thinkingBlockInProgress) {
221
+ streamedThinkingBlocks++;
222
+ thinkingBlockInProgress = false;
223
+ }
224
+ flush();
225
+ }
226
+
227
+ if (bufferSize > 0 && event.type === "message_update") {
228
+ const assistantEvent = event.assistantMessageEvent;
229
+ if (assistantEvent.type === "thinking_start") {
230
+ // Reset counter for new thinking block
231
+ streamedThinkingChars = 0;
232
+ thinkingBlockInProgress = true;
233
+ } else if (assistantEvent.type === "thinking_delta") {
234
+ thinkingBuffer += assistantEvent.delta;
235
+ if (thinkingBuffer.length >= bufferSize) {
236
+ // Round down to nearest sentence boundary when possible
237
+ const boundary = findLastSentenceBoundary(thinkingBuffer);
238
+ if (boundary >= 0) {
239
+ const flushText = thinkingBuffer.slice(0, boundary + 1);
240
+ thinkingBuffer = thinkingBuffer.slice(boundary + 1);
241
+ safeAppend(path, `${timestamp()} [THINKING] ${flushText}\n`);
242
+ streamedThinkingChars += flushText.length;
243
+ } else {
244
+ // No sentence boundary found, flush at buffer limit
245
+ flushThinkingBuffer();
246
+ }
247
+ }
248
+ } else if (assistantEvent.type === "thinking_end") {
249
+ // thinking_end carries the full block. Flush the buffered tail first
250
+ // (counted in streamedThinkingChars), then stream whatever remains.
251
+ flushThinkingBuffer();
252
+ if (assistantEvent.content && assistantEvent.content.length > streamedThinkingChars) {
253
+ const remaining = assistantEvent.content.slice(streamedThinkingChars);
254
+ safeAppend(path, `${timestamp()} [THINKING] ${remaining}\n`);
255
+ streamedThinkingChars = assistantEvent.content.length;
256
+ }
257
+ streamedThinkingBlocks++;
258
+ thinkingBlockInProgress = false;
259
+ }
260
+ }
180
261
  });
181
262
 
182
263
  return () => {
183
264
  // Final flush
265
+ flushThinkingBuffer();
184
266
  flush();
185
267
 
186
268
  // Write DONE line
187
- const { turnCount = 0, toolUseCount = 0, totalTokens = 0, cost = 0 } = stats ?? {};
188
- const tokensStr = `${formatTokens(totalTokens)} tokens`;
189
- const costStr = `$${cost.toFixed(3)}`;
190
- safeAppend(path, `${timestamp()} [DONE] ${turnCount} turns, ${toolUseCount} tool uses, ${tokensStr}, ${costStr}\n`);
269
+ const doneStats = stats ?? { turnCount: 0, toolUseCount: 0, totalTokens: 0, cost: 0 };
270
+ safeAppend(path, formatDoneLine(doneStats));
191
271
 
192
272
  // Unsubscribe from session events
193
273
  unsubscribe();
@@ -219,10 +299,12 @@ export class AgentOutputLog {
219
299
  readonly path: string;
220
300
  private cleanup?: () => void;
221
301
  private statsRef?: OutputFinalStats;
302
+ private bufferSize: number;
222
303
 
223
- constructor(agentId: string, prompt: string, baseDir?: string) {
304
+ constructor(agentId: string, prompt: string, baseDir?: string, bufferSize: number = 0) {
224
305
  this.path = createOutputFilePath(agentId, baseDir);
225
306
  writeInitialEntry(this.path, prompt);
307
+ this.bufferSize = bufferSize;
226
308
  }
227
309
 
228
310
  /**
@@ -232,7 +314,7 @@ export class AgentOutputLog {
232
314
  */
233
315
  attach(session: AgentSession): void {
234
316
  this.statsRef = { turnCount: 0, toolUseCount: 0, totalTokens: 0, cost: 0 };
235
- this.cleanup = streamToOutputFile(session, this.path, this.statsRef);
317
+ this.cleanup = streamToOutputFile(session, this.path, this.statsRef, this.bufferSize);
236
318
  }
237
319
 
238
320
  /**
@@ -254,9 +336,7 @@ export class AgentOutputLog {
254
336
  this.statsRef = undefined;
255
337
  } else {
256
338
  // No attach was called — write DONE directly
257
- const tokensStr = `${formatTokens(stats.totalTokens)} tokens`;
258
- const costStr = `$${stats.cost.toFixed(3)}`;
259
- safeAppend(this.path, `${timestamp()} [DONE] ${stats.turnCount} turns, ${stats.toolUseCount} tool uses, ${tokensStr}, ${costStr}\n`);
339
+ safeAppend(this.path, formatDoneLine(stats));
260
340
  }
261
341
  }
262
342
  }
@@ -52,16 +52,9 @@ function errorResult(text: string, details?: Record<string, unknown>) {
52
52
  * Consolidates the identical field-selection logic previously duplicated
53
53
  * across emitIndividualNudge, executeSpawnForeground, and executeSpawnBackground.
54
54
  */
55
- interface AgentDetailsOptions {
56
- /** Include full stats (turns, tokens, context%, compactions, cost). Default: false. */
57
- includeStats?: boolean;
58
- /** Include status and outputFile. Default: false. */
59
- includeStatus?: boolean;
60
- }
61
-
62
55
  export function buildAgentDetails(
63
56
  record: AgentRecord,
64
- options?: AgentDetailsOptions,
57
+ opts?: { includeStats?: boolean; includeStatus?: boolean },
65
58
  ): Record<string, unknown> {
66
59
  const details: Record<string, unknown> = {
67
60
  type: record.display.type,
@@ -72,12 +65,12 @@ export function buildAgentDetails(
72
65
  details.worktreePath = record.display.worktreePath;
73
66
  }
74
67
 
75
- if (options?.includeStatus) {
68
+ if (opts?.includeStatus) {
76
69
  details.status = record.lifecycle.status;
77
70
  details.outputFile = record.display.outputFile;
78
71
  }
79
72
 
80
- if (options?.includeStats) {
73
+ if (opts?.includeStats) {
81
74
  const elapsedMs = record.lifecycle.completedAt ? record.lifecycle.completedAt - record.lifecycle.startedAt : 0;
82
75
 
83
76
  details.turnCount = record.stats.turnCount;
@@ -113,8 +106,13 @@ export async function executeAgentTool(
113
106
  if (rawWorktreePath && rawWorktreePath.trim() !== "") {
114
107
  try {
115
108
  const parentCwd = getSessionCtx()?.cwd ?? ctx.cwd;
116
- const validation = await validateWorktreePath(getPiInstance(), rawWorktreePath, parentCwd);
109
+ const warnings: string[] = [];
110
+ const onWarning = (msg: string) => { warnings.push(msg); };
111
+ const validation = await validateWorktreePath(getPiInstance(), rawWorktreePath, parentCwd, onWarning);
117
112
  if (!validation.ok) {
113
+ for (const msg of warnings) {
114
+ if (ctx.ui?.notify) ctx.ui.notify(`[pi-subagents-lite] ${msg}`, "warning");
115
+ }
118
116
  return errorResult(validation.error);
119
117
  }
120
118
  validatedWorktreePath = validation.resolvedPath;
@@ -198,7 +196,7 @@ export async function executeAgentTool(
198
196
 
199
197
  /**
200
198
  * Build a compact list of running (or queued) agents.
201
- * Format: "type·short_id, type·short_id" — one line, easy for LLM to parse.
199
+ * Format: "short_id (type), short_id (type)" — one line, easy for LLM to parse.
202
200
  */
203
201
  function formatRunningAgents(): string {
204
202
  const agents = getManager()!.listAgents().filter(
@@ -208,7 +206,7 @@ function formatRunningAgents(): string {
208
206
  if (agents.length === 0) return "none";
209
207
 
210
208
  return agents
211
- .map((a) => `${a.display.type}·${a.id.slice(0, SHORT_ID_LENGTH)}`)
209
+ .map((a) => `${a.id.slice(0, SHORT_ID_LENGTH)} (${a.display.type})`)
212
210
  .join(", ");
213
211
  }
214
212
 
@@ -29,17 +29,10 @@ type SessionStatsLike = {
29
29
  };
30
30
  export type SessionLike = { getSessionStats(): SessionStatsLike };
31
31
 
32
- /** Format a token count compactly: "12.3k", "1.2M", or raw number. */
33
- export function formatTokens(count: number): string {
32
+ /** Format a token count compactly: "12.3k", "1.2M", or raw number. When compact is true, thousands round to whole numbers. */
33
+ export function formatTokens(count: number, compact = false): string {
34
34
  if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
35
- if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k`;
36
- return `${count}`;
37
- }
38
-
39
- /** Format token count for widget display: rounded to whole number for k. */
40
- export function formatTokensCompact(count: number): string {
41
- if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
42
- if (count >= 1_000) return `${Math.round(count / 1_000)}k`;
35
+ if (count >= 1_000) return compact ? `${Math.round(count / 1_000)}k` : `${(count / 1_000).toFixed(1)}k`;
43
36
  return `${count}`;
44
37
  }
45
38
 
@@ -11,46 +11,57 @@ import type { SubagentsConfig } from "../models/model-precedence.js";
11
11
 
12
12
  const CONFIG_DIR = path.join(process.env.HOME || "", ".pi", "agent");
13
13
  const CONFIG_PATH = path.join(CONFIG_DIR, "subagents-lite.json");
14
-
14
+ /** Path to custom prompt file for subagent system prompts. */
15
+ export const CUSTOM_PROMPT_PATH = path.join(CONFIG_DIR, "subagents-lite-prompt.md");
15
16
  /** Default number of grace turns before an agent is force-stopped. */
16
17
  export const DEFAULT_GRACE_TURNS = 6;
17
18
 
18
- /** Default configuration used when config file doesn't exist or is invalid. */
19
- export const DEFAULT_CONFIG: SubagentsConfig = {
20
- agent: {
21
- default: null,
22
- forceBackground: false,
23
- graceTurns: DEFAULT_GRACE_TURNS,
24
- widgetMaxLines: 12,
25
- // widgetMaxLinesCompact intentionally omitted — derives from widgetMaxLines
26
- widgetDescLengthFull: 50,
27
- widgetDescLengthCompact: 30,
28
- widgetCompact: false,
29
- widgetShortcut: false,
30
- systemPromptMode: "replace",
31
- includeContextFiles: true,
32
- disableDefaultAgents: false,
33
- showTools: true,
34
- showTurns: true,
35
- showInput: true,
36
- showOutput: true,
37
- showContext: true,
38
- showCost: false,
39
- showTime: true,
40
- },
41
- concurrency: { default: 4 },
19
+ /** Valid system prompt modes. */
20
+ export const VALID_SYSTEM_PROMPT_MODES = new Set<string>(["replace", "inherit", "custom"]);
21
+
22
+ /** Default concurrency config — used for resets. */
23
+ export const DEFAULT_CONCURRENCY: SubagentsConfig["concurrency"] = { default: 4 };
24
+
25
+ /** Default agent settings — merged into loaded config so callers get a complete shape. */
26
+ const DEFAULT_AGENT: SubagentsConfig["agent"] = {
27
+ default: null,
28
+ forceBackground: false,
29
+ graceTurns: DEFAULT_GRACE_TURNS,
30
+ widgetMaxLines: 12,
31
+ widgetDescLengthFull: 50,
32
+ widgetDescLengthCompact: 30,
33
+ widgetCompact: false,
34
+ widgetShortcut: false,
35
+ systemPromptMode: "replace",
36
+ includeContextFiles: true,
37
+ disableDefaultAgents: false,
38
+ showTools: true,
39
+ showTurns: true,
40
+ showInput: true,
41
+ showOutput: true,
42
+ showContext: true,
43
+ showCost: false,
44
+ showTime: true,
42
45
  };
43
46
 
44
47
  /**
45
- * Read config from disk. Returns defaults if file doesn't exist or is invalid.
48
+ * Read config from disk. Merges loaded values over defaults so the result
49
+ * is always a complete SubagentsConfig — no partial shapes for callers to handle.
46
50
  */
47
51
  export function loadConfig(): SubagentsConfig {
52
+ let raw: SubagentsConfig;
48
53
  try {
49
- const raw = fs.readFileSync(CONFIG_PATH, "utf-8");
50
- return JSON.parse(raw) as SubagentsConfig;
54
+ raw = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8")) as SubagentsConfig;
51
55
  } catch {
52
- return { ...DEFAULT_CONFIG, agent: { ...DEFAULT_CONFIG.agent }, concurrency: { ...DEFAULT_CONFIG.concurrency } };
56
+ raw = {} as SubagentsConfig;
53
57
  }
58
+
59
+ // @ts-expect-error TS2783: spread may override 'default', which is intentional (loaded value wins)
60
+ const concurrency = { default: 4, ...(raw.concurrency ?? {}) } as SubagentsConfig["concurrency"];
61
+ return {
62
+ agent: { ...DEFAULT_AGENT, ...raw.agent },
63
+ concurrency,
64
+ };
54
65
  }
55
66
 
56
67
  /** Write config to disk with atomic rename. */