@narumitw/pi-subagents 0.43.0 → 0.46.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -13
- package/package.json +1 -1
- package/src/agents.ts +16 -0
- package/src/config-ui.ts +151 -20
- package/src/consult-render.ts +194 -0
- package/src/consult.ts +164 -37
- package/src/cwd-policy.ts +183 -0
- package/src/execution.ts +127 -64
- package/src/in-process-transport.ts +3 -3
- package/src/inspect-render.ts +234 -0
- package/src/inspect.ts +51 -3
- package/src/persistence.ts +29 -0
- package/src/pi-invocation.ts +168 -0
- package/src/registry.ts +12 -0
- package/src/render-common.ts +252 -0
- package/src/render.ts +134 -99
- package/src/runner.ts +19 -22
- package/src/settings.ts +111 -11
- package/src/stateful-guidance.ts +35 -0
- package/src/stateful-lifecycle.ts +31 -0
- package/src/stateful-render.ts +249 -0
- package/src/stateful-safety.ts +91 -0
- package/src/stateful.ts +235 -218
- package/src/subagents.ts +60 -18
- package/src/subprocess-transport.ts +19 -2
package/src/runner.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
|
|
|
6
6
|
import type { Message } from "@earendil-works/pi-ai";
|
|
7
7
|
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
8
8
|
import type { AgentConfig, AgentScope, AgentSource, SubagentThinkingLevel } from "./agents.js";
|
|
9
|
+
import type { TargetPolicyAudit } from "./cwd-policy.js";
|
|
9
10
|
import {
|
|
10
11
|
appendBounded,
|
|
11
12
|
DEFAULT_MAX_CONTEXT_BYTES,
|
|
@@ -15,6 +16,7 @@ import {
|
|
|
15
16
|
MAX_SUBAGENT_TIMEOUT_MS,
|
|
16
17
|
truncateUtf8,
|
|
17
18
|
} from "./limits.js";
|
|
19
|
+
import { resolvePiInvocation } from "./pi-invocation.js";
|
|
18
20
|
import { JsonLineDecoder } from "./protocol.js";
|
|
19
21
|
|
|
20
22
|
export const KILL_GRACE_MS = 5000;
|
|
@@ -81,6 +83,7 @@ export interface SingleResult {
|
|
|
81
83
|
malformedEvents?: number;
|
|
82
84
|
launchFailed?: boolean;
|
|
83
85
|
processStarted?: boolean;
|
|
86
|
+
target?: TargetPolicyAudit;
|
|
84
87
|
policy?: {
|
|
85
88
|
inherited: string[];
|
|
86
89
|
overridden: string[];
|
|
@@ -351,22 +354,6 @@ export function buildPiArgs(options: PiArgsOptions): string[] {
|
|
|
351
354
|
return args;
|
|
352
355
|
}
|
|
353
356
|
|
|
354
|
-
function getPiInvocation(args: string[]): { command: string; args: string[] } {
|
|
355
|
-
const currentScript = process.argv[1];
|
|
356
|
-
const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
|
|
357
|
-
if (currentScript && !isBunVirtualScript && fs.existsSync(currentScript)) {
|
|
358
|
-
return { command: process.execPath, args: [currentScript, ...args] };
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
const execName = path.basename(process.execPath).toLowerCase();
|
|
362
|
-
const isGenericRuntime = /^(node|bun)(\.exe)?$/.test(execName);
|
|
363
|
-
if (!isGenericRuntime) {
|
|
364
|
-
return { command: process.execPath, args };
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
return { command: "pi", args };
|
|
368
|
-
}
|
|
369
|
-
|
|
370
357
|
function signalProcess(proc: ReturnType<typeof spawn>, signal: NodeJS.Signals): void {
|
|
371
358
|
if (process.platform !== "win32" && proc.pid) {
|
|
372
359
|
try {
|
|
@@ -574,16 +561,26 @@ export async function runSingleAgent(
|
|
|
574
561
|
systemPromptPath: tmpPromptPath ?? undefined,
|
|
575
562
|
task,
|
|
576
563
|
});
|
|
577
|
-
let
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
const exitCode = await new Promise<number>((resolve) => {
|
|
581
|
-
const invocation = invocationOverride
|
|
564
|
+
let invocation: { command: string; args: string[] };
|
|
565
|
+
try {
|
|
566
|
+
invocation = invocationOverride
|
|
582
567
|
? {
|
|
583
568
|
command: invocationOverride.command,
|
|
584
569
|
args: [...(invocationOverride.argsPrefix ?? []), ...args],
|
|
585
570
|
}
|
|
586
|
-
:
|
|
571
|
+
: resolvePiInvocation(args);
|
|
572
|
+
} catch (error) {
|
|
573
|
+
currentResult.launchFailed = true;
|
|
574
|
+
currentResult.exitCode = 1;
|
|
575
|
+
currentResult.stderr = setErrorMessage(
|
|
576
|
+
error instanceof Error ? error.message : String(error),
|
|
577
|
+
);
|
|
578
|
+
return currentResult;
|
|
579
|
+
}
|
|
580
|
+
let wasAborted = false;
|
|
581
|
+
let timedOut = false;
|
|
582
|
+
|
|
583
|
+
const exitCode = await new Promise<number>((resolve) => {
|
|
587
584
|
let settled = false;
|
|
588
585
|
let cleanupTermination: (() => void) | undefined;
|
|
589
586
|
let timeout: NodeJS.Timeout | undefined;
|
package/src/settings.ts
CHANGED
|
@@ -6,8 +6,12 @@ import lockfile from "proper-lockfile";
|
|
|
6
6
|
import {
|
|
7
7
|
type AgentConfig,
|
|
8
8
|
CONSULT_RESOURCE_POLICIES,
|
|
9
|
+
CONSULTATION_CWD_POLICIES,
|
|
9
10
|
type CompletionDelivery,
|
|
11
|
+
type ConsultationCwdPolicy,
|
|
10
12
|
type ConsultResourcePolicy,
|
|
13
|
+
DELEGATION_CWD_POLICIES,
|
|
14
|
+
type DelegationCwdPolicy,
|
|
11
15
|
isThinkingLevel,
|
|
12
16
|
type SubagentAgentConfig,
|
|
13
17
|
type SubagentSettings,
|
|
@@ -158,6 +162,29 @@ export function normalizeSubagentSettings(value: unknown): SubagentSettings | un
|
|
|
158
162
|
}
|
|
159
163
|
settings.consult = consult;
|
|
160
164
|
}
|
|
165
|
+
if (hasOwn(value, "cwdPolicy")) {
|
|
166
|
+
if (!isPlainObject(value.cwdPolicy)) return undefined;
|
|
167
|
+
const cwdPolicy: NonNullable<SubagentSettings["cwdPolicy"]> = {};
|
|
168
|
+
if (hasOwn(value.cwdPolicy, "consultation")) {
|
|
169
|
+
if (
|
|
170
|
+
typeof value.cwdPolicy.consultation !== "string" ||
|
|
171
|
+
!CONSULTATION_CWD_POLICIES.includes(value.cwdPolicy.consultation as ConsultationCwdPolicy)
|
|
172
|
+
) {
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
175
|
+
cwdPolicy.consultation = value.cwdPolicy.consultation as ConsultationCwdPolicy;
|
|
176
|
+
}
|
|
177
|
+
if (hasOwn(value.cwdPolicy, "delegation")) {
|
|
178
|
+
if (
|
|
179
|
+
typeof value.cwdPolicy.delegation !== "string" ||
|
|
180
|
+
!DELEGATION_CWD_POLICIES.includes(value.cwdPolicy.delegation as DelegationCwdPolicy)
|
|
181
|
+
) {
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
184
|
+
cwdPolicy.delegation = value.cwdPolicy.delegation as DelegationCwdPolicy;
|
|
185
|
+
}
|
|
186
|
+
settings.cwdPolicy = cwdPolicy;
|
|
187
|
+
}
|
|
161
188
|
return settings;
|
|
162
189
|
}
|
|
163
190
|
|
|
@@ -165,6 +192,8 @@ const SETTINGS_FILE = "pi-subagents.json";
|
|
|
165
192
|
const LEGACY_SETTINGS_FILE = "pi-subagents-config.json";
|
|
166
193
|
const DEFAULT_COMPLETION_DELIVERY: CompletionDelivery = "next-turn";
|
|
167
194
|
export const DEFAULT_CONSULT_RESOURCE_POLICY: ConsultResourcePolicy = "project-context";
|
|
195
|
+
export const DEFAULT_CONSULTATION_CWD_POLICY: ConsultationCwdPolicy = "anywhere";
|
|
196
|
+
export const DEFAULT_DELEGATION_CWD_POLICY: DelegationCwdPolicy = "trusted-targets";
|
|
168
197
|
const SETTINGS_LOCK_FS_ADAPTER = {
|
|
169
198
|
mkdir: fs.mkdir,
|
|
170
199
|
mkdirSync: fs.mkdirSync,
|
|
@@ -261,6 +290,18 @@ export interface ConsultResourceSettingsSnapshot {
|
|
|
261
290
|
error?: string;
|
|
262
291
|
}
|
|
263
292
|
|
|
293
|
+
export interface CwdPolicyFieldSnapshot<T> {
|
|
294
|
+
value: T;
|
|
295
|
+
source: "default" | "user settings";
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export interface CwdPolicySettingsSnapshot {
|
|
299
|
+
path: string;
|
|
300
|
+
consultation: CwdPolicyFieldSnapshot<ConsultationCwdPolicy>;
|
|
301
|
+
delegation: CwdPolicyFieldSnapshot<DelegationCwdPolicy>;
|
|
302
|
+
error?: string;
|
|
303
|
+
}
|
|
304
|
+
|
|
264
305
|
export interface SubagentSettingsSnapshot {
|
|
265
306
|
path: string;
|
|
266
307
|
settings?: SubagentSettings;
|
|
@@ -302,16 +343,28 @@ function inspectSubagentSettingsPath(configPath: string): {
|
|
|
302
343
|
settings?: SubagentSettings;
|
|
303
344
|
error?: string;
|
|
304
345
|
} {
|
|
346
|
+
const fileName = path.basename(configPath);
|
|
347
|
+
let contents: string;
|
|
305
348
|
try {
|
|
306
|
-
|
|
307
|
-
const settings = normalizeSubagentSettings(raw);
|
|
308
|
-
if (!isPlainObject(raw) || !settings) {
|
|
309
|
-
throw new Error(`${path.basename(configPath)} is not a valid settings object`);
|
|
310
|
-
}
|
|
311
|
-
return { path: configPath, raw, settings };
|
|
349
|
+
contents = fs.readFileSync(configPath, "utf8");
|
|
312
350
|
} catch (error) {
|
|
313
|
-
|
|
351
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
352
|
+
return {
|
|
353
|
+
path: configPath,
|
|
354
|
+
error: `${fileName} could not be read${code ? ` (${safeErrorCode(code)})` : ""}`,
|
|
355
|
+
};
|
|
314
356
|
}
|
|
357
|
+
let raw: unknown;
|
|
358
|
+
try {
|
|
359
|
+
raw = JSON.parse(contents);
|
|
360
|
+
} catch {
|
|
361
|
+
return { path: configPath, error: `${fileName} contains malformed JSON` };
|
|
362
|
+
}
|
|
363
|
+
const settings = normalizeSubagentSettings(raw);
|
|
364
|
+
if (!isPlainObject(raw) || !settings) {
|
|
365
|
+
return { path: configPath, error: `${fileName} is not a valid settings object` };
|
|
366
|
+
}
|
|
367
|
+
return { path: configPath, raw, settings };
|
|
315
368
|
}
|
|
316
369
|
|
|
317
370
|
export function inspectSubagentSettings(): SubagentSettingsSnapshot {
|
|
@@ -343,6 +396,30 @@ export function inspectConsultResourceSettings(): ConsultResourceSettingsSnapsho
|
|
|
343
396
|
};
|
|
344
397
|
}
|
|
345
398
|
|
|
399
|
+
export function inspectCwdPolicySettings(): CwdPolicySettingsSnapshot {
|
|
400
|
+
const inspected = inspectSubagentSettingsDocument();
|
|
401
|
+
if (!inspected.raw || !inspected.settings) {
|
|
402
|
+
return {
|
|
403
|
+
path: inspected.path,
|
|
404
|
+
consultation: { value: DEFAULT_CONSULTATION_CWD_POLICY, source: "default" },
|
|
405
|
+
delegation: { value: DEFAULT_DELEGATION_CWD_POLICY, source: "default" },
|
|
406
|
+
...(inspected.error ? { error: inspected.error } : {}),
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
const rawPolicy = isPlainObject(inspected.raw.cwdPolicy) ? inspected.raw.cwdPolicy : undefined;
|
|
410
|
+
return {
|
|
411
|
+
path: inspected.path,
|
|
412
|
+
consultation: {
|
|
413
|
+
value: inspected.settings.cwdPolicy?.consultation ?? DEFAULT_CONSULTATION_CWD_POLICY,
|
|
414
|
+
source: rawPolicy && hasOwn(rawPolicy, "consultation") ? "user settings" : "default",
|
|
415
|
+
},
|
|
416
|
+
delegation: {
|
|
417
|
+
value: inspected.settings.cwdPolicy?.delegation ?? DEFAULT_DELEGATION_CWD_POLICY,
|
|
418
|
+
source: rawPolicy && hasOwn(rawPolicy, "delegation") ? "user settings" : "default",
|
|
419
|
+
},
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
|
|
346
423
|
export function inspectDelegationWorkflowSettings(): DelegationWorkflowSettingsSnapshot {
|
|
347
424
|
const inspected = inspectSubagentSettingsDocument();
|
|
348
425
|
if (!inspected.raw || !inspected.settings) {
|
|
@@ -458,6 +535,29 @@ export function updateConsultResourceSetting(value: ConsultResourcePolicy): void
|
|
|
458
535
|
});
|
|
459
536
|
}
|
|
460
537
|
|
|
538
|
+
export function updateCwdPolicySetting(field: "consultation", value: ConsultationCwdPolicy): void;
|
|
539
|
+
export function updateCwdPolicySetting(field: "delegation", value: DelegationCwdPolicy): void;
|
|
540
|
+
export function updateCwdPolicySetting(
|
|
541
|
+
field: "consultation" | "delegation",
|
|
542
|
+
value: ConsultationCwdPolicy | DelegationCwdPolicy,
|
|
543
|
+
): void {
|
|
544
|
+
withSettingsMutationLock(() => {
|
|
545
|
+
const update = readSettingsObjectForUpdate();
|
|
546
|
+
const raw = update.document;
|
|
547
|
+
const cwdPolicy = raw.cwdPolicy;
|
|
548
|
+
if (cwdPolicy !== undefined && !isPlainObject(cwdPolicy)) {
|
|
549
|
+
throw new Error(`Cannot update invalid ${SETTINGS_FILE} cwdPolicy settings`);
|
|
550
|
+
}
|
|
551
|
+
writeSettingsObjectUnlocked(
|
|
552
|
+
{
|
|
553
|
+
...raw,
|
|
554
|
+
cwdPolicy: { ...(cwdPolicy ?? {}), [field]: value },
|
|
555
|
+
},
|
|
556
|
+
update.replaceCanonical,
|
|
557
|
+
);
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
|
|
461
561
|
export function updateAgentToolsSetting(name: string, tools: string[] | undefined): void {
|
|
462
562
|
withSettingsMutationLock(() => {
|
|
463
563
|
const update = readSettingsObjectForUpdate();
|
|
@@ -504,8 +604,8 @@ function readSettingsObjectForUpdate(): SettingsObjectForUpdate {
|
|
|
504
604
|
let parsed: unknown;
|
|
505
605
|
try {
|
|
506
606
|
parsed = JSON.parse(fs.readFileSync(activePath, "utf8"));
|
|
507
|
-
} catch
|
|
508
|
-
throw new Error(`Cannot update malformed ${activeFile}
|
|
607
|
+
} catch {
|
|
608
|
+
throw new Error(`Cannot update malformed ${activeFile}`);
|
|
509
609
|
}
|
|
510
610
|
if (!isPlainObject(parsed) || !normalizeSubagentSettings(parsed)) {
|
|
511
611
|
throw new Error(`Cannot update invalid ${activeFile}`);
|
|
@@ -585,8 +685,8 @@ function readSettingsSnapshot(configPath: string): {
|
|
|
585
685
|
}
|
|
586
686
|
}
|
|
587
687
|
|
|
588
|
-
function
|
|
589
|
-
return
|
|
688
|
+
function safeErrorCode(value: string): string {
|
|
689
|
+
return value.replace(/[^A-Z0-9_-]/giu, "?").slice(0, 64);
|
|
590
690
|
}
|
|
591
691
|
|
|
592
692
|
export function uniqueToolNames(tools: string[]): string[] {
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { CompletionDelivery } from "./agents.js";
|
|
2
|
+
|
|
3
|
+
export function createSpawnPromptGuidelines(
|
|
4
|
+
completionDelivery: CompletionDelivery,
|
|
5
|
+
blockingEnabled = true,
|
|
6
|
+
): string[] {
|
|
7
|
+
const deliveryGuidance =
|
|
8
|
+
completionDelivery === "auto-resume"
|
|
9
|
+
? blockingEnabled
|
|
10
|
+
? "With subagent_spawn completion delivery set to auto-resume, prefer one subagent_spawn for broad asynchronous research or review that covers related branches even when the final answer depends on its result; do not choose blocking parallel fan-out merely to keep delegation in the same turn."
|
|
11
|
+
: "With subagent_spawn completion delivery set to auto-resume, prefer one subagent_spawn for broad asynchronous research or review that covers related branches even when the final answer depends on its result."
|
|
12
|
+
: blockingEnabled
|
|
13
|
+
? "With subagent_spawn completion delivery set to next-turn (the default), prefer one subagent_spawn for broad asynchronous research or review only when the current response does not depend on its result; use the blocking subagent when the final answer depends on the detached result."
|
|
14
|
+
: "With subagent_spawn completion delivery set to next-turn (the default), use subagent_spawn only when the current response does not depend on its result; complete final-answer-dependent work directly because an idle root is not awakened.";
|
|
15
|
+
const noLocalWorkGuidance =
|
|
16
|
+
completionDelivery === "auto-resume"
|
|
17
|
+
? "After subagent_spawn returns, do useful non-overlapping local work immediately. If none remains, briefly tell the user what subagent_spawn launched and end the response; auto-resume will request a synthesis turn after completion."
|
|
18
|
+
: "After subagent_spawn returns, do useful non-overlapping local work immediately. If none remains, briefly tell the user what subagent_spawn launched and end the response only when the current response does not depend on its result; next-turn delivery will not wake an idle root.";
|
|
19
|
+
return [
|
|
20
|
+
"Do not use subagent_spawn for simple or critical-path work that the main agent can perform directly.",
|
|
21
|
+
"Set subagent_spawn thinkingLevel to the lowest sufficient thinking level for the delegated task: use off or minimal for extraction, formatting, or mechanical work; low for straightforward bounded work; medium for ordinary multi-step research or implementation; high for complex debugging, design, review, or cross-file analysis; xhigh for highly ambiguous, cross-system, or high-risk analysis; and max only for the hardest tasks when quality clearly outweighs latency and cost. Omit subagent_spawn thinkingLevel only to preserve the agent or child default.",
|
|
22
|
+
deliveryGuidance,
|
|
23
|
+
"Use a single subagent_spawn only for a concrete bounded subtask that can run independently and has an isolation or specialization benefit such as independent review, bounded context/output, a distinct model/tool profile, or workspace isolation.",
|
|
24
|
+
...(blockingEnabled
|
|
25
|
+
? [
|
|
26
|
+
"Use the blocking subagent instead of subagent_spawn when synchronous output is required before the main agent can continue and waiting is intentional; queued steering cannot be processed until that blocking call returns.",
|
|
27
|
+
"When subagent_spawn fits the completion-delivery policy, do not choose a blocking parallel subagent merely to keep delegation in the same turn.",
|
|
28
|
+
]
|
|
29
|
+
: []),
|
|
30
|
+
"Add another subagent_spawn only for truly independent work with safe workspace concurrency.",
|
|
31
|
+
noLocalWorkGuidance,
|
|
32
|
+
'Consume and synthesize available subagent_spawn completion messages; use subagent_manage with action "interrupt" or "close" for agents that are no longer needed.',
|
|
33
|
+
'Completion from subagent_spawn is delivered automatically. Do not poll with subagent_manage action "list" or subagent_mailbox action "read", repeatedly check progress, or duplicate the delegated work.',
|
|
34
|
+
];
|
|
35
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { AgentRegistry } from "./registry.js";
|
|
2
|
+
import type { WorkspaceManager } from "./workspace.js";
|
|
3
|
+
|
|
4
|
+
export async function disposeStatefulRuntime(
|
|
5
|
+
registry: AgentRegistry | undefined,
|
|
6
|
+
workspaceManager: WorkspaceManager,
|
|
7
|
+
): Promise<unknown[]> {
|
|
8
|
+
const errors: unknown[] = [];
|
|
9
|
+
try {
|
|
10
|
+
await registry?.shutdown();
|
|
11
|
+
} catch (error) {
|
|
12
|
+
errors.push(error);
|
|
13
|
+
}
|
|
14
|
+
try {
|
|
15
|
+
await workspaceManager.cleanupAll();
|
|
16
|
+
} catch (error) {
|
|
17
|
+
errors.push(error);
|
|
18
|
+
}
|
|
19
|
+
return errors;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function assertCurrentSpawn(
|
|
23
|
+
signal: AbortSignal | undefined,
|
|
24
|
+
generation: number,
|
|
25
|
+
currentGeneration: number,
|
|
26
|
+
): void {
|
|
27
|
+
if (!signal?.aborted && generation === currentGeneration) return;
|
|
28
|
+
const error = new Error("Subagent spawn owner was replaced or aborted");
|
|
29
|
+
error.name = "AbortError";
|
|
30
|
+
throw error;
|
|
31
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import type { AgentToolResult } from "@earendil-works/pi-agent-core";
|
|
2
|
+
import type { Theme, ToolRenderResultOptions } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
4
|
+
import {
|
|
5
|
+
COLLAPSED_LIST_LIMIT,
|
|
6
|
+
expansionHint,
|
|
7
|
+
type RenderStatus,
|
|
8
|
+
recordList,
|
|
9
|
+
recordValue,
|
|
10
|
+
renderFallbackResult,
|
|
11
|
+
safeBlock,
|
|
12
|
+
safeLine,
|
|
13
|
+
statusBadge,
|
|
14
|
+
stringValue,
|
|
15
|
+
type ToolRendererContext,
|
|
16
|
+
textResult,
|
|
17
|
+
toolHeader,
|
|
18
|
+
} from "./render-common.js";
|
|
19
|
+
|
|
20
|
+
export type StatefulRenderTool = "spawn" | "send" | "manage" | "mailbox";
|
|
21
|
+
|
|
22
|
+
export function createStatefulToolRenderer(tool: StatefulRenderTool) {
|
|
23
|
+
return {
|
|
24
|
+
renderCall(args: unknown, theme: Theme) {
|
|
25
|
+
return renderStatefulCall(tool, recordValue(args) ?? {}, theme);
|
|
26
|
+
},
|
|
27
|
+
renderResult(
|
|
28
|
+
result: AgentToolResult<unknown>,
|
|
29
|
+
options: ToolRenderResultOptions,
|
|
30
|
+
theme: Theme,
|
|
31
|
+
context: ToolRendererContext<unknown>,
|
|
32
|
+
) {
|
|
33
|
+
return renderStatefulResult(tool, result, options, theme, context);
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function renderStatefulCall(tool: StatefulRenderTool, args: Record<string, unknown>, theme: Theme) {
|
|
39
|
+
if (tool === "spawn") {
|
|
40
|
+
const metadata = [
|
|
41
|
+
`[${safeLine(args.agentScope, "user", 64)}]`,
|
|
42
|
+
"detached",
|
|
43
|
+
safeLine(args.workspaceMode, "shared", 64),
|
|
44
|
+
];
|
|
45
|
+
if (typeof args.thinkingLevel === "string") metadata.push(`thinking:${args.thinkingLevel}`);
|
|
46
|
+
return new Text(
|
|
47
|
+
[
|
|
48
|
+
toolHeader(theme, "subagent_spawn", args.agent, metadata),
|
|
49
|
+
` ${theme.fg("dim", safeLine(args.task, "...", 2 * 1024))}`,
|
|
50
|
+
].join("\n"),
|
|
51
|
+
0,
|
|
52
|
+
0,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
if (tool === "send") {
|
|
56
|
+
return new Text(
|
|
57
|
+
[
|
|
58
|
+
toolHeader(theme, "subagent_send", args.agentId, ["follow-up"]),
|
|
59
|
+
` ${theme.fg("dim", safeLine(args.task, "...", 2 * 1024))}`,
|
|
60
|
+
].join("\n"),
|
|
61
|
+
0,
|
|
62
|
+
0,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
if (tool === "manage") {
|
|
66
|
+
const metadata: string[] = [];
|
|
67
|
+
if (typeof args.agentId === "string") metadata.push(`id:${safeLine(args.agentId, "", 256)}`);
|
|
68
|
+
if (args.subtree === true) metadata.push("subtree");
|
|
69
|
+
if (args.includeClosed === true) metadata.push("include closed");
|
|
70
|
+
return new Text(toolHeader(theme, "subagent_manage", args.action, metadata), 0, 0);
|
|
71
|
+
}
|
|
72
|
+
const metadata = [`id:${safeLine(args.agentId, "...", 256)}`];
|
|
73
|
+
if (args.action === "read") {
|
|
74
|
+
metadata.push(args.acknowledge === false ? "leave unread" : "acknowledge");
|
|
75
|
+
}
|
|
76
|
+
const lines = [toolHeader(theme, "subagent_mailbox", args.action, metadata)];
|
|
77
|
+
if (args.action === "send") {
|
|
78
|
+
lines.push(` ${theme.fg("dim", safeLine(args.message, "...", 2 * 1024))}`);
|
|
79
|
+
}
|
|
80
|
+
return new Text(lines.join("\n"), 0, 0);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function renderStatefulResult(
|
|
84
|
+
tool: StatefulRenderTool,
|
|
85
|
+
result: AgentToolResult<unknown>,
|
|
86
|
+
options: ToolRenderResultOptions,
|
|
87
|
+
theme: Theme,
|
|
88
|
+
context: ToolRendererContext<unknown>,
|
|
89
|
+
) {
|
|
90
|
+
const details = recordValue(result.details);
|
|
91
|
+
const args = recordValue(context.args) ?? {};
|
|
92
|
+
if (!details) return renderFallbackResult(result, options, theme, context.isError);
|
|
93
|
+
if (tool === "spawn" || tool === "send") {
|
|
94
|
+
const agent = recordValue(details.agent);
|
|
95
|
+
if (!agent) return renderFallbackResult(result, options, theme, context.isError);
|
|
96
|
+
return new Text(renderAgentResult(agent, result, options.expanded, theme), 0, 0);
|
|
97
|
+
}
|
|
98
|
+
if (tool === "manage") {
|
|
99
|
+
return new Text(renderManageResult(args, details, result, options.expanded, theme), 0, 0);
|
|
100
|
+
}
|
|
101
|
+
return new Text(renderMailboxResult(args, details, options.expanded, theme), 0, 0);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function renderAgentResult(
|
|
105
|
+
agent: Record<string, unknown>,
|
|
106
|
+
result: AgentToolResult<unknown>,
|
|
107
|
+
expanded: boolean,
|
|
108
|
+
theme: Theme,
|
|
109
|
+
): string {
|
|
110
|
+
const state = safeLine(agent.state, "unknown", 128);
|
|
111
|
+
const lines = [
|
|
112
|
+
`${statusBadge(theme, lifecycleStatus(state))} · ${theme.fg("accent", safeLine(agent.id, "agent", 256))} · ${theme.fg("toolOutput", safeLine(agent.agent, "subagent", 256))} · ${theme.fg("muted", state)}`,
|
|
113
|
+
];
|
|
114
|
+
const thinking = stringValue(agent.thinkingLevel);
|
|
115
|
+
const unread = typeof agent.unreadMessages === "number" ? agent.unreadMessages : 0;
|
|
116
|
+
if (thinking || unread > 0) {
|
|
117
|
+
lines.push(
|
|
118
|
+
theme.fg(
|
|
119
|
+
"dim",
|
|
120
|
+
[thinking && `thinking:${safeLine(thinking, "", 128)}`, unread > 0 && `unread:${unread}`]
|
|
121
|
+
.filter(Boolean)
|
|
122
|
+
.join(" · "),
|
|
123
|
+
),
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
if (expanded) {
|
|
127
|
+
const task = safeBlock(agent.currentTask, "", 2 * 1024).trim();
|
|
128
|
+
const error = safeBlock(agent.error, "", 2 * 1024).trim();
|
|
129
|
+
if (task) lines.push(theme.fg("dim", `task: ${task}`));
|
|
130
|
+
if (error) lines.push(theme.fg("error", `error: ${error}`));
|
|
131
|
+
const content = safeBlock(textResult(result), "", 8 * 1024).trim();
|
|
132
|
+
if (content) lines.push(theme.fg("toolOutput", content));
|
|
133
|
+
} else lines.push(expansionHint());
|
|
134
|
+
return lines.join("\n");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function renderManageResult(
|
|
138
|
+
args: Record<string, unknown>,
|
|
139
|
+
details: Record<string, unknown>,
|
|
140
|
+
result: AgentToolResult<unknown>,
|
|
141
|
+
expanded: boolean,
|
|
142
|
+
theme: Theme,
|
|
143
|
+
): string {
|
|
144
|
+
const action = safeLine(args.action, "action", 128);
|
|
145
|
+
const agents = recordList(details.agents);
|
|
146
|
+
const primary = recordValue(details.agent);
|
|
147
|
+
if (action === "list") {
|
|
148
|
+
const lines = [
|
|
149
|
+
`${statusBadge(theme, "completed")} · list · ${agents.length} agent${agents.length === 1 ? "" : "s"}`,
|
|
150
|
+
];
|
|
151
|
+
const selected = expanded ? agents : agents.slice(0, COLLAPSED_LIST_LIMIT);
|
|
152
|
+
for (const agent of selected) lines.push(formatAgentLine(agent, theme, expanded));
|
|
153
|
+
if (agents.length === 0) lines.push(theme.fg("muted", "(no retained agents)"));
|
|
154
|
+
if (agents.length > selected.length)
|
|
155
|
+
lines.push(theme.fg("muted", `… ${agents.length - selected.length} omitted`));
|
|
156
|
+
if (!expanded && agents.length > 0) lines.push(expansionHint());
|
|
157
|
+
return lines.join("\n");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const subtree = args.subtree === true;
|
|
161
|
+
const affected = subtree ? agents.length : agents.length > 0 ? agents.length : primary ? 1 : 0;
|
|
162
|
+
const status: RenderStatus = action === "interrupt" ? "interrupted" : "closed";
|
|
163
|
+
const lines = [
|
|
164
|
+
`${statusBadge(theme, status)} · ${affected} agent${affected === 1 ? "" : "s"}${subtree ? theme.fg("muted", " · subtree") : ""}`,
|
|
165
|
+
];
|
|
166
|
+
const selected = agents.length > 0 ? agents : !subtree && primary ? [primary] : [];
|
|
167
|
+
for (const agent of expanded ? selected : selected.slice(0, COLLAPSED_LIST_LIMIT)) {
|
|
168
|
+
lines.push(formatAgentLine(agent, theme, expanded));
|
|
169
|
+
}
|
|
170
|
+
if (selected.length === 0) {
|
|
171
|
+
const content = safeBlock(textResult(result), "(no output)", 8 * 1024);
|
|
172
|
+
lines.push(theme.fg("toolOutput", content));
|
|
173
|
+
}
|
|
174
|
+
if (!expanded && selected.length > 0) lines.push(expansionHint());
|
|
175
|
+
return lines.join("\n");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function renderMailboxResult(
|
|
179
|
+
args: Record<string, unknown>,
|
|
180
|
+
details: Record<string, unknown>,
|
|
181
|
+
expanded: boolean,
|
|
182
|
+
theme: Theme,
|
|
183
|
+
): string {
|
|
184
|
+
const action = safeLine(args.action, "action", 64);
|
|
185
|
+
if (action === "send") {
|
|
186
|
+
const message = recordValue(details.message);
|
|
187
|
+
if (!message) return `${statusBadge(theme, "completed")} · Queued message`;
|
|
188
|
+
const lines = [
|
|
189
|
+
`${theme.fg("success", "✓")} ${theme.fg("success", "Queued")} · ${theme.fg("accent", safeLine(message.id, "message", 256))} · ${theme.fg("muted", `to ${safeLine(message.recipientId, safeLine(args.agentId), 256)}`)}`,
|
|
190
|
+
];
|
|
191
|
+
if (expanded) {
|
|
192
|
+
lines.push(theme.fg("toolOutput", safeBlock(message.content, "(empty message)", 2 * 1024)));
|
|
193
|
+
} else lines.push(expansionHint());
|
|
194
|
+
return lines.join("\n");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const messages = recordList(details.messages);
|
|
198
|
+
const acknowledged = args.acknowledge === false ? "left unread" : "acknowledged";
|
|
199
|
+
const lines = [
|
|
200
|
+
`${statusBadge(theme, "completed")} · ${messages.length} message${messages.length === 1 ? "" : "s"} · ${acknowledged}`,
|
|
201
|
+
];
|
|
202
|
+
const selected = expanded ? messages : messages.slice(0, COLLAPSED_LIST_LIMIT);
|
|
203
|
+
for (const message of selected) {
|
|
204
|
+
const content = expanded
|
|
205
|
+
? safeBlock(message.content, "(empty message)", 2 * 1024)
|
|
206
|
+
: safeLine(message.content, "(empty message)", 512);
|
|
207
|
+
lines.push(
|
|
208
|
+
`${theme.fg("muted", "• ")}${theme.fg("accent", safeLine(message.id, "message", 256))} ${theme.fg("muted", `from ${safeLine(message.senderId, "unknown", 256)}: `)}${theme.fg("toolOutput", content)}`,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
if (messages.length === 0) lines.push(theme.fg("muted", "(no unread messages)"));
|
|
212
|
+
if (messages.length > selected.length)
|
|
213
|
+
lines.push(theme.fg("muted", `… ${messages.length - selected.length} omitted`));
|
|
214
|
+
if (!expanded && messages.length > 0) lines.push(expansionHint());
|
|
215
|
+
return lines.join("\n");
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function formatAgentLine(agent: Record<string, unknown>, theme: Theme, expanded: boolean): string {
|
|
219
|
+
const unread = typeof agent.unreadMessages === "number" ? agent.unreadMessages : 0;
|
|
220
|
+
const lines = [
|
|
221
|
+
`${theme.fg("muted", "• ")}${theme.fg("accent", safeLine(agent.id, "agent", 256))} ${theme.fg("toolOutput", safeLine(agent.agent, "subagent", 256))} ${theme.fg("muted", safeLine(agent.state, "unknown", 128))}${unread > 0 ? theme.fg("warning", ` · unread:${unread}`) : ""}`,
|
|
222
|
+
];
|
|
223
|
+
if (expanded) {
|
|
224
|
+
const task = safeBlock(agent.currentTask, "", 2 * 1024).trim();
|
|
225
|
+
const error = safeBlock(agent.error, "", 2 * 1024).trim();
|
|
226
|
+
if (task) lines.push(` ${theme.fg("dim", `task: ${task}`)}`);
|
|
227
|
+
if (error) lines.push(` ${theme.fg("error", `error: ${error}`)}`);
|
|
228
|
+
}
|
|
229
|
+
return lines.join("\n");
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function lifecycleStatus(state: string): RenderStatus {
|
|
233
|
+
switch (state) {
|
|
234
|
+
case "starting":
|
|
235
|
+
return "starting";
|
|
236
|
+
case "running":
|
|
237
|
+
return "running";
|
|
238
|
+
case "idle":
|
|
239
|
+
return "idle";
|
|
240
|
+
case "failed":
|
|
241
|
+
return "failed";
|
|
242
|
+
case "interrupted":
|
|
243
|
+
return "interrupted";
|
|
244
|
+
case "closed":
|
|
245
|
+
return "closed";
|
|
246
|
+
default:
|
|
247
|
+
return "completed";
|
|
248
|
+
}
|
|
249
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { realpathSync } from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { type AgentScope, discoverAgents, type SubagentSettings } from "./agents.js";
|
|
5
|
+
import type { AgentRegistry, ManagedAgent } from "./registry.js";
|
|
6
|
+
import { safeTerminalLine } from "./safe-text.js";
|
|
7
|
+
import { readSubagentSettings } from "./settings.js";
|
|
8
|
+
|
|
9
|
+
export function assertNoSharedWriteConflict(
|
|
10
|
+
registry: AgentRegistry,
|
|
11
|
+
agentName: string,
|
|
12
|
+
cwd: string,
|
|
13
|
+
scope: AgentScope,
|
|
14
|
+
settings?: SubagentSettings,
|
|
15
|
+
): void {
|
|
16
|
+
const agents = discoverAgents(cwd, scope, settings ?? readSubagentSettings()).agents;
|
|
17
|
+
const requested = agents.find((agent) => agent.name === agentName);
|
|
18
|
+
if (!isWriteCapable(requested?.tools)) return;
|
|
19
|
+
for (const active of registry.list()) {
|
|
20
|
+
if (
|
|
21
|
+
!isSameCwd(active.cwd, cwd) ||
|
|
22
|
+
(active.state !== "running" && active.state !== "starting")
|
|
23
|
+
) {
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
const activeConfig = agents.find((agent) => agent.name === active.agent);
|
|
27
|
+
if (isWriteCapable(activeConfig?.tools)) {
|
|
28
|
+
throw new Error(
|
|
29
|
+
`Write-capable subagent ${active.id} is already active in shared workspace ${cwd}. ` +
|
|
30
|
+
"Prefer one subagent_spawn covering combined asynchronous work. Use the blocking subagent parallel mode only when concurrent synchronous outputs justify making the main agent unavailable. Otherwise let the active agent finish or close it; set allowConcurrentWrites only when overlapping writes are knowingly safe, or use workspaceMode worktree when repository isolation is needed.",
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function assertFollowUpWriteAllowed(
|
|
37
|
+
registry: AgentRegistry,
|
|
38
|
+
agent: ManagedAgent,
|
|
39
|
+
allowConcurrentWrites: boolean,
|
|
40
|
+
isolatedWorkspace: boolean,
|
|
41
|
+
settings?: SubagentSettings,
|
|
42
|
+
): void {
|
|
43
|
+
if (allowConcurrentWrites || isolatedWorkspace) return;
|
|
44
|
+
assertNoSharedWriteConflict(
|
|
45
|
+
registry,
|
|
46
|
+
agent.agent,
|
|
47
|
+
agent.cwd,
|
|
48
|
+
agent.agentScope ?? "user",
|
|
49
|
+
settings,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function isWriteCapable(tools: string[] | undefined): boolean {
|
|
54
|
+
if (!tools) return true;
|
|
55
|
+
return tools.some((tool) => ["bash", "write", "edit"].includes(tool));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function confirmProjectAgent(
|
|
59
|
+
name: string,
|
|
60
|
+
scope: AgentScope,
|
|
61
|
+
confirm: boolean,
|
|
62
|
+
ctx: ExtensionContext,
|
|
63
|
+
cwd: string,
|
|
64
|
+
settings?: SubagentSettings,
|
|
65
|
+
): Promise<void> {
|
|
66
|
+
if (scope !== "project" && scope !== "both") return;
|
|
67
|
+
if (!isSameCwd(cwd, ctx.cwd)) {
|
|
68
|
+
throw new Error("Project-local subagent definitions cannot run with an overridden cwd");
|
|
69
|
+
}
|
|
70
|
+
if (!ctx.isProjectTrusted()) {
|
|
71
|
+
throw new Error("Project-local subagent definitions require a trusted project");
|
|
72
|
+
}
|
|
73
|
+
const discovery = discoverAgents(cwd, scope, settings ?? readSubagentSettings());
|
|
74
|
+
const agent = discovery.agents.find((candidate) => candidate.name === name);
|
|
75
|
+
if (agent?.source !== "project") return;
|
|
76
|
+
if (confirm && ctx.hasUI) {
|
|
77
|
+
const approved = await ctx.ui.confirm(
|
|
78
|
+
"Run project-local agent?",
|
|
79
|
+
`Agent: ${safeTerminalLine(name, 256)}\nSource: ${safeTerminalLine(agent.filePath)}`,
|
|
80
|
+
);
|
|
81
|
+
if (!approved) throw new Error("Project-local subagent was not approved");
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function isSameCwd(left: string, right: string): boolean {
|
|
86
|
+
try {
|
|
87
|
+
return realpathSync(left) === realpathSync(right);
|
|
88
|
+
} catch {
|
|
89
|
+
return path.resolve(left) === path.resolve(right);
|
|
90
|
+
}
|
|
91
|
+
}
|