@nredd/pi-subagents 0.22.0-nredd.6 → 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 +160 -43
- package/package.json +8 -8
- package/src/agent-file-toggle.ts +6 -4
- package/src/agent-manager.ts +418 -118
- 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 +231 -107
- package/src/invocation-config.ts +8 -35
- 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 +31 -26
- package/src/output-file.ts +23 -1
- package/src/package-resources.ts +25 -10
- package/src/process-budget.ts +346 -0
- package/src/quota-admission.ts +21 -9
- package/src/resource-loader.ts +51 -0
- package/src/schedule.ts +41 -19
- package/src/settings.ts +111 -8
- package/src/skill-loader.ts +8 -7
- package/src/stale-ctx.ts +16 -0
- package/src/types.ts +33 -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
|
@@ -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,
|
|
@@ -357,7 +375,11 @@ export class SubagentScheduler {
|
|
|
357
375
|
// Terminal states: completed/steered = success; error/aborted/stopped = error.
|
|
358
376
|
// awaitStartup first: with isolation: "worktree" the run promise only exists
|
|
359
377
|
// once the repo copy is made, and a failed copy rejects here.
|
|
360
|
-
|
|
378
|
+
// The start gate comes first: `bypassQueue` skips the pool but not the
|
|
379
|
+
// process budget (`maxActiveSubagents`), so a fired job can sit "queued"
|
|
380
|
+
// with no startup and no promise yet, and would otherwise finalize at once.
|
|
381
|
+
Promise.resolve(manager.getRecord(agentId)?.startGate)
|
|
382
|
+
.then(() => manager.awaitStartup(agentId))
|
|
361
383
|
.then(() => manager.getRecord(agentId)?.promise)
|
|
362
384
|
.then(() => {
|
|
363
385
|
const r = manager.getRecord(agentId);
|
package/src/settings.ts
CHANGED
|
@@ -4,9 +4,11 @@
|
|
|
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";
|
|
11
|
+
import { type BudgetPolicy, DEFAULT_BUDGET_POLICY } from "./process-budget.js";
|
|
10
12
|
import type { AgentMentionMode, JoinMode, ViewerMarkdownMode, WidgetMode } from "./types.js";
|
|
11
13
|
|
|
12
14
|
export interface SubagentsSettings {
|
|
@@ -33,6 +35,30 @@ export interface SubagentsSettings {
|
|
|
33
35
|
* several blocking resumes in one message can still exceed the limit.
|
|
34
36
|
*/
|
|
35
37
|
maxConcurrentForeground?: number;
|
|
38
|
+
/**
|
|
39
|
+
* Process-wide ceiling on running subagents, across every manager in this pi
|
|
40
|
+
* process: top-level, nested, workflow, scheduled, RPC and mention spawns,
|
|
41
|
+
* and resumes, in the root session and in sessions a `/new`, `/resume`,
|
|
42
|
+
* `/fork` or `/reload` left running. `0` = off, the default. The process,
|
|
43
|
+
* not the machine: two pi processes each get their own. See
|
|
44
|
+
* `process-budget.ts`.
|
|
45
|
+
*
|
|
46
|
+
* Global configuration only, like `modelAliases`: a project file's value is
|
|
47
|
+
* ignored with a warning. Shared by every activation in the process, so the
|
|
48
|
+
* last one to apply its settings wins. A change that tightens it waits until
|
|
49
|
+
* no subagent in the process holds a slot (`setBudgetPolicy`).
|
|
50
|
+
*/
|
|
51
|
+
maxActiveSubagents?: number;
|
|
52
|
+
/**
|
|
53
|
+
* Slots of `maxActiveSubagents` held back per nesting level below a run, so
|
|
54
|
+
* parents that keep their slot while they wait can never starve their own
|
|
55
|
+
* children. Defaults to `2`; with the global `maxSubagentDepth: 2` that
|
|
56
|
+
* leaves `maxActiveSubagents - 2` for top-level runs. With nesting on it
|
|
57
|
+
* must be at least `1`, and `maxActiveSubagents` at least
|
|
58
|
+
* `nestedSubagentReserve * (maxSubagentDepth - 1) + 1`; a budget that
|
|
59
|
+
* breaks either is ignored with a warning, since it could deadlock.
|
|
60
|
+
*/
|
|
61
|
+
nestedSubagentReserve?: number;
|
|
36
62
|
/**
|
|
37
63
|
* 0 = unlimited — the extension's single source of truth for that convention:
|
|
38
64
|
* `normalizeMaxTurns()` in agent-runner.ts treats 0 → `undefined`, and the
|
|
@@ -243,6 +269,10 @@ export interface SubagentsSettings {
|
|
|
243
269
|
fallbackSubagent?: string;
|
|
244
270
|
/** Trusted-global logical name -> ordered canonical provider/model candidates. */
|
|
245
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;
|
|
246
276
|
/**
|
|
247
277
|
* Trusted-global map of agent `type` (or `"*"` for every type) -> model, in
|
|
248
278
|
* any form `Agent({ model })` accepts. Precedence, highest first: the Agent
|
|
@@ -357,6 +387,8 @@ export type ToolDescriptionMode = "full" | "compact" | "custom";
|
|
|
357
387
|
export interface SettingsAppliers {
|
|
358
388
|
setMaxConcurrent: (n: number) => void;
|
|
359
389
|
setMaxConcurrentForeground: (n: number) => void;
|
|
390
|
+
/** The process budget, from `loadBudgetPolicy`: global configuration only. */
|
|
391
|
+
setBudgetPolicy: (policy: BudgetPolicy) => void;
|
|
360
392
|
setDefaultMaxTurns: (n: number) => void;
|
|
361
393
|
setGraceTurns: (n: number) => void;
|
|
362
394
|
setDefaultJoinMode: (mode: JoinMode) => void;
|
|
@@ -442,6 +474,20 @@ function sanitize(raw: unknown): SubagentsSettings {
|
|
|
442
474
|
) {
|
|
443
475
|
out.maxConcurrentForeground = r.maxConcurrentForeground as number;
|
|
444
476
|
}
|
|
477
|
+
if (
|
|
478
|
+
Number.isInteger(r.maxActiveSubagents) &&
|
|
479
|
+
(r.maxActiveSubagents as number) >= 0 &&
|
|
480
|
+
(r.maxActiveSubagents as number) <= MAX_CONCURRENT_CEILING
|
|
481
|
+
) {
|
|
482
|
+
out.maxActiveSubagents = r.maxActiveSubagents as number;
|
|
483
|
+
}
|
|
484
|
+
if (
|
|
485
|
+
Number.isInteger(r.nestedSubagentReserve) &&
|
|
486
|
+
(r.nestedSubagentReserve as number) >= 0 &&
|
|
487
|
+
(r.nestedSubagentReserve as number) <= MAX_CONCURRENT_CEILING
|
|
488
|
+
) {
|
|
489
|
+
out.nestedSubagentReserve = r.nestedSubagentReserve as number;
|
|
490
|
+
}
|
|
445
491
|
if (
|
|
446
492
|
Number.isInteger(r.defaultMaxTurns) &&
|
|
447
493
|
(r.defaultMaxTurns as number) >= 0 &&
|
|
@@ -535,6 +581,24 @@ function sanitize(raw: unknown): SubagentsSettings {
|
|
|
535
581
|
} else if (typeof r.fallbackSubagent === "string" && r.fallbackSubagent.trim()) {
|
|
536
582
|
out.fallbackSubagent = r.fallbackSubagent.trim();
|
|
537
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
|
+
}
|
|
538
602
|
if (r.modelAliases !== undefined) {
|
|
539
603
|
const aliases = sanitizeModelAliases(r.modelAliases);
|
|
540
604
|
if (aliases) out.modelAliases = aliases;
|
|
@@ -589,8 +653,8 @@ function sanitizeModelAliases(raw: unknown): Record<string, string[]> | undefine
|
|
|
589
653
|
return aliases;
|
|
590
654
|
}
|
|
591
655
|
|
|
592
|
-
function globalPath(): string {
|
|
593
|
-
return join(
|
|
656
|
+
function globalPath(homeDir?: string): string {
|
|
657
|
+
return join(resolveAgentDir(homeDir), "subagents.json");
|
|
594
658
|
}
|
|
595
659
|
|
|
596
660
|
function projectPath(cwd: string): string {
|
|
@@ -614,19 +678,55 @@ function readSettingsFile(path: string): SubagentsSettings {
|
|
|
614
678
|
}
|
|
615
679
|
|
|
616
680
|
/** Load merged settings: global provides defaults, project overrides. */
|
|
617
|
-
export function loadSettings(cwd: string = process.cwd()): SubagentsSettings {
|
|
681
|
+
export function loadSettings(cwd: string = process.cwd(), homeDir?: string): SubagentsSettings {
|
|
618
682
|
// Aliases pick the provider a subagent runs on, so they are machine
|
|
619
683
|
// configuration: a cloned repo's project file must not redirect them.
|
|
620
|
-
const {
|
|
684
|
+
const {
|
|
685
|
+
modelAliases: projectModelAliases,
|
|
686
|
+
modelTiers: projectModelTiers,
|
|
687
|
+
modelResolution: projectModelResolution,
|
|
688
|
+
agentModels: projectAgentModels,
|
|
689
|
+
maxActiveSubagents: projectMaxActive,
|
|
690
|
+
nestedSubagentReserve: projectReserve,
|
|
691
|
+
...project
|
|
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
|
+
}
|
|
621
696
|
if (projectModelAliases !== undefined) {
|
|
622
697
|
console.warn("[pi-subagents] Ignoring modelAliases in project .pi/subagents.json: aliases are read from trusted global configuration only.");
|
|
623
698
|
}
|
|
624
699
|
if (projectAgentModels !== undefined) {
|
|
625
700
|
console.warn("[pi-subagents] Ignoring agentModels in project .pi/subagents.json: per-agent models are read from trusted global configuration only.");
|
|
626
701
|
}
|
|
627
|
-
|
|
702
|
+
// The process budget spans every session in the process, so no one
|
|
703
|
+
// project's file may set it, least of all a cloned one lifting the cap.
|
|
704
|
+
if (projectMaxActive !== undefined || projectReserve !== undefined) {
|
|
705
|
+
console.warn("[pi-subagents] Ignoring maxActiveSubagents/nestedSubagentReserve in project .pi/subagents.json: the process-wide budget is read from global configuration only.");
|
|
706
|
+
}
|
|
707
|
+
return { ...trustedSettings(homeDir), ...project };
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/** Global settings with the enterprise manifest over them: the layer a project file cannot touch. */
|
|
711
|
+
function trustedSettings(homeDir?: string): SubagentsSettings {
|
|
712
|
+
const enterprise = readEnterpriseLocalManifest(homeDir)?.subagents;
|
|
628
713
|
const enterpriseSettings = enterprise === undefined ? {} : sanitize(enterprise);
|
|
629
|
-
return { ...readSettingsFile(globalPath()), ...enterpriseSettings
|
|
714
|
+
return { ...readSettingsFile(globalPath(homeDir)), ...enterpriseSettings };
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* The process budget's policy, from trusted configuration only -- including
|
|
719
|
+
* its depth, the GLOBAL `maxSubagentDepth`: a project file may still change
|
|
720
|
+
* how deep its own sessions nest, but not what the process-wide reserve
|
|
721
|
+
* covers. A missing key means its default, so removing the cap turns it off.
|
|
722
|
+
*/
|
|
723
|
+
export function loadBudgetPolicy(homeDir?: string): BudgetPolicy {
|
|
724
|
+
const trusted = trustedSettings(homeDir);
|
|
725
|
+
return {
|
|
726
|
+
maxActive: trusted.maxActiveSubagents ?? DEFAULT_BUDGET_POLICY.maxActive,
|
|
727
|
+
nestedReserve: trusted.nestedSubagentReserve ?? DEFAULT_BUDGET_POLICY.nestedReserve,
|
|
728
|
+
maxDepth: trusted.maxSubagentDepth ?? DEFAULT_BUDGET_POLICY.maxDepth,
|
|
729
|
+
};
|
|
630
730
|
}
|
|
631
731
|
|
|
632
732
|
/**
|
|
@@ -656,6 +756,7 @@ export function applySettings(s: SubagentsSettings, appliers: SettingsAppliers):
|
|
|
656
756
|
if (typeof s.maxSubagentDepth === "number") appliers.setMaxSubagentDepth(s.maxSubagentDepth);
|
|
657
757
|
if (typeof s.fallbackSubagent === "string") appliers.setFallbackSubagent(s.fallbackSubagent);
|
|
658
758
|
// Module state: reset when a session omits it, or the previous activation's aliases leak.
|
|
759
|
+
setModelTiers(s.modelTiers, s.modelResolution);
|
|
659
760
|
appliers.setModelAliases(s.modelAliases);
|
|
660
761
|
appliers.setAgentModels(s.agentModels);
|
|
661
762
|
if (s.defaultJoinMode) appliers.setDefaultJoinMode(s.defaultJoinMode);
|
|
@@ -703,9 +804,11 @@ export function applyAndEmitLoaded(
|
|
|
703
804
|
appliers: SettingsAppliers,
|
|
704
805
|
emit: SettingsEmit,
|
|
705
806
|
cwd: string = process.cwd(),
|
|
807
|
+
homeDir?: string,
|
|
706
808
|
): SubagentsSettings {
|
|
707
|
-
const settings = loadSettings(cwd);
|
|
809
|
+
const settings = loadSettings(cwd, homeDir);
|
|
708
810
|
applySettings(settings, appliers);
|
|
811
|
+
appliers.setBudgetPolicy(loadBudgetPolicy(homeDir));
|
|
709
812
|
emit("subagents:settings_loaded", { settings });
|
|
710
813
|
return settings;
|
|
711
814
|
}
|
package/src/skill-loader.ts
CHANGED
|
@@ -25,7 +25,7 @@ import type { Dirent } from "node:fs";
|
|
|
25
25
|
import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
26
26
|
import { homedir } from "node:os";
|
|
27
27
|
import { dirname, join } from "node:path";
|
|
28
|
-
import {
|
|
28
|
+
import { resolveAgentDir } from "./home-paths.js";
|
|
29
29
|
import { isSymlink, isUnsafeName, safeReadFile } from "./memory.js";
|
|
30
30
|
|
|
31
31
|
export interface PreloadedSkill {
|
|
@@ -33,21 +33,22 @@ export interface PreloadedSkill {
|
|
|
33
33
|
content: string;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
export function preloadSkills(skillNames: string[], cwd: string): PreloadedSkill[] {
|
|
37
|
-
return skillNames.map((name) => ({ name, content: loadSkillContent(name, cwd) }));
|
|
36
|
+
export function preloadSkills(skillNames: string[], cwd: string, homeDir = homedir()): PreloadedSkill[] {
|
|
37
|
+
return skillNames.map((name) => ({ name, content: loadSkillContent(name, cwd, homeDir) }));
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
function loadSkillContent(name: string, cwd: string): string {
|
|
40
|
+
function loadSkillContent(name: string, cwd: string, homeDir: string): string {
|
|
41
41
|
if (isUnsafeName(name)) {
|
|
42
42
|
return `(Skill "${name}" skipped: name contains path traversal characters)`;
|
|
43
43
|
}
|
|
44
44
|
// `trusted`: user-owned root, symlinks followed. Project roots stay strict.
|
|
45
|
+
const agentDir = resolveAgentDir(homeDir);
|
|
45
46
|
const roots = [
|
|
46
47
|
{ path: join(cwd, ".pi", "skills"), trusted: false }, // project — Pi standard
|
|
47
48
|
{ path: join(cwd, ".agents", "skills"), trusted: false }, // project — Agent Skills spec
|
|
48
|
-
{ path: join(
|
|
49
|
-
{ path: join(
|
|
50
|
-
{ path: join(
|
|
49
|
+
{ path: join(agentDir, "skills"), trusted: true }, // user — Pi standard
|
|
50
|
+
{ path: join(homeDir, ".agents", "skills"), trusted: true }, // user — Agent Skills spec
|
|
51
|
+
{ path: join(homeDir, ".pi", "skills"), trusted: true }, // legacy global, pre-Pi
|
|
51
52
|
];
|
|
52
53
|
for (const root of roots) {
|
|
53
54
|
const content = findInRoot(root.path, name, root.trusted);
|
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/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 { BudgetLease } from "./process-budget.js";
|
|
7
11
|
import type { LifetimeUsage } from "./usage.js";
|
|
8
12
|
|
|
9
13
|
export type { ThinkingLevel };
|
|
@@ -146,6 +150,9 @@ export interface AgentTombstone {
|
|
|
146
150
|
description: string;
|
|
147
151
|
/** Always set — a record with no session file is never tombstoned. */
|
|
148
152
|
sessionFile: string;
|
|
153
|
+
/** The evicted run's tier and fallback, handed back when `@handle` reopens it. */
|
|
154
|
+
modelTier?: TierDisclosure;
|
|
155
|
+
modelFallback?: ModelFallback;
|
|
149
156
|
completedAt: number;
|
|
150
157
|
}
|
|
151
158
|
|
|
@@ -204,6 +211,11 @@ export interface AgentRecord {
|
|
|
204
211
|
* thing that moves it, together with the counters.
|
|
205
212
|
*/
|
|
206
213
|
chargedPool?: Pool;
|
|
214
|
+
/**
|
|
215
|
+
* This run's process-wide budget lease (`process-budget.ts`), held from
|
|
216
|
+
* admission until the run settles. Spawns and resumes alike; cleared on release.
|
|
217
|
+
*/
|
|
218
|
+
budgetLease?: BudgetLease;
|
|
207
219
|
/**
|
|
208
220
|
* Removes the listener that stops this run when its caller's signal aborts.
|
|
209
221
|
* At most one is armed at a time — queueing arms one, starting replaces it.
|
|
@@ -264,6 +276,24 @@ export interface AgentRecord {
|
|
|
264
276
|
isBackground?: boolean;
|
|
265
277
|
/** Resolved spawn params, captured for UI display. Fixed at spawn time. */
|
|
266
278
|
invocation?: AgentInvocation;
|
|
279
|
+
/**
|
|
280
|
+
* Set when a configured model did not resolve and the agent runs on the parent
|
|
281
|
+
* model. The single source for every disclosure surface; a resume reuses it
|
|
282
|
+
* as is, never recomputes it from the resuming call's config.
|
|
283
|
+
*/
|
|
284
|
+
modelFallback?: ModelFallback;
|
|
285
|
+
/**
|
|
286
|
+
* This record's selection, retained across live resumes and physical relabelling.
|
|
287
|
+
* An evicted-session reopen selects anew; current fallback disclosure always
|
|
288
|
+
* comes from that selection.
|
|
289
|
+
*/
|
|
290
|
+
modelSelection?: ModelSelection;
|
|
291
|
+
/** Actual run's tier provenance, preserved through eviction and replay. */
|
|
292
|
+
modelTier?: TierDisclosure;
|
|
293
|
+
/** Prior evicted run's outcome, never the continuation's selection or disclosure. */
|
|
294
|
+
previousModelTier?: TierDisclosure;
|
|
295
|
+
/** Prior evicted run's fallback, retained separately from the continuation's current disclosure. */
|
|
296
|
+
previousModelFallback?: ModelFallback;
|
|
267
297
|
/** Nesting depth: top-level subagent = 1. */
|
|
268
298
|
depth?: number;
|
|
269
299
|
/**
|
|
@@ -344,6 +374,9 @@ export interface NotificationDetails {
|
|
|
344
374
|
durationMs: number;
|
|
345
375
|
outputFile?: string;
|
|
346
376
|
error?: string;
|
|
377
|
+
/** The `Note:` line when the agent ran on the parent model because its configured one did not resolve. */
|
|
378
|
+
modelNote?: string;
|
|
379
|
+
modelTier?: TierDisclosure;
|
|
347
380
|
resultPreview: string;
|
|
348
381
|
/** Additional agents in a group notification. */
|
|
349
382
|
others?: NotificationDetails[];
|
package/src/ui/agent-row.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { pathToFileURL } from "node:url";
|
|
|
14
14
|
import { type Component, hyperlink, truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
15
15
|
import { type Clock, realClock, type TimerHandle } from "../clock.js";
|
|
16
16
|
import { formatClock } from "../model-call.js";
|
|
17
|
+
import { modelSelectionNote } from "../model-selection.js";
|
|
17
18
|
import type { AgentRecord } from "../types.js";
|
|
18
19
|
import { getLifetimeCost, getLifetimeTotal } from "../usage.js";
|
|
19
20
|
import {
|
|
@@ -26,6 +27,7 @@ import {
|
|
|
26
27
|
SPINNER,
|
|
27
28
|
type Theme,
|
|
28
29
|
} from "./agent-widget.js";
|
|
30
|
+
import { fallbackFlag, withLeadingFlag } from "./fallback-flag.js";
|
|
29
31
|
|
|
30
32
|
/** Lines of the result shown when expanded; the rest is one `get_subagent_result` away. */
|
|
31
33
|
const EXPANDED_RESULT_LINES = 50;
|
|
@@ -89,6 +91,7 @@ export function agentRowSummary(
|
|
|
89
91
|
live: AgentRowLive,
|
|
90
92
|
theme: Theme,
|
|
91
93
|
now: number = Date.now(),
|
|
94
|
+
width?: number,
|
|
92
95
|
): string {
|
|
93
96
|
const record = details.agentId ? live.getRecord(details.agentId) : undefined;
|
|
94
97
|
const activity = details.agentId ? live.getActivity(details.agentId) : undefined;
|
|
@@ -106,7 +109,10 @@ export function agentRowSummary(
|
|
|
106
109
|
if (tokenText) parts.push(tokenText);
|
|
107
110
|
if (cost) parts.push(cost);
|
|
108
111
|
if (!finished && details.backgroundHint && elapsed >= 2000) parts.push("(ctrl+b to run in background)");
|
|
109
|
-
|
|
112
|
+
// Leading, ahead of the status text, which may carry a 300-char error.
|
|
113
|
+
const flag = fallbackFlag(theme, record?.modelFallback || record?.modelTier ? record : details);
|
|
114
|
+
const body = parts.map(p => fgPreservingNestedStyles(theme, "dim", p)).join(` ${theme.fg("dim", "·")} `);
|
|
115
|
+
return width === undefined ? `${icon} ${flag ? `${flag} ` : ""}${body}` : withLeadingFlag(`${icon} `, flag, body, width);
|
|
110
116
|
}
|
|
111
117
|
|
|
112
118
|
/**
|
|
@@ -116,7 +122,9 @@ export function agentRowSummary(
|
|
|
116
122
|
*/
|
|
117
123
|
function tagsLine(details: AgentDetails, theme: Theme): string | undefined {
|
|
118
124
|
const parts = [...(details.modelName ? [details.modelName] : []), ...(details.tags ?? [])];
|
|
119
|
-
|
|
125
|
+
const flag = fallbackFlag(theme, details);
|
|
126
|
+
if (parts.length === 0 && !flag) return undefined;
|
|
127
|
+
return `${theme.fg("dim", "↳")} ${flag ? `${flag} ` : ""}${theme.fg("dim", parts.join(" · "))}`;
|
|
120
128
|
}
|
|
121
129
|
|
|
122
130
|
/** The transcript as an OSC 8 hyperlink, so a click opens it. */
|
|
@@ -189,12 +197,14 @@ export class AgentResultView implements Component {
|
|
|
189
197
|
) {}
|
|
190
198
|
|
|
191
199
|
render(width: number): string[] {
|
|
192
|
-
const lines = [
|
|
200
|
+
const lines = [agentRowSummary(this.details, this.live, this.theme, this.clock.now(), width)];
|
|
193
201
|
if (!this.expanded) return lines;
|
|
194
202
|
|
|
195
203
|
const record = this.details.agentId ? this.live.getRecord(this.details.agentId) : undefined;
|
|
196
204
|
const tags = tagsLine(this.details, this.theme);
|
|
197
205
|
if (tags) lines.push(truncateToWidth(tags, width));
|
|
206
|
+
const note = modelSelectionNote(record) || this.details.modelNote;
|
|
207
|
+
if (note) lines.push(...wrapTextWithAnsi(this.theme.fg("warning", note), width));
|
|
198
208
|
const transcript = record?.outputFile ?? this.details.outputFile;
|
|
199
209
|
if (transcript) lines.push(truncateToWidth(transcriptLink(transcript, this.theme), width));
|
|
200
210
|
|
package/src/ui/agent-widget.ts
CHANGED
|
@@ -11,8 +11,11 @@ import { type AgentManager, isTopLevelAgent } from "../agent-manager.js";
|
|
|
11
11
|
import { getConfig } from "../agent-types.js";
|
|
12
12
|
import { type Clock, realClock, type TimerHandle } from "../clock.js";
|
|
13
13
|
import { describeModelCall, type ModelCallState } from "../model-call.js";
|
|
14
|
+
import type { ModelFallback } from "../model-resolver.js";
|
|
15
|
+
import type { TierDisclosure } from "../model-tiers.js";
|
|
14
16
|
import type { AgentInvocation, SubagentType, WidgetMode } from "../types.js";
|
|
15
17
|
import { getLifetimeCost, getLifetimeTotal, getSessionContextPercent, type LifetimeUsage, type SessionLike } from "../usage.js";
|
|
18
|
+
import { fallbackFlag } from "./fallback-flag.js";
|
|
16
19
|
|
|
17
20
|
// ---- Constants ----
|
|
18
21
|
|
|
@@ -83,6 +86,11 @@ export interface AgentDetails {
|
|
|
83
86
|
backgroundHint?: boolean;
|
|
84
87
|
/** Short label for the model the run used, e.g. "haiku 4.5". */
|
|
85
88
|
modelName?: string;
|
|
89
|
+
/** Set when a configured model did not resolve and the run fell back to the parent model. */
|
|
90
|
+
modelNote?: string;
|
|
91
|
+
/** The structured form of {@link modelNote}, for the one-line label. */
|
|
92
|
+
modelFallback?: ModelFallback;
|
|
93
|
+
modelTier?: TierDisclosure;
|
|
86
94
|
/** Notable config tags (e.g. ["thinking: high", "isolated"]). */
|
|
87
95
|
tags?: string[];
|
|
88
96
|
/** Current turn count. */
|
|
@@ -389,7 +397,7 @@ export class AgentWidget {
|
|
|
389
397
|
}
|
|
390
398
|
|
|
391
399
|
/** Render a finished agent line. */
|
|
392
|
-
private renderFinishedLine(a: { id: string; type: SubagentType; status: string; description: string; toolUses: number; startedAt: number; completedAt?: number; error?: string; lifetimeUsage?: LifetimeUsage }, theme: Theme): string {
|
|
400
|
+
private renderFinishedLine(a: { id: string; type: SubagentType; status: string; description: string; toolUses: number; startedAt: number; completedAt?: number; error?: string; lifetimeUsage?: LifetimeUsage; modelFallback?: ModelFallback; modelTier?: TierDisclosure }, theme: Theme): string {
|
|
393
401
|
const modeLabel = getPromptModeLabel(a.type);
|
|
394
402
|
const duration = formatMs((a.completedAt ?? this.clock.now()) - a.startedAt);
|
|
395
403
|
|
|
@@ -418,6 +426,7 @@ export class AgentWidget {
|
|
|
418
426
|
const activity = this.agentActivity.get(a.id);
|
|
419
427
|
if (activity) parts.push(formatTurns(activity.turnCount, activity.maxTurns));
|
|
420
428
|
if (a.toolUses > 0) parts.push(`${a.toolUses} tool use${a.toolUses === 1 ? "" : "s"}`);
|
|
429
|
+
// Not gated by `showModel`: a substituted model is never a cosmetic detail.
|
|
421
430
|
// From the record, not the activity tracker: that entry is deleted the
|
|
422
431
|
// moment an agent finishes, and "what did it cost" is a question asked
|
|
423
432
|
// about finished agents.
|
|
@@ -426,7 +435,8 @@ export class AgentWidget {
|
|
|
426
435
|
parts.push(duration);
|
|
427
436
|
|
|
428
437
|
const modeTag = modeLabel ? ` ${theme.fg("dim", `(${modeLabel})`)}` : "";
|
|
429
|
-
|
|
438
|
+
const flag = fallbackFlag(theme, a) ? `${fallbackFlag(theme, a)} ` : "";
|
|
439
|
+
return `${icon} ${flag}${renderAgentName(a.type, theme, { fallbackColor: "dim" })}${modeTag} ${theme.fg("dim", a.description)} ${theme.fg("dim", "·")} ${theme.fg("dim", parts.join(" · "))}${statusText}`;
|
|
430
440
|
}
|
|
431
441
|
|
|
432
442
|
/**
|
|
@@ -466,6 +476,7 @@ export class AgentWidget {
|
|
|
466
476
|
for (const a of running) {
|
|
467
477
|
const modeLabel = getPromptModeLabel(a.type);
|
|
468
478
|
const modeTag = modeLabel ? ` ${theme.fg("dim", `(${modeLabel})`)}` : "";
|
|
479
|
+
const flag = fallbackFlag(theme, a) ? `${fallbackFlag(theme, a)} ` : "";
|
|
469
480
|
const elapsed = formatMs(this.clock.now() - a.startedAt);
|
|
470
481
|
|
|
471
482
|
const bg = this.agentActivity.get(a.id);
|
|
@@ -489,7 +500,7 @@ export class AgentWidget {
|
|
|
489
500
|
const thinkingTag = tags.find(tag => tag.startsWith("thinking: "));
|
|
490
501
|
if (thinkingTag) parts.push(thinkingTag);
|
|
491
502
|
}
|
|
492
|
-
|
|
503
|
+
if (bg) parts.push(formatTurns(bg.turnCount, bg.maxTurns));
|
|
493
504
|
if (toolUses > 0) parts.push(`${toolUses} tool use${toolUses === 1 ? "" : "s"}`);
|
|
494
505
|
if (tokenText) parts.push(tokenText);
|
|
495
506
|
if (costText) parts.push(costText);
|
|
@@ -499,7 +510,7 @@ export class AgentWidget {
|
|
|
499
510
|
const activity = bg ? describeActivity(bg.activeTools, bg.responseText, bg.modelCall, this.clock.now()) : "thinking…";
|
|
500
511
|
|
|
501
512
|
runningLines.push([
|
|
502
|
-
truncate(theme.fg("dim", "├─") + ` ${theme.fg("accent", frame)} ${renderAgentName(a.type, theme, { bold: true })}${modeTag} ${theme.fg("muted", a.description)} ${theme.fg("dim", "·")} ${fgPreservingNestedStyles(theme, "dim", statsText)}`),
|
|
513
|
+
truncate(theme.fg("dim", "├─") + ` ${theme.fg("accent", frame)} ${flag}${renderAgentName(a.type, theme, { bold: true })}${modeTag} ${theme.fg("muted", a.description)} ${theme.fg("dim", "·")} ${fgPreservingNestedStyles(theme, "dim", statsText)}`),
|
|
503
514
|
truncate(theme.fg("dim", "│ ") + theme.fg("dim", ` ⎿ ${activity}`)),
|
|
504
515
|
]);
|
|
505
516
|
}
|