@blackbelt-technology/pi-agent-dashboard 0.4.6 → 0.5.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.
Files changed (181) hide show
  1. package/AGENTS.md +339 -190
  2. package/README.md +50 -7
  3. package/docs/architecture.md +238 -23
  4. package/package.json +23 -13
  5. package/packages/extension/package.json +11 -3
  6. package/packages/extension/src/__tests__/ask-user-tool.test.ts +1 -1
  7. package/packages/extension/src/__tests__/build-provider-catalogue.test.ts +176 -0
  8. package/packages/extension/src/__tests__/command-handler.test.ts +68 -0
  9. package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +1 -1
  10. package/packages/extension/src/__tests__/markdown-image-inliner.test.ts +355 -0
  11. package/packages/extension/src/__tests__/no-tui-multiselect-arm-regression.test.ts +1 -1
  12. package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +68 -0
  13. package/packages/extension/src/__tests__/prompt-expander.test.ts +45 -0
  14. package/packages/extension/src/__tests__/provider-register-reload.test.ts +74 -0
  15. package/packages/extension/src/__tests__/retry-tracker.test.ts +147 -0
  16. package/packages/extension/src/__tests__/server-launcher.test.ts +24 -1
  17. package/packages/extension/src/__tests__/session-sync.test.ts +72 -0
  18. package/packages/extension/src/__tests__/usage-limit-orderer.test.ts +105 -0
  19. package/packages/extension/src/ask-user-tool.ts +1 -1
  20. package/packages/extension/src/bridge-context.ts +1 -1
  21. package/packages/extension/src/bridge.ts +169 -4
  22. package/packages/extension/src/command-handler.ts +65 -2
  23. package/packages/extension/src/flow-event-wiring.ts +1 -1
  24. package/packages/extension/src/markdown-image-inliner.ts +268 -0
  25. package/packages/extension/src/multiselect-list.ts +1 -1
  26. package/packages/extension/src/pi-env.d.ts +16 -9
  27. package/packages/extension/src/prompt-expander.ts +50 -2
  28. package/packages/extension/src/provider-register.ts +132 -8
  29. package/packages/extension/src/retry-tracker.ts +123 -0
  30. package/packages/extension/src/server-launcher.ts +18 -1
  31. package/packages/extension/src/session-sync.ts +15 -1
  32. package/packages/extension/src/usage-limit-orderer.ts +76 -0
  33. package/packages/server/package.json +6 -6
  34. package/packages/server/src/__tests__/auto-attach-slug-defense.test.ts +104 -0
  35. package/packages/server/src/__tests__/bootstrap-install-from-list.test.ts +263 -0
  36. package/packages/server/src/__tests__/browser-gateway-snapshot-on-connect.test.ts +143 -0
  37. package/packages/server/src/__tests__/build-auth-status.test.ts +190 -0
  38. package/packages/server/src/__tests__/changelog-fs.test.ts +171 -0
  39. package/packages/server/src/__tests__/changelog-parser.test.ts +220 -0
  40. package/packages/server/src/__tests__/changelog-remote.test.ts +193 -0
  41. package/packages/server/src/__tests__/cli-parse.test.ts +22 -4
  42. package/packages/server/src/__tests__/cold-boot-openspec-broadcast.test.ts +161 -0
  43. package/packages/server/src/__tests__/directory-service-refresh-force.test.ts +1 -1
  44. package/packages/server/src/__tests__/directory-service-specs-mtime.test.ts +1 -1
  45. package/packages/server/src/__tests__/directory-service-toctou.test.ts +1 -1
  46. package/packages/server/src/__tests__/directory-service.test.ts +1 -1
  47. package/packages/server/src/__tests__/doctor-route.test.ts +132 -0
  48. package/packages/server/src/__tests__/event-wiring-providers-list.test.ts +154 -0
  49. package/packages/server/src/__tests__/fixtures/pi-changelog-slice.md +180 -0
  50. package/packages/server/src/__tests__/fork-empty-session-preflight.test.ts +268 -0
  51. package/packages/server/src/__tests__/has-openspec-dir.test.ts +64 -0
  52. package/packages/server/src/__tests__/headless-pid-registry.test.ts +83 -0
  53. package/packages/server/src/__tests__/health-shape.test.ts +43 -0
  54. package/packages/server/src/__tests__/idle-timer-respects-terminals.test.ts +115 -0
  55. package/packages/server/src/__tests__/is-pi-process.test.ts +1 -1
  56. package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +92 -0
  57. package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +4 -4
  58. package/packages/server/src/__tests__/package-routes.test.ts +1 -1
  59. package/packages/server/src/__tests__/pending-fork-registry.test.ts +48 -24
  60. package/packages/server/src/__tests__/pi-changelog-integration.test.ts +165 -0
  61. package/packages/server/src/__tests__/pi-changelog-routes.test.ts +409 -0
  62. package/packages/server/src/__tests__/pi-core-checker.test.ts +155 -13
  63. package/packages/server/src/__tests__/pi-core-updater-managed-path.test.ts +236 -0
  64. package/packages/server/src/__tests__/pi-core-updater.test.ts +1 -1
  65. package/packages/server/src/__tests__/pi-dev-version-check.test.ts +184 -0
  66. package/packages/server/src/__tests__/pi-version-skew.test.ts +4 -4
  67. package/packages/server/src/__tests__/process-manager-codes.test.ts +80 -0
  68. package/packages/server/src/__tests__/process-manager-managed-path.test.ts +73 -0
  69. package/packages/server/src/__tests__/provider-auth-routes.test.ts +12 -4
  70. package/packages/server/src/__tests__/provider-auth-storage.test.ts +42 -11
  71. package/packages/server/src/__tests__/provider-catalogue-cache.test.ts +44 -0
  72. package/packages/server/src/__tests__/recommended-routes.test.ts +1 -1
  73. package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +17 -2
  74. package/packages/server/src/__tests__/session-action-handler-spawn.test.ts +150 -0
  75. package/packages/server/src/__tests__/session-discovery-skill-firstmessage.test.ts +95 -0
  76. package/packages/server/src/__tests__/spawn-correlation-token-integration.test.ts +91 -0
  77. package/packages/server/src/__tests__/spawn-failure-log.test.ts +118 -0
  78. package/packages/server/src/__tests__/spawn-preflight.test.ts +91 -0
  79. package/packages/server/src/__tests__/spawn-register-watchdog.test.ts +250 -0
  80. package/packages/server/src/__tests__/spawn-token.test.ts +57 -0
  81. package/packages/server/src/__tests__/subscription-handler.test.ts +98 -6
  82. package/packages/server/src/__tests__/system-routes-reextract.test.ts +91 -0
  83. package/packages/server/src/__tests__/system-routes-spawn-failures.test.ts +84 -0
  84. package/packages/server/src/__tests__/terminal-manager.test.ts +45 -0
  85. package/packages/server/src/bootstrap-install-from-list.ts +232 -0
  86. package/packages/server/src/bootstrap-state.ts +18 -0
  87. package/packages/server/src/browser-gateway.ts +70 -24
  88. package/packages/server/src/browser-handlers/directory-handler.ts +4 -0
  89. package/packages/server/src/browser-handlers/handler-context.ts +9 -0
  90. package/packages/server/src/browser-handlers/session-action-handler.ts +159 -18
  91. package/packages/server/src/browser-handlers/subscription-handler.ts +50 -3
  92. package/packages/server/src/changelog-fs.ts +167 -0
  93. package/packages/server/src/changelog-parser.ts +321 -0
  94. package/packages/server/src/changelog-remote.ts +134 -0
  95. package/packages/server/src/cli.ts +23 -2
  96. package/packages/server/src/directory-service.ts +31 -0
  97. package/packages/server/src/event-wiring.ts +105 -5
  98. package/packages/server/src/headless-pid-registry.ts +54 -5
  99. package/packages/server/src/home-lock.d.ts +124 -0
  100. package/packages/server/src/home-lock.js +330 -0
  101. package/packages/server/src/home-lock.js.map +1 -0
  102. package/packages/server/src/idle-timer.ts +15 -1
  103. package/packages/server/src/pending-client-correlations.ts +73 -0
  104. package/packages/server/src/pending-fork-registry.ts +24 -12
  105. package/packages/server/src/pi-core-checker.ts +77 -17
  106. package/packages/server/src/pi-core-updater.ts +81 -15
  107. package/packages/server/src/pi-dev-version-check.ts +145 -0
  108. package/packages/server/src/pi-gateway.ts +10 -0
  109. package/packages/server/src/pi-version-skew.ts +12 -4
  110. package/packages/server/src/process-manager.ts +115 -21
  111. package/packages/server/src/provider-auth-handlers.ts +9 -0
  112. package/packages/server/src/provider-auth-storage.ts +83 -51
  113. package/packages/server/src/provider-catalogue-cache.ts +47 -0
  114. package/packages/server/src/routes/doctor-routes.ts +140 -0
  115. package/packages/server/src/routes/pi-changelog-routes.ts +194 -0
  116. package/packages/server/src/routes/pi-core-routes.ts +1 -1
  117. package/packages/server/src/routes/provider-auth-routes.ts +14 -1
  118. package/packages/server/src/routes/provider-routes.ts +4 -4
  119. package/packages/server/src/routes/system-routes.ts +38 -1
  120. package/packages/server/src/server.ts +85 -66
  121. package/packages/server/src/session-api.ts +54 -3
  122. package/packages/server/src/session-bootstrap.ts +27 -12
  123. package/packages/server/src/session-discovery.ts +11 -4
  124. package/packages/server/src/session-file-reader.ts +1 -1
  125. package/packages/server/src/session-scanner.ts +4 -2
  126. package/packages/server/src/spawn-failure-log.ts +130 -0
  127. package/packages/server/src/spawn-preflight.ts +82 -0
  128. package/packages/server/src/spawn-register-watchdog.ts +291 -0
  129. package/packages/server/src/spawn-token.ts +20 -0
  130. package/packages/server/src/terminal-manager.ts +12 -1
  131. package/packages/shared/package.json +1 -1
  132. package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +25 -17
  133. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +5 -4
  134. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +6 -5
  135. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +1 -0
  136. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +5 -4
  137. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +2 -1
  138. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +6 -3
  139. package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +1 -1
  140. package/packages/shared/src/__tests__/bootstrap-install-resolve-npm.test.ts +72 -0
  141. package/packages/shared/src/__tests__/browser-protocol-types.test.ts +47 -1
  142. package/packages/shared/src/__tests__/changelog-types.test.ts +78 -0
  143. package/packages/shared/src/__tests__/config.test.ts +48 -0
  144. package/packages/shared/src/__tests__/dashboard-starter.test.ts +40 -0
  145. package/packages/shared/src/__tests__/detached-spawn.test.ts +24 -0
  146. package/packages/shared/src/__tests__/doctor-core.test.ts +134 -0
  147. package/packages/shared/src/__tests__/doctor-fault-tolerance.test.ts +218 -0
  148. package/packages/shared/src/__tests__/doctor-format.test.ts +121 -0
  149. package/packages/shared/src/__tests__/install-managed-node-bootstrap-order.test.ts +68 -0
  150. package/packages/shared/src/__tests__/install-managed-node.test.ts +192 -0
  151. package/packages/shared/src/__tests__/installable-list.test.ts +130 -0
  152. package/packages/shared/src/__tests__/managed-node-path.test.ts +122 -0
  153. package/packages/shared/src/__tests__/managed-runtime-strategy.test.ts +74 -0
  154. package/packages/shared/src/__tests__/no-installable-list-in-bridge.test.ts +52 -0
  155. package/packages/shared/src/__tests__/no-raw-openspec-status-in-skills.test.ts +6 -1
  156. package/packages/shared/src/__tests__/node-spawn-jiti-contract.test.ts +56 -20
  157. package/packages/shared/src/__tests__/resolve-jiti.test.ts +140 -9
  158. package/packages/shared/src/__tests__/skill-block-parser.test.ts +153 -0
  159. package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +1 -1
  160. package/packages/shared/src/bootstrap-install.ts +197 -3
  161. package/packages/shared/src/browser-protocol.ts +155 -1
  162. package/packages/shared/src/changelog-types.ts +111 -0
  163. package/packages/shared/src/config.ts +15 -0
  164. package/packages/shared/src/dashboard-starter.ts +33 -0
  165. package/packages/shared/src/doctor-core.ts +821 -0
  166. package/packages/shared/src/index.ts +9 -0
  167. package/packages/shared/src/installable-list.ts +152 -0
  168. package/packages/shared/src/launch-source-flag.ts +14 -0
  169. package/packages/shared/src/launch-source-types.ts +18 -0
  170. package/packages/shared/src/openspec-activity-detector.ts +25 -7
  171. package/packages/shared/src/platform/detached-spawn.ts +13 -2
  172. package/packages/shared/src/platform/managed-node-path.ts +77 -0
  173. package/packages/shared/src/platform/node-spawn.ts +29 -21
  174. package/packages/shared/src/protocol.ts +54 -2
  175. package/packages/shared/src/resolve-jiti.ts +62 -9
  176. package/packages/shared/src/rest-api.ts +4 -0
  177. package/packages/shared/src/skill-block-parser.ts +115 -0
  178. package/packages/shared/src/tool-registry/__tests__/managed-runtime-strategy.test.ts +166 -0
  179. package/packages/shared/src/tool-registry/definitions.ts +33 -8
  180. package/packages/shared/src/tool-registry/strategies.ts +42 -0
  181. package/packages/shared/src/types.ts +64 -0
