@jaybeeuu/agent-cortex 2.3.0 → 2.4.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,66 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.4.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 7a6288b: Align the review REPORT contract on the run-pipeline-stage playbook shape: the review template
8
+ and the `review-security` skill now emit `OUTCOME: SUCCESS|BLOCKED` with `BLOCKING_ISSUES`,
9
+ instead of the unparsed `REVIEW_OUTCOME`/`CHANGES_REQUESTED` keys.
10
+ - 58d6627: Make the generated Claude plugin manifest valid: `repository` is always emitted as a string, normalising npm's object form to its `url` (falling back to `directory`, and omitted when neither exists) instead of copying the object through. An object there made Claude Code reject the whole plugin, hiding every skill and agent. The installer now also re-reads `claude plugin list --json` after registering and fails with Claude's validation error when the plugin reports failed to load, instead of reporting success. When that list cannot be read, or does not list the plugin, the installer warns that the load state is unconfirmed and omits its success message.
11
+ - 00fbab3: Record the dev loop in `docs/dev-loop.md`: the three-command refresh of the installed runtime
12
+ (`pnpm add -g …@latest`, then `agent-cortex install pi` and `agent-cortex install claude`, with
13
+ `--dry-run` for the plan and a pi restart (or `/reload-plugins` in Claude Code) to apply), the
14
+ installed layout under `~/.agent-cortex`, and how to try uncommitted work without overwriting it —
15
+ stage the checkout with `install pi --output <scratch dir>` and load it with `pi -ne -e
16
+ <scratch dir>`, which writes no settings and needs no undo. The page also states the boundary plainly: the checkout is what is
17
+ developed, `~/.agent-cortex` is what is run, and a path inside `~/src` is never a package source,
18
+ with the two historical violations named as anti-patterns. `README.md` links it from the pi
19
+ staging section and the Claude one-session section, and its Claude plugin section now states the
20
+ correct skill count (34, not 29).
21
+ - dda3a31: `agent-cortex install pi` now reports the two ways a machine ends up in a mixed state, instead of
22
+ leaving them to surface later as missing tools or conflict errors.
23
+
24
+ A configured package source whose path no longer exists is named with the command that drops it.
25
+ Detection is an existence check, so it no longer depends on reading the source `package.json` —
26
+ which is exactly what failed on a pruned worktree path, letting the entry survive a whole install
27
+ and every `pi remove` afterwards. The report gives `pi remove <resolved path>`: pi matches a local
28
+ source by resolved path, and a relative argument resolves against the current directory rather
29
+ than the settings file, so the obvious `pi remove <as configured>` misses. The installer does not
30
+ remove the entry itself — a missing path may be temporarily absent.
31
+
32
+ A pi package the staged package bundles and the user also registers standalone is reported with
33
+ both copies, the copy that takes precedence (load order decides, and on pi 1.x the first-listed
34
+ source wins), and the `pi remove` command that resolves it. The staged package always ships its own copy, so the
35
+ standalone entry is the one to drop; the installer does not remove packages the user configured.
36
+
37
+ `LEGACY_ORPHAN_SKILLS` no longer lists `productivity/migration`: it ships again, so the package root
38
+ attributes its copy, and the entry now follows its own instruction to be dropped once that holds.
39
+
40
+ ## 2.4.0
41
+
42
+ ### Minor Changes
43
+
44
+ - 0b72348: The extensions now load outside this checkout. `auto-name`, `notify`, `subagent` and `agent-modes`
45
+ value-imported `@earendil-works/pi-*`, which resolved only inside the repository, where pnpm had
46
+ installed each extension's own copy of pi's packages. The staged pi package carried no such
47
+ `node_modules`, so after `agent-cortex install pi` those four extensions failed to load for every
48
+ project outside this checkout — `subagent` included, which is the dispatcher ralph uses.
49
+
50
+ The runtime coupling is gone: frontmatter parsing, pi's user agent directory, the key helper and the
51
+ transcript components are now local, and the extensions declare no peer dependency on pi's packages. Two
52
+ callers lose their model-backed behaviour, since an extension has no completion API of its own:
53
+ `auto-name` names a session from its first prompt truncated (the fallback it already had) and
54
+ `notify` summarises with that same truncation. Subagent output renders as plain text instead of
55
+ markdown.
56
+
57
+ One bare runtime import remains: `typebox`. pi documents it as a host-provided module for
58
+ extensions and aliases it in its loader, so it resolves in the staged package. Everything imported
59
+ from `@earendil-works/*` is `import type`, which is erased. `test/pi-extensions-load.test.mjs`
60
+ stages the package and loads every extension from a temp directory with pi absent. A static scan in
61
+ the same file then rejects any value import from `@earendil-works/*` — including an unused one, which
62
+ tsx elides before the loader sees it.
63
+
3
64
  ## 2.3.0
