@blackbelt-technology/pi-agent-dashboard 0.5.1 → 0.5.2

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 (129) hide show
  1. package/AGENTS.md +26 -5
  2. package/README.md +30 -0
  3. package/docs/architecture.md +129 -1
  4. package/package.json +6 -6
  5. package/packages/extension/package.json +2 -2
  6. package/packages/extension/src/__tests__/bridge-slash-command-routing.test.ts +362 -0
  7. package/packages/extension/src/__tests__/command-handler.test.ts +10 -8
  8. package/packages/extension/src/__tests__/extension-slash-command-detection.test.ts +107 -0
  9. package/packages/extension/src/__tests__/prompt-expander.test.ts +110 -1
  10. package/packages/extension/src/__tests__/server-launcher-launch.test.ts +78 -0
  11. package/packages/extension/src/bridge-context.ts +67 -3
  12. package/packages/extension/src/bridge.ts +20 -8
  13. package/packages/extension/src/command-handler.ts +36 -13
  14. package/packages/extension/src/prompt-expander.ts +74 -63
  15. package/packages/extension/src/server-launcher.ts +31 -70
  16. package/packages/extension/src/slash-dispatch.ts +123 -0
  17. package/packages/server/bin/pi-dashboard.mjs +84 -0
  18. package/packages/server/package.json +6 -5
  19. package/packages/server/scripts/fix-pty-permissions.cjs +52 -0
  20. package/packages/server/src/__tests__/cli-parse.test.ts +12 -18
  21. package/packages/server/src/__tests__/directory-service-openspec-enabled.test.ts +187 -0
  22. package/packages/server/src/__tests__/directory-service.test.ts +1 -1
  23. package/packages/server/src/__tests__/dispatch-extension-command-router.test.ts +178 -0
  24. package/packages/server/src/__tests__/e2e/model-proxy-google-flash.test.ts +184 -0
  25. package/packages/server/src/__tests__/headless-pid-registry.test.ts +233 -0
  26. package/packages/server/src/__tests__/keeper-manager.test.ts +298 -0
  27. package/packages/server/src/__tests__/legacy-pi-cleanup.test.ts +149 -0
  28. package/packages/server/src/__tests__/model-proxy-api-key-routes.test.ts +277 -0
  29. package/packages/server/src/__tests__/model-proxy-auth-gate.test.ts +263 -0
  30. package/packages/server/src/__tests__/model-proxy-multi-user.test.ts +169 -0
  31. package/packages/server/src/__tests__/model-proxy-routes.test.ts +286 -0
  32. package/packages/server/src/__tests__/model-proxy-second-port.test.ts +116 -0
  33. package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +64 -8
  34. package/packages/server/src/__tests__/openspec-group-broadcast.test.ts +97 -0
  35. package/packages/server/src/__tests__/openspec-group-join.test.ts +80 -0
  36. package/packages/server/src/__tests__/openspec-group-routes.test.ts +370 -0
  37. package/packages/server/src/__tests__/openspec-group-store.test.ts +496 -0
  38. package/packages/server/src/__tests__/pi-ai-shape.test.ts +147 -0
  39. package/packages/server/src/__tests__/pi-dashboard-bin-wrapper.test.ts +84 -0
  40. package/packages/server/src/__tests__/process-manager-keeper-spawn.test.ts +206 -0
  41. package/packages/server/src/__tests__/provider-routes-recursion-guard.test.ts +131 -0
  42. package/packages/server/src/__tests__/recommended-routes.test.ts +2 -2
  43. package/packages/server/src/__tests__/tunnel-watchdog.test.ts +139 -0
  44. package/packages/server/src/auth-plugin.ts +3 -0
  45. package/packages/server/src/bootstrap-state.ts +10 -0
  46. package/packages/server/src/browser-gateway.ts +15 -7
  47. package/packages/server/src/browser-handlers/session-action-handler.ts +30 -4
  48. package/packages/server/src/cli.ts +61 -81
  49. package/packages/server/src/config-api.ts +14 -2
  50. package/packages/server/src/directory-service.ts +106 -4
  51. package/packages/server/src/event-wiring.ts +31 -1
  52. package/packages/server/src/headless-pid-registry.ts +299 -41
  53. package/packages/server/src/legacy-pi-cleanup.ts +151 -0
  54. package/packages/server/src/model-proxy/__tests__/api-key-store.test.ts +142 -0
  55. package/packages/server/src/model-proxy/__tests__/auth-json-contention.test.ts +98 -0
  56. package/packages/server/src/model-proxy/__tests__/concurrency.test.ts +107 -0
  57. package/packages/server/src/model-proxy/__tests__/failed-auth-backoff.test.ts +46 -0
  58. package/packages/server/src/model-proxy/__tests__/recursion-guard.test.ts +61 -0
  59. package/packages/server/src/model-proxy/__tests__/streamer.test.ts +139 -0
  60. package/packages/server/src/model-proxy/api-key-store.ts +87 -0
  61. package/packages/server/src/model-proxy/auth-gate.ts +116 -0
  62. package/packages/server/src/model-proxy/concurrency.ts +76 -0
  63. package/packages/server/src/model-proxy/convert/UPSTREAM.md +13 -0
  64. package/packages/server/src/model-proxy/convert/__tests__/anthropic-in.test.ts +137 -0
  65. package/packages/server/src/model-proxy/convert/__tests__/anthropic-out.test.ts +183 -0
  66. package/packages/server/src/model-proxy/convert/__tests__/openai-in.test.ts +134 -0
  67. package/packages/server/src/model-proxy/convert/__tests__/openai-out.test.ts +166 -0
  68. package/packages/server/src/model-proxy/convert/anthropic-in.ts +129 -0
  69. package/packages/server/src/model-proxy/convert/anthropic-out.ts +173 -0
  70. package/packages/server/src/model-proxy/convert/index.ts +8 -0
  71. package/packages/server/src/model-proxy/convert/openai-in.ts +119 -0
  72. package/packages/server/src/model-proxy/convert/openai-out.ts +151 -0
  73. package/packages/server/src/model-proxy/convert/types.ts +70 -0
  74. package/packages/server/src/model-proxy/failed-auth-backoff.ts +45 -0
  75. package/packages/server/src/model-proxy/internal-auth-storage.ts +146 -0
  76. package/packages/server/src/model-proxy/internal-registry.ts +157 -0
  77. package/packages/server/src/model-proxy/recursion-guard.ts +72 -0
  78. package/packages/server/src/model-proxy/registry-singleton.ts +109 -0
  79. package/packages/server/src/model-proxy/request-log.ts +53 -0
  80. package/packages/server/src/model-proxy/streamer.ts +59 -0
  81. package/packages/server/src/openspec-group-store.ts +490 -0
  82. package/packages/server/src/process-manager.ts +128 -0
  83. package/packages/server/src/provider-auth-storage.ts +29 -47
  84. package/packages/server/src/restart-helper.ts +17 -16
  85. package/packages/server/src/routes/bootstrap-routes.ts +37 -0
  86. package/packages/server/src/routes/jj-routes.ts +3 -0
  87. package/packages/server/src/routes/model-proxy-api-key-routes.ts +168 -0
  88. package/packages/server/src/routes/model-proxy-refresh-routes.ts +24 -0
  89. package/packages/server/src/routes/model-proxy-routes.ts +330 -0
  90. package/packages/server/src/routes/openspec-group-routes.ts +231 -0
  91. package/packages/server/src/routes/provider-auth-routes.ts +3 -0
  92. package/packages/server/src/routes/provider-routes.ts +24 -1
  93. package/packages/server/src/routes/system-routes.ts +44 -2
  94. package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi-shim.sh +9 -0
  95. package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi.cjs +50 -0
  96. package/packages/server/src/rpc-keeper/__tests__/keeper.test.ts +371 -0
  97. package/packages/server/src/rpc-keeper/dispatch-router.ts +85 -0
  98. package/packages/server/src/rpc-keeper/keeper-manager.ts +364 -0
  99. package/packages/server/src/rpc-keeper/keeper.cjs +313 -0
  100. package/packages/server/src/server.ts +178 -2
  101. package/packages/server/src/session-api.ts +9 -1
  102. package/packages/server/src/tunnel-watchdog.ts +230 -0
  103. package/packages/server/src/tunnel.ts +5 -1
  104. package/packages/shared/package.json +1 -1
  105. package/packages/shared/src/__tests__/binary-lookup-resolveJiti.test.ts +228 -0
  106. package/packages/shared/src/__tests__/config-openspec.test.ts +74 -0
  107. package/packages/shared/src/__tests__/model-proxy-config.test.ts +146 -0
  108. package/packages/shared/src/__tests__/no-raw-node-import.test.ts +7 -5
  109. package/packages/shared/src/__tests__/node-spawn.test.ts +51 -0
  110. package/packages/shared/src/__tests__/openspec-groups-types.test.ts +135 -0
  111. package/packages/shared/src/__tests__/publish-workflow-contract.test.ts +96 -0
  112. package/packages/shared/src/__tests__/recommended-extensions.test.ts +11 -3
  113. package/packages/shared/src/__tests__/server-launcher.test.ts +227 -0
  114. package/packages/shared/src/bootstrap-install.ts +1 -1
  115. package/packages/shared/src/browser-protocol.ts +27 -0
  116. package/packages/shared/src/config.ts +172 -2
  117. package/packages/shared/src/dashboard-plugin/manifest-types.ts +16 -1
  118. package/packages/shared/src/dashboard-plugin/slot-props.ts +8 -0
  119. package/packages/shared/src/dashboard-plugin/slot-types.ts +57 -0
  120. package/packages/shared/src/platform/binary-lookup.ts +204 -0
  121. package/packages/shared/src/platform/node-spawn.ts +42 -5
  122. package/packages/shared/src/protocol.ts +19 -1
  123. package/packages/shared/src/recommended-extensions.ts +18 -0
  124. package/packages/shared/src/rest-api.ts +219 -1
  125. package/packages/shared/src/server-launcher.ts +277 -0
  126. package/packages/shared/src/tool-registry/__tests__/pi-ai-registration.test.ts +124 -0
  127. package/packages/shared/src/types.ts +55 -0
  128. package/packages/shared/src/__tests__/resolve-jiti.test.ts +0 -184
  129. package/packages/shared/src/resolve-jiti.ts +0 -155
