@herbertgao/pi-subagents 0.15.2 → 0.15.4
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/CHANGELOG.md +14 -0
- package/package.json +8 -4
- package/src/agent-color.ts +72 -67
- package/src/agent-file-toggle.ts +255 -0
- package/src/agent-manager.ts +192 -3
- package/src/agent-runner.ts +18 -3
- package/src/index.ts +322 -148
- package/src/nested-tools.ts +26 -35
- package/src/output-file.ts +24 -1
- package/src/prompts.ts +15 -1
- package/src/ui/agent-widget.ts +24 -2
- package/src/ui/schedule-menu.ts +9 -8
- package/src/ui/select-item.ts +48 -0
package/src/nested-tools.ts
CHANGED
|
@@ -399,47 +399,38 @@ export function createNestedSubagentTools(
|
|
|
399
399
|
// one earlier would silently give a grandchild the wrong worktree base, the
|
|
400
400
|
// wrong conversation under inherit_context, and the wrong inherited model.
|
|
401
401
|
//
|
|
402
|
-
//
|
|
403
|
-
//
|
|
404
|
-
//
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
const id = context.manager.spawn(
|
|
408
|
-
context.pi,
|
|
409
|
-
ctx,
|
|
410
|
-
resolvedType,
|
|
411
|
-
params.prompt,
|
|
412
|
-
{
|
|
413
|
-
...options,
|
|
414
|
-
isBackground: true,
|
|
415
|
-
},
|
|
416
|
-
)
|
|
417
|
-
// Synchronous, before the event loop yields — onSessionCreated fires
|
|
418
|
-
// asynchronously inside runAgent, so the file is attached in time.
|
|
419
|
-
attachTranscript(id)
|
|
420
|
-
return textResult(
|
|
421
|
-
`Nested agent started in background. Agent ID: ${id}`,
|
|
422
|
-
)
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
const { record } = await context.manager.spawnAndWait(
|
|
402
|
+
// Startup throws mean no child ran. Let them propagate so Pi marks the
|
|
403
|
+
// nested Agent tool call failed instead of presenting the message as a
|
|
404
|
+
// successful child result.
|
|
405
|
+
if (invocation.runInBackground) {
|
|
406
|
+
const id = context.manager.spawn(
|
|
426
407
|
context.pi,
|
|
427
408
|
ctx,
|
|
428
409
|
resolvedType,
|
|
429
410
|
params.prompt,
|
|
430
|
-
{
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
formatRecord(record, "inline"),
|
|
435
|
-
record.status === "error",
|
|
436
|
-
)
|
|
437
|
-
} catch (err) {
|
|
438
|
-
return textResult(
|
|
439
|
-
err instanceof Error ? err.message : String(err),
|
|
440
|
-
true,
|
|
411
|
+
{
|
|
412
|
+
...options,
|
|
413
|
+
isBackground: true,
|
|
414
|
+
},
|
|
441
415
|
)
|
|
416
|
+
// Synchronous, before the event loop yields — onSessionCreated fires
|
|
417
|
+
// asynchronously inside runAgent, so the file is attached in time.
|
|
418
|
+
attachTranscript(id)
|
|
419
|
+
return textResult(`Nested agent started in background. Agent ID: ${id}`)
|
|
442
420
|
}
|
|
421
|
+
|
|
422
|
+
const { record } = await context.manager.spawnAndWait(
|
|
423
|
+
context.pi,
|
|
424
|
+
ctx,
|
|
425
|
+
resolvedType,
|
|
426
|
+
params.prompt,
|
|
427
|
+
{ ...options, signal },
|
|
428
|
+
attachTranscript,
|
|
429
|
+
)
|
|
430
|
+
return textResult(
|
|
431
|
+
formatRecord(record, "inline"),
|
|
432
|
+
record.status === "error",
|
|
433
|
+
)
|
|
443
434
|
},
|
|
444
435
|
})
|
|
445
436
|
|
package/src/output-file.ts
CHANGED
|
@@ -66,6 +66,23 @@ export function createOutputFilePath(
|
|
|
66
66
|
return join(dir, `${agentId}.output`)
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Ensure a transcript file exists without disturbing what is already in it.
|
|
71
|
+
*
|
|
72
|
+
* A resume reuses the agent's existing transcript (same deterministic path), so
|
|
73
|
+
* it must never call `writeInitialEntry` — that truncates, discarding turns the
|
|
74
|
+
* completion notification still points the user at, and any history the session
|
|
75
|
+
* has since compacted away is gone for good. Appending nothing creates the file
|
|
76
|
+
* when this is the agent's first transcript and is a no-op when it is not.
|
|
77
|
+
*/
|
|
78
|
+
export function ensureOutputFile(path: string): void {
|
|
79
|
+
try {
|
|
80
|
+
appendFileSync(path, "", "utf-8")
|
|
81
|
+
} catch {
|
|
82
|
+
/* ignore — streaming writes are best-effort too */
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
69
86
|
/** Write the initial user prompt entry. */
|
|
70
87
|
export function writeInitialEntry(
|
|
71
88
|
path: string,
|
|
@@ -93,8 +110,14 @@ export function streamToOutputFile(
|
|
|
93
110
|
path: string,
|
|
94
111
|
agentId: string,
|
|
95
112
|
cwd: string,
|
|
113
|
+
startIndex?: number,
|
|
96
114
|
): () => void {
|
|
97
|
-
|
|
115
|
+
// Index of the first message this stream is responsible for. A spawn writes
|
|
116
|
+
// messages[0] as the initial prompt entry, so it starts at 1. A resume hands
|
|
117
|
+
// in the session's length as of just before the run: the session already
|
|
118
|
+
// holds every prior turn, and re-emitting those would duplicate history that
|
|
119
|
+
// is already in the file.
|
|
120
|
+
let writtenCount = startIndex ?? 1
|
|
98
121
|
|
|
99
122
|
const flush = () => {
|
|
100
123
|
const messages = session.messages
|
package/src/prompts.ts
CHANGED
|
@@ -10,6 +10,8 @@ export interface PromptExtras {
|
|
|
10
10
|
memoryBlock?: string
|
|
11
11
|
/** Preloaded skill contents to inject. */
|
|
12
12
|
skillBlocks?: { name: string; content: string }[]
|
|
13
|
+
/** Original checkout path when cwd is an isolated worktree copy. */
|
|
14
|
+
worktreeBase?: string
|
|
13
15
|
}
|
|
14
16
|
|
|
15
17
|
/**
|
|
@@ -55,6 +57,12 @@ Platform: ${env.platform}`
|
|
|
55
57
|
}
|
|
56
58
|
const extrasSuffix =
|
|
57
59
|
extraSections.length > 0 ? "\n\n" + extraSections.join("\n") : ""
|
|
60
|
+
const worktreeSection = extras?.worktreeBase
|
|
61
|
+
? `\n\n<worktree_isolation>
|
|
62
|
+
Your working directory is an isolated git worktree copy of ${extras.worktreeBase}.
|
|
63
|
+
Work only inside it — never in ${extras.worktreeBase}, even if other instructions name that path as your working directory.
|
|
64
|
+
</worktree_isolation>`
|
|
65
|
+
: ""
|
|
58
66
|
|
|
59
67
|
if (config.promptMode === "append") {
|
|
60
68
|
const identity = parentSystemPrompt || genericBase
|
|
@@ -88,6 +96,7 @@ You are operating as a sub-agent invoked to handle a specific task.
|
|
|
88
96
|
"\n\n" +
|
|
89
97
|
activeAgentTag +
|
|
90
98
|
envBlock +
|
|
99
|
+
worktreeSection +
|
|
91
100
|
customSection +
|
|
92
101
|
extrasSuffix
|
|
93
102
|
)
|
|
@@ -100,7 +109,12 @@ You have been invoked to handle a specific task autonomously.
|
|
|
100
109
|
${envBlock}`
|
|
101
110
|
|
|
102
111
|
return (
|
|
103
|
-
activeAgentTag +
|
|
112
|
+
activeAgentTag +
|
|
113
|
+
replaceHeader +
|
|
114
|
+
worktreeSection +
|
|
115
|
+
"\n\n" +
|
|
116
|
+
config.systemPrompt +
|
|
117
|
+
extrasSuffix
|
|
104
118
|
)
|
|
105
119
|
}
|
|
106
120
|
|
package/src/ui/agent-widget.ts
CHANGED
|
@@ -358,6 +358,17 @@ export class AgentWidget {
|
|
|
358
358
|
}
|
|
359
359
|
}
|
|
360
360
|
|
|
361
|
+
/**
|
|
362
|
+
* Drop an agent's finished-age (call when a settled agent starts running
|
|
363
|
+
* again, i.e. a background resume). markFinished only seeds an age it has not
|
|
364
|
+
* seen before, so a resumed agent would otherwise keep the age from its
|
|
365
|
+
* previous run — already past the linger limit, hiding the new run's
|
|
366
|
+
* completion line entirely.
|
|
367
|
+
*/
|
|
368
|
+
markRunning(agentId: string) {
|
|
369
|
+
this.finishedTurnAge.delete(agentId)
|
|
370
|
+
}
|
|
371
|
+
|
|
361
372
|
/** Render a finished agent line. */
|
|
362
373
|
private renderFinishedLine(
|
|
363
374
|
a: {
|
|
@@ -536,6 +547,16 @@ export class AgentWidget {
|
|
|
536
547
|
let hiddenRunning = 0
|
|
537
548
|
let hiddenFinished = 0
|
|
538
549
|
|
|
550
|
+
// Reserve the queued line's row up front. It is a single summary of N
|
|
551
|
+
// waiting agents, so it cannot be folded into the "+N more" count (which
|
|
552
|
+
// is denominated in agents) without either under-reporting it as 1 or
|
|
553
|
+
// inflating the total with agents that were never getting their own rows.
|
|
554
|
+
// Reserving costs at most one running agent — which IS counted below —
|
|
555
|
+
// and makes the drop unreachable. It matters most exactly when it used to
|
|
556
|
+
// vanish: the pool is saturated and the queue is what the user needs to see.
|
|
557
|
+
const queuedReserve = queuedLine ? 1 : 0
|
|
558
|
+
budget -= queuedReserve
|
|
559
|
+
|
|
539
560
|
// 1. Running agents (2 lines each)
|
|
540
561
|
for (const pair of runningLines) {
|
|
541
562
|
if (budget >= 2) {
|
|
@@ -546,8 +567,9 @@ export class AgentWidget {
|
|
|
546
567
|
}
|
|
547
568
|
}
|
|
548
569
|
|
|
549
|
-
// 2. Queued line
|
|
550
|
-
if (queuedLine
|
|
570
|
+
// 2. Queued line (always fits — its row was reserved above)
|
|
571
|
+
if (queuedLine) {
|
|
572
|
+
budget += queuedReserve
|
|
551
573
|
lines.push(queuedLine)
|
|
552
574
|
budget--
|
|
553
575
|
}
|
package/src/ui/schedule-menu.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent"
|
|
12
12
|
import type { SubagentScheduler } from "../schedule.js"
|
|
13
13
|
import type { ScheduledSubagent } from "../types.js"
|
|
14
|
+
import { selectItem } from "./select-item.js"
|
|
14
15
|
|
|
15
16
|
/** Format an ISO timestamp as relative time ("in 4h", "2d ago", "—"). */
|
|
16
17
|
function relTime(iso: string | undefined, now = Date.now()): string {
|
|
@@ -88,16 +89,16 @@ export async function showSchedulesMenu(
|
|
|
88
89
|
return
|
|
89
90
|
}
|
|
90
91
|
|
|
91
|
-
|
|
92
|
-
|
|
92
|
+
// Numbered + item-paired: two jobs whose names agree in the first 18
|
|
93
|
+
// characters format identically, and matching the returned string back
|
|
94
|
+
// against a parallel label array cancelled whichever came first.
|
|
95
|
+
const job = await selectItem(
|
|
96
|
+
ctx.ui,
|
|
93
97
|
`Scheduled jobs (${jobs.length}) — select to cancel`,
|
|
94
|
-
|
|
98
|
+
jobs,
|
|
99
|
+
(j) => formatJob(j, scheduler),
|
|
95
100
|
)
|
|
96
|
-
if (!
|
|
97
|
-
|
|
98
|
-
const idx = labels.indexOf(choice)
|
|
99
|
-
if (idx < 0) return
|
|
100
|
-
const job = jobs[idx]
|
|
101
|
+
if (!job) return
|
|
101
102
|
|
|
102
103
|
const ok = await ctx.ui.confirm(
|
|
103
104
|
`Cancel "${job.name}"?`,
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* select-item.ts — pick an item from a list via `ctx.ui.select`, safely.
|
|
3
|
+
*
|
|
4
|
+
* Pi's dialog API is `select(title, options: string[]) => Promise<string | undefined>`:
|
|
5
|
+
* strings in, string out, with no index or value form. Callers therefore have to
|
|
6
|
+
* map the returned string back to the item it came from, and the obvious way —
|
|
7
|
+
* `labels.indexOf(choice)` over a parallel array — silently resolves to the
|
|
8
|
+
* FIRST match whenever two rows format identically. Row formatters here truncate
|
|
9
|
+
* (job names to 18 chars, agent descriptions to whatever fits), and the text they
|
|
10
|
+
* truncate is LLM-authored, so collisions are ordinary rather than exotic.
|
|
11
|
+
*
|
|
12
|
+
* This numbers every row, which makes the labels unique by construction — no
|
|
13
|
+
* data-dependent branch that only executes in the case nobody exercises — and
|
|
14
|
+
* keeps each label paired with its item so a later edit that sorts or filters
|
|
15
|
+
* between building and resolving cannot desync them.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Minimal shape of the `ctx.ui` surface this needs. */
|
|
19
|
+
export interface SelectUI {
|
|
20
|
+
select(title: string, options: string[]): Promise<string | undefined>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Show a numbered picker and return the chosen item (not its label).
|
|
25
|
+
*
|
|
26
|
+
* Returns undefined when the user escapes, or when the returned string is not
|
|
27
|
+
* one we offered.
|
|
28
|
+
*/
|
|
29
|
+
export async function selectItem<T>(
|
|
30
|
+
ui: SelectUI,
|
|
31
|
+
title: string,
|
|
32
|
+
items: readonly T[],
|
|
33
|
+
format: (item: T, index: number) => string,
|
|
34
|
+
): Promise<T | undefined> {
|
|
35
|
+
// Pad the number so a 10+ item list keeps its columns aligned.
|
|
36
|
+
const width = String(items.length).length
|
|
37
|
+
const rows = items.map((item, i) => ({
|
|
38
|
+
item,
|
|
39
|
+
label: `${String(i + 1).padStart(width)}. ${format(item, i)}`,
|
|
40
|
+
}))
|
|
41
|
+
|
|
42
|
+
const choice = await ui.select(
|
|
43
|
+
title,
|
|
44
|
+
rows.map((r) => r.label),
|
|
45
|
+
)
|
|
46
|
+
if (!choice) return undefined
|
|
47
|
+
return rows.find((r) => r.label === choice)?.item
|
|
48
|
+
}
|