4
65
 
5
66
  ### Minor Changes
package/README.md CHANGED
@@ -75,7 +75,10 @@ temp-dir install plus structural checks:
75
75
  - **Manifests** are generated too: the plugin's `.claude-plugin/plugin.json` (its `version`
76
76
  tracks `package.json`, so it never goes stale) and `hooks.json` (copied from the canonical
77
77
  `hooks/claude/hooks.json` source), plus any support files under `hooks/claude/` bundled into
78
- the plugin's `hooks/` so hook commands can reach them via `$CLAUDE_PLUGIN_ROOT`. Hand-authored
78
+ the plugin's `hooks/` so hook commands can reach them via `$CLAUDE_PLUGIN_ROOT`. Its
79
+ `repository` is always a string: `package.json`'s object form is reduced to its `url`, then
80
+ its `directory`, and dropped when neither exists. Claude rejects any other shape and drops
81
+ the whole plugin — every skill and agent in it — with that one field. Hand-authored
79
82
  extras — `.mcp.json` and `scripts/` — are copied into every install from the `claude-extras/`
80
83
  dir, which is their canonical store. See `docs/claude-hooks.md` for the
81
84
  extension→hook mapping and the rejections (auto-name, agent-guardrails, skill-stats, subagent, agent-modes).
@@ -201,7 +204,7 @@ The staged package contains:
201
204
  | `package.json` | pi manifest — `keywords: ["pi-package"]`, `pi.extensions` + `pi.skills`; the version tracks the package |
202
205
  | `agents/` | composed flat agents (ralph, plan, ralph-plan, strategy) with `{{TOOL:...}}` / `{{PATH:...}}` substituted — the shipped `agent-modes` extension resolves them from the package root |
203
206
  | `skills/` | token-substituted skill copies (real files, never symlinks) |
204
- | `extensions/` | copy of the repo `extensions/` tree |
207
+ | `extensions/` | copy of the repo `extensions/` tree — imports nothing from pi's runtime except the host-aliased `typebox`, so pi loads it from any directory |
205
208
  | `token-map.json` | the token map `agent-modes` reads from the package root |
206
209
  | `node_modules/` | the required third-party pi packages and their transitive closure |
207
210
 
@@ -229,10 +232,19 @@ A superseded raw-repo source that `pi remove` cannot drop fails the install
229
232
  reporting success would leave the two-source state registration exists to
230
233
  prevent. Nothing is installed on that path — remove the source by hand and re-run.
231
234
 
235
+ A source whose path no longer exists, and a package the staged bundle ships that
236
+ you also registered standalone, are reported and left configured: the installer
237
+ never removes an entry you added. Each report gives the `pi remove` command that
238
+ resolves it, with the resolved path, because `pi remove` resolves a relative
239
+ argument against the current directory.
240
+
232
241
  Re-run `agent-cortex install pi` after every `git pull` to refresh the staged
233
242
  package; registration is already in place, so that re-run changes nothing in
234
243
  pi's settings. Verify with `pi list` and `pi config` (restart pi to apply).
235
244
 
245
+ Refreshing the installed runtime after a release, and trying uncommitted work without
246
+ overwriting it, are recorded in [`docs/dev-loop.md`](docs/dev-loop.md).
247
+
236
248
  ### Desktop notifications
237
249
 
238
250
  Desktop notifications are handled by the local `extensions/notify/` extension
@@ -270,7 +282,7 @@ instructions file, leaving the rest of that file — and a `.bak` copy — intac
270
282
 
271
283
  A plain `agent-cortex install claude` **materialises** the plugin into the home install
272
284
  root `~/.agent-cortex/claude` — **4 agents** (`strategy`, `plan`, `ralph-plan`, `ralph`),
