@nredd/pi-subagents 0.22.0-nredd.8 → 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 (50) hide show
  1. package/README.md +136 -55
  2. package/package.json +14 -11
  3. package/src/abortable.ts +1 -1
  4. package/src/agent-file-toggle.ts +0 -1
  5. package/src/agent-manager.ts +360 -103
  6. package/src/agent-runner.ts +121 -63
  7. package/src/child-context.ts +19 -1
  8. package/src/completion-outbox.ts +22 -3
  9. package/src/cross-extension-rpc.ts +10 -6
  10. package/src/custom-agents.ts +2 -1
  11. package/src/default-agents.ts +19 -17
  12. package/src/detached-results.ts +18 -2
  13. package/src/enterprise-local.ts +2 -1
  14. package/src/error-message.ts +11 -0
  15. package/src/git-path.ts +66 -0
  16. package/src/group-join.ts +4 -10
  17. package/src/index.ts +731 -231
  18. package/src/invocation-config.ts +1 -5
  19. package/src/mention-clone.ts +4 -3
  20. package/src/model-resolver.ts +51 -14
  21. package/src/model-selection.ts +17 -6
  22. package/src/model-tiers.ts +5 -0
  23. package/src/nested-tools.ts +80 -21
  24. package/src/notification-result.ts +118 -0
  25. package/src/plan-mode-children.ts +176 -0
  26. package/src/prompts.ts +7 -2
  27. package/src/quota-admission.ts +16 -6
  28. package/src/resource-loader.ts +20 -6
  29. package/src/schedule.ts +65 -19
  30. package/src/settings.ts +21 -1
  31. package/src/skill-loader.ts +244 -94
  32. package/src/status-note.ts +28 -5
  33. package/src/types.ts +52 -2
  34. package/src/ui/agent-mention.ts +5 -2
  35. package/src/ui/agent-widget.ts +41 -22
  36. package/src/ui/conversation-viewer.ts +4 -2
  37. package/src/ui/fallback-flag.ts +28 -4
  38. package/src/ui/fleet-list.ts +89 -54
  39. package/src/ui/notification-view.ts +4 -1
  40. package/src/ui/workflow-card.ts +1 -8
  41. package/src/ui/workflow-dialog.ts +31 -18
  42. package/src/workflow/host.ts +4 -5
  43. package/src/workflow/meta.ts +7 -4
  44. package/src/workflow/progress.ts +4 -3
  45. package/src/workflow/runtime.ts +23 -7
  46. package/src/workflow/saved.ts +2 -4
  47. package/src/workflow/task.ts +12 -7
  48. package/src/workflow/tool-description.ts +2 -2
  49. package/src/workflow/worker-source.ts +6 -2
  50. package/src/worktree.ts +309 -19
@@ -1,138 +1,288 @@
1
- /**
2
- * skill-loader.ts — Preload named skills.
3
- *
4
- * Roots, in precedence order:
5
- * - <cwd>/.pi/skills (project, Pi's standard)
6
- * - <cwd>/.agents/skills (project, cross-tool Agent Skills spec — https://agentskills.io)
7
- * - getAgentDir()/skills (user, default ~/.pi/agent/skills — Pi's standard)
8
- * - ~/.agents/skills (user, cross-tool Agent Skills spec)
9
- * - ~/.pi/skills (legacy global, pre-Pi)
10
- *
11
- * Layout per root:
12
- * - <root>/<name>.md (flat file at the top level)
13
- * - <root>/.../<name>/SKILL.md (directory skill, may be nested — Pi's standard)
14
- *
15
- * Recursion skips dotfile entries and node_modules. A directory that itself contains
16
- * SKILL.md is a skill — we don't descend into it (Pi: skills don't nest).
17
- *
18
- * Project roots reject symlinks at every layer: a cloned repository must not be able to
19
- * point a skill at files outside it (deviation from Pi). User roots belong to the user and
20
- * follow symlinks like Pi does — that is how skill installers and dotfile managers link
21
- * skills in — skipping broken links and directory cycles.
22
- */
1
+ /** Resolve named preloaded skills through Pi's configured resource discovery. */
23
2
 
