@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
@@ -289,6 +289,23 @@ export interface UiDataListMessage {
289
289
  items: unknown[];
290
290
  }
291
291
 
292
+ // ── RPC keeper: bridge → server slash dispatch ──
293
+ // See change: add-rpc-stdin-dispatch-with-keeper-sidecar.
294
+ //
295
+ // Emitted by `slash-dispatch.ts::tryDispatchExtensionCommand` when the
296
+ // active pi build does NOT expose `pi.dispatchCommand` AND the bridge
297
+ // detects a headless RPC pi (per `isHeadlessRpcSession()`). The server's
298
+ // dispatch-router writes `{type:"prompt", message: command, id: requestId}`
299
+ // to the session's keeper UDS / named pipe and emits the optimistic
300
+ // `command_feedback {status:"completed"}` (or error) to browser subscribers.
301
+ export interface DispatchExtensionCommandMessage {
302
+ type: "dispatch_extension_command";
303
+ sessionId: string;
304
+ command: string;
305
+ /** UUID minted by the bridge so pi's RPC response can be correlated. */
306
+ requestId: string;
307
+ }
308
+
292
309
  // ── Extension UI System (Phase 2: live in-page decorations) ──
293
310
  // See change: add-extension-ui-decorations.
294
311
 
@@ -354,7 +371,8 @@ export type ExtensionToServerMessage =
354
371
  | UiModulesListMessage
355
372
  | UiDataListMessage
356
373
  | ExtUiDecoratorMessage
357
- | AssetRegisterMessage;
374
+ | AssetRegisterMessage
375
+ | DispatchExtensionCommandMessage;
358
376
 
359
377
  // ── Server → Extension ──────────────────────────────────────────────
360
378
 
@@ -165,6 +165,24 @@ export const RECOMMENDED_EXTENSIONS: readonly RecommendedExtension[] = [
165
165
  unlocks: ["browser tool (open, snapshot, click, screenshot)"],
166
166
  toolsRegistered: ["browser"],
167
167
  },
168
+ {
169
+ id: "pi-memory-honcho",
170
+ source: "npm:pi-memory-honcho",
171
+ displayName: "pi-memory-honcho",
172
+ fallbackDescription:
173
+ "Persistent cross-session memory backed by Honcho. Pairs with " +
174
+ "the @blackbelt-technology/pi-dashboard-honcho-plugin dashboard " +
175
+ "plugin which adds a settings panel, per-card actions, and " +
176
+ "optional self-hosted Honcho server lifecycle.",
177
+ status: "optional",
178
+ unlocks: [
179
+ "Honcho memory tools (honcho_search, honcho_context, honcho_profile)",
180
+ "Honcho settings panel (when honcho-plugin is loaded)",
181
+ "Per-card 🧠 status badge + interview/sync/map actions",
182
+ ],
183
+ toolsRegistered: ["honcho_search", "honcho_context", "honcho_profile"],
184
+ autowired: true,
185
+ },
168
186
  ];
169
187
 