@@ -1,5 +1,11 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { buildJitiRegisterUrl, resolveJitiImport } from "../resolve-jiti.js";
2
+ import {
3
+ buildJitiRegisterUrl,
4
+ resolveJitiImport,
5
+ pickJitiRegisterUrl,
6
+ pickJitiFromAnchor,
7
+ JITI_PACKAGES,
8
+ } from "../resolve-jiti.js";
3
9
 
4
10
  describe("buildJitiRegisterUrl", () => {
5
11
  // Pure function: given a jiti package.json path, return the file:// URL of
@@ -38,16 +44,141 @@ describe("buildJitiRegisterUrl", () => {
38
44
  });
39
45
 
40
46
  describe("resolveJitiImport", () => {
41
- // Integration-lite: in vitest context (not inside pi's jiti loader),
42
- // process.argv[1] points at the test runner, not pi — so peer-dep
43
- // resolution fails and the function throws a helpful error. The
44
- // URL-contract behavior is covered by buildJitiRegisterUrl above.
47
+ // Integration-lite: behaviour depends on what's resolvable from
48
+ // process.argv[1] (the vitest runner). Two valid outcomes:
49
+ // (a) vitest's own transitive `jiti` dep resolves → returns a URL.
50
+ // (b) nothing resolves throws the documented error.
51
+ // The URL-contract behaviour is covered by buildJitiRegisterUrl above
52
+ // and the lookup-order behaviour by pickJitiRegisterUrl below. This
53
+ // describe block exercises only the runtime-anchor branch.
45
54
 
46
- it("throws with clear error when pi-coding-agent is not resolvable", () => {
47
- expect(() => resolveJitiImport()).toThrow("Cannot find pi's TypeScript loader");
55
+ it("either returns a file:// URL or throws the documented error", () => {
56
+ let result: string | undefined;
57
+ let err: Error | undefined;
58
+ try {
59
+ result = resolveJitiImport();
60
+ } catch (e) {
61
+ err = e as Error;
62
+ }
63
+ if (result !== undefined) {
64
+ expect(result.startsWith("file://")).toBe(true);
65
+ expect(result.endsWith("/lib/jiti-register.mjs")).toBe(true);
66
+ } else {
67
+ expect(err).toBeDefined();
68
+ expect(err!.message).toContain("Cannot find pi's TypeScript loader");
69
+ expect(err!.message).toContain("pi-coding-agent");
70
+ }
71
+ });
72
+ });
73
+
74
+ describe("JITI_PACKAGES contract", () => {
75
+ // The lookup-order contract: upstream first, legacy fork as fallback.
76
+ // Pinned so a future contributor doesn't accidentally re-order and
77
+ // silently change resolution priority for users mid-migration.
78
+ // See change: pi-fork migration to @earendil-works (drops @oh-my-pi).
79
+ it("contains the supported provider names in lookup order", () => {
80
+ expect(JITI_PACKAGES).toEqual(["jiti", "@mariozechner/jiti"]);
81
+ });
82
+ });
83
+
84
+ describe("pickJitiRegisterUrl (test seam)", () => {
85
+ // Mock a Node-style resolver. Returns a path when the spec matches
86
+ // a configured "installed" package; throws like Node's resolve() does
87
+ // for unfound modules.
88
+ function makeResolver(installed: Record<string, string>) {
89
+ return (spec: string): string => {
90
+ if (spec in installed) return installed[spec];
91
+ throw new Error(`Cannot find module '${spec}'`);
92
+ };
93
+ }
94
+
95
+ it("returns @mariozechner/jiti's URL when only the legacy fork is installed", () => {
96
+ const resolver = makeResolver({
97
+ "@mariozechner/jiti/package.json": "/r/node_modules/@mariozechner/jiti/package.json",
98
+ });
99
+ const url = pickJitiRegisterUrl(resolver);
100
+ expect(url).not.toBeNull();
101
+ expect(url!).toContain("@mariozechner/jiti");
102
+ expect(url!.endsWith("/lib/jiti-register.mjs")).toBe(true);
103
+ });
104
+
105
+ it("returns upstream jiti's URL when only upstream is installed (pi 0.73.1+)", () => {
106
+ const resolver = makeResolver({
107
+ "jiti/package.json": "/r/node_modules/jiti/package.json",
108
+ });
109
+ const url = pickJitiRegisterUrl(resolver);
110
+ expect(url).not.toBeNull();
111
+ // Match `/jiti/lib/...` but NOT `/@mariozechner/jiti/lib/...`
112
+ expect(url!).toMatch(/\/jiti\/lib\/jiti-register\.mjs$/);
113
+ expect(url!).not.toContain("@mariozechner");
114
+ expect(url!).not.toContain("@oh-my-pi");
115
+ });
116
+
117
+ it("prefers upstream jiti when BOTH upstream and legacy fork are present", () => {
118
+ const calls: string[] = [];
119
+ const resolver = (spec: string): string => {
120
+ calls.push(spec);
121
+ if (
122
+ spec === "@mariozechner/jiti/package.json" ||
123
+ spec === "jiti/package.json"
124
+ ) {
125
+ return `/r/node_modules/${spec}`;
126
+ }
127
+ throw new Error("nope");
128
+ };
129
+ const url = pickJitiRegisterUrl(resolver);
130
+ // Match `/jiti/lib/...` but NOT `/@mariozechner/jiti/lib/...`
131
+ expect(url!).toMatch(/\/jiti\/lib\/jiti-register\.mjs$/);
132
+ expect(url!).not.toContain("@mariozechner");
133
+ // Crucially: the resolver was NOT asked for the legacy fork because
134
+ // upstream won first.
135
+ expect(calls).toEqual(["jiti/package.json"]);
136
+ });
137
+
138
+ it("returns null when no provider resolves", () => {
139
+ const resolver = makeResolver({});
140
+ expect(pickJitiRegisterUrl(resolver)).toBeNull();
141
+ });
142
+ });
143
+
144
+ describe("pickJitiFromAnchor (test seam)", () => {
145
+ function makeResolver(installed: Record<string, string>) {
146
+ return (spec: string): string => {
147
+ if (spec in installed) return installed[spec];
148
+ throw new Error(`Cannot find module '${spec}'`);
149
+ };
150
+ }
151
+
152
+ it("returns upstream jiti's URL when only upstream is on the anchor's chain", () => {
153
+ const resolver = makeResolver({
154
+ "jiti/package.json": "/anchor/node_modules/jiti/package.json",
155
+ });
156
+ const pathExists = (p: string): boolean =>
157
+ p === "/anchor/node_modules/jiti/lib/jiti-register.mjs";
158
+ const url = pickJitiFromAnchor(resolver, pathExists);
159
+ expect(url).not.toBeNull();
160
+ expect(url!).toMatch(/\/jiti\/lib\/jiti-register\.mjs$/);
161
+ });
162
+
163
+ it("skips a provider whose register file does not exist on disk", () => {
164
+ // Resolver finds package.json but jiti-register.mjs is missing
165
+ // (corrupt install). Expect skip-to-next, ultimately null.
166
+ const resolver = makeResolver({
167
+ "@mariozechner/jiti/package.json":
168
+ "/anchor/node_modules/@mariozechner/jiti/package.json",
169
+ });
170
+ const pathExists = (): boolean => false;
171
+ expect(pickJitiFromAnchor(resolver, pathExists)).toBeNull();
48
172
  });
49
173
 
50
- it("error message mentions pi-coding-agent", () => {
51
- expect(() => resolveJitiImport()).toThrow("pi-coding-agent");
174
+ it("returns null when nothing resolves", () => {
175
+ expect(
176
+ pickJitiFromAnchor(
177
+ () => {
178
+ throw new Error("nope");
179
+ },
180
+ () => true,
181
+ ),
182
+ ).toBeNull();
52
183
  });
53
184
  });
@@ -0,0 +1,153 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { parseSkillBlock, buildSkillBlock } from "../skill-block-parser.js";
3
+
4
+ describe("parseSkillBlock", () => {
5
+ it("matches a well-formed wrapper with args", () => {
6
+ const text =
7
+ `<skill name="foo" location="/x/SKILL.md">\nReferences are relative to /x.\n\nbody\n</skill>\n\nargs here`;
8
+ const block = parseSkillBlock(text);
9
+ expect(block).not.toBeNull();
10
+ expect(block!.name).toBe("foo");
11
+ expect(block!.location).toBe("/x/SKILL.md");
12
+ expect(block!.body).toBe("body");
13
+ expect(block!.args).toBe("args here");
14
+ expect(block!.condensed).toBe("/skill:foo args here");
15
+ });
16
+
17
+ it("strips the 'References are relative to <baseDir>.\\n\\n' preamble from body", () => {
18
+ const text =
19
+ `<skill name="foo" location="/x/SKILL.md">\nReferences are relative to /x.\n\nfirst body line\nsecond body line\n</skill>`;
20
+ const block = parseSkillBlock(text);
21
+ expect(block!.body).toBe("first body line\nsecond body line");
22
+ expect(block!.body.startsWith("References are relative")).toBe(false);
23
+ });
24
+
25
+ it("falls back to verbatim content when preamble is absent", () => {
26
+ // Hand-crafted wrapper without the standard preamble (defensive: pi may evolve).
27
+ const text = `<skill name="foo" location="/x">\nbody only\n</skill>`;
28
+ expect(parseSkillBlock(text)!.body).toBe("body only");
29
+ });
30
+
31
+ it("matches a wrapper without args (no preamble)", () => {
32
+ const text = `<skill name="foo" location="/x">\nbody\n</skill>`;
33
+ const block = parseSkillBlock(text);
34
+ expect(block).not.toBeNull();
35
+ expect(block!.args).toBeUndefined();
36
+ expect(block!.condensed).toBe("/skill:foo");
37
+ expect(block!.body).toBe("body");
38
+ });
39
+
40
+ it("preserves multi-line user args verbatim", () => {
41
+ const text =
42
+ `<skill name="foo" location="/x">\nbody\n</skill>\n\nline1\nline2\nline3`;
43
+ const block = parseSkillBlock(text);
44
+ expect(block!.args).toBe("line1\nline2\nline3");
45
+ expect(block!.condensed).toBe("/skill:foo line1\nline2\nline3");
46
+ });
47
+
48
+ it("returns null for plain text", () => {
49
+ expect(parseSkillBlock("Hello, this is just text.")).toBeNull();
50
+ expect(parseSkillBlock("")).toBeNull();
51
+ expect(parseSkillBlock("/skill:foo args (unexpanded)")).toBeNull();
52
+ });
53
+
54
+ it("returns null for mid-document <skill> (anchor enforcement)", () => {
55
+ const text = `prefix\n<skill name="foo" location="/x">\nbody\n</skill>`;
56
+ expect(parseSkillBlock(text)).toBeNull();
57
+ });
58
+
59
+ it("returns null for trailing whitespace after </skill>", () => {
60
+ // Anchor end-of-string: a stray newline after </skill> with no args fails the optional-args group.
61
+ const text = `<skill name="foo" location="/x">\nbody\n</skill>\n`;
62
+ expect(parseSkillBlock(text)).toBeNull();
63
+ });
64
+
65
+ it("does not terminate prematurely on body containing literal <skill> text", () => {
66
+ const text =
67
+ `<skill name="real" location="/x">\nReferences are relative to /x.\n\nDocumented like: <skill name="example">…</skill>\nThat ended.\n</skill>`;
68
+ const block = parseSkillBlock(text);
69
+ expect(block).not.toBeNull();
70
+ expect(block!.name).toBe("real");
71
+ expect(block!.body).toContain('Documented like: <skill name="example">…</skill>');
72
+ expect(block!.body).toContain("That ended.");
73
+ // preamble was stripped — body starts with the user-visible content
74
+ expect(block!.body.startsWith("Documented")).toBe(true);
75
+ });
76
+
77
+ it("handles an empty body (no preamble)", () => {
78
+ const text = `<skill name="empty" location="/x">\n\n</skill>`;
79
+ const block = parseSkillBlock(text);
80
+ expect(block).not.toBeNull();
81
+ expect(block!.body).toBe("");
82
+ });
83
+
84
+ it("condensed form has a single space between name and args", () => {
85
+ const text = `<skill name="x" location="/p">\nb\n</skill>\n\nfoo`;
86
+ expect(parseSkillBlock(text)!.condensed).toBe("/skill:x foo");
87
+ });
88
+
89
+ it("condensed form has no trailing space when args is absent", () => {
90
+ const text = `<skill name="x" location="/p">\nb\n</skill>`;
91
+ expect(parseSkillBlock(text)!.condensed).toBe("/skill:x");
92
+ });
93
+ });
94
+
95
+ describe("buildSkillBlock", () => {
96
+ it("emits pi's exact wrapper format with args", () => {
97
+ const out = buildSkillBlock({
98
+ name: "openspec-explore",
99
+ filePath: "/x/openspec-explore/SKILL.md",
100
+ baseDir: "/x/openspec-explore",
101
+ body: "Enter explore mode.",
102
+ userArgs: "continue with X",
103
+ });
104
+ expect(out).toBe(
105
+ `<skill name="openspec-explore" location="/x/openspec-explore/SKILL.md">\n` +
106
+ `References are relative to /x/openspec-explore.\n\n` +
107
+ `Enter explore mode.\n` +
108
+ `</skill>\n\n` +
109
+ `continue with X`,
110
+ );
111
+ });
112
+
113
+ it("emits wrapper without trailing args block when userArgs is absent", () => {
114
+ const out = buildSkillBlock({
115
+ name: "foo",
116
+ filePath: "/x/foo/SKILL.md",
117
+ baseDir: "/x/foo",
118
+ body: "body line",
119
+ });
120
+ expect(out.endsWith("</skill>")).toBe(true);
121
+ expect(out).not.toContain("</skill>\n\n");
122
+ });
123
+ });
124
+
125
+ describe("buildSkillBlock + parseSkillBlock round-trip", () => {
126
+ it("round-trips name / body / args (with args)", () => {
127
+ const built = buildSkillBlock({
128
+ name: "round",
129
+ filePath: "/p/SKILL.md",
130
+ baseDir: "/p",
131
+ body: "Some body\nwith multiple lines",
132
+ userArgs: "the args here",
133
+ });
134
+ const parsed = parseSkillBlock(built);
135
+ expect(parsed).not.toBeNull();
136
+ expect(parsed!.name).toBe("round");
137
+ expect(parsed!.location).toBe("/p/SKILL.md");
138
+ expect(parsed!.body).toBe("Some body\nwith multiple lines");
139
+ expect(parsed!.args).toBe("the args here");
140
+ });
141
+
142
+ it("round-trips with no args", () => {
143
+ const built = buildSkillBlock({
144
+ name: "noargs",
145
+ filePath: "/p/SKILL.md",
146
+ baseDir: "/p",
147
+ body: "Body only",
148
+ });
149
+ const parsed = parseSkillBlock(built);
150
+ expect(parsed!.args).toBeUndefined();
151
+ expect(parsed!.condensed).toBe("/skill:noargs");
152
+ });
153
+ });
@@ -104,7 +104,7 @@ describe("pi binary definition", () => {
104
104
  });
105
105
 
106
106
  describe("pi-coding-agent module definition", () => {
107
- it("probes both @mariozechner and @oh-my-pi alias names", () => {
107
+ it("probes both @earendil-works (preferred) and @mariozechner (legacy fallback) alias names", () => {
108
108
  const r = freshRegistry({ exists: () => false });
109
109
  const res = r.resolve("pi-coding-agent");
110
110
  const names = res.tried.map((t) => t.strategy);
@@ -12,8 +12,15 @@
12
12
  *
13
13
  * See change: unified-bootstrap-install.
14
14
  */
15
- import { spawn as cpSpawn } from "./platform/exec.js";
16
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
15
+ import { spawn as cpSpawn, spawnSync as cpSpawnSync } from "./platform/exec.js";
16
+ import {
17
+ cpSync,
18
+ existsSync,
19
+ mkdirSync,
20
+ readFileSync,
21
+ rmSync,
22
+ writeFileSync,
23
+ } from "node:fs";
17
24
  import path from "node:path";
18
25
  import { getManagedDir } from "./managed-paths.js";
19
26
  import { getDefaultRegistry, type ToolRegistry } from "./tool-registry/index.js";
@@ -206,7 +213,194 @@ export async function bootstrapInstallDefaults(
206
213
  progress?: ProgressCallback,
207
214
  ): Promise<BootstrapInstallResult> {
208
215
  return bootstrapInstall({
209
- packages: ["@mariozechner/pi-coding-agent", "@fission-ai/openspec", "tsx"],
216
+ packages: ["@earendil-works/pi-coding-agent", "@fission-ai/openspec", "tsx"],
210
217
  progress,
211
218
  });
212
219
  }
220
+
221
+ // ── Managed Node runtime install ───────────────────────────────────────
222
+ //
223
+ // See change: embed-managed-node-runtime (spec: managed-node-runtime).
224
+ //
225
+ // `installManagedNode` copies a bundled Node distribution into
226
+ // `<managedDir>/node/` and writes a `<managedDir>/node/.version` marker.
227
+ // Idempotent: skip when marker matches the bundled version, replace on
228
+ // mismatch, no-op when the bundled source isn't available (standalone
229
+ // CLI install with no Electron resources).
230
+
231
+ export interface InstallManagedNodeOptions {
232
+ /**
233
+ * Source directory containing the bundled Node distribution.
234
+ * Layout matches the upstream Node zip/tar:
235
+ * Windows: `<dir>/node.exe`, `<dir>/npm.cmd`, `<dir>/npx.cmd`
236
+ * Unix: `<dir>/bin/node`, `<dir>/bin/npm`, `<dir>/bin/npx`
237
+ *
238
+ * Caller resolves this (Electron uses `path.dirname(getBundledNodePath())`
239
+ * after stripping the platform-specific suffix). Pass `null` /
240
+ * `undefined` for the standalone CLI install case — the function
241
+ * no-ops without error.
242
+ */
243
+ bundledNodeDir?: string | null;
244
+ /** Root of the managed install. Defaults to `getManagedDir()`. */
245
+ managedDir?: string;
246
+ /** Called on every progress tick. Mirrors `bootstrapInstall` shape. */
247
+ progress?: ProgressCallback;
248
+ /**
249
+ * Test seam: override how the bundled Node version is read. Default
250
+ * spawns `<bundledNodeDir>/bin/node --version` (or `node.exe` on
251
+ * Windows) and trims stdout. Tests inject a fake to avoid real spawns.
252
+ */
253
+ _readVersion?: (sourceBinary: string) => string | null;
254
+ }
255
+
256
+ export interface InstallManagedNodeResult {
257
+ /** True iff the operation succeeded (including the no-op cases). */
258
+ ok: boolean;
259
+ /** Did we actually copy files? false when no-op or skipped. */
260
+ copied: boolean;
261
+ /** Resolved managed Node directory (`<managedDir>/node`). */
262
+ managedNodeDir: string;
263
+ /** Bundled Node version (e.g. `v22.12.0`) when known. */
264
+ version?: string;
265
+ /** Reason for skip / failure. Always set when `copied === false`. */
266
+ reason?: string;
267
+ /** Error message when `ok === false`. */
268
+ error?: string;
269
+ }
270
+
271
+ /** Path to the source `node` / `node.exe` binary inside `bundledNodeDir`. */
272
+ function sourceNodeBinary(bundledNodeDir: string): string {
273
+ return process.platform === "win32" // platform-branch-ok: Node distribution layout differs Windows vs Unix
274
+ ? path.join(bundledNodeDir, "node.exe")
275
+ : path.join(bundledNodeDir, "bin", "node");
276
+ }
277
+
278
+ /**
279
+ * Spawn `<nodeBinary> --version` and return the trimmed stdout (e.g.
280
+ * `v22.12.0`). Returns null on failure or when the binary is missing.
281
+ * Synchronous because bootstrap is naturally serial.
282
+ */
283
+ function readNodeVersion(nodeBinary: string): string | null {
284
+ if (!existsSync(nodeBinary)) return null;
285
+ try {
286
+ const r = cpSpawnSync(nodeBinary, ["--version"], {
287
+ stdio: ["ignore", "pipe", "pipe"],
288
+ timeout: 5_000,
289
+ encoding: "utf-8",
290
+ });
291
+ if (r.status !== 0) return null;
292
+ const out = (r.stdout ?? "").toString().trim();
293
+ return out || null;
294
+ } catch {
295
+ return null;
296
+ }
297
+ }
298
+
299
+ /** Read the `<managedNodeDir>/.version` marker if present, else null. */
300
+ function readManagedMarker(managedNodeDir: string): string | null {
301
+ const markerPath = path.join(managedNodeDir, ".version");
302
+ if (!existsSync(markerPath)) return null;
303
+ try {
304
+ return readFileSync(markerPath, "utf-8").trim() || null;
305
+ } catch {
306
+ return null;
307
+ }
308
+ }
309
+
310
+ /**
311
+ * Idempotently copy the bundled Node distribution into `<managedDir>/node/`.
312
+ *
313
+ * - First-run: full recursive copy + write `.version` marker.
314
+ * - Re-run with matching marker: no-op.
315
+ * - Mismatched marker (or missing marker with dir present): replace +
316
+ * rewrite marker.
317
+ * - Bundled source absent or `bundledNodeDir == null`: no-op.
318
+ * - Failed copy mid-flight: marker NOT written, so next call retries.
319
+ */
320
+ export async function installManagedNode(
321
+ opts: InstallManagedNodeOptions = {},
322
+ ): Promise<InstallManagedNodeResult> {
323
+ const managedDir = opts.managedDir ?? getManagedDir();
324
+ const managedNodeDir = path.join(managedDir, "node");
325
+ const step = "node-runtime";
326
+
327
+ const bundledDir = opts.bundledNodeDir ?? null;
328
+ if (!bundledDir) {
329
+ return {
330
+ ok: true,
331
+ copied: false,
332
+ managedNodeDir,
333
+ reason: "no bundled source",
334
+ };
335
+ }
336
+
337
+ const sourceBinary = sourceNodeBinary(bundledDir);
338
+ const sourceVersion = (opts._readVersion ?? readNodeVersion)(sourceBinary);
339
+ if (!sourceVersion) {
340
+ return {
341
+ ok: true,
342
+ copied: false,
343
+ managedNodeDir,
344
+ reason: `bundled node binary missing or unreadable: ${sourceBinary}`,
345
+ };
346
+ }
347
+
348
+ const existingMarker = readManagedMarker(managedNodeDir);
349
+ if (existingMarker === sourceVersion) {
350
+ return {
351
+ ok: true,
352
+ copied: false,
353
+ managedNodeDir,
354
+ version: sourceVersion,
355
+ reason: "version matches bundled — no copy needed",
356
+ };
357
+ }
358
+
359
+ opts.progress?.({
360
+ step,
361
+ status: "running",
362
+ output: `Installing Node ${sourceVersion} runtime`,
363
+ });
364
+
365
+ try {
366
+ // Replace any existing dir (handles the mismatch + missing-marker
367
+ // cases) so the copy is from a clean slate.
368
+ if (existsSync(managedNodeDir)) {
369
+ rmSync(managedNodeDir, { recursive: true, force: true });
370
+ }
371
+ mkdirSync(path.dirname(managedNodeDir), { recursive: true });
372
+
373
+ cpSync(bundledDir, managedNodeDir, {
374
+ recursive: true,
375
+ force: true,
376
+ // dereference: false keeps symlinks-as-symlinks (Unix npm shim).
377
+ // verbatimSymlinks would also work in newer Node; default is fine.
378
+ });
379
+
380
+ // Marker last — partial copy on failure leaves no marker, so the
381
+ // next invocation treats the dir as missing and retries.
382
+ writeFileSync(
383
+ path.join(managedNodeDir, ".version"),
384
+ sourceVersion + "\n",
385
+ "utf-8",
386
+ );
387
+
388
+ opts.progress?.({ step, status: "done", output: `Node ${sourceVersion}` });
389
+ return {
390
+ ok: true,
391
+ copied: true,
392
+ managedNodeDir,
393
+ version: sourceVersion,
394
+ };
395
+ } catch (err) {
396
+ const message = err instanceof Error ? err.message : String(err);
397
+ opts.progress?.({ step, status: "error", error: message });
398
+ return {
399
+ ok: false,
400
+ copied: false,
401
+ managedNodeDir,
402
+ version: sourceVersion,
403
+ error: message,
404
+ };
405
+ }
406
+ }