@nredd/pi-subagents 0.22.0-nredd.7 → 0.22.0-nredd.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +245 -77
  2. package/package.json +16 -13
  3. package/src/abortable.ts +1 -1
  4. package/src/agent-file-toggle.ts +6 -5
  5. package/src/agent-manager.ts +489 -129
  6. package/src/agent-runner.ts +244 -93
  7. package/src/child-context.ts +19 -1
  8. package/src/completion-outbox.ts +22 -3
  9. package/src/cross-extension-rpc.ts +21 -42
  10. package/src/custom-agents.ts +11 -6
  11. package/src/default-agents.ts +21 -21
  12. package/src/detached-results.ts +18 -2
  13. package/src/enabled-models.ts +8 -7
  14. package/src/enterprise-local.ts +7 -6
  15. package/src/error-message.ts +11 -0
  16. package/src/git-path.ts +66 -0
  17. package/src/group-join.ts +4 -10
  18. package/src/home-paths.ts +27 -0
  19. package/src/index.ts +898 -297
  20. package/src/invocation-config.ts +4 -21
  21. package/src/memory.ts +9 -9
  22. package/src/mention-clone.ts +13 -8
  23. package/src/model-resolver.ts +92 -25
  24. package/src/model-scope.ts +2 -1
  25. package/src/model-selection.ts +170 -0
  26. package/src/model-tiers.ts +176 -0
  27. package/src/nested-tools.ts +99 -44
  28. package/src/notification-result.ts +118 -0
  29. package/src/output-file.ts +23 -1
  30. package/src/package-resources.ts +25 -10
  31. package/src/plan-mode-children.ts +176 -0
  32. package/src/process-budget.ts +6 -3
  33. package/src/prompts.ts +7 -2
  34. package/src/quota-admission.ts +35 -13
  35. package/src/resource-loader.ts +65 -0
  36. package/src/schedule.ts +94 -30
  37. package/src/settings.ts +63 -13
  38. package/src/skill-loader.ts +244 -93
  39. package/src/stale-ctx.ts +16 -0
  40. package/src/status-note.ts +28 -5
  41. package/src/types.ts +79 -2
  42. package/src/ui/agent-mention.ts +5 -2
  43. package/src/ui/agent-row.ts +13 -3
  44. package/src/ui/agent-widget.ts +55 -25
  45. package/src/ui/conversation-viewer.ts +10 -5
  46. package/src/ui/fallback-flag.ts +114 -0
  47. package/src/ui/fleet-list.ts +431 -86
  48. package/src/ui/fleet-state.ts +154 -0
  49. package/src/ui/notification-view.ts +22 -3
  50. package/src/ui/workflow-card.ts +6 -9
  51. package/src/ui/workflow-dialog.ts +36 -13
  52. package/src/workflow/host.ts +47 -50
  53. package/src/workflow/journal.ts +13 -1
  54. package/src/workflow/meta.ts +7 -4
  55. package/src/workflow/progress.ts +8 -3
  56. package/src/workflow/runtime.ts +48 -15
  57. package/src/workflow/saved.ts +21 -21
  58. package/src/workflow/task.ts +22 -7
  59. package/src/workflow/tool-description.ts +2 -2
  60. package/src/workflow/worker-source.ts +162 -40
  61. package/src/worktree.ts +309 -19
