@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.
- package/README.md +136 -55
- package/package.json +14 -11
- package/src/abortable.ts +1 -1
- package/src/agent-file-toggle.ts +0 -1
- package/src/agent-manager.ts +360 -103
- package/src/agent-runner.ts +121 -63
- package/src/child-context.ts +19 -1
- package/src/completion-outbox.ts +22 -3
- package/src/cross-extension-rpc.ts +10 -6
- package/src/custom-agents.ts +2 -1
- package/src/default-agents.ts +19 -17
- package/src/detached-results.ts +18 -2
- package/src/enterprise-local.ts +2 -1
- package/src/error-message.ts +11 -0
- package/src/git-path.ts +66 -0
- package/src/group-join.ts +4 -10
- package/src/index.ts +731 -231
- package/src/invocation-config.ts +1 -5
- package/src/mention-clone.ts +4 -3
- package/src/model-resolver.ts +51 -14
- package/src/model-selection.ts +17 -6
- package/src/model-tiers.ts +5 -0
- package/src/nested-tools.ts +80 -21
- package/src/notification-result.ts +118 -0
- package/src/plan-mode-children.ts +176 -0
- package/src/prompts.ts +7 -2
- package/src/quota-admission.ts +16 -6
- package/src/resource-loader.ts +20 -6
- package/src/schedule.ts +65 -19
- package/src/settings.ts +21 -1
- package/src/skill-loader.ts +244 -94
- package/src/status-note.ts +28 -5
- package/src/types.ts +52 -2
- package/src/ui/agent-mention.ts +5 -2
- package/src/ui/agent-widget.ts +41 -22
- package/src/ui/conversation-viewer.ts +4 -2
- package/src/ui/fallback-flag.ts +28 -4
- package/src/ui/fleet-list.ts +89 -54
- package/src/ui/notification-view.ts +4 -1
- package/src/ui/workflow-card.ts +1 -8
- package/src/ui/workflow-dialog.ts +31 -18
- package/src/workflow/host.ts +4 -5
- package/src/workflow/meta.ts +7 -4
- package/src/workflow/progress.ts +4 -3
- package/src/workflow/runtime.ts +23 -7
- package/src/workflow/saved.ts +2 -4
- package/src/workflow/task.ts +12 -7
- package/src/workflow/tool-description.ts +2 -2
- package/src/workflow/worker-source.ts +6 -2
- package/src/worktree.ts +309 -19
package/src/skill-loader.ts
CHANGED
|
@@ -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
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
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
|
|
37
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
164
|
+
const content = findInRoot(root, name, trusted);
|
|
55
165
|
if (content !== undefined) return content;
|
|
56
166
|
}
|
|
57
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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;
|
|
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
|
}
|
package/src/status-note.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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;
|
package/src/ui/agent-mention.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
95
|
+
taken.add(own.toLowerCase());
|
|
93
96
|
targets.push({ kind: "record", handle, record, typeLabel: displayNameOf(record.type) });
|
|
94
97
|
}
|
|
95
98
|
|