@nredd/pi-subagents 0.22.0-nredd.7 → 0.22.0-nredd.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +245 -77
- package/package.json +16 -13
- package/src/abortable.ts +1 -1
- package/src/agent-file-toggle.ts +6 -5
- package/src/agent-manager.ts +489 -129
- package/src/agent-runner.ts +244 -93
- package/src/child-context.ts +19 -1
- package/src/completion-outbox.ts +22 -3
- package/src/cross-extension-rpc.ts +21 -42
- package/src/custom-agents.ts +11 -6
- package/src/default-agents.ts +21 -21
- package/src/detached-results.ts +18 -2
- package/src/enabled-models.ts +8 -7
- package/src/enterprise-local.ts +7 -6
- package/src/error-message.ts +11 -0
- package/src/git-path.ts +66 -0
- package/src/group-join.ts +4 -10
- package/src/home-paths.ts +27 -0
- package/src/index.ts +898 -297
- package/src/invocation-config.ts +4 -21
- package/src/memory.ts +9 -9
- package/src/mention-clone.ts +13 -8
- package/src/model-resolver.ts +92 -25
- package/src/model-scope.ts +2 -1
- package/src/model-selection.ts +170 -0
- package/src/model-tiers.ts +176 -0
- package/src/nested-tools.ts +99 -44
- package/src/notification-result.ts +118 -0
- package/src/output-file.ts +23 -1
- package/src/package-resources.ts +25 -10
- package/src/plan-mode-children.ts +176 -0
- package/src/process-budget.ts +6 -3
- package/src/prompts.ts +7 -2
- package/src/quota-admission.ts +35 -13
- package/src/resource-loader.ts +65 -0
- package/src/schedule.ts +94 -30
- package/src/settings.ts +63 -13
- package/src/skill-loader.ts +244 -93
- package/src/stale-ctx.ts +16 -0
- package/src/status-note.ts +28 -5
- package/src/types.ts +79 -2
- package/src/ui/agent-mention.ts +5 -2
- package/src/ui/agent-row.ts +13 -3
- package/src/ui/agent-widget.ts +55 -25
- package/src/ui/conversation-viewer.ts +10 -5
- package/src/ui/fallback-flag.ts +114 -0
- package/src/ui/fleet-list.ts +431 -86
- package/src/ui/fleet-state.ts +154 -0
- package/src/ui/notification-view.ts +22 -3
- package/src/ui/workflow-card.ts +6 -9
- package/src/ui/workflow-dialog.ts +36 -13
- package/src/workflow/host.ts +47 -50
- package/src/workflow/journal.ts +13 -1
- package/src/workflow/meta.ts +7 -4
- package/src/workflow/progress.ts +8 -3
- package/src/workflow/runtime.ts +48 -15
- package/src/workflow/saved.ts +21 -21
- package/src/workflow/task.ts +22 -7
- package/src/workflow/tool-description.ts +2 -2
- package/src/workflow/worker-source.ts +162 -40
- package/src/worktree.ts +309 -19
package/src/skill-loader.ts
CHANGED
|
@@ -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
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
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
|
|
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
|
-
|
|
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
|
|
164
|
+
const content = findInRoot(root, name, trusted);
|
|
54
165
|
if (content !== undefined) return content;
|
|
55
166
|
}
|
|
56
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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;
|
|
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
|
}
|
package/src/stale-ctx.ts
ADDED
|
@@ -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
|
+
}
|
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
|
@@ -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
|
|
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[];
|
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
|
|