273
- **29 skills** (copied flat per skill, `{{TOOL:...}}` / `{{PATH:...}}` token-substituted — no
285
+ **34 skills** (copied flat per skill, `{{TOOL:...}}` / `{{PATH:...}}` token-substituted — no
274
286
  symlinks), `SessionStart` + `Notification` hooks, and 2 MCP servers — writes a marketplace
275
287
  manifest at `~/.agent-cortex/.claude-plugin/marketplace.json` exposing `./claude`, and
276
288
  registers it with Claude Code, and merges the committed `claude/settings.json` template into
@@ -306,8 +318,11 @@ claude --plugin-dir /tmp/agent-cortex-claude
306
318
  claude --plugin-dir /tmp/agent-cortex-claude plugin details agent-cortex
307
319
  ```
308
320
 
309
- `SKILL.md` edits are picked up live in that session; agent, hook, and MCP changes need
310
- `/reload-plugins`.
321
+ Edits to the generated copy of a `SKILL.md` are picked up live; re-run the generator after
322
+ editing the checkout. Agent, hook, and MCP changes need `/reload-plugins`.
323
+
324
+ Trying uncommitted work without overwriting the installed runtime is recorded in
325
+ [`docs/dev-loop.md`](docs/dev-loop.md).
311
326
 
312
327
  #### Install persistently (recommended)
313
328
 
@@ -321,7 +336,11 @@ exit code: `claude plugin marketplace list --json` picks add-vs-update and
321
336
  `claude plugin list --json` picks install-vs-update against the materialised version. A
322
337
  fresh install adds the marketplace and installs the plugin; a re-run is the update path
323
338
  (`marketplace update`, plus `plugin update` only when a newer version is materialised); a
324
- repeat install at the same version is a true no-op. It requires the `claude` plugin CLI
339
+ repeat install at the same version is a true no-op. Those commands exit 0 on a manifest Claude
340
+ rejects, so the installer re-reads `claude plugin list --json` after they run. A plugin that
341
+ list reports as failed to load fails the install with Claude's validation message. A list the
342
+ installer cannot read, or one that omits the plugin, warns that the load state is unconfirmed
343
+ and prints no success line. It requires the `claude` plugin CLI
325
344
  (Claude Code v2+): with a missing or pre-v2 CLI the installer warns and prints the manual
326
345
  commands below, exiting 0 — `--require-register` makes registration mandatory and fails
327
346
  non-zero when it can't run:
@@ -52,7 +52,12 @@
52
52
  // re-run is the update path, and a repeat install at the same version is a
53
53
  // true no-op. If the `claude` CLI is missing or predates the plugin subcommand
54
54
  // (v2+), the installer warns and prints the manual registration commands,
55
- // exiting non-zero only with --require-register.
55
+ // exiting non-zero only with --require-register. After the commands run,
56
+ // `claude plugin list --json` is re-read: a plugin Claude Code reports as failed
57
+ // to load fails the install with its validation error, because those commands
58
+ // exit 0 on a manifest they reject. When that list cannot be read, or omits the
59
+ // plugin, the load state is unconfirmed and the installer warns instead of
60
+ // claiming success.
56
61
  //
57
62
  // Zero dependencies so it runs on the CI Node and local Node alike.
58
63
 
@@ -253,6 +258,26 @@ async function copyTree(src, dest, transform, dryRun) {
253
258
  return { md, files };
254
259
  }
255
260
 
261
+ /**
262
+ * Normalise package.json's `repository` field into the string Claude Code's
263
+ * plugin manifest requires. npm permits a string or an object
264
+ * (`{ type, url, directory }`); Claude rejects anything but a string, and one
265
+ * invalid manifest makes the whole plugin fail to load, so the object form must
266
+ * never be copied through. Prefer `url`, fall back to `directory`, and omit the
267
+ * field when neither is usable rather than emitting junk.
268
+ * @param {unknown} repository parsed package.json `repository` value
269
+ * @returns {string | undefined}
270
+ */
271
+ function normaliseRepository(repository) {
272
+ if (typeof repository === "string") return repository.trim() === "" ? undefined : repository;
273
+ if (repository === null || typeof repository !== "object") return undefined;
274
+ const { url, directory } = /** @type {{ url?: unknown, directory?: unknown }} */ (repository);
275
+ for (const candidate of [url, directory]) {
276
+ if (typeof candidate === "string" && candidate.trim() !== "") return candidate;
277
+ }
278
+ return undefined;
279
+ }
280
+
256
281
  /**
257
282
  * plugin.json manifest; version tracks the package so it can never go stale.
258
283
  * @param {string} root
@@ -268,7 +293,9 @@ async function buildPluginJson(root) {
268
293
  description: "Personal Claude Code plugin with custom agents and skills",
269
294
  author: { name: "jaybeeuu" },
270
295
  license: pkg.license ?? "MIT",
271
- repository: pkg.repository,
296
+ // JSON.stringify drops an undefined value, so an unusable repository
297
+ // is omitted instead of emitted as junk.
298
+ repository: normaliseRepository(pkg.repository),
272
299
  hooks: "./hooks.json",
273
300
  },
274
301
  null,
@@ -940,6 +967,50 @@ async function installedPluginVersion(claudeBin, pluginId) {
940
967
  }
941
968
  }
942
969
 
970
+ /**
971
+ * Verdict of `claude plugin list --json` on whether `pluginId` actually loads.
972
+ * An entry carries `errors` when the materialised manifest could not be loaded
973
+ * (the CLI then also reports version "unknown") — a broken plugin that every
974
+ * install command still exits 0 on. Unreadable or unexpected output yields
975
+ * `unknown`, and a readable list that omits the plugin yields `not-listed`:
976
+ * both are no verdict, never a false failure.
977
+ * @typedef {{ verdict: "unknown" } | { verdict: "not-listed" } | { verdict: "loaded" } | { verdict: "failed", errors: string[] }} PluginLoadVerdict
978
+ */
979
+
980
+ /**
981
+ * The string members of `values`, in order.
982
+ * @param {unknown[]} values
983
+ * @returns {string[]}
984
+ */
985
+ function stringValues(values) {
986
+ /** @type {string[]} */
987
+ const strings = [];
988
+ for (const value of values) if (typeof value === "string") strings.push(value);
989
+ return strings;
990
+ }
991
+
992
+ /**
993
+ * @param {string} claudeBin
994
+ * @param {string} pluginId
995
+ * @returns {Promise<PluginLoadVerdict>}
996
+ */
997
+ async function pluginLoadVerdict(claudeBin, pluginId) {
998
+ const out = await queryClaudeCommand(claudeBin, ["plugin", "list", "--json"]);
999
+ if (out == null) return { verdict: "unknown" };
1000
+ /** @type {any} */
1001
+ let entries;
1002
+ try {
1003
+ entries = JSON.parse(out.trim() || "[]");
1004
+ } catch {
1005
+ return { verdict: "unknown" };
1006
+ }
1007
+ if (!Array.isArray(entries)) return { verdict: "unknown" };
1008
+ const entry = entries.find((/** @type {any} */ e) => e?.id === pluginId);
1009
+ if (entry === undefined) return { verdict: "not-listed" };
1010
+ const errors = Array.isArray(entry.errors) ? stringValues(/** @type {unknown[]} */ (entry.errors)) : [];
1011
+ return errors.length > 0 ? { verdict: "failed", errors } : { verdict: "loaded" };
1012
+ }
1013
+
943
1014
  /**
944
1015
  * Register the materialised plugin with Claude Code by driving the `claude
945
1016
  * plugin` CLI against the home install root as the marketplace source.
@@ -967,6 +1038,13 @@ async function installedPluginVersion(claudeBin, pluginId) {
967
1038
  * `requireRegister` is set — the default is warn-only because the plugin is
968
1039
  * already materialised by then; the file side of the install succeeded.
969
1040
  *
1041
+ * A plugin that `claude plugin list --json` reports as failed to load is never
1042
+ * reported as a success: the plugin commands exit 0 on an invalid manifest, so
1043
+ * the run throws with Claude's validation error (which names the manifest)
1044
+ * rather than leaving every skill and agent in the plugin silently unavailable.
1045
+ * When that list cannot be read, or does not list the plugin, the load state is
1046
+ * unconfirmed: the install warns and skips the success line.
1047
+ *
970
1048
  * @param {object} options
971
1049
  * @param {string} [options.root] Marketplace root carrying .claude-plugin/…
972
1050
  * (defaults to the repo containing this module)
@@ -1054,8 +1132,33 @@ export async function registerClaude({
1054
1132
  console.log(` ${claudeBin} ${args.join(" ")}`);
1055
1133
  await runClaudeCommand(claudeBin, args);
1056
1134
  }
1057
- console.log(
1058
- `Installed — verify with "claude plugin list" / "claude plugin details ${plugin}" (restart Claude Code to apply).`,
1059
- );
1135
+ // The commands exiting 0 does not mean the plugin loads: a manifest Claude
1136
+ // rejects still installs and updates cleanly. Claiming success there would
1137
+ // hide that every skill and agent in the plugin is unavailable.
1138
+ const load = await pluginLoadVerdict(claudeBin, pluginId);
1139
+ if (load.verdict === "failed") {
1140
+ throw new Error(
1141
+ `plugin "${pluginId}" is installed but Claude Code reports it as failed to load: ${load.errors.join("; ")} — ` +
1142
+ `every skill and agent in the plugin stays unavailable until the manifest loads; fix it and re-run`,
1143
+ );
1144
+ }
1145
+ // No verdict is not a success either: an unreadable list or a list that omits
1146
+ // the plugin leaves its load state unconfirmed, so warn instead of claiming
1147
+ // the plugin is installed and working.
1148
+ if (load.verdict === "unknown") {
1149
+ warn(
1150
+ `could not read "${claudeBin} plugin list --json", so the plugin's load state is unconfirmed — ` +
1151
+ `verify with "${claudeBin} plugin list" (restart Claude Code to apply)`,
1152
+ );
1153
+ } else if (load.verdict === "not-listed") {
1154
+ warn(
1155
+ `"${pluginId}" is not in "${claudeBin} plugin list --json" after registration, so it may not have registered — ` +
1156
+ `verify with "${claudeBin} plugin list" (restart Claude Code to apply)`,
1157
+ );
1158
+ } else {
1159
+ console.log(
1160
+ `Installed — verify with "claude plugin list" / "claude plugin details ${plugin}" (restart Claude Code to apply).`,
1161
+ );
1162
+ }
1060
1163
  return summary;
