@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.
Files changed (61) hide show
  1. package/README.md +245 -77
  2. package/package.json +16 -13
  3. package/src/abortable.ts +1 -1
  4. package/src/agent-file-toggle.ts +6 -5
  5. package/src/agent-manager.ts +489 -129
  6. package/src/agent-runner.ts +244 -93
  7. package/src/child-context.ts +19 -1
  8. package/src/completion-outbox.ts +22 -3
  9. package/src/cross-extension-rpc.ts +21 -42
  10. package/src/custom-agents.ts +11 -6
  11. package/src/default-agents.ts +21 -21
  12. package/src/detached-results.ts +18 -2
  13. package/src/enabled-models.ts +8 -7
  14. package/src/enterprise-local.ts +7 -6
  15. package/src/error-message.ts +11 -0
  16. package/src/git-path.ts +66 -0
  17. package/src/group-join.ts +4 -10
  18. package/src/home-paths.ts +27 -0
  19. package/src/index.ts +898 -297
  20. package/src/invocation-config.ts +4 -21
  21. package/src/memory.ts +9 -9
  22. package/src/mention-clone.ts +13 -8
  23. package/src/model-resolver.ts +92 -25
  24. package/src/model-scope.ts +2 -1
  25. package/src/model-selection.ts +170 -0
  26. package/src/model-tiers.ts +176 -0
  27. package/src/nested-tools.ts +99 -44
  28. package/src/notification-result.ts +118 -0
  29. package/src/output-file.ts +23 -1
  30. package/src/package-resources.ts +25 -10
  31. package/src/plan-mode-children.ts +176 -0
  32. package/src/process-budget.ts +6 -3
  33. package/src/prompts.ts +7 -2
  34. package/src/quota-admission.ts +35 -13
  35. package/src/resource-loader.ts +65 -0
  36. package/src/schedule.ts +94 -30
  37. package/src/settings.ts +63 -13
  38. package/src/skill-loader.ts +244 -93
  39. package/src/stale-ctx.ts +16 -0
  40. package/src/status-note.ts +28 -5
  41. package/src/types.ts +79 -2
  42. package/src/ui/agent-mention.ts +5 -2
  43. package/src/ui/agent-row.ts +13 -3
  44. package/src/ui/agent-widget.ts +55 -25
  45. package/src/ui/conversation-viewer.ts +10 -5
  46. package/src/ui/fallback-flag.ts +114 -0
  47. package/src/ui/fleet-list.ts +431 -86
  48. package/src/ui/fleet-state.ts +154 -0
  49. package/src/ui/notification-view.ts +22 -3
  50. package/src/ui/workflow-card.ts +6 -9
  51. package/src/ui/workflow-dialog.ts +36 -13
  52. package/src/workflow/host.ts +47 -50
  53. package/src/workflow/journal.ts +13 -1
  54. package/src/workflow/meta.ts +7 -4
  55. package/src/workflow/progress.ts +8 -3
  56. package/src/workflow/runtime.ts +48 -15
  57. package/src/workflow/saved.ts +21 -21
  58. package/src/workflow/task.ts +22 -7
  59. package/src/workflow/tool-description.ts +2 -2
  60. package/src/workflow/worker-source.ts +162 -40
  61. package/src/worktree.ts +309 -19
@@ -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
- const childSessionContext = new AsyncLocalStorage<boolean>();
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
+ }
@@ -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
- build(ids: readonly string[]): OutboxMessage;
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 messages = items.map(item => item.build(item.ids));
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 { type ModelRegistry, resolveModel } from "./model-resolver.js";
18
- import { checkModelScope } from "./model-scope.js";
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 handler's ownership check. Narrowed
43
- * to the two fields `isTopLevelAgent` reads, so the RPC layer keeps its
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
- // Cross-extension RPC callers (e.g. pi-tasks TaskExecute) naturally
116
- // forward serializable values, so options.model can be a string like
117
- // "openai-codex/gpt-5.5". Resolve it to a real Model instance here
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
- let model = override;
135
- if (typeof override === "string") {
136
- const resolved = resolveModel(override, modelRegistry);
137
- if (typeof resolved === "string") {
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 (verdict.kind === "error") throw new Error(verdict.message);
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): void => {
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
  );
@@ -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 { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
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(getAgentDir(), "agents");
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 pkgDirs = opts.packageDirs ?? packageAgentDirs(cwd, { projectTrusted });
103
- const pkgFiles = opts.packageFiles ?? packageAgentFiles(cwd, { projectTrusted });
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", { projectTrusted }));
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 = err instanceof Error ? err.message : String(err);
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;
@@ -6,7 +6,17 @@
6
6
 
7
7
  import type { AgentConfig } from "./types.js";
8
8
 
9
- const READ_ONLY_TOOLS = ["read", "bash", "grep", "find", "ls"];
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
- builtinToolNames: READ_ONLY_TOOLS,
35
- extensions: true,
36
- skills: true,
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 (NOT the bash find command)
58
- - Use the grep tool for content search (NOT bash grep/rg command)
59
- - Use the read tool for reading files (NOT bash cat/head/tail)
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
- builtinToolNames: READ_ONLY_TOOLS,
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 (NOT the bash find command)
110
- - Use the grep tool for content search (NOT bash grep/rg command)
111
- - Use the read tool for reading files (NOT bash cat/head/tail)
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
@@ -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 { AgentRecord } from "./types.js";
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(): boolean;
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 [];
@@ -28,7 +28,7 @@
28
28
 
29
29
  import { existsSync, readFileSync, statSync } from "node:fs";
30
30
  import { join } from "node:path";
31
- import { getAgentDir } from "@earendil-works/pi-coding-agent";
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(getAgentDir(), "settings.json"),
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;
@@ -8,7 +8,8 @@
8
8
 
9
9
  import { existsSync, readFileSync, statSync } from "node:fs";
10
10
  import { join } from "node:path";
11
- import { getAgentDir } from "@earendil-works/pi-coding-agent";
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(getAgentDir(), MANIFEST_NAME);
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 = error instanceof Error ? error.message : String(error);
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
+ }
@@ -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 || group.delivered) return 'pass';
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.groupId);
113
+ this.cleanupGroup(group);
118
114
  }
119
115
 
120
- private cleanupGroup(groupId: string): void {
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
+ }