@jwilger/pi-development-system 0.48.0 → 0.51.1

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.
@@ -9,7 +9,6 @@ import { applyPromptSection } from "../src/context/system-prompt.ts";
9
9
  import { DEFAULT_VERIFIER_MAX, registerTurnVerifier } from "../src/context/turn-verifier.ts";
10
10
  import { type Exec, timeoutAsFailure } from "../src/core/exec.ts";
11
11
  import { defaultMatrix } from "../src/core/models.ts";
12
- import { detectProfiles } from "../src/core/profile.ts";
13
12
  import { createApprovalStore } from "../src/gates/approvals.ts";
14
13
  import { registerCommitGuard } from "../src/gates/commit-guard.ts";
15
14
  import { registerGitGuard } from "../src/gates/git-guard.ts";
@@ -24,6 +23,7 @@ import { createReviewRecordTool, createReviewStartTool } from "../src/review/rev
24
23
  import { registerCiCommand } from "../src/state/ci-command.ts";
25
24
  import { loadConfig } from "../src/state/config.ts";
26
25
  import { createModelsTool, registerModelsCommand } from "../src/state/models-command.ts";
26
+ import { detectProfiles } from "../src/state/profile-detect.ts";
27
27
  import { createRouteTaskTool } from "../src/state/route-task-tool.ts";
28
28
  import { createSessionState } from "../src/state/session-state.ts";
29
29
  import { registerTestEvidence } from "../src/state/test-evidence.ts";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jwilger/pi-development-system",
3
- "version": "0.48.0",
3
+ "version": "0.51.1",
4
4
  "description": "A pi extension package representing a seasoned approach to software development using a full AI SDLC.",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -59,7 +59,7 @@
59
59
  "test:jev": "node --experimental-transform-types --no-warnings --test \"test/live/*.live.ts\"",
60
60
  "check:jev": "node scripts/run-jev-fixtures.ts",
61
61
  "typecheck": "tsc --noEmit",
62
- "lint": "biome check .",
62
+ "lint": "biome check --error-on-warnings .",
63
63
  "lint:fix": "biome check --write .",
64
64
  "check:build-gate": "node scripts/build-gate.ts",
65
65
  "check:version": "node scripts/check-version.ts",
@@ -39,7 +39,8 @@ A warning is an error that has not been triaged yet. Before every commit:
39
39
  working; deny in CI.
40
40
  - Vendored code under `src/subagents` is on a ratchet: `test/subagents/nocheck-files.ts`
41
41
  lists the files still skipping type checks and may only shrink. A file you touch there
42
- is made clean and dropped from the list.
42
+ is made type-clean and lint-clean, dropped from the list, and removed from the
43
+ `!src/subagents/...` exclusions in `biome.json`.
43
44
 
44
45
  ## What the extension does
45
46
 
@@ -1,12 +1,11 @@
1
- import { access } from "node:fs/promises";
2
- import { join } from "node:path";
3
- import { isParseError, type ParseError, type Profile, parseError } from "./types.ts";
1
+ import { type ParseError, type Profile, parseError } from "./types.ts";
4
2
 
5
3
  export type { Profile };
6
4
 
7
5
  const PROFILES: readonly Profile[] = ["rust", "typescript"];
8
6
 
9
- const MARKERS: ReadonlyArray<{ profile: Profile; files: readonly string[] }> = [
7
+ /** Files whose presence marks a repo as using a language profile. */
8
+ export const MARKERS: ReadonlyArray<{ profile: Profile; files: readonly string[] }> = [
10
9
  { profile: "rust", files: ["Cargo.toml"] },
11
10
  { profile: "typescript", files: ["package.json", "tsconfig.json"] },
12
11
  ];
@@ -24,26 +23,3 @@ export function parseProfiles(names: readonly string[]): Profile[] | ParseError
24
23
  }
25
24
  return out;
26
25
  }