@@ -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, getAgentDir, SettingsManager } from "@earendil-works/pi-coding-agent";
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 = getAgentDir();
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;
@@ -0,0 +1,176 @@
1
+ /**
2
+ * plan-mode-children.ts -- keeps a child started while the parent plans in Plan
3
+ * mode (#437), as Claude Code keeps a subagent spawned in plan mode in plan mode.
4
+ *
5
+ * pi-plan-mode gates only its own session's tool calls, and a child is a separate
6
+ * session on a separate event bus. So right before a child runs, the manager asks
7
+ * the parent's pi-plan-mode, synchronously on the parent's `pi.events`, whether it
8
+ * is planning; when it is, the answer carries the Plan tool policy, and this
9
+ * module applies it to every call the child makes. No answer means pi-plan-mode is
10
+ * not installed, and nothing changes.
11
+ *
12
+ * The contract mirrors `packages/plan-mode/src/child-policy.ts`, repeated rather
13
+ * than imported because either package installs without the other.
14
+ */
15
+ import type { ToolInfo } from "@earendil-works/pi-coding-agent";
16
+ import type { AgentRecord } from "./types.js";
17
+
18
+ export const PLAN_MODE_STATE_QUERY = "pi-plan-mode:state-query";
19
+ /** pi-plan-mode asks here whether a compatible gate is installed; answering is the handshake. */
20
+ export const PLAN_MODE_GATE_QUERY = "pi-plan-mode:gate-query";
21
+ const PLAN_MODE_STATE_VERSION = 1;
22
+
23
+ /** Buses on which pi-plan-mode has shown itself by asking {@link PLAN_MODE_GATE_QUERY}. */
24
+ const planModeSeen = new WeakSet<object>();
25
+
26
+ /**
27
+ * Answer pi-plan-mode's gate query, so Plan admits `Agent` and friends, and remember that Plan
28
+ * mode is installed on this bus: from then on a state query nobody answers fails closed.
29
+ */
30
+ export function answerPlanGateQueries(events: { on(channel: string, handler: (data: unknown) => void): unknown }): void {
31
+ events.on(PLAN_MODE_GATE_QUERY, (data) => {
32
+ planModeSeen.add(events);
33
+ const reply = (data as { reply?: unknown } | undefined)?.reply;
34
+ if (typeof reply === "function") reply({ version: PLAN_MODE_STATE_VERSION, gate: "@nredd/pi-subagents" });
35
+ });
36
+ }
37
+
38
+ /** Thrown when pi-plan-mode is installed but did not answer whether it is planning. */
39
+ export const PLAN_MODE_SILENT_ERROR =
40
+ "Plan mode is installed but did not answer whether it is planning, so this subagent was not started. Reload Pi, or update @nredd/pi-plan-mode and @nredd/pi-subagents together.";
41
+
42
+ /** One tool call inside a child session. */
43
+ interface PlanModeChildToolCall {
44
+ toolName: string;
45
+ input: unknown;
46
+ cwd: string;
47
+ tool: ToolInfo | undefined;
48
+ }
49
+
50
+ /** Why Plan mode refuses a child's tool call, or `undefined` when it may run. */
51
+ export type PlanToolPolicy = (call: PlanModeChildToolCall) => string | undefined;
52
+
53
+ /**
54
+ * The Plan restriction one child run is under. Mutable on purpose: it is decided
55
+ * per run (a spawn, then each resume) while the hooks reading it are installed
56
+ * once, on the child's session.
57
+ */
58
+ export interface PlanGate {
59
+ policy?: PlanToolPolicy;
60
+ }
61
+
62
+ /** Tools a child keeps without asking: safe built-ins only. */
63
+ const FAIL_CLOSED_TOOLS: ReadonlySet<string> = new Set(["read", "grep", "find", "ls"]);
64
+
65
+ /**
66
+ * For a planner that says it is active but sent no policy this host understands:
67
+ * nothing beyond the safe built-ins, so a contract mismatch can never run a child
68
+ * unrestricted.
69
+ */
70
+ const failClosedPolicy: PlanToolPolicy = ({ toolName }) =>
71
+ FAIL_CLOSED_TOOLS.has(toolName)
72
+ ? undefined
73
+ : `Plan mode is active and sent no tool policy this subagent understands, so '${toolName}' is blocked.`;
74
+
75
+ /**
76
+ * Ask the parent's pi-plan-mode whether a child started now must plan. Returns
77
+ * the policy to run it under, or `undefined` when nobody is planning. Several
78
+ * answers (two Plan extensions) combine fail-closed: any active one restricts.
79
+ * A malformed answer (not version 1 with a boolean `active`) restricts like an unusable one.
80
+ * When Plan mode is installed but nothing answers, a new child is refused
81
+ * (`silent: "refuse"`, which throws) and a resumed one runs fail-closed.
82
+ */
83
+ export function queryPlanPolicy(
84
+ events: { emit(channel: string, data: unknown): void } | undefined,
85
+ silent: "refuse" | "restrict" = "refuse",
86
+ ): PlanToolPolicy | undefined {
87
+ // Hosts without an event bus (bare programmatic callers) cannot run pi-plan-mode either.
88
+ if (!events) return undefined;
89
+ const policies: PlanToolPolicy[] = [];
90
+ let answered = false;
91
+ events.emit(PLAN_MODE_STATE_QUERY, {
92
+ version: PLAN_MODE_STATE_VERSION,
93
+ reply: (state: unknown) => {
94
+ const answer = state as { version?: unknown; active?: unknown; evaluate?: unknown } | undefined;
95
+ // Only a well-formed answer says whether Plan is on; anything else restricts, never frees.
96
+ if (answer?.version !== PLAN_MODE_STATE_VERSION || typeof answer.active !== "boolean") {
97
+ policies.push(failClosedPolicy);
98
+ return;
99
+ }
100
+ answered = true;
101
+ if (!answer.active) return;
102
+ policies.push(typeof answer.evaluate === "function" ? (answer.evaluate as PlanToolPolicy) : failClosedPolicy);
103
+ },
104
+ });
105
+ if (!answered && policies.length === 0 && planModeSeen.has(events)) {
106
+ if (silent === "refuse") throw new Error(PLAN_MODE_SILENT_ERROR);
107
+ return failClosedPolicy;
108
+ }
109
+ if (policies.length === 0) return undefined;
110
+ return (call) => {
111
+ for (const policy of policies) {
112
+ const reason = policy(call);
113
+ if (reason !== undefined) return reason;
114
+ }
115
+ return undefined;
116
+ };
117
+ }
118
+
119
+ /**
120
+ * Whether a Plan policy is active on `events` now, failing closed: an installed Plan mode that
121
+ * answers badly or not at all counts as planning. For work that would otherwise outlive Plan
122
+ * unrestricted (a schedule, a quota-parked dispatch, steering an unrestricted child).
123
+ */
124
+ export function planningNow(events: { emit(channel: string, data: unknown): void } | undefined): boolean {
125
+ return queryPlanPolicy(events, "restrict") !== undefined;
126
+ }
127
+
128
+ /**
129
+ * Whether a steer must be refused: the agent is running or queued under no Plan policy (it started
130
+ * before Plan) while Plan is active, so the steer would drive it with its full surface. The one
131
+ * predicate for `steer_subagent` and `subagents:rpc:steer`.
132
+ */
133
+ export function planSteerBlocked(
134
+ record: Pick<AgentRecord, "status" | "planGate">,
135
+ events: { emit(channel: string, data: unknown): void } | undefined,
136
+ ): boolean {
137
+ return (record.status === "running" || record.status === "queued") && !record.planGate?.policy && planningNow(events);
138
+ }
139
+
140
+ /** Plan's model must not steer a child that runs without Plan's restrictions. */
141
+ export function planSteerRefusal(id: string): string {
142
+ return (
143
+ `Cannot steer agent "${id}" while Plan mode is active: it started before Plan and runs without Plan's restrictions, so a steer could make it edit. ` +
144
+ "Wait for it to finish and steer it then (that resumes it read-only), stop it with stop_subagent, or leave Plan mode (/plan exit) first."
145
+ );
146
+ }
147
+
148
+ /** A scheduled agent fires as a fresh spawn, after Plan, with nothing restricting it. */
149
+ export const PLAN_SCHEDULE_REFUSAL =
150
+ "Cannot schedule an agent while Plan mode is active: it would start later, after Plan, without Plan's restrictions. Start it now, or leave Plan mode first.";
151
+
152
+ /** A quota-parked dispatch is a scheduled job too. */
153
+ export const PLAN_PARK_REFUSAL =
154
+ "The agent was not started or parked: Plan mode is active, and a parked agent would start after Plan without Plan's restrictions. Retry after the reset, or leave Plan mode first.";
155
+
156
+ /**
157
+ * The refusal for one child tool call, or `undefined`. Tools this extension
158
+ * injects (nested delegation, whose children inherit the gate; StructuredOutput;
159
+ * the workflow refusal) are exempt: Plan's policy has no entry for them.
160
+ */
161
+ export function planGateReason(
162
+ gate: PlanGate | undefined,
163
+ call: { toolName: string; input: unknown; cwd: string },
164
+ tools: () => ToolInfo[],
165
+ exempt: ReadonlySet<string>,
166
+ ): string | undefined {
167
+ const policy = gate?.policy;
168
+ if (!policy || exempt.has(call.toolName)) return undefined;
169
+ return policy({ ...call, tool: tools().find((tool) => tool.name === call.toolName) });
170
+ }
171
+
172
+ /** Told to a child started while planning, so a refused write is not a surprise. */
173
+ export const PLAN_MODE_CHILD_PROMPT = `<plan_mode>
174
+ Your parent is in Plan mode, so you are too: investigate and report, do not change anything.
175
+ edit and write are blocked, bash runs only reviewed read-only commands, and other tools run only when Plan mode admits them.
176
+ </plan_mode>`;
@@ -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) atDepth[Math.min(top, Math.max(1, lease.depth))]++;
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 [waiter] = s.waiters.splice(i, 1);
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/prompts.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  * prompts.ts — System prompt builder for agents.
3
3
  */