24
- import type { Dirent } from "node:fs";
25
- import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
26
- import { homedir } from "node:os";
27
- import { dirname, join } from "node:path";
28
- import { resolveAgentDir } from "./home-paths.js";
3
+ import { type Dirent, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, symlinkSync } from "node:fs";
4
+ import { homedir, tmpdir } from "node:os";
5
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
6
+ import { loadSkills, type ResolvedResource, SettingsManager, type SettingsManager as SettingsManagerType } from "@earendil-works/pi-coding-agent";
29
7
  import { isSymlink, isUnsafeName, safeReadFile } from "./memory.js";
8
+ import { createHomePackageManager } from "./resource-loader.js";
9
+
10
+ /** Discovery is inspect-only: missing sources are never installed or activated. */
11
+ const skipMissingSource = async (): Promise<"skip"> => "skip";
30
12
 
31
13
  export interface PreloadedSkill {
32
14
  name: string;
33
15
  content: string;
34
16
  }
35
17
 
36
- export function preloadSkills(skillNames: string[], cwd: string, homeDir = homedir()): PreloadedSkill[] {
37
- return skillNames.map((name) => ({ name, content: loadSkillContent(name, cwd, homeDir) }));
18
+ export interface SkillPreloadOptions {
19
+ agentDir: string;
20
+ settingsManager: SettingsManagerType;
21
+ homeDir?: string;
22
+ }
23
+
24
+ /**
25
+ * Load named skills through Pi, retaining the preloader's legacy user roots.
26
+ *
27
+ * Pi owns configured package discovery, parsing, trust, and missing-package
28
+ * handling. A second untrusted manager exposes the user/package candidates
29
+ * which Pi's realpath collapse can hide behind a rejected project symlink.
30
+ */
31
+ export async function preloadSkills(
32
+ skillNames: string[],
33
+ cwd: string,
34
+ options: SkillPreloadOptions,
35
+ ): Promise<PreloadedSkill[]> {
36
+ if (skillNames.length === 0) return [];
37
+ const primary = createHomePackageManager({ cwd, agentDir: options.agentDir, settingsManager: options.settingsManager }, options.homeDir);
38
+ const primaryResources = await primary.resolve(skipMissingSource);
39
+ const fallbackSettings = SettingsManager.create(cwd, options.agentDir, { projectTrusted: false });
40
+ const fallback = createHomePackageManager({ cwd, agentDir: options.agentDir, settingsManager: fallbackSettings }, options.homeDir);
41
+ const fallbackResources = await fallback.resolve(skipMissingSource);
42
+
43
+ const rejectedProjectPaths = new Set(primaryResources.skills
44
+ .filter(resource => isTier(resource, "project") && isSymlinkedProjectSkill(resource, cwd))
45
+ .map(resource => canonicalPath(resource.path)));
46
+ // A path that cannot be canonicalized never matches, so it is never restored.
47
+ const isBehindRejectedAlias = (resource: ResolvedResource): boolean => {
48
+ const canonical = canonicalPath(resource.path);
49
+ return canonical !== undefined && rejectedProjectPaths.has(canonical);
50
+ };
51
+ // Package candidates collapsed behind a rejected project alias, resolved with
52
+ // Pi's own project package filters applied.
53
+ const aliasFreeSkills = rejectedProjectPaths.size === 0 ? [] : await resolveAliasFreePackageSkills(cwd, options);
54
+ const recoveredPackages = aliasFreeSkills.filter(isBehindRejectedAlias);
55
+
56
+ // Resolve native disabled paths through Pi's parser, preserving every filter form it supports.
57
+ const disabledNames = new Set(loadTier(
58
+ [...primaryResources.skills, ...fallbackResources.skills, ...recoveredPackages].filter(resource => !resource.enabled),
59
+ cwd,
60
+ options.agentDir,
61
+ ).map(skill => skill.name));
62
+ const project = discoveredSkills(
63
+ primaryResources.skills.filter(resource => resource.enabled && isTier(resource, "project") && !isSymlinkedProjectSkill(resource, cwd)),
64
+ cwd,
65
+ options.agentDir,
66
+ );
67
+ const user = discoveredSkills(
68
+ [
69
+ ...primaryResources.skills,
70
+ // Only restore a user candidate Pi collapsed behind a rejected project alias.
71
+ // Other fallback user resources could bypass Pi's project filters.
72
+ ...fallbackResources.skills.filter(isBehindRejectedAlias),
73
+ ].filter(resource => resource.enabled && isTier(resource, "user") && !isAmbientUserAgentsSkill(resource, options.homeDir)),
74
+ cwd,
75
+ options.agentDir,
76
+ );
77
+ const packages = discoveredSkills(
78
+ [
79
+ ...primaryResources.skills,
80
+ // Only restore a package candidate Pi collapsed behind a rejected project alias.
81
+ ...recoveredPackages,
82
+ ].filter(resource => resource.enabled && isTier(resource, "package") && !isAmbientUserAgentsSkill(resource, options.homeDir)),
83
+ cwd,
84
+ options.agentDir,
85
+ );
86
+
87
+ // Historical user roots, including their raw top-level `<name>.md` lookup.
88
+ const home = resolve(options.homeDir ?? homedir());
89
+ const legacyUserRoots = [join(options.agentDir, "skills"), join(home, ".agents", "skills"), join(home, ".pi", "skills")];
90
+ return skillNames.map(name => {
91
+ const native = project.get(name) ?? user.get(name) ?? packages.get(name);
92
+ const content = native
93
+ ?? (disabledNames.has(name)
94
+ ? undefined
95
+ : (options.settingsManager.isProjectTrusted() ? findInRoots(projectSkillRoots(cwd).map(root => join(root, "skills")), name, false) : undefined)
96
+ ?? findInRoots(legacyUserRoots, name, true));
97
+ return { name, content: content ?? `(Skill "${name}" not found in Pi's configured skill resources)` };
98
+ });
99
+ }
100
+
101
+ /** Precedence tier of a resolved skill: top-level project or user resources, or package resources. */
102
+ function isTier(resource: ResolvedResource, tier: "project" | "user" | "package"): boolean {
103
+ return tier === "package" ? resource.metadata.origin === "package" : resource.metadata.origin === "top-level" && resource.metadata.scope === tier;
38
104
  }
39
105
 
40
- function loadSkillContent(name: string, cwd: string, homeDir: string): string {
41
- if (isUnsafeName(name)) {
42
- return `(Skill "${name}" skipped: name contains path traversal characters)`;
106
+ /** Absolutize an existing local project package source, which Pi resolves against `<cwd>/.pi`. */
107
+ function absolutizeProjectSource(source: string, cwd: string): string {
108
+ const local = resolve(cwd, ".pi", source);
109
+ return !source.startsWith("npm:") && existsSync(local) ? local : source;
110
+ }
111
+
112
+ /**
113
+ * Resolve package skills as Pi would, minus the project's top-level skill dirs.
114
+ *
115
+ * Pi collapses resources by realpath, so a rejected project symlink can hide a
116
+ * package skill together with the project package filter that applies to it.
117
+ * Resolving from an empty scratch cwd, with the real project settings and
118
+ * npm/git install roots carried over, keeps Pi as the owner of those filters.
119
+ */
120
+ async function resolveAliasFreePackageSkills(cwd: string, options: SkillPreloadOptions): Promise<ResolvedResource[]> {
121
+ const scratch = mkdtempSync(join(tmpdir(), "skill-preload-"));
122
+ try {
123
+ for (const dir of ["npm", "git"]) {
124
+ const real = join(cwd, ".pi", dir);
125
+ if (!existsSync(real)) continue;
126
+ mkdirSync(join(scratch, ".pi"), { recursive: true });
127
+ symlinkSync(real, join(scratch, ".pi", dir), "dir");
128
+ }
129
+ const project = options.settingsManager.getProjectSettings();
130
+ const settings = {
131
+ global: options.settingsManager.getGlobalSettings(),
132
+ project: { ...project, packages: project.packages?.map(pkg => typeof pkg === "string"
133
+ ? absolutizeProjectSource(pkg, cwd)
134
+ : { ...pkg, source: absolutizeProjectSource(pkg.source, cwd) }) },
135
+ };
136
+ // Pi's public storage seam replaces the scratch cwd's settings with the real
137
+ // ones. `normalizeHomeResourceSettings` cannot: it only rewrites a manager's
138
+ // own settings, and `createHomePackageManager` still applies it on top.
139
+ const settingsManager = SettingsManager.fromStorage(
140
+ { withLock: (scope, read) => void read(JSON.stringify(settings[scope])) },
141
+ { projectTrusted: options.settingsManager.isProjectTrusted() },
142
+ );
143
+ const manager = createHomePackageManager({ cwd: scratch, agentDir: options.agentDir, settingsManager }, options.homeDir);
144
+ return (await manager.resolve(skipMissingSource)).skills.filter(resource => resource.metadata.origin === "package");
145
+ } finally {
146
+ rmSync(scratch, { recursive: true, force: true });
43
147
  }
44
- // `trusted`: user-owned root, symlinks followed. Project roots stay strict.
45
- const agentDir = resolveAgentDir(homeDir);
46
- const roots = [
47
- { path: join(cwd, ".pi", "skills"), trusted: false }, // project — Pi standard
48
- { path: join(cwd, ".agents", "skills"), trusted: false }, // project — Agent Skills spec
49
- { path: join(agentDir, "skills"), trusted: true }, // user — Pi standard
50
- { path: join(homeDir, ".agents", "skills"), trusted: true }, // user — Agent Skills spec
51
- { path: join(homeDir, ".pi", "skills"), trusted: true }, // legacy global, pre-Pi
52
- ];
148
+ }
149
+
150
+ /** Parse resolved skill paths with Pi's own loader and validation. */
151
+ function loadTier(resources: ResolvedResource[], cwd: string, agentDir: string): ReturnType<typeof loadSkills>["skills"] {
152
+ return loadSkills({ cwd, agentDir, skillPaths: resources.map(resource => resource.path), includeDefaults: false }).skills;
153
+ }
154
+
155
+ /** Load Pi-valid skills from a precedence tier, retaining the first declared name. */
156
+ function discoveredSkills(resources: ResolvedResource[], cwd: string, agentDir: string): Map<string, string> {
157
+ return new Map(loadTier(resources, cwd, agentDir).map(skill => [skill.name, readFileSync(skill.filePath, "utf-8").trim()]));
158
+ }
159
+
160
+ /** Find a named legacy skill under roots in order without broadening Pi discovery. */
161
+ function findInRoots(roots: string[], name: string, trusted: boolean): string | undefined {
162
+ if (isUnsafeName(name)) return undefined;
53
163
  for (const root of roots) {
54
- const content = findInRoot(root.path, name, root.trusted);
164
+ const content = findInRoot(root, name, trusted);
55
165
  if (content !== undefined) return content;
56
166
  }
57
- return `(Skill "${name}" not found in .pi/skills/, .agents/skills/, or global skill locations)`;
167
+ return undefined;
58
168
  }
59
169
 
170
+ /** Match the former preloader's flat-file and recursive Agent Skills lookup. */
60
171
  function findInRoot(root: string, name: string, trusted: boolean): string | undefined {
61
- // Reject a project root that is, or sits in, a link: `<cwd>/.pi -> /elsewhere` counts too.
62
- if (!trusted && (isSymlink(root) || isSymlink(dirname(root)))) return undefined;
172
+ if (!trusted && isPathThroughSymlink(dirname(root), root)) return undefined;
63
173
  const flat = readSkillFile(join(root, `${name}.md`), trusted)?.trim();
64
174
  if (flat !== undefined) return flat;
65
- return findSkillDirectory(root, name, trusted);
66
- }
67
-
68
- /** A regular file's text; `trusted` also follows a symlink to one, else symlinks are refused. */
69
- function readSkillFile(path: string, trusted: boolean): string | undefined {
70
- if (!trusted) return safeReadFile(path);
71
- try {
72
- return statSync(path).isFile() ? readFileSync(path, "utf-8") : undefined;
73
- } catch {
74
- return undefined; // missing or broken link
75
- }
76
- }
77
-
78
- /** BFS under `root` for a directory named `name` containing `SKILL.md`. Pi-conforming filters. */
79
- function findSkillDirectory(root: string, name: string, trusted: boolean): string | undefined {
80
175
  if (!existsSync(root)) return undefined;
81
- const queue: string[] = [root];
82
- // Canonical paths already queued: a followed link back to an ancestor cannot loop.
83
- const seen = new Set<string>();
84
176
 
85
- while (queue.length > 0) {
86
- const current = queue.shift();
87
- if (current === undefined) continue;
177
+ const queue = [root];
178
+ const seen = new Set<string>();
179
+ // `root` passed the symlink check above and every queued directory passed it before it was
180
+ // pushed, so `current` needs no second check. Iteration visits entries pushed meanwhile, in order.
181
+ for (const current of queue) {
88
182
  if (trusted) {
89
- let real: string;
90
- try {
91
- real = realpathSync(current);
92
- } catch {
93
- continue;
94
- }
95
- if (seen.has(real)) continue;
96
- seen.add(real);
183
+ const canonical = canonicalPath(current);
184
+ // A directory raced away since it was queued has nothing left to read.
185
+ if (canonical === undefined || seen.has(canonical)) continue;
186
+ seen.add(canonical);
97
187
  }
98
-
99
188
  let entries: Dirent<string>[];
100
189
  try {
101
- entries = readdirSync(current, { withFileTypes: true });
190
+ entries = readdirSync(current, { withFileTypes: true }).sort((left, right) => Number(left.name > right.name) - Number(left.name < right.name));
102
191
  } catch {
103
192
  continue;
104
193
  }
105
-
106
- // Deterministic byte-order traversal — locale-independent.
107
- entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
108
-
109
194
  for (const entry of entries) {
110
195
  if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
111
196
  const path = join(current, entry.name);
112
- // Dirent uses lstat semantics, so a symlinked dir is not `isDirectory()`: refused unless trusted.
113
- if (!entry.isDirectory() && !(trusted && entry.isSymbolicLink() && isDirectoryTarget(path))) continue;
114
-
115
- const skillMd = join(path, "SKILL.md");
116
- const isSkillDir = existsSync(skillMd);
117
-
118
- if (isSkillDir) {
119
- if (entry.name === name) {
120
- const content = readSkillFile(skillMd, trusted)?.trim();
121
- if (content !== undefined) return content;
122
- }
123
- continue; // Pi rule: skills don't nest — don't descend into a skill dir
197
+ if (!trusted && isPathThroughSymlink(dirname(root), path)) continue;
198
+ const isDirectory = entry.isDirectory() || (trusted && entry.isSymbolicLink() && isDirectoryTarget(path));
199
+ if (!isDirectory) continue;
200
+ const skillFile = join(path, "SKILL.md");
201
+ if (existsSync(skillFile)) {
202
+ const content = entry.name === name ? readSkillFile(skillFile, trusted)?.trim() : undefined;
203
+ if (content !== undefined) return content;
204
+ continue;
124
205
  }
125
-
126
206
  queue.push(path);
127
207
  }
128
208
  }
129
209
  return undefined;
130
210
  }
131
211
 
212
+ function readSkillFile(path: string, trusted: boolean): string | undefined {
213
+ if (!trusted) return safeReadFile(path);
214
+ try {
215
+ return statSync(path).isFile() ? readFileSync(path, "utf-8") : undefined;
216
+ } catch {
217
+ return undefined;
218
+ }
219
+ }
220
+
132
221
  function isDirectoryTarget(path: string): boolean {
133
222
  try {
134
223
  return statSync(path).isDirectory();
135
224
  } catch {
136
- return false; // broken link
225
+ return false;
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Match Pi's realpath identity without importing its private path utility.
231
+ *
232
+ * Unlike Pi, an uncanonicalizable path (raced away, EACCES) yields undefined
233
+ * rather than the raw path, so it can never match a rejected alias. That is
234
+ * also why `realOrSelf` in `package-resources.ts` is not reused: it falls back
235
+ * to the realpath of the nearest existing parent.
236
+ */
237
+ function canonicalPath(path: string): string | undefined {
238
+ try {
239
+ return realpathSync(path);
240
+ } catch {
241
+ return undefined;
242
+ }
243
+ }
244
+
245
+ /** Exclude Pi's ambient user root when a caller supplies a distinct injected home. */
246
+ export function isAmbientUserAgentsSkill(resource: ResolvedResource, homeDir?: string, runtimeHome = homedir()): boolean {
247
+ if (homeDir === undefined || resource.metadata.scope !== "user" || resource.metadata.source !== "auto") return false;
248
+ const injectedRoot = join(resolve(homeDir), ".agents", "skills");
249
+ return !isPathWithin(injectedRoot, resource.path) && isPathWithin(join(runtimeHome, ".agents", "skills"), resource.path);
250
+ }
251
+
252
+ /** Reject project symlinks before accepting their resources or resolving collisions. Callers pass project-tier resources only. */
253
+ function isSymlinkedProjectSkill(resource: ResolvedResource, cwd: string): boolean {
254
+ return projectSkillRoots(cwd).some(root => isPathThroughSymlink(root, resource.path));
255
+ }
256
+
257
+ /** Match Pi's project skill roots while checking their parent directories too. */
258
+ function projectSkillRoots(cwd: string): string[] {
259
+ const roots = [join(cwd, ".pi")];
260
+ let directory = resolve(cwd);
261
+ while (true) {
262
+ roots.push(join(directory, ".agents"));
263
+ if (existsSync(join(directory, ".git"))) break;
264
+ const parent = dirname(directory);
265
+ if (parent === directory) break;
266
+ directory = parent;
267
+ }
268
+ return roots;
269
+ }
270
+
271
+ /** Textual containment. Unlike `isInside` in `package-resources.ts`, it must not canonicalize: that would erase the symlinks callers look for. */
272
+ function isPathWithin(root: string, path: string): boolean {
273
+ const relativePath = relative(resolve(root), resolve(path));
274
+ return relativePath === "" || (!relativePath.startsWith(`..${sep}`) && relativePath !== ".." && !isAbsolute(relativePath));
275
+ }
276
+
277
+ function isPathThroughSymlink(root: string, path: string): boolean {
278
+ const absoluteRoot = resolve(root);
279
+ if (!isPathWithin(absoluteRoot, path)) return false;
280
+ const relativePath = relative(absoluteRoot, resolve(path));
281
+ let current = absoluteRoot;
282
+ if (isSymlink(current)) return true;
283
+ for (const segment of relativePath.split(sep)) {
284
+ current = join(current, segment);
285
+ if (isSymlink(current)) return true;
137
286
  }
287
+ return false;
138
288
  }
@@ -10,6 +10,9 @@
10
10
 
11
11
  import type { AgentRecord } from "./types.js";
12
12
 
13
+ /** Who stopped a `stopped` record: the spawning agent, or (unset) a human. */
14
+ type StoppedBy = AgentRecord["stoppedBy"];
15
+
13
16
  /**
14
17
  * Explicit parenthetical note for a non-normal terminal outcome, so the parent
15
18
  * agent can't mistake partial output for a completed result. Empty string for a
@@ -17,12 +20,16 @@ import type { AgentRecord } from "./types.js";
17
20
  *
18
21
  * `stopped` (a human aborted it) is deliberately distinct from `aborted` (the
19
22
  * turn limit was hit) — the parent should treat human intervention differently
20
- * from a budget cutoff.
23
+ * from a budget cutoff. A stop the parent itself made with `stop_subagent`
24
+ * (`stoppedBy: "parent"`) is stated plainly instead: shouting a human
25
+ * intervention that never happened would misreport its own decision back to it.
21
26
  */
22
- export function getStatusNote(status: string): string {
27
+ export function getStatusNote(status: string, stoppedBy?: StoppedBy): string {
23
28
  switch (status) {
24
29
  case "stopped":
25
- return " (STOPPED BY THE USER before completion — output is partial; the task was NOT finished)";
30
+ return stoppedBy === "parent"
31
+ ? " (stopped by the parent with stop_subagent before completion — output is partial; the task was NOT finished)"
32
+ : " (STOPPED BY THE USER before completion — output is partial; the task was NOT finished)";
26
33
  case "aborted":
27
34
  return " (aborted — hit the turn limit before completion; output may be incomplete)";
28
35
  case "steered":
@@ -66,10 +73,12 @@ export function getStatusNote(status: string): string {
66
73
  * behavior) and adding an instruction (which can) are not equally safe bets.
67
74
  * Don't add either back without a way to measure it.
68
75
  */
69
- export function getForegroundOutcomeNote(status: string): string {
76
+ export function getForegroundOutcomeNote(status: string, stoppedBy?: StoppedBy): string {
70
77
  switch (status) {
71
78
  case "stopped":
72
- return " (STOPPED BY THE USER — everything the agent produced is above; the task is unfinished)";
79
+ return stoppedBy === "parent"
80
+ ? " (stopped by the parent with stop_subagent — everything the agent produced is above; the task is unfinished)"
81
+ : " (STOPPED BY THE USER — everything the agent produced is above; the task is unfinished)";
73
82
  case "aborted":
74
83
  return " (aborted at the turn limit — everything the agent produced is above; the task is unfinished)";
75
84
  case "steered":
@@ -79,6 +88,20 @@ export function getForegroundOutcomeNote(status: string): string {
79
88
  }
80
89
  }
81
90
 
91
+ /**
92
+ * For an `error` run whose session survived, the hint that resuming it
93
+ * continues the work, naming the agent; "" otherwise. A provider or auth
94
+ * failure (e.g. a credential helper that failed transiently, #456) ends the
95
+ * turn but keeps the whole transcript, so the parent should resume the agent
96
+ * rather than re-spawn it and repay its context. A run that never got a
97
+ * session has nothing to resume.
98
+ */
99
+ export function resumeHint(record: Pick<AgentRecord, "id" | "status" | "session">): string {
100
+ return record.status === "error" && record.session
101
+ ? `transcript intact; resume agent ${record.id} to continue`
102
+ : "";
103
+ }
104
+
82
105
  /**
83
106
  * Salvaged partial output of a failed run, as a labeled suffix for the error
84
107
  * surfaces (or "" if the run produced nothing). `record.result` is bounded to
package/src/types.ts CHANGED
@@ -7,6 +7,7 @@ import type { AgentSession } from "@earendil-works/pi-coding-agent";
7
7
  import type { ModelFallback } from "./model-resolver.js";
8
8
  import type { ModelSelection } from "./model-selection.js";
9
9
  import type { TierDisclosure } from "./model-tiers.js";
10
+ import type { PlanGate, PlanToolPolicy } from "./plan-mode-children.js";
10
11
  import type { BudgetLease } from "./process-budget.js";
11
12
  import type { LifetimeUsage } from "./usage.js";
12
13
 
@@ -184,8 +185,21 @@ export interface AgentRecord {
184
185
  * by its type and `@explore` never comes to mean "start another one".
185
186
  */
186
187
  alias?: string;
188
+ /**
189
+ * `handleBase(name)` before collision numbering: `auth-audit` for an alias of
190
+ * `auth-audit-2`. Announced as `nameBase` so a listener matching the spawner's
191
+ * `name` finds a retry rather than the failed first try still holding the name.
192
+ */
193
+ nameBase?: string;
187
194
  description: string;
188
195
  status: "queued" | "running" | "completed" | "steered" | "aborted" | "stopped" | "error";
196
+ /**
197
+ * Set when the agent that spawned this one stopped it with `stop_subagent`
198
+ * (#443). Unset on a `stopped` record means a human stopped it (a viewer's
199
+ * `x`, the stop-all chord, `/agents`, or the RPC), which the status notes
200
+ * word differently.
201
+ */
202
+ stoppedBy?: "parent";
189
203
  result?: string;
190
204
  error?: string;
191
205
  toolUses: number;
@@ -233,14 +247,34 @@ export interface AgentRecord {
233
247
  joinMode?: JoinMode;
234
248
  /** Set when result was already consumed via get_subagent_result — suppresses completion notification. */
235
249
  resultConsumed?: boolean;
250
+ /**
251
+ * Set when the completion notice was sent to the parent, whole or clipped, failures
252
+ * included. Unlike `resultConsumed` it does not mean FleetView acknowledged it; the
253
+ * reap notice only asks "did the parent ever see this?".
254
+ */
255
+ noticeDelivered?: boolean;
256
+ /**
257
+ * The latest run actually began executing (the runner was started on a live signal).
258
+ * Positive evidence the reap notice requires: a record that was stopped in the queue,
259
+ * refused, or failed before the runner started never ran, so evicting it loses nothing.
260
+ * Reset on every resume.
261
+ */
262
+ ranThisRun?: boolean;
236
263
  /** When `resultConsumed` was set; the GC keeps a consumed record for a short window from here. */
237
264
  consumedAt?: number;
238
265
  /** Steering messages queued before the session was ready. */
239
266
  pendingSteers?: string[];
267
+ /**
268
+ * The Plan restriction its runs are under (#437): set at spawn, re-decided at
269
+ * each resume, read by the child's tool hooks on every call.
270
+ */
271
+ planGate?: PlanGate;
272
+ /** Asks the parent's pi-plan-mode again, for a resume. */
273
+ planQuery?: () => PlanToolPolicy | undefined;
240
274
  /** Worktree info if the agent is running in an isolated worktree. */
241
275
  worktree?: { path: string; branch: string; baseSha: string; repoRoot: string; workPath: string };
242
276
  /** Worktree cleanup result after agent completion. */
243
- worktreeResult?: { hasChanges: boolean; branch?: string };
277
+ worktreeResult?: { hasChanges: boolean; branch?: string; path?: string; error?: string };
244
278
  /** The tool_use_id from the original Agent tool call. */
245
279
  toolCallId?: string;
246
280
  /** Path to the streaming output transcript file. */
@@ -294,8 +328,20 @@ export interface AgentRecord {
294
328
  previousModelTier?: TierDisclosure;
295
329
  /** Prior evicted run's fallback, retained separately from the continuation's current disclosure. */
296
330
  previousModelFallback?: ModelFallback;
331
+ /**
332
+ * The evicted id this record reopened (#330), announced as `previousId` on its
333
+ * lifecycle events so a listener keyed by the old id follows the conversation.
334
+ */
335
+ previousId?: string;
336
+ /**
337
+ * Order of this record's spawn among the manager's spawns and settles, set once at
338
+ * creation (before any queueing) and announced as `spawnSeq` on lifecycle events.
339
+ */
340
+ spawnSeq?: number;
341
+ /** Order of this record's latest settle on the same counter, announced as `settleSeq`. */
342
+ settleSeq?: number;
297
343
  /** Nesting depth: top-level subagent = 1. */
298
- depth?: number;
344
+ depth: number;
299
345
  /**
300
346
  * The validated `StructuredOutput` payload, as canonical JSON.
301
347
  *
@@ -354,6 +400,8 @@ export interface AgentInvocation {
354
400
  inheritContext?: boolean;
355
401
  runInBackground?: boolean;
356
402
  isolation?: IsolationMode;
403
+ /** The run is under Plan mode's policy because its parent was planning (#437). */
404
+ planMode?: boolean;
357
405
  }
358
406
 
359
407
  /** Details attached to custom notification messages for visual rendering. */
@@ -374,6 +422,8 @@ export interface NotificationDetails {
374
422
  durationMs: number;
375
423
  outputFile?: string;
376
424
  error?: string;
425
+ /** Set for a failed agent whose transcript survived: `resume agent <id> to continue` (status-note.ts `resumeHint`). */
426
+ resumeHint?: string;
377
427
  /** The `Note:` line when the agent ran on the parent model because its configured one did not resolve. */
378
428
  modelNote?: string;
379
429
  modelTier?: TierDisclosure;
@@ -87,9 +87,12 @@ export function mentionRoster(
87
87
  // only — both names resolve, but showing two rows for one agent reads as two
88
88
  // agents. The type handle stays addressable whether or not it is listed.
89
89
  for (const record of records) {
90
- const handle = record.alias ?? record.handle!;
90
+ // The filter above kept only records with a handle, and `handleBase` never
91
+ // derives an empty one.
92
+ const own = record.handle!;
93
+ const handle = record.alias ?? own;
91
94
  taken.add(handle.toLowerCase());
92
- if (record.handle) taken.add(record.handle.toLowerCase());
95
+ taken.add(own.toLowerCase());
93
96
  targets.push({ kind: "record", handle, record, typeLabel: displayNameOf(record.type) });
94
97
  }
95
98