pi-subagents-lite 1.1.0 → 1.3.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 +23 -8
- package/package.json +1 -1
- package/src/agent-manager.ts +7 -0
- package/src/agent-status.ts +50 -0
- package/src/agent-types.ts +21 -1
- package/src/index.ts +14 -2
- package/src/menus.ts +408 -11
- package/src/renderer.ts +6 -0
- package/src/state.ts +4 -1
- package/src/tool-execution.ts +36 -5
- package/src/types.ts +4 -0
- package/src/ui/agent-widget.ts +19 -9
- package/src/worktree-validator.ts +199 -0
package/README.md
CHANGED
|
@@ -18,13 +18,14 @@ Every tool the LLM sees costs tokens — in the system prompt, and in every turn
|
|
|
18
18
|
| `promptGuidelines` with rules | _(none)_ |
|
|
19
19
|
| Parameters with `.description()` | Bare `Type.String()` |
|
|
20
20
|
|
|
21
|
-
Tool names like `Agent` and `
|
|
21
|
+
Tool names like `Agent`, `StopAgent`, and `AgentStatus`, and parameter names like `prompt`, `description`, `run_in_background` are self-documenting. The LLM infers usage from the schema — no verbose descriptions needed. Tool results reinforce correct usage with clear success/error messages.
|
|
22
22
|
|
|
23
|
-
**Result:** foreground and background agents, custom agent types, per-model concurrency, cost tracking, steering, model overrides — all with minimal token overhead.
|
|
23
|
+
**Result:** foreground and background agents, custom agent types, per-model concurrency, cost tracking, steering, model overrides, agent status — all with minimal token overhead.
|
|
24
24
|
|
|
25
25
|
## Features
|
|
26
26
|
|
|
27
|
-
- **
|
|
27
|
+
- **Three tools** — `Agent` (spawn), `StopAgent` (stop), and `AgentStatus` (list agents)
|
|
28
|
+
- **Manual spawn** — spawn agents from the `/agents` menu without asking the LLM. Full control over model, thinking, turns, and background mode.
|
|
28
29
|
- **Foreground & background** — block or fire-and-forget with auto-delivered results
|
|
29
30
|
- **Custom agent types** — define via `.md` files with YAML frontmatter (tools, model, thinking, turn limits)
|
|
30
31
|
- **Smart model resolution** — 6-level precedence: session → config → frontmatter → parent. Set once, forget
|
|
@@ -38,6 +39,7 @@ Tool names like `Agent` and `StopAgent`, and parameter names like `prompt`, `des
|
|
|
38
39
|
- **Output logs** — human-readable, `tail -f` friendly
|
|
39
40
|
- **Grace turns** — configurable grace turns after `max_turns` before hard abort
|
|
40
41
|
- **Reload safety** — warns when active agents are killed by session reload
|
|
42
|
+
- **Worktree support** — `worktree_path` parameter runs agents in a git worktree with validated path, worktree agent discovery, and UI label
|
|
41
43
|
|
|
42
44
|
## Install
|
|
43
45
|
|
|
@@ -96,9 +98,10 @@ Stop a running agent at any time via /agents command
|
|
|
96
98
|
| Parameter | Required | Description |
|
|
97
99
|
|---|---|---|
|
|
98
100
|
| `prompt` | ✅ | The task for the sub-agent |
|
|
99
|
-
| `description` |
|
|
101
|
+
| `description` | | Brief description for the LLM caller (optional — if omitted, derived from prompt) |
|
|
100
102
|
| `agent` | | Type name — `general-purpose`, `Explore`, or any custom type you define (see [Custom Agent Types](#custom-agent-types)). The available values are **auto-populated** from `.md` files in your agent directories — drop a file, it appears in the enum. Set `hidden: true` in frontmatter to hide a type from this list (still callable by name). |
|
|
101
103
|
| `run_in_background` | | Fire-and-forget; result delivered automatically when done |
|
|
104
|
+
| `worktree_path` | | Absolute path to a git worktree. Agent runs in that worktree's context, discovers agents from its `.pi/agents/` directory, and displays a worktree label in the widget and menus. Path is validated against the parent repo's git common dir. |
|
|
102
105
|
|
|
103
106
|
> `model`, `max_turns`, and `thinking` are **not visible to the LLM** through tool introspection — the extension injects them at call time from agent config and frontmatter. `model` is resolved via the [Model Resolution](#model-resolution) chain; `max_turns`/`thinking` come from the agent's config. See [Custom Agent Types](#custom-agent-types) to set them.
|
|
104
107
|
|
|
@@ -282,12 +285,14 @@ The LLM never passes `model` — it's injected at call time via the `tool_call`
|
|
|
282
285
|
|
|
283
286
|
### `/agents`
|
|
284
287
|
|
|
285
|
-
Management menu with
|
|
288
|
+
Management menu with four sections:
|
|
286
289
|
|
|
287
|
-
- **Model settings** — global default, per-type overrides, force background mode, cost display toggle, grace turns
|
|
288
|
-
- **Concurrency** — default limit, per-provider and per-model slots, reset to defaults
|
|
289
290
|
- **Running agents** — list with status and description; per-agent actions: view snapshot, view result, view error, steer, stop; bulk stop all running
|
|
290
|
-
- **
|
|
291
|
+
- **Spawn agent** — manually spawn an agent without asking the LLM. Pick a type, enter a prompt, configure options (model, thinking, max turns, grace turns, background), and spawn. Options are pre-filled from agent config and current settings. Spawn immediately or customize first.
|
|
292
|
+
- **Settings** — model, concurrency, and widget settings grouped together
|
|
293
|
+
- **Model settings** — global default, per-type overrides, force background mode, cost display toggle, grace turns
|
|
294
|
+
- **Concurrency** — default limit, per-provider and per-model slots, reset to defaults
|
|
295
|
+
- **Widget settings** — force compact mode, max lines (full/compact), ctrl+o shortcut
|
|
291
296
|
- **Debug** — agent types, agent briefing (sends capabilities to the LLM)
|
|
292
297
|
|
|
293
298
|
## Interface
|
|
@@ -384,6 +389,16 @@ Agent IDs can be discovered from:
|
|
|
384
389
|
- The `StopAgent` error message, which lists all running agent IDs
|
|
385
390
|
- The `/agents` menu's **Running agents** section
|
|
386
391
|
|
|
392
|
+
## AgentStatus Tool
|
|
393
|
+
|
|
394
|
+
List all agents with their type, short ID, and status. Returns a formatted list of all agents (running, queued, completed, stopped, error) and a nudge message reminding the LLM not to poll.
|
|
395
|
+
|
|
396
|
+
**Usage:** The LLM calls `AgentStatus` to check on agents, but the extension nudges it to wait for automatic notifications instead of polling. This prevents wasteful repeated calls while still allowing the LLM to discover agents when needed.
|
|
397
|
+
|
|
398
|
+
**Output format:** `type·short_id·status, type·short_id·status, ...`
|
|
399
|
+
|
|
400
|
+
Example: `general-purpose·a1b2c3·running, Explore·d4e5f6·completed`
|
|
401
|
+
|
|
387
402
|
## Output Logs
|
|
388
403
|
|
|
389
404
|
`/tmp/pi-agent-outputs/<agentId>.log` — append-only, human-readable, `tail -f` friendly. Every line is prefixed with an ISO 8601 timestamp:
|
package/package.json
CHANGED
package/src/agent-manager.ts
CHANGED
|
@@ -94,6 +94,10 @@ export interface SpawnOptions {
|
|
|
94
94
|
maxTurns?: number;
|
|
95
95
|
thinkingLevel?: ThinkingLevel;
|
|
96
96
|
isBackground?: boolean;
|
|
97
|
+
/** Resolved worktree path — forwarded as cwd to runAgent. */
|
|
98
|
+
worktreePath?: string;
|
|
99
|
+
/** Short display label for the worktree (set on record display after spawn). */
|
|
100
|
+
worktreeLabel?: string;
|
|
97
101
|
/**
|
|
98
102
|
* Model key for concurrency pool lookup (e.g. "llamacpp/4b_small").
|
|
99
103
|
* When set, the agent is counted against that model's concurrency limit.
|
|
@@ -260,6 +264,8 @@ export class AgentManager {
|
|
|
260
264
|
type,
|
|
261
265
|
description: options.description,
|
|
262
266
|
invocation: options.invocation,
|
|
267
|
+
worktreePath: options.worktreePath,
|
|
268
|
+
worktreeLabel: options.worktreeLabel,
|
|
263
269
|
},
|
|
264
270
|
execution: {
|
|
265
271
|
abortController,
|
|
@@ -318,6 +324,7 @@ export class AgentManager {
|
|
|
318
324
|
model: options.model,
|
|
319
325
|
maxTurns: options.maxTurns,
|
|
320
326
|
thinkingLevel: options.thinkingLevel,
|
|
327
|
+
cwd: options.worktreePath,
|
|
321
328
|
graceTurns: options.graceTurns,
|
|
322
329
|
signal: record.execution.abortController!.signal,
|
|
323
330
|
...this.createRecordCallbacks(record, options),
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-status.ts — AgentStatus tool implementation.
|
|
3
|
+
*
|
|
4
|
+
* A lightweight informational tool that lists all agents (running, queued,
|
|
5
|
+
* completed, stopped, error) from the manager and returns a clear message
|
|
6
|
+
* about not polling for status.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import type { AgentRecord } from "./types.js";
|
|
11
|
+
import { SHORT_ID_LENGTH } from "./types.js";
|
|
12
|
+
import { getManager } from "./state.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Format a single agent record as "type·short_id·status".
|
|
16
|
+
*/
|
|
17
|
+
function formatAgent(record: AgentRecord): string {
|
|
18
|
+
const shortId = record.id.slice(0, SHORT_ID_LENGTH);
|
|
19
|
+
return `${record.display.type}·${shortId}·${record.lifecycle.status}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Execute the AgentStatus tool.
|
|
24
|
+
*
|
|
25
|
+
* Returns a formatted list of all agents with their type, short ID, and status,
|
|
26
|
+
* followed by a nudge message telling the model not to poll.
|
|
27
|
+
*/
|
|
28
|
+
export async function executeAgentStatusTool(
|
|
29
|
+
_toolCallId: string,
|
|
30
|
+
_params: Record<string, unknown>,
|
|
31
|
+
_signal: AbortSignal | undefined,
|
|
32
|
+
_onUpdate: ((update: any) => void) | undefined,
|
|
33
|
+
_ctx: ExtensionContext,
|
|
34
|
+
): Promise<any> {
|
|
35
|
+
const manager = getManager();
|
|
36
|
+
const agents = manager.listAgents();
|
|
37
|
+
|
|
38
|
+
const nudge = "Don't poll — you'll receive notifications when agents complete.";
|
|
39
|
+
|
|
40
|
+
if (agents.length === 0) {
|
|
41
|
+
return {
|
|
42
|
+
content: [{ type: "text", text: `No agents running or completed.\n\n${nudge}` }],
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const formatted = agents.map(formatAgent).join(", ");
|
|
47
|
+
return {
|
|
48
|
+
content: [{ type: "text", text: `${formatted}\n\n${nudge}` }],
|
|
49
|
+
};
|
|
50
|
+
}
|
package/src/agent-types.ts
CHANGED
|
@@ -60,8 +60,13 @@ export function setAgentScanDirs(userDir: string, projectDir: string): void {
|
|
|
60
60
|
/**
|
|
61
61
|
* Scan the known agent directories and register any newly discovered agents
|
|
62
62
|
* that aren't already in the registry. Returns the number of new agents added.
|
|
63
|
+
*
|
|
64
|
+
* @param worktreeDir - Optional absolute path to a worktree's `.pi/agents/` directory.
|
|
65
|
+
* When set, agents from this directory are also scanned and added to the registry.
|
|
66
|
+
* Worktree-local types use "project" source attribution and follow the same
|
|
67
|
+
* parsing and name-uniqueness rules as the parent's project scan.
|
|
63
68
|
*/
|
|
64
|
-
export async function discoverNewAgents(): Promise<number> {
|
|
69
|
+
export async function discoverNewAgents(worktreeDir?: string): Promise<number> {
|
|
65
70
|
const [userAgents, projectAgents] = await Promise.all([
|
|
66
71
|
scanAgentFilesInDir(userAgentDir, "user"),
|
|
67
72
|
scanAgentFilesInDir(projectAgentDir, "project"),
|
|
@@ -76,6 +81,21 @@ export async function discoverNewAgents(): Promise<number> {
|
|
|
76
81
|
count++;
|
|
77
82
|
}
|
|
78
83
|
}
|
|
84
|
+
|
|
85
|
+
// Scan worktree-local agents (only when worktreeDir is provided)
|
|
86
|
+
if (worktreeDir) {
|
|
87
|
+
const worktreeAgents = await scanAgentFilesInDir(worktreeDir, "project");
|
|
88
|
+
// Use mergeAgents to convert AgentConfigFromMd to AgentConfig (applies fromMd
|
|
89
|
+
// and BASE_DEFAULTS), then add only names not already in the registry.
|
|
90
|
+
const wtMerged = mergeAgents(new Map(), [], worktreeAgents);
|
|
91
|
+
for (const [name, config] of wtMerged) {
|
|
92
|
+
if (!agents.has(name)) {
|
|
93
|
+
agents.set(name, config);
|
|
94
|
+
count++;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
79
99
|
return count;
|
|
80
100
|
}
|
|
81
101
|
|
package/src/index.ts
CHANGED
|
@@ -38,6 +38,7 @@ import { AgentWidget, type UICtx } from "./ui/agent-widget.js";
|
|
|
38
38
|
import { showAgentsMainMenu } from "./menus.js";
|
|
39
39
|
import { loadConfig } from "./config-io.js";
|
|
40
40
|
import { executeAgentTool, executeStopAgentTool, toolCallListener, backgroundAgentIds, scheduleNudge } from "./tool-execution.js";
|
|
41
|
+
import { executeAgentStatusTool } from "./agent-status.js";
|
|
41
42
|
import { renderAgentToolCall, renderAgentToolResult, renderSubagentResult } from "./renderer.js";
|
|
42
43
|
import {
|
|
43
44
|
__config,
|
|
@@ -49,6 +50,7 @@ import {
|
|
|
49
50
|
clearManager,
|
|
50
51
|
setWidget,
|
|
51
52
|
setPiInstance,
|
|
53
|
+
setSessionCtx,
|
|
52
54
|
resetSessionOverrides,
|
|
53
55
|
resetLastToolsExpanded,
|
|
54
56
|
syncWidgetSettings,
|
|
@@ -167,10 +169,10 @@ function registerAgentTool(pi: ExtensionAPI): void {
|
|
|
167
169
|
label: "Agent",
|
|
168
170
|
parameters: Type.Object({
|
|
169
171
|
prompt: Type.String(),
|
|
170
|
-
description: Type.String(),
|
|
172
|
+
description: Type.Optional(Type.String()),
|
|
171
173
|
agent: agentParam,
|
|
172
174
|
run_in_background: Type.Optional(Type.Boolean()),
|
|
173
|
-
|
|
175
|
+
worktree_path: Type.Optional(Type.String()),
|
|
174
176
|
}),
|
|
175
177
|
execute: executeAgentTool,
|
|
176
178
|
|
|
@@ -210,6 +212,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
210
212
|
execute: executeStopAgentTool,
|
|
211
213
|
});
|
|
212
214
|
|
|
215
|
+
// AgentStatus tool — stealth schema, list all agents and their statuses
|
|
216
|
+
// @ts-expect-error — description removed to save prompt tokens
|
|
217
|
+
pi.registerTool({
|
|
218
|
+
name: "AgentStatus",
|
|
219
|
+
label: "AgentStatus",
|
|
220
|
+
parameters: Type.Object({}),
|
|
221
|
+
execute: executeAgentStatusTool,
|
|
222
|
+
});
|
|
223
|
+
|
|
213
224
|
// Message renderer — subagent-result (background agent completion)
|
|
214
225
|
pi.registerMessageRenderer("subagent-result", (message, options, theme) =>
|
|
215
226
|
renderSubagentResult(
|
|
@@ -248,6 +259,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
248
259
|
let unregisterTerminalInput: (() => void) | undefined;
|
|
249
260
|
|
|
250
261
|
pi.on("session_start", async (_event: unknown, ctx: ExtensionContext) => {
|
|
262
|
+
setSessionCtx(ctx);
|
|
251
263
|
resetSessionOverrides();
|
|
252
264
|
agentActivity.clear();
|
|
253
265
|
resetLastToolsExpanded();
|
package/src/menus.ts
CHANGED
|
@@ -6,22 +6,27 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
9
|
-
import { getAgentConfig, getAvailableTypes, getAllTypes } from "./agent-types.js";
|
|
10
|
-
import type { AgentRecord } from "./types.js";
|
|
9
|
+
import { getAgentConfig, getAvailableTypes, getAllTypes, resolveType, discoverNewAgents } from "./agent-types.js";
|
|
10
|
+
import type { AgentRecord, ThinkingLevel } from "./types.js";
|
|
11
11
|
import { SHORT_ID_LENGTH, CONFIG_AGENT_NON_MODEL_KEYS } from "./types.js";
|
|
12
|
+
import type { SpawnOptions } from "./agent-manager.js";
|
|
12
13
|
import { ModelSelectorDialog, type ModelOption } from "./model-selector.js";
|
|
13
14
|
import { ResultViewer, type ResultViewerStats } from "./result-viewer.js";
|
|
14
15
|
import { getDisplayName } from "./format.js";
|
|
15
16
|
import { buildSnapshotMarkdown } from "./context.js";
|
|
16
17
|
|
|
17
|
-
import { parseModelKey } from "./utils.js";
|
|
18
|
+
import { parseModelKey, findModelInRegistry } from "./utils.js";
|
|
18
19
|
import {
|
|
19
20
|
__config,
|
|
20
21
|
sessionOverrides,
|
|
21
22
|
piInstance,
|
|
23
|
+
sessionCtx,
|
|
24
|
+
agentActivity,
|
|
22
25
|
getManager,
|
|
26
|
+
getWidget,
|
|
23
27
|
} from "./state.js";
|
|
24
28
|
import { resolveModel } from "./model-precedence.js";
|
|
29
|
+
import { createActivityTracker, backgroundAgentIds } from "./tool-execution.js";
|
|
25
30
|
import {
|
|
26
31
|
setModelOverride,
|
|
27
32
|
setDefaultModel,
|
|
@@ -242,6 +247,96 @@ async function runMenuLoop(
|
|
|
242
247
|
}
|
|
243
248
|
}
|
|
244
249
|
|
|
250
|
+
// ============================================================================
|
|
251
|
+
// Worktree picker helpers
|
|
252
|
+
// ============================================================================
|
|
253
|
+
|
|
254
|
+
/** Timeout for git worktree list command (ms). */
|
|
255
|
+
const WORKTREE_LIST_TIMEOUT_MS = 5000;
|
|
256
|
+
|
|
257
|
+
/** Max display length for a worktree path before truncation. */
|
|
258
|
+
const WORKTREE_PATH_TRUNCATE_LEN = 60;
|
|
259
|
+
|
|
260
|
+
interface WorktreeEntry {
|
|
261
|
+
path: string;
|
|
262
|
+
branch: string | null;
|
|
263
|
+
isDetached: boolean;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Parse `git worktree list --porcelain` output into structured entries.
|
|
268
|
+
*
|
|
269
|
+
* Format (one block per worktree, separated by blank lines):
|
|
270
|
+
* worktree /path/to/worktree
|
|
271
|
+
* HEAD <sha>
|
|
272
|
+
* branch refs/heads/<name> (or: (detached))
|
|
273
|
+
*/
|
|
274
|
+
function parseWorktreeList(output: string): WorktreeEntry[] {
|
|
275
|
+
const entries: WorktreeEntry[] = [];
|
|
276
|
+
const blocks = output.split(/\n\n+/);
|
|
277
|
+
for (const block of blocks) {
|
|
278
|
+
if (!block.trim()) continue;
|
|
279
|
+
const lines = block.split("\n");
|
|
280
|
+
let path = "";
|
|
281
|
+
let branch: string | null = null;
|
|
282
|
+
let isDetached = false;
|
|
283
|
+
for (const line of lines) {
|
|
284
|
+
if (line.startsWith("worktree ")) {
|
|
285
|
+
path = line.slice("worktree ".length);
|
|
286
|
+
} else if (line.startsWith("branch refs/heads/")) {
|
|
287
|
+
branch = line.slice("branch refs/heads/".length);
|
|
288
|
+
} else if (line === "detached") {
|
|
289
|
+
isDetached = true;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if (path) {
|
|
293
|
+
entries.push({ path, branch, isDetached });
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return entries;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Truncate a path for display, keeping the tail. */
|
|
300
|
+
function truncatePath(p: string): string {
|
|
301
|
+
if (p.length <= WORKTREE_PATH_TRUNCATE_LEN) return p;
|
|
302
|
+
return "..." + p.slice(p.length - WORKTREE_PATH_TRUNCATE_LEN + 3);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Fetch worktrees via `git worktree list --porcelain`.
|
|
307
|
+
* Returns null if git is unavailable or the command fails.
|
|
308
|
+
*/
|
|
309
|
+
async function listWorktrees(cwd: string): Promise<WorktreeEntry[] | null> {
|
|
310
|
+
try {
|
|
311
|
+
const result = await piInstance.exec(
|
|
312
|
+
"git",
|
|
313
|
+
["worktree", "list", "--porcelain"],
|
|
314
|
+
{ cwd, timeout: WORKTREE_LIST_TIMEOUT_MS },
|
|
315
|
+
);
|
|
316
|
+
if (result.code !== 0) return null;
|
|
317
|
+
return parseWorktreeList(result.stdout);
|
|
318
|
+
} catch {
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Check whether a directory is inside a git repository.
|
|
325
|
+
* Uses `git rev-parse --git-common-dir` — the same strategy as the worktree validator.
|
|
326
|
+
*/
|
|
327
|
+
async function isInGitRepo(cwd: string): Promise<boolean> {
|
|
328
|
+
try {
|
|
329
|
+
const result = await piInstance.exec(
|
|
330
|
+
"git",
|
|
331
|
+
["rev-parse", "--git-common-dir"],
|
|
332
|
+
{ cwd, timeout: WORKTREE_LIST_TIMEOUT_MS },
|
|
333
|
+
);
|
|
334
|
+
return result.code === 0 && result.stdout.trim() !== "";
|
|
335
|
+
} catch {
|
|
336
|
+
return false;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
245
340
|
// ============================================================================
|
|
246
341
|
// /agents command handler
|
|
247
342
|
// ============================================================================
|
|
@@ -439,26 +534,318 @@ function matchMenuChoice(
|
|
|
439
534
|
return handlers[key];
|
|
440
535
|
}
|
|
441
536
|
|
|
537
|
+
// ============================================================================
|
|
538
|
+
// Spawn agent menu
|
|
539
|
+
// ============================================================================
|
|
540
|
+
|
|
541
|
+
const THINKING_LEVELS: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"];
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Show the spawn agent flow: type selection → prompt → options sub-menu → spawn.
|
|
545
|
+
* Escape at any step aborts the flow and returns to the main menu.
|
|
546
|
+
*/
|
|
547
|
+
export async function showSpawnAgentMenu(
|
|
548
|
+
ctx: ExtensionCommandContext,
|
|
549
|
+
modelOptions: string[],
|
|
550
|
+
): Promise<void> {
|
|
551
|
+
// Step 1: Type selection loop (unknown type → error → retry)
|
|
552
|
+
let selectedType: string;
|
|
553
|
+
while (true) {
|
|
554
|
+
const types = getAvailableTypes();
|
|
555
|
+
if (types.length === 0) {
|
|
556
|
+
ctx.ui.notify("No agent types available", "error");
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
const type = await ctx.ui.select("Select agent type", types);
|
|
560
|
+
if (type === undefined) return; // Escape → main menu
|
|
561
|
+
|
|
562
|
+
const config = getAgentConfig(type);
|
|
563
|
+
if (!config) {
|
|
564
|
+
ctx.ui.notify(`Unknown agent type: ${type}`, "error");
|
|
565
|
+
continue; // Loop back to type selection
|
|
566
|
+
}
|
|
567
|
+
selectedType = type;
|
|
568
|
+
break;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const agentConfig = getAgentConfig(selectedType)!;
|
|
572
|
+
|
|
573
|
+
// Step 2: Prompt entry loop (empty prompt → error → retry)
|
|
574
|
+
let prompt: string;
|
|
575
|
+
while (true) {
|
|
576
|
+
const input = await ctx.ui.input("Agent prompt");
|
|
577
|
+
if (input === undefined) return; // Escape → main menu
|
|
578
|
+
|
|
579
|
+
if (!input.trim()) {
|
|
580
|
+
ctx.ui.notify("Prompt cannot be empty", "error");
|
|
581
|
+
continue; // Loop back to prompt input
|
|
582
|
+
}
|
|
583
|
+
prompt = input.trim();
|
|
584
|
+
break;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// Step 3: Options sub-menu with spawn action
|
|
588
|
+
const autoDescription = prompt.length > 50 ? prompt.slice(0, 50) : prompt;
|
|
589
|
+
let description = autoDescription;
|
|
590
|
+
|
|
591
|
+
// Check if parent's cwd is inside a git repo (for worktree picker visibility)
|
|
592
|
+
const parentCwd = sessionCtx?.cwd ?? "";
|
|
593
|
+
const inGitRepo = parentCwd ? await isInGitRepo(parentCwd) : false;
|
|
594
|
+
|
|
595
|
+
// Worktree picker state
|
|
596
|
+
let currentWorktreePath: string | undefined;
|
|
597
|
+
let currentWorktreeLabel = "Inherits parent cwd";
|
|
598
|
+
|
|
599
|
+
// Pre-fill model from precedence chain
|
|
600
|
+
const parentModelId = sessionCtx?.model
|
|
601
|
+
? `${sessionCtx.model.provider}/${sessionCtx.model.id}`
|
|
602
|
+
: "";
|
|
603
|
+
const effectiveModelStr = resolveModel({
|
|
604
|
+
subagentType: selectedType,
|
|
605
|
+
agentConfig,
|
|
606
|
+
config: __config,
|
|
607
|
+
parentModelId,
|
|
608
|
+
sessionOverrides,
|
|
609
|
+
});
|
|
610
|
+
let currentModelStr = effectiveModelStr || ""; // "" means inherit parent
|
|
611
|
+
let currentThinking: ThinkingLevel | undefined = agentConfig.thinking;
|
|
612
|
+
let currentMaxTurns: number | undefined = agentConfig.maxTurns;
|
|
613
|
+
let currentGraceTurns: number | undefined = __config.agent.graceTurns ?? 6;
|
|
614
|
+
let currentBackground: boolean = __config.agent.forceBackground ?? false;
|
|
615
|
+
|
|
616
|
+
while (true) {
|
|
617
|
+
const displayModel = currentModelStr || "(inherits parent)";
|
|
618
|
+
const displayThinking = currentThinking ?? "inherit";
|
|
619
|
+
const displayMaxTurns = currentMaxTurns != null ? String(currentMaxTurns) : "unlimited";
|
|
620
|
+
const displayGraceTurns = String(currentGraceTurns ?? 6);
|
|
621
|
+
const displayBackground = currentBackground ? "ON" : "OFF";
|
|
622
|
+
|
|
623
|
+
const items = [
|
|
624
|
+
"Spawn",
|
|
625
|
+
"",
|
|
626
|
+
`Model · ${displayModel}`,
|
|
627
|
+
`Background · ${displayBackground}`,
|
|
628
|
+
`Thinking · ${displayThinking}`,
|
|
629
|
+
`Max turns · ${displayMaxTurns}`,
|
|
630
|
+
`Grace turns · ${displayGraceTurns}`,
|
|
631
|
+
`Description · ${description}`,
|
|
632
|
+
];
|
|
633
|
+
|
|
634
|
+
if (inGitRepo) {
|
|
635
|
+
items.push(`Worktree · ${currentWorktreeLabel}`);
|
|
636
|
+
};
|
|
637
|
+
|
|
638
|
+
const choice = await ctx.ui.select("Spawn Options", items);
|
|
639
|
+
if (choice === undefined) return; // Escape → main menu
|
|
640
|
+
|
|
641
|
+
if (choice === "Spawn") {
|
|
642
|
+
// Resolve model string to Model object
|
|
643
|
+
let model: ReturnType<typeof findModelInRegistry> = undefined;
|
|
644
|
+
let modelKey: string | undefined;
|
|
645
|
+
|
|
646
|
+
if (currentModelStr) {
|
|
647
|
+
const registry = sessionCtx?.modelRegistry ?? ctx.modelRegistry;
|
|
648
|
+
model = findModelInRegistry(currentModelStr, registry, undefined);
|
|
649
|
+
if (!model) {
|
|
650
|
+
ctx.ui.notify(`Model not found: ${currentModelStr}`, "error");
|
|
651
|
+
continue; // Return to options sub-menu
|
|
652
|
+
}
|
|
653
|
+
modelKey = `${model.provider}/${model.id}`;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// Discover worktree-local agent types before spawn
|
|
657
|
+
if (currentWorktreePath) {
|
|
658
|
+
await discoverNewAgents(`${currentWorktreePath}/.pi/agents`);
|
|
659
|
+
}
|
|
660
|
+
// Resolve type (may have been discovered from worktree)
|
|
661
|
+
const resolvedType = resolveType(selectedType) ?? selectedType;
|
|
662
|
+
|
|
663
|
+
const spawnOptions: SpawnOptions = {
|
|
664
|
+
description,
|
|
665
|
+
model,
|
|
666
|
+
maxTurns: currentMaxTurns,
|
|
667
|
+
thinkingLevel: currentThinking,
|
|
668
|
+
isBackground: currentBackground,
|
|
669
|
+
modelKey,
|
|
670
|
+
invocation: {
|
|
671
|
+
modelName: model?.id,
|
|
672
|
+
thinking: currentThinking,
|
|
673
|
+
maxTurns: currentMaxTurns,
|
|
674
|
+
runInBackground: currentBackground,
|
|
675
|
+
},
|
|
676
|
+
graceTurns: currentGraceTurns,
|
|
677
|
+
worktreePath: currentWorktreePath,
|
|
678
|
+
worktreeLabel: currentWorktreePath ? currentWorktreeLabel : undefined,
|
|
679
|
+
};
|
|
680
|
+
|
|
681
|
+
const { state: activityState, callbacks } = createActivityTracker(currentMaxTurns);
|
|
682
|
+
|
|
683
|
+
let agentId: string;
|
|
684
|
+
try {
|
|
685
|
+
agentId = getManager().spawn(piInstance, sessionCtx, resolvedType, prompt, {
|
|
686
|
+
...spawnOptions,
|
|
687
|
+
...callbacks,
|
|
688
|
+
});
|
|
689
|
+
} catch (err) {
|
|
690
|
+
ctx.ui.notify(
|
|
691
|
+
`Spawn failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
692
|
+
"error",
|
|
693
|
+
);
|
|
694
|
+
return; // Return to main menu
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// Wire activity tracking for widget
|
|
698
|
+
agentActivity.set(agentId, activityState);
|
|
699
|
+
// Set UI context so widget can render (same as tool_execution_start handler)
|
|
700
|
+
const widget = getWidget();
|
|
701
|
+
if (widget) {
|
|
702
|
+
widget.setUICtx(ctx.ui as unknown as import("./ui/agent-widget.js").UICtx);
|
|
703
|
+
widget.ensureTimer();
|
|
704
|
+
widget.update();
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
if (currentBackground) {
|
|
708
|
+
backgroundAgentIds.add(agentId);
|
|
709
|
+
return; // Background: return to main menu immediately
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// Foreground: block until completion
|
|
713
|
+
const fgRecord = getManager().getRecord(agentId);
|
|
714
|
+
if (fgRecord?.execution?.promise) {
|
|
715
|
+
await fgRecord.execution.promise;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
agentActivity.delete(agentId);
|
|
719
|
+
getWidget()?.markFinished(agentId);
|
|
720
|
+
getWidget()?.update();
|
|
721
|
+
|
|
722
|
+
return; // Return to main menu
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// Handle option changes
|
|
726
|
+
if (choice.startsWith("Description")) {
|
|
727
|
+
const input = await ctx.ui.input("Description", description);
|
|
728
|
+
if (input !== undefined && input.trim()) {
|
|
729
|
+
description = input.trim();
|
|
730
|
+
}
|
|
731
|
+
} else if (choice.startsWith("Model")) {
|
|
732
|
+
const chosen = await promptModelSelection(
|
|
733
|
+
ctx, modelOptions, currentModelStr || "(inherits parent)",
|
|
734
|
+
);
|
|
735
|
+
if (chosen !== null) {
|
|
736
|
+
currentModelStr = chosen === "(inherits parent)" ? "" : chosen;
|
|
737
|
+
}
|
|
738
|
+
} else if (choice.startsWith("Thinking")) {
|
|
739
|
+
const allLevels = [...THINKING_LEVELS, "inherit"];
|
|
740
|
+
const chosen = await ctx.ui.select("Thinking level", allLevels);
|
|
741
|
+
if (chosen !== undefined) {
|
|
742
|
+
currentThinking = chosen === "inherit" ? undefined : (chosen as ThinkingLevel);
|
|
743
|
+
}
|
|
744
|
+
} else if (choice.startsWith("Max turns")) {
|
|
745
|
+
const initial = currentMaxTurns != null ? String(currentMaxTurns) : "unlimited";
|
|
746
|
+
const input = await ctx.ui.input("Max turns (number or 'unlimited')", initial);
|
|
747
|
+
if (input !== undefined) {
|
|
748
|
+
const trimmed = input.trim().toLowerCase();
|
|
749
|
+
if (trimmed === "unlimited" || trimmed === "") {
|
|
750
|
+
currentMaxTurns = undefined;
|
|
751
|
+
} else {
|
|
752
|
+
const parsed = parseInt(trimmed, 10);
|
|
753
|
+
if (isNaN(parsed) || parsed < 1) {
|
|
754
|
+
ctx.ui.notify("Invalid value — must be a number ≥ 1 or 'unlimited'", "error");
|
|
755
|
+
} else {
|
|
756
|
+
currentMaxTurns = parsed;
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
} else if (choice.startsWith("Grace turns")) {
|
|
761
|
+
const parsed = await parseNumericInput(ctx, "Grace turns (≥ 0)", String(currentGraceTurns ?? 6), 0, "≥ 0");
|
|
762
|
+
if (parsed !== undefined) currentGraceTurns = parsed;
|
|
763
|
+
} else if (choice.startsWith("Background")) {
|
|
764
|
+
currentBackground = !currentBackground;
|
|
765
|
+
} else if (choice.startsWith("Worktree") && inGitRepo) {
|
|
766
|
+
// Open worktree picker
|
|
767
|
+
const worktrees = await listWorktrees(parentCwd);
|
|
768
|
+
if (!worktrees || worktrees.length === 0) {
|
|
769
|
+
ctx.ui.notify(
|
|
770
|
+
"No worktrees found or git worktree list unavailable",
|
|
771
|
+
"error",
|
|
772
|
+
);
|
|
773
|
+
continue; // Return to options sub-menu
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
const pickerItems = [
|
|
777
|
+
"Inherits parent cwd",
|
|
778
|
+
...worktrees.map(wt => {
|
|
779
|
+
const branchLabel = wt.isDetached ? "detached" : (wt.branch ?? "detached");
|
|
780
|
+
const truncPath = truncatePath(wt.path);
|
|
781
|
+
return `${branchLabel} · ${truncPath}`;
|
|
782
|
+
}),
|
|
783
|
+
];
|
|
784
|
+
|
|
785
|
+
const picked = await ctx.ui.select("Select worktree", pickerItems);
|
|
786
|
+
if (picked === undefined) continue; // Escape → return to options sub-menu
|
|
787
|
+
|
|
788
|
+
if (picked === "Inherits parent cwd") {
|
|
789
|
+
currentWorktreePath = undefined;
|
|
790
|
+
currentWorktreeLabel = "Inherits parent cwd";
|
|
791
|
+
} else {
|
|
792
|
+
// Find the matching worktree by index (offset by "Inherits parent cwd")
|
|
793
|
+
const idx = pickerItems.indexOf(picked) - 1;
|
|
794
|
+
if (idx >= 0 && idx < worktrees.length) {
|
|
795
|
+
const wt = worktrees[idx];
|
|
796
|
+
currentWorktreePath = wt.path;
|
|
797
|
+
currentWorktreeLabel = wt.branch ?? "detached";
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
export async function showSettingsMenu(
|
|
805
|
+
ctx: ExtensionCommandContext,
|
|
806
|
+
modelOptions: string[],
|
|
807
|
+
): Promise<void> {
|
|
808
|
+
const menuItems = [
|
|
809
|
+
"1. Model settings — Set global default and per-type model overrides",
|
|
810
|
+
"2. Concurrency settings — Set per-model slot limits",
|
|
811
|
+
"3. Widget settings — Configure widget display options",
|
|
812
|
+
"",
|
|
813
|
+
"Back",
|
|
814
|
+
];
|
|
815
|
+
|
|
816
|
+
const handlers: Record<string, () => Promise<void>> = {
|
|
817
|
+
"1": () => showModelSettingsMenu(ctx, modelOptions),
|
|
818
|
+
"2": () => showConcurrencySettingsMenu(ctx, modelOptions),
|
|
819
|
+
"3": () => showWidgetSettingsMenu(ctx),
|
|
820
|
+
};
|
|
821
|
+
|
|
822
|
+
while (true) {
|
|
823
|
+
const choice = await ctx.ui.select("Settings", menuItems);
|
|
824
|
+
if (choice === undefined || choice === "Back") return;
|
|
825
|
+
|
|
826
|
+
const action = matchMenuChoice(choice, handlers);
|
|
827
|
+
if (action) await action();
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
442
831
|
export async function showAgentsMainMenu(
|
|
443
832
|
ctx: ExtensionCommandContext,
|
|
444
833
|
modelOptions: string[],
|
|
445
834
|
): Promise<void> {
|
|
446
835
|
const menuItems = [
|
|
447
836
|
"1. Running agents — List running/queued agents",
|
|
448
|
-
"2.
|
|
449
|
-
"3.
|
|
450
|
-
"4.
|
|
451
|
-
"5. Debug — Agent types, briefing, diagnostics",
|
|
837
|
+
"2. Spawn agent — Manually spawn a new agent",
|
|
838
|
+
"3. Settings — Model, concurrency, and widget settings",
|
|
839
|
+
"4. Debug — Agent types, briefing, diagnostics",
|
|
452
840
|
"",
|
|
453
841
|
"Press Escape to close",
|
|
454
842
|
];
|
|
455
843
|
|
|
456
844
|
const handlers: Record<string, () => Promise<void>> = {
|
|
457
845
|
"1": () => showRunningAgentsMenu(ctx),
|
|
458
|
-
"2": () =>
|
|
459
|
-
"3": () =>
|
|
460
|
-
"4": () =>
|
|
461
|
-
"5": () => showDebugMenu(ctx),
|
|
846
|
+
"2": () => showSpawnAgentMenu(ctx, modelOptions),
|
|
847
|
+
"3": () => showSettingsMenu(ctx, modelOptions),
|
|
848
|
+
"4": () => showDebugMenu(ctx),
|
|
462
849
|
};
|
|
463
850
|
|
|
464
851
|
// Loop so sub-menus navigate back to root; only Escape at root closes
|
|
@@ -572,6 +959,7 @@ async function handleAgentBriefing(ctx: ExtensionCommandContext): Promise<void>
|
|
|
572
959
|
lines.push("| `agent` | Which agent type to use (default: general-purpose) |");
|
|
573
960
|
lines.push("| `thinking` | Optional thinking mode override (e.g., `off`, `minimal`, `low`, `medium`, `high`, `xhigh`) |");
|
|
574
961
|
lines.push("| `run_in_background` | When `true`, result is auto-delivered — do NOT poll. Continue working while waiting. |");
|
|
962
|
+
lines.push("| `worktree_path` | Optional path to a git worktree of the parent's repo. See below for details. |");
|
|
575
963
|
lines.push("");
|
|
576
964
|
|
|
577
965
|
// Usage guidelines
|
|
@@ -579,6 +967,15 @@ async function handleAgentBriefing(ctx: ExtensionCommandContext): Promise<void>
|
|
|
579
967
|
lines.push("- Agents start fresh with their config — they do NOT inherit the parent conversation");
|
|
580
968
|
lines.push("- For parallel tasks, spawn multiple `run_in_background: true` agents in one turn");
|
|
581
969
|
lines.push(" → Results are auto-delivered — do NOT poll, the result will arrive when ready");
|
|
970
|
+
lines.push("");
|
|
971
|
+
lines.push("## `worktree_path` Parameter\n");
|
|
972
|
+
lines.push("Use `worktree_path` to run a subagent in a different git worktree of the parent's repository.");
|
|
973
|
+
lines.push("");
|
|
974
|
+
lines.push("- **Optional.** Omit to run the subagent in the parent's working directory (default behavior).");
|
|
975
|
+
lines.push("- **Must be a path** inside a git worktree of the parent's repo, including the main checkout. Not a different repo, not a non-git directory.");
|
|
976
|
+
lines.push("- **Relative paths** are resolved against the parent's working directory.");
|
|
977
|
+
lines.push("- **On failure** the validator returns a specific reason (e.g., 'not a worktree of the parent's repository', 'path does not exist') — use this to self-correct.");
|
|
978
|
+
lines.push("- **Agent type discovery:** The worktree's `.pi/agents/` directory is scanned for agent types when this param is set, so worktree-local types become available to that spawn.");
|
|
582
979
|
piInstance.sendUserMessage(lines.join("\n"));
|
|
583
980
|
ctx.ui.notify("Agent briefing sent to LLM", "info");
|
|
584
981
|
}
|
package/src/renderer.ts
CHANGED
|
@@ -117,6 +117,9 @@ export function renderSubagentResult(
|
|
|
117
117
|
if (d.outputFile as string) {
|
|
118
118
|
headerLine += `\n ${theme.fg("dim", `tail -f ${d.outputFile}`)}`;
|
|
119
119
|
}
|
|
120
|
+
if (d.worktreePath as string) {
|
|
121
|
+
headerLine += `\n ${theme.fg("dim", `worktree: ${d.worktreePath}`)}`;
|
|
122
|
+
}
|
|
120
123
|
inner.addChild(new Text(headerLine, 0, 0));
|
|
121
124
|
|
|
122
125
|
if (expanded && text) {
|
|
@@ -153,5 +156,8 @@ function buildFallbackResultLine(
|
|
|
153
156
|
if (d?.outputFile) {
|
|
154
157
|
line += `\n ${theme.fg("dim", `tail -f ${d.outputFile}`)}`;
|
|
155
158
|
}
|
|
159
|
+
if (d?.worktreePath) {
|
|
160
|
+
line += `\n ${theme.fg("dim", `worktree: ${d.worktreePath}`)}`;
|
|
161
|
+
}
|
|
156
162
|
return line;
|
|
157
163
|
}
|
package/src/state.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* PI runtime doesn't propagate ESM live binding reassignments.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import type { ExtensionContext, ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
9
9
|
import type { SessionModelOverrides, SubagentsConfig } from "./model-precedence.js";
|
|
10
10
|
import { DEFAULT_CONFIG } from "./config-io.js";
|
|
11
11
|
import { AgentManager } from "./agent-manager.js";
|
|
@@ -15,6 +15,8 @@ export let sessionOverrides: SessionModelOverrides = { default: null };
|
|
|
15
15
|
export let __config: SubagentsConfig = { ...DEFAULT_CONFIG, agent: { ...DEFAULT_CONFIG.agent }, concurrency: { ...DEFAULT_CONFIG.concurrency } };
|
|
16
16
|
export const agentActivity = new Map<string, AgentActivity>();
|
|
17
17
|
export let piInstance: ExtensionAPI;
|
|
18
|
+
/** Stored ExtensionContext from session_start — used by menu spawn flow. */
|
|
19
|
+
export let sessionCtx: ExtensionContext;
|
|
18
20
|
|
|
19
21
|
// Holder objects — PI runtime doesn't propagate ESM live binding reassignments
|
|
20
22
|
const managerHolder: { current?: AgentManager } = {};
|
|
@@ -26,6 +28,7 @@ export function setManager(m: AgentManager): void { managerHolder.current = m; }
|
|
|
26
28
|
export function clearManager(): void { managerHolder.current = undefined; }
|
|
27
29
|
export function setWidget(w: AgentWidget | undefined): void { widgetHolder.current = w; }
|
|
28
30
|
export function setPiInstance(pi: ExtensionAPI): void { piInstance = pi; }
|
|
31
|
+
export function setSessionCtx(ctx: ExtensionContext): void { sessionCtx = ctx; }
|
|
29
32
|
export function getManager(): AgentManager { return managerHolder.current!; }
|
|
30
33
|
export function getWidget(): AgentWidget | undefined { return widgetHolder.current; }
|
|
31
34
|
|
package/src/tool-execution.ts
CHANGED
|
@@ -14,6 +14,7 @@ import type { AgentActivity } from "./ui/agent-widget.js";
|
|
|
14
14
|
import { resolveType, getAgentConfig, discoverNewAgents } from "./agent-types.js";
|
|
15
15
|
import { resolveModel } from "./model-precedence.js";
|
|
16
16
|
import { addUsage, getLifetimeTotal, getSessionContextPercent, type LifetimeUsage } from "./usage.js";
|
|
17
|
+
import { validateWorktreePath } from "./worktree-validator.js";
|
|
17
18
|
|
|
18
19
|
// Shared state imported from state.ts
|
|
19
20
|
import { parseModelKey, findModelInRegistry, parseThinkingLevel } from "./utils.js";
|
|
@@ -24,6 +25,7 @@ import {
|
|
|
24
25
|
agentActivity,
|
|
25
26
|
getManager,
|
|
26
27
|
getWidget,
|
|
28
|
+
sessionCtx,
|
|
27
29
|
} from "./state.js";
|
|
28
30
|
|
|
29
31
|
// ============================================================================
|
|
@@ -60,8 +62,9 @@ export function errorResult(text: string, details?: Record<string, unknown>) {
|
|
|
60
62
|
/**
|
|
61
63
|
* Create an AgentActivity state and spawn callbacks for tracking tool usage.
|
|
62
64
|
* Used by both foreground and background paths to avoid duplication.
|
|
65
|
+
* Exported for use by the menu spawn flow.
|
|
63
66
|
*/
|
|
64
|
-
function createActivityTracker(maxTurns?: number, onStreamUpdate?: () => void) {
|
|
67
|
+
export function createActivityTracker(maxTurns?: number, onStreamUpdate?: () => void) {
|
|
65
68
|
const state: AgentActivity = {
|
|
66
69
|
activeTools: new Map(),
|
|
67
70
|
toolUses: 0,
|
|
@@ -136,6 +139,10 @@ export function buildAgentDetails(
|
|
|
136
139
|
description: record.display.description,
|
|
137
140
|
};
|
|
138
141
|
|
|
142
|
+
if (record.display.worktreePath) {
|
|
143
|
+
details.worktreePath = record.display.worktreePath;
|
|
144
|
+
}
|
|
145
|
+
|
|
139
146
|
if (options?.includeStatus) {
|
|
140
147
|
details.status = record.lifecycle.status;
|
|
141
148
|
details.outputFile = record.display.outputFile;
|
|
@@ -191,7 +198,7 @@ function emitIndividualNudge(agentId: string, record?: AgentRecord): void {
|
|
|
191
198
|
piInstance.sendMessage(
|
|
192
199
|
{
|
|
193
200
|
customType: "subagent-result",
|
|
194
|
-
content: `[Subagent "${record.display.type}"
|
|
201
|
+
content: `[Subagent "${record.display.type}" ${record.lifecycle.status}]\n\n${record.result ?? ""}`,
|
|
195
202
|
details,
|
|
196
203
|
display: true,
|
|
197
204
|
},
|
|
@@ -213,11 +220,32 @@ export async function executeAgentTool(
|
|
|
213
220
|
_onUpdate: ((update: any) => void) | undefined,
|
|
214
221
|
ctx: ExtensionContext,
|
|
215
222
|
): Promise<any> {
|
|
223
|
+
// Validate worktree_path early — needed for on-demand agent discovery
|
|
224
|
+
const rawWorktreePath = params.worktree_path as string | undefined;
|
|
225
|
+
let validatedWorktreePath: string | undefined;
|
|
226
|
+
let worktreeLabel: string | undefined;
|
|
227
|
+
if (rawWorktreePath && rawWorktreePath.trim() !== "") {
|
|
228
|
+
try {
|
|
229
|
+
const parentCwd = sessionCtx?.cwd ?? ctx.cwd;
|
|
230
|
+
const validation = await validateWorktreePath(piInstance, rawWorktreePath, parentCwd);
|
|
231
|
+
if (!validation.ok) {
|
|
232
|
+
return errorResult(validation.error);
|
|
233
|
+
}
|
|
234
|
+
validatedWorktreePath = validation.resolvedPath;
|
|
235
|
+
worktreeLabel = validation.label;
|
|
236
|
+
} catch (err: unknown) {
|
|
237
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
238
|
+
return errorResult(`worktree_path validation failed: ${msg}`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
216
242
|
const type = (params.agent as string) || "general-purpose";
|
|
217
243
|
let resolvedType = resolveType(type);
|
|
218
244
|
if (!resolvedType) {
|
|
219
|
-
// Not found in registry — try scanning filesystem for agents added during the session
|
|
220
|
-
|
|
245
|
+
// Not found in registry — try scanning filesystem for agents added during the session.
|
|
246
|
+
// When worktree_path is set, also scan the worktree's .pi/agents/ directory.
|
|
247
|
+
const worktreeDir = validatedWorktreePath ? `${validatedWorktreePath}/.pi/agents` : undefined;
|
|
248
|
+
await discoverNewAgents(worktreeDir);
|
|
221
249
|
resolvedType = resolveType(type);
|
|
222
250
|
}
|
|
223
251
|
if (!resolvedType) {
|
|
@@ -225,9 +253,10 @@ export async function executeAgentTool(
|
|
|
225
253
|
}
|
|
226
254
|
|
|
227
255
|
const prompt = params.prompt as string;
|
|
228
|
-
const description = params.description as string;
|
|
256
|
+
const description = (params.description as string | undefined) || prompt.split("\n")[0].slice(0, 80) || prompt.slice(0, 80);
|
|
229
257
|
const runInBackground = params.run_in_background as boolean | undefined;
|
|
230
258
|
const maxTurns = params.max_turns as number | undefined ?? getAgentConfig(resolvedType)?.maxTurns;
|
|
259
|
+
|
|
231
260
|
const modelStr = params.model as string | undefined;
|
|
232
261
|
const model = findModelInRegistry(modelStr, ctx.modelRegistry, ctx.model);
|
|
233
262
|
const modelKey = model ? `${model.provider}/${model.id}` : undefined;
|
|
@@ -247,6 +276,8 @@ export async function executeAgentTool(
|
|
|
247
276
|
modelKey,
|
|
248
277
|
invocation: { modelName },
|
|
249
278
|
graceTurns: __config.agent.graceTurns,
|
|
279
|
+
worktreePath: validatedWorktreePath,
|
|
280
|
+
worktreeLabel,
|
|
250
281
|
};
|
|
251
282
|
|
|
252
283
|
if (runInBackground || __config.agent.forceBackground) {
|
package/src/types.ts
CHANGED
|
@@ -142,6 +142,10 @@ export interface AgentDisplayInfo {
|
|
|
142
142
|
invocation?: AgentInvocation;
|
|
143
143
|
/** The tool_use_id from the original Agent tool call. */
|
|
144
144
|
toolCallId?: string;
|
|
145
|
+
/** Resolved absolute path of the worktree this agent is running in. */
|
|
146
|
+
worktreePath?: string;
|
|
147
|
+
/** Short display label for the worktree (e.g., "feature" or "feature/packages/web"). */
|
|
148
|
+
worktreeLabel?: string;
|
|
145
149
|
}
|
|
146
150
|
|
|
147
151
|
/**
|
package/src/ui/agent-widget.ts
CHANGED
|
@@ -362,11 +362,18 @@ export class AgentWidget {
|
|
|
362
362
|
const truncate = (line: string) => truncateToWidth(line, w);
|
|
363
363
|
const blocks: RenderBlock[] = [];
|
|
364
364
|
for (const a of finished) {
|
|
365
|
+
const continuations: string[] = [];
|
|
366
|
+
if (!this.isCompact()) {
|
|
367
|
+
if (a.display.outputFile || a.display.worktreeLabel) {
|
|
368
|
+
const parts: string[] = [];
|
|
369
|
+
if (a.display.worktreeLabel) parts.push(`@${a.display.worktreeLabel}`);
|
|
370
|
+
if (a.display.outputFile) parts.push(`tail -f ${a.display.outputFile}`);
|
|
371
|
+
continuations.push(truncate(theme.fg("dim", `${VLINE} ${parts.join(" ")}`)));
|
|
372
|
+
}
|
|
373
|
+
}
|
|
365
374
|
blocks.push({
|
|
366
375
|
header: truncate(`${theme.fg("dim", BRANCH)} ${this.renderFinishedLine(a, theme)}`),
|
|
367
|
-
continuations
|
|
368
|
-
? [truncate(theme.fg("dim", `${VLINE} tail -f ${a.display.outputFile}`))]
|
|
369
|
-
: [],
|
|
376
|
+
continuations,
|
|
370
377
|
});
|
|
371
378
|
}
|
|
372
379
|
return blocks;
|
|
@@ -398,14 +405,17 @@ export class AgentWidget {
|
|
|
398
405
|
} else {
|
|
399
406
|
// Full: header + continuation lines
|
|
400
407
|
const headerLine = `${BRANCH} ${theme.fg("accent", frame)} ${theme.bold(name)} ${a.display.description} ${statsLine}`;
|
|
408
|
+
const continuations: string[] = [];
|
|
409
|
+
if (a.display.outputFile || a.display.worktreeLabel) {
|
|
410
|
+
const parts: string[] = [];
|
|
411
|
+
if (a.display.worktreeLabel) parts.push(`@${a.display.worktreeLabel}`);
|
|
412
|
+
if (a.display.outputFile) parts.push(`tail -f ${a.display.outputFile}`);
|
|
413
|
+
continuations.push(truncate(`${VLINE} ` + theme.fg("dim", `${VLINE} ${parts.join(" ")}`)));
|
|
414
|
+
}
|
|
415
|
+
continuations.push(truncate(`${VLINE} ` + theme.fg("dim", `└ ${activity}`)));
|
|
401
416
|
blocks.push({
|
|
402
417
|
header: truncate(headerLine),
|
|
403
|
-
continuations
|
|
404
|
-
...(a.display.outputFile
|
|
405
|
-
? [truncate(`${VLINE} ` + theme.fg("dim", `${VLINE} tail -f ${a.display.outputFile}`))]
|
|
406
|
-
: []),
|
|
407
|
-
truncate(`${VLINE} ` + theme.fg("dim", `└ ${activity}`)),
|
|
408
|
-
],
|
|
418
|
+
continuations,
|
|
409
419
|
});
|
|
410
420
|
}
|
|
411
421
|
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* worktree-validator.ts — Validate, resolve, and label a worktree path.
|
|
3
|
+
*
|
|
4
|
+
* Pure async functions that validate a `worktree_path` value against the parent's
|
|
5
|
+
* git repository. Depends on `pi.exec` for git commands.
|
|
6
|
+
*
|
|
7
|
+
* Validation strategy: compare `git-common-dir` of the parent and target paths.
|
|
8
|
+
* If they share the same common dir, the target is a worktree of the parent's repo.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import * as path from "node:path";
|
|
12
|
+
import { existsSync, statSync, realpathSync } from "node:fs";
|
|
13
|
+
|
|
14
|
+
/** Timeout for git commands (ms). */
|
|
15
|
+
const GIT_EXEC_TIMEOUT_MS = 5000;
|
|
16
|
+
|
|
17
|
+
/** Specific error messages returned to the LLM for self-correction. */
|
|
18
|
+
export const WORKTREE_VALIDATION_ERRORS = {
|
|
19
|
+
PATH_DOES_NOT_EXIST: "worktree_path does not exist: the specified path was not found on disk",
|
|
20
|
+
NOT_A_DIRECTORY: "worktree_path is not a directory: the specified path exists but is not a directory",
|
|
21
|
+
PARENT_NOT_IN_GIT_REPO: "worktree_path validation failed: the parent session is not inside a git repository",
|
|
22
|
+
NOT_IN_GIT_REPO: "worktree_path is not inside a git repository",
|
|
23
|
+
DIFFERENT_REPO: "worktree_path is not a worktree of the parent's repository",
|
|
24
|
+
GIT_NOT_FOUND: "worktree_path validation failed: git executable not found on this host",
|
|
25
|
+
GIT_TIMEOUT: "worktree_path validation failed: git command timed out",
|
|
26
|
+
} as const;
|
|
27
|
+
|
|
28
|
+
/** Successful validation result. */
|
|
29
|
+
export interface WorktreeValidationSuccess {
|
|
30
|
+
ok: true;
|
|
31
|
+
/** Resolved absolute path (symlinks followed, relative resolved). Undefined when path is empty/omitted. */
|
|
32
|
+
resolvedPath?: string;
|
|
33
|
+
/** Worktree root directory. */
|
|
34
|
+
worktreeRoot?: string;
|
|
35
|
+
/** Short display label for the widget. */
|
|
36
|
+
label?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Failed validation result. */
|
|
40
|
+
export interface WorktreeValidationFailure {
|
|
41
|
+
ok: false;
|
|
42
|
+
/** Human-readable error describing the specific failure reason. */
|
|
43
|
+
error: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type WorktreeValidationResult = WorktreeValidationSuccess | WorktreeValidationFailure;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Minimal interface for the pi exec function — only what the validator needs.
|
|
50
|
+
*/
|
|
51
|
+
interface PiExec {
|
|
52
|
+
exec(cmd: string, args: string[], opts?: { cwd?: string; timeout?: number }): Promise<{ code: number; stdout: string; stderr: string }>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Run `git rev-parse --git-common-dir` and return the trimmed result.
|
|
57
|
+
* Returns a failure result if the command fails or git is unavailable.
|
|
58
|
+
*/
|
|
59
|
+
async function getGitCommonDir(
|
|
60
|
+
pi: PiExec,
|
|
61
|
+
cwd: string,
|
|
62
|
+
notInRepoError: string,
|
|
63
|
+
): Promise<{ ok: true; commonDir: string } | { ok: false; error: string }> {
|
|
64
|
+
try {
|
|
65
|
+
const result = await pi.exec("git", ["rev-parse", "--git-common-dir"], { cwd, timeout: GIT_EXEC_TIMEOUT_MS });
|
|
66
|
+
if (result.code !== 0) return { ok: false, error: notInRepoError };
|
|
67
|
+
const commonDir = result.stdout.trim();
|
|
68
|
+
if (!commonDir) return { ok: false, error: notInRepoError };
|
|
69
|
+
return { ok: true, commonDir };
|
|
70
|
+
} catch (err: unknown) {
|
|
71
|
+
const msg = String(err instanceof Error ? err.message : err);
|
|
72
|
+
if (msg.includes("ENOENT") || msg.includes("not found")) {
|
|
73
|
+
return { ok: false, error: WORKTREE_VALIDATION_ERRORS.GIT_NOT_FOUND };
|
|
74
|
+
}
|
|
75
|
+
return { ok: false, error: WORKTREE_VALIDATION_ERRORS.GIT_TIMEOUT };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Validate a worktree path against the parent's git repository.
|
|
81
|
+
*
|
|
82
|
+
* Resolution order:
|
|
83
|
+
* 1. Empty/whitespace → treated as omitted (return ok with no path)
|
|
84
|
+
* 2. Resolve relative against parent cwd
|
|
85
|
+
* 3. Resolve symlinks (realpath)
|
|
86
|
+
* 4. Check exists + is directory
|
|
87
|
+
* 5. Get and compare git-common-dir for parent and target
|
|
88
|
+
* 6. Get worktree root via --show-toplevel
|
|
89
|
+
* 7. Normalize and compute display label
|
|
90
|
+
*
|
|
91
|
+
* @param pi - Minimal exec interface (pi.exec)
|
|
92
|
+
* @param worktreePath - The raw worktree_path value from the LLM
|
|
93
|
+
* @param parentCwd - The parent session's working directory
|
|
94
|
+
* @returns Validation result with resolved path + label, or error
|
|
95
|
+
*/
|
|
96
|
+
export async function validateWorktreePath(
|
|
97
|
+
pi: PiExec,
|
|
98
|
+
worktreePath: string,
|
|
99
|
+
parentCwd: string,
|
|
100
|
+
): Promise<WorktreeValidationResult> {
|
|
101
|
+
// Step 1: Empty / whitespace → treat as omitted
|
|
102
|
+
if (!worktreePath || worktreePath.trim() === "") {
|
|
103
|
+
return { ok: true };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Step 2: Resolve relative paths against parent cwd
|
|
107
|
+
const resolved = path.isAbsolute(worktreePath)
|
|
108
|
+
? worktreePath
|
|
109
|
+
: path.resolve(parentCwd, worktreePath);
|
|
110
|
+
|
|
111
|
+
// Step 3: Check existence
|
|
112
|
+
if (!existsSync(resolved)) {
|
|
113
|
+
return { ok: false, error: WORKTREE_VALIDATION_ERRORS.PATH_DOES_NOT_EXIST };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Step 4: Check is directory (resolve symlinks first via stat)
|
|
117
|
+
let realPath: string;
|
|
118
|
+
try {
|
|
119
|
+
const stat = statSync(resolved);
|
|
120
|
+
if (!stat.isDirectory()) {
|
|
121
|
+
return { ok: false, error: WORKTREE_VALIDATION_ERRORS.NOT_A_DIRECTORY };
|
|
122
|
+
}
|
|
123
|
+
// Resolve symlinks — use realpathSync to get the canonical path
|
|
124
|
+
realPath = realpathSync(resolved);
|
|
125
|
+
} catch {
|
|
126
|
+
// stat failed — likely a broken symlink or permission issue
|
|
127
|
+
return { ok: false, error: WORKTREE_VALIDATION_ERRORS.PATH_DOES_NOT_EXIST };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Step 5: Get and compare git-common-dir for parent and target
|
|
131
|
+
const parentResult = await getGitCommonDir(pi, parentCwd, WORKTREE_VALIDATION_ERRORS.PARENT_NOT_IN_GIT_REPO);
|
|
132
|
+
if (!parentResult.ok) return parentResult;
|
|
133
|
+
|
|
134
|
+
const targetResult = await getGitCommonDir(pi, realPath, WORKTREE_VALIDATION_ERRORS.NOT_IN_GIT_REPO);
|
|
135
|
+
if (!targetResult.ok) return targetResult;
|
|
136
|
+
|
|
137
|
+
// Compare common dirs — must share the same repo
|
|
138
|
+
const parentCommonAbs = path.isAbsolute(parentResult.commonDir)
|
|
139
|
+
? parentResult.commonDir
|
|
140
|
+
: path.resolve(parentCwd, parentResult.commonDir);
|
|
141
|
+
const targetCommonAbs = path.isAbsolute(targetResult.commonDir)
|
|
142
|
+
? targetResult.commonDir
|
|
143
|
+
: path.resolve(realPath, targetResult.commonDir);
|
|
144
|
+
|
|
145
|
+
if (parentCommonAbs !== targetCommonAbs) {
|
|
146
|
+
return { ok: false, error: WORKTREE_VALIDATION_ERRORS.DIFFERENT_REPO };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Step 6: Get the worktree root via git rev-parse --show-toplevel
|
|
150
|
+
let worktreeRoot: string;
|
|
151
|
+
try {
|
|
152
|
+
const result = await pi.exec("git", ["rev-parse", "--show-toplevel"], { cwd: realPath, timeout: GIT_EXEC_TIMEOUT_MS });
|
|
153
|
+
if (result.code !== 0) {
|
|
154
|
+
worktreeRoot = realPath;
|
|
155
|
+
} else {
|
|
156
|
+
const raw = result.stdout.trim();
|
|
157
|
+
worktreeRoot = raw ? (path.isAbsolute(raw) ? raw : path.resolve(realPath, raw)) : realPath;
|
|
158
|
+
}
|
|
159
|
+
} catch {
|
|
160
|
+
worktreeRoot = realPath;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Step 7: Normalize and compute display label
|
|
164
|
+
const normalizedRealPath = realPath.replace(/\\/g, "/");
|
|
165
|
+
const normalizedRoot = worktreeRoot.replace(/\\/g, "/");
|
|
166
|
+
const label = computeLabel(normalizedRealPath, normalizedRoot);
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
ok: true,
|
|
170
|
+
resolvedPath: normalizedRealPath,
|
|
171
|
+
worktreeRoot: normalizedRoot,
|
|
172
|
+
label,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Compute a short display label for the worktree path.
|
|
178
|
+
*
|
|
179
|
+
* Rules:
|
|
180
|
+
* - Root of worktree → basename (e.g., "/wt/feature" → "feature")
|
|
181
|
+
* - Subdirectory → basename/relative (e.g., "/wt/feature/packages/web" → "feature/packages/web")
|
|
182
|
+
* - Always forward slashes regardless of host OS
|
|
183
|
+
*/
|
|
184
|
+
export function computeLabel(resolvedPath: string, worktreeRoot: string): string {
|
|
185
|
+
// Normalize both paths to forward slashes for cross-platform comparison
|
|
186
|
+
const normalizedResolved = resolvedPath.replace(/\\/g, "/");
|
|
187
|
+
const normalizedRoot = worktreeRoot.replace(/\\/g, "/");
|
|
188
|
+
|
|
189
|
+
const rootBasename = normalizedRoot.split("/").filter(Boolean).pop() ?? "";
|
|
190
|
+
|
|
191
|
+
if (normalizedResolved === normalizedRoot) {
|
|
192
|
+
return rootBasename;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Compute relative path using posix separator
|
|
196
|
+
const relative = path.posix.relative(normalizedRoot, normalizedResolved);
|
|
197
|
+
|
|
198
|
+
return `${rootBasename}/${relative}`;
|
|
199
|
+
}
|