4
4
 
5
+ import { PLAN_MODE_CHILD_PROMPT } from "./plan-mode-children.js";
5
6
  import type { AgentConfig, EnvInfo } from "./types.js";
6
7
 
7
8
  /** Extra sections to inject into the system prompt (memory, skills, etc.). */
@@ -16,6 +17,8 @@ export interface PromptExtras {
16
17
  * to stay in the copy.
17
18
  */
18
19
  worktreeBase?: string;
20
+ /** Set when the parent was planning at spawn: the child is told it plans too (#437). */
21
+ planMode?: boolean;
19
22
  /**
20
23
  * Set only for a workflow's own children, and only when they have no
21
24
  * `StructuredOutput` tool to answer through.
@@ -93,6 +96,8 @@ Return only the answer, in exactly the shape the prompt asks for — no preamble
93
96
  </workflow_child>`
94
97
  : "";
95
98
 
99
+ const planModeBlock = extras?.planMode ? `\n\n${PLAN_MODE_CHILD_PROMPT}` : "";
100
+
96
101
  const nestingBlock = extras?.nestingDisabled
97
102
  ? `\n\n<nesting_disabled>
98
103
  Nested subagents are disabled for this session, so you have no Agent tool. Do the work yourself, inline.
@@ -137,7 +142,7 @@ You are operating as a sub-agent invoked to handle a specific task.
137
142
  // placed verbatim (no wrapper tag) so it forms an identical byte prefix
138
143
  // with the parent session, maximising KV cache hits. The <active_agent>
139
144
  // tag and env block vary per call and are placed after the cached prefix.
140
- return identity + "\n\n" + bridge + "\n\n" + activeAgentTag + envBlock + worktreeBlock + workflowBlock + nestingBlock + customSection + extrasSuffix;
145
+ return identity + "\n\n" + bridge + "\n\n" + activeAgentTag + envBlock + worktreeBlock + planModeBlock + workflowBlock + nestingBlock + customSection + extrasSuffix;
141
146
  }
142
147
 
143
148
  // "replace" mode — env header + the config's full system prompt
@@ -146,7 +151,7 @@ You have been invoked to handle a specific task autonomously.
146
151
 
147
152
  ${envBlock}`;
148
153
 
149
- return activeAgentTag + replaceHeader + worktreeBlock + workflowBlock + nestingBlock + "\n\n" + config.systemPrompt + extrasSuffix;
154
+ return activeAgentTag + replaceHeader + worktreeBlock + planModeBlock + workflowBlock + nestingBlock + "\n\n" + config.systemPrompt + extrasSuffix;
150
155
  }
151
156
 
152
157
  /** Fallback base prompt when parent system prompt is unavailable in append mode. */
@@ -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` is ignored).
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,55 @@ 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,
59
+ signal?: AbortSignal,
58
60
  ): Promise<QuotaBlock | undefined> {
61
+ return (await admissionOutcome(events, model, timeoutMs, clock, signal)).block;
62
+ }
63
+
64
+ /** Retain the physical admission label and whether the router supplied a signal. */
65
+ export function admissionOutcome(
66
+ events: AdmissionEvents,
67
+ model: string,
68
+ timeoutMs = ADMIT_TIMEOUT_MS,
69
+ clock: Clock = realClock,
70
+ signal?: AbortSignal,
71
+ ): Promise<TierAdmission> {
72
+ if (signal?.aborted) return Promise.reject(new DOMException("Admission cancelled", "AbortError"));
59
73
  const requestId = nanoid();
60
- return new Promise(resolve => {
61
- let done = false;
62
- const finish = (block: QuotaBlock | undefined) => {
63
- if (done) return;
64
- done = true;
74
+ return new Promise((resolve, reject) => {
75
+ // Every step is idempotent and a promise settles once, so no `done` latch is needed.
76
+ const cleanup = () => {
65
77
  timer.cancel();
66
78
  unsubscribe();
67
- resolve(block);
79
+ signal?.removeEventListener("abort", abort);
80
+ };
81
+ const abort = () => {
82
+ cleanup();
83
+ reject(new DOMException("Admission cancelled", "AbortError"));
84
+ };
85
+ const finish = (outcome: TierAdmission) => {
86
+ cleanup();
87
+ resolve(outcome);
68
88
  };
69
89
  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 !== false) return finish(undefined);
90
+ const reply = raw as { success?: unknown; data?: { ok?: unknown; resolvedModel?: unknown; resetAt?: unknown; wait?: unknown } } | undefined;
91
+ if (reply?.success !== true || typeof reply.data?.ok !== "boolean") return finish({ quotaSignal: false });
92
+ if (reply.data.ok) return finish({ quotaSignal: true, resolvedModel: typeof reply.data.resolvedModel === "string" ? reply.data.resolvedModel : undefined });
72
93
  const resetAt = reply.data.resetAt;
73
94
  const wait = reply.data.wait;
74
- finish({
95
+ finish({ quotaSignal: true, block: {
75
96
  model,
76
97
  resetAt: typeof resetAt === "number" && Number.isFinite(resetAt) ? resetAt : undefined,
77
98
  ...(typeof wait === "boolean" ? { wait } : {}),
78
- });
99
+ } });
79
100
  });
80
- const timer = clock.setTimeout(() => finish(undefined), timeoutMs);
101
+ const timer = clock.setTimeout(() => finish({ quotaSignal: false }), timeoutMs);
102
+ signal?.addEventListener("abort", abort, { once: true });
81
103
  events.emit(ADMIT_CHANNEL, { requestId, model });
82
104
  });
83
105
  }
@@ -0,0 +1,65 @@
1
+ /** Normalize explicit settings paths before Pi's real package manager resolves them. */
2
+ import { DefaultPackageManager, 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
+ type PackageManagerOptions = ConstructorParameters<typeof DefaultPackageManager>[0];
39
+
40
+ /** Wrap settings readers once so Pi receives injected-home resource paths. */
41
+ function normalizeHomeResourceSettings(settingsManager: SettingsManager, homeDir: string): SettingsManager {
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 settingsManager;
51
+ }
52
+
53
+ /** Build Pi's package manager with injected-home settings normalization. */
54
+ export function createHomePackageManager(options: PackageManagerOptions, homeDir?: string): DefaultPackageManager {
55
+ if (homeDir === undefined) return new DefaultPackageManager(options);
56
+ // `homeDir` also redirects Pi's automatic `<home>/.agents/skills` root (nredd/pi#13).
57
+ return new DefaultPackageManager({ ...options, homeDir, settingsManager: normalizeHomeResourceSettings(options.settingsManager, homeDir) });
58
+ }
59
+
60
+ /** Use the real loader/manager while preventing ambient-home expansion of explicit settings. */
61
+ export function createHomeResourceLoader(options: ResourceLoaderOptions, homeDir?: string): DefaultResourceLoader {
62
+ if (homeDir === undefined) return new DefaultResourceLoader(options);
63
+ const settingsManager = options.settingsManager ?? SettingsManager.create(options.cwd, options.agentDir);
64
+ return new DefaultResourceLoader({ ...options, homeDir, settingsManager: normalizeHomeResourceSettings(settingsManager, homeDir) });
65
+ }