@narumitw/pi-subagents 0.13.0 → 0.14.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.
@@ -0,0 +1,50 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { discoverAgents } from "./agents.js";
3
+ import type { ManagedAgent, TurnOutcome } from "./registry.js";
4
+ import { getResultFinalOutput, runSingleAgent, type SubagentDetails } from "./runner.js";
5
+ import { readSubagentSettings, resolveSubagentThinkingLevel } from "./settings.js";
6
+ import {
7
+ buildStatefulTurnPrompt,
8
+ resolveStatefulTurnTimeout,
9
+ } from "./stateful-prompt.js";
10
+ import type { SubagentTransport } from "./transport.js";
11
+
12
+ export class SubprocessTransport implements SubagentTransport {
13
+ readonly kind = "subprocess" as const;
14
+
15
+ constructor(private readonly ctx: ExtensionContext) {}
16
+
17
+ async runTurn(record: ManagedAgent, task: string, signal: AbortSignal): Promise<TurnOutcome> {
18
+ const settings = readSubagentSettings();
19
+ const discovery = discoverAgents(record.cwd, record.agentScope ?? "user", settings);
20
+ const agent = discovery.agents.find((candidate) => candidate.name === record.agent);
21
+ const boundedTask = buildStatefulTurnPrompt(record, task);
22
+ const makeDetails = (results: SubagentDetails["results"]): SubagentDetails => ({
23
+ mode: "single",
24
+ agentScope: record.agentScope ?? "user",
25
+ projectAgentsDir: discovery.projectAgentsDir,
26
+ results,
27
+ });
28
+ const single = await runSingleAgent(
29
+ record.cwd,
30
+ discovery.agents,
31
+ record.agent,
32
+ boundedTask.text,
33
+ undefined,
34
+ undefined,
35
+ signal,
36
+ resolveSubagentThinkingLevel(discovery.agents, record.agent),
37
+ resolveStatefulTurnTimeout(agent),
38
+ undefined,
39
+ makeDetails,
40
+ );
41
+ return {
42
+ output: getResultFinalOutput(single),
43
+ exitCode: single.exitCode,
44
+ aborted: single.aborted,
45
+ truncated: single.truncated || boundedTask.truncated,
46
+ error: single.errorMessage || single.stderr || undefined,
47
+ policy: single.policy,
48
+ };
49
+ }
50
+ }
@@ -0,0 +1,30 @@
1
+ import type { ManagedAgent, TurnOutcome } from "./registry.js";
2
+
3
+ export interface SubagentTransport {
4
+ readonly kind: "subprocess" | "in-process" | "fake";
5
+ runTurn(agent: ManagedAgent, task: string, signal: AbortSignal): Promise<TurnOutcome>;
6
+ release?(agent: ManagedAgent): Promise<void>;
7
+ shutdown?(): Promise<void>;
8
+ }
9
+
10
+ export type AgentTurnRunner = (
11
+ agent: ManagedAgent,
12
+ task: string,
13
+ signal: AbortSignal,
14
+ ) => Promise<TurnOutcome>;
15
+
16
+ export class FunctionTransport implements SubagentTransport {
17
+ readonly kind = "fake" as const;
18
+
19
+ constructor(private readonly runner: AgentTurnRunner) {}
20
+
21
+ runTurn(agent: ManagedAgent, task: string, signal: AbortSignal): Promise<TurnOutcome> {
22
+ return this.runner(agent, task, signal);
23
+ }
24
+ }
25
+
26
+ export function normalizeTransport(
27
+ transport: SubagentTransport | AgentTurnRunner,
28
+ ): SubagentTransport {
29
+ return typeof transport === "function" ? new FunctionTransport(transport) : transport;
30
+ }
@@ -0,0 +1,116 @@
1
+ import { execFile } from "node:child_process";
2
+ import * as fs from "node:fs";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import { promisify } from "node:util";
6
+
7
+ const execFileAsync = promisify(execFile);
8
+ const WORKTREE_PREFIX = "pi-subagent-worktree-";
9
+
10
+ export interface IsolatedWorkspace {
11
+ mode: "worktree";
12
+ path: string;
13
+ rootPath: string;
14
+ repositoryRoot: string;
15
+ }
16
+
17
+ export class WorkspaceManager {
18
+ private readonly owned = new Map<string, IsolatedWorkspace>();
19
+
20
+ async create(ownerId: string, cwd: string): Promise<IsolatedWorkspace> {
21
+ if (this.owned.has(ownerId)) throw new Error(`Workspace owner already exists: ${ownerId}`);
22
+ const resolvedCwd = path.resolve(cwd);
23
+ const repositoryRoot = (
24
+ await execFileAsync("git", ["-C", resolvedCwd, "rev-parse", "--show-toplevel"])
25
+ ).stdout.trim();
26
+ const relativeCwd = path.relative(repositoryRoot, resolvedCwd);
27
+ if (relativeCwd.startsWith("..") || path.isAbsolute(relativeCwd)) {
28
+ throw new Error("Subagent cwd is outside the Git repository");
29
+ }
30
+ const status = (
31
+ await execFileAsync("git", ["-C", repositoryRoot, "status", "--porcelain"])
32
+ ).stdout;
33
+ if (status.trim()) throw new Error("Isolated subagent workspace requires a clean Git repository");
34
+ const rootPath = await fs.promises.mkdtemp(path.join(os.tmpdir(), WORKTREE_PREFIX));
35
+ let registered = false;
36
+ try {
37
+ await execFileAsync("git", [
38
+ "-C",
39
+ repositoryRoot,
40
+ "worktree",
41
+ "add",
42
+ "--detach",
43
+ rootPath,
44
+ "HEAD",
45
+ ]);
46
+ registered = true;
47
+ await fs.promises.writeFile(`${rootPath}.owner`, ownerId, { mode: 0o600 });
48
+ const workspace = {
49
+ mode: "worktree" as const,
50
+ path: path.join(rootPath, relativeCwd),
51
+ rootPath,
52
+ repositoryRoot,
53
+ };
54
+ this.owned.set(ownerId, workspace);
55
+ return workspace;
56
+ } catch (error) {
57
+ if (registered) {
58
+ await execFileAsync("git", [
59
+ "-C",
60
+ repositoryRoot,
61
+ "worktree",
62
+ "remove",
63
+ "--force",
64
+ rootPath,
65
+ ]).catch(() => undefined);
66
+ }
67
+ await fs.promises.rm(rootPath, { recursive: true, force: true });
68
+ await fs.promises.rm(`${rootPath}.owner`, { force: true });
69
+ throw error;
70
+ }
71
+ }
72
+
73
+ async cleanup(ownerId: string): Promise<void> {
74
+ const workspace = this.owned.get(ownerId);
75
+ if (!workspace) return;
76
+ if (!(await this.isOwned(workspace.rootPath, ownerId))) {
77
+ throw new Error(`Refusing to clean unowned subagent workspace: ${workspace.rootPath}`);
78
+ }
79
+ this.owned.delete(ownerId);
80
+ await execFileAsync("git", [
81
+ "-C",
82
+ workspace.repositoryRoot,
83
+ "worktree",
84
+ "remove",
85
+ "--force",
86
+ workspace.rootPath,
87
+ ]).catch(async () => {
88
+ await fs.promises.rm(workspace.rootPath, { recursive: true, force: true });
89
+ await execFileAsync("git", ["-C", workspace.repositoryRoot, "worktree", "prune"])
90
+ .catch(() => undefined);
91
+ });
92
+ await fs.promises.rm(`${workspace.rootPath}.owner`, { force: true });
93
+ }
94
+
95
+ async cleanupAll(): Promise<void> {
96
+ const results = await Promise.allSettled(
97
+ [...this.owned.keys()].map((ownerId) => this.cleanup(ownerId)),
98
+ );
99
+ const failures = results.filter((result) => result.status === "rejected");
100
+ if (failures.length > 0) {
101
+ throw new AggregateError(
102
+ failures.map((failure) => (failure as PromiseRejectedResult).reason),
103
+ `Failed to clean ${failures.length} subagent workspace(s)`,
104
+ );
105
+ }
106
+ }
107
+
108
+ private async isOwned(rootPath: string, ownerId: string): Promise<boolean> {
109
+ if (!path.basename(rootPath).startsWith(WORKTREE_PREFIX)) return false;
110
+ try {
111
+ return (await fs.promises.readFile(`${rootPath}.owner`, "utf8")) === ownerId;
112
+ } catch {
113
+ return false;
114
+ }
115
+ }
116
+ }