@nredd/pi-subagents 0.22.0-nredd.7 → 0.22.0-nredd.8
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 +124 -37
- package/package.json +8 -8
- package/src/agent-file-toggle.ts +6 -4
- package/src/agent-manager.ts +131 -28
- package/src/agent-runner.ts +155 -62
- package/src/cross-extension-rpc.ts +11 -36
- package/src/custom-agents.ts +9 -5
- package/src/default-agents.ts +2 -4
- package/src/enabled-models.ts +8 -7
- package/src/enterprise-local.ts +5 -5
- package/src/home-paths.ts +27 -0
- package/src/index.ts +191 -90
- package/src/invocation-config.ts +3 -16
- package/src/memory.ts +9 -9
- package/src/mention-clone.ts +9 -5
- package/src/model-resolver.ts +41 -11
- package/src/model-scope.ts +2 -1
- package/src/model-selection.ts +159 -0
- package/src/model-tiers.ts +171 -0
- package/src/nested-tools.ts +22 -26
- package/src/output-file.ts +23 -1
- package/src/package-resources.ts +25 -10
- package/src/process-budget.ts +6 -3
- package/src/quota-admission.ts +21 -9
- package/src/resource-loader.ts +51 -0
- package/src/schedule.ts +36 -18
- package/src/settings.ts +42 -12
- package/src/skill-loader.ts +8 -7
- package/src/stale-ctx.ts +16 -0
- package/src/types.ts +27 -0
- package/src/ui/agent-row.ts +13 -3
- package/src/ui/agent-widget.ts +15 -4
- package/src/ui/conversation-viewer.ts +6 -3
- package/src/ui/fallback-flag.ts +90 -0
- package/src/ui/fleet-list.ts +363 -53
- package/src/ui/fleet-state.ts +154 -0
- package/src/ui/notification-view.ts +18 -2
- package/src/ui/workflow-card.ts +5 -1
- package/src/ui/workflow-dialog.ts +10 -0
- package/src/workflow/host.ts +43 -45
- package/src/workflow/journal.ts +13 -1
- package/src/workflow/progress.ts +4 -0
- package/src/workflow/runtime.ts +25 -8
- package/src/workflow/saved.ts +19 -17
- package/src/workflow/task.ts +10 -0
- package/src/workflow/worker-source.ts +156 -38
package/src/nested-tools.ts
CHANGED
|
@@ -17,8 +17,7 @@ import {
|
|
|
17
17
|
} from "./agent-types.js";
|
|
18
18
|
import { loadCustomAgents } from "./custom-agents.js";
|
|
19
19
|
import { isolationParam, resolveAgentInvocationConfig } from "./invocation-config.js";
|
|
20
|
-
import {
|
|
21
|
-
import { checkModelScope } from "./model-scope.js";
|
|
20
|
+
import { type ModelSelection, modelSelectionNote, selectModel } from "./model-selection.js";
|
|
22
21
|
import {
|
|
23
22
|
createOutputFilePath,
|
|
24
23
|
getOutputTranscriptDefault,
|
|
@@ -75,8 +74,10 @@ export const NESTED_WORKFLOW_NO_AGENT_ERROR =
|
|
|
75
74
|
+ "Do the work directly.";
|
|
76
75
|
|
|
77
76
|
interface NestedSpawnOptions {
|
|
77
|
+
homeDir?: string;
|
|
78
78
|
description: string;
|
|
79
79
|
model?: Model<any>;
|
|
80
|
+
modelSelection?: ModelSelection;
|
|
80
81
|
maxTurns?: number;
|
|
81
82
|
isolated?: boolean;
|
|
82
83
|
inheritContext?: boolean;
|
|
@@ -120,6 +121,8 @@ export interface NestedAgentManager {
|
|
|
120
121
|
}
|
|
121
122
|
|
|
122
123
|
export interface NestedToolContext {
|
|
124
|
+
/** Home shared by this branch's user-resource discovery. */
|
|
125
|
+
homeDir?: string;
|
|
123
126
|
manager: NestedAgentManager;
|
|
124
127
|
pi: ExtensionAPI;
|
|
125
128
|
parentAgentId: string;
|
|
@@ -160,6 +163,13 @@ function ownsRecord(record: AgentRecord | undefined, parentAgentId: string): rec
|
|
|
160
163
|
type ResultPosition = "inline" | "fetched";
|
|
161
164
|
|
|
162
165
|
function formatRecord(record: AgentRecord, position: ResultPosition): string {
|
|
166
|
+
// Read off the record, so a fetched or resumed child reports what ran, not
|
|
167
|
+
// what the current call would have configured.
|
|
168
|
+
const note = modelSelectionNote(record);
|
|
169
|
+
return note ? `${note}\n\n${formatOutcome(record, position)}` : formatOutcome(record, position);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function formatOutcome(record: AgentRecord, position: ResultPosition): string {
|
|
163
173
|
if (record.status === "error") {
|
|
164
174
|
return `Agent failed: ${record.error ?? "unknown error"}${partialOutputSuffix(record)}`;
|
|
165
175
|
}
|
|
@@ -181,7 +191,7 @@ export function createNestedSubagentTools(context: NestedToolContext): ToolDefin
|
|
|
181
191
|
// Agents resolve from a registry built for THIS branch's config root (under
|
|
182
192
|
// worktree isolation, the copy). Never via registerAgents — that is
|
|
183
193
|
// process-global state shared with the main session and every other agent.
|
|
184
|
-
const loadRegistry = () => buildAgentRegistry(loadCustomAgents(context.configCwd, false, { projectTrusted: context.projectTrusted }));
|
|
194
|
+
const loadRegistry = () => buildAgentRegistry(loadCustomAgents(context.configCwd, false, { projectTrusted: context.projectTrusted, homeDir: context.homeDir }));
|
|
185
195
|
const allowedTypesIn = (registry: Map<string, AgentConfig>): Set<string> | undefined =>
|
|
186
196
|
context.allowedSubagents === "all"
|
|
187
197
|
? undefined
|
|
@@ -201,7 +211,7 @@ export function createNestedSubagentTools(context: NestedToolContext): ToolDefin
|
|
|
201
211
|
prompt: Type.String({ description: "Self-contained task for the nested agent." }),
|
|
202
212
|
description: Type.String({ description: "Short 3-5 word task description." }),
|
|
203
213
|
subagent_type: Type.String({ description: `Allowed nested agent type. Available: ${availableIn(loadRegistry()).join(", ") || "none"}.` }),
|
|
204
|
-
model: Type.Optional(Type.String({ description: "Optional provider/model override." })),
|
|
214
|
+
model: Type.Optional(Type.String({ description: "Optional configured @tier or provider/model override; explicit values win over agentModels and the definition." })),
|
|
205
215
|
thinking: Type.Optional(Type.String({ description: "Optional thinking level." })),
|
|
206
216
|
max_turns: Type.Optional(Type.Number({ minimum: 1 })),
|
|
207
217
|
run_in_background: Type.Optional(
|
|
@@ -273,40 +283,26 @@ export function createNestedSubagentTools(context: NestedToolContext): ToolDefin
|
|
|
273
283
|
// Foreground regardless of `backgroundByDefault` — see the reasoning on
|
|
274
284
|
// ResolveOptions. An explicit `true` here still opts in.
|
|
275
285
|
const invocation = resolveAgentInvocationConfig(config, params, {
|
|
276
|
-
agentType: resolvedType,
|
|
277
286
|
worktreeAllowed: isWorktreeIsolationEnabled(),
|
|
278
287
|
defaultRunInBackground: false,
|
|
279
288
|
});
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
if (invocation.modelFromParams) return textResult(resolvedModel, true);
|
|
285
|
-
} else {
|
|
286
|
-
model = resolvedModel;
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
// Same scopeModels policy as the top-level Agent tool — a nested spawn
|
|
291
|
-
// must not escape the allowlist. A "warn" verdict proceeds silently:
|
|
292
|
-
// child sessions have no UI surface to toast to.
|
|
293
|
-
const scopeVerdict = checkModelScope({
|
|
294
|
-
model,
|
|
295
|
-
cwd: context.configCwd,
|
|
296
|
-
modelRegistry: ctx.modelRegistry,
|
|
297
|
-
callerSupplied: invocation.modelFromParams,
|
|
298
|
-
agentLabel: config?.displayName ?? resolvedType,
|
|
299
|
-
modelInput: invocation.modelInput,
|
|
289
|
+
const selection = selectModel({
|
|
290
|
+
homeDir: context.homeDir,
|
|
291
|
+
agentType: resolvedType, definitionModel: config?.model, paramModel: params.model,
|
|
292
|
+
parentModel: ctx.model, registry: ctx.modelRegistry, cwd: context.configCwd, agentLabel: config?.displayName,
|
|
300
293
|
});
|
|
301
|
-
if (
|
|
294
|
+
if (typeof selection === "string") return textResult(selection, true);
|
|
295
|
+
const model = selection.model;
|
|
302
296
|
|
|
303
297
|
// The whole branch shares the root session's transcript directory; read it
|
|
304
298
|
// off the owning parent rather than this child session's own id.
|
|
305
299
|
const rootSessionId = context.manager.getRecord(context.parentAgentId)?.rootSessionId;
|
|
306
300
|
const childDepth = context.depth + 1;
|
|
307
301
|
const options: NestedSpawnOptions = {
|
|
302
|
+
homeDir: context.homeDir,
|
|
308
303
|
description: params.description,
|
|
309
304
|
model,
|
|
305
|
+
modelSelection: selection,
|
|
310
306
|
maxTurns: invocation.maxTurns,
|
|
311
307
|
isolated: invocation.isolated,
|
|
312
308
|
inheritContext: invocation.inheritContext,
|
package/src/output-file.ts
CHANGED
|
@@ -5,10 +5,12 @@
|
|
|
5
5
|
* matching Claude Code's task output file format.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { appendFileSync, chmodSync, mkdirSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
9
9
|
import { tmpdir } from "node:os";
|
|
10
10
|
import { join } from "node:path";
|
|
11
11
|
import type { AgentSession, AgentSessionEvent } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import { type ModelFallback, modelFallbackNote } from "./model-resolver.js";
|
|
13
|
+
import { type TierDisclosure, tierDisclosure, tierNote } from "./model-tiers.js";
|
|
12
14
|
|
|
13
15
|
/**
|
|
14
16
|
* Project/global default for writing a subagent's `.output` transcript; a custom
|
|
@@ -80,6 +82,26 @@ export function ensureOutputFile(path: string): void {
|
|
|
80
82
|
} catch { /* ignore — streaming writes are best-effort too */ }
|
|
81
83
|
}
|
|
82
84
|
|
|
85
|
+
/** Append the model-fallback disclosure as a transcript line (best effort, like streaming writes). */
|
|
86
|
+
export function appendModelFallbackEntry(path: string, agentId: string, fallback: ModelFallback): void {
|
|
87
|
+
try {
|
|
88
|
+
// A resumed agent reuses its transcript, which may already say it.
|
|
89
|
+
if (existsSync(path) && readFileSync(path, "utf-8").includes('"type":"model-fallback"')) return;
|
|
90
|
+
appendFileSync(
|
|
91
|
+
path,
|
|
92
|
+
`${JSON.stringify({ isSidechain: true, agentId, type: "model-fallback", modelFallback: fallback, note: modelFallbackNote(fallback), timestamp: new Date().toISOString() })}\n`,
|
|
93
|
+
"utf-8",
|
|
94
|
+
);
|
|
95
|
+
} catch { /* ignore */ }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Append tier provenance separately from assistant messages for durable replay. */
|
|
99
|
+
export function appendModelTierEntry(path: string, agentId: string, modelTier: TierDisclosure): void {
|
|
100
|
+
try {
|
|
101
|
+
appendFileSync(path, `${JSON.stringify({ isSidechain: true, agentId, type: "model-tier", modelTier: tierDisclosure(modelTier), note: tierNote(modelTier), timestamp: new Date().toISOString() })}\n`, "utf-8");
|
|
102
|
+
} catch { /* transcript writes are best effort */ }
|
|
103
|
+
}
|
|
104
|
+
|
|
83
105
|
/** Write the initial user prompt entry. */
|
|
84
106
|
export function writeInitialEntry(path: string, agentId: string, prompt: string, cwd: string): void {
|
|
85
107
|
const entry = {
|
package/src/package-resources.ts
CHANGED
|
@@ -55,7 +55,8 @@
|
|
|
55
55
|
|
|
56
56
|
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
57
57
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
58
|
-
import { DefaultPackageManager,
|
|
58
|
+
import { DefaultPackageManager, SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
59
|
+
import { normalizeHomePath, resolveAgentDir } from "./home-paths.js";
|
|
59
60
|
|
|
60
61
|
/** Resource kinds a package may declare. Agents are `.md`, workflows are `.js`. */
|
|
61
62
|
export type PackageResourceKind = "agents" | "workflows";
|
|
@@ -103,6 +104,8 @@ export interface PiPackage {
|
|
|
103
104
|
export type PackageGate = boolean | string[] | undefined;
|
|
104
105
|
|
|
105
106
|
export interface PackageDiscoveryOptions {
|
|
107
|
+
/** Home directory for user settings and installed resources, resolved at call time. */
|
|
108
|
+
homeDir?: string;
|
|
106
109
|
/**
|
|
107
110
|
* Pi's project-trust state for `cwd`, from `ctx.isProjectTrusted()`. When
|
|
108
111
|
* false, `.pi/settings.json` is not read — so a package configured only by an
|
|
@@ -376,14 +379,26 @@ interface ConfiguredPackageRow {
|
|
|
376
379
|
* so a later pi that drops or renames this API contributes no packages instead
|
|
377
380
|
* of failing activation.
|
|
378
381
|
*/
|
|
379
|
-
function configuredPackages(cwd: string, projectTrusted: boolean): ConfiguredPackageRow[] {
|
|
382
|
+
function configuredPackages(cwd: string, projectTrusted: boolean, homeDir?: string): ConfiguredPackageRow[] {
|
|
380
383
|
try {
|
|
381
384
|
if (typeof DefaultPackageManager !== "function") return [];
|
|
382
|
-
const agentDir =
|
|
385
|
+
const agentDir = resolveAgentDir(homeDir);
|
|
383
386
|
const settingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted });
|
|
384
387
|
const manager = new DefaultPackageManager({ cwd, agentDir, settingsManager });
|
|
385
388
|
if (typeof manager.listConfiguredPackages !== "function") return [];
|
|
386
|
-
return manager.listConfiguredPackages();
|
|
389
|
+
if (homeDir === undefined) return manager.listConfiguredPackages();
|
|
390
|
+
// Pi's enumerator expands local package sources through its own ambient
|
|
391
|
+
// home. Reuse its public installed-path resolver with normalized inputs,
|
|
392
|
+
// preserving the original source for package-gate matching and diagnostics.
|
|
393
|
+
const rows: ConfiguredPackageRow[] = [];
|
|
394
|
+
for (const scope of ["user", "project"] as const) {
|
|
395
|
+
const settings = scope === "user" ? settingsManager.getGlobalSettings() : settingsManager.getProjectSettings();
|
|
396
|
+
for (const pkg of settings.packages ?? []) {
|
|
397
|
+
const source = typeof pkg === "string" ? pkg : pkg.source;
|
|
398
|
+
rows.push({ source, scope, installedPath: manager.getInstalledPath(normalizeHomePath(source, homeDir), scope) });
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
return rows;
|
|
387
402
|
} catch {
|
|
388
403
|
return [];
|
|
389
404
|
}
|
|
@@ -394,10 +409,10 @@ function configuredPackages(cwd: string, projectTrusted: boolean): ConfiguredPac
|
|
|
394
409
|
* order (global entries first, then project). Duplicates by root are collapsed —
|
|
395
410
|
* a package listed in both scopes is one package on disk.
|
|
396
411
|
*/
|
|
397
|
-
export function listPiPackages(cwd: string, projectTrusted = false): PiPackage[] {
|
|
412
|
+
export function listPiPackages(cwd: string, projectTrusted = false, homeDir?: string): PiPackage[] {
|
|
398
413
|
const seen = new Set<string>();
|
|
399
414
|
const out: PiPackage[] = [];
|
|
400
|
-
for (const entry of configuredPackages(cwd, projectTrusted)) {
|
|
415
|
+
for (const entry of configuredPackages(cwd, projectTrusted, homeDir)) {
|
|
401
416
|
const root = entry.installedPath;
|
|
402
417
|
if (!root || seen.has(root)) continue;
|
|
403
418
|
const manifest = readPackageJson(root);
|
|
@@ -484,9 +499,9 @@ export function invalidatePackageCache(): void {
|
|
|
484
499
|
cache.clear();
|
|
485
500
|
}
|
|
486
501
|
|
|
487
|
-
function scan(cwd: string, projectTrusted: boolean, gate: PackageGate): CacheEntry {
|
|
502
|
+
function scan(cwd: string, projectTrusted: boolean, gate: PackageGate, homeDir?: string): CacheEntry {
|
|
488
503
|
const entry = emptyEntry();
|
|
489
|
-
for (const pkg of listPiPackages(cwd, projectTrusted)) {
|
|
504
|
+
for (const pkg of listPiPackages(cwd, projectTrusted, homeDir)) {
|
|
490
505
|
if (!packageAllowed(pkg, gate)) continue;
|
|
491
506
|
// The root is normalized here, not at lookup time: the declared paths below
|
|
492
507
|
// go through `resolve(root, entry)`, so a root that arrived with a trailing
|
|
@@ -520,10 +535,10 @@ function cacheEntryFor(cwd: string, kind: PackageResourceKind, opts: PackageDisc
|
|
|
520
535
|
// `\u0000` written as an escape, not a raw byte: a literal NUL makes this
|
|
521
536
|
// whole file read as binary to grep, ripgrep and git, which silently drops
|
|
522
537
|
// it from search results.
|
|
523
|
-
const key = `${cwd}\u0000${projectTrusted ? 1 : 0}\u0000${JSON.stringify(gate ?? true)}`;
|
|
538
|
+
const key = `${cwd}\u0000${opts.homeDir ?? ""}\u0000${resolveAgentDir(opts.homeDir)}\u0000${projectTrusted ? 1 : 0}\u0000${JSON.stringify(gate ?? true)}`;
|
|
524
539
|
let hit = cache.get(key);
|
|
525
540
|
if (!hit) {
|
|
526
|
-
hit = scan(cwd, projectTrusted, gate);
|
|
541
|
+
hit = scan(cwd, projectTrusted, gate, opts.homeDir);
|
|
527
542
|
cache.set(key, hit);
|
|
528
543
|
}
|
|
529
544
|
return hit;
|
package/src/process-budget.ts
CHANGED
|
@@ -247,10 +247,13 @@ function admits(s: BudgetState, depth: number): boolean {
|
|
|
247
247
|
if (level > top) return false;
|
|
248
248
|
// Leases deeper than `top` cannot exist while it is on; clamp so they still count in the total.
|
|
249
249
|
const atDepth = new Array<number>(top + 1).fill(0);
|
|
250
|
-
for (const lease of s.active)
|
|
250
|
+
for (const lease of s.active) {
|
|
251
|
+
const index = Math.min(top, Math.max(1, lease.depth));
|
|
252
|
+
atDepth[index] = atDepth[index]! + 1;
|
|
253
|
+
}
|
|
251
254
|
let atOrAbove = 0;
|
|
252
255
|
for (let j = 1; j <= top; j++) {
|
|
253
|
-
atOrAbove += atDepth[j]
|
|
256
|
+
atOrAbove += atDepth[j]!;
|
|
254
257
|
if (j >= level && atOrAbove >= admissionLimit(policy, j)) return false;
|
|
255
258
|
}
|
|
256
259
|
return true;
|
|
@@ -322,7 +325,7 @@ export function pumpBudget(): void {
|
|
|
322
325
|
for (;;) {
|
|
323
326
|
const i = s.waiters.findIndex(w => admits(s, w.depth) && w.ready());
|
|
324
327
|
if (i === -1) break;
|
|
325
|
-
const
|
|
328
|
+
const waiter = s.waiters.splice(i, 1)[0]!;
|
|
326
329
|
const lease = newLease(s, waiter.depth);
|
|
327
330
|
try {
|
|
328
331
|
waiter.grant(lease);
|
package/src/quota-admission.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Quota tracking lives in a separate extension (nredd/pi-subscription-router),
|
|
5
5
|
* reached over `pi.events` with the same envelope as `cross-extension-rpc.ts`:
|
|
6
6
|
* emit `router:rpc:admit` `{ requestId, model }`, reply on
|
|
7
|
-
* `router:rpc:admit:reply:<requestId>` as `{ success, data?: { ok, resolvedModel?, resetAt?, wait? } }` (`resolvedModel`
|
|
7
|
+
* `router:rpc:admit:reply:<requestId>` as `{ success, data?: { ok, resolvedModel?, resetAt?, wait? } }` (`resolvedModel` labels the admitted physical model).
|
|
8
8
|
*
|
|
9
9
|
* Admission fails open. No router installed, a slow router, a malformed reply
|
|
10
10
|
* or a router error all admit the dispatch: quota is advisory here, and the
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import { nanoid } from "nanoid";
|
|
15
15
|
import { type Clock, realClock } from "./clock.js";
|
|
16
|
+
import type { TierAdmission } from "./model-tiers.js";
|
|
16
17
|
|
|
17
18
|
export const ADMIT_CHANNEL = "router:rpc:admit";
|
|
18
19
|
|
|
@@ -50,34 +51,45 @@ export function modelRef(model: { provider: string; id: string }): string {
|
|
|
50
51
|
}
|
|
51
52
|
|
|
52
53
|
/** Resolve to the block when the router refuses `model`, else undefined. */
|
|
53
|
-
export function checkAdmission(
|
|
54
|
+
export async function checkAdmission(
|
|
54
55
|
events: AdmissionEvents,
|
|
55
56
|
model: string,
|
|
56
57
|
timeoutMs = ADMIT_TIMEOUT_MS,
|
|
57
58
|
clock: Clock = realClock,
|
|
58
59
|
): Promise<QuotaBlock | undefined> {
|
|
60
|
+
return (await admissionOutcome(events, model, timeoutMs, clock)).block;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Retain the physical admission label and whether the router supplied a signal. */
|
|
64
|
+
export function admissionOutcome(
|
|
65
|
+
events: AdmissionEvents,
|
|
66
|
+
model: string,
|
|
67
|
+
timeoutMs = ADMIT_TIMEOUT_MS,
|
|
68
|
+
clock: Clock = realClock,
|
|
69
|
+
): Promise<TierAdmission> {
|
|
59
70
|
const requestId = nanoid();
|
|
60
71
|
return new Promise(resolve => {
|
|
61
72
|
let done = false;
|
|
62
|
-
const finish = (
|
|
73
|
+
const finish = (outcome: TierAdmission) => {
|
|
63
74
|
if (done) return;
|
|
64
75
|
done = true;
|
|
65
76
|
timer.cancel();
|
|
66
77
|
unsubscribe();
|
|
67
|
-
resolve(
|
|
78
|
+
resolve(outcome);
|
|
68
79
|
};
|
|
69
80
|
const unsubscribe = events.on(`${ADMIT_CHANNEL}:reply:${requestId}`, raw => {
|
|
70
|
-
const reply = raw as { success?: unknown; data?: { ok?: unknown; resetAt?: unknown; wait?: unknown } } | undefined;
|
|
71
|
-
if (reply?.success !== true || reply.data?.ok !==
|
|
81
|
+
const reply = raw as { success?: unknown; data?: { ok?: unknown; resolvedModel?: unknown; resetAt?: unknown; wait?: unknown } } | undefined;
|
|
82
|
+
if (reply?.success !== true || typeof reply.data?.ok !== "boolean") return finish({ quotaSignal: false });
|
|
83
|
+
if (reply.data.ok) return finish({ quotaSignal: true, resolvedModel: typeof reply.data.resolvedModel === "string" ? reply.data.resolvedModel : undefined });
|
|
72
84
|
const resetAt = reply.data.resetAt;
|
|
73
85
|
const wait = reply.data.wait;
|
|
74
|
-
finish({
|
|
86
|
+
finish({ quotaSignal: true, block: {
|
|
75
87
|
model,
|
|
76
88
|
resetAt: typeof resetAt === "number" && Number.isFinite(resetAt) ? resetAt : undefined,
|
|
77
89
|
...(typeof wait === "boolean" ? { wait } : {}),
|
|
78
|
-
});
|
|
90
|
+
} });
|
|
79
91
|
});
|
|
80
|
-
const timer = clock.setTimeout(() => finish(
|
|
92
|
+
const timer = clock.setTimeout(() => finish({ quotaSignal: false }), timeoutMs);
|
|
81
93
|
events.emit(ADMIT_CHANNEL, { requestId, model });
|
|
82
94
|
});
|
|
83
95
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/** Normalize explicit settings paths before Pi's real package manager resolves them. */
|
|
2
|
+
import { DefaultResourceLoader, SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { normalizeHomePath } from "./home-paths.js";
|
|
4
|
+
|
|
5
|
+
type ResourceLoaderOptions = ConstructorParameters<typeof DefaultResourceLoader>[0];
|
|
6
|
+
type ResourceSettings = ReturnType<SettingsManager["getGlobalSettings"]>;
|
|
7
|
+
|
|
8
|
+
const RESOURCE_FIELDS = ["extensions", "skills", "prompts", "themes"] as const;
|
|
9
|
+
|
|
10
|
+
/** Keep filter modifiers and non-home paths intact; only replace leading home references. */
|
|
11
|
+
function expandSetting(entry: string, homeDir: string, modifiers = true): string {
|
|
12
|
+
const prefix = modifiers && /^[!+-]/.test(entry) ? entry[0] : "";
|
|
13
|
+
const path = entry.slice(prefix.length);
|
|
14
|
+
const trimmed = path.trim();
|
|
15
|
+
if (trimmed !== "~" && !trimmed.startsWith("~/") && !(process.platform === "win32" && trimmed.startsWith("~\\"))) return entry;
|
|
16
|
+
return prefix + normalizeHomePath(trimmed, homeDir);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Return copies so loading resources never changes persisted settings or their source objects. */
|
|
20
|
+
export function normalizeResourceSettings(settings: ResourceSettings, homeDir: string): ResourceSettings {
|
|
21
|
+
const normalized = { ...settings };
|
|
22
|
+
for (const field of RESOURCE_FIELDS) {
|
|
23
|
+
if (settings[field]) normalized[field] = settings[field].map(entry => expandSetting(entry, homeDir));
|
|
24
|
+
}
|
|
25
|
+
if (settings.packages) {
|
|
26
|
+
normalized.packages = settings.packages.map(pkg => {
|
|
27
|
+
if (typeof pkg === "string") return expandSetting(pkg, homeDir, false);
|
|
28
|
+
const copy = { ...pkg, source: expandSetting(pkg.source, homeDir, false) };
|
|
29
|
+
for (const field of RESOURCE_FIELDS) {
|
|
30
|
+
if (pkg[field]) copy[field] = pkg[field].map(entry => expandSetting(entry, homeDir));
|
|
31
|
+
}
|
|
32
|
+
return copy;
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
return normalized;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Use the real loader/manager while preventing ambient-home expansion of explicit settings. */
|
|
39
|
+
export function createHomeResourceLoader(options: ResourceLoaderOptions, homeDir?: string): DefaultResourceLoader {
|
|
40
|
+
if (homeDir === undefined) return new DefaultResourceLoader(options);
|
|
41
|
+
const settingsManager = options.settingsManager ?? SettingsManager.create(options.cwd, options.agentDir);
|
|
42
|
+
const globalSettings = settingsManager.getGlobalSettings.bind(settingsManager);
|
|
43
|
+
const projectSettings = settingsManager.getProjectSettings.bind(settingsManager);
|
|
44
|
+
// Wrap the public readers, not a cached snapshot: reload and project-trust
|
|
45
|
+
// decisions continue to be owned by Pi, and every read receives fresh copies.
|
|
46
|
+
Object.assign(settingsManager, {
|
|
47
|
+
getGlobalSettings: () => normalizeResourceSettings(globalSettings(), homeDir),
|
|
48
|
+
getProjectSettings: () => normalizeResourceSettings(projectSettings(), homeDir),
|
|
49
|
+
});
|
|
50
|
+
return new DefaultResourceLoader({ ...options, settingsManager });
|
|
51
|
+
}
|
package/src/schedule.ts
CHANGED
|
@@ -15,15 +15,14 @@
|
|
|
15
15
|
* `subagent-notification` path. No new delivery code.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import type { Model } from "@earendil-works/pi-ai";
|
|
19
18
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
20
19
|
import { Cron } from "croner";
|
|
21
20
|
import { nanoid } from "nanoid";
|
|
22
21
|
import type { AgentManager } from "./agent-manager.js";
|
|
23
22
|
import { normalizeMaxTurns } from "./agent-runner.js";
|
|
24
|
-
import { resolveSpawnType } from "./agent-types.js";
|
|
23
|
+
import { getAgentConfig, resolveSpawnType } from "./agent-types.js";
|
|
25
24
|
import { type Clock, realClock, type TimerHandle } from "./clock.js";
|
|
26
|
-
import {
|
|
25
|
+
import { admitModelSelection, type ModelSelection, selectModel } from "./model-selection.js";
|
|
27
26
|
import { checkAdmission, describeBlock, mayPark, modelRef, PARKED_RESUME_DELAY_MS, QUOTA_RESTART_GRACE_MS } from "./quota-admission.js";
|
|
28
27
|
import type { ScheduleStore } from "./schedule-store.js";
|
|
29
28
|
import type { IsolationMode, ScheduledSubagent, SubagentType, ThinkingLevel } from "./types.js";
|
|
@@ -54,6 +53,7 @@ export interface NewJobInput {
|
|
|
54
53
|
|
|
55
54
|
/** Optional in-process scheduling dependencies; omitted clock uses native time. */
|
|
56
55
|
export interface SubagentSchedulerOptions {
|
|
56
|
+
homeDir?: string;
|
|
57
57
|
clock?: Clock;
|
|
58
58
|
}
|
|
59
59
|
|
|
@@ -66,7 +66,7 @@ export class SubagentScheduler {
|
|
|
66
66
|
private ctx: ExtensionContext | undefined;
|
|
67
67
|
private manager: AgentManager | undefined;
|
|
68
68
|
|
|
69
|
-
constructor(options: SubagentSchedulerOptions = {}) {
|
|
69
|
+
constructor(private readonly options: SubagentSchedulerOptions = {}) {
|
|
70
70
|
this.clock = options.clock ?? realClock;
|
|
71
71
|
}
|
|
72
72
|
|
|
@@ -247,22 +247,34 @@ export class SubagentScheduler {
|
|
|
247
247
|
|
|
248
248
|
store.update(id, { lastStatus: "running" });
|
|
249
249
|
|
|
250
|
-
// Resolve
|
|
251
|
-
//
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
250
|
+
// Resolve the original request at fire time, using the same type and model
|
|
251
|
+
// policy as a manual dispatch. Never drop a failed explicit override.
|
|
252
|
+
const dispatch = resolveSpawnType(job.subagent_type);
|
|
253
|
+
const config = dispatch.ok ? getAgentConfig(dispatch.type) : undefined;
|
|
254
|
+
const selection = dispatch.ok ? selectModel({
|
|
255
|
+
homeDir: this.options.homeDir,
|
|
256
|
+
agentType: dispatch.type, definitionModel: config?.model, agentLabel: config?.displayName,
|
|
257
|
+
paramModel: job.model, parentModel: ctx.model, registry: ctx.modelRegistry, cwd: ctx.cwd,
|
|
258
|
+
}) : dispatch.message;
|
|
259
|
+
if (typeof selection === "string") {
|
|
260
|
+
store.update(id, { lastRun: new Date(this.clock.now()).toISOString(), lastStatus: "error" });
|
|
261
|
+
this.emit({ type: "error", jobId: id, error: selection });
|
|
262
|
+
return;
|
|
257
263
|
}
|
|
258
264
|
|
|
265
|
+
if (selection.scopeWarning) ctx.ui.notify(selection.scopeWarning, "warning");
|
|
266
|
+
|
|
259
267
|
// A quota-parked job re-asks the router before starting: the window it
|
|
260
268
|
// waited for may have refilled while another is still exhausted.
|
|
261
|
-
const admissionModel =
|
|
262
|
-
if (job.quotaParked && admissionModel) {
|
|
263
|
-
|
|
269
|
+
const admissionModel = selection.model;
|
|
270
|
+
if ((job.quotaParked || selection.tierBinding) && admissionModel) {
|
|
271
|
+
const admission = selection.tierBinding
|
|
272
|
+
? admitModelSelection(selection, ctx.modelRegistry, pi.events)
|
|
273
|
+
: checkAdmission(pi.events, modelRef(admissionModel));
|
|
274
|
+
void admission.then(block => {
|
|
275
|
+
if (this.store !== store || this.ctx !== ctx) return;
|
|
264
276
|
if (!block) {
|
|
265
|
-
this.spawnJob(id,
|
|
277
|
+
this.spawnJob(id, selection);
|
|
266
278
|
} else if (mayPark(block) && block.resetAt > this.clock.now()) {
|
|
267
279
|
this.repark(id, block.resetAt);
|
|
268
280
|
} else {
|
|
@@ -272,10 +284,14 @@ export class SubagentScheduler {
|
|
|
272
284
|
this.store?.update(id, { lastRun: new Date(this.clock.now()).toISOString(), lastStatus: "error" });
|
|
273
285
|
this.emit({ type: "error", jobId: id, error: `${describeBlock(block)} The agent was not started.` });
|
|
274
286
|
}
|
|
287
|
+
}).catch(error => {
|
|
288
|
+
if (this.store !== store) return;
|
|
289
|
+
store.update(id, { lastStatus: "error" });
|
|
290
|
+
this.emit({ type: "error", jobId: id, error: error instanceof Error ? error.message : String(error) });
|
|
275
291
|
});
|
|
276
292
|
return;
|
|
277
293
|
}
|
|
278
|
-
this.spawnJob(id,
|
|
294
|
+
this.spawnJob(id, selection);
|
|
279
295
|
}
|
|
280
296
|
|
|
281
297
|
/** Re-arm a quota-parked one-shot at a later reset instead of starting it. */
|
|
@@ -285,11 +301,12 @@ export class SubagentScheduler {
|
|
|
285
301
|
enabled: true,
|
|
286
302
|
schedule: new Date(resetAt + QUOTA_RESTART_GRACE_MS).toISOString(),
|
|
287
303
|
lastStatus: undefined,
|
|
304
|
+
quotaParked: true,
|
|
288
305
|
});
|
|
289
306
|
}
|
|
290
307
|
|
|
291
308
|
/** Spawn a fired job (bypassing the concurrency queue) and persist its outcome. */
|
|
292
|
-
private spawnJob(id: string,
|
|
309
|
+
private spawnJob(id: string, selection: ModelSelection): void {
|
|
293
310
|
const store = this.store;
|
|
294
311
|
const pi = this.pi;
|
|
295
312
|
const ctx = this.ctx;
|
|
@@ -312,7 +329,8 @@ export class SubagentScheduler {
|
|
|
312
329
|
description: job.description,
|
|
313
330
|
isBackground: true,
|
|
314
331
|
bypassQueue: true,
|
|
315
|
-
model:
|
|
332
|
+
model: selection.model,
|
|
333
|
+
modelSelection: selection,
|
|
316
334
|
maxTurns: job.max_turns,
|
|
317
335
|
isolated: job.isolated,
|
|
318
336
|
thinkingLevel: job.thinking,
|
package/src/settings.ts
CHANGED
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
|
|
5
5
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
6
6
|
import { dirname, join } from "node:path";
|
|
7
|
-
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
8
7
|
import { NO_FALLBACK } from "./agent-types.js";
|
|
9
8
|
import { readEnterpriseLocalManifest } from "./enterprise-local.js";
|
|
9
|
+
import { resolveAgentDir } from "./home-paths.js";
|
|
10
|
+
import { type ModelResolution, parseTierBinding, setModelTiers } from "./model-tiers.js";
|
|
10
11
|
import { type BudgetPolicy, DEFAULT_BUDGET_POLICY } from "./process-budget.js";
|
|
11
12
|
import type { AgentMentionMode, JoinMode, ViewerMarkdownMode, WidgetMode } from "./types.js";
|
|
12
13
|
|
|
@@ -268,6 +269,10 @@ export interface SubagentsSettings {
|
|
|
268
269
|
fallbackSubagent?: string;
|
|
269
270
|
/** Trusted-global logical name -> ordered canonical provider/model candidates. */
|
|
270
271
|
modelAliases?: Record<string, string[]>;
|
|
272
|
+
/** Trusted global/enterprise exact model bindings; never read from a project. */
|
|
273
|
+
modelTiers?: Record<string, string[]>;
|
|
274
|
+
/** Missing/unavailable tier policy. Both default to inherit with disclosure. */
|
|
275
|
+
modelResolution?: ModelResolution;
|
|
271
276
|
/**
|
|
272
277
|
* Trusted-global map of agent `type` (or `"*"` for every type) -> model, in
|
|
273
278
|
* any form `Agent({ model })` accepts. Precedence, highest first: the Agent
|
|
@@ -576,6 +581,24 @@ function sanitize(raw: unknown): SubagentsSettings {
|
|
|
576
581
|
} else if (typeof r.fallbackSubagent === "string" && r.fallbackSubagent.trim()) {
|
|
577
582
|
out.fallbackSubagent = r.fallbackSubagent.trim();
|
|
578
583
|
}
|
|
584
|
+
if (r.modelTiers !== undefined) {
|
|
585
|
+
out.modelTiers = {};
|
|
586
|
+
if (r.modelTiers && typeof r.modelTiers === "object" && !Array.isArray(r.modelTiers)) {
|
|
587
|
+
for (const [tier, members] of Object.entries(r.modelTiers)) {
|
|
588
|
+
const binding = parseTierBinding(tier, members);
|
|
589
|
+
if (binding) out.modelTiers[tier] = binding.kind === "routed" ? [binding.profile] : binding.members;
|
|
590
|
+
else console.warn(`[pi-subagents] Ignoring invalid modelTiers entry "${tier}".`);
|
|
591
|
+
}
|
|
592
|
+
} else console.warn("[pi-subagents] Ignoring modelTiers: expected an object of tier -> model list.");
|
|
593
|
+
}
|
|
594
|
+
if (r.modelResolution && typeof r.modelResolution === "object" && !Array.isArray(r.modelResolution)) {
|
|
595
|
+
out.modelResolution = {};
|
|
596
|
+
for (const key of ["onUnmapped", "onUnavailable"] as const) {
|
|
597
|
+
const mode = (r.modelResolution as Record<string, unknown>)[key];
|
|
598
|
+
if (mode === "error" || mode === "inherit") out.modelResolution[key] = mode;
|
|
599
|
+
else if (mode !== undefined) console.warn(`[pi-subagents] Ignoring invalid modelResolution.${key}.`);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
579
602
|
if (r.modelAliases !== undefined) {
|
|
580
603
|
const aliases = sanitizeModelAliases(r.modelAliases);
|
|
581
604
|
if (aliases) out.modelAliases = aliases;
|
|
@@ -630,8 +653,8 @@ function sanitizeModelAliases(raw: unknown): Record<string, string[]> | undefine
|
|
|
630
653
|
return aliases;
|
|
631
654
|
}
|
|
632
655
|
|
|
633
|
-
function globalPath(): string {
|
|
634
|
-
return join(
|
|
656
|
+
function globalPath(homeDir?: string): string {
|
|
657
|
+
return join(resolveAgentDir(homeDir), "subagents.json");
|
|
635
658
|
}
|
|
636
659
|
|
|
637
660
|
function projectPath(cwd: string): string {
|
|
@@ -655,16 +678,21 @@ function readSettingsFile(path: string): SubagentsSettings {
|
|
|
655
678
|
}
|
|
656
679
|
|
|
657
680
|
/** Load merged settings: global provides defaults, project overrides. */
|
|
658
|
-
export function loadSettings(cwd: string = process.cwd()): SubagentsSettings {
|
|
681
|
+
export function loadSettings(cwd: string = process.cwd(), homeDir?: string): SubagentsSettings {
|
|
659
682
|
// Aliases pick the provider a subagent runs on, so they are machine
|
|
660
683
|
// configuration: a cloned repo's project file must not redirect them.
|
|
661
684
|
const {
|
|
662
685
|
modelAliases: projectModelAliases,
|
|
686
|
+
modelTiers: projectModelTiers,
|
|
687
|
+
modelResolution: projectModelResolution,
|
|
663
688
|
agentModels: projectAgentModels,
|
|
664
689
|
maxActiveSubagents: projectMaxActive,
|
|
665
690
|
nestedSubagentReserve: projectReserve,
|
|
666
691
|
...project
|
|
667
692
|
} = readSettingsFile(projectPath(cwd));
|
|
693
|
+
if (projectModelTiers !== undefined || projectModelResolution !== undefined) {
|
|
694
|
+
console.warn("[pi-subagents] Ignoring modelTiers/modelResolution in project .pi/subagents.json: model policy is read from trusted global configuration only.");
|
|
695
|
+
}
|
|
668
696
|
if (projectModelAliases !== undefined) {
|
|
669
697
|
console.warn("[pi-subagents] Ignoring modelAliases in project .pi/subagents.json: aliases are read from trusted global configuration only.");
|
|
670
698
|
}
|
|
@@ -676,14 +704,14 @@ export function loadSettings(cwd: string = process.cwd()): SubagentsSettings {
|
|
|
676
704
|
if (projectMaxActive !== undefined || projectReserve !== undefined) {
|
|
677
705
|
console.warn("[pi-subagents] Ignoring maxActiveSubagents/nestedSubagentReserve in project .pi/subagents.json: the process-wide budget is read from global configuration only.");
|
|
678
706
|
}
|
|
679
|
-
return { ...trustedSettings(), ...project };
|
|
707
|
+
return { ...trustedSettings(homeDir), ...project };
|
|
680
708
|
}
|
|
681
709
|
|
|
682
710
|
/** Global settings with the enterprise manifest over them: the layer a project file cannot touch. */
|
|
683
|
-
function trustedSettings(): SubagentsSettings {
|
|
684
|
-
const enterprise = readEnterpriseLocalManifest()?.subagents;
|
|
711
|
+
function trustedSettings(homeDir?: string): SubagentsSettings {
|
|
712
|
+
const enterprise = readEnterpriseLocalManifest(homeDir)?.subagents;
|
|
685
713
|
const enterpriseSettings = enterprise === undefined ? {} : sanitize(enterprise);
|
|
686
|
-
return { ...readSettingsFile(globalPath()), ...enterpriseSettings };
|
|
714
|
+
return { ...readSettingsFile(globalPath(homeDir)), ...enterpriseSettings };
|
|
687
715
|
}
|
|
688
716
|
|
|
689
717
|
/**
|
|
@@ -692,8 +720,8 @@ function trustedSettings(): SubagentsSettings {
|
|
|
692
720
|
* how deep its own sessions nest, but not what the process-wide reserve
|
|
693
721
|
* covers. A missing key means its default, so removing the cap turns it off.
|
|
694
722
|
*/
|
|
695
|
-
export function loadBudgetPolicy(): BudgetPolicy {
|
|
696
|
-
const trusted = trustedSettings();
|
|
723
|
+
export function loadBudgetPolicy(homeDir?: string): BudgetPolicy {
|
|
724
|
+
const trusted = trustedSettings(homeDir);
|
|
697
725
|
return {
|
|
698
726
|
maxActive: trusted.maxActiveSubagents ?? DEFAULT_BUDGET_POLICY.maxActive,
|
|
699
727
|
nestedReserve: trusted.nestedSubagentReserve ?? DEFAULT_BUDGET_POLICY.nestedReserve,
|
|
@@ -728,6 +756,7 @@ export function applySettings(s: SubagentsSettings, appliers: SettingsAppliers):
|
|
|
728
756
|
if (typeof s.maxSubagentDepth === "number") appliers.setMaxSubagentDepth(s.maxSubagentDepth);
|
|
729
757
|
if (typeof s.fallbackSubagent === "string") appliers.setFallbackSubagent(s.fallbackSubagent);
|
|
730
758
|
// Module state: reset when a session omits it, or the previous activation's aliases leak.
|
|
759
|
+
setModelTiers(s.modelTiers, s.modelResolution);
|
|
731
760
|
appliers.setModelAliases(s.modelAliases);
|
|
732
761
|
appliers.setAgentModels(s.agentModels);
|
|
733
762
|
if (s.defaultJoinMode) appliers.setDefaultJoinMode(s.defaultJoinMode);
|
|
@@ -775,10 +804,11 @@ export function applyAndEmitLoaded(
|
|
|
775
804
|
appliers: SettingsAppliers,
|
|
776
805
|
emit: SettingsEmit,
|
|
777
806
|
cwd: string = process.cwd(),
|
|
807
|
+
homeDir?: string,
|
|
778
808
|
): SubagentsSettings {
|
|
779
|
-
const settings = loadSettings(cwd);
|
|
809
|
+
const settings = loadSettings(cwd, homeDir);
|
|
780
810
|
applySettings(settings, appliers);
|
|
781
|
-
appliers.setBudgetPolicy(loadBudgetPolicy());
|
|
811
|
+
appliers.setBudgetPolicy(loadBudgetPolicy(homeDir));
|
|
782
812
|
emit("subagents:settings_loaded", { settings });
|
|
783
813
|
return settings;
|
|
784
814
|
}
|