170
188
  /**
@@ -5,6 +5,8 @@ import type {
5
5
  DashboardSession,
6
6
  DashboardEvent,
7
7
  ApiResponse,
8
+ OpenSpecGroup,
9
+ OpenSpecGroupsFile,
8
10
  } from "./types.js";
9
11
 
10
12
  export type { ApiResponse } from "./types.js";
@@ -163,8 +165,22 @@ export type MkdirResponse = ApiResponse<MkdirResult>;
163
165
 
164
166
  // ── Tunnel Status ───────────────────────────────────────────────────
165
167
 
168
+ export interface TunnelWatchdogPublicStatus {
169
+ running: boolean;
170
+ intervalMs: number;
171
+ failureThreshold: number;
172
+ probeTimeoutMs: number;
173
+ lastProbeAt: number | null;
174
+ lastSuccessAt: number | null;
175
+ lastFailureAt: number | null;
176
+ lastFailureReason: string | null;
177
+ consecutiveFailures: number;
178
+ lastRecycleAt: number | null;
179
+ recycleCount: number;
180
+ }
181
+
166
182
  export type TunnelStatus =
167
- | { status: "active"; url: string; serverOs: string }
183
+ | { status: "active"; url: string; serverOs: string; watchdog?: TunnelWatchdogPublicStatus }
168
184
  | { status: "inactive"; serverOs: string }
169
185
  | { status: "unavailable"; serverOs: string };
170
186
 
@@ -447,3 +463,205 @@ export interface RescanToolsRequest {
447
463
  export interface SetToolOverrideRequest {
448
464
  path: string;
449
465
  }
466
+
467
+ // ── Model Proxy: wire-protocol types ────────────────────────────────
468
+
469
+ /** OpenAI Chat Completions request shape (subset relevant to the proxy). */
470
+ export interface OpenAIChatCompletionRequest {
471
+ model: string;
472
+ messages: OpenAIChatMessage[];
473
+ stream?: boolean;
474
+ max_tokens?: number;
475
+ temperature?: number;
476
+ top_p?: number;
477
+ tools?: OpenAITool[];
478
+ tool_choice?: string | { type: string; function?: { name: string } };
479
+ stop?: string | string[];
480
+ }
481
+
482
+ export interface OpenAIChatMessage {
483
+ role: "system" | "user" | "assistant" | "tool";
484
+ content?: string | OpenAIContentPart[];
485
+ name?: string;
486
+ tool_calls?: OpenAIToolCall[];
487
+ tool_call_id?: string;
488
+ }
489
+
490
+ export interface OpenAIContentPart {
491
+ type: "text" | "image_url";
492
+ text?: string;
493
+ image_url?: { url: string; detail?: string };
494
+ }
495
+
496
+ export interface OpenAITool {
497
+ type: "function";
498
+ function: { name: string; description?: string; parameters?: Record<string, unknown> };
499
+ }
500
+
501
+ export interface OpenAIToolCall {
502
+ id: string;
503
+ type: "function";
504
+ function: { name: string; arguments: string };
505
+ }
506
+
507
+ export interface OpenAIChatCompletionResponse {
508
+ id: string;
509
+ object: "chat.completion";
510
+ created: number;
511
+ model: string;
512
+ choices: {
513
+ index: number;
514
+ message: { role: "assistant"; content?: string | null; tool_calls?: OpenAIToolCall[] };
515
+ finish_reason: string | null;
516
+ }[];
517
+ usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number };
518
+ }
519
+
520
+ export interface OpenAIChatCompletionStreamChunk {
521
+ id: string;
522
+ object: "chat.completion.chunk";
523
+ created: number;
524
+ model: string;
525
+ choices: {
526
+ index: number;
527
+ delta: {
528
+ role?: "assistant";
529
+ content?: string | null;
530
+ reasoning_content?: string | null;
531
+ tool_calls?: { index: number; id?: string; type?: "function"; function?: { name?: string; arguments?: string } }[];
532
+ };
533
+ finish_reason: string | null;
534
+ }[];
535
+ }
536
+
537
+ export interface OpenAIModelEntry {
538
+ id: string;
539
+ object: "model";
540
+ created: number;
541
+ owned_by: string;
542
+ "x-pi"?: {
543
+ contextWindow?: number;
544
+ maxTokens?: number;
545
+ reasoning?: boolean;
546
+ cost?: { input?: number; output?: number };
547
+ input?: string[];
548
+ };
549
+ }
550
+
551
+ export interface OpenAIModelsResponse {
552
+ object: "list";
553
+ data: OpenAIModelEntry[];
554
+ }
555
+
556
+ /** Anthropic Messages request shape (subset relevant to the proxy). */
557
+ export interface AnthropicMessagesRequest {
558
+ model: string;
559
+ messages: AnthropicMessage[];
560
+ system?: string | AnthropicContentBlock[];
561
+ max_tokens: number;
562
+ stream?: boolean;
563
+ temperature?: number;
564
+ top_p?: number;
565
+ tools?: AnthropicTool[];
566
+ stop_sequences?: string[];
567
+ }
568
+
569
+ export interface AnthropicMessage {
570
+ role: "user" | "assistant";
571
+ content: string | AnthropicContentBlock[];
572
+ }
573
+
574
+ export interface AnthropicContentBlock {
575
+ type: string;
576
+ text?: string;
577
+ source?: { type: "base64"; media_type: string; data: string };
578
+ [key: string]: unknown;
579
+ }
580
+
581
+ export interface AnthropicTool {
582
+ name: string;
583
+ description?: string;
584
+ input_schema: Record<string, unknown>;
585
+ }
586
+
587
+ export interface AnthropicMessagesResponse {
588
+ id: string;
589
+ type: "message";
590
+ role: "assistant";
591
+ content: AnthropicContentBlock[];
592
+ model: string;
593
+ stop_reason: string | null;
594
+ usage: { input_tokens: number; output_tokens: number };
595
+ }
596
+
597
+ export interface AnthropicMessagesStreamEvent {
598
+ type: string;
599
+ message?: AnthropicMessagesResponse;
600
+ index?: number;
601
+ content_block?: AnthropicContentBlock;
602
+ delta?: { type: string; text?: string; partial_json?: string; thinking?: string; [key: string]: unknown };
603
+ usage?: { output_tokens: number };
604
+ }
605
+
606
+ // ── Model Proxy: API key management ─────────────────────────────────
607
+
608
+ export interface ModelProxyApiKeysCreateRequest {
609
+ label: string;
610
+ scopes?: string[];
611
+ expiresAt?: number;
612
+ }
613
+
614
+ export interface ModelProxyApiKeyEntry {
615
+ id: string;
616
+ label: string;
617
+ createdBy?: string;
618
+ scopes: string[];
619
+ createdAt: number;
620
+ lastUsedAt?: number;
621
+ expiresAt?: number;
622
+ revokedAt?: number;
623
+ hash: string; // redacted to "***" in list responses
624
+ }
625
+
626
+ export type ModelProxyApiKeysListResponse = ApiResponse<{
627
+ keys: ModelProxyApiKeyEntry[];
628
+ revoked: ModelProxyApiKeyEntry[];
629
+ }>;
630
+
631
+ export type ModelProxyApiKeysCreateResponse = ApiResponse<{
632
+ id: string;
633
+ label: string;
634
+ createdBy?: string;
635
+ scopes: string[];
636
+ createdAt: number;
637
+ expiresAt?: number;
638
+ key: string; // cleartext, revealed ONCE
639
+ }>;
640
+
641
+ // ── OpenSpec Change Grouping ────────────────────────────────────────
642
+ // See change: add-openspec-change-grouping (tasks 1.6, 5.x).
643
+ // Endpoints under `/api/openspec/groups[?cwd=…]`.
644
+
645
+ export type GetOpenSpecGroupsResponse = ApiResponse<OpenSpecGroupsFile>;
646
+
647
+ export interface CreateOpenSpecGroupRequest {
648
+ name: string;
649
+ color?: string;
650
+ }
651
+ export type CreateOpenSpecGroupResponse = ApiResponse<OpenSpecGroup>;
652
+
653
+ export interface UpdateOpenSpecGroupRequest {
654
+ name?: string;
655
+ color?: string;
656
+ order?: number;
657
+ }
658
+ export type UpdateOpenSpecGroupResponse = ApiResponse<OpenSpecGroup>;
659
+
660
+ export type DeleteOpenSpecGroupResponse = ApiResponse<void>;
661
+
662
+ export interface SetOpenSpecGroupAssignmentRequest {
663
+ changeName: string;
664
+ /** `null` removes the assignment (change becomes Ungrouped). */
665
+ groupId: string | null;
666
+ }
667
+ export type SetOpenSpecGroupAssignmentResponse = ApiResponse<void>;
@@ -0,0 +1,277 @@
1
+ /**
2
+ * `launchDashboardServer` — single shared spawn primitive for the
3
+ * dashboard server. Used by every starter (Bridge, Standalone CLI,
4
+ * Electron). Owns:
5
+ *
6
+ * - jiti loader resolution via `ToolResolver.resolveJiti({ anchor })`
7
+ * - argv construction via `spawnNodeScript` (which delegates to
8
+ * `buildNodeImportArgvParts` for the `--import` chunk)
9
+ * - env merge: `ToolResolver.buildSpawnEnv()` ∪ caller `env`
10
+ * (caller wins on conflict, e.g. `DASHBOARD_STARTER`)
11
+ * - log-file policy: caller-owned absolute path; we mkdir, open in
12
+ * append mode, write a header line, pass the fd, then close the
13
+ * parent's copy after spawn.
14
+ * - readiness policy: poll `isDashboardRunning(port)` and resolve /
15
+ * reject on the first of: health-ok, port-conflict, child early
16
+ * exit, or `healthTimeoutMs` elapsed.
17
+ *
18
+ * Does NOT own the log-file PATH — that's caller policy. Conventions:
19
+ * - extension: `stdio: "ignore"`
20
+ * - cli (`cmdStart`): `~/.pi/dashboard/server.log`
21
+ * - electron: existing electron log path
22
+ *
23
+ * See change: unify-server-launch-ts-loader.
24
+ */
25
+ import { dirname } from "node:path";
26
+ import {
27
+ closeSync,
28
+ mkdirSync,
29
+ openSync,
30
+ writeSync,
31
+ } from "node:fs";
32
+ import type { ChildProcess, SpawnOptions } from "node:child_process"; // ban:child_process-ok — types only
33
+ import { spawnNodeScript } from "./platform/node-spawn.js";
34
+ import { ToolResolver } from "./platform/binary-lookup.js";
35
+ import { isDashboardRunning } from "./server-identity.js";
36
+
37
+ // ── Errors ──────────────────────────────────────────────────────────────────
38
+
39
+ /** No jiti install resolved at any anchor. */
40
+ export class JitiNotFoundError extends Error {
41
+ constructor(message =
42
+ "Cannot find pi's TypeScript loader (jiti). " +
43
+ "Is @earendil-works/pi-coding-agent or @mariozechner/pi-coding-agent installed?",
44
+ ) {
45
+ super(message);
46
+ this.name = "JitiNotFoundError";
47
+ }
48
+ }
49
+
50
+ /** Target port is occupied by a non-dashboard service. */
51
+ export class PortConflictError extends Error {
52
+ readonly port: number;
53
+ constructor(port: number) {
54
+ super(`Port ${port} is occupied by a non-dashboard service`);
55
+ this.name = "PortConflictError";
56
+ this.port = port;
57
+ }
58
+ }
59
+
60
+ /** Spawned child exited before reaching health-ok. */
61
+ export class EarlyExitError extends Error {
62
+ readonly code: number | null;
63
+ readonly signal: NodeJS.Signals | null;
64
+ constructor(code: number | null, signal: NodeJS.Signals | null = null) {
65
+ super(`Server child exited (code=${code}, signal=${signal}) before reaching health`);
66
+ this.name = "EarlyExitError";
67
+ this.code = code;
68
+ this.signal = signal;
69
+ }
70
+ }
71
+
72
+ // ── Options + result ────────────────────────────────────────────────────────
73
+
74
+ export interface LaunchOpts {
75
+ /** Path to node binary. Defaults to `process.execPath`. */
76
+ nodeBin?: string;
77
+ /** Path to the dashboard server CLI script. */
78
+ cliPath: string;
79
+ /** Args appended after the entry script (e.g. `--port`, `--pi-port`, `start`). */
80
+ extraArgs?: readonly string[];
81
+ /** Caller-supplied jiti-resolution anchor (e.g. cliPath inside a node_modules tree). */
82
+ anchor?: string;
83
+ /**
84
+ * Caller env overrides merged ON TOP of `ToolResolver.buildSpawnEnv()`.
85
+ * Conflicting keys: caller wins. Pass `DASHBOARD_STARTER` here.
86
+ * Omit to fall back to the resolver-merged process env.
87
+ */
88
+ env?: Record<string, string | undefined>;
89
+ /**
90
+ * Stdio routing. `"ignore"` for fire-and-forget (extension); a
91
+ * `{ logFile }` object for caller-owned append-mode log capture.
92
+ */
93
+ stdio: "ignore" | { logFile: string };
94
+ /**
95
+ * Optional starter label written to the log header line and (when
96
+ * present) injected as `DASHBOARD_STARTER` env var if `env` does not
97
+ * already supply it. Plain string ("Bridge", "Standalone", "Electron").
98
+ */
99
+ starter?: string;
100
+ /** Health-check timeout in milliseconds. */
101
+ healthTimeoutMs: number;
102
+ /** Port to probe via `isDashboardRunning(port)`. */
103
+ port: number;
104
+ /**
105
+ * Whether the spawned server detaches from the parent's process
106
+ * group / Windows Job Object. Default: `true` (server outlives the
107
+ * launcher — correct for Bridge auto-spawn and Standalone CLI).
108
+ *
109
+ * Pass `false` when the caller deliberately ties the server's
110
+ * lifecycle to its own (Electron — server should die when Electron
111
+ * quits unless Electron explicitly decides to keep it).
112
+ */
113
+ detach?: boolean;
114
+ /**
115
+ * Working directory for the spawned process. Defaults to the
116
+ * launcher's own cwd. Electron passes the project directory.
117
+ */
118
+ cwd?: string;
119
+ // ── Test seams (production omits) ────────────────────────────────────────
120
+ /** Replace `ToolResolver.resolveJiti` (returns loader URL or null). */
121
+ _resolveJiti?: () => string | null;
122
+ /** Replace `spawnNodeScript` (returns ChildProcess). */
123
+ _spawnNodeScript?: typeof spawnNodeScript;
124
+ /** Replace `isDashboardRunning`. */
125
+ _isDashboardRunning?: typeof isDashboardRunning;
126
+ /** Replace fs primitives used for log-file handling. */
127
+ _fs?: {
128
+ mkdirSync?: typeof mkdirSync;
129
+ openSync?: typeof openSync;
130
+ closeSync?: typeof closeSync;
131
+ writeSync?: typeof writeSync;
132
+ };
133
+ /** Override poll interval (ms). Default 300. */
134
+ _pollIntervalMs?: number;
135
+ /** Override `Date.now` for deterministic timeout testing. */
136
+ _now?: () => number;
137
+ /** Override the sleep function used between polls. */
138
+ _sleep?: (ms: number) => Promise<void>;
139
+ }
140
+
141
+ export interface LaunchResult {
142
+ /** Spawned process pid (always present once spawn succeeded). */
143
+ childPid: number;
144
+ /** PID reported by `/api/health` (matches `dashboard.pid`); null if unavailable. */
145
+ reportedPid: number | null;
146
+ /** Always true when this resolves — readiness was confirmed. */
147
+ healthOk: true;
148
+ }
149
+
150
+ // ── Implementation ──────────────────────────────────────────────────────────
151
+
152
+ const DEFAULT_POLL_MS = 300;
153
+
154
+ function defaultSleep(ms: number): Promise<void> {
155
+ return new Promise((r) => setTimeout(r, ms));
156
+ }
157
+
158
+ /**
159
+ * Filter out `undefined` values from an env-record (NodeJS.ProcessEnv
160
+ * tolerates undefined; child_process.spawn does not).
161
+ */
162
+ function compactEnv(base: NodeJS.ProcessEnv): Record<string, string> {
163
+ const out: Record<string, string> = {};
164
+ for (const [k, v] of Object.entries(base)) {
165
+ if (typeof v === "string") out[k] = v;
166
+ }
167
+ return out;
168
+ }
169
+
170
+ /**
171
+ * Spawn the dashboard server and wait for `/api/health` to confirm
172
+ * identity. Resolves with `{ childPid, reportedPid, healthOk: true }`
173
+ * on success; rejects with `JitiNotFoundError` / `PortConflictError`
174
+ * / `EarlyExitError` / readiness-timeout `Error` per the spec.
175
+ */
176
+ export async function launchDashboardServer(opts: LaunchOpts): Promise<LaunchResult> {
177
+ const nodeBin = opts.nodeBin ?? process.execPath;
178
+ const resolveJiti = opts._resolveJiti ?? (() => new ToolResolver({ processExecPath: nodeBin }).resolveJiti({ anchor: opts.anchor }));
179
+ const spawn = opts._spawnNodeScript ?? spawnNodeScript;
180
+ const probe = opts._isDashboardRunning ?? isDashboardRunning;
181
+ const pollIntervalMs = opts._pollIntervalMs ?? DEFAULT_POLL_MS;
182
+ const now = opts._now ?? Date.now;
183
+ const sleep = opts._sleep ?? defaultSleep;
184
+ const fsMkdir = opts._fs?.mkdirSync ?? mkdirSync;
185
+ const fsOpen = opts._fs?.openSync ?? openSync;
186
+ const fsClose = opts._fs?.closeSync ?? closeSync;
187
+ const fsWrite = opts._fs?.writeSync ?? writeSync;
188
+
189
+ // 1. Loader resolution.
190
+ const loader = resolveJiti();
191
+ if (!loader) throw new JitiNotFoundError();
192
+
193
+ // 2. Env: ToolResolver.buildSpawnEnv() merged with caller env (caller wins).
194
+ const baseEnv = new ToolResolver({ processExecPath: nodeBin }).buildSpawnEnv(process.env);
195
+ const env: Record<string, string> = compactEnv(baseEnv);
196
+ if (opts.starter && !(opts.env && "DASHBOARD_STARTER" in opts.env)) {
197
+ env["DASHBOARD_STARTER"] = opts.starter;
198
+ }
199
+ if (opts.env) {
200
+ for (const [k, v] of Object.entries(opts.env)) {
201
+ if (typeof v === "string") env[k] = v;
202
+ else if (v === undefined) delete env[k];
203
+ }
204
+ }
205
+
206
+ // 3. Stdio + log header.
207
+ let logFd: number | undefined;
208
+ let stdio: SpawnOptions["stdio"];
209
+ if (opts.stdio === "ignore") {
210
+ stdio = "ignore";
211
+ } else {
212
+ const { logFile } = opts.stdio;
213
+ fsMkdir(dirname(logFile), { recursive: true });
214
+ logFd = fsOpen(logFile, "a");
215
+ const header = `[${new Date().toISOString()}] ${opts.starter ?? "dashboard"} launch (parent pid ${process.pid}, port ${opts.port}, cli ${opts.cliPath})\n`;
216
+ try { fsWrite(logFd, header); } catch { /* best-effort */ }
217
+ stdio = ["ignore", logFd, logFd];
218
+ }
219
+
220
+ // 4. Spawn. spawnNodeScript handles --import URL-wrapping + entry rule.
221
+ let child: ChildProcess;
222
+ try {
223
+ child = spawn({
224
+ nodeBin,
225
+ loader,
226
+ entry: opts.cliPath,
227
+ args: opts.extraArgs ? [...opts.extraArgs] : undefined,
228
+ spawnOptions: {
229
+ detached: opts.detach ?? true,
230
+ stdio,
231
+ env,
232
+ cwd: opts.cwd,
233
+ windowsHide: true,
234
+ },
235
+ });
236
+ } finally {
237
+ // Always close the parent's copy of the log fd; the child has its own.
238
+ if (logFd !== undefined) {
239
+ try { fsClose(logFd); } catch { /* ignore */ }
240
+ }
241
+ }
242
+
243
+ try { child.unref(); } catch { /* ignore */ }
244
+
245
+ if (!child.pid) {
246
+ throw new EarlyExitError(child.exitCode ?? null, child.signalCode ?? null);
247
+ }
248
+
249
+ // 5. Readiness loop.
250
+ const deadline = now() + opts.healthTimeoutMs;
251
+ while (true) {
252
+ // Early-exit detection (beats timeout per spec).
253
+ if (child.exitCode !== null) {
254
+ throw new EarlyExitError(child.exitCode, child.signalCode ?? null);
255
+ }
256
+ let status;
257
+ try {
258
+ status = await probe(opts.port);
259
+ } catch {
260
+ status = { running: false } as const;
261
+ }
262
+ if (status.running) {
263
+ return {
264
+ childPid: child.pid,
265
+ reportedPid: status.pid ?? null,
266
+ healthOk: true,
267
+ };
268
+ }
269
+ if (status.portConflict) {
270
+ throw new PortConflictError(opts.port);
271
+ }
272
+ if (now() >= deadline) {
273
+ throw new Error("readiness timeout");
274
+ }
275
+ await sleep(pollIntervalMs);
276
+ }
277
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Registration and resolution tests for the `pi-ai` module-kind tool.
3
+ *
4
+ * Verifies:
5
+ * - Registry resolves pi-ai when ~/.pi-dashboard/node_modules/@mariozechner/pi-ai/dist/index.js exists (managed)
6
+ * - Falls back to npmGlobalStrategy when only globally installed
7
+ * - Returns failed resolution with diagnostic trail when none match
8
+ * - Override takes precedence
9
+ *
10
+ * Note: bareImportStrategy uses real module resolution (createRequire) and
11
+ * cannot be injected via StrategyDeps.resolveModule in moduleDefWithAliases.
12
+ * It is implicitly tested (fails gracefully when pi-ai isn't a project dep).
13
+ *
14
+ * See change: add-dashboard-model-proxy (task 2.1).
15
+ */
16
+ import os from "node:os";
17
+ import path from "node:path";
18
+ import { describe, expect, it } from "vitest";
19
+ import {
20
+ ToolRegistry,
21
+ registerDefaultTools,
22
+ OverridesStore,
23
+ } from "../index.js";
24
+
25
+ const HOME = os.homedir();
26
+ const MANAGED_PATH = path.join(
27
+ HOME,
28
+ ".pi-dashboard",
29
+ "node_modules",
30
+ "@mariozechner",
31
+ "pi-ai",
32
+ "dist",
33
+ "index.js",
34
+ );
35
+
36
+ function freshRegistry(opts: {
37
+ exists?: (p: string) => boolean;
38
+ which?: (name: string) => string | null;
39
+ npmRootGlobal?: () => string;
40
+ overrides?: Record<string, string>;
41
+ }) {
42
+ const store = new OverridesStore({
43
+ filePath: path.join(os.tmpdir(), `pi-ai-test-${Math.random()}.json`),
44
+ warn: () => {},
45
+ });
46
+ for (const [k, v] of Object.entries(opts.overrides ?? {})) store.set(k, v);
47
+
48
+ const r = new ToolRegistry({
49
+ overrides: store,
50
+ platform: "linux",
51
+ });
52
+ registerDefaultTools(r, {
53
+ exists: opts.exists ?? (() => false),
54
+ which: opts.which ?? (() => null),
55
+ npmRootGlobal: opts.npmRootGlobal ?? (() => ""),
56
+ });
57
+ return r;
58
+ }
59
+
60
+ describe("pi-ai: module registration", () => {
61
+ it("resolves via managed path when ~/.pi-dashboard/node_modules/@mariozechner/pi-ai exists", () => {
62
+ const r = freshRegistry({
63
+ exists: (p) => p === MANAGED_PATH,
64
+ });
65
+ const result = r.resolve("pi-ai");
66
+ expect(result.ok).toBe(true);
67
+ expect(result.path).toBe(MANAGED_PATH);
68
+ expect(result.source).toBe("managed");
69
+ });
70
+
71
+ it("falls back to npm-global when only globally installed", () => {
72
+ const globalRoot = "/usr/lib/node_modules";
73
+ const globalPath = path.join(
74
+ globalRoot,
75
+ "@mariozechner",
76
+ "pi-ai",
77
+ "dist",
78
+ "index.js",
79
+ );
80
+ const r = freshRegistry({
81
+ exists: (p) => p === globalPath,
82
+ npmRootGlobal: () => globalRoot,
83
+ });
84
+ const result = r.resolve("pi-ai");
85
+ expect(result.ok).toBe(true);
86
+ expect(result.path).toBe(globalPath);
87
+ expect(result.source).toBe("npm-global");
88
+ });
89
+
90
+ it("returns failed resolution with diagnostic trail when none match", () => {
91
+ const r = freshRegistry({});
92
+ const result = r.resolve("pi-ai");
93
+ expect(result.ok).toBe(false);
94
+ expect(result.tried).toBeDefined();
95
+ expect(result.tried!.length).toBeGreaterThan(0);
96
+ // Should have tried override, bare-import, managed, npm-global
97
+ const strategyNames = result.tried!.map((t) => t.strategy);
98
+ expect(strategyNames).toContain("override");
99
+ expect(strategyNames).toContain("bare-import");
100
+ expect(strategyNames).toContain("managed");
101
+ expect(strategyNames).toContain("npm-global");
102
+ });
103
+
104
+ it("override takes precedence over managed", () => {
105
+ const overridePath = "/custom/pi-ai/dist/index.js";
106
+ const r = freshRegistry({
107
+ exists: (p) => p === overridePath || p === MANAGED_PATH,
108
+ overrides: { "pi-ai": overridePath },
109
+ });
110
+ const result = r.resolve("pi-ai");
111
+ expect(result.ok).toBe(true);
112
+ expect(result.path).toBe(overridePath);
113
+ expect(result.source).toBe("override");
114
+ });
115
+
116
+ it("is registered and resolvable by name", () => {
117
+ const r = freshRegistry({
118
+ exists: (p) => p === MANAGED_PATH,
119
+ });
120
+ const result = r.resolve("pi-ai");
121
+ expect(result.name).toBe("pi-ai");
122
+ expect(result.ok).toBe(true);
123
+ });
124
+ });