@@ -404,6 +404,40 @@ export interface OpenSpecChange {
404
404
  * "Archive anyway" escape hatch when artifacts are authored but tasks remain unchecked.
405
405
  */
406
406
  isComplete?: boolean;
407
+ /**
408
+ * Group assignment joined server-side from `<cwd>/openspec/groups/groups.json`.
409
+ * `null` or absent means Ungrouped. Clients SHALL NOT recompute the join.
410
+ * See change: add-openspec-change-grouping.
411
+ */
412
+ groupId?: string | null;
413
+ }
414
+
415
+ /** Schema version for the per-repo OpenSpec groups file at
416
+ * `<cwd>/openspec/groups/groups.json`. Bumped only on incompatible shape changes.
417
+ * See change: add-openspec-change-grouping. */
418
+ export const OPENSPEC_GROUPS_SCHEMA_VERSION = 1 as const;
419
+
420
+ /** A user-defined group of OpenSpec changes within a single repo.
421
+ * See change: add-openspec-change-grouping. */
422
+ export interface OpenSpecGroup {
423
+ /** Server-generated slug from `name` plus collision suffix. Stable across rename. */
424
+ id: string;
425
+ /** User-visible label; editable. */
426
+ name: string;
427
+ /** Optional CSS hex color (`#RRGGBB`). Clients fall back to a default palette when omitted. */
428
+ color?: string;
429
+ /** Display order; server keeps values contiguous `0..groups.length - 1` after every reorder. */
430
+ order: number;
431
+ }
432
+
433
+ /** Shape of the on-disk groups file at `<cwd>/openspec/groups/groups.json`.
434
+ * Single combined file for groups + assignments — one read, one write, atomic.
435
+ * See change: add-openspec-change-grouping. */
436
+ export interface OpenSpecGroupsFile {
437
+ schemaVersion: number;
438
+ groups: OpenSpecGroup[];
439
+ /** `changeName` → `groupId`. Unassigned changes have no entry. */
440
+ assignments: Record<string, string>;
407
441
  }
