@ferris1225/pi-subagents 4.2.13 → 4.3.1
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 +34 -0
- package/README.md +113 -149
- package/agents/artisan.md +19 -0
- package/agents/scout.md +18 -0
- package/agents/steward.md +19 -0
- package/package.json +4 -3
- package/src/agents.ts +24 -28
- package/src/announcements.ts +11 -18
- package/src/background.ts +56 -9
- package/src/completion.ts +0 -6
- package/src/config.ts +55 -90
- package/src/dispatch.ts +52 -67
- package/src/durable.ts +6 -53
- package/src/index.ts +7 -10
- package/src/monitor.ts +2 -2
- package/src/prompt.ts +104 -43
- package/src/recovery.ts +35 -10
- package/src/rpc-run.ts +59 -1
- package/src/runtime.ts +74 -17
- package/src/setup.ts +108 -102
- package/src/spawn.ts +6 -4
- package/src/thread-lifecycle.ts +127 -95
- package/src/tools.ts +4 -20
- package/agents/executor.md +0 -54
- package/agents/explorer.md +0 -37
package/src/dispatch.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The `subagent` tool: dispatches the enabled agents (
|
|
2
|
+
* The `subagent` tool: dispatches the enabled agents (scout, artisan, steward,
|
|
3
3
|
* plus custom roles) as isolated pi
|
|
4
4
|
* child processes, single or parallel. Owns the public dispatch contract and
|
|
5
5
|
* per-run status tracking. Stable thread generations, final integration, and
|
|
@@ -12,7 +12,7 @@ import { Text } from "@earendil-works/pi-tui";
|
|
|
12
12
|
import { join, resolve } from "node:path";
|
|
13
13
|
import { Type } from "typebox";
|
|
14
14
|
import { discoverAgents, isWriteCapableAgent, resolveAgentTools, type AgentConfig } from "./agents.ts";
|
|
15
|
-
import { loadConfig
|
|
15
|
+
import { loadConfig } from "./config.ts";
|
|
16
16
|
import { formatCompletionBlock, formatUsage, queuedResult } from "./format.ts";
|
|
17
17
|
import {
|
|
18
18
|
formatTaskSummary,
|
|
@@ -24,7 +24,8 @@ import {
|
|
|
24
24
|
type RunView,
|
|
25
25
|
type RunWaitReason,
|
|
26
26
|
} from "./monitor.ts";
|
|
27
|
-
import
|
|
27
|
+
import { formatPhaseLeaseReceipt } from "./prompt.ts";
|
|
28
|
+
import type { SubagentRuntime, SubagentThread } from "./runtime.ts";
|
|
28
29
|
import { persistThreadCheckpoint } from "./thread-lifecycle.ts";
|
|
29
30
|
import {
|
|
30
31
|
getProjectRoot,
|
|
@@ -51,23 +52,16 @@ export { isWorktreeCapableAgent, runInManagedRepositoryLane } from "./thread-lif
|
|
|
51
52
|
const NON_BLANK_TASK_OPTIONS = { minLength: 1, pattern: "\\S" } as const;
|
|
52
53
|
|
|
53
54
|
const ISOLATION_DESCRIPTION =
|
|
54
|
-
"Filesystem isolation: shared uses the caller's working tree; worktree creates a detached temporary Git worktree (write-capable agents, including
|
|
55
|
+
"Filesystem isolation: shared uses the caller's working tree; worktree creates a detached temporary Git worktree (write-capable agents, including artisan and steward, only)";
|
|
55
56
|
|
|
56
57
|
const IsolationSchema = Type.Optional(
|
|
57
58
|
StringEnum(["shared", "worktree"] as const, { description: ISOLATION_DESCRIPTION }),
|
|
58
59
|
);
|
|
59
60
|
|
|
60
|
-
const ThinkingSchema = Type.Optional(
|
|
61
|
-
StringEnum(THINKING_LEVEL_VALUES, {
|
|
62
|
-
description:
|
|
63
|
-
"Optional reasoning strength for this task; omit to keep the agent's own level",
|
|
64
|
-
}),
|
|
65
|
-
);
|
|
66
|
-
|
|
67
61
|
const WaitSchema = Type.Optional(
|
|
68
62
|
Type.Boolean({
|
|
69
63
|
description:
|
|
70
|
-
"Block until every run started by this call settles, then return
|
|
64
|
+
"Block until every run started by this call settles, then return each result exactly once in this tool response. If the tool call is aborted, undelivered results fall back to completion messages. Intended for one-shot (pi -p) sessions or an immediate dependent step.",
|
|
71
65
|
}),
|
|
72
66
|
);
|
|
73
67
|
|
|
@@ -75,29 +69,27 @@ const TaskItem = Type.Object({
|
|
|
75
69
|
agent: Type.String({ description: "Name of the agent to invoke" }),
|
|
76
70
|
task: Type.String({
|
|
77
71
|
...NON_BLANK_TASK_OPTIONS,
|
|
78
|
-
description: "
|
|
72
|
+
description: "Substantial self-contained phase worth a fresh paid context (the agent has no memory of this conversation)",
|
|
79
73
|
}),
|
|
80
74
|
cwd: Type.Optional(Type.String({ description: "Working directory for the agent process" })),
|
|
81
75
|
isolation: IsolationSchema,
|
|
82
|
-
thinking: ThinkingSchema,
|
|
83
76
|
});
|
|
84
77
|
|
|
85
78
|
const SubagentParams = Type.Object({
|
|
86
79
|
agent: Type.Optional(Type.String({ description: "Name of the agent to invoke (single mode)" })),
|
|
87
80
|
task: Type.Optional(
|
|
88
|
-
Type.String({ ...NON_BLANK_TASK_OPTIONS, description: "
|
|
81
|
+
Type.String({ ...NON_BLANK_TASK_OPTIONS, description: "Substantial self-contained phase worth a fresh paid context (single mode)" }),
|
|
89
82
|
),
|
|
90
|
-
tasks: Type.Optional(Type.Array(TaskItem, { description: "
|
|
83
|
+
tasks: Type.Optional(Type.Array(TaskItem, { description: "Independently justified, disjoint phases for parallel execution" })),
|
|
91
84
|
cwd: Type.Optional(Type.String({ description: "Working directory for the agent process (single mode)" })),
|
|
92
85
|
isolation: IsolationSchema,
|
|
93
|
-
thinking: ThinkingSchema,
|
|
94
86
|
wait: WaitSchema,
|
|
95
87
|
});
|
|
96
88
|
|
|
97
89
|
/** Roles that default to worktree isolation in parallel dispatches even when
|
|
98
90
|
* the live catalog cannot be consulted (render-only call sites). Custom
|
|
99
91
|
* write-capable agents join them via isWriteCapableAgent on the execute path. */
|
|
100
|
-
const WORKTREE_DEFAULT_AGENTS = new Set(["
|
|
92
|
+
const WORKTREE_DEFAULT_AGENTS = new Set(["artisan", "steward"]);
|
|
101
93
|
|
|
102
94
|
/** Resolve the default isolation for a dispatch. Precedence: an explicit
|
|
103
95
|
* per-call request, then the role's own frontmatter declaration (`worktree`
|
|
@@ -145,9 +137,8 @@ function toolUsage(runtime: SubagentRuntime, runIds: number[]): { usage?: Usage
|
|
|
145
137
|
}
|
|
146
138
|
|
|
147
139
|
/** In-turn wait behind dispatch `wait: true` — the escape hatch for one-shot
|
|
148
|
-
* `pi -p` parents that exit at end of turn
|
|
149
|
-
* started settles, then hand back
|
|
150
|
-
* never take this path; their results arrive as completion wake-ups. No
|
|
140
|
+
* `pi -p` parents that exit at end of turn or an immediate dependent step: hold
|
|
141
|
+
* the call until every run it started settles, then hand back result blocks. No
|
|
151
142
|
* timer: a waiter resolves the moment its run's result registers (children
|
|
152
143
|
* are bounded by the idle watchdog), an already-parked run answers
|
|
153
144
|
* immediately with its resume handle, and the turn's abort signal remains the
|
|
@@ -336,11 +327,17 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
336
327
|
(mode: "single" | "parallel", background = false) =>
|
|
337
328
|
(results: SingleResult[]): SubagentDetails => ({ mode, results, background });
|
|
338
329
|
|
|
330
|
+
const phaseLeaseReceipt = (runIds: number[]): string =>
|
|
331
|
+
formatPhaseLeaseReceipt(
|
|
332
|
+
runIds
|
|
333
|
+
.map((runId) => runtime.threads.get(runId))
|
|
334
|
+
.filter((thread): thread is SubagentThread => thread !== undefined),
|
|
335
|
+
);
|
|
336
|
+
|
|
339
337
|
/** Pacing note appended to dispatch confirmations whenever runs are actually
|
|
340
338
|
* waiting. Slot waits and repository-lane waits are stated separately with
|
|
341
339
|
* the real capacity: a lane-serialized shared writer or a starting child
|
|
342
|
-
* must never read as an exhausted pool
|
|
343
|
-
* slots are free. Empty when nothing is waiting. */
|
|
340
|
+
* must never read as an exhausted pool. Empty when nothing is waiting. */
|
|
344
341
|
const queuePacingNote = (): string => {
|
|
345
342
|
const runs = monitor.getRuns();
|
|
346
343
|
const queuedWith = (reason: RunWaitReason): number =>
|
|
@@ -363,7 +360,7 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
363
360
|
(slotWaiting === 0 ? ` (${freeSlots} of ${capacity} slots free; parallel writers avoid the lane via worktree isolation)` : ""),
|
|
364
361
|
);
|
|
365
362
|
}
|
|
366
|
-
return ` Pacing: ${parts.join(" · ")}
|
|
363
|
+
return ` Pacing: ${parts.join(" · ")}.`;
|
|
367
364
|
};
|
|
368
365
|
|
|
369
366
|
const startBackground = createBackgroundDispatcher({
|
|
@@ -383,14 +380,7 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
383
380
|
pi.registerTool({
|
|
384
381
|
name: "subagent",
|
|
385
382
|
label: "Subagent",
|
|
386
|
-
description:
|
|
387
|
-
"Dispatch enabled agents as isolated leaf Pi child processes: single {agent, task} or parallel {tasks: [...]}. Dispatching never blocks your turn — runs proceed in the background and each completion resumes you automatically; never poll or restate delivered results.",
|
|
388
|
-
"Put every genuinely independent unit in one `tasks` array: there is no per-call cap, and runs beyond the machine's free process slots simply wait and start as slots free.",
|
|
389
|
-
"Parallel write-capable agents default to a detached Git worktree so writers run concurrently; explicit `shared` keeps the caller's checkout and serializes same-repository writers. Worktree setup failure never silently falls back to shared.",
|
|
390
|
-
"A configured child-model failure continues the retained session on the current main model.",
|
|
391
|
-
].join(" "),
|
|
392
|
-
promptSnippet:
|
|
393
|
-
"Dispatch isolated background agents for recon, implementation, cleanup, docs sync, or result merging; never blocks your turn, and completions wake you automatically.",
|
|
383
|
+
description: "Start paid leaf runs for broad reconnaissance or substantial self-contained work. Each active normalized task+cwd owns its phase; exact duplicates are rejected. Batch scopes must be independent. wait:true returns results in-turn; otherwise completions wake main. Parallel writers default to detached Git worktrees; isolation:'shared' serializes same-repository writes.",
|
|
394
384
|
parameters: SubagentParams,
|
|
395
385
|
|
|
396
386
|
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
@@ -460,17 +450,16 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
460
450
|
}
|
|
461
451
|
|
|
462
452
|
// Sub-agents run detached from the foreground turn: the editor stays
|
|
463
|
-
// available
|
|
464
|
-
//
|
|
465
|
-
//
|
|
466
|
-
// processes actually run at once, so no per-call task cap is enforced.
|
|
453
|
+
// available for disjoint orchestration while the launch receipt leases
|
|
454
|
+
// each delegated phase. The queue paces child processes without changing
|
|
455
|
+
// phase ownership or requiring a per-call task cap.
|
|
467
456
|
if (params.tasks && params.tasks.length > 0) {
|
|
468
|
-
|
|
469
|
-
//
|
|
470
|
-
//
|
|
471
|
-
|
|
457
|
+
// Admission is synchronous and ordered; slow worktree preparation belongs
|
|
458
|
+
// to the bounded queue. Promise.all preserves caller result order while no
|
|
459
|
+
// item waits for a sibling's filesystem setup.
|
|
460
|
+
const results = await Promise.all(params.tasks.map((item) => {
|
|
472
461
|
const catalogAgent = agents.find((candidate) => candidate.name === item.agent);
|
|
473
|
-
|
|
462
|
+
return startBackground(
|
|
474
463
|
item.agent,
|
|
475
464
|
item.task,
|
|
476
465
|
item.cwd,
|
|
@@ -481,14 +470,14 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
481
470
|
catalogAgent ? isWriteCapableAgent(catalogAgent) : undefined,
|
|
482
471
|
catalogAgent?.isolation,
|
|
483
472
|
),
|
|
484
|
-
{
|
|
485
|
-
)
|
|
486
|
-
}
|
|
473
|
+
{ deliveryRoute: params.wait ? "await" : "background" },
|
|
474
|
+
);
|
|
475
|
+
}));
|
|
487
476
|
const startedRuns = results.filter((result) => result.exitCode === -1);
|
|
488
477
|
const started = startedRuns.length;
|
|
489
|
-
const
|
|
490
|
-
|
|
491
|
-
|
|
478
|
+
const startedIds = startedRuns
|
|
479
|
+
.map((result) => result.runId)
|
|
480
|
+
.filter((id): id is number => id !== undefined);
|
|
492
481
|
const failureLines = results.flatMap((result, index) => {
|
|
493
482
|
if (result.exitCode === -1) return [];
|
|
494
483
|
const reason = getResultOutput(result).trim() || "unknown startup failure";
|
|
@@ -499,21 +488,15 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
499
488
|
if (started === 0) {
|
|
500
489
|
// Pi marks custom-tool failures only when execute throws; returning an
|
|
501
490
|
// `isError` property is still a successful AgentToolResult.
|
|
502
|
-
throw new Error(`No
|
|
491
|
+
throw new Error(`No subagents started.\n${failureLines.join("\n")}`);
|
|
503
492
|
}
|
|
504
493
|
if (params.wait) {
|
|
505
|
-
const startedIds = startedRuns
|
|
506
|
-
.map((result) => result.runId)
|
|
507
|
-
.filter((id): id is number => id !== undefined);
|
|
508
494
|
const blocks = await awaitRunResults(runtime, startedIds, signal, config.maxResultLines, ctx.cwd, makeProgress(makeDetails("parallel", true)(results)));
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
"",
|
|
515
|
-
blocks,
|
|
516
|
-
].join("\n");
|
|
495
|
+
if (signal?.aborted) runtime.fallbackAwaitDelivery(startedIds);
|
|
496
|
+
else runtime.completeAwaitDelivery(startedIds);
|
|
497
|
+
const text = failureLines.length > 0
|
|
498
|
+
? `${blocks}\n\nLaunch failures:\n${failureLines.join("\n")}`
|
|
499
|
+
: blocks;
|
|
517
500
|
return {
|
|
518
501
|
content: [{ type: "text", text }],
|
|
519
502
|
details: makeDetails("parallel", true)(results),
|
|
@@ -521,10 +504,8 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
521
504
|
};
|
|
522
505
|
}
|
|
523
506
|
const text = [
|
|
524
|
-
|
|
525
|
-
...(failureLines.length > 0
|
|
526
|
-
? [`${failureLines.length} task${failureLines.length === 1 ? "" : "s"} failed before launch:`, ...failureLines]
|
|
527
|
-
: []),
|
|
507
|
+
phaseLeaseReceipt(startedIds),
|
|
508
|
+
...(failureLines.length > 0 ? ["Launch failures:", ...failureLines] : []),
|
|
528
509
|
].join("\n") + queuePacingNote();
|
|
529
510
|
return {
|
|
530
511
|
content: [{ type: "text", text }],
|
|
@@ -544,22 +525,26 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
|
|
|
544
525
|
singleCatalogAgent ? isWriteCapableAgent(singleCatalogAgent) : undefined,
|
|
545
526
|
singleCatalogAgent?.isolation,
|
|
546
527
|
),
|
|
547
|
-
{
|
|
528
|
+
{ deliveryRoute: params.wait ? "await" : "background" },
|
|
548
529
|
);
|
|
549
530
|
if (result.exitCode !== -1) {
|
|
550
531
|
throw new Error(getResultOutput(result));
|
|
551
532
|
}
|
|
552
|
-
const runRef = result.runId === undefined ? result.agent : `#${result.runId} ${result.agent}`;
|
|
553
533
|
if (params.wait && result.runId !== undefined) {
|
|
554
534
|
const blocks = await awaitRunResults(runtime, [result.runId], signal, config.maxResultLines, ctx.cwd, makeProgress(makeDetails("single", true)([result])));
|
|
535
|
+
if (signal?.aborted) runtime.fallbackAwaitDelivery([result.runId]);
|
|
536
|
+
else runtime.completeAwaitDelivery([result.runId]);
|
|
555
537
|
return {
|
|
556
|
-
content: [{ type: "text", text:
|
|
538
|
+
content: [{ type: "text", text: blocks }],
|
|
557
539
|
details: makeDetails("single", true)([result]),
|
|
558
540
|
...toolUsage(runtime, [result.runId]),
|
|
559
541
|
};
|
|
560
542
|
}
|
|
561
543
|
return {
|
|
562
|
-
content: [{
|
|
544
|
+
content: [{
|
|
545
|
+
type: "text",
|
|
546
|
+
text: phaseLeaseReceipt(result.runId === undefined ? [] : [result.runId]) + queuePacingNote(),
|
|
547
|
+
}],
|
|
563
548
|
details: makeDetails("single", true)([result]),
|
|
564
549
|
};
|
|
565
550
|
|
package/src/durable.ts
CHANGED
|
@@ -20,7 +20,7 @@ import { uptime } from "node:os";
|
|
|
20
20
|
import { dirname, join } from "node:path";
|
|
21
21
|
import type { UsageStats } from "./rpc-run.ts";
|
|
22
22
|
import type { SubagentThread } from "./runtime.ts";
|
|
23
|
-
import { getResultOutput, isFailedResult, getProjectRoot,
|
|
23
|
+
import { getResultOutput, isFailedResult, getProjectRoot, getSubagentsRoot, type SingleResult } from "./spawn.ts";
|
|
24
24
|
import {
|
|
25
25
|
isPathInside,
|
|
26
26
|
restoreWorktreeIsolation,
|
|
@@ -34,7 +34,7 @@ export const THREADS_MANIFEST_FILE_NAME = "pi-subagents-threads.json";
|
|
|
34
34
|
const THREADS_MANIFEST_VERSION = 1;
|
|
35
35
|
|
|
36
36
|
/** Project directories whose newest file has not been touched for this long
|
|
37
|
-
* are deleted wholesale
|
|
37
|
+
* are deleted wholesale at session start, so per-project sessions/worktrees/results
|
|
38
38
|
* can never accumulate forever. Parked threads' manifest references always
|
|
39
39
|
* win over the age rule. */
|
|
40
40
|
export const PROJECT_ROOT_MAX_AGE_MS = 3 * 24 * 60 * 60 * 1_000;
|
|
@@ -118,12 +118,6 @@ export function getThreadsManifestPath(configPath: string, cwd: string): string
|
|
|
118
118
|
return join(getProjectRoot(configPath, cwd), THREADS_MANIFEST_FILE_NAME);
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
-
/** Location of the pre-per-project global manifest; only read by the
|
|
122
|
-
* one-time migration that folds it into the project roots. */
|
|
123
|
-
function getLegacyManifestPath(configPath: string): string {
|
|
124
|
-
return join(dirname(configPath), THREADS_MANIFEST_FILE_NAME);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
121
|
function normalizeUsage(value: unknown): UsageStats {
|
|
128
122
|
const raw = (value && typeof value === "object" ? value : {}) as Record<string, unknown>;
|
|
129
123
|
const num = (key: string): number => (typeof raw[key] === "number" && Number.isFinite(raw[key]) ? raw[key] : 0);
|
|
@@ -226,7 +220,7 @@ function projectManifestPaths(durableRoot: string): string[] {
|
|
|
226
220
|
* sweeps that must see references from anywhere. */
|
|
227
221
|
export async function readThreadRecords(configPath: string): Promise<ThreadRecord[]> {
|
|
228
222
|
const manifests = await Promise.all(
|
|
229
|
-
projectManifestPaths(
|
|
223
|
+
projectManifestPaths(getSubagentsRoot(configPath))
|
|
230
224
|
.map((path) => readManifestRecords(path)),
|
|
231
225
|
);
|
|
232
226
|
return manifests.flat();
|
|
@@ -365,12 +359,12 @@ async function discardRecordArtifacts(record: ThreadRecord): Promise<void> {
|
|
|
365
359
|
}
|
|
366
360
|
|
|
367
361
|
/** Drop records past their retention age along with their artifacts. Runs at
|
|
368
|
-
*
|
|
362
|
+
* session start; the fixed age honors the no-config-knobs policy. */
|
|
369
363
|
export async function pruneThreadRecords(
|
|
370
364
|
configPath: string,
|
|
371
365
|
now = Date.now(),
|
|
372
366
|
): Promise<void> {
|
|
373
|
-
const durableRoot =
|
|
367
|
+
const durableRoot = getSubagentsRoot(configPath);
|
|
374
368
|
for (const path of projectManifestPaths(durableRoot)) {
|
|
375
369
|
await withFileMutationQueue(path, async () => {
|
|
376
370
|
const records = await readManifestRecords(path);
|
|
@@ -390,47 +384,6 @@ export async function pruneThreadRecords(
|
|
|
390
384
|
}
|
|
391
385
|
}
|
|
392
386
|
|
|
393
|
-
/** One-time move of the pre-per-project global manifest beside the config into
|
|
394
|
-
* the project roots its records belong to, so an upgrade keeps parked work
|
|
395
|
-
* resumable and pi home is left without a manifest. Existing project records
|
|
396
|
-
* win over legacy ones; the legacy file is removed only after every group
|
|
397
|
-
* landed, and an unreadable file stays put for the next boot. */
|
|
398
|
-
export async function migrateLegacyThreadsManifest(configPath: string): Promise<void> {
|
|
399
|
-
const legacyPath = getLegacyManifestPath(configPath);
|
|
400
|
-
let records: ThreadRecord[];
|
|
401
|
-
try {
|
|
402
|
-
const parsed = JSON.parse(await readFile(legacyPath, "utf8")) as { records?: unknown };
|
|
403
|
-
if (!Array.isArray(parsed.records)) return;
|
|
404
|
-
records = parsed.records.flatMap((record) => {
|
|
405
|
-
const normalized = normalizeRecord(record);
|
|
406
|
-
return normalized ? [normalized] : [];
|
|
407
|
-
});
|
|
408
|
-
} catch {
|
|
409
|
-
return;
|
|
410
|
-
}
|
|
411
|
-
const groups = new Map<string, ThreadRecord[]>();
|
|
412
|
-
for (const record of records) {
|
|
413
|
-
const path = getThreadsManifestPath(configPath, record.cwd);
|
|
414
|
-
const group = groups.get(path);
|
|
415
|
-
if (group) group.push(record);
|
|
416
|
-
else groups.set(path, [record]);
|
|
417
|
-
}
|
|
418
|
-
let migrated = true;
|
|
419
|
-
for (const [path, group] of groups) {
|
|
420
|
-
await withFileMutationQueue(path, async () => {
|
|
421
|
-
const existing = await readManifestRecords(path);
|
|
422
|
-
const merged = [...existing];
|
|
423
|
-
for (const record of group) {
|
|
424
|
-
if (!merged.some((candidate) => candidate.runId === record.runId)) merged.push(record);
|
|
425
|
-
}
|
|
426
|
-
await writeManifest(path, merged);
|
|
427
|
-
}).catch(() => {
|
|
428
|
-
migrated = false;
|
|
429
|
-
});
|
|
430
|
-
}
|
|
431
|
-
if (migrated) await rm(legacyPath, { force: true }).catch(() => undefined);
|
|
432
|
-
}
|
|
433
|
-
|
|
434
387
|
/** Paths a manifest still references; used by the state-root sweep so
|
|
435
388
|
* freshly created-but-unrecorded directories are never touched. */
|
|
436
389
|
export function referencedDurablePaths(records: readonly ThreadRecord[]): Set<string> {
|
|
@@ -490,7 +443,7 @@ export async function pruneStaleProjectRoots(configPath: string, options: { now?
|
|
|
490
443
|
const now = options.now ?? Date.now();
|
|
491
444
|
const records = await readThreadRecords(configPath).catch(() => [] as ThreadRecord[]);
|
|
492
445
|
const referenced = referencedDurablePaths(records);
|
|
493
|
-
const root =
|
|
446
|
+
const root = getSubagentsRoot(configPath);
|
|
494
447
|
let projects: Dirent[];
|
|
495
448
|
try {
|
|
496
449
|
projects = readdirSync(root, { withFileTypes: true });
|
package/src/index.ts
CHANGED
|
@@ -72,30 +72,27 @@ export default function (pi: ExtensionAPI): void {
|
|
|
72
72
|
registerLookupTools(pi, runtime);
|
|
73
73
|
|
|
74
74
|
pi.registerCommand("subagents-setup", {
|
|
75
|
-
description: "Configure pi-subagents: agents,
|
|
75
|
+
description: "Configure pi-subagents: agents, models, and per-role thinking",
|
|
76
76
|
handler: async (_args, ctx) => {
|
|
77
77
|
await runSetup(ctx, configPath);
|
|
78
78
|
},
|
|
79
79
|
});
|
|
80
80
|
|
|
81
|
+
pi.on("session_start", async () => {
|
|
82
|
+
await bootstrapDurableState(runtime);
|
|
83
|
+
});
|
|
81
84
|
registerAnnouncements(pi, runtime);
|
|
82
85
|
|
|
83
|
-
//
|
|
84
|
-
// restart keeps status and resume working, then age out old records and sweep
|
|
85
|
-
// leaked temp/state directories. Registration never blocks on it and every
|
|
86
|
-
// stage is best-effort; the restore pass is published as runtime.durableRestore
|
|
87
|
-
// so the tools and the session-start notice wait for it instead of racing it.
|
|
88
|
-
void bootstrapDurableState(runtime);
|
|
89
|
-
|
|
90
|
-
// Proactive dispatch: inject the delegation directive into the parent system prompt.
|
|
86
|
+
// Inject the routing contract plus bounded live phase leases into each parent turn.
|
|
91
87
|
pi.on("before_agent_start", async (event, ctx) => {
|
|
88
|
+
await runtime.durableRestore;
|
|
92
89
|
const config = await loadConfig(configPath);
|
|
93
90
|
const { agents } = discoverAgents(ctx.cwd, {
|
|
94
91
|
scope: config.agentScope,
|
|
95
92
|
enabledNames: config.enabledAgents,
|
|
96
93
|
projectTrusted: ctx.isProjectTrusted?.() === true,
|
|
97
94
|
});
|
|
98
|
-
const directive = buildDelegationDirective(agents);
|
|
95
|
+
const directive = buildDelegationDirective(agents, runtime.threads.values());
|
|
99
96
|
if (!directive) return undefined;
|
|
100
97
|
return { systemPrompt: `${event.systemPrompt}\n${directive}` };
|
|
101
98
|
});
|
package/src/monitor.ts
CHANGED
|
@@ -52,7 +52,7 @@ export interface RunView {
|
|
|
52
52
|
model?: string;
|
|
53
53
|
/** Selected model ref when the run handed off to current main. */
|
|
54
54
|
modelFallbackFrom?: string;
|
|
55
|
-
/** Effective thinking strength this run was launched with (
|
|
55
|
+
/** Effective thinking strength this run was launched with (setup override or role default). */
|
|
56
56
|
thinking?: string;
|
|
57
57
|
isolation?: IsolationMode;
|
|
58
58
|
integrationStatus?: RunIntegrationStatus;
|
|
@@ -187,7 +187,7 @@ function tailGraphemes(segments: string[], maxWidth: number): string {
|
|
|
187
187
|
* One-line task preview, capped by `maxWidth` display columns (default 80).
|
|
188
188
|
* `keysOnly` (default): extracted key fragments (paths, quoted phrases,
|
|
189
189
|
* symbols) are shown bare — the agent name is already displayed next to the
|
|
190
|
-
* task line, so templated prose ("
|
|
190
|
+
* task line, so templated prose ("scout: trace how ...") adds nothing.
|
|
191
191
|
* `keysOnly: false` keeps the prose as `head…tail` (used for completion
|
|
192
192
|
* messages, where the Task line is the reader's only context).
|
|
193
193
|
* Grapheme-safe — CJK, ZWJ emoji and combining sequences are never split.
|
package/src/prompt.ts
CHANGED
|
@@ -1,70 +1,131 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Builds the delegation directive injected into the parent model's
|
|
3
3
|
* system prompt via `before_agent_start`. It is paid on every turn, so it
|
|
4
|
-
* stays a lean routing
|
|
5
|
-
*
|
|
6
|
-
* intentionally minimal so role/process guidance is not paid for twice.
|
|
4
|
+
* stays a lean routing, phase-ownership, and verification contract.
|
|
5
|
+
* Detailed role guidance remains in each child's own prompt.
|
|
7
6
|
*/
|
|
8
7
|
|
|
8
|
+
import { resolve } from "node:path";
|
|
9
9
|
import type { AgentConfig } from "./agents.ts";
|
|
10
10
|
import { formatCatalogEntry } from "./agents.ts";
|
|
11
11
|
|
|
12
|
+
export interface PhaseLeaseSource {
|
|
13
|
+
id: number;
|
|
14
|
+
agentName: string;
|
|
15
|
+
task: string;
|
|
16
|
+
cwd: string;
|
|
17
|
+
state: "queued" | "resuming" | "running" | "interrupting" | "parked" | "completed" | "failed" | "stopped";
|
|
18
|
+
lifecycleOperation?: "park" | "resume" | "stop" | "settle";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const ACTIVE_LEASE_STATES = new Set<PhaseLeaseSource["state"]>([
|
|
22
|
+
"queued",
|
|
23
|
+
"resuming",
|
|
24
|
+
"running",
|
|
25
|
+
"interrupting",
|
|
26
|
+
"parked",
|
|
27
|
+
]);
|
|
28
|
+
const MAX_ACTIVE_LEASES = 2;
|
|
29
|
+
const MAX_LEASE_TASK_LENGTH = 56;
|
|
30
|
+
|
|
12
31
|
function bullets(lines: readonly string[]): string {
|
|
13
32
|
return lines.map((line) => `- ${line}`).join("\n");
|
|
14
33
|
}
|
|
15
34
|
|
|
35
|
+
function phaseForAgent(agentName: string): string {
|
|
36
|
+
if (agentName === "scout") return "broad reconnaissance";
|
|
37
|
+
if (agentName === "artisan") return "implementation and targeted checks";
|
|
38
|
+
if (agentName === "steward") return "pre-commit cleanup and cross-cutting docs";
|
|
39
|
+
return "delegated scope";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isActivePhaseLease(source: PhaseLeaseSource): boolean {
|
|
43
|
+
return source.lifecycleOperation === "settle" || ACTIVE_LEASE_STATES.has(source.state);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function normalizedTask(task: string): string {
|
|
47
|
+
return task.replace(/\s+/gu, " ").trim();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function normalizedCwd(cwd: string): string {
|
|
51
|
+
const resolved = resolve(cwd);
|
|
52
|
+
return process.platform === "win32" ? resolved.toLowerCase() : resolved;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function findDuplicateActiveDispatch(
|
|
56
|
+
sources: Iterable<PhaseLeaseSource>,
|
|
57
|
+
task: string,
|
|
58
|
+
cwd: string,
|
|
59
|
+
): PhaseLeaseSource | undefined {
|
|
60
|
+
const taskKey = normalizedTask(task);
|
|
61
|
+
const cwdKey = normalizedCwd(cwd);
|
|
62
|
+
return [...sources].find((source) =>
|
|
63
|
+
isActivePhaseLease(source) &&
|
|
64
|
+
normalizedTask(source.task) === taskKey &&
|
|
65
|
+
normalizedCwd(source.cwd) === cwdKey,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function summarizeLeaseTask(task: string): string {
|
|
70
|
+
const oneLine = normalizedTask(task);
|
|
71
|
+
const characters = [...oneLine];
|
|
72
|
+
return characters.length <= MAX_LEASE_TASK_LENGTH
|
|
73
|
+
? oneLine
|
|
74
|
+
: `${characters.slice(0, MAX_LEASE_TASK_LENGTH - 1).join("")}…`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function formatActivePhaseLeases(sources: Iterable<PhaseLeaseSource>): string {
|
|
78
|
+
const active = [...sources].filter(isActivePhaseLease);
|
|
79
|
+
if (active.length === 0) return "";
|
|
80
|
+
const lines = active.slice(0, MAX_ACTIVE_LEASES).map((source) => {
|
|
81
|
+
const state = source.lifecycleOperation === "settle" ? "settling" : source.state;
|
|
82
|
+
return `- #${source.id} ${phaseForAgent(source.agentName)} (${source.agentName}, ${state}): ${summarizeLeaseTask(source.task)}`;
|
|
83
|
+
});
|
|
84
|
+
if (active.length > MAX_ACTIVE_LEASES) {
|
|
85
|
+
lines.push(`- … ${active.length - MAX_ACTIVE_LEASES} more active lease${active.length - MAX_ACTIVE_LEASES === 1 ? "" : "s"} omitted`);
|
|
86
|
+
}
|
|
87
|
+
return lines.join("\n");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function formatPhaseLeaseReceipt(sources: Iterable<PhaseLeaseSource>): string {
|
|
91
|
+
const leases = formatActivePhaseLeases(sources);
|
|
92
|
+
if (!leases) return "";
|
|
93
|
+
return `Active phase lease:\n${leases}\nDo not duplicate it; continue only disjoint work.`;
|
|
94
|
+
}
|
|
95
|
+
|
|
16
96
|
export function buildDelegationDirective(
|
|
17
97
|
agents: AgentConfig[],
|
|
98
|
+
activeLeaseSources: Iterable<PhaseLeaseSource> = [],
|
|
18
99
|
): string {
|
|
19
|
-
|
|
100
|
+
const activeLeases = formatActivePhaseLeases(activeLeaseSources);
|
|
101
|
+
if (agents.length === 0 && !activeLeases) return "";
|
|
20
102
|
|
|
21
|
-
const catalog = agents.map(formatCatalogEntry).join("\n");
|
|
22
|
-
const
|
|
23
|
-
const
|
|
103
|
+
const catalog = agents.length > 0 ? agents.map(formatCatalogEntry).join("\n") : "- (none enabled)";
|
|
104
|
+
const hasScout = agents.some((agent) => agent.name === "scout");
|
|
105
|
+
const hasArtisan = agents.some((agent) => agent.name === "artisan");
|
|
106
|
+
const hasSteward = agents.some((agent) => agent.name === "steward");
|
|
24
107
|
|
|
25
108
|
const dispatchRules = [
|
|
26
|
-
|
|
27
|
-
"Keep
|
|
28
|
-
...(
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
"`executor`: brief it as the edit authorization. For cleanup, name the scope (uncommitted diff, Git range, directory) — every safe proven cut applies without per-item approval; finding no safe cut is a valid result. After a wide fan-out, pass the result-artifact paths to one executor and read its merged brief instead of every result yourself.",
|
|
36
|
-
]
|
|
37
|
-
: []),
|
|
38
|
-
"A discovered defect is not a change: re-read the current code and confirm it is not a false positive before you edit or brief a writer to edit.",
|
|
39
|
-
"Parallelize by default: map the todo list onto ONE `tasks` dispatch. One child owns one deliverable and its files; only genuinely dependent work waits for its prerequisite.",
|
|
40
|
-
"Brief each child completely — goal, exact paths, constraints, expected output; it has no conversation memory and cannot delegate. Resume parked threads with `subagent_control resume`.",
|
|
41
|
-
];
|
|
42
|
-
|
|
43
|
-
const handoffRules = [
|
|
44
|
-
"Dispatch never blocks or ends your turn — keep working, but only on what the children are not: never re-read a scope you just delegated. Each completion resumes you automatically; never sleep or poll for it.",
|
|
45
|
-
"Results are already shown; add only your conclusion or next action, never a restatement.",
|
|
46
|
-
"Never declare the overall task done while a dispatched run is still active.",
|
|
47
|
-
];
|
|
48
|
-
|
|
49
|
-
const verificationRules = [
|
|
50
|
-
"Never report an unrun check as passed; surface unavailable checks and pre-existing failures, and inspect the actual diff before reporting completion.",
|
|
51
|
-
"Commit or push only when explicitly requested and applicable checks pass.",
|
|
109
|
+
"Main owns routing, architecture, integration, the final gate, and release. Each child starts a paid context; delegate only when saved main-context work exceeds handoff cost.",
|
|
110
|
+
"Keep atomic lookups, focused edits, known answers, and context-heavy work in main. Cluster related reconnaissance into one scout brief.",
|
|
111
|
+
...(hasScout ? ["`scout`: broad or unfamiliar reconnaissance; return compact findings and decisive citations."] : []),
|
|
112
|
+
...(hasArtisan ? ["`artisan`: substantial self-contained implementation, including affected tests, docs, comments, and targeted checks."] : []),
|
|
113
|
+
...(hasSteward ? ["`steward`: one pre-commit cleanup or cross-cutting docs/comments pass for a completed broad or multi-writer change; keep small diff hygiene inline."] : []),
|
|
114
|
+
"One owner per phase; dependent phases wait. A launch leases that phase: main may inspect its result, citations, diff, and check output but must not rerun it.",
|
|
115
|
+
"Parallelize only independently justified, disjoint scopes. Brief goal, scope, constraints, and expected output; resume retained work with `subagent_control`.",
|
|
116
|
+
"Completions deliver automatically. Never poll, restate a result, or finish while a run is active.",
|
|
117
|
+
"Inspect the integrated diff and actual check output. Never report an unrun check as passed.",
|
|
52
118
|
];
|
|
53
119
|
|
|
54
120
|
return `
|
|
55
|
-
## Sub-agent delegation
|
|
56
|
-
|
|
57
|
-
\`subagent\` runs isolated leaf Pi child processes in the background.
|
|
121
|
+
## Sub-agent delegation
|
|
58
122
|
|
|
59
123
|
Agents:
|
|
60
124
|
${catalog}
|
|
61
125
|
|
|
62
|
-
|
|
63
|
-
${bullets(dispatchRules)}
|
|
64
|
-
|
|
65
|
-
Result handoff:
|
|
66
|
-
${bullets(handoffRules)}
|
|
126
|
+
Rules:
|
|
127
|
+
${bullets(dispatchRules)}${activeLeases ? `
|
|
67
128
|
|
|
68
|
-
|
|
69
|
-
${
|
|
129
|
+
Active phase leases:
|
|
130
|
+
${activeLeases}` : ""}`;
|
|
70
131
|
}
|