@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
@@ -1,137 +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 { getAgentDir } from "@earendil-works/pi-coding-agent";
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): PreloadedSkill[] {
37
- return skillNames.map((name) => ({ name, content: loadSkillContent(name, cwd) }));
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): 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 roots = [
46
- { path: join(cwd, ".pi", "skills"), trusted: false }, // project — Pi standard
47
- { path: join(cwd, ".agents", "skills"), trusted: false }, // project — Agent Skills spec
48
- { path: join(getAgentDir(), "skills"), trusted: true }, // user — Pi standard
49
- { path: join(homedir(), ".agents", "skills"), trusted: true }, // user — Agent Skills spec
50
- { path: join(homedir(), ".pi", "skills"), trusted: true }, // legacy global, pre-Pi
51
- ];
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;
52
163
  for (const root of roots) {
53
- const content = findInRoot(root.path, name, root.trusted);
164
+ const content = findInRoot(root, name, trusted);
54
165
  if (content !== undefined) return content;
55
166
  }
56
- return `(Skill "${name}" not found in .pi/skills/, .agents/skills/, or global skill locations)`;
167
+ return undefined;
57
168
  }
58
169
 
170
+ /** Match the former preloader's flat-file and recursive Agent Skills lookup. */
59
171
  function findInRoot(root: string, name: string, trusted: boolean): string | undefined {
60
- // Reject a project root that is, or sits in, a link: `<cwd>/.pi -> /elsewhere` counts too.
61
- if (!trusted && (isSymlink(root) || isSymlink(dirname(root)))) return undefined;
172
+ if (!trusted && isPathThroughSymlink(dirname(root), root)) return undefined;
62
173
  const flat = readSkillFile(join(root, `${name}.md`), trusted)?.trim();
63
174
  if (flat !== undefined) return flat;
64
- return findSkillDirectory(root, name, trusted);
65
- }
66
-
67
- /** A regular file's text; `trusted` also follows a symlink to one, else symlinks are refused. */
68
- function readSkillFile(path: string, trusted: boolean): string | undefined {
69
- if (!trusted) return safeReadFile(path);
70
- try {
71
- return statSync(path).isFile() ? readFileSync(path, "utf-8") : undefined;
72
- } catch {
73
- return undefined; // missing or broken link
74
- }
75
- }
76
-
77
- /** BFS under `root` for a directory named `name` containing `SKILL.md`. Pi-conforming filters. */
78
- function findSkillDirectory(root: string, name: string, trusted: boolean): string | undefined {
79
175
  if (!existsSync(root)) return undefined;
80
- const queue: string[] = [root];
81
- // Canonical paths already queued: a followed link back to an ancestor cannot loop.
82
- const seen = new Set<string>();
83
176
 
84
- while (queue.length > 0) {
85
- const current = queue.shift();
86
- 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) {
87
182
  if (trusted) {
88
- let real: string;
89
- try {
90
- real = realpathSync(current);
91
- } catch {
92
- continue;
93
- }
94
- if (seen.has(real)) continue;
95
- 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);
96
187
  }
97
-
98
188
  let entries: Dirent<string>[];
99
189
  try {
100
- entries = readdirSync(current, { withFileTypes: true });
190
+ entries = readdirSync(current, { withFileTypes: true }).sort((left, right) => Number(left.name > right.name) - Number(left.name < right.name));
101
191
  } catch {
102
192
  continue;
103
193
  }
104
-
105
- // Deterministic byte-order traversal — locale-independent.
106
- entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
107
-
108
194
  for (const entry of entries) {
109
195
  if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
110
196
  const path = join(current, entry.name);
111
- // Dirent uses lstat semantics, so a symlinked dir is not `isDirectory()`: refused unless trusted.
112
- if (!entry.isDirectory() && !(trusted && entry.isSymbolicLink() && isDirectoryTarget(path))) continue;
113
-
114
- const skillMd = join(path, "SKILL.md");
115
- const isSkillDir = existsSync(skillMd);
116
-
117
- if (isSkillDir) {
118
- if (entry.name === name) {
119
- const content = readSkillFile(skillMd, trusted)?.trim();
120
- if (content !== undefined) return content;
121
- }
122
- 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;
123
205
  }
124
-
125
206
  queue.push(path);
126
207
  }
127
208
  }
128
209
  return undefined;
129
210
  }
130
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
+
131
221
  function isDirectoryTarget(path: string): boolean {
132
222
  try {
133
223
  return statSync(path).isDirectory();
134
224
  } catch {
135
- 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;
136
286
  }
287
+ return false;
137
288
  }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * stale-ctx.ts -- recognising pi's "extension ctx is stale" failure.
