@nredd/pi-subagents 0.22.0-nredd.7 → 0.22.0-nredd.9
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 +245 -77
- package/package.json +16 -13
- package/src/abortable.ts +1 -1
- package/src/agent-file-toggle.ts +6 -5
- package/src/agent-manager.ts +489 -129
- package/src/agent-runner.ts +244 -93
- package/src/child-context.ts +19 -1
- package/src/completion-outbox.ts +22 -3
- package/src/cross-extension-rpc.ts +21 -42
- package/src/custom-agents.ts +11 -6
- package/src/default-agents.ts +21 -21
- package/src/detached-results.ts +18 -2
- package/src/enabled-models.ts +8 -7
- package/src/enterprise-local.ts +7 -6
- package/src/error-message.ts +11 -0
- package/src/git-path.ts +66 -0
- package/src/group-join.ts +4 -10
- package/src/home-paths.ts +27 -0
- package/src/index.ts +898 -297
- package/src/invocation-config.ts +4 -21
- package/src/memory.ts +9 -9
- package/src/mention-clone.ts +13 -8
- package/src/model-resolver.ts +92 -25
- package/src/model-scope.ts +2 -1
- package/src/model-selection.ts +170 -0
- package/src/model-tiers.ts +176 -0
- package/src/nested-tools.ts +99 -44
- package/src/notification-result.ts +118 -0
- package/src/output-file.ts +23 -1
- package/src/package-resources.ts +25 -10
- package/src/plan-mode-children.ts +176 -0
- package/src/process-budget.ts +6 -3
- package/src/prompts.ts +7 -2
- package/src/quota-admission.ts +35 -13
- package/src/resource-loader.ts +65 -0
- package/src/schedule.ts +94 -30
- package/src/settings.ts +63 -13
- package/src/skill-loader.ts +244 -93
- package/src/stale-ctx.ts +16 -0
- package/src/status-note.ts +28 -5
- package/src/types.ts +79 -2
- package/src/ui/agent-mention.ts +5 -2
- package/src/ui/agent-row.ts +13 -3
- package/src/ui/agent-widget.ts +55 -25
- package/src/ui/conversation-viewer.ts +10 -5
- package/src/ui/fallback-flag.ts +114 -0
- package/src/ui/fleet-list.ts +431 -86
- package/src/ui/fleet-state.ts +154 -0
- package/src/ui/notification-view.ts +22 -3
- package/src/ui/workflow-card.ts +6 -9
- package/src/ui/workflow-dialog.ts +36 -13
- package/src/workflow/host.ts +47 -50
- package/src/workflow/journal.ts +13 -1
- package/src/workflow/meta.ts +7 -4
- package/src/workflow/progress.ts +8 -3
- package/src/workflow/runtime.ts +48 -15
- package/src/workflow/saved.ts +21 -21
- package/src/workflow/task.ts +22 -7
- package/src/workflow/tool-description.ts +2 -2
- package/src/workflow/worker-source.ts +162 -40
- package/src/worktree.ts +309 -19
package/src/child-context.ts
CHANGED
|
@@ -4,7 +4,13 @@ import { AsyncLocalStorage } from "node:async_hooks";
|
|
|
4
4
|
* Marks resource loading/session construction performed for a subagent. This is
|
|
5
5
|
* async-context-local so concurrent top-level extension work is unaffected.
|
|
6
6
|
*/
|
|
7
|
-
|
|
7
|
+
// Pi reloads extension modules with jiti's module cache disabled. The parent
|
|
8
|
+
// runner and the discovered child extension can therefore hold different module
|
|
9
|
+
// instances, but must consult the same async-local session marker.
|
|
10
|
+
const contextKey = Symbol.for("pi-subagents:child-session-context");
|
|
11
|
+
const globals = globalThis as typeof globalThis & { [contextKey]?: AsyncLocalStorage<boolean> };
|
|
12
|
+
const childSessionContext = globals[contextKey] ?? new AsyncLocalStorage<boolean>();
|
|
13
|
+
globals[contextKey] = childSessionContext;
|
|
8
14
|
|
|
9
15
|
export function inChildSessionContext(): boolean {
|
|
10
16
|
return childSessionContext.getStore() === true;
|
|
@@ -13,3 +19,15 @@ export function inChildSessionContext(): boolean {
|
|
|
13
19
|
export function runInChildSessionContext<T>(fn: () => Promise<T>): Promise<T> {
|
|
14
20
|
return childSessionContext.run(true, fn);
|
|
15
21
|
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Make every future `reload()` of a child session's loader run in the child
|
|
25
|
+
* context. A session reload (e.g. an extension calling `ctx.reload()`) calls
|
|
26
|
+
* `loader.reload()` again and re-runs each extension factory, long after the
|
|
27
|
+
* construction-time {@link runInChildSessionContext} has exited.
|
|
28
|
+
*/
|
|
29
|
+
export function bindChildResourceLoader<T extends { reload(): Promise<void> }>(loader: T): T {
|
|
30
|
+
const reload = loader.reload.bind(loader);
|
|
31
|
+
loader.reload = () => runInChildSessionContext(reload);
|
|
32
|
+
return loader;
|
|
33
|
+
}
|
package/src/completion-outbox.ts
CHANGED
|
@@ -27,18 +27,28 @@ export const NOTIFICATION_HOLD_MS = 200;
|
|
|
27
27
|
export interface OutboxMessage {
|
|
28
28
|
content: string;
|
|
29
29
|
details: NotificationDetails;
|
|
30
|
+
/** Agents whose whole result the content carries: sending it reads their result. */
|
|
31
|
+
delivered?: string[];
|
|
32
|
+
/** Agents whose whole result the content carries, failures included: the parent has seen it, though it stays unread in FleetView. */
|
|
33
|
+
noticed?: string[];
|
|
30
34
|
}
|
|
31
35
|
|
|
32
36
|
/**
|
|
33
37
|
* A notification for one or more agents, built when they finished: a resume
|
|
34
38
|
* before the send must not rewrite it. Reading an agent's result cancels its
|
|
35
|
-
* id, and an item left with none is dropped; `build` gets the ids still pending
|
|
39
|
+
* id, and an item left with none is dropped; `build` gets the ids still pending
|
|
40
|
+
* and the result length the batch allots each (see `notification-result.ts`).
|
|
36
41
|
*/
|
|
37
42
|
export interface OutboxItem {
|
|
38
43
|
ids: string[];
|
|
39
|
-
|
|
44
|
+
/** Each agent's full result length, for the batch to split its budget by. */
|
|
45
|
+
resultChars?: Readonly<Record<string, number>>;
|
|
46
|
+
build(ids: readonly string[], caps: Readonly<Record<string, number>>): OutboxMessage;
|
|
40
47
|
}
|
|
41
48
|
|
|
49
|
+
/** Result length per agent of one batch, from each agent's full length. */
|
|
50
|
+
export type ResultAllotment = (chars: Readonly<Record<string, number>>) => Record<string, number>;
|
|
51
|
+
|
|
42
52
|
export class CompletionOutbox {
|
|
43
53
|
private items: OutboxItem[] = [];
|
|
44
54
|
private timer: TimerHandle | undefined;
|
|
@@ -50,6 +60,8 @@ export class CompletionOutbox {
|
|
|
50
60
|
private parentRunning: () => boolean,
|
|
51
61
|
/** Shared with the scheduler by hosts that control time in-process. */
|
|
52
62
|
private clock: Clock = realClock,
|
|
63
|
+
/** Splits a batch's result budget; by default nothing is allotted and each item keeps its own cap. */
|
|
64
|
+
private allot: ResultAllotment = () => ({}),
|
|
53
65
|
) {}
|
|
54
66
|
|
|
55
67
|
/** A newer completion of the same agent (a resume) replaces its pending one. */
|
|
@@ -101,7 +113,10 @@ export class CompletionOutbox {
|
|
|
101
113
|
this.timer = undefined;
|
|
102
114
|
const items = this.items;
|
|
103
115
|
this.items = [];
|
|
104
|
-
const
|
|
116
|
+
const chars: Record<string, number> = {};
|
|
117
|
+
for (const item of items) for (const id of item.ids) if (item.resultChars?.[id] !== undefined) chars[id] = item.resultChars[id];
|
|
118
|
+
const caps = this.allot(chars);
|
|
119
|
+
const messages = items.map(item => item.build(item.ids, caps));
|
|
105
120
|
return messages.length > 0 ? combine(messages) : undefined;
|
|
106
121
|
}
|
|
107
122
|
|
|
@@ -118,8 +133,12 @@ export class CompletionOutbox {
|
|
|
118
133
|
function combine(messages: OutboxMessage[]): OutboxMessage {
|
|
119
134
|
if (messages.length === 1) return messages[0];
|
|
120
135
|
const [first, ...rest] = messages.flatMap(({ details: { others, ...own } }) => [own, ...(others ?? [])]);
|
|
136
|
+
const delivered = messages.flatMap(m => m.delivered ?? []);
|
|
137
|
+
const noticed = messages.flatMap(m => m.noticed ?? []);
|
|
121
138
|
return {
|
|
122
139
|
content: `${messages.length} background tasks finished.\n\n${messages.map(m => m.content).join("\n\n")}`,
|
|
123
140
|
details: { ...first, others: rest },
|
|
141
|
+
...(delivered.length > 0 ? { delivered } : {}),
|
|
142
|
+
...(noticed.length > 0 ? { noticed } : {}),
|
|
124
143
|
};
|
|
125
144
|
}
|
|
@@ -14,8 +14,9 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { isTopLevelAgent } from "./agent-manager.js";
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
17
|
+
import type { ModelRegistry } from "./model-resolver.js";
|
|
18
|
+
import { selectModel } from "./model-selection.js";
|
|
19
|
+
import { planSteerBlocked, planSteerRefusal } from "./plan-mode-children.js";
|
|
19
20
|
import type { AgentRecord } from "./types.js";
|
|
20
21
|
|
|
21
22
|
/** Minimal event bus interface needed by the RPC handlers. */
|
|
@@ -39,11 +40,11 @@ export interface SpawnCapable {
|
|
|
39
40
|
awaitStartup(id: string): Promise<void>;
|
|
40
41
|
abort(id: string): boolean;
|
|
41
42
|
/**
|
|
42
|
-
* The record behind an id, for the stop
|
|
43
|
-
*
|
|
44
|
-
* deliberately shallow view of the manager.
|
|
43
|
+
* The record behind an id, for the stop and steer handlers' ownership check
|
|
44
|
+
* and the steer handler's Plan check. Narrowed to the fields those read, so
|
|
45
|
+
* the RPC layer keeps its deliberately shallow view of the manager.
|
|
45
46
|
*/
|
|
46
|
-
getRecord(id: string): Pick<AgentRecord, "parentAgentId" | "workflowId"> | undefined;
|
|
47
|
+
getRecord(id: string): Pick<AgentRecord, "parentAgentId" | "workflowId" | "status" | "planGate"> | undefined;
|
|
47
48
|
/**
|
|
48
49
|
* Mark a settled agent's result as read by the caller, suppressing the
|
|
49
50
|
* completion notification — what `get_subagent_result` does when it returns
|
|
@@ -58,6 +59,7 @@ export interface SpawnCapable {
|
|
|
58
59
|
}
|
|
59
60
|
|
|
60
61
|
export interface RpcDeps {
|
|
62
|
+
homeDir?: string;
|
|
61
63
|
events: EventBus;
|
|
62
64
|
pi: unknown; // passed through to manager.spawn
|
|
63
65
|
getCtx: () => unknown | undefined; // returns current ExtensionContext
|
|
@@ -112,13 +114,9 @@ export function registerRpcHandlers(deps: RpcDeps): RpcHandle {
|
|
|
112
114
|
const ctx = getCtx();
|
|
113
115
|
if (!ctx) throw new Error("No active session");
|
|
114
116
|
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
|
|
118
|
-
// — same pattern the scheduler path already uses — so the spawned
|
|
119
|
-
// agent's auth lookup doesn't crash with "No API key found for
|
|
120
|
-
// undefined".
|
|
121
|
-
let normalizedOptions = options ?? {};
|
|
117
|
+
// Validate serializable overrides through the shared selector. Preserve
|
|
118
|
+
// the raw request for spawn, which records its source and outcome.
|
|
119
|
+
const normalizedOptions = options ?? {};
|
|
122
120
|
// `!= null` on purpose: a JSON-forwarding caller can serialize an unset
|
|
123
121
|
// field as null, and the runner reads `options.model ?? default`, so null
|
|
124
122
|
// means "inherit" — not an override to resolve or scope-check.
|
|
@@ -131,34 +129,12 @@ export function registerRpcHandlers(deps: RpcDeps): RpcHandle {
|
|
|
131
129
|
if (!modelRegistry) {
|
|
132
130
|
throw new Error(`Model override "${label}" provided but ctx.modelRegistry is unavailable`);
|
|
133
131
|
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
// resolveModel returns a human-readable error string when the
|
|
139
|
-
// input doesn't match any available model. Surface it instead of
|
|
140
|
-
// silently falling back so the caller sees the auth/typo issue.
|
|
141
|
-
throw new Error(resolved);
|
|
142
|
-
}
|
|
143
|
-
model = resolved;
|
|
144
|
-
normalizedOptions = { ...normalizedOptions, model: resolved };
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// A model on the RPC payload is an orchestrator-level choice, exactly
|
|
148
|
-
// like Agent({ model }) — so it gets the Agent tool's hard error, never
|
|
149
|
-
// the frontmatter warn (#240). The check reads the RESOLVED model:
|
|
150
|
-
// resolveModel is fuzzy, so a bare "sonnet" can land on a provider the
|
|
151
|
-
// caller never named. Frontmatter-pinned and parent-inherited models are
|
|
152
|
-
// resolved later, in agent-runner, and keep warn-and-proceed.
|
|
153
|
-
const verdict = checkModelScope({
|
|
154
|
-
model,
|
|
155
|
-
cwd: cwd ?? process.cwd(),
|
|
156
|
-
modelRegistry,
|
|
157
|
-
callerSupplied: true,
|
|
158
|
-
agentLabel: type,
|
|
159
|
-
modelInput: label,
|
|
132
|
+
const selection = selectModel({
|
|
133
|
+
homeDir: deps.homeDir,
|
|
134
|
+
agentType: type, paramModel: override, parentModel: undefined,
|
|
135
|
+
registry: modelRegistry, cwd: cwd ?? process.cwd(),
|
|
160
136
|
});
|
|
161
|
-
if (
|
|
137
|
+
if (typeof selection === "string") throw new Error(selection);
|
|
162
138
|
}
|
|
163
139
|
|
|
164
140
|
const id = manager.spawn(pi, ctx, type, prompt, normalizedOptions);
|
|
@@ -177,10 +153,11 @@ export function registerRpcHandlers(deps: RpcDeps): RpcHandle {
|
|
|
177
153
|
// a live hole: no RPC hands out agent ids, so a caller has no ordinary way to
|
|
178
154
|
// name one it does not own — but the guard is cheap and the id may leak some
|
|
179
155
|
// other way. Same refuse-what-we-should-not-touch stance as `consume` below.
|
|
180
|
-
const assertOwned = (agentId: string)
|
|
156
|
+
const assertOwned = (agentId: string) => {
|
|
181
157
|
const record = manager.getRecord(agentId);
|
|
182
158
|
if (!record) throw new Error("Agent not found");
|
|
183
159
|
if (!isTopLevelAgent(record)) throw new Error("Agent is owned by another agent or workflow");
|
|
160
|
+
return record;
|
|
184
161
|
};
|
|
185
162
|
|
|
186
163
|
const unsubStop = handleRpc<{ requestId: string; agentId: string }>(
|
|
@@ -212,7 +189,9 @@ export function registerRpcHandlers(deps: RpcDeps): RpcHandle {
|
|
|
212
189
|
const unsubSteer = handleRpc<{ requestId: string; agentId: string; message: unknown }>(
|
|
213
190
|
events, "subagents:rpc:steer", ({ agentId, message }) => {
|
|
214
191
|
if (typeof message !== "string" || message.trim().length === 0) throw new Error("Steer message must be a non-empty string");
|
|
215
|
-
assertOwned(agentId);
|
|
192
|
+
const record = assertOwned(agentId);
|
|
193
|
+
// Same refusal as `steer_subagent`, so another extension cannot steer a pre-Plan agent into edits.
|
|
194
|
+
if (planSteerBlocked(record, events)) throw new Error(planSteerRefusal(agentId));
|
|
216
195
|
if (!manager.steer(agentId, message)) throw new Error("Agent is not running");
|
|
217
196
|
},
|
|
218
197
|
);
|
package/src/custom-agents.ts
CHANGED
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
|
|
5
5
|
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
6
6
|
import { basename, join } from "node:path";
|
|
7
|
-
import {
|
|
7
|
+
import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
|
|
8
8
|
import { BUILTIN_TOOL_NAMES } from "./agent-types.js";
|
|
9
|
+
import { errorMessage } from "./error-message.js";
|
|
10
|
+
import { resolveAgentDir } from "./home-paths.js";
|
|
9
11
|
import {
|
|
10
12
|
isPackageResourceExcluded,
|
|
11
13
|
isProjectTrusted,
|
|
@@ -55,6 +57,8 @@ export interface LoadCustomAgentsOptions {
|
|
|
55
57
|
* answer is recorded.
|
|
56
58
|
*/
|
|
57
59
|
projectTrusted?: boolean;
|
|
60
|
+
/** Home directory for the default global agent directory. Defaults at call time. */
|
|
61
|
+
homeDir?: string;
|
|
58
62
|
}
|
|
59
63
|
|
|
60
64
|
/**
|
|
@@ -94,16 +98,17 @@ export function loadCustomAgents(
|
|
|
94
98
|
strict = false,
|
|
95
99
|
opts: LoadCustomAgentsOptions = {},
|
|
96
100
|
): Map<string, AgentConfig> {
|
|
97
|
-
const globalDir = join(
|
|
101
|
+
const globalDir = join(resolveAgentDir(opts.homeDir), "agents");
|
|
98
102
|
const workspaceProjectDir = join(cwd, ".agents", "agents");
|
|
99
103
|
const projectDir = join(cwd, ".pi", "agents");
|
|
100
104
|
|
|
101
105
|
const projectTrusted = opts.projectTrusted ?? isProjectTrusted();
|
|
102
|
-
const
|
|
103
|
-
const
|
|
106
|
+
const discovery = { projectTrusted, homeDir: opts.homeDir };
|
|
107
|
+
const pkgDirs = opts.packageDirs ?? packageAgentDirs(cwd, discovery);
|
|
108
|
+
const pkgFiles = opts.packageFiles ?? packageAgentFiles(cwd, discovery);
|
|
104
109
|
// `!./agents/wip.md` alongside `./agents` excludes one file out of a directory
|
|
105
110
|
// that is only enumerated here, so the check has to run per file.
|
|
106
|
-
const excluded: Excluder = opts.packageExcluded ?? (path => isPackageResourceExcluded(path, cwd, "agents",
|
|
111
|
+
const excluded: Excluder = opts.packageExcluded ?? (path => isPackageResourceExcluded(path, cwd, "agents", discovery));
|
|
107
112
|
|
|
108
113
|
const agents = new Map<string, AgentConfig>();
|
|
109
114
|
for (const dir of pkgDirs) loadFromDir(dir, agents, "package", strict, excluded); // lowest priority
|
|
@@ -286,7 +291,7 @@ function readAgentFile(path: string, strict: boolean): { frontmatter: Record<str
|
|
|
286
291
|
try {
|
|
287
292
|
return parseAgentFrontmatter<Record<string, unknown>>(readFileSync(path, "utf-8"));
|
|
288
293
|
} catch (err) {
|
|
289
|
-
const reason =
|
|
294
|
+
const reason = errorMessage(err);
|
|
290
295
|
if (strict) throw new Error(`${path}: ${reason}`);
|
|
291
296
|
warnIfNew(`Skipping agent file ${path}: ${reason}`);
|
|
292
297
|
return undefined;
|
package/src/default-agents.ts
CHANGED
|
@@ -6,7 +6,17 @@
|
|
|
6
6
|
|
|
7
7
|
import type { AgentConfig } from "./types.js";
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
/**
|
|
10
|
+
* Tool surface shared by Explore and Plan. `bash` is left out because a prompt cannot
|
|
11
|
+
* make an unrestricted shell read-only, and extensions are off because extension tools
|
|
12
|
+
* can run commands or write. A `.pi/agents/<Name>.md` override can opt back in with an
|
|
13
|
+
* explicit `extensions:` list.
|
|
14
|
+
*/
|
|
15
|
+
const READ_ONLY_SURFACE: Pick<AgentConfig, "builtinToolNames" | "extensions" | "skills"> = {
|
|
16
|
+
builtinToolNames: ["read", "grep", "find", "ls"],
|
|
17
|
+
extensions: false,
|
|
18
|
+
skills: true,
|
|
19
|
+
};
|
|
10
20
|
|
|
11
21
|
export const DEFAULT_AGENTS: Map<string, AgentConfig> = new Map([
|
|
12
22
|
[
|
|
@@ -31,13 +41,9 @@ export const DEFAULT_AGENTS: Map<string, AgentConfig> = new Map([
|
|
|
31
41
|
name: "Explore",
|
|
32
42
|
displayName: "Explore",
|
|
33
43
|
description: "Fast read-only search agent for locating code. Use it to find files by pattern (eg. \"src/components/**/*.tsx\"), grep for symbols or keywords (eg. \"API endpoints\"), or answer \"where is X defined / which files reference Y.\" Do NOT use it for code review, design-doc auditing, cross-file consistency checks, or open-ended analysis — it reads excerpts rather than whole files and will miss content past its read window. When calling, specify search breadth: \"quick\" for a single targeted lookup, \"medium\" for moderate exploration, or \"very thorough\" to search across multiple locations and naming conventions.",
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
// Fast/cheap portable default for read-only search. A target-local alias
|
|
38
|
-
// can select its canonical provider; otherwise fuzzy resolution finds a
|
|
39
|
-
// matching enabled model under any provider.
|
|
40
|
-
model: "claude-haiku-4-5",
|
|
44
|
+
...READ_ONLY_SURFACE,
|
|
45
|
+
// Bind the role per machine, not to a provider or model generation.
|
|
46
|
+
model: "@cheap",
|
|
41
47
|
systemPrompt: `# CRITICAL: READ-ONLY MODE - NO FILE MODIFICATIONS
|
|
42
48
|
You are a file search specialist. You excel at thoroughly navigating and exploring codebases.
|
|
43
49
|
Your role is EXCLUSIVELY to search and analyze existing code. You do NOT have access to file editing tools.
|
|
@@ -51,13 +57,10 @@ You are STRICTLY PROHIBITED from:
|
|
|
51
57
|
- Using redirect operators (>, >>, |) or heredocs to write to files
|
|
52
58
|
- Running ANY commands that change system state
|
|
53
59
|
|
|
54
|
-
Use Bash ONLY for read-only operations: ls, git status, git log, git diff, find, cat, head, tail.
|
|
55
|
-
|
|
56
60
|
# Tool Usage
|
|
57
|
-
- Use the find tool for file pattern matching
|
|
58
|
-
- Use the grep tool for content search
|
|
59
|
-
- Use the read tool for reading files
|
|
60
|
-
- Use Bash ONLY for read-only operations
|
|
61
|
+
- Use the find tool for file pattern matching
|
|
62
|
+
- Use the grep tool for content search
|
|
63
|
+
- Use the read tool for reading files
|
|
61
64
|
- Make independent tool calls in parallel for efficiency
|
|
62
65
|
- Adapt search approach based on thoroughness level specified
|
|
63
66
|
|
|
@@ -76,9 +79,7 @@ Use Bash ONLY for read-only operations: ls, git status, git log, git diff, find,
|
|
|
76
79
|
name: "Plan",
|
|
77
80
|
displayName: "Plan",
|
|
78
81
|
description: "Software architect agent for designing implementation plans. Use this when you need to plan the implementation strategy for a task. Returns step-by-step plans, identifies critical files, and considers architectural trade-offs.",
|
|
79
|
-
|
|
80
|
-
extensions: true,
|
|
81
|
-
skills: true,
|
|
82
|
+
...READ_ONLY_SURFACE,
|
|
82
83
|
systemPrompt: `# CRITICAL: READ-ONLY MODE - NO FILE MODIFICATIONS
|
|
83
84
|
You are a software architect and planning specialist.
|
|
84
85
|
Your role is EXCLUSIVELY to explore the codebase and design implementation plans.
|
|
@@ -106,10 +107,9 @@ You are STRICTLY PROHIBITED from:
|
|
|
106
107
|
- Follow existing patterns where appropriate
|
|
107
108
|
|
|
108
109
|
# Tool Usage
|
|
109
|
-
- Use the find tool for file pattern matching
|
|
110
|
-
- Use the grep tool for content search
|
|
111
|
-
- Use the read tool for reading files
|
|
112
|
-
- Use Bash ONLY for read-only operations
|
|
110
|
+
- Use the find tool for file pattern matching
|
|
111
|
+
- Use the grep tool for content search
|
|
112
|
+
- Use the read tool for reading files
|
|
113
113
|
|
|
114
114
|
# Output Format
|
|
115
115
|
- Use absolute file paths
|
package/src/detached-results.ts
CHANGED
|
@@ -13,7 +13,9 @@ import { createHash } from "node:crypto";
|
|
|
13
13
|
import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
14
14
|
import { tmpdir } from "node:os";
|
|
15
15
|
import { join } from "node:path";
|
|
16
|
-
import type {
|
|
16
|
+
import type { EvictedNote } from "./agent-manager.js";
|
|
17
|
+
import type { NoticeParts } from "./notification-result.js";
|
|
18
|
+
import type { AgentRecord, AgentTombstone } from "./types.js";
|
|
17
19
|
import type { AgentActivity } from "./ui/agent-widget.js";
|
|
18
20
|
|
|
19
21
|
/** One finished agent, in the shape `session_start` needs to re-announce it. */
|
|
@@ -25,6 +27,14 @@ export interface DetachedResult {
|
|
|
25
27
|
details: unknown;
|
|
26
28
|
/** History entry for a result that finished detached; absent if already appended. */
|
|
27
29
|
record?: Record<string, unknown>;
|
|
30
|
+
/** The `subagents:completed`/`subagents:failed` event the live path would have emitted, replayed on delivery. */
|
|
31
|
+
lifecycle?: { channel: "subagents:completed" | "subagents:failed"; data: Record<string, unknown> };
|
|
32
|
+
/** What the reopened session keeps so these agents' ids still answer after their records are gone. */
|
|
33
|
+
evicted?: EvictedNote[];
|
|
34
|
+
/** Reopenable sessions among them, so a steer or resume of these ids continues the saved conversation. */
|
|
35
|
+
tombstones?: AgentTombstone[];
|
|
36
|
+
/** A full-result notice's parts, so delivery clips it against its batch's budget; `content` is the fallback. */
|
|
37
|
+
notice?: NoticeParts & { footer: string };
|
|
28
38
|
}
|
|
29
39
|
|
|
30
40
|
const SINK_KEY = Symbol.for("pi-subagents:detached-sink");
|
|
@@ -96,7 +106,8 @@ export function watchDetached(sessionFile: string, onSpooled: () => void): () =>
|
|
|
96
106
|
export interface RunningDetached {
|
|
97
107
|
record: AgentRecord;
|
|
98
108
|
activity?: AgentActivity;
|
|
99
|
-
abort
|
|
109
|
+
/** `by` as for `AgentManager.abort`: `"parent"` for `stop_subagent`, omitted for a human. */
|
|
110
|
+
abort(by?: AgentRecord["stoppedBy"]): boolean;
|
|
100
111
|
steer(message: string): boolean;
|
|
101
112
|
}
|
|
102
113
|
|
|
@@ -127,6 +138,11 @@ export function unregisterRunningDetached(sessionFile: string, id: string): void
|
|
|
127
138
|
if (byId?.size === 0) running().delete(sessionFile);
|
|
128
139
|
}
|
|
129
140
|
|
|
141
|
+
/** Every orphan still running in the process with the session file it is registered (and wakes) under. */
|
|
142
|
+
export function listAllRunningDetached(): Array<{ sessionFile: string; agent: RunningDetached }> {
|
|
143
|
+
return [...running()].flatMap(([sessionFile, byId]) => [...byId.values()].map((agent) => ({ sessionFile, agent })));
|
|
144
|
+
}
|
|
145
|
+
|
|
130
146
|
/** Orphans still running for `sessionFile`, in registration order. */
|
|
131
147
|
export function listRunningDetached(sessionFile: string | undefined): RunningDetached[] {
|
|
132
148
|
if (sessionFile === undefined) return [];
|
package/src/enabled-models.ts
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
|
|
29
29
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
30
30
|
import { join } from "node:path";
|
|
31
|
-
import {
|
|
31
|
+
import { resolveAgentDir } from "./home-paths.js";
|
|
32
32
|
import type { ModelEntry } from "./model-resolver.js";
|
|
33
33
|
|
|
34
34
|
/** Minimal registry shape — only the methods resolveEnabledModels actually calls. */
|
|
@@ -38,10 +38,10 @@ export interface ModelRegistryRef {
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
/** Paths to pi's settings.json files: [project, global] (project takes precedence). */
|
|
41
|
-
function settingsPaths(cwd: string): [project: string, global: string] {
|
|
41
|
+
function settingsPaths(cwd: string, homeDir?: string): [project: string, global: string] {
|
|
42
42
|
return [
|
|
43
43
|
join(cwd, ".pi", "settings.json"),
|
|
44
|
-
join(
|
|
44
|
+
join(resolveAgentDir(homeDir), "settings.json"),
|
|
45
45
|
];
|
|
46
46
|
}
|
|
47
47
|
|
|
@@ -63,8 +63,8 @@ function readField(path: string): string[] | undefined {
|
|
|
63
63
|
* (and matches our own loadSettings precedence in src/settings.ts).
|
|
64
64
|
* Returns undefined when neither file has the field.
|
|
65
65
|
*/
|
|
66
|
-
export function readEnabledModels(cwd: string): string[] | undefined {
|
|
67
|
-
const [project, global] = settingsPaths(cwd);
|
|
66
|
+
export function readEnabledModels(cwd: string, homeDir?: string): string[] | undefined {
|
|
67
|
+
const [project, global] = settingsPaths(cwd, homeDir);
|
|
68
68
|
return readField(project) ?? readField(global);
|
|
69
69
|
}
|
|
70
70
|
|
|
@@ -102,11 +102,12 @@ export function resolveEnabledModels(
|
|
|
102
102
|
patterns: string[] | undefined,
|
|
103
103
|
registry: ModelRegistryRef,
|
|
104
104
|
cwd: string = process.cwd(),
|
|
105
|
+
homeDir?: string,
|
|
105
106
|
): Set<string> | undefined {
|
|
106
107
|
// Fast path: check cache (stat both project and global settings.json files)
|
|
107
108
|
const patternsKey = JSON.stringify(patterns);
|
|
108
|
-
const [project, global] = settingsPaths(cwd);
|
|
109
|
-
const fileHash = `${hashOf(project)};${hashOf(global)}`;
|
|
109
|
+
const [project, global] = settingsPaths(cwd, homeDir);
|
|
110
|
+
const fileHash = `${project}:${hashOf(project)};${global}:${hashOf(global)}`;
|
|
110
111
|
|
|
111
112
|
if (fileHash === cachedHash && patternsKey === cachedPatternsKey) {
|
|
112
113
|
return cachedAllowed;
|
package/src/enterprise-local.ts
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
|
|
9
9
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
10
10
|
import { join } from "node:path";
|
|
11
|
-
import {
|
|
11
|
+
import { errorMessage } from "./error-message.js";
|
|
12
|
+
import { resolveAgentDir } from "./home-paths.js";
|
|
12
13
|
|
|
13
14
|
const MANIFEST_VERSION = 1;
|
|
14
15
|
const MANIFEST_NAME = "enterprise.local.json";
|
|
@@ -21,16 +22,16 @@ export interface EnterpriseLocalManifest {
|
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
/** Return the target-local manifest path without reading it. */
|
|
24
|
-
export function enterpriseLocalPath(): string {
|
|
25
|
-
return join(
|
|
25
|
+
export function enterpriseLocalPath(homeDir?: string): string {
|
|
26
|
+
return join(resolveAgentDir(homeDir), MANIFEST_NAME);
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
/**
|
|
29
30
|
* Read and minimally validate the machine-local manifest. It never exposes its
|
|
30
31
|
* contents in diagnostics: providers can include command-backed credentials.
|
|
31
32
|
*/
|
|
32
|
-
export function readEnterpriseLocalManifest(): EnterpriseLocalManifest | undefined {
|
|
33
|
-
const path = enterpriseLocalPath();
|
|
33
|
+
export function readEnterpriseLocalManifest(homeDir?: string): EnterpriseLocalManifest | undefined {
|
|
34
|
+
const path = enterpriseLocalPath(homeDir);
|
|
34
35
|
if (!existsSync(path)) return undefined;
|
|
35
36
|
|
|
36
37
|
try {
|
|
@@ -59,7 +60,7 @@ export function readEnterpriseLocalManifest(): EnterpriseLocalManifest | undefin
|
|
|
59
60
|
}
|
|
60
61
|
return manifest as unknown as EnterpriseLocalManifest;
|
|
61
62
|
} catch (error) {
|
|
62
|
-
const reason =
|
|
63
|
+
const reason = errorMessage(error);
|
|
63
64
|
console.warn(`[pi-subagents] Ignoring invalid ${path}: ${reason}`);
|
|
64
65
|
return undefined;
|
|
65
66
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* error-message.ts — the text a caught value reports.
|
|
3
|
+
*
|
|
4
|
+
* `catch` binds `unknown`: an `Error` gives its message, and anything else a
|
|
5
|
+
* caller threw (a string, a rejected non-Error) is stringified as-is.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** The message of `error` when it is an `Error`, else `String(error)`. */
|
|
9
|
+
export function errorMessage(error: unknown): string {
|
|
10
|
+
return error instanceof Error ? error.message : String(error);
|
|
11
|
+
}
|
package/src/git-path.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* git-path.ts -- decode the C-style quoted paths git prints without `-z`.
|
|
3
|
+
*
|
|
4
|
+
* `pi.exec` decodes each stdout chunk as UTF-8 on its own, so a multibyte
|
|
5
|
+
* character split across two chunks becomes U+FFFD. With `core.quotePath=true`
|
|
6
|
+
* git escapes every non-ASCII byte (and control characters, `"` and `\`) as
|
|
7
|
+
* `"..."` with octal escapes, so a listing read without `-z` is pure ASCII and
|
|
8
|
+
* survives any chunking; this module turns it back into the real path.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const SIMPLE_ESCAPES: Record<string, number> = {
|
|
12
|
+
a: 0x07,
|
|
13
|
+
b: 0x08,
|
|
14
|
+
f: 0x0c,
|
|
15
|
+
n: 0x0a,
|
|
16
|
+
r: 0x0d,
|
|
17
|
+
t: 0x09,
|
|
18
|
+
v: 0x0b,
|
|
19
|
+
'"': 0x22,
|
|
20
|
+
"\\": 0x5c,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const UTF8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
24
|
+
|
|
25
|
+
/** Args that make `git` quote every non-ASCII path byte; put them before the subcommand. */
|
|
26
|
+
export const QUOTE_PATH_ARGS = ["-c", "core.quotePath=true"];
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Decode one path as git prints it: a plain name is returned as is, a `"..."` name is
|
|
30
|
+
* unescaped to bytes and decoded as UTF-8. Throws on anything that cannot name a file
|
|
31
|
+
* as a JS string: raw non-ASCII (the listing was not quoted, so it may be mis-decoded),
|
|
32
|
+
* a malformed escape, or bytes that are not valid UTF-8.
|
|
33
|
+
*/
|
|
34
|
+
export function unquoteGitPath(text: string): string {
|
|
35
|
+
const quoted = text.length >= 2 && text.startsWith('"') && text.endsWith('"');
|
|
36
|
+
const body = quoted ? text.slice(1, -1) : text;
|
|
37
|
+
for (let i = 0; i < body.length; i++) {
|
|
38
|
+
if (body.charCodeAt(i) > 0x7f) throw new Error(`git printed a non-ASCII path unquoted (${JSON.stringify(text)}); it may be mis-decoded`);
|
|
39
|
+
}
|
|
40
|
+
if (!quoted) return text;
|
|
41
|
+
const bytes: number[] = [];
|
|
42
|
+
for (let i = 0; i < body.length; i++) {
|
|
43
|
+
const char = body.charAt(i);
|
|
44
|
+
if (char === '"') throw new Error(`malformed quoted git path ${JSON.stringify(text)}`);
|
|
45
|
+
if (char !== "\\") {
|
|
46
|
+
bytes.push(body.charCodeAt(i));
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const next = body.charAt(i + 1);
|
|
50
|
+
const octal = /^[0-3][0-7]{2}$/.test(body.slice(i + 1, i + 4)) ? body.slice(i + 1, i + 4) : undefined;
|
|
51
|
+
if (octal) {
|
|
52
|
+
bytes.push(Number.parseInt(octal, 8));
|
|
53
|
+
i += 3;
|
|
54
|
+
} else if (next in SIMPLE_ESCAPES) {
|
|
55
|
+
bytes.push(SIMPLE_ESCAPES[next] as number);
|
|
56
|
+
i += 1;
|
|
57
|
+
} else {
|
|
58
|
+
throw new Error(`malformed escape in quoted git path ${JSON.stringify(text)}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
return UTF8.decode(Uint8Array.from(bytes));
|
|
63
|
+
} catch {
|
|
64
|
+
throw new Error(`git path ${JSON.stringify(text)} is not valid UTF-8 and cannot be inspected`);
|
|
65
|
+
}
|
|
66
|
+
}
|
package/src/group-join.ts
CHANGED
|
@@ -15,7 +15,6 @@ interface AgentGroup {
|
|
|
15
15
|
agentIds: Set<string>;
|
|
16
16
|
completedRecords: Map<string, AgentRecord>;
|
|
17
17
|
timeoutHandle?: ReturnType<typeof setTimeout>;
|
|
18
|
-
delivered: boolean;
|
|
19
18
|
/** Shorter timeout for stragglers after a partial delivery. */
|
|
20
19
|
isStraggler: boolean;
|
|
21
20
|
}
|
|
@@ -40,7 +39,6 @@ export class GroupJoinManager {
|
|
|
40
39
|
groupId,
|
|
41
40
|
agentIds: new Set(agentIds),
|
|
42
41
|
completedRecords: new Map(),
|
|
43
|
-
delivered: false,
|
|
44
42
|
isStraggler: false,
|
|
45
43
|
};
|
|
46
44
|
this.groups.set(groupId, group);
|
|
@@ -61,7 +59,7 @@ export class GroupJoinManager {
|
|
|
61
59
|
if (!groupId) return 'pass';
|
|
62
60
|
|
|
63
61
|
const group = this.groups.get(groupId);
|
|
64
|
-
if (!group
|
|
62
|
+
if (!group) return 'pass';
|
|
65
63
|
|
|
66
64
|
group.completedRecords.set(record.id, record);
|
|
67
65
|
|
|
@@ -83,7 +81,6 @@ export class GroupJoinManager {
|
|
|
83
81
|
}
|
|
84
82
|
|
|
85
83
|
private onTimeout(group: AgentGroup): void {
|
|
86
|
-
if (group.delivered) return;
|
|
87
84
|
group.timeoutHandle = undefined;
|
|
88
85
|
|
|
89
86
|
// Partial delivery — some agents still running
|
|
@@ -112,18 +109,15 @@ export class GroupJoinManager {
|
|
|
112
109
|
clearTimeout(group.timeoutHandle);
|
|
113
110
|
group.timeoutHandle = undefined;
|
|
114
111
|
}
|
|
115
|
-
group.delivered = true;
|
|
116
112
|
this.deliverCb([...group.completedRecords.values()], partial);
|
|
117
|
-
this.cleanupGroup(group
|
|
113
|
+
this.cleanupGroup(group);
|
|
118
114
|
}
|
|
119
115
|
|
|
120
|
-
private cleanupGroup(
|
|
121
|
-
const group = this.groups.get(groupId);
|
|
122
|
-
if (!group) return;
|
|
116
|
+
private cleanupGroup(group: AgentGroup): void {
|
|
123
117
|
for (const id of group.agentIds) {
|
|
124
118
|
this.agentToGroup.delete(id);
|
|
125
119
|
}
|
|
126
|
-
this.groups.delete(groupId);
|
|
120
|
+
this.groups.delete(group.groupId);
|
|
127
121
|
}
|
|
128
122
|
|
|
129
123
|
/** Check if an agent is in a group. */
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** User-scoped paths. Omitted homes keep Pi's call-time runtime defaults. */
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
|
|
7
|
+
/** Mirror Pi's agent-dir override semantics without changing the process environment. */
|
|
8
|
+
export function resolveAgentDir(homeDir?: string): string {
|
|
9
|
+
if (homeDir === undefined) return getAgentDir();
|
|
10
|
+
const configured = process.env.PI_CODING_AGENT_DIR;
|
|
11
|
+
if (!configured) return join(homeDir, ".pi", "agent");
|
|
12
|
+
return normalizeHomePath(configured, homeDir);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Pi's normalizePath is not exported by its public package entry point. */
|
|
16
|
+
export function normalizeHomePath(path: string, homeDir = homedir()): string {
|
|
17
|
+
// Match Pi's native normalization of Git Bash, WSL and Cygwin drive paths.
|
|
18
|
+
if (process.platform === "win32" && path.startsWith("/") && !path.startsWith("//") && !path.includes("\\")) {
|
|
19
|
+
const [, letter, remainder] = path.match(/^\/(?:mnt\/|cygdrive\/)?([a-z])(?:\/(.*))?$/i) ?? [];
|
|
20
|
+
if (letter) path = `${letter.toUpperCase()}:\\${remainder?.replaceAll("/", "\\") ?? ""}`;
|
|
21
|
+
}
|
|
22
|
+
if (path === "~") return homeDir;
|
|
23
|
+
if (path.startsWith("~/") || (process.platform === "win32" && path.startsWith("~\\"))) {
|
|
24
|
+
return join(homeDir, path.slice(2));
|
|
25
|
+
}
|
|
26
|
+
return path.startsWith("file://") ? fileURLToPath(path) : path;
|
|
27
|
+
}
|