@gotgenes/pi-subagents 1.0.0 → 1.0.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/AGENTS.md +4 -82
- package/CHANGELOG.md +29 -0
- package/README.md +18 -4
- package/docs/architecture/architecture.md +391 -0
- package/docs/decisions/0001-deferred-patches.md +13 -14
- package/docs/plans/0051-update-adr-0001-hard-fork.md +74 -0
- package/package.json +12 -17
- package/.markdownlint-cli2.yaml +0 -19
- package/.release-please-manifest.json +0 -3
- package/dist/agent-manager.d.ts +0 -108
- package/dist/agent-manager.js +0 -390
- package/dist/agent-runner.d.ts +0 -93
- package/dist/agent-runner.js +0 -428
- package/dist/agent-types.d.ts +0 -48
- package/dist/agent-types.js +0 -136
- package/dist/context.d.ts +0 -12
- package/dist/context.js +0 -56
- package/dist/cross-extension-rpc.d.ts +0 -46
- package/dist/cross-extension-rpc.js +0 -54
- package/dist/custom-agents.d.ts +0 -14
- package/dist/custom-agents.js +0 -127
- package/dist/default-agents.d.ts +0 -7
- package/dist/default-agents.js +0 -119
- package/dist/env.d.ts +0 -6
- package/dist/env.js +0 -28
- package/dist/group-join.d.ts +0 -32
- package/dist/group-join.js +0 -116
- package/dist/index.d.ts +0 -13
- package/dist/index.js +0 -1731
- package/dist/invocation-config.d.ts +0 -22
- package/dist/invocation-config.js +0 -15
- package/dist/memory.d.ts +0 -49
- package/dist/memory.js +0 -151
- package/dist/model-resolver.d.ts +0 -19
- package/dist/model-resolver.js +0 -62
- package/dist/output-file.d.ts +0 -24
- package/dist/output-file.js +0 -86
- package/dist/prompts.d.ts +0 -29
- package/dist/prompts.js +0 -72
- package/dist/schedule-store.d.ts +0 -36
- package/dist/schedule-store.js +0 -144
- package/dist/schedule.d.ts +0 -109
- package/dist/schedule.js +0 -338
- package/dist/settings.d.ts +0 -66
- package/dist/settings.js +0 -130
- package/dist/skill-loader.d.ts +0 -24
- package/dist/skill-loader.js +0 -93
- package/dist/types.d.ts +0 -164
- package/dist/types.js +0 -5
- package/dist/ui/agent-widget.d.ts +0 -134
- package/dist/ui/agent-widget.js +0 -451
- package/dist/ui/conversation-viewer.d.ts +0 -35
- package/dist/ui/conversation-viewer.js +0 -252
- package/dist/ui/schedule-menu.d.ts +0 -16
- package/dist/ui/schedule-menu.js +0 -95
- package/dist/usage.d.ts +0 -50
- package/dist/usage.js +0 -49
- package/dist/worktree.d.ts +0 -36
- package/dist/worktree.js +0 -139
- package/prek.toml +0 -24
- package/release-please-config.json +0 -22
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
---
|
|
2
|
+
issue: 51
|
|
3
|
+
issue_title: "docs: update ADR 0001 to reflect hard-fork decision"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Update ADR 0001 to reflect hard-fork decision
|
|
7
|
+
|
|
8
|
+
## Problem Statement
|
|
9
|
+
|
|
10
|
+
ADR 0001 (`docs/decisions/0001-deferred-patches.md`) was written when the fork was a thin-patch layer over `tintinweb/pi-subagents`.
|
|
11
|
+
The new architecture document (`docs/architecture/architecture.md`) commits to a hard fork with material scope reduction — scheduling removal, a `SubagentsAPI` boundary, `index.ts` decomposition, and more.
|
|
12
|
+
|
|
13
|
+
Several claims in ADR 0001 are now outdated:
|
|
14
|
+
|
|
15
|
+
1. The status is "accepted" but the decision has been superseded by the architecture doc.
|
|
16
|
+
2. The Upstream PRs section states "the fork's divergence reduces to package naming and tooling," which is no longer true.
|
|
17
|
+
3. The Consequences → Operational section implies that merging the upstream PRs eliminates behavioral divergence, which no longer holds.
|
|
18
|
+
|
|
19
|
+
## Goals
|
|
20
|
+
|
|
21
|
+
- Add a supersession note to ADR 0001 pointing to `docs/architecture/architecture.md`.
|
|
22
|
+
- Update the "Upstream PRs are open" subsection so the "divergence reduces to…" claim reflects reality.
|
|
23
|
+
- Update the Consequences → Operational section to note intentional divergence per the architecture document.
|
|
24
|
+
- Preserve all existing rationale — no information is removed.
|
|
25
|
+
|
|
26
|
+
## Non-Goals
|
|
27
|
+
|
|
28
|
+
- Rewriting the ADR from scratch — the original context is still useful.
|
|
29
|
+
- Updating the architecture document itself.
|
|
30
|
+
- Any code changes.
|
|
31
|
+
|
|
32
|
+
## Background
|
|
33
|
+
|
|
34
|
+
ADR 0001 has YAML frontmatter (`status: accepted`, `date: 2026-05-11`) and follows a standard ADR structure: Status, Context, Decision, Consequences.
|
|
35
|
+
|
|
36
|
+
The architecture document (`docs/architecture/architecture.md`) describes a six-phase plan that materially diverges from upstream: scheduling removal, ad-hoc RPC replacement, group-join and output-file removal, a typed `SubagentsAPI` boundary, and `index.ts` decomposition.
|
|
37
|
+
|
|
38
|
+
The three upstream PRs (#71, #72, #73) are still open and factually accurate — that section just needs the concluding sentence revised.
|
|
39
|
+
|
|
40
|
+
## Design Overview
|
|
41
|
+
|
|
42
|
+
The update touches three areas of the ADR:
|
|
43
|
+
|
|
44
|
+
1. **Frontmatter + Status section** — change `status: accepted` to `status: superseded` in frontmatter, and update the Status section body to read "Superseded" with a pointer to `docs/architecture/architecture.md`.
|
|
45
|
+
2. **"Upstream PRs are open" subsection** — keep the PR list and factual statements intact; revise the final sentence ("Once these land upstream, the fork's divergence reduces to package naming and tooling.") to note that the fork now diverges intentionally beyond those patches, per the architecture document.
|
|
46
|
+
3. **Consequences → Operational** — add a sentence noting that the fork diverges intentionally beyond patches, and that the architecture document governs the fork's direction going forward. Keep the existing bullet about upstream PRs.
|
|
47
|
+
|
|
48
|
+
No structural changes (new sections, removed sections, reordered content).
|
|
49
|
+
|
|
50
|
+
## Module-Level Changes
|
|
51
|
+
|
|
52
|
+
| File | Change |
|
|
53
|
+
| ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
54
|
+
| `docs/decisions/0001-deferred-patches.md` | Update frontmatter `status` from `accepted` to `superseded`; revise Status section; revise closing sentence in "Upstream PRs are open"; add divergence note to Consequences → Operational |
|
|
55
|
+
|
|
56
|
+
## Test Impact Analysis
|
|
57
|
+
|
|
58
|
+
No tests are affected — this is a docs-only change.
|
|
59
|
+
|
|
60
|
+
## TDD Order
|
|
61
|
+
|
|
62
|
+
1. Update ADR 0001 with all four edits described above.
|
|
63
|
+
Commit: `docs: update ADR 0001 to reflect hard-fork decision (#51)`
|
|
64
|
+
|
|
65
|
+
## Risks and Mitigations
|
|
66
|
+
|
|
67
|
+
| Risk | Mitigation |
|
|
68
|
+
| ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
|
|
69
|
+
| Supersession note makes the ADR look stale | Keep all original rationale intact; the note clarifies evolution, not obsolescence |
|
|
70
|
+
| Wording drift between ADR and architecture doc | Use a direct pointer (`docs/architecture/architecture.md`) rather than paraphrasing the architecture doc's decisions |
|
|
71
|
+
|
|
72
|
+
## Open Questions
|
|
73
|
+
|
|
74
|
+
None — the issue's acceptance criteria are unambiguous.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotgenes/pi-subagents",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"description": "A pi extension that brings Claude Code-style autonomous sub-agents to pi. Friendly fork of @tintinweb/pi-subagents.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Chris Lasher"
|
|
@@ -8,11 +8,12 @@
|
|
|
8
8
|
"license": "MIT",
|
|
9
9
|
"repository": {
|
|
10
10
|
"type": "git",
|
|
11
|
-
"url": "git+https://github.com/gotgenes/pi-
|
|
11
|
+
"url": "git+https://github.com/gotgenes/pi-packages.git",
|
|
12
|
+
"directory": "packages/pi-subagents"
|
|
12
13
|
},
|
|
13
|
-
"homepage": "https://github.com/gotgenes/pi-subagents#readme",
|
|
14
|
+
"homepage": "https://github.com/gotgenes/pi-packages/tree/main/packages/pi-subagents#readme",
|
|
14
15
|
"bugs": {
|
|
15
|
-
"url": "https://github.com/gotgenes/pi-
|
|
16
|
+
"url": "https://github.com/gotgenes/pi-packages/issues"
|
|
16
17
|
},
|
|
17
18
|
"keywords": [
|
|
18
19
|
"pi-package",
|
|
@@ -40,10 +41,10 @@
|
|
|
40
41
|
},
|
|
41
42
|
"devDependencies": {
|
|
42
43
|
"@biomejs/biome": "^2.4.14",
|
|
43
|
-
"@types/node": "^25.
|
|
44
|
-
"
|
|
45
|
-
"
|
|
46
|
-
"
|
|
44
|
+
"@types/node": "^25.6.2",
|
|
45
|
+
"typescript": "^6.0.3",
|
|
46
|
+
"vitest": "^4.1.5",
|
|
47
|
+
"rumdl": "^0.1.93"
|
|
47
48
|
},
|
|
48
49
|
"pi": {
|
|
49
50
|
"extensions": [
|
|
@@ -53,16 +54,10 @@
|
|
|
53
54
|
"image": "https://github.com/gotgenes/pi-subagents/raw/main/media/screenshot.png"
|
|
54
55
|
},
|
|
55
56
|
"scripts": {
|
|
56
|
-
"
|
|
57
|
+
"check": "tsc --noEmit",
|
|
57
58
|
"test": "vitest run",
|
|
58
59
|
"test:watch": "vitest",
|
|
59
|
-
"
|
|
60
|
-
"lint": "biome check
|
|
61
|
-
"lint:fix": "biome check --fix src/ test/",
|
|
62
|
-
"lint:md": "markdownlint-cli2 '*.md' 'docs/**/*.md'",
|
|
63
|
-
"lint:md:fix": "markdownlint-cli2 --fix '*.md' 'docs/**/*.md'",
|
|
64
|
-
"lint:all": "pnpm run lint && pnpm run lint:md",
|
|
65
|
-
"format": "biome format --write src/ test/",
|
|
66
|
-
"check": "pnpm run build && pnpm run lint:all && pnpm run test"
|
|
60
|
+
"lint:md": "rumdl check '*.md' 'docs/**/*.md'",
|
|
61
|
+
"lint": "biome check . && pnpm run lint:md"
|
|
67
62
|
}
|
|
68
63
|
}
|
package/.markdownlint-cli2.yaml
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
# CHANGELOG.md is machine-generated by release-please. Its double blank lines
|
|
2
|
-
# between sections are intentional; exclude it from linting entirely.
|
|
3
|
-
#
|
|
4
|
-
# README.md is largely the upstream tintinweb/pi-subagents README — we
|
|
5
|
-
# preserve the original copy as-is and add a "Fork notice" block at the top
|
|
6
|
-
# rather than reformatting the entire document.
|
|
7
|
-
ignores:
|
|
8
|
-
- "CHANGELOG.md"
|
|
9
|
-
- "README.md"
|
|
10
|
-
|
|
11
|
-
config:
|
|
12
|
-
line-length: false
|
|
13
|
-
no-duplicate-heading:
|
|
14
|
-
siblings_only: true
|
|
15
|
-
no-inline-html:
|
|
16
|
-
allowed_elements:
|
|
17
|
-
- p
|
|
18
|
-
- img
|
|
19
|
-
first-line-heading: false
|
package/dist/agent-manager.d.ts
DELETED
|
@@ -1,108 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* agent-manager.ts — Tracks agents, background execution, resume support.
|
|
3
|
-
*
|
|
4
|
-
* Background agents are subject to a configurable concurrency limit (default: 4).
|
|
5
|
-
* Excess agents are queued and auto-started as running agents complete.
|
|
6
|
-
* Foreground agents bypass the queue (they block the parent anyway).
|
|
7
|
-
*/
|
|
8
|
-
import type { Model } from "@earendil-works/pi-ai";
|
|
9
|
-
import type { AgentSession, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
10
|
-
import { type ToolActivity } from "./agent-runner.js";
|
|
11
|
-
import type { AgentInvocation, AgentRecord, IsolationMode, SubagentType, ThinkingLevel } from "./types.js";
|
|
12
|
-
export type OnAgentComplete = (record: AgentRecord) => void;
|
|
13
|
-
export type OnAgentStart = (record: AgentRecord) => void;
|
|
14
|
-
export type OnAgentCompact = (record: AgentRecord, info: CompactionInfo) => void;
|
|
15
|
-
export type CompactionInfo = {
|
|
16
|
-
reason: "manual" | "threshold" | "overflow";
|
|
17
|
-
tokensBefore: number;
|
|
18
|
-
};
|
|
19
|
-
interface SpawnOptions {
|
|
20
|
-
description: string;
|
|
21
|
-
model?: Model<any>;
|
|
22
|
-
maxTurns?: number;
|
|
23
|
-
isolated?: boolean;
|
|
24
|
-
inheritContext?: boolean;
|
|
25
|
-
thinkingLevel?: ThinkingLevel;
|
|
26
|
-
isBackground?: boolean;
|
|
27
|
-
/**
|
|
28
|
-
* Skip the maxConcurrent queue check for this spawn — start immediately even
|
|
29
|
-
* if the configured concurrency limit would otherwise queue it. Used by the
|
|
30
|
-
* scheduler so a fired job can't be deferred past its trigger window.
|
|
31
|
-
*/
|
|
32
|
-
bypassQueue?: boolean;
|
|
33
|
-
/** Isolation mode — "worktree" creates a temp git worktree for the agent. */
|
|
34
|
-
isolation?: IsolationMode;
|
|
35
|
-
/** Resolved invocation snapshot captured for UI display. */
|
|
36
|
-
invocation?: AgentInvocation;
|
|
37
|
-
/** Parent abort signal — when aborted, the subagent is also stopped. */
|
|
38
|
-
signal?: AbortSignal;
|
|
39
|
-
/** Called on tool start/end with activity info (for streaming progress to UI). */
|
|
40
|
-
onToolActivity?: (activity: ToolActivity) => void;
|
|
41
|
-
/** Called on streaming text deltas from the assistant response. */
|
|
42
|
-
onTextDelta?: (delta: string, fullText: string) => void;
|
|
43
|
-
/** Called when the agent session is created (for accessing session stats). */
|
|
44
|
-
onSessionCreated?: (session: AgentSession) => void;
|
|
45
|
-
/** Called at the end of each agentic turn with the cumulative count. */
|
|
46
|
-
onTurnEnd?: (turnCount: number) => void;
|
|
47
|
-
/** Called once per assistant message_end with that message's usage delta. */
|
|
48
|
-
onAssistantUsage?: (usage: {
|
|
49
|
-
input: number;
|
|
50
|
-
output: number;
|
|
51
|
-
cacheWrite: number;
|
|
52
|
-
}) => void;
|
|
53
|
-
/** Called when the session successfully compacts. */
|
|
54
|
-
onCompaction?: (info: CompactionInfo) => void;
|
|
55
|
-
}
|
|
56
|
-
export declare class AgentManager {
|
|
57
|
-
private agents;
|
|
58
|
-
private cleanupInterval;
|
|
59
|
-
private onComplete?;
|
|
60
|
-
private onStart?;
|
|
61
|
-
private onCompact?;
|
|
62
|
-
private maxConcurrent;
|
|
63
|
-
/** Queue of background agents waiting to start. */
|
|
64
|
-
private queue;
|
|
65
|
-
/** Number of currently running background agents. */
|
|
66
|
-
private runningBackground;
|
|
67
|
-
constructor(onComplete?: OnAgentComplete, maxConcurrent?: number, onStart?: OnAgentStart, onCompact?: OnAgentCompact);
|
|
68
|
-
/** Update the max concurrent background agents limit. */
|
|
69
|
-
setMaxConcurrent(n: number): void;
|
|
70
|
-
getMaxConcurrent(): number;
|
|
71
|
-
/**
|
|
72
|
-
* Spawn an agent and return its ID immediately (for background use).
|
|
73
|
-
* If the concurrency limit is reached, the agent is queued.
|
|
74
|
-
*/
|
|
75
|
-
spawn(pi: ExtensionAPI, ctx: ExtensionContext, type: SubagentType, prompt: string, options: SpawnOptions): string;
|
|
76
|
-
/** Actually start an agent (called immediately or from queue drain). */
|
|
77
|
-
private startAgent;
|
|
78
|
-
/** Start queued agents up to the concurrency limit. */
|
|
79
|
-
private drainQueue;
|
|
80
|
-
/**
|
|
81
|
-
* Spawn an agent and wait for completion (foreground use).
|
|
82
|
-
* Foreground agents bypass the concurrency queue.
|
|
83
|
-
*/
|
|
84
|
-
spawnAndWait(pi: ExtensionAPI, ctx: ExtensionContext, type: SubagentType, prompt: string, options: Omit<SpawnOptions, "isBackground">): Promise<AgentRecord>;
|
|
85
|
-
/**
|
|
86
|
-
* Resume an existing agent session with a new prompt.
|
|
87
|
-
*/
|
|
88
|
-
resume(id: string, prompt: string, signal?: AbortSignal): Promise<AgentRecord | undefined>;
|
|
89
|
-
getRecord(id: string): AgentRecord | undefined;
|
|
90
|
-
listAgents(): AgentRecord[];
|
|
91
|
-
abort(id: string): boolean;
|
|
92
|
-
/** Dispose a record's session and remove it from the map. */
|
|
93
|
-
private removeRecord;
|
|
94
|
-
private cleanup;
|
|
95
|
-
/**
|
|
96
|
-
* Remove all completed/stopped/errored records immediately.
|
|
97
|
-
* Called on session start/switch so tasks from a prior session don't persist.
|
|
98
|
-
*/
|
|
99
|
-
clearCompleted(): void;
|
|
100
|
-
/** Whether any agents are still running or queued. */
|
|
101
|
-
hasRunning(): boolean;
|
|
102
|
-
/** Abort all running and queued agents immediately. */
|
|
103
|
-
abortAll(): number;
|
|
104
|
-
/** Wait for all running and queued agents to complete (including queued ones). */
|
|
105
|
-
waitForAll(): Promise<void>;
|
|
106
|
-
dispose(): void;
|
|
107
|
-
}
|
|
108
|
-
export {};
|
package/dist/agent-manager.js
DELETED
|
@@ -1,390 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* agent-manager.ts — Tracks agents, background execution, resume support.
|
|
3
|
-
*
|
|
4
|
-
* Background agents are subject to a configurable concurrency limit (default: 4).
|
|
5
|
-
* Excess agents are queued and auto-started as running agents complete.
|
|
6
|
-
* Foreground agents bypass the queue (they block the parent anyway).
|
|
7
|
-
*/
|
|
8
|
-
import { randomUUID } from "node:crypto";
|
|
9
|
-
import { resumeAgent, runAgent } from "./agent-runner.js";
|
|
10
|
-
import { addUsage } from "./usage.js";
|
|
11
|
-
import { cleanupWorktree, createWorktree, pruneWorktrees, } from "./worktree.js";
|
|
12
|
-
/** Default max concurrent background agents. */
|
|
13
|
-
const DEFAULT_MAX_CONCURRENT = 4;
|
|
14
|
-
export class AgentManager {
|
|
15
|
-
agents = new Map();
|
|
16
|
-
cleanupInterval;
|
|
17
|
-
onComplete;
|
|
18
|
-
onStart;
|
|
19
|
-
onCompact;
|
|
20
|
-
maxConcurrent;
|
|
21
|
-
/** Queue of background agents waiting to start. */
|
|
22
|
-
queue = [];
|
|
23
|
-
/** Number of currently running background agents. */
|
|
24
|
-
runningBackground = 0;
|
|
25
|
-
constructor(onComplete, maxConcurrent = DEFAULT_MAX_CONCURRENT, onStart, onCompact) {
|
|
26
|
-
this.onComplete = onComplete;
|
|
27
|
-
this.onStart = onStart;
|
|
28
|
-
this.onCompact = onCompact;
|
|
29
|
-
this.maxConcurrent = maxConcurrent;
|
|
30
|
-
// Cleanup completed agents after 10 minutes (but keep sessions for resume)
|
|
31
|
-
this.cleanupInterval = setInterval(() => this.cleanup(), 60_000);
|
|
32
|
-
this.cleanupInterval.unref();
|
|
33
|
-
}
|
|
34
|
-
/** Update the max concurrent background agents limit. */
|
|
35
|
-
setMaxConcurrent(n) {
|
|
36
|
-
this.maxConcurrent = Math.max(1, n);
|
|
37
|
-
// Start queued agents if the new limit allows
|
|
38
|
-
this.drainQueue();
|
|
39
|
-
}
|
|
40
|
-
getMaxConcurrent() {
|
|
41
|
-
return this.maxConcurrent;
|
|
42
|
-
}
|
|
43
|
-
/**
|
|
44
|
-
* Spawn an agent and return its ID immediately (for background use).
|
|
45
|
-
* If the concurrency limit is reached, the agent is queued.
|
|
46
|
-
*/
|
|
47
|
-
spawn(pi, ctx, type, prompt, options) {
|
|
48
|
-
const id = randomUUID().slice(0, 17);
|
|
49
|
-
const abortController = new AbortController();
|
|
50
|
-
const record = {
|
|
51
|
-
id,
|
|
52
|
-
type,
|
|
53
|
-
description: options.description,
|
|
54
|
-
status: options.isBackground ? "queued" : "running",
|
|
55
|
-
toolUses: 0,
|
|
56
|
-
startedAt: Date.now(),
|
|
57
|
-
abortController,
|
|
58
|
-
lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 },
|
|
59
|
-
compactionCount: 0,
|
|
60
|
-
invocation: options.invocation,
|
|
61
|
-
};
|
|
62
|
-
this.agents.set(id, record);
|
|
63
|
-
const args = { pi, ctx, type, prompt, options };
|
|
64
|
-
if (options.isBackground && !options.bypassQueue && this.runningBackground >= this.maxConcurrent) {
|
|
65
|
-
// Queue it — will be started when a running agent completes
|
|
66
|
-
this.queue.push({ id, args });
|
|
67
|
-
return id;
|
|
68
|
-
}
|
|
69
|
-
// startAgent can throw (e.g. strict worktree-isolation failure) — clean
|
|
70
|
-
// up the record so callers don't see an orphan in `listAgents()`.
|
|
71
|
-
try {
|
|
72
|
-
this.startAgent(id, record, args);
|
|
73
|
-
}
|
|
74
|
-
catch (err) {
|
|
75
|
-
this.agents.delete(id);
|
|
76
|
-
throw err;
|
|
77
|
-
}
|
|
78
|
-
return id;
|
|
79
|
-
}
|
|
80
|
-
/** Actually start an agent (called immediately or from queue drain). */
|
|
81
|
-
startAgent(id, record, { pi, ctx, type, prompt, options }) {
|
|
82
|
-
// Worktree isolation: try to create a temporary git worktree. Strict —
|
|
83
|
-
// fail loud if not possible (no silent fallback to main tree). Done
|
|
84
|
-
// BEFORE state mutation so a throw doesn't leave the record half-running.
|
|
85
|
-
let worktreeCwd;
|
|
86
|
-
if (options.isolation === "worktree") {
|
|
87
|
-
const wt = createWorktree(ctx.cwd, id);
|
|
88
|
-
if (!wt) {
|
|
89
|
-
throw new Error('Cannot run with isolation: "worktree" — not a git repo, no commits yet, or `git worktree add` failed. ' +
|
|
90
|
-
'Initialize git and commit at least once, or omit `isolation`.');
|
|
91
|
-
}
|
|
92
|
-
record.worktree = wt;
|
|
93
|
-
worktreeCwd = wt.path;
|
|
94
|
-
}
|
|
95
|
-
record.status = "running";
|
|
96
|
-
record.startedAt = Date.now();
|
|
97
|
-
if (options.isBackground)
|
|
98
|
-
this.runningBackground++;
|
|
99
|
-
this.onStart?.(record);
|
|
100
|
-
// Wire parent abort signal to stop the subagent when the parent is interrupted
|
|
101
|
-
let detachParentSignal;
|
|
102
|
-
if (options.signal) {
|
|
103
|
-
const onParentAbort = () => this.abort(id);
|
|
104
|
-
options.signal.addEventListener("abort", onParentAbort, { once: true });
|
|
105
|
-
detachParentSignal = () => options.signal.removeEventListener("abort", onParentAbort);
|
|
106
|
-
}
|
|
107
|
-
const detach = () => { detachParentSignal?.(); detachParentSignal = undefined; };
|
|
108
|
-
const promise = runAgent(ctx, type, prompt, {
|
|
109
|
-
pi,
|
|
110
|
-
model: options.model,
|
|
111
|
-
maxTurns: options.maxTurns,
|
|
112
|
-
isolated: options.isolated,
|
|
113
|
-
inheritContext: options.inheritContext,
|
|
114
|
-
thinkingLevel: options.thinkingLevel,
|
|
115
|
-
cwd: worktreeCwd,
|
|
116
|
-
signal: record.abortController.signal,
|
|
117
|
-
onToolActivity: (activity) => {
|
|
118
|
-
if (activity.type === "end")
|
|
119
|
-
record.toolUses++;
|
|
120
|
-
options.onToolActivity?.(activity);
|
|
121
|
-
},
|
|
122
|
-
onTurnEnd: options.onTurnEnd,
|
|
123
|
-
onTextDelta: options.onTextDelta,
|
|
124
|
-
onAssistantUsage: (usage) => {
|
|
125
|
-
addUsage(record.lifetimeUsage, usage);
|
|
126
|
-
options.onAssistantUsage?.(usage);
|
|
127
|
-
},
|
|
128
|
-
onCompaction: (info) => {
|
|
129
|
-
record.compactionCount++;
|
|
130
|
-
this.onCompact?.(record, info);
|
|
131
|
-
options.onCompaction?.(info);
|
|
132
|
-
},
|
|
133
|
-
onSessionCreated: (session) => {
|
|
134
|
-
record.session = session;
|
|
135
|
-
// Flush any steers that arrived before the session was ready
|
|
136
|
-
if (record.pendingSteers?.length) {
|
|
137
|
-
for (const msg of record.pendingSteers) {
|
|
138
|
-
session.steer(msg).catch(() => { });
|
|
139
|
-
}
|
|
140
|
-
record.pendingSteers = undefined;
|
|
141
|
-
}
|
|
142
|
-
options.onSessionCreated?.(session);
|
|
143
|
-
},
|
|
144
|
-
})
|
|
145
|
-
.then(({ responseText, session, aborted, steered }) => {
|
|
146
|
-
// Don't overwrite status if externally stopped via abort()
|
|
147
|
-
if (record.status !== "stopped") {
|
|
148
|
-
record.status = aborted ? "aborted" : steered ? "steered" : "completed";
|
|
149
|
-
}
|
|
150
|
-
record.result = responseText;
|
|
151
|
-
record.session = session;
|
|
152
|
-
record.completedAt ??= Date.now();
|
|
153
|
-
detach();
|
|
154
|
-
// Final flush of streaming output file
|
|
155
|
-
if (record.outputCleanup) {
|
|
156
|
-
try {
|
|
157
|
-
record.outputCleanup();
|
|
158
|
-
}
|
|
159
|
-
catch { /* ignore */ }
|
|
160
|
-
record.outputCleanup = undefined;
|
|
161
|
-
}
|
|
162
|
-
// Clean up worktree if used
|
|
163
|
-
if (record.worktree) {
|
|
164
|
-
const wtResult = cleanupWorktree(ctx.cwd, record.worktree, options.description);
|
|
165
|
-
record.worktreeResult = wtResult;
|
|
166
|
-
if (wtResult.hasChanges && wtResult.branch) {
|
|
167
|
-
record.result = (record.result ?? "") +
|
|
168
|
-
`\n\n---\nChanges saved to branch \`${wtResult.branch}\`. Merge with: \`git merge ${wtResult.branch}\``;
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
if (options.isBackground) {
|
|
172
|
-
this.runningBackground--;
|
|
173
|
-
try {
|
|
174
|
-
this.onComplete?.(record);
|
|
175
|
-
}
|
|
176
|
-
catch { /* ignore completion side-effect errors */ }
|
|
177
|
-
this.drainQueue();
|
|
178
|
-
}
|
|
179
|
-
return responseText;
|
|
180
|
-
})
|
|
181
|
-
.catch((err) => {
|
|
182
|
-
// Don't overwrite status if externally stopped via abort()
|
|
183
|
-
if (record.status !== "stopped") {
|
|
184
|
-
record.status = "error";
|
|
185
|
-
}
|
|
186
|
-
record.error = err instanceof Error ? err.message : String(err);
|
|
187
|
-
record.completedAt ??= Date.now();
|
|
188
|
-
detach();
|
|
189
|
-
// Final flush of streaming output file on error
|
|
190
|
-
if (record.outputCleanup) {
|
|
191
|
-
try {
|
|
192
|
-
record.outputCleanup();
|
|
193
|
-
}
|
|
194
|
-
catch { /* ignore */ }
|
|
195
|
-
record.outputCleanup = undefined;
|
|
196
|
-
}
|
|
197
|
-
// Best-effort worktree cleanup on error
|
|
198
|
-
if (record.worktree) {
|
|
199
|
-
try {
|
|
200
|
-
const wtResult = cleanupWorktree(ctx.cwd, record.worktree, options.description);
|
|
201
|
-
record.worktreeResult = wtResult;
|
|
202
|
-
}
|
|
203
|
-
catch { /* ignore cleanup errors */ }
|
|
204
|
-
}
|
|
205
|
-
if (options.isBackground) {
|
|
206
|
-
this.runningBackground--;
|
|
207
|
-
this.onComplete?.(record);
|
|
208
|
-
this.drainQueue();
|
|
209
|
-
}
|
|
210
|
-
return "";
|
|
211
|
-
});
|
|
212
|
-
record.promise = promise;
|
|
213
|
-
}
|
|
214
|
-
/** Start queued agents up to the concurrency limit. */
|
|
215
|
-
drainQueue() {
|
|
216
|
-
while (this.queue.length > 0 && this.runningBackground < this.maxConcurrent) {
|
|
217
|
-
const next = this.queue.shift();
|
|
218
|
-
const record = this.agents.get(next.id);
|
|
219
|
-
if (!record || record.status !== "queued")
|
|
220
|
-
continue;
|
|
221
|
-
try {
|
|
222
|
-
this.startAgent(next.id, record, next.args);
|
|
223
|
-
}
|
|
224
|
-
catch (err) {
|
|
225
|
-
// Late failure (e.g. strict worktree-isolation) — surface on the record
|
|
226
|
-
// so the user/agent can see it via /agents, then keep draining.
|
|
227
|
-
record.status = "error";
|
|
228
|
-
record.error = err instanceof Error ? err.message : String(err);
|
|
229
|
-
record.completedAt = Date.now();
|
|
230
|
-
this.onComplete?.(record);
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
/**
|
|
235
|
-
* Spawn an agent and wait for completion (foreground use).
|
|
236
|
-
* Foreground agents bypass the concurrency queue.
|
|
237
|
-
*/
|
|
238
|
-
async spawnAndWait(pi, ctx, type, prompt, options) {
|
|
239
|
-
const id = this.spawn(pi, ctx, type, prompt, { ...options, isBackground: false });
|
|
240
|
-
const record = this.agents.get(id);
|
|
241
|
-
await record.promise;
|
|
242
|
-
return record;
|
|
243
|
-
}
|
|
244
|
-
/**
|
|
245
|
-
* Resume an existing agent session with a new prompt.
|
|
246
|
-
*/
|
|
247
|
-
async resume(id, prompt, signal) {
|
|
248
|
-
const record = this.agents.get(id);
|
|
249
|
-
if (!record?.session)
|
|
250
|
-
return undefined;
|
|
251
|
-
record.status = "running";
|
|
252
|
-
record.startedAt = Date.now();
|
|
253
|
-
record.completedAt = undefined;
|
|
254
|
-
record.result = undefined;
|
|
255
|
-
record.error = undefined;
|
|
256
|
-
try {
|
|
257
|
-
const responseText = await resumeAgent(record.session, prompt, {
|
|
258
|
-
onToolActivity: (activity) => {
|
|
259
|
-
if (activity.type === "end")
|
|
260
|
-
record.toolUses++;
|
|
261
|
-
},
|
|
262
|
-
onAssistantUsage: (usage) => {
|
|
263
|
-
addUsage(record.lifetimeUsage, usage);
|
|
264
|
-
},
|
|
265
|
-
onCompaction: (info) => {
|
|
266
|
-
record.compactionCount++;
|
|
267
|
-
this.onCompact?.(record, info);
|
|
268
|
-
},
|
|
269
|
-
signal,
|
|
270
|
-
});
|
|
271
|
-
record.status = "completed";
|
|
272
|
-
record.result = responseText;
|
|
273
|
-
record.completedAt = Date.now();
|
|
274
|
-
}
|
|
275
|
-
catch (err) {
|
|
276
|
-
record.status = "error";
|
|
277
|
-
record.error = err instanceof Error ? err.message : String(err);
|
|
278
|
-
record.completedAt = Date.now();
|
|
279
|
-
}
|
|
280
|
-
return record;
|
|
281
|
-
}
|
|
282
|
-
getRecord(id) {
|
|
283
|
-
return this.agents.get(id);
|
|
284
|
-
}
|
|
285
|
-
listAgents() {
|
|
286
|
-
return [...this.agents.values()].sort((a, b) => b.startedAt - a.startedAt);
|
|
287
|
-
}
|
|
288
|
-
abort(id) {
|
|
289
|
-
const record = this.agents.get(id);
|
|
290
|
-
if (!record)
|
|
291
|
-
return false;
|
|
292
|
-
// Remove from queue if queued
|
|
293
|
-
if (record.status === "queued") {
|
|
294
|
-
this.queue = this.queue.filter(q => q.id !== id);
|
|
295
|
-
record.status = "stopped";
|
|
296
|
-
record.completedAt = Date.now();
|
|
297
|
-
return true;
|
|
298
|
-
}
|
|
299
|
-
if (record.status !== "running")
|
|
300
|
-
return false;
|
|
301
|
-
record.abortController?.abort();
|
|
302
|
-
record.status = "stopped";
|
|
303
|
-
record.completedAt = Date.now();
|
|
304
|
-
return true;
|
|
305
|
-
}
|
|
306
|
-
/** Dispose a record's session and remove it from the map. */
|
|
307
|
-
removeRecord(id, record) {
|
|
308
|
-
record.session?.dispose?.();
|
|
309
|
-
record.session = undefined;
|
|
310
|
-
this.agents.delete(id);
|
|
311
|
-
}
|
|
312
|
-
cleanup() {
|
|
313
|
-
const cutoff = Date.now() - 10 * 60_000;
|
|
314
|
-
for (const [id, record] of this.agents) {
|
|
315
|
-
if (record.status === "running" || record.status === "queued")
|
|
316
|
-
continue;
|
|
317
|
-
if ((record.completedAt ?? 0) >= cutoff)
|
|
318
|
-
continue;
|
|
319
|
-
this.removeRecord(id, record);
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
/**
|
|
323
|
-
* Remove all completed/stopped/errored records immediately.
|
|
324
|
-
* Called on session start/switch so tasks from a prior session don't persist.
|
|
325
|
-
*/
|
|
326
|
-
clearCompleted() {
|
|
327
|
-
for (const [id, record] of this.agents) {
|
|
328
|
-
if (record.status === "running" || record.status === "queued")
|
|
329
|
-
continue;
|
|
330
|
-
this.removeRecord(id, record);
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
/** Whether any agents are still running or queued. */
|
|
334
|
-
hasRunning() {
|
|
335
|
-
return [...this.agents.values()].some(r => r.status === "running" || r.status === "queued");
|
|
336
|
-
}
|
|
337
|
-
/** Abort all running and queued agents immediately. */
|
|
338
|
-
abortAll() {
|
|
339
|
-
let count = 0;
|
|
340
|
-
// Clear queued agents first
|
|
341
|
-
for (const queued of this.queue) {
|
|
342
|
-
const record = this.agents.get(queued.id);
|
|
343
|
-
if (record) {
|
|
344
|
-
record.status = "stopped";
|
|
345
|
-
record.completedAt = Date.now();
|
|
346
|
-
count++;
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
this.queue = [];
|
|
350
|
-
// Abort running agents
|
|
351
|
-
for (const record of this.agents.values()) {
|
|
352
|
-
if (record.status === "running") {
|
|
353
|
-
record.abortController?.abort();
|
|
354
|
-
record.status = "stopped";
|
|
355
|
-
record.completedAt = Date.now();
|
|
356
|
-
count++;
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
return count;
|
|
360
|
-
}
|
|
361
|
-
/** Wait for all running and queued agents to complete (including queued ones). */
|
|
362
|
-
async waitForAll() {
|
|
363
|
-
// Loop because drainQueue respects the concurrency limit — as running
|
|
364
|
-
// agents finish they start queued ones, which need awaiting too.
|
|
365
|
-
while (true) {
|
|
366
|
-
this.drainQueue();
|
|
367
|
-
const pending = [...this.agents.values()]
|
|
368
|
-
.filter(r => r.status === "running" || r.status === "queued")
|
|
369
|
-
.map(r => r.promise)
|
|
370
|
-
.filter(Boolean);
|
|
371
|
-
if (pending.length === 0)
|
|
372
|
-
break;
|
|
373
|
-
await Promise.allSettled(pending);
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
dispose() {
|
|
377
|
-
clearInterval(this.cleanupInterval);
|
|
378
|
-
// Clear queue
|
|
379
|
-
this.queue = [];
|
|
380
|
-
for (const record of this.agents.values()) {
|
|
381
|
-
record.session?.dispose();
|
|
382
|
-
}
|
|
383
|
-
this.agents.clear();
|
|
384
|
-
// Prune any orphaned git worktrees (crash recovery)
|
|
385
|
-
try {
|
|
386
|
-
pruneWorktrees(process.cwd());
|
|
387
|
-
}
|
|
388
|
-
catch { /* ignore */ }
|
|
389
|
-
}
|
|
390
|
-
}
|