3
+ *
4
+ * pi invalidates an extension runner on reload and on session replacement
5
+ * (/new, /fork, /resume). Anything that still reaches the old runner or its
6
+ * `pi` throws this message (#145).
7
+ */
8
+
9
+ /** Matched on the stable prefix of core's message. */
10
+ export const STALE_CTX_RE = /extension ctx is stale after session replacement or reload/i;
11
+
12
+ /** Whether `error` (an `Error` or a message string) is the stale-ctx failure. */
13
+ export function isStaleCtxError(error: unknown): boolean {
14
+ const message = error instanceof Error ? error.message : typeof error === "string" ? error : "";
15
+ return STALE_CTX_RE.test(message);
16
+ }
@@ -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
@@ -4,6 +4,10 @@
4
4
 
5
5
  import type { ThinkingLevel } from "@earendil-works/pi-ai";
6
6
  import type { AgentSession } from "@earendil-works/pi-coding-agent";
7
+ import type { ModelFallback } from "./model-resolver.js";
8
+ import type { ModelSelection } from "./model-selection.js";
9
+ import type { TierDisclosure } from "./model-tiers.js";
10
+ import type { PlanGate, PlanToolPolicy } from "./plan-mode-children.js";
7
11
  import type { BudgetLease } from "./process-budget.js";
8
12
  import type { LifetimeUsage } from "./usage.js";
9
13
 
@@ -147,6 +151,9 @@ export interface AgentTombstone {
147
151
  description: string;
148
152
  /** Always set — a record with no session file is never tombstoned. */
149
153
  sessionFile: string;
154
+ /** The evicted run's tier and fallback, handed back when `@handle` reopens it. */
155
+ modelTier?: TierDisclosure;
156
+ modelFallback?: ModelFallback;
150
157
  completedAt: number;
151
158
  }
152
159
 
@@ -178,8 +185,21 @@ export interface AgentRecord {
178
185
  * by its type and `@explore` never comes to mean "start another one".
179
186
  */
180
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;
181
194
  description: string;
182
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";
183
203
  result?: string;
184
204
  error?: string;
185
205
  toolUses: number;
@@ -227,14 +247,34 @@ export interface AgentRecord {
227
247
  joinMode?: JoinMode;
228
248
  /** Set when result was already consumed via get_subagent_result — suppresses completion notification. */
229
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;
230
263
  /** When `resultConsumed` was set; the GC keeps a consumed record for a short window from here. */
231
264
  consumedAt?: number;
232
265
  /** Steering messages queued before the session was ready. */
233
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;
234
274
  /** Worktree info if the agent is running in an isolated worktree. */
235
275
  worktree?: { path: string; branch: string; baseSha: string; repoRoot: string; workPath: string };
236
276
  /** Worktree cleanup result after agent completion. */
237
- worktreeResult?: { hasChanges: boolean; branch?: string };
277
+ worktreeResult?: { hasChanges: boolean; branch?: string; path?: string; error?: string };
238
278
  /** The tool_use_id from the original Agent tool call. */
239
279
  toolCallId?: string;
240
280
  /** Path to the streaming output transcript file. */
@@ -270,8 +310,38 @@ export interface AgentRecord {
270
310
  isBackground?: boolean;
271
311
  /** Resolved spawn params, captured for UI display. Fixed at spawn time. */
272
312
  invocation?: AgentInvocation;
313
+ /**
314
+ * Set when a configured model did not resolve and the agent runs on the parent
315
+ * model. The single source for every disclosure surface; a resume reuses it
316
+ * as is, never recomputes it from the resuming call's config.
317
+ */
318
+ modelFallback?: ModelFallback;
319
+ /**
320
+ * This record's selection, retained across live resumes and physical relabelling.
321
+ * An evicted-session reopen selects anew; current fallback disclosure always
322
+ * comes from that selection.
323
+ */
324
+ modelSelection?: ModelSelection;
325
+ /** Actual run's tier provenance, preserved through eviction and replay. */
326
+ modelTier?: TierDisclosure;
327
+ /** Prior evicted run's outcome, never the continuation's selection or disclosure. */
328
+ previousModelTier?: TierDisclosure;
329
+ /** Prior evicted run's fallback, retained separately from the continuation's current disclosure. */
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;
273
343
  /** Nesting depth: top-level subagent = 1. */
274
- depth?: number;
344
+ depth: number;
275
345
  /**
276
346
  * The validated `StructuredOutput` payload, as canonical JSON.
277
347
  *
@@ -330,6 +400,8 @@ export interface AgentInvocation {
330
400
  inheritContext?: boolean;
331
401
  runInBackground?: boolean;
332
402
  isolation?: IsolationMode;
403
+ /** The run is under Plan mode's policy because its parent was planning (#437). */
404
+ planMode?: boolean;
333
405
  }
334
406
 
335
407
  /** Details attached to custom notification messages for visual rendering. */
@@ -350,6 +422,11 @@ export interface NotificationDetails {
350
422
  durationMs: number;
351
423
  outputFile?: string;
352
424
  error?: string;
425
+ /** Set for a failed agent whose transcript survived: `resume agent <id> to continue` (status-note.ts `resumeHint`). */
426
+ resumeHint?: string;
427
+ /** The `Note:` line when the agent ran on the parent model because its configured one did not resolve. */
428
+ modelNote?: string;
429
+ modelTier?: TierDisclosure;
353
430
  resultPreview: string;
354
431
  /** Additional agents in a group notification. */
355
432
  others?: NotificationDetails[];
@@ -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