@jwilger/pi-development-system 0.50.0 → 0.52.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jwilger/pi-development-system",
3
- "version": "0.50.0",
3
+ "version": "0.52.0",
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
 
@@ -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.*|Command exited with code \d+)$/;
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;
@@ -74,7 +80,7 @@ const isRecord = (v: unknown): v is Record<string, unknown> =>
74
80
  export function exitCodeOf(result: ResultLike): number {
75
81
  // `npm test` in a package without a test script exits 1 but ran no tests, so it is not a RED;
76
82
  // but with --workspaces one package may lack the script while another fails for real.
77
- if (NO_TEST_SCRIPT.test(result.text) && !FAILURE_MARKERS.test(result.text)) return 0;
83
+ if (onlyMissingScript(result.text) && !FAILURE_MARKERS.test(result.text)) return 0;
78
84
  const reported = reportedExit(result);
79
85
  if (reported !== 0) return reported;
80
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,
@@ -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,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);
@@ -788,10 +788,16 @@ export class ThreadManager {
788
788
  while (this.records.size >= limit) {
789
789
  const paths = [...this.records.keys()];
790
790
  const victim = paths.find((path) => {
791
- const state = this.records.get(path)?.view.state;
791
+ const held = this.records.get(path);
792
+ const state = held?.view.state;
792
793
  const finished = state === "completed" || state === "failed" || state === "stopped";
794
+ // A driver still being created would land on a dropped record and never be disposed.
795
+ const opening =
796
+ held?.driver === undefined &&
797
+ (held?.initializing !== undefined || held?.contextReady !== undefined);
793
798
  return (
794
799
  finished &&
800
+ !opening &&
795
801
  !isDescendant(spawning, path) &&
796
802
  !paths.some((other) => isDescendant(other, path))
797
803
  );
@@ -1,46 +1,46 @@
1
- // @ts-nocheck: vendored upstream pi-subagent-manager 0.14.0 compiled under looser options; see src/subagents/VENDORED.md
1
+ // biome-ignore-all lint/complexity/noExcessiveCognitiveComplexity: the flagged functions (driver construction, settings resolution) are long sequences over pi sessions; only the pin and resume paths are covered by test/subagents/spawn-pins.test.ts, so splitting them would move untested behaviour (see src/subagents/VENDORED.md)
2
+ import { existsSync } from "node:fs";
3
+ import { mkdir, readFile, realpath } from "node:fs/promises";
2
4
  import path from "node:path";
3
5
  import type { AssistantMessage } from "@earendil-works/pi-ai";
4
6
  import { clampThinkingLevel } from "@earendil-works/pi-ai/compat";
5
- import { existsSync } from "node:fs";
6
- import { mkdir, readFile, realpath } from "node:fs/promises";
7
7
  import {
8
8
  createAgentSession,
9
9
  createCodemodeExtension,
10
10
  createToolSearchExtension,
11
11
  DefaultResourceLoader,
12
+ type ExtensionContext,
12
13
  getAgentDir,
13
14
  ModelRuntime,
14
15
  SessionManager,
15
16
  SettingsManager,
16
- type ExtensionContext,
17
17
  } from "@earendil-works/pi-coding-agent";
18
- import {
19
- BOOTSTRAP_MESSAGE,
20
- buildBootstrapMessage,
21
- buildQueuedUserMessage,
22
- buildUpdateDetails,
23
- DurableMailbox,
24
- } from "./mailbox.ts";
25
- import { selectTools } from "../prefs/config.ts";
26
- import { TranscriptChannel } from "./transcript.ts";
27
- import type { InheritedToolSource } from "./inherited-tools.ts";
28
- import type { ModelSelectionMode, ToolFilteringMode } from "../prefs/settings.ts";
29
18
  import { applySpawnOverrides, modelSource } from "../../core/spawn-overrides.ts";
19
+ import { selectTools } from "../prefs/config.ts";
30
20
  import {
31
- modelIdentity,
32
21
  getModelPreferences,
33
- selectPreferredModel,
34
22
  ModelPreferenceError,
23
+ modelIdentity,
24
+ selectPreferredModel,
35
25
  } from "../prefs/models.ts";
26
+ import type { ModelSelectionMode, ToolFilteringMode } from "../prefs/settings.ts";
36
27
  import {
37
- THINKING_LEVELS,
38
28
  type AgentType,
39
- type ResolvedAgentSettings,
40
29
  type DriverFactory,
41
30
  type DriverOptions,
31
+ type ResolvedAgentSettings,
32
+ THINKING_LEVELS,
42
33
  type ThinkingLevel,
43
34
  } from "../types.ts";
35
+ import type { InheritedToolSource } from "./inherited-tools.ts";
36
+ import {
37
+ BOOTSTRAP_MESSAGE,
38
+ buildBootstrapMessage,
39
+ buildQueuedUserMessage,
40
+ buildUpdateDetails,
41
+ DurableMailbox,
42
+ } from "./mailbox.ts";
43
+ import { TranscriptChannel } from "./transcript.ts";
44
44
 
45
45
  const BUILTINS = ["read", "bash", "powershell", "edit", "write", "grep", "find", "ls"];
46
46
  /** Persisted before transcript messages so older-leaf restores still carry ownership. */
@@ -157,7 +157,7 @@ export function createDriverFactory(
157
157
  type: AgentType,
158
158
  parentPath?: string | null,
159
159
  restored?: ReturnType<SessionManager["buildSessionContext"]>,
160
- ): { provider?: string; id?: string; thinkingLevel: ThinkingLevel };
160
+ ): { provider?: string | undefined; id?: string | undefined; thinkingLevel: ThinkingLevel };
161
161
  } {
162
162
  // Keep resolved settings even after disposal: descendants inherit settings, not the caller's history.
163
163
  const resolved = new Map<string, { provider: string; id: string; thinking: ThinkingLevel }>();
@@ -226,9 +226,9 @@ export function createDriverFactory(
226
226
  const candidates = filtering
227
227
  ? availableScopedModels(normalizeScopedModels(ctx.scopedModels))
228
228
  : availableModelCandidates();
229
- ({ provider, id } = parseModelIdentity(
230
- selectTypeModelPreference(type, candidates, filtering)!,
231
- ));
229
+ const preferred = selectTypeModelPreference(type, candidates, filtering);
230
+ if (preferred === undefined) throw new Error("No preferred model is available");
231
+ ({ provider, id } = parseModelIdentity(preferred));
232
232
  } else if (source === "restored" && restored?.model) {
233
233
  provider = restored.model.provider;
234
234
  id = restored.model.modelId;
@@ -252,7 +252,7 @@ export function createDriverFactory(
252
252
  const { provider, id, thinkingLevel } = resolveInitialSettings(type, parentPath);
253
253
  const model =
254
254
  normalizeScopedModels(ctx.scopedModels).find(
255
- ({ model }) => model.provider === provider && model.id === id,
255
+ (scoped) => scoped.model.provider === provider && scoped.model.id === id,
256
256
  )?.model ?? (provider && id ? ctx.modelRegistry.find(provider, id) : undefined);
257
257
  if (!model)
258
258
  throw new Error(
@@ -381,14 +381,15 @@ export function createDriverFactory(
381
381
  modelId: string | undefined,
382
382
  scopedModels: readonly ScopedModel[],
383
383
  ) => {
384
- if (!providerId || !modelId)
384
+ if (!(providerId && modelId))
385
385
  throw new Error(
386
386
  `Subagent model ${providerId ?? "(unset)"}/${modelId ?? "(unset)"} is unavailable; select a physical model in the root or agent type`,
387
387
  );
388
388
  await mirrorRuntimeAuth(providerId);
389
389
  const sourceModel =
390
- scopedModels.find(({ model }) => model.provider === providerId && model.id === modelId)
391
- ?.model ?? ctx.modelRegistry.find(providerId, modelId);
390
+ scopedModels.find(
391
+ (scoped) => scoped.model.provider === providerId && scoped.model.id === modelId,
392
+ )?.model ?? ctx.modelRegistry.find(providerId, modelId);
392
393
  if (sourceModel?.api === "pi-virtual") {
393
394
  throw new Error(
394
395
  `Virtual model ${providerId}/${modelId} cannot be reproduced through the public registry API. Set this agent type's model to a physical provider/model-id.`,
@@ -414,13 +415,16 @@ export function createDriverFactory(
414
415
  const rootActive = new Set(inherited.activeNames);
415
416
  // Preserve deferred/codemode exposure rather than flooding model declarations with MCP tools.
416
417
  const activeToolNames = toolNames.filter(
417
- (name) => localNames.has(name) || rootActive.has(name) || options.type.tools?.allow?.includes(name) && toolFiltering === "allowed",
418
+ (name) =>
419
+ localNames.has(name) ||
420
+ rootActive.has(name) ||
421
+ (options.type.tools?.allow?.includes(name) && toolFiltering === "allowed"),
418
422
  );
419
423
  const customTools = [...options.tools, ...externalTools].filter(
420
424
  (tool) => allowed.has(tool.name) && tool.name !== "codemode" && tool.name !== "tool_search",
421
425
  );
422
426
  const settingsManager = SettingsManager.inMemory({ cacheWarming: "off" });
423
- let parkQueue = () => {};
427
+ let parkQueue: () => void = () => undefined;
424
428
  const pauseBoundary = () => {
425
429
  if (!options.shouldPause()) return undefined;
426
430
  parkQueue();
@@ -558,7 +562,7 @@ export function createDriverFactory(
558
562
  throw error;
559
563
  }
560
564
  const streamFunction = session.agent.streamFunction;
561
- session.agent.streamFunction = async (requestModel, context, streamOptions) => {
565
+ session.agent.streamFunction = (requestModel, context, streamOptions) => {
562
566
  assertScopedRequestAuthorized(
563
567
  modelIdentity(requestModel),
564
568
  normalizeScopedModels(getRootContext().scopedModels),
@@ -579,7 +583,7 @@ export function createDriverFactory(
579
583
  sessionManager.appendMessage(buildBootstrapMessage());
580
584
  emit("activity", BOOTSTRAP_MESSAGE);
581
585
  };
582
- const queueAccepted = async (kind: "steer" | "followUp", content: string) => {
586
+ const queueAccepted = (kind: "steer" | "followUp", content: string): Promise<void> => {
583
587
  assertCurrentModelAuthorized();
584
588
  ensurePersistedSession();
585
589
  const accepted = mailbox.accept(kind, content);
@@ -587,6 +591,7 @@ export function createDriverFactory(
587
591
  if (kind === "followUp") session.agent.followUp(buildQueuedUserMessage(accepted));
588
592
  else session.agent.steer(buildQueuedUserMessage(accepted));
589
593
  mailbox.markEnqueued(accepted.id);
594
+ return Promise.resolve();
590
595
  };
591
596
  const resumePending = async () => {
592
597
  for (const accepted of mailbox.replayablePending()) {
@@ -641,7 +646,7 @@ export function createDriverFactory(
641
646
  const usage = message.usage;
642
647
  const input = usage.input + usage.cacheRead + usage.cacheWrite;
643
648
  const output = usage.output;
644
- if (!Number.isFinite(input) || !Number.isFinite(output)) return;
649
+ if (!(Number.isFinite(input) && Number.isFinite(output))) return;
645
650
  return { input, output, reported: input + output + usage.totalTokens > 0 };
646
651
  };
647
652
 
@@ -650,7 +655,7 @@ export function createDriverFactory(
650
655
  .find((entry) => entry.type === "custom" && entry.customType === THREAD_OWNERSHIP_TYPE);
651
656
  const inheritedMetadata =
652
657
  ownership?.type === "custom"
653
- ? (ownership.data as { inheritedCount?: unknown })?.inheritedCount
658
+ ? (ownership.data as { inheritedCount?: unknown }).inheritedCount
654
659
  : undefined;
655
660
  const inheritedCount =
656
661
  typeof inheritedMetadata === "number" &&
@@ -1,9 +1,8 @@
1
- // @ts-nocheck: vendored upstream pi-subagent-manager 0.14.0 compiled under looser options; see src/subagents/VENDORED.md
2
- import { Type } from "typebox";
3
1
  import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
4
- import type { ThreadManager } from "./manager.ts";
2
+ import { Type } from "typebox";
5
3
  import { ModelPreferenceError } from "../prefs/models.ts";
6
4
  import type { AgentType, ResolvedAgentSettings, ThreadView } from "../types.ts";
5
+ import type { ThreadManager } from "./manager.ts";
7
6
 
8
7
  const path = Type.String({ description: "Absolute agent path, or name relative to the caller" });
9
8
  const text = Type.String({ minLength: 1 });
@@ -30,8 +29,10 @@ export function agentTools(
30
29
  const make = (
31
30
  name: string,
32
31
  description: string,
32
+ // biome-ignore-start lint/suspicious/noExplicitAny: the host validates params against the TypeBox schema before execute runs, and each handler reads only fields that schema declares
33
33
  parameters: any,
34
34
  execute: (params: any, signal?: AbortSignal) => Promise<any> | any,
35
+ // biome-ignore-end lint/suspicious/noExplicitAny: end of the schema-validated boundary
35
36
  ): ToolDefinition => ({
36
37
  name,
37
38
  label: name,
@@ -53,12 +54,7 @@ export function agentTools(
53
54
  return {
54
55
  name,
55
56
  description,
56
- error:
57
- error instanceof ModelPreferenceError
58
- ? error.summary
59
- : error instanceof Error
60
- ? error.message
61
- : String(error),
57
+ error: describeError(error),
62
58
  };
63
59
  }
64
60
  });
@@ -99,7 +95,8 @@ export function agentTools(
99
95
  ),
100
96
  thinkingLevel: Type.Optional(
101
97
  Type.String({
102
- description: "Pin this child's thinking level: off, minimal, low, medium, high, xhigh or max.",
98
+ description:
99
+ "Pin this child's thinking level: off, minimal, low, medium, high, xhigh or max.",
103
100
  }),
104
101
  ),
105
102
  }),
@@ -175,3 +172,8 @@ export function agentTools(
175
172
  ),
176
173
  ];
177
174
  }
175
+
176
+ function describeError(error: unknown): string {
177
+ if (error instanceof ModelPreferenceError) return error.summary;
178
+ return error instanceof Error ? error.message : String(error);
179
+ }
@@ -1,13 +1,12 @@
1
- // @ts-nocheck: vendored upstream pi-subagent-manager 0.14.0 compiled under looser options; see src/subagents/VENDORED.md
2
- import { constants, lstatSync, mkdirSync, openSync, closeSync, writeFileSync } from "node:fs";
1
+ import { closeSync, constants, lstatSync, mkdirSync, openSync, writeFileSync } from "node:fs";
3
2
  import { join } from "node:path";
4
3
  import { fileURLToPath } from "node:url";
5
4
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
5
+ import { dialogText } from "../ui/dialog.ts";
6
+ import { selectImportAgents } from "../ui/import-picker.ts";
6
7
  import { AGENT_COLORS, type ConfigStore } from "./config.ts";
7
8
  import { discoverImportCandidates, type ImportCandidate } from "./import-discovery.ts";
8
- import { selectImportAgents } from "../ui/import-picker.ts";
9
9
  import { MIGRATION_INSTRUCTIONS } from "./import-instructions.ts";
10
- import { dialogText } from "../ui/dialog.ts";
11
10
 
12
11
  export const IMPORT_REQUEST_PREFIX =
13
12
  "Import ONLY the agents I selected in the pi-subagent-manager migration picker.";
@@ -70,7 +69,8 @@ function importWasOffered(agentDir: string): boolean {
70
69
  }
71
70
 
72
71
  export function markImportOffered(agentDir: string): void {
73
- const directory = stateDirectory(agentDir, true)!;
72
+ const directory = stateDirectory(agentDir, true);
73
+ if (directory === undefined) throw new Error("Import-state directory could not be created");
74
74
  const file = join(directory, OFFER_MARKER);
75
75
  const stat = statIfPresent(file);
76
76
  if (stat) {
@@ -94,11 +94,11 @@ function buildImportPrompt(options: {
94
94
  agentDir: string;
95
95
  cwd: string;
96
96
  includeProject: boolean;
97
- existingTypes: { name: string; source?: string; filePath?: string }[];
97
+ existingTypes: { name: string; source?: string | undefined; filePath?: string | undefined }[];
98
98
  scopedModels: readonly string[];
99
- scopedModelDetails?: readonly { identity: string; api: string; virtual: boolean }[];
100
- parentModel?: string;
101
- parentModelApi?: string;
99
+ scopedModelDetails?: readonly { identity: string; api: string; virtual: boolean }[] | undefined;
100
+ parentModel?: string | undefined;
101
+ parentModelApi?: string | undefined;
102
102
  }): string {
103
103
  return [
104
104
  IMPORT_REQUEST_PREFIX,
@@ -159,10 +159,10 @@ export async function offerAgentImport(
159
159
  cwd: ctx.cwd,
160
160
  agentDir: options.agentDir,
161
161
  includeProject: ctx.isProjectTrusted(),
162
- homeDir: options.homeDir,
163
- extraAgentDirs: options.extraAgentDirs,
162
+ ...(options.homeDir === undefined ? {} : { homeDir: options.homeDir }),
163
+ ...(options.extraAgentDirs === undefined ? {} : { extraAgentDirs: options.extraAgentDirs }),
164
164
  });
165
- if (found.diagnostics.length)
165
+ if (found.diagnostics.length > 0)
166
166
  ctx.ui.notify(dialogText(found.diagnostics.join("\n")), "warning");
167
167
  const remember = () => {
168
168
  try {
@@ -174,9 +174,9 @@ export async function offerAgentImport(
174
174
  );
175
175
  }
176
176
  };
177
- if (!found.candidates.length) {
177
+ if (found.candidates.length === 0) {
178
178
  // Retry an incomplete scan next startup, rather than permanently hiding unreadable definitions.
179
- if (!found.diagnostics.length) remember();
179
+ if (found.diagnostics.length === 0) remember();
180
180
  if (!firstRun)
181
181
  ctx.ui.notify(
182
182
  "No external agent definitions found. Package-provided defaults are not scanned.",
@@ -208,7 +208,7 @@ export async function offerAgentImport(
208
208
  }
209
209
  const selected = new Set(ids);
210
210
  const candidates = found.candidates.filter((candidate) => selected.has(candidate.id));
211
- if (!candidates.length) return false;
211
+ if (candidates.length === 0) return false;
212
212
  options.store.reload();
213
213
  const prompt = buildImportPrompt({
214
214
  candidates,