1061
1164
  }
@@ -59,8 +59,12 @@
59
59
  // pi's own user settings (settings.json, keybindings.json) are NOT written by
60
60
  // the installer: they are pi's config, not the package's, and `pi install` is
61
61
  // the command that owns them. They are READ (never written) to decide
62
- // install-vs-no-op and to find stale raw-repo sources; the committed pi/*.json
63
- // templates remain reference copies.
62
+ // install-vs-no-op, to find stale raw-repo sources, and to report two states
63
+ // the installer cannot resolve on its own: a configured source whose path no
64
+ // longer exists (it cannot be identified, so only `pi remove <resolved path>`
65
+ // can drop it), and a package the user also registers standalone that the
66
+ // staged package bundles (both copies load, and their tools collide). The
67
+ // committed pi/*.json templates remain reference copies.
64
68
  //
65
69
  // LEGACY SWEEP (sweepLegacyPiUserDirs): the pre-staged-model installer rendered
66
70
  // agents and skills into pi's own user directory (~/.pi/agent). Those copies are
@@ -198,13 +202,14 @@ const DEFAULT_WARN = (/** @type {string} */ msg) => console.warn(`[pi-installer]
198
202
 
199
203
  // Artifacts the pre-staged-model installer wrote into pi's own user directory
200
204
  // that the package root can no longer name on its own: the orphaned
201
- // `pipeline-worker` agent (an older render with no canonical source today) and
202
- // the `migration` skill (authored on a branch this epic does not carry).
205
+ // `pipeline-worker` agent (an older render with no canonical source today).
203
206
  // Everything else is attributed from the package root — see
204
207
  // sweepLegacyPiUserDirs. Drop an entry once it is either shipped again (the
205
- // root-derived rules then cover it) or gone for good.
208
+ // root-derived rules then cover it) or gone for good; `productivity/migration`
209
+ // was dropped that way once the skill shipped again.
206
210
  const LEGACY_ORPHAN_AGENTS = ["pipeline-worker"];
207
- const LEGACY_ORPHAN_SKILLS = ["productivity/migration"];
211
+ /** @type {string[]} Skills an older render shipped under a name the root no longer has. */
212
+ const LEGACY_ORPHAN_SKILLS = [];
208
213
 
209
214
  // A path record some install flows leave in pi's user directory, listing the
210
215
  // copies they wrote there. It describes the same tree the sweep removes, so it
@@ -405,10 +410,13 @@ async function installSkills({ root, skillsDir, dryRun, pluginRoot, tokenMap, wa
405
410
  /**
406
411
  * Copy the repo's extensions/ tree into the staged package verbatim.
407
412
  *
408
- * The extensions import `@earendil-works/*`, which pi's loader supplies, so no
409
- * dependency tree is needed. `node_modules` (pnpm workspace links) and symlinks
410
- * are never copied: the staged package owns its dependencies and must contain
411
- * real files only.
413
+ * The extensions import nothing from pi's runtime — only `import type`, which is
414
+ * erased, and the one module pi documents as host-provided (`typebox`) — so no
415
+ * dependency tree of their own is needed. A value import of `@earendil-works/*`
416
+ * would only resolve where pnpm installed it and would break the staged package
417
+ * everywhere else, which test/pi-extensions-load.test.mjs guards. `node_modules`
418
+ * (pnpm workspace links) and symlinks are never copied: the staged package owns
419
+ * its dependencies and must contain real files only.
412
420
  * @param {{ root: string, output: string, dryRun: boolean }} options
413
421
  * @returns {Promise<{ files: number, dir: string }>}
414
422
  */
@@ -579,6 +587,11 @@ async function readDependencyNames(dir) {
579
587
  * `git pull && agent-cortex install pi` is a true no-op)
580
588
  * a source resolves to a raw agent-cortex checkout (this package's own name)
581
589
  * → `pi remove` it first, so exactly one agent-cortex source stays
590
+ * a local source whose path does not exist → reported with the command that
591
+ * drops it (see reportDanglingSources)
592
+ * a package the staged package bundles is also configured standalone →
593
+ * reported with the copy that wins and the command that resolves it (see
594
+ * reportBundledCollisions)
582
595
  *
583
596
  * A superseded source that survives its removal fails the run: pi would load
584
597
  * both sources side by side, so reporting success would leave the very state
@@ -612,7 +625,19 @@ export async function registerPi({
612
625
  const stagedPath = resolve(output);
613
626
  const rawPackageName = (await readJsonObject(join(root, "package.json")))?.name;
614
627
  const sources = await readConfiguredPackages(settingsPath);
615
- const { registered, stale } = await classifySources({ sources, settingsPath, stagedPath, rawPackageName });
628
+ const { registered, stale, dangling } = await classifySources({ sources, settingsPath, stagedPath, rawPackageName });
629
+
630
+ // Both reports describe state this installer must not change on its own — a
631
+ // source it cannot identify, and a source the user configured deliberately —
632
+ // so they are reported on every path, including the no-op and dry-run ones.
633
+ reportDanglingSources(dangling, settingsPath, warn);
634
+ await reportBundledCollisions({
635
+ sources,
636
+ settingsPath,
637
+ stagedPath,
638
+ bundledPackages: await readBundledPackages(stagedPath),
639
+ warn,
640
+ });
616
641
 
617
642
  // The same sequence a human would run by hand, in the order the installer
618
643
  // runs it: drop the superseded sources, then register the staged package.
@@ -625,7 +650,7 @@ export async function registerPi({
625
650
  console.log(`Registering the staged pi package with pi (via ${piBin})…`);
626
651
 
627
652
  if (dryRun) {
628
- console.log(` would read: ${settingsPath} (install-vs-no-op and stale raw-repo sources)`);
653
+ console.log(` would read: ${settingsPath} (install-vs-no-op, stale raw-repo sources, unusable sources)`);
629
654
  for (const { path } of stale) console.log(` would run: ${piBin} remove ${path}`);
630
655
  if (registered) console.log(` already configured: ${stagedPath}`);
631
656
  else console.log(` would run: ${piBin} install ${stagedPath}`);
@@ -736,17 +761,23 @@ function resolveSourcePath(source, settingsDir) {
736
761
 
737
762
  /**
738
763
  * Split the configured local sources into "the staged package is already
739
- * registered" and "a raw agent-cortex checkout superseded by it". A source
740
- * whose directory is gone is left alone: a missing path cannot be told apart
741
- * from a temporarily absent one, and removing it could drop a package the user
742
- * still wants.
764
+ * registered", "a raw agent-cortex checkout superseded by it", and "a source
765
+ * whose path is not there".
766
+ *
767
+ * The third group is reported, never acted on: a missing path cannot be told
768
+ * apart from a temporarily absent one, and removing it could drop a package the
769
+ * user still wants. It also cannot be identified by name — reading the source
770
+ * package.json to recognise a raw agent-cortex checkout fails on a path that is
771
+ * not there — which is how such an entry survived a whole install unnoticed.
743
772
  * @param {{ sources: string[], settingsPath: string, stagedPath: string, rawPackageName: string | undefined }} options
744
- * @returns {Promise<{ registered: boolean, stale: { path: string }[] }>}
773
+ * @returns {Promise<{ registered: boolean, stale: { path: string }[], dangling: { source: string, path: string }[] }>}
745
774
  */
746
775
  async function classifySources({ sources, settingsPath, stagedPath, rawPackageName }) {
747
776
  const settingsDir = dirname(settingsPath);
748
777
  /** @type {{ path: string }[]} */
749
778
  const stale = [];
779
+ /** @type {{ source: string, path: string }[]} */
780
+ const dangling = [];
750
781
  let registered = false;
751
782
  for (const source of sources) {
752
783
  if (!isLocalSource(source)) continue;
@@ -755,10 +786,137 @@ async function classifySources({ sources, settingsPath, stagedPath, rawPackageNa
755
786
  registered = true;
756
787
  continue;
757
788
  }
789
+ // A local source may also be a single extension file, so absence means
790
+ // neither a directory nor a file is there.
791
+ if (!(await isDirectory(path)) && !(await isFile(path))) {
792
+ dangling.push({ source, path });
793
+ continue;
794
+ }
758
795
  if (rawPackageName === undefined) continue;
759
796
  if ((await readJsonObject(join(path, "package.json")))?.name === rawPackageName) stale.push({ path });
760
797
  }
761
- return { registered, stale };
798
+ return { registered, stale, dangling };
799
+ }
800
+
801
+ /**
802
+ * Report a configured local source whose path does not exist, with the command
803
+ * that drops it.
804
+ *
805
+ * The source is named twice — as configured and as its resolved path — because
806
+ * pi matches a local source by its resolved path: a relative `pi remove`
807
+ * argument resolves against the CURRENT directory, not against the directory
808
+ * holding settings.json (pi 1.x, probed against 1.4.0), so the obvious
809
+ * `pi remove <as configured>` misses from anywhere else and leaves the entry
810
+ * stuck.
811
+ * @param {{ source: string, path: string }[]} dangling
812
+ * @param {string} settingsPath
813
+ * @param {Warn} warn
814
+ */
815
+ function reportDanglingSources(dangling, settingsPath, warn) {
816
+ for (const { source, path } of dangling) {
817
+ warn(
818
+ `pi package source "${source}" cannot be identified: ${path} does not exist. ` +
819
+ `pi loads nothing from it and still lists it, so everything it was registered for is missing. ` +
820
+ `It is left configured in ${settingsPath} — a missing path may be temporarily absent. ` +
821
+ `Drop it with:\n pi remove ${path}\n` +
822
+ `(pass the resolved path: pi 1.x resolves a relative remove argument against the current directory, not ${settingsPath})`,
823
+ );
824
+ }
825
+ }
826
+
827
+ /**
828
+ * Names of the third-party pi packages the staged package ships its own copy
829
+ * of, read from its generated manifest.
830
+ *
831
+ * Bundling stamps one `node_modules/<name>/…` resource path per package that
832
+ * ships pi resources (see resolvePiPackages), so the manifest names exactly the
833
+ * set that can load a second time beside a standalone registration. A package
834
+ * without a `pi` manifest key is only a module and cannot collide.
835
+ * @param {string} stagedPath
836
+ * @returns {Promise<string[]>}
837
+ */
838
+ async function readBundledPackages(stagedPath) {
839
+ const manifest = await readJsonObject(join(stagedPath, "package.json"));
840
+ if (manifest === null) return [];
841
+ /** @type {Set<string>} */
842
+ const names = new Set();
843
+ for (const resource of [...asStringList(manifest.pi?.extensions), ...asStringList(manifest.pi?.skills)]) {
844
+ const match = /^node_modules\/((?:@[^/]+\/)?[^/]+)\//.exec(resource);
845
+ if (match !== null) names.add(match[1]);
846
+ }
847
+ return [...names];
848
+ }
849
+
850
+ /**
851
+ * Report every pi package the staged package bundles that the user also
852
+ * registers standalone.
853
+ *
854
+ * Staging copies each third-party pi package under the staged node_modules, so
855
+ * a standalone registration of the same package loads a second copy: pi keeps
856
+ * both — precedence follows load order on pi 1.x — and reports the overlap as
857
+ * tool-name, shortcut, and skill conflicts at startup, long after the install
858
+ * said it succeeded. The installer cannot drop the standalone entry (it is
859
+ * the user's configuration) and must not drop the bundle, so it says which
860
+ * copy wins and which command resolves it.
861
+ * @param {{ sources: string[], settingsPath: string, stagedPath: string,
862
+ * bundledPackages: string[], warn: Warn }} options
863
+ * @returns {Promise<void>}
864
+ */
865
+ async function reportBundledCollisions({ sources, settingsPath, stagedPath, bundledPackages, warn }) {
866
+ if (bundledPackages.length === 0) return;
867
+ const settingsDir = dirname(settingsPath);
868
+ const bundled = new Set(bundledPackages);
869
+ // pi 1.x loads configured sources in order, so the first one listed wins a
870
+ // shared tool name (probed against pi 1.4.0 — pi's behaviour, not a contract,
871
+ // so the message dates the claim); `-1` means the staged package is not
872
+ // configured at all, and `pi install` appends, so a standalone entry stays
873
+ // listed first.
874
+ const stagedIndex = sources.findIndex((source) => isLocalSource(source) && resolveSourcePath(source, settingsDir) === stagedPath);
875
+ for (const [index, source] of sources.entries()) {
876
+ const local = isLocalSource(source);
877
+ const path = local ? resolveSourcePath(source, settingsDir) : undefined;
878
+ if (path === stagedPath) continue;
879
+ const name = await configuredPackageName(source, settingsDir);
880
+ if (name === undefined || !bundled.has(name)) continue;
881
+
882
+ const bundledDir = join(stagedPath, "node_modules", name);
883
+ const standalone = path ?? source;
884
+ const winner =
885
+ stagedIndex === -1 || index < stagedIndex
886
+ ? `${standalone} takes precedence because it is listed first in ${settingsPath}`
887
+ : `the bundled copy at ${bundledDir} takes precedence because ${settingsPath} lists the staged package first`;
888
+ warn(
889
+ `pi loads "${name}" twice: standalone from ${standalone}, and bundled at ${bundledDir}. ` +
890
+ `The copies collide on tool names, extension shortcuts, and skills; on pi 1.x ${winner}. ` +
891
+ `The staged package always ships its own copy, so drop the standalone one with:\n` +
892
+ ` pi remove ${standalone}`,
893
+ );
894
+ }
895
+ }
896
+
897
+ /**
898
+ * The package name a configured source registers, or `undefined` when the
899
+ * installer cannot name it: an `npm:` spec by its name, a local source by the
900
+ * `name` in its package.json.
901
+ * @param {string} source
902
+ * @param {string} settingsDir
903
+ * @returns {Promise<string | undefined>}
904
+ */
905
+ async function configuredPackageName(source, settingsDir) {
906
+ if (source.trim().startsWith("npm:")) return npmPackageName(source);
907
+ if (!isLocalSource(source)) return undefined;
908
+ return (await readJsonObject(join(resolveSourcePath(source, settingsDir), "package.json")))?.name;
909
+ }
910
+
911
+ /**
912
+ * `npm:@scope/name@^1.2.3` → `@scope/name`.
913
+ * @param {string} source
914
+ * @returns {string}
915
+ */
916
+ function npmPackageName(source) {
917
+ const spec = source.trim().slice("npm:".length).trim();
918
+ const at = spec.indexOf("@", spec.startsWith("@") ? 1 : 0);
919
+ return at === -1 ? spec : spec.slice(0, at);
762
920
  }
763
921
 
764
922
  /**
@@ -12,8 +12,5 @@
12
12
  "@types/node": "^22.0.0",
13
13
  "tsx": "^4.0.0",
14
14
  "typescript": "^5.0.0"
15
- },
16
- "peerDependencies": {
17
- "@earendil-works/pi-coding-agent": "^0.78.1"
18
15
  }
19
16
  }
@@ -23,7 +23,6 @@
23
23
  import { join, dirname } from "node:path";
24
24
  import { fileURLToPath } from "node:url";
25
25
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
26
- import { Key } from "@earendil-works/pi-tui";
27
26
  import { discoverAgents, DEFAULT_TOOLS, type AgentDef } from "./discover.ts";
28
27
  import type { ThemeColor } from "@earendil-works/pi-coding-agent";
29
28
 
@@ -196,8 +195,10 @@ export default function agentModesExtension(pi: ExtensionAPI): void {
196
195
  },
197
196
  });
198
197
 
199
- // Ctrl+Shift+A to cycle agents (A = Agent)
200
- pi.registerShortcut(Key.ctrlShift("a"), {
198
+ // Ctrl+Shift+A to cycle agents (A = Agent). Spelled out rather than built
199
+ // with pi-tui's Key helper: a value import of pi-tui stops the extension
200
+ // loading outside the agent-cortex checkout (test/pi-extensions-load.test.mjs).
201
+ pi.registerShortcut("ctrl+shift+a", {
201
202
  description: "Next agent mode",
202
203
  handler: async (ctx) => {
203
204
  const next = (activeAgentIndex + 1) % (agents.length + 1);
@@ -9,12 +9,8 @@
9
9
  },
10
10
  "devDependencies": {
11
11
  "@earendil-works/pi-coding-agent": "^0.78.1",
12
- "@earendil-works/pi-tui": "^0.78.1",
13
12
  "@types/node": "^22.0.0",
14
13
  "tsx": "^4.0.0",
15
14
  "typescript": "^5.0.0"
16
- },
17
- "peerDependencies": {
18
- "@earendil-works/pi-coding-agent": "^0.78.1"
19
15
  }
20
- }
16
+ }