27
-
28
- const exists = (path: string): Promise<boolean> =>
29
- access(path).then(
30
- () => true,
31
- () => false,
32
- );
33
-
34
- /** Profiles that apply to `repoRoot`; a non-empty `override` (valid names only) replaces detection. */
35
- export async function detectProfiles(
36
- repoRoot: string,
37
- override: readonly string[] = [],
38
- ): Promise<Profile[]> {
39
- if (override.length > 0) {
40
- const parsed = parseProfiles(override);
41
- if (!isParseError(parsed)) return parsed;
42
- }
43
- const found: Profile[] = [];
44
- for (const { profile, files } of MARKERS) {
45
- const hits = await Promise.all(files.map((f) => exists(join(repoRoot, f))));
46
- if (hits.some(Boolean)) found.push(profile);
47
- }
48
- return found;
49
- }
@@ -6,8 +6,8 @@ export type ThinkingLevel = (typeof THINKING_LEVELS)[number];
6
6
 
7
7
  /** Per-spawn choices the coordinator makes; they beat agent-type preferences and inheritance. */
8
8
  export type SpawnOverrides = {
9
- readonly model?: string;
10
- readonly thinkingLevel?: ThinkingLevel;
9
+ readonly model?: string | undefined;
10
+ readonly thinkingLevel?: ThinkingLevel | undefined;
11
11
  };
12
12
 
