pi-subagents-lite 1.4.1 → 1.4.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.
- package/README.md +6 -5
- package/package.json +5 -4
- package/src/agents/agent-manager.ts +2 -1
- package/src/agents/agent-status.ts +2 -2
- package/src/agents/output-file.ts +87 -5
- package/src/agents/tool-execution.ts +8 -3
- package/src/config/config-store.ts +7 -0
- package/src/config/types.ts +1 -0
- package/src/events.ts +3 -1
- package/src/models/model-precedence.ts +2 -0
- package/src/spawn/spawn-coordinator.ts +37 -22
- package/src/spawn/worktree-validator.ts +9 -3
- package/src/ui/agent-widget.ts +1 -1
- package/src/ui/menu/helpers.ts +37 -7
- package/src/ui/menu/menu-concurrency.ts +29 -17
- package/src/ui/menu/menu-model-settings.ts +26 -22
- package/src/ui/menu/menu-spawn-options.ts +5 -0
- package/src/ui/menu/menu-spawn-wizard.ts +30 -15
- package/src/ui/menu/menu-system-prompt.ts +5 -1
- package/src/ui/menu/menu-widget-settings.ts +28 -0
- package/src/ui/menu/submenus/model-select.ts +3 -3
- package/src/ui/menu/wrappers/settings-list.ts +73 -105
- package/src/ui/result-viewer.ts +42 -10
- package/src/{models/model-selector.ts → ui/searchable-select.ts} +29 -29
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.
|
|
3
|
+
"version": "1.4.2",
|
|
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": "
|
|
32
|
-
"@earendil-works/pi-coding-agent": "
|
|
33
|
-
"@earendil-works/pi-tui": "
|
|
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": [
|
|
@@ -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);
|
|
@@ -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
|
|
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}
|
|
19
|
+
return `${shortId} (${record.display.type}) ${record.lifecycle.status}`;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
/**
|
|
@@ -12,6 +12,19 @@ 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
|
+
}
|
|
15
28
|
/** Max content length for full tool result display — longer results get a summary line. */
|
|
16
29
|
const MAX_TOOL_RESULT_DISPLAY_LENGTH = 500;
|
|
17
30
|
|
|
@@ -111,12 +124,14 @@ function formatToolResult(toolName: string, content: ReadonlyArray<Record<string
|
|
|
111
124
|
function formatMessageLine(
|
|
112
125
|
role: "ASSISTANT" | "TOOL" | "USER",
|
|
113
126
|
content: string | ReadonlyArray<Record<string, unknown>> | undefined,
|
|
127
|
+
skipThinkingCount: number = 0,
|
|
114
128
|
): string {
|
|
115
129
|
if (typeof content === "string") {
|
|
116
130
|
return splitAndPrefix(content, role);
|
|
117
131
|
}
|
|
118
132
|
|
|
119
133
|
if (Array.isArray(content)) {
|
|
134
|
+
let thinkingSkipped = 0;
|
|
120
135
|
return content
|
|
121
136
|
.map((item) => {
|
|
122
137
|
if (item.type === "text" && typeof item.text === "string") {
|
|
@@ -126,6 +141,10 @@ function formatMessageLine(
|
|
|
126
141
|
return formatToolItem(item);
|
|
127
142
|
}
|
|
128
143
|
if (item.type === "thinking" && typeof item.thinking === "string") {
|
|
144
|
+
if (thinkingSkipped < skipThinkingCount) {
|
|
145
|
+
thinkingSkipped++;
|
|
146
|
+
return ""; // Already streamed, skip
|
|
147
|
+
}
|
|
129
148
|
const text = item.redacted ? "[redacted]" : item.thinking;
|
|
130
149
|
return splitAndPrefix(text, "THINKING");
|
|
131
150
|
}
|
|
@@ -136,7 +155,6 @@ function formatMessageLine(
|
|
|
136
155
|
|
|
137
156
|
return "";
|
|
138
157
|
}
|
|
139
|
-
|
|
140
158
|
/**
|
|
141
159
|
* Subscribe to session events and flush new messages to the output file
|
|
142
160
|
* on each turn_end. Returns a cleanup function that writes the DONE line
|
|
@@ -148,15 +166,28 @@ export function streamToOutputFile(
|
|
|
148
166
|
session: AgentSession,
|
|
149
167
|
path: string,
|
|
150
168
|
stats?: { turnCount: number; toolUseCount: number; totalTokens: number; cost: number },
|
|
169
|
+
bufferSize: number = 0,
|
|
151
170
|
): () => void {
|
|
152
171
|
let writtenCount = 1; // initial user prompt already written
|
|
172
|
+
let thinkingBuffer = "";
|
|
173
|
+
let streamedThinkingBlocks = 0; // thinking blocks written live; skipped in the final flush
|
|
174
|
+
let streamedThinkingChars = 0; // track total chars streamed for deduplication
|
|
175
|
+
let thinkingBlockInProgress = false; // true between thinking_start and thinking_end
|
|
176
|
+
|
|
177
|
+
const flushThinkingBuffer = () => {
|
|
178
|
+
if (thinkingBuffer.length > 0) {
|
|
179
|
+
safeAppend(path, splitAndPrefix(thinkingBuffer, "THINKING"));
|
|
180
|
+
streamedThinkingChars += thinkingBuffer.length;
|
|
181
|
+
thinkingBuffer = "";
|
|
182
|
+
}
|
|
183
|
+
};
|
|
153
184
|
|
|
154
185
|
const flush = () => {
|
|
155
186
|
const messages = session.messages;
|
|
156
187
|
while (writtenCount < messages.length) {
|
|
157
188
|
const msg = messages[writtenCount];
|
|
158
189
|
if (msg.role === "assistant") {
|
|
159
|
-
const lines = formatMessageLine("ASSISTANT", msg.content as any);
|
|
190
|
+
const lines = formatMessageLine("ASSISTANT", msg.content as any, streamedThinkingBlocks);
|
|
160
191
|
if (lines) safeAppend(path, lines);
|
|
161
192
|
} else if (msg.role === "user") {
|
|
162
193
|
const text = extractUserText(msg.content as any);
|
|
@@ -176,11 +207,60 @@ export function streamToOutputFile(
|
|
|
176
207
|
};
|
|
177
208
|
|
|
178
209
|
const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
|
|
179
|
-
if (event.type === "turn_end")
|
|
210
|
+
if (event.type === "turn_end") {
|
|
211
|
+
flushThinkingBuffer();
|
|
212
|
+
// If thinking_end never fired, treat this as if it did to avoid duplicates
|
|
213
|
+
if (thinkingBlockInProgress) {
|
|
214
|
+
streamedThinkingBlocks++;
|
|
215
|
+
thinkingBlockInProgress = false;
|
|
216
|
+
}
|
|
217
|
+
flush();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (bufferSize > 0 && event.type === "message_update") {
|
|
221
|
+
const assistantEvent = event.assistantMessageEvent;
|
|
222
|
+
if (assistantEvent.type === "thinking_start") {
|
|
223
|
+
// Reset counter for new thinking block
|
|
224
|
+
streamedThinkingChars = 0;
|
|
225
|
+
thinkingBlockInProgress = true;
|
|
226
|
+
} else if (assistantEvent.type === "thinking_delta") {
|
|
227
|
+
thinkingBuffer += assistantEvent.delta;
|
|
228
|
+
if (thinkingBuffer.length >= bufferSize || thinkingBuffer.includes("\n")) {
|
|
229
|
+
// Round down to nearest sentence boundary when possible
|
|
230
|
+
if (thinkingBuffer.length >= bufferSize) {
|
|
231
|
+
const boundary = findLastSentenceBoundary(thinkingBuffer);
|
|
232
|
+
if (boundary >= 0) {
|
|
233
|
+
const flushText = thinkingBuffer.slice(0, boundary + 1);
|
|
234
|
+
thinkingBuffer = thinkingBuffer.slice(boundary + 1);
|
|
235
|
+
safeAppend(path, splitAndPrefix(flushText, "THINKING"));
|
|
236
|
+
streamedThinkingChars += flushText.length;
|
|
237
|
+
} else {
|
|
238
|
+
// No sentence boundary found, flush at buffer limit
|
|
239
|
+
flushThinkingBuffer();
|
|
240
|
+
}
|
|
241
|
+
} else {
|
|
242
|
+
// Newline found before buffer limit
|
|
243
|
+
flushThinkingBuffer();
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
} else if (assistantEvent.type === "thinking_end") {
|
|
247
|
+
// thinking_end carries the full block. Flush the buffered tail first
|
|
248
|
+
// (counted in streamedThinkingChars), then stream whatever remains.
|
|
249
|
+
flushThinkingBuffer();
|
|
250
|
+
if (assistantEvent.content && assistantEvent.content.length > streamedThinkingChars) {
|
|
251
|
+
const remaining = assistantEvent.content.slice(streamedThinkingChars);
|
|
252
|
+
safeAppend(path, splitAndPrefix(remaining, "THINKING"));
|
|
253
|
+
streamedThinkingChars = assistantEvent.content.length;
|
|
254
|
+
}
|
|
255
|
+
streamedThinkingBlocks++;
|
|
256
|
+
thinkingBlockInProgress = false;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
180
259
|
});
|
|
181
260
|
|
|
182
261
|
return () => {
|
|
183
262
|
// Final flush
|
|
263
|
+
flushThinkingBuffer();
|
|
184
264
|
flush();
|
|
185
265
|
|
|
186
266
|
// Write DONE line
|
|
@@ -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
|
/**
|
|
@@ -113,8 +113,13 @@ export async function executeAgentTool(
|
|
|
113
113
|
if (rawWorktreePath && rawWorktreePath.trim() !== "") {
|
|
114
114
|
try {
|
|
115
115
|
const parentCwd = getSessionCtx()?.cwd ?? ctx.cwd;
|
|
116
|
-
const
|
|
116
|
+
const warnings: string[] = [];
|
|
117
|
+
const onWarning = (msg: string) => { warnings.push(msg); };
|
|
118
|
+
const validation = await validateWorktreePath(getPiInstance(), rawWorktreePath, parentCwd, onWarning);
|
|
117
119
|
if (!validation.ok) {
|
|
120
|
+
for (const msg of warnings) {
|
|
121
|
+
if (ctx.ui?.notify) ctx.ui.notify(`[pi-subagents-lite] ${msg}`, "warning");
|
|
122
|
+
}
|
|
118
123
|
return errorResult(validation.error);
|
|
119
124
|
}
|
|
120
125
|
validatedWorktreePath = validation.resolvedPath;
|
|
@@ -198,7 +203,7 @@ export async function executeAgentTool(
|
|
|
198
203
|
|
|
199
204
|
/**
|
|
200
205
|
* Build a compact list of running (or queued) agents.
|
|
201
|
-
* Format: "type
|
|
206
|
+
* Format: "short_id (type), short_id (type)" — one line, easy for LLM to parse.
|
|
202
207
|
*/
|
|
203
208
|
function formatRunningAgents(): string {
|
|
204
209
|
const agents = getManager()!.listAgents().filter(
|
|
@@ -208,7 +213,7 @@ function formatRunningAgents(): string {
|
|
|
208
213
|
if (agents.length === 0) return "none";
|
|
209
214
|
|
|
210
215
|
return agents
|
|
211
|
-
.map((a) => `${a.
|
|
216
|
+
.map((a) => `${a.id.slice(0, SHORT_ID_LENGTH)} (${a.display.type})`)
|
|
212
217
|
.join(", ");
|
|
213
218
|
}
|
|
214
219
|
|
|
@@ -76,6 +76,8 @@ export interface ResolvedAgentSettings {
|
|
|
76
76
|
readonly showContext: boolean;
|
|
77
77
|
/** Whether to show elapsed time in widget stats line. */
|
|
78
78
|
readonly showTime: boolean;
|
|
79
|
+
/** Buffer size for streaming thinking blocks to output file. 0 = disabled. */
|
|
80
|
+
readonly outputThinkingBufferSize: number;
|
|
79
81
|
}
|
|
80
82
|
|
|
81
83
|
/** Side-effect targets, injected after construction. */
|
|
@@ -140,6 +142,7 @@ export class ConfigStore {
|
|
|
140
142
|
showOutput: a.showOutput !== false,
|
|
141
143
|
showContext: a.showContext !== false,
|
|
142
144
|
showTime: a.showTime !== false,
|
|
145
|
+
outputThinkingBufferSize: a.outputThinkingBufferSize ?? 0,
|
|
143
146
|
};
|
|
144
147
|
}
|
|
145
148
|
|
|
@@ -271,6 +274,10 @@ export class ConfigStore {
|
|
|
271
274
|
setShowOutput: (enabled: boolean) => this.setAgentVisibility("showOutput", enabled),
|
|
272
275
|
setShowContext: (enabled: boolean) => this.setAgentVisibility("showContext", enabled),
|
|
273
276
|
setShowTime: (enabled: boolean) => this.setAgentVisibility("showTime", enabled),
|
|
277
|
+
setOutputThinkingBufferSize: (size: number): void => {
|
|
278
|
+
this.config.agent.outputThinkingBufferSize = size;
|
|
279
|
+
this.persist();
|
|
280
|
+
},
|
|
274
281
|
},
|
|
275
282
|
widget: {
|
|
276
283
|
setCompact: (enabled: boolean): void => {
|
package/src/config/types.ts
CHANGED
package/src/events.ts
CHANGED
|
@@ -39,13 +39,15 @@ export function ensureManagerAndWidget(): void {
|
|
|
39
39
|
const newManager = new AgentManager(
|
|
40
40
|
undefined, // onComplete wired below
|
|
41
41
|
getStore().concurrency as unknown as ConstructorParameters<typeof AgentManager>[1],
|
|
42
|
+
undefined,
|
|
43
|
+
getStore().agent.outputThinkingBufferSize,
|
|
42
44
|
);
|
|
43
45
|
setManager(newManager);
|
|
44
46
|
// Sync the manager as a config side-effect target (concurrency setters call setConcurrency).
|
|
45
47
|
getStore().setDeps({ manager: newManager });
|
|
46
48
|
|
|
47
49
|
// Now create coordinator with the real manager
|
|
48
|
-
const coordinator = new SpawnCoordinator(newManager
|
|
50
|
+
const coordinator = new SpawnCoordinator(newManager);
|
|
49
51
|
setCoordinator(coordinator);
|
|
50
52
|
|
|
51
53
|
// Wire the manager's onComplete to the coordinator
|
|
@@ -56,6 +56,8 @@ export interface SubagentsConfig {
|
|
|
56
56
|
widgetDescLengthFull?: number;
|
|
57
57
|
/** Max description length in widget compact mode. Default: 30. */
|
|
58
58
|
widgetDescLengthCompact?: number;
|
|
59
|
+
/** When > 0, thinking deltas stream to output file during message_update events. Default: 0 (disabled). */
|
|
60
|
+
outputThinkingBufferSize?: number;
|
|
59
61
|
[agentType: string]: string | null | undefined | boolean | number;
|
|
60
62
|
};
|
|
61
63
|
concurrency: {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getStatusNote } from "../status-note.js";
|
|
2
|
+
import { getWidget } from "../shell.js";
|
|
2
3
|
/**
|
|
3
4
|
* spawn-coordinator.ts — Spawn-and-track coordination for subagents.
|
|
4
5
|
*
|
|
@@ -14,7 +15,6 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
|
|
|
14
15
|
import type { AgentRecord, SpawnConfig, ToolActivity } from "../types.js";
|
|
15
16
|
import type { AgentManager, SpawnOptions } from "../agents/agent-manager.js";
|
|
16
17
|
import { buildAgentDetails } from "../agents/tool-execution.js";
|
|
17
|
-
import { getWidget } from "../shell.js";
|
|
18
18
|
|
|
19
19
|
// ============================================================================
|
|
20
20
|
// Types
|
|
@@ -64,12 +64,13 @@ export class SpawnCoordinator {
|
|
|
64
64
|
/** Active nudge timer. */
|
|
65
65
|
private nudgeTimer: ReturnType<typeof setTimeout> | null = null;
|
|
66
66
|
|
|
67
|
-
/**
|
|
67
|
+
/** Latest ExtensionAPI reference, refreshed on each spawn() call. */
|
|
68
68
|
private pi: ExtensionAPI | null = null;
|
|
69
69
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
70
|
+
/** Set during dispose to prevent nudge emission after session replacement. */
|
|
71
|
+
private disposed = false;
|
|
72
|
+
|
|
73
|
+
constructor(private manager: AgentManager) {}
|
|
73
74
|
|
|
74
75
|
/**
|
|
75
76
|
* Spawn + wire tracking + (foreground) await.
|
|
@@ -80,9 +81,6 @@ export class SpawnCoordinator {
|
|
|
80
81
|
ctx: ExtensionContext,
|
|
81
82
|
intent: SpawnIntent,
|
|
82
83
|
): Promise<SpawnResult> {
|
|
83
|
-
// Store pi for nudge emission
|
|
84
|
-
this.pi = pi;
|
|
85
|
-
|
|
86
84
|
// Create live view BEFORE spawn so callbacks can close over it
|
|
87
85
|
const liveView: LiveView = {
|
|
88
86
|
activeTools: new Map(),
|
|
@@ -116,6 +114,9 @@ export class SpawnCoordinator {
|
|
|
116
114
|
this.backgroundAgentIds.add(agentId);
|
|
117
115
|
}
|
|
118
116
|
|
|
117
|
+
// Store latest pi reference for nudge delivery
|
|
118
|
+
this.pi = pi;
|
|
119
|
+
|
|
119
120
|
const record = this.manager.getRecord(agentId)!;
|
|
120
121
|
|
|
121
122
|
if (!intent.runInBackground) {
|
|
@@ -183,7 +184,7 @@ export class SpawnCoordinator {
|
|
|
183
184
|
this.pendingNudges.clear();
|
|
184
185
|
this.liveViews.clear();
|
|
185
186
|
this.backgroundAgentIds.clear();
|
|
186
|
-
this.
|
|
187
|
+
this.disposed = true;
|
|
187
188
|
}
|
|
188
189
|
|
|
189
190
|
// ── Private ──
|
|
@@ -208,25 +209,39 @@ export class SpawnCoordinator {
|
|
|
208
209
|
|
|
209
210
|
/** Emit an individual nudge for a completed background agent. */
|
|
210
211
|
private emitIndividualNudge(agentId: string): void {
|
|
212
|
+
// Skip if disposed — prevents stale pi usage after session replacement
|
|
213
|
+
if (this.disposed) return;
|
|
214
|
+
|
|
215
|
+
// Skip if no pi instance yet (no spawn has occurred)
|
|
216
|
+
if (!this.pi) {
|
|
217
|
+
console.warn(`subagent nudge skipped for ${agentId}: no pi instance available (no spawn has occurred yet)`);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
211
221
|
const record = this.manager.getRecord(agentId);
|
|
212
|
-
if (!record
|
|
222
|
+
if (!record) return;
|
|
213
223
|
|
|
214
224
|
const details = buildAgentDetails(record, {
|
|
215
225
|
includeStats: true,
|
|
216
226
|
includeStatus: true,
|
|
217
227
|
});
|
|
218
228
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
229
|
+
try {
|
|
230
|
+
this.pi.sendMessage(
|
|
231
|
+
{
|
|
232
|
+
customType: "subagent-result",
|
|
233
|
+
content: `[Subagent "${record.display.type}" ${record.lifecycle.status}]\n\n${record.result ?? ""} ${getStatusNote(record.lifecycle.status)}`,
|
|
234
|
+
details,
|
|
235
|
+
display: true,
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
deliverAs: "steer",
|
|
239
|
+
triggerTurn: true,
|
|
240
|
+
},
|
|
241
|
+
);
|
|
242
|
+
} catch (error) {
|
|
243
|
+
// Defense-in-depth: pi may be stale after session replacement/reload.
|
|
244
|
+
console.warn(`subagent nudge failed for ${agentId}:`, error);
|
|
245
|
+
}
|
|
231
246
|
}
|
|
232
247
|
}
|
|
@@ -60,6 +60,7 @@ async function getGitCommonDir(
|
|
|
60
60
|
pi: PiExec,
|
|
61
61
|
cwd: string,
|
|
62
62
|
notInRepoError: string,
|
|
63
|
+
onWarning?: (msg: string) => void,
|
|
63
64
|
): Promise<{ ok: true; commonDir: string } | { ok: false; error: string }> {
|
|
64
65
|
try {
|
|
65
66
|
const result = await pi.exec("git", ["rev-parse", "--git-common-dir"], { cwd, timeout: GIT_EXEC_TIMEOUT_MS });
|
|
@@ -72,7 +73,11 @@ async function getGitCommonDir(
|
|
|
72
73
|
if (msg.includes("ENOENT") || msg.includes("not found")) {
|
|
73
74
|
return { ok: false, error: WORKTREE_VALIDATION_ERRORS.GIT_NOT_FOUND };
|
|
74
75
|
}
|
|
75
|
-
|
|
76
|
+
if (msg.includes("timed out") || msg.includes("timeout")) {
|
|
77
|
+
return { ok: false, error: WORKTREE_VALIDATION_ERRORS.GIT_TIMEOUT };
|
|
78
|
+
}
|
|
79
|
+
onWarning?.(`git rev-parse --git-common-dir failed in ${cwd}: ${msg}`);
|
|
80
|
+
return { ok: false, error: `worktree_path validation failed: git rev-parse failed: ${msg}` };
|
|
76
81
|
}
|
|
77
82
|
}
|
|
78
83
|
|
|
@@ -97,6 +102,7 @@ export async function validateWorktreePath(
|
|
|
97
102
|
pi: PiExec,
|
|
98
103
|
worktreePath: string,
|
|
99
104
|
parentCwd: string,
|
|
105
|
+
onWarning?: (msg: string) => void,
|
|
100
106
|
): Promise<WorktreeValidationResult> {
|
|
101
107
|
// Step 1: Empty / whitespace → treat as omitted
|
|
102
108
|
if (!worktreePath || worktreePath.trim() === "") {
|
|
@@ -128,10 +134,10 @@ export async function validateWorktreePath(
|
|
|
128
134
|
}
|
|
129
135
|
|
|
130
136
|
// Step 5: Get and compare git-common-dir for parent and target
|
|
131
|
-
const parentResult = await getGitCommonDir(pi, parentCwd, WORKTREE_VALIDATION_ERRORS.PARENT_NOT_IN_GIT_REPO);
|
|
137
|
+
const parentResult = await getGitCommonDir(pi, parentCwd, WORKTREE_VALIDATION_ERRORS.PARENT_NOT_IN_GIT_REPO, onWarning);
|
|
132
138
|
if (!parentResult.ok) return parentResult;
|
|
133
139
|
|
|
134
|
-
const targetResult = await getGitCommonDir(pi, realPath, WORKTREE_VALIDATION_ERRORS.NOT_IN_GIT_REPO);
|
|
140
|
+
const targetResult = await getGitCommonDir(pi, realPath, WORKTREE_VALIDATION_ERRORS.NOT_IN_GIT_REPO, onWarning);
|
|
135
141
|
if (!targetResult.ok) return targetResult;
|
|
136
142
|
|
|
137
143
|
// Compare common dirs — must share the same repo
|
package/src/ui/agent-widget.ts
CHANGED
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
import { formatMs, buildStatsParts, getDisplayName, truncateDesc, type StatsVisibility } from "./format.js";
|
|
15
15
|
import type { LiveView } from "../spawn/spawn-coordinator.js";
|
|
16
16
|
|
|
17
|
-
// Re-export Theme so existing consumers (
|
|
17
|
+
// Re-export Theme so existing consumers (searchable-select, result-viewer) don't break
|
|
18
18
|
export type { Theme } from "./types.js";
|
|
19
19
|
|
|
20
20
|
// ---- Constants ----
|
package/src/ui/menu/helpers.ts
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* helpers.ts — Shared helpers for menu modules:
|
|
3
3
|
* theme builders for SettingsList/SelectList, numeric validation,
|
|
4
|
-
* model-option building,
|
|
4
|
+
* model-option building, a swappable delegating component, and a
|
|
5
|
+
* searchable pick-list submenu factory.
|
|
5
6
|
*/
|
|
6
|
-
|
|
7
7
|
import type { Component, SettingsListTheme } from "@earendil-works/pi-tui";
|
|
8
|
-
import type
|
|
8
|
+
import { SearchableSelectDialog, type SelectOption } from "../searchable-select.js";
|
|
9
9
|
import { parseModelKey } from "../../utils.js";
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
|
-
* Build
|
|
12
|
+
* Build SelectOption[] from raw "provider/model-id" strings.
|
|
13
13
|
* Includes "(inherits parent)" as the first option.
|
|
14
14
|
*/
|
|
15
|
-
export function buildModelOptions(rawOptions: string[]):
|
|
16
|
-
const items:
|
|
15
|
+
export function buildModelOptions(rawOptions: string[]): SelectOption[] {
|
|
16
|
+
const items: SelectOption[] = [
|
|
17
17
|
{ value: "(inherits parent)", label: "(inherits parent)", provider: "" },
|
|
18
18
|
];
|
|
19
19
|
|
|
@@ -33,7 +33,7 @@ export function buildSettingsListTheme(theme: { fg(color: string, text: string):
|
|
|
33
33
|
return {
|
|
34
34
|
label: (text, selected) => selected ? theme.fg("accent", text) : text,
|
|
35
35
|
value: (text, selected) => selected ? theme.fg("accent", text) : theme.fg("muted", text),
|
|
36
|
-
description: (text) => theme.fg("
|
|
36
|
+
description: (text) => theme.fg("dim", text),
|
|
37
37
|
// Use "→ " (2 chars) to match non-selected prefix " " (2 spaces)
|
|
38
38
|
// This prevents menu items from shifting left/right when cursor moves
|
|
39
39
|
cursor: theme.fg("accent", "→ "),
|
|
@@ -91,3 +91,33 @@ export function buildSelectListTheme(theme: { fg(color: string, text: string): s
|
|
|
91
91
|
};
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Build a searchable pick-list submenu backed by SearchableSelectDialog.
|
|
96
|
+
*
|
|
97
|
+
* Hides the delegator-forward-declaration dance shared by every menu that
|
|
98
|
+
* needs "type to filter, Enter to pick" over a flat option list
|
|
99
|
+
* (provider/model/type/worktree selection). onSelect may return a Component
|
|
100
|
+
* to chain into next (e.g. a numeric-input submenu); returning void leaves
|
|
101
|
+
* the submenu as-is so the caller can close it via done().
|
|
102
|
+
*/
|
|
103
|
+
export function createSearchableSelect(
|
|
104
|
+
items: SelectOption[],
|
|
105
|
+
callbacks: { onSelect: (value: string) => Component | void; onCancel: () => void },
|
|
106
|
+
theme: any,
|
|
107
|
+
): Component {
|
|
108
|
+
let delegator: ReturnType<typeof createDelegatingComponent>;
|
|
109
|
+
const selector = new SearchableSelectDialog(
|
|
110
|
+
items,
|
|
111
|
+
null,
|
|
112
|
+
{
|
|
113
|
+
onSelect: (value) => {
|
|
114
|
+
const next = callbacks.onSelect(value);
|
|
115
|
+
if (next) delegator.setActive(next);
|
|
116
|
+
},
|
|
117
|
+
onCancel: callbacks.onCancel,
|
|
118
|
+
},
|
|
119
|
+
theme,
|
|
120
|
+
);
|
|
121
|
+
delegator = createDelegatingComponent(selector);
|
|
122
|
+
return delegator;
|
|
123
|
+
}
|