408
442
 
409
443
  /** Lifecycle state of an OpenSpec change, derived from artifacts + task status */
@@ -425,6 +459,12 @@ export function deriveChangeState(change: OpenSpecChange): ChangeState {
425
459
 
426
460
  /** OpenSpec data for a session's project */
427
461
  export interface OpenSpecData {
462
+ /**
463
+ * `openspec list` returned authoritative data for this cwd. Requires both
464
+ * `<cwd>/openspec/` AND `<cwd>/openspec/changes/` to exist AND the CLI to
465
+ * succeed. Does NOT distinguish "openspec project, no changes yet" from
466
+ * "truly not an openspec project" — see `hasOpenspecDir` for that.
467
+ */
428
468
  initialized: boolean;
429
469
  changes: OpenSpecChange[];
430
470
  /**
@@ -440,6 +480,21 @@ export interface OpenSpecData {
440
480
  * See change: fix-cold-boot-openspec-protocol.
441
481
  */
442
482
  pending?: boolean;
483
+ /**
484
+ * Whether `<cwd>/openspec/` directory exists. Strictly weaker than
485
+ * `initialized`: this can be `true` while `initialized` is `false` when
486
+ * the project is OpenSpec-initialized (`openspec init` was run) but
487
+ * `openspec/changes/` doesn't exist yet (no proposals authored). In that
488
+ * case `openspec list` errors out and `initialized` stays `false`, but
489
+ * the session card should still show the OPENSPEC subcard as an
490
+ * init/attach affordance.
491
+ *
492
+ * Optional for backwards compatibility — absence means "unknown, fall
493
+ * back to `initialized || pending`" on the client side.
494
+ *
495
+ * See change: auto-hide-empty-session-subcards.
496
+ */
497
+ hasOpenspecDir?: boolean;
443
498
  }
444
499
 
445
500
  /** OpenSpec workflow phase detected from tool calls */
@@ -1,184 +0,0 @@
1
- import { describe, it, expect } from "vitest";
2
- import {
3
- buildJitiRegisterUrl,
4
- resolveJitiImport,
5
- pickJitiRegisterUrl,
6
- pickJitiFromAnchor,
7
- JITI_PACKAGES,
8
- } from "../resolve-jiti.js";
9
-
10
- describe("buildJitiRegisterUrl", () => {
11
- // Pure function: given a jiti package.json path, return the file:// URL of
12
- // its register hook. The URL contract is the critical invariant — Node's
13
- // --import on Windows rejects raw drive-letter paths (parses "C:" as a
14
- // URL scheme). See change: fix-windows-server-parity.
15
-
16
- it("returns a file:// URL", () => {
17
- const url = buildJitiRegisterUrl("/usr/lib/node_modules/@mariozechner/jiti/package.json");
18
- expect(url.startsWith("file://")).toBe(true);
19
- });
20
-
21
- it("URL is parseable by new URL() without throwing", () => {
22
- const url = buildJitiRegisterUrl("/usr/lib/node_modules/@mariozechner/jiti/package.json");
23
- expect(() => new URL(url)).not.toThrow();
24
- });
25
-
26
- it("points at lib/jiti-register.mjs under the package dir", () => {
27
- const url = buildJitiRegisterUrl("/usr/lib/node_modules/@mariozechner/jiti/package.json");
28
- expect(url.endsWith("/lib/jiti-register.mjs")).toBe(true);
29
- });
30
-
31
- it("handles Windows drive-letter paths (regression for ERR_UNSUPPORTED_ESM_URL_SCHEME)", () => {
32
- // This is the exact shape that crashed pre-fix: a raw path with a
33
- // drive letter was passed to `node --import` and Node parsed "B:" as
34
- // a URL scheme. A file:// URL sidesteps the parser entirely.
35
- const url = buildJitiRegisterUrl("B:\\Dev\\Nodejs\\global\\node_modules\\@mariozechner\\jiti\\package.json");
36
- expect(url.startsWith("file:///")).toBe(true);
37
- expect(() => new URL(url)).not.toThrow();
38
- expect(new URL(url).protocol).toBe("file:");
39
- // The drive letter survives as part of the pathname, not as a protocol
40
- expect(url.toLowerCase()).toContain("/b:/");
41
- expect(url.endsWith("/lib/jiti-register.mjs")).toBe(true);
42
- });
43
-
44
- });
45
-
46
- describe("resolveJitiImport", () => {
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.
54
-
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();
172
- });
173
-
174
- it("returns null when nothing resolves", () => {
175
- expect(
176
- pickJitiFromAnchor(
177
- () => {
178
- throw new Error("nope");
179
- },
180
- () => true,
181
- ),
182
- ).toBeNull();
183
- });
184
- });
@@ -1,155 +0,0 @@
1
- /**
2
- * Resolve the jiti register hook from pi's process context.
3
- *
4
- * The bridge extension runs inside pi's Node.js process. process.argv[1]
5
- * points to pi's CLI entry (e.g., pi-coding-agent/dist/cli.js). Since
6
- * jiti is a dependency of pi-coding-agent, createRequire(process.argv[1])
7
- * can resolve it directly.
8
- *
9
- * Supported jiti providers, in lookup order:
10
- * 1. `@mariozechner/jiti` — legacy fork shipped with pi ≤ 0.73.0.
11
- * 2. `@oh-my-pi/jiti` — fork shipped with `@oh-my-pi/pi-coding-agent`.
12
- * 3. `jiti` — upstream package. Pi 0.73.1+ dropped the
13
- * fork in favour of upstream jiti 2.7,
14
- * which ships the same `lib/jiti-register.mjs`
15
- * layout the helpers below assume.
16
- *
17
- * Forks are tried first to preserve behaviour for users on older pi
18
- * versions; upstream is the fallthrough for pi 0.73.1+. See change:
19
- * support-upstream-jiti-resolution.
20
- */
21
-
22
- import { createRequire } from "node:module";
23
- import { existsSync, realpathSync } from "node:fs";
24
- import path from "node:path";
25
- import { pathToFileURL } from "node:url";
26
-
27
- export const JITI_PACKAGES = [
28
- // @earendil-works/pi-coding-agent depends on plain `jiti`. Try the
29
- // bare name first; fall back to the legacy namespaced fork.
30
- "jiti",
31
- "@mariozechner/jiti",
32
- ];
33
-
34
- /**
35
- * Pure helper: given a jiti package.json path, return the file:// URL of
36
- * its register hook. Exported for testing — no I/O.
37
- *
38
- * Returns a file:// URL (not a raw path) because Node >= 20 on Windows
39
- * rejects raw absolute paths with a drive letter for --import (parses
40
- * "C:" / "B:" as a URL scheme → ERR_UNSUPPORTED_ESM_URL_SCHEME). file://
41
- * URLs are accepted on every OS.
42
- * See change: fix-windows-server-parity.
43
- */
44
- export function buildJitiRegisterUrl(pkgJsonPath: string): string {
45
- // Detect Windows-style input (drive letter + backslash) regardless of
46
- // host OS, so unit tests can exercise the Windows path contract on macOS/Linux.
47
- // Production behaviour is unchanged because the host-OS `path`/`pathToFileURL`
48
- // match the input style automatically.
49
- const isWindowsStyle = /^[A-Za-z]:[\\/]/.test(pkgJsonPath);
50
- if (isWindowsStyle) {
51
- // Manually build file:///C:/path/lib/jiti-register.mjs — pathToFileURL on
52
- // POSIX hosts URL-encodes backslashes rather than treating them as
53
- // separators. Do the join with path.win32 and format the URL ourselves.
54
- const registerPath = path.win32.join(path.win32.dirname(pkgJsonPath), "lib", "jiti-register.mjs");
55
- return `file:///${registerPath.replace(/\\/g, "/")}`;
56
- }
57
- const registerPath = path.join(path.dirname(pkgJsonPath), "lib", "jiti-register.mjs");
58
- return pathToFileURL(registerPath).href;
59
- }
60
-
61
- /**
62
- * Test seam: a function that takes a package specifier (e.g.
63
- * `"jiti/package.json"`) and returns the resolved path. Production
64
- * supplies `createRequire(realpath(process.argv[1])).resolve`; tests
65
- * supply a stub.
66
- */
67
- export type JitiResolver = (specifier: string) => string;
68
-
69
- /**
70
- * Internal: walk the JITI_PACKAGES list using the given resolver and
71
- * return the first hit's register URL. Pure function once the
72
- * resolver is supplied. Returns null when no provider resolves.
73
- */
74
- export function pickJitiRegisterUrl(resolver: JitiResolver): string | null {
75
- for (const jiti of JITI_PACKAGES) {
76
- try {
77
- const pkgJson = resolver(`${jiti}/package.json`);
78
- return buildJitiRegisterUrl(pkgJson);
79
- } catch { /* next */ }
80
- }
81
- return null;
82
- }
83
-
84
- /**
85
- * Returns jiti's register hook as a file:// URL suitable for `node --import`.
86
- * Uses process.argv[1] (pi's entry point) to anchor module resolution.
87
- *
88
- * The return value is ALWAYS a file:// URL (never a raw path). See
89
- * buildJitiRegisterUrl for the URL contract rationale.
90
- */
91
- export function resolveJitiImport(): string {
92
- const anchor = process.argv[1];
93
- if (anchor) {
94
- try {
95
- // Resolve symlinks — process.argv[1] may be a symlink (e.g., bin/pi → dist/cli.js)
96
- const resolved = realpathSync(anchor);
97
- const req = createRequire(resolved);
98
- const url = pickJitiRegisterUrl((spec) => req.resolve(spec));
99
- if (url) return url;
100
- } catch { /* fall through */ }
101
- }
102
-
103
- throw new Error(
104
- "Cannot find pi's TypeScript loader (jiti). " +
105
- "Is @earendil-works/pi-coding-agent or @mariozechner/pi-coding-agent installed?"
106
- );
107
- }
108
-
109
- /**
110
- * Resolve jiti's register hook from an arbitrary anchor path (e.g. a
111
- * pi-coding-agent package.json in a managed install, or a pi binary on
112
- * the system PATH). Returns a file:// URL or null if jiti cannot be
113
- * resolved from the anchor.
114
- *
115
- * This is the Electron/managed-install variant of `resolveJitiImport`
116
- * — the difference is the caller supplies the anchor explicitly
117
- * instead of using `process.argv[1]`. Consolidates what used to be a
118
- * duplicate `resolveJitiFromAnchor` in
119
- * `packages/electron/src/lib/server-lifecycle.ts`.
120
- * See change: consolidate-platform-handlers.
121
- */
122
- export function resolveJitiFromAnchor(anchorPath: string): string | null {
123
- if (!existsSync(anchorPath)) return null;
124
- try {
125
- const req = createRequire(anchorPath);
126
- for (const jiti of JITI_PACKAGES) {
127
- try {
128
- const pkgJson = req.resolve(`${jiti}/package.json`);
129
- const registerPath = path.join(path.dirname(pkgJson), "lib", "jiti-register.mjs");
130
- if (existsSync(registerPath)) return pathToFileURL(registerPath).href;
131
- } catch { /* next */ }
132
- }
133
- } catch { /* ignore */ }
134
- return null;
135
- }
136
-
137
- /**
138
- * Test seam for `resolveJitiFromAnchor`. Pure function: given a resolver
139
- * and a `pathExists` predicate, walk JITI_PACKAGES and return the first
140
- * hit's register URL. Production wires `createRequire(anchor).resolve`
141
- * and `existsSync`; tests inject stubs.
142
- */
143
- export function pickJitiFromAnchor(
144
- resolver: JitiResolver,
145
- pathExists: (p: string) => boolean,
146
- ): string | null {
147
- for (const jiti of JITI_PACKAGES) {
148
- try {
149
- const pkgJson = resolver(`${jiti}/package.json`);
150
- const registerPath = path.join(path.dirname(pkgJson), "lib", "jiti-register.mjs");
151
- if (pathExists(registerPath)) return pathToFileURL(registerPath).href;
152
- } catch { /* next */ }
153
- }
154
- return null;
155
- }