13
13
  export type Settings = {
@@ -2,7 +2,7 @@ import { basename, isAssignment, segments, splitLines, WRAPPERS } from "./git-in
2
2
  import { redactSecrets } from "./redact.ts";
3
3
 
4
4
  const PACKAGE_MANAGERS = new Set(["npm", "pnpm", "yarn", "bun"]);
5
- const DIRECT_RUNNERS = new Set(["vitest", "jest", "pytest", "mocha", "ava"]);
5
+ const DIRECT_RUNNERS = new Set(["vitest", "jest", "pytest", "mocha", "ava", "tap"]);
6
6
  const EXEC_PREFIX = new Set(["npx", "bunx", "pnpx"]);
7
7
  const SUMMARY_LINES = 3;
8
8
  const SUMMARY_MAX = 300;
@@ -63,6 +63,12 @@ export type ResultLike = {
63
63
 
64
64
  /** A pipe or sequence makes the shell report the last command's status, not the runner's. */
65
65
  const NO_TEST_SCRIPT = /Missing script: "?test/;
66
+ /** What npm prints around a missing script; any other line means something else ran or failed. */
67
+ const NPM_MISSING_SCRIPT_NOISE =
68
+ /^(?:npm (?:error|ERR!)\s*)?(?:$|.*Missing script.*|.*To see a list of scripts.*|.*A complete log of this run.*|.*Did you mean.*|.*npm run.*)$/;
69
+ const onlyMissingScript = (text: string): boolean =>
70
+ NO_TEST_SCRIPT.test(text) &&
71
+ text.split("\n").every((l) => NPM_MISSING_SCRIPT_NOISE.test(l.trim()));
66
72
  const MASKS_STATUS = /[|;\n]/;
67
73
  const FAILURE_MARKERS =
68
74
  /^(?:# |ℹ )fail [1-9]|test result: FAILED|\b[1-9]\d* (?:failed|failing)\b|^FAILED\b|^FAIL\b|\bnot ok\b/m;
@@ -72,8 +78,9 @@ const isRecord = (v: unknown): v is Record<string, unknown> =>
72
78
 
73
79
  /** Exit code of a bash tool result: structured `exit_code`, else pi's status line, else error/not. */
74
80
  export function exitCodeOf(result: ResultLike): number {
75
- // `npm test` in a package without a test script exits 1 but ran no tests, so it is not a RED.
76
- if (NO_TEST_SCRIPT.test(result.text)) return 0;
81
+ // `npm test` in a package without a test script exits 1 but ran no tests, so it is not a RED;
82
+ // but with --workspaces one package may lack the script while another fails for real.
83
+ if (onlyMissingScript(result.text) && !FAILURE_MARKERS.test(result.text)) return 0;
77
84
  const reported = reportedExit(result);
78
85
  if (reported !== 0) return reported;
79
86
  // `npm test | tail` exits 0 whatever the tests did, even in the structured status; trust the output.
@@ -102,7 +102,8 @@ export async function snapshotDiff(
102
102
  const hashed = await hashUntracked(exec, cwd, untracked);
103
103
  if (!hashed.ok) return hashed;
104
104
  const files = { ...tracked.value, ...hashed.value };
105
- const names = Object.keys(files).sort((a, b) => a.localeCompare(b));
105
+ // Code-unit order, not locale order: the digest must not depend on the runtime's collation.
106
+ const names = Object.keys(files).sort(byCodeUnit);
106
107
  const untrackedStat = [
107
108
  ...untracked.map((p) => ` ${p} (untracked)`),
108
109
  ...nested.map((p) => ` ${p} (nested repository, not reviewable)`),
@@ -118,6 +119,11 @@ export async function snapshotDiff(
118
119
  }
119
120
  }
120
121
 
122
+ const byCodeUnit = (a: string, b: string): number => {
123
+ if (a === b) return 0;
124
+ return a < b ? -1 : 1;
125
+ };
126
+
121
127
  /** Content digests (`new:<blob>`) of untracked files; symlinks are hashed by target, not followed. */
122
128
  async function hashUntracked(
123
129
  exec: Exec,
@@ -0,0 +1,27 @@
1
+ import { access } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { MARKERS, parseProfiles } from "../core/profile.ts";
4
+ import { isParseError, type Profile } from "../core/types.ts";
5
+
6
+ const exists = (path: string): Promise<boolean> =>
7
+ access(path).then(
8
+ () => true,
9
+ () => false,
10
+ );
11
+
12
+ /** Profiles that apply to `repoRoot`; a non-empty `override` (valid names only) replaces detection. */
13
+ export async function detectProfiles(
14
+ repoRoot: string,
15
+ override: readonly string[] = [],
16
+ ): Promise<Profile[]> {
17
+ if (override.length > 0) {
18
+ const parsed = parseProfiles(override);
19
+ if (!isParseError(parsed)) return parsed;
20
+ }
21
+ const found: Profile[] = [];
22
+ for (const { profile, files } of MARKERS) {
23
+ const hits = await Promise.all(files.map((f) => exists(join(repoRoot, f))));
24
+ if (hits.some(Boolean)) found.push(profile);
25
+ }
26
+ return found;
27
+ }
@@ -9,7 +9,7 @@
9
9
 
10
10
  ## Local modifications (keep this list current)
11
11
 
12
- 1. Every `*.ts` file starts with `// @ts-nocheck`: upstream compiles under looser options than this repo's strict `tsconfig.json` (`exactOptionalPropertyTypes`, `erasableSyntaxOnly`). `biome.json` excludes `src/subagents`. Both are deliberate: the code is verbatim, and reformatting or retyping it would make upstream diffs unreadable.
12
+ 1. Ratchet. Upstream compiles under looser options than this repo's strict `tsconfig.json` (`exactOptionalPropertyTypes`, `erasableSyntaxOnly`). Files not yet retyped keep `// @ts-nocheck` and are excluded from biome in `biome.json`; both lists must match `test/subagents/nocheck-files.ts`, which may only shrink (`test/subagents/ratchet.test.ts`). Any file we touch is retyped, linted at the repo's strict biome rules and removed from the list. Retyped files that keep a rule violation carry a file-level `biome-ignore-all` with a reason.
13
13
  2. Relative `.js` import specifiers were rewritten to `.ts` (upstream relies on a TypeScript-aware loader; this repo's tests run under Node's native type stripping).
14
14
  3. `prefs/config.ts`: the bundled-agents directory is `../../../agents/` (was `../../agents/`) because the sources moved one level deeper.
15
15
  4. Per-spawn pins (I5.2): `types.ts` `AgentType.spawnOverrides` and `ThreadService.spawn` args gain `model`/`thinkingLevel`; `orch/tools.ts` `agent_spawn` schema exposes both; `orch/manager.ts` `spawn` validates them with `src/core/spawn-overrides.ts` and stores them on the cloned type (so saved threads keep them); `orch/runtime.ts` `resolveInitialSettings` skips the type's preference list when a model is pinned (via `modelSource` in `src/core/spawn-overrides.ts`, so resumed threads keep their model), applies the pin last for fresh spawns, and `createDriver` drops model preferences/scope policy when a model is pinned; `ThreadView.pinned` (`types.ts`) is filled in `orch/manager.ts` `view()` so `agent_status` reports the pins. A pin therefore bypasses `/scoped-models`.
@@ -1,21 +1,25 @@
1
1
  // @ts-nocheck: vendored upstream pi-subagent-manager 0.14.0 compiled under looser options; see src/subagents/VENDORED.md
2
2
  import { randomUUID } from "node:crypto";
3
- import { buildSessionContext, getAgentDir } from "@earendil-works/pi-coding-agent";
4
3
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+ import { buildSessionContext, getAgentDir } from "@earendil-works/pi-coding-agent";
5
5
  import { fuzzyFilter } from "@earendil-works/pi-tui";
6
- import { ConfigStore } from "./prefs/config.ts";
7
- import { IMPORT_REQUEST_PREFIX, markImportOffered, offerAgentImport } from "./prefs/agent-import.ts";
6
+ import { createInheritedToolSource } from "./orch/inherited-tools.ts";
8
7
  import { ThreadManager } from "./orch/manager.ts";
8
+ import { subagentPrompt } from "./orch/prompt.ts";
9
+ import { createDriverFactory } from "./orch/runtime.ts";
10
+ import { agentTools } from "./orch/tools.ts";
11
+ import {
12
+ IMPORT_REQUEST_PREFIX,
13
+ markImportOffered,
14
+ offerAgentImport,
15
+ } from "./prefs/agent-import.ts";
16
+ import { ConfigStore } from "./prefs/config.ts";
9
17
  import { loadManagerSettings } from "./prefs/settings.ts";
18
+ import type { SavedThread, ThreadEvent } from "./types.ts";
19
+ import { AgentNavigationEditor } from "./ui/agent-navigation-editor.ts";
10
20
  import { configureAgents } from "./ui/settings-ui.ts";
11
21
  import { AgentNavigationController, showAgentStatus } from "./ui/status-ui.ts";
12
- import { AgentNavigationEditor } from "./ui/agent-navigation-editor.ts";
13
- import { createDriverFactory } from "./orch/runtime.ts";
14
- import { createInheritedToolSource } from "./orch/inherited-tools.ts";
15
- import { subagentPrompt } from "./orch/prompt.ts";
16
- import { agentTools } from "./orch/tools.ts";
17
22
  import { editAgentTypes, updateWidget } from "./ui/ui.ts";
18
- import type { SavedThread, ThreadEvent } from "./types.ts";
19
23
 
20
24
  const REGISTRY_ENTRY = "pi-subagent:registry:v1";
21
25
  const ROOT_MAILBOX_ENTRY = "pi-subagent:root-mailbox:v1";
@@ -81,32 +85,55 @@ export default function piSubagent(pi: ExtensionAPI): void {
81
85
  if (existingFactory && existingFactory !== navigationEditorFactory) return;
82
86
  // The host only transfers raw text, not cursor/undo/expanded-paste state.
83
87
  if (ctx.ui.getEditorText?.()) return;
84
- const history = buildSessionContext(ctx.sessionManager.getBranch()).messages.flatMap((message) => {
85
- if (message.role !== "user") return [];
86
- const content = message.content;
87
- return [typeof content === "string" ? content : content
88
- .filter((block) => block.type === "text").map((block) => block.text).join("")];
89
- });
88
+ const history = buildSessionContext(ctx.sessionManager.getBranch()).messages.flatMap(
89
+ (message) => {
90
+ if (message.role !== "user") return [];
91
+ const content = message.content;
92
+ return [
93
+ typeof content === "string"
94
+ ? content
95
+ : content
96
+ .filter((block) => block.type === "text")
97
+ .map((block) => block.text)
98
+ .join(""),
99
+ ];
100
+ },
101
+ );
90
102
  navigationEditorFactory = (tui, theme, keys) => {
91
103
  const editor = new AgentNavigationEditor(tui, theme, keys, {
92
104
  // Initial startup hydrates after session_start; replacement editors do not.
93
105
  initialHistory: seedHistory ? history : undefined,
94
106
  generation: () => navigationGeneration,
95
- canOpen: () => manager !== undefined && context?.mode === "tui" &&
96
- limits.subagentMode !== "off" && !navigation.isOpen &&
97
- manager.list().some((thread) => thread.path !== "/root" &&
98
- (thread.state === "starting" || thread.state === "running")),
99
- openTree: () => manager && context
100
- ? navigation.open(context, manager.scope("/root"), undefined, limits.nerdFontIcons) : undefined,
101
- canCollapse: () => manager !== undefined && context?.mode === "tui" &&
102
- limits.subagentMode !== "off" && !navigation.isOpen && !widgetCollapsed &&
103
- limits.widgetMode === "full" && widgetThreads().some((thread) => thread.path !== "/root"),
107
+ canOpen: () =>
108
+ manager !== undefined &&
109
+ context?.mode === "tui" &&
110
+ limits.subagentMode !== "off" &&
111
+ !navigation.isOpen &&
112
+ manager
113
+ .list()
114
+ .some(
115
+ (thread) =>
116
+ thread.path !== "/root" &&
117
+ (thread.state === "starting" || thread.state === "running"),
118
+ ),
119
+ openTree: () =>
120
+ manager && context
121
+ ? navigation.open(context, manager.scope("/root"), undefined, limits.nerdFontIcons)
122
+ : undefined,
123
+ canCollapse: () =>
124
+ manager !== undefined &&
125
+ context?.mode === "tui" &&
126
+ limits.subagentMode !== "off" &&
127
+ !navigation.isOpen &&
128
+ !widgetCollapsed &&
129
+ limits.widgetMode === "full" &&
130
+ widgetThreads().some((thread) => thread.path !== "/root"),
104
131
  collapseWidget: () => {
105
132
  widgetCollapsed = true;
106
133
  if (context) refreshWidget(context);
107
134
  },
108
- onError: (error) => context?.ui.notify(
109
- error instanceof Error ? error.message : String(error), "error"),
135
+ onError: (error) =>
136
+ context?.ui.notify(error instanceof Error ? error.message : String(error), "error"),
110
137
  });
111
138
  navigationEditor = editor;
112
139
  return editor;
@@ -120,7 +147,10 @@ export default function piSubagent(pi: ExtensionAPI): void {
120
147
  // Migration requires the root's file tools and must not delegate source prompts.
121
148
  if (limits.subagentMode === "orchestration") {
122
149
  if (!firstRun)
123
- ctx.ui.notify("Agent import requires Opportunistic mode. Change Subagent Mode in /agents settings.", "info");
150
+ ctx.ui.notify(
151
+ "Agent import requires Opportunistic mode. Change Subagent Mode in /agents settings.",
152
+ "info",
153
+ );
124
154
  return;
125
155
  }
126
156
  if (importInProgress) return;
@@ -140,12 +170,15 @@ export default function piSubagent(pi: ExtensionAPI): void {
140
170
  if (!manager) throw new Error("Subagent threads are not initialized; start a Pi session first");
141
171
  return manager;
142
172
  };
143
- const widgetThreads = () => requireManager().list().filter((thread) => {
144
- // Resuming a retained session makes it visible for this task, even after it settles again.
145
- if (thread.state === "starting" || thread.state === "running")
146
- hiddenWidgetThreads.delete(thread.path);
147
- return !hiddenWidgetThreads.has(thread.path);
148
- });
173
+ const widgetThreads = () =>
174
+ requireManager()
175
+ .list()
176
+ .filter((thread) => {
177
+ // Resuming a retained session makes it visible for this task, even after it settles again.
178
+ if (thread.state === "starting" || thread.state === "running")
179
+ hiddenWidgetThreads.delete(thread.path);
180
+ return !hiddenWidgetThreads.has(thread.path);
181
+ });
149
182
  const requireContext = () => {
150
183
  if (!context) throw new Error("No active Pi session");
151
184
  return context;
@@ -187,44 +220,72 @@ export default function piSubagent(pi: ExtensionAPI): void {
187
220
  };
188
221
  const pendingSummaries = (ctx: ExtensionContext) => {
189
222
  const entries = ctx.sessionManager.getBranch();
190
- const summarized = new Set(entries.flatMap((entry) => {
191
- if (entry.type !== "custom_message" || entry.customType !== ROOT_SUMMARY_MESSAGE) return [];
192
- const ids = (entry.details as { mailboxIds?: unknown } | undefined)?.mailboxIds;
193
- return Array.isArray(ids) ? ids.filter((id): id is string => typeof id === "string") : [];
194
- }));
223
+ const summarized = new Set(
224
+ entries.flatMap((entry) => {
225
+ if (entry.type !== "custom_message" || entry.customType !== ROOT_SUMMARY_MESSAGE) return [];
226
+ const ids = (entry.details as { mailboxIds?: unknown } | undefined)?.mailboxIds;
227
+ return Array.isArray(ids) ? ids.filter((id): id is string => typeof id === "string") : [];
228
+ }),
229
+ );
195
230
  return entries.flatMap((entry) => {
196
231
  if (entry.type !== "custom" || entry.customType !== ROOT_MAILBOX_ENTRY) return [];
197
232
  const notification = entry.data as RootNotification | undefined;
198
233
  return notification?.rootSessionId === ctx.sessionManager.getSessionId() &&
199
- notification.finalRecap === true && typeof notification.content === "string" &&
234
+ notification.finalRecap === true &&
235
+ typeof notification.content === "string" &&
200
236
  typeof notification.details?.mailboxId === "string" &&
201
- !summarized.has(notification.details.mailboxId) ? [notification] : [];
237
+ !summarized.has(notification.details.mailboxId)
238
+ ? [notification]
239
+ : [];
202
240
  });
203
241
  };
204
242
  const scheduleSummary = (ctx: ExtensionContext) => {
205
- if (!limits.finalRecap || limits.subagentMode === "off" || summaryRunning || suspendingSummaries ||
206
- summaryTimer !== undefined || ctx.isIdle() === false || !pendingSummaries(ctx).length) return;
243
+ if (
244
+ !limits.finalRecap ||
245
+ limits.subagentMode === "off" ||
246
+ summaryRunning ||
247
+ suspendingSummaries ||
248
+ summaryTimer !== undefined ||
249
+ ctx.isIdle() === false ||
250
+ !pendingSummaries(ctx).length
251
+ )
252
+ return;
207
253
  const currentGeneration = generation;
208
254
  // Coalesce siblings finishing together and leave the settlement boundary before
209
255
  // starting a new turn. Recheck settings/session/idle state after the deferral.
210
256
  summaryTimer = setTimeout(() => {
211
257
  summaryTimer = undefined;
212
- if (currentGeneration !== generation || context?.sessionManager !== ctx.sessionManager ||
213
- !limits.finalRecap || limits.subagentMode === "off" || suspendingSummaries || ctx.isIdle() === false) return;
258
+ if (
259
+ currentGeneration !== generation ||
260
+ context?.sessionManager !== ctx.sessionManager ||
261
+ !limits.finalRecap ||
262
+ limits.subagentMode === "off" ||
263
+ suspendingSummaries ||
264
+ ctx.isIdle() === false
265
+ )
266
+ return;
214
267
  const notifications = pendingSummaries(ctx);
215
268
  if (!notifications.length) return;
216
269
  summaryRunning = true;
217
270
  try {
218
- pi.sendMessage({
219
- customType: ROOT_SUMMARY_MESSAGE,
220
- content: "Summarize the newly finished asynchronous subagent results for the user. " +
221
- "Their results are in the preceding subagent notifications; use agent_output if more detail is needed. " +
222
- "Give a concise main-thread answer covering results and any failures. " +
223
- "Do not spawn or resume agents solely to summarize.\nAgents: " +
224
- [...new Set(notifications.map((notification) => notification.details.path))].join(", "),
225
- display: false,
226
- details: { mailboxIds: notifications.map((notification) => notification.details.mailboxId) },
227
- }, { triggerTurn: true });
271
+ pi.sendMessage(
272
+ {
273
+ customType: ROOT_SUMMARY_MESSAGE,
274
+ content:
275
+ "Summarize the newly finished asynchronous subagent results for the user. " +
276
+ "Their results are in the preceding subagent notifications; use agent_output if more detail is needed. " +
277
+ "Give a concise main-thread answer covering results and any failures. " +
278
+ "Do not spawn or resume agents solely to summarize.\nAgents: " +
279
+ [...new Set(notifications.map((notification) => notification.details.path))].join(
280
+ ", ",
281
+ ),
282
+ display: false,
283
+ details: {
284
+ mailboxIds: notifications.map((notification) => notification.details.mailboxId),
285
+ },
286
+ },
287
+ { triggerTurn: true },
288
+ );
228
289
  } catch (error) {
229
290
  summaryRunning = false;
230
291
  warnDelivery(error);
@@ -278,7 +339,10 @@ export default function piSubagent(pi: ExtensionAPI): void {
278
339
  content: message,
279
340
  // Foreground results already handled by a busy main thread do not need
280
341
  // another turn. Completions during a summary do need a follow-up summary.
281
- finalRecap: limits.finalRecap && !suspendingSummaries && event.kind === "settled" &&
342
+ finalRecap:
343
+ limits.finalRecap &&
344
+ !suspendingSummaries &&
345
+ event.kind === "settled" &&
282
346
  (thread.state === "completed" || thread.state === "failed") &&
283
347
  (requireContext().isIdle() || summaryRunning),
284
348
  details: {
@@ -335,8 +399,14 @@ export default function piSubagent(pi: ExtensionAPI): void {
335
399
  const refreshWidget = (ctx: ExtensionContext) => {
336
400
  if (limits.subagentMode === "off") {
337
401
  if (ctx.hasUI) ctx.ui.setWidget("pi-subagent", undefined);
338
- } else updateWidget(ctx, widgetThreads(), widgetCollapsed ? "minimal" : limits.widgetMode,
339
- limits.nerdFontIcons, rootTurnEnded);
402
+ } else
403
+ updateWidget(
404
+ ctx,
405
+ widgetThreads(),
406
+ widgetCollapsed ? "minimal" : limits.widgetMode,
407
+ limits.nerdFontIcons,
408
+ rootTurnEnded,
409
+ );
340
410
  };
341
411
  pi.on("agent_start", async (_event, ctx) => {
342
412
  // A new task must not resurrect the previous task's settled agents. Automatic
@@ -374,7 +444,11 @@ export default function piSubagent(pi: ExtensionAPI): void {
374
444
  };
375
445
  });
376
446
 
377
- const attachSession = async (ctx: ExtensionContext, installEditor = false, seedHistory = true) => {
447
+ const attachSession = async (
448
+ ctx: ExtensionContext,
449
+ installEditor = false,
450
+ seedHistory = true,
451
+ ) => {
378
452
  cancelSummary();
379
453
  const token = ++generation;
380
454
  closeNavigation(installEditor);
@@ -531,9 +605,19 @@ export default function piSubagent(pi: ExtensionAPI): void {
531
605
  },
532
606
  });
533
607
  } else if (command === "status") {
534
- await showAgentStatus(ctx, requireManager().scope("/root"), undefined, limits.nerdFontIcons);
608
+ await showAgentStatus(
609
+ ctx,
610
+ requireManager().scope("/root"),
611
+ undefined,
612
+ limits.nerdFontIcons,
613
+ );
535
614
  } else if (command === "tree") {
536
- await navigation.open(ctx, requireManager().scope("/root"), rest.join(" ") || undefined, limits.nerdFontIcons);
615
+ await navigation.open(
616
+ ctx,
617
+ requireManager().scope("/root"),
618
+ rest.join(" ") || undefined,
619
+ limits.nerdFontIcons,
620
+ );
537
621
  } else
538
622
  ctx.ui.notify(
539
623
  "Usage: /agents [tree [path] | status | settings | types | import | reload]",
@@ -43,7 +43,7 @@ export function createInheritedToolSource(pi: ExtensionAPI) {
43
43
  for (const tool of loadout.registered) {
44
44
  const exposure = loadout.getExposure(tool.name);
45
45
  // Inactive direct tools and hidden tools are not available in the root either.
46
- if (!callable.has(tool.name) && !active.has(tool.name)) continue;
46
+ if (!(callable.has(tool.name) || active.has(tool.name))) continue;
47
47
  if (exposure === "hidden") continue;
48
48
  const metadata = info.get(tool.name);
49
49
  tools.push({
@@ -64,14 +64,14 @@ export function createInheritedToolSource(pi: ExtensionAPI) {
64
64
  // SDK orchestrators are instead recreated locally by runtime.
65
65
  throw new Error(
66
66
  `Cannot safely inherit model-only tool ${tool.name}: the SDK does not ` +
67
- "provide a hook-preserving execution bridge. Run it in the main session.",
67
+ "provide a hook-preserving execution bridge. Run it in the main session.",
68
68
  );
69
69
  }
70
70
  const bridge = rootContext ?? context;
71
71
  if (!bridge) {
72
72
  throw new Error(
73
73
  "Inherited tool execution needs a main-session agent_* tool call first. " +
74
- "Use agent_steer to resume this child instead of the thread dialog.",
74
+ "Use agent_steer to resume this child instead of the thread dialog.",
75
75
  );
76
76
  }
77
77
  const outcome = await bridge.executeTool(tool.name, args, { signal, onUpdate });
@@ -1,4 +1,3 @@
1
- // @ts-nocheck: vendored upstream pi-subagent-manager 0.14.0 compiled under looser options; see src/subagents/VENDORED.md
2
1
  import { randomUUID } from "node:crypto";
3
2
  import type { SessionEntry, SessionManager } from "@earendil-works/pi-coding-agent";
4
3
 
@@ -94,10 +93,12 @@ const normalizeLegacyQueue = (
94
93
  const steering = value.steering;
95
94
  const followUp = value.followUp;
96
95
  if (
97
- !Array.isArray(steering) ||
98
- !steering.every((text) => typeof text === "string") ||
99
- !Array.isArray(followUp) ||
100
- !followUp.every((text) => typeof text === "string")
96
+ !(
97
+ Array.isArray(steering) &&
98
+ steering.every((text) => typeof text === "string") &&
99
+ Array.isArray(followUp) &&
100
+ followUp.every((text) => typeof text === "string")
101
+ )
101
102
  ) {
102
103
  return undefined;
103
104
  }
@@ -137,7 +138,10 @@ export class DurableMailbox {
137
138
  private readonly consumed = new Set<string>();
138
139
  private readonly enqueued = new Set<string>();
139
140
 
140
- constructor(private readonly sessionManager: SessionManager) {
141
+ private readonly sessionManager: SessionManager;
142
+
143
+ constructor(sessionManager: SessionManager) {
144
+ this.sessionManager = sessionManager;
141
145
  this.migrateLegacyQueue();
142
146
  this.restore();
143
147
  }
@@ -166,7 +170,7 @@ export class DurableMailbox {
166
170
 
167
171
  private consumeAppended(messageEntryId: string, markerSource: unknown): void {
168
172
  const id = getMailboxMarker(markerSource)?.id;
169
- if (!id || !this.accepted.has(id) || this.consumed.has(id)) return;
173
+ if (!(id && this.accepted.has(id)) || this.consumed.has(id)) return;
170
174
  this.sessionManager.appendCustomEntry(CONSUMED_TYPE, { id, messageEntryId });
171
175
  this.consumed.add(id);
172
176
  this.enqueued.delete(id);