@blackbelt-technology/pi-agent-dashboard 0.4.5 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/AGENTS.md +342 -267
  2. package/README.md +51 -2
  3. package/docs/architecture.md +266 -25
  4. package/package.json +14 -4
  5. package/packages/extension/package.json +2 -2
  6. package/packages/extension/src/__tests__/build-provider-catalogue.test.ts +176 -0
  7. package/packages/extension/src/__tests__/markdown-image-inliner.test.ts +355 -0
  8. package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +68 -0
  9. package/packages/extension/src/__tests__/prompt-bus.test.ts +44 -0
  10. package/packages/extension/src/__tests__/prompt-expander.test.ts +45 -0
  11. package/packages/extension/src/__tests__/server-launcher.test.ts +24 -1
  12. package/packages/extension/src/__tests__/vcs-info-jj.test.ts +145 -0
  13. package/packages/extension/src/__tests__/{git-info.test.ts → vcs-info.test.ts} +6 -6
  14. package/packages/extension/src/bridge-context.ts +7 -0
  15. package/packages/extension/src/bridge.ts +142 -4
  16. package/packages/extension/src/command-handler.ts +6 -0
  17. package/packages/extension/src/markdown-image-inliner.ts +268 -0
  18. package/packages/extension/src/model-tracker.ts +35 -1
  19. package/packages/extension/src/prompt-bus.ts +4 -3
  20. package/packages/extension/src/prompt-expander.ts +50 -2
  21. package/packages/extension/src/provider-register.ts +117 -0
  22. package/packages/extension/src/server-launcher.ts +18 -1
  23. package/packages/extension/src/session-sync.ts +6 -1
  24. package/packages/extension/src/vcs-info.ts +184 -0
  25. package/packages/server/package.json +4 -4
  26. package/packages/server/src/__tests__/auto-attach-slug-defense.test.ts +104 -0
  27. package/packages/server/src/__tests__/bootstrap-install-from-list.test.ts +263 -0
  28. package/packages/server/src/__tests__/browser-gateway-snapshot-on-connect.test.ts +143 -0
  29. package/packages/server/src/__tests__/build-auth-status.test.ts +190 -0
  30. package/packages/server/src/__tests__/cold-boot-openspec-broadcast.test.ts +161 -0
  31. package/packages/server/src/__tests__/doctor-route.test.ts +132 -0
  32. package/packages/server/src/__tests__/event-wiring-providers-list.test.ts +87 -0
  33. package/packages/server/src/__tests__/has-openspec-dir.test.ts +64 -0
  34. package/packages/server/src/__tests__/health-shape.test.ts +43 -0
  35. package/packages/server/src/__tests__/idle-timer-respects-terminals.test.ts +115 -0
  36. package/packages/server/src/__tests__/is-unread-trigger.test.ts +4 -2
  37. package/packages/server/src/__tests__/jj-routes.test.ts +93 -0
  38. package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +92 -0
  39. package/packages/server/src/__tests__/openspec-tasks-parser.test.ts +114 -0
  40. package/packages/server/src/__tests__/pi-core-updater-managed-path.test.ts +177 -0
  41. package/packages/server/src/__tests__/process-manager-codes.test.ts +80 -0
  42. package/packages/server/src/__tests__/process-manager-managed-path.test.ts +73 -0
  43. package/packages/server/src/__tests__/provider-auth-storage.test.ts +42 -11
  44. package/packages/server/src/__tests__/provider-catalogue-cache.test.ts +54 -0
  45. package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +17 -2
  46. package/packages/server/src/__tests__/session-action-handler-spawn.test.ts +150 -0
  47. package/packages/server/src/__tests__/session-diff-vcs.test.ts +61 -0
  48. package/packages/server/src/__tests__/session-discovery-skill-firstmessage.test.ts +95 -0
  49. package/packages/server/src/__tests__/spawn-failure-log.test.ts +118 -0
  50. package/packages/server/src/__tests__/spawn-preflight.test.ts +91 -0
  51. package/packages/server/src/__tests__/spawn-register-watchdog.test.ts +166 -0
  52. package/packages/server/src/__tests__/subscription-handler.test.ts +98 -6
  53. package/packages/server/src/__tests__/system-routes-reextract.test.ts +91 -0
  54. package/packages/server/src/__tests__/system-routes-restart.test.ts +4 -4
  55. package/packages/server/src/__tests__/system-routes-spawn-failures.test.ts +84 -0
  56. package/packages/server/src/__tests__/terminal-manager.test.ts +45 -0
  57. package/packages/server/src/bootstrap-install-from-list.ts +232 -0
  58. package/packages/server/src/bootstrap-state.ts +18 -0
  59. package/packages/server/src/browser-gateway.ts +58 -21
  60. package/packages/server/src/browser-handlers/directory-handler.ts +4 -0
  61. package/packages/server/src/browser-handlers/session-action-handler.ts +60 -2
  62. package/packages/server/src/browser-handlers/subscription-handler.ts +50 -3
  63. package/packages/server/src/cli.ts +22 -0
  64. package/packages/server/src/directory-service.ts +31 -0
  65. package/packages/server/src/event-wiring.ts +57 -2
  66. package/packages/server/src/home-lock.d.ts +124 -0
  67. package/packages/server/src/home-lock.js +330 -0
  68. package/packages/server/src/home-lock.js.map +1 -0
  69. package/packages/server/src/idle-timer.ts +15 -1
  70. package/packages/server/src/openspec-tasks.ts +50 -19
  71. package/packages/server/src/pi-core-updater.ts +65 -9
  72. package/packages/server/src/pi-gateway.ts +6 -0
  73. package/packages/server/src/process-manager.ts +62 -11
  74. package/packages/server/src/provider-auth-handlers.ts +9 -0
  75. package/packages/server/src/provider-auth-storage.ts +83 -51
  76. package/packages/server/src/provider-catalogue-cache.ts +41 -0
  77. package/packages/server/src/routes/doctor-routes.ts +140 -0
  78. package/packages/server/src/routes/jj-routes.ts +386 -0
  79. package/packages/server/src/routes/provider-auth-routes.ts +9 -0
  80. package/packages/server/src/routes/session-routes.ts +12 -3
  81. package/packages/server/src/routes/system-routes.ts +38 -1
  82. package/packages/server/src/server.ts +16 -9
  83. package/packages/server/src/session-bootstrap.ts +27 -12
  84. package/packages/server/src/session-diff.ts +118 -1
  85. package/packages/server/src/session-discovery.ts +10 -3
  86. package/packages/server/src/session-scanner.ts +4 -2
  87. package/packages/server/src/spawn-failure-log.ts +130 -0
  88. package/packages/server/src/spawn-preflight.ts +82 -0
  89. package/packages/server/src/spawn-register-watchdog.ts +236 -0
  90. package/packages/server/src/terminal-manager.ts +12 -1
  91. package/packages/shared/package.json +1 -1
  92. package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +1 -0
  93. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +1 -0
  94. package/packages/shared/src/__tests__/bootstrap-install-resolve-npm.test.ts +72 -0
  95. package/packages/shared/src/__tests__/browser-protocol-types.test.ts +47 -1
  96. package/packages/shared/src/__tests__/config.test.ts +48 -0
  97. package/packages/shared/src/__tests__/dashboard-starter.test.ts +40 -0
  98. package/packages/shared/src/__tests__/detached-spawn.test.ts +24 -0
  99. package/packages/shared/src/__tests__/doctor-core.test.ts +134 -0
  100. package/packages/shared/src/__tests__/doctor-fault-tolerance.test.ts +218 -0
  101. package/packages/shared/src/__tests__/doctor-format.test.ts +121 -0
  102. package/packages/shared/src/__tests__/install-managed-node-bootstrap-order.test.ts +68 -0
  103. package/packages/shared/src/__tests__/install-managed-node.test.ts +192 -0
  104. package/packages/shared/src/__tests__/installable-list.test.ts +130 -0
  105. package/packages/shared/src/__tests__/managed-node-path.test.ts +122 -0
  106. package/packages/shared/src/__tests__/managed-runtime-strategy.test.ts +74 -0
  107. package/packages/shared/src/__tests__/no-installable-list-in-bridge.test.ts +52 -0
  108. package/packages/shared/src/__tests__/no-raw-openspec-status-in-skills.test.ts +6 -1
  109. package/packages/shared/src/__tests__/platform-jj.test.ts +339 -0
  110. package/packages/shared/src/__tests__/skill-block-parser.test.ts +153 -0
  111. package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +18 -2
  112. package/packages/shared/src/bootstrap-install.ts +196 -2
  113. package/packages/shared/src/browser-protocol.ts +112 -1
  114. package/packages/shared/src/config.ts +29 -0
  115. package/packages/shared/src/dashboard-starter.ts +33 -0
  116. package/packages/shared/src/diff-types.ts +17 -0
  117. package/packages/shared/src/doctor-core.ts +821 -0
  118. package/packages/shared/src/index.ts +9 -0
  119. package/packages/shared/src/installable-list.ts +152 -0
  120. package/packages/shared/src/launch-source-flag.ts +14 -0
  121. package/packages/shared/src/launch-source-types.ts +18 -0
  122. package/packages/shared/src/openspec-activity-detector.ts +25 -7
  123. package/packages/shared/src/platform/detached-spawn.ts +13 -2
  124. package/packages/shared/src/platform/jj.ts +405 -0
  125. package/packages/shared/src/platform/managed-node-path.ts +77 -0
  126. package/packages/shared/src/protocol.ts +60 -2
  127. package/packages/shared/src/rest-api.ts +4 -0
  128. package/packages/shared/src/skill-block-parser.ts +115 -0
  129. package/packages/shared/src/tool-registry/__tests__/managed-runtime-strategy.test.ts +166 -0
  130. package/packages/shared/src/tool-registry/definitions.ts +19 -5
  131. package/packages/shared/src/tool-registry/strategies.ts +42 -0
  132. package/packages/shared/src/types.ts +91 -0
  133. package/packages/extension/src/git-info.ts +0 -55
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Prepend the managed Node runtime directory to a child-process `PATH`.
3
+ *
4
+ * The managed Node lives at `<managedDir>/node/` (Windows: binaries at
5
+ * the root; Unix: binaries under `bin/`). When present, every spawn the
6
+ * dashboard controls SHALL inherit that directory at the front of its
7
+ * `PATH` so plain `node` / `npm` invocations inside the child process
8
+ * resolve to the managed runtime.
9
+ *
10
+ * Pure helper: never mutates `process.env`, returns a distinct cloned
11
+ * env object, no-ops when the managed runtime is absent.
12
+ *
13
+ * See change: embed-managed-node-runtime (spec: managed-node-runtime,
14
+ * Requirement: Spawned children inherit managed Node on PATH).
15
+ */
16
+ import path from "node:path";
17
+ import { existsSync } from "node:fs";
18
+ import { getManagedDir, type ManagedPathsEnv } from "../managed-paths.js";
19
+
20
+ /**
21
+ * Resolve the bin directory inside the managed Node tree.
22
+ * Windows: `<managedDir>/node` (node.exe + npm.cmd live at root)
23
+ * Unix: `<managedDir>/node/bin` (node, npm, npx live under bin/)
24
+ *
25
+ * `platform` defaults to `process.platform`; tests inject `"win32"` from
26
+ * a Linux host to exercise the Windows layout.
27
+ */
28
+ export function getManagedNodeBinDir(
29
+ env?: ManagedPathsEnv,
30
+ platform: NodeJS.Platform = process.platform,
31
+ ): string {
32
+ const root = path.join(getManagedDir(env), "node");
33
+ return platform === "win32" ? root : path.join(root, "bin");
34
+ }
35
+
36
+ /** Path to the managed `node` / `node.exe` binary. */
37
+ export function getManagedNodeBinary(
38
+ env?: ManagedPathsEnv,
39
+ platform: NodeJS.Platform = process.platform,
40
+ ): string {
41
+ const bin = getManagedNodeBinDir(env, platform);
42
+ return path.join(bin, platform === "win32" ? "node.exe" : "node");
43
+ }
44
+
45
+ /** True iff the managed Node runtime is installed (binary exists). */
46
+ export function isManagedNodePresent(
47
+ env?: ManagedPathsEnv,
48
+ platform: NodeJS.Platform = process.platform,
49
+ ): boolean {
50
+ return existsSync(getManagedNodeBinary(env, platform));
51
+ }
52
+
53
+ /**
54
+ * Return a shallow-cloned env with the managed Node bin directory
55
+ * prepended to `PATH`. No-op (still returns a clone) when the managed
56
+ * runtime is not installed or its directory is already on PATH.
57
+ *
58
+ * Never mutates the input env or `process.env`.
59
+ */
60
+ export function prependManagedNodeToPath(
61
+ baseEnv: NodeJS.ProcessEnv = process.env,
62
+ managedPathsEnv?: ManagedPathsEnv,
63
+ ): NodeJS.ProcessEnv {
64
+ const cloned: NodeJS.ProcessEnv = { ...baseEnv };
65
+ if (!isManagedNodePresent(managedPathsEnv)) return cloned;
66
+
67
+ const dir = getManagedNodeBinDir(managedPathsEnv);
68
+ const currentPath = cloned.PATH ?? "";
69
+ // Avoid duplicate prepends when the dir is already at the head; cheap
70
+ // string contains check matches `buildSpawnEnv` style.
71
+ if (currentPath.split(path.delimiter).includes(dir)) return cloned;
72
+
73
+ cloned.PATH = currentPath
74
+ ? `${dir}${path.delimiter}${currentPath}`
75
+ : dir;
76
+ return cloned;
77
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Extension ↔ Server WebSocket protocol messages.
3
3
  */
4
- import type { DashboardEvent, CommandInfo, FlowInfo, SessionSource, ImageContent, FileEntry, TurnUsage, ContextUsage, ModelInfo, PiSessionInfo, OpenSpecPhase, RoleInfo, ExtensionUiModule, DecoratorDescriptor } from "./types.js";
4
+ import type { DashboardEvent, CommandInfo, FlowInfo, SessionSource, ImageContent, FileEntry, TurnUsage, ContextUsage, ModelInfo, ProviderInfo, PiSessionInfo, OpenSpecPhase, RoleInfo, ExtensionUiModule, DecoratorDescriptor } from "./types.js";
5
5
 
6
6
  // ── Extension → Server ──────────────────────────────────────────────
7
7
 
@@ -130,6 +130,19 @@ export interface GitInfoUpdateMessage {
130
130
  gitPrUrl?: string;
131
131
  }
132
132
 
133
+ /**
134
+ * Bridge → server: jj workspace state for the session's cwd.
135
+ * Sent only when the bridge's VCS probe finds `.jj/` AND `jj` resolves
136
+ * via the tool registry. Cleared (sent with `jjState: null`) when the
137
+ * session leaves a jj repo (e.g. cwd switch). See change: add-jj-workspace-plugin.
138
+ */
139
+ export interface JjStateUpdateMessage {
140
+ type: "jj_state_update";
141
+ sessionId: string;
142
+ /** `null` clears the session's `jjState` field on the server. */
143
+ jjState: import("./types.js").JjState | null;
144
+ }
145
+
133
146
  // OpenSpecUpdateMessage removed — server polls directly via DirectoryService
134
147
 
135
148
  export interface ModelsListMessage {
@@ -138,6 +151,17 @@ export interface ModelsListMessage {
138
151
  models: ModelInfo[];
139
152
  }
140
153
 
154
+ /**
155
+ * Bridge -> server: pi's live provider catalogue derived from
156
+ * `modelRegistry.authStorage` + `modelRegistry.getProviderDisplayName`.
157
+ * Sent alongside ModelsListMessage. See change: replace-hardcoded-provider-lists.
158
+ */
159
+ export interface ProvidersListMessage {
160
+ type: "providers_list";
161
+ sessionId: string;
162
+ providers: ProviderInfo[];
163
+ }
164
+
141
165
  export interface SessionNameUpdateMessage {
142
166
  type: "session_name_update";
143
167
  sessionId: string;
@@ -273,6 +297,27 @@ export interface ExtUiDecoratorMessage {
273
297
  removed?: boolean;
274
298
  }
275
299
 
300
+ // ── Markdown asset inlining (chat-markdown-local-images-and-math) ──
301
+ //
302
+ // Bridge → server: register a base64-encoded image asset under a content
303
+ // hash. Emitted by the bridge BEFORE the `message_update` / `message_end`
304
+ // event whose text references `pi-asset:<hash>`. Bytes ride exactly once
305
+ // per (session, hash) pair — subsequent references in later events emit
306
+ // no further `asset_register`. Persisted in `events.jsonl` alongside the
307
+ // referencing message events so reconnect/replay rebuilds the per-session
308
+ // asset registry deterministically. See change:
309
+ // chat-markdown-local-images-and-math.
310
+ export interface AssetRegisterMessage {
311
+ type: "asset_register";
312
+ sessionId: string;
313
+ /** Content hash (sha256 truncated to 16 hex chars). */
314
+ hash: string;
315
+ /** MIME type (one of the bridge's image allowlist). */
316
+ mimeType: string;
317
+ /** Base64-encoded file bytes. */
318
+ data: string;
319
+ }
320
+
276
321
  export type ExtensionToServerMessage =
277
322
  | SessionRegisterMessage
278
323
  | SessionUnregisterMessage
@@ -283,8 +328,10 @@ export type ExtensionToServerMessage =
283
328
  | ExtensionUiRequestMessage
284
329
  | FilesListMessage
285
330
  | GitInfoUpdateMessage
331
+ | JjStateUpdateMessage
286
332
  | SessionNameUpdateMessage
287
333
  | ModelsListMessage
334
+ | ProvidersListMessage
288
335
  | ModelUpdateMessage
289
336
  | SessionsListExtensionMessage
290
337
  | ExtensionUiDismissMessage
@@ -298,7 +345,8 @@ export type ExtensionToServerMessage =
298
345
  | ProcessListMessage
299
346
  | UiModulesListMessage
300
347
  | UiDataListMessage
301
- | ExtUiDecoratorMessage;
348
+ | ExtUiDecoratorMessage
349
+ | AssetRegisterMessage;
302
350
 
303
351
  // ── Server → Extension ──────────────────────────────────────────────
304
352
 
@@ -343,6 +391,15 @@ export interface RequestModelsMessage {
343
391
  sessionId: string;
344
392
  }
345
393
 
394
+ /**
395
+ * Server -> bridge: ask the bridge to push a fresh providers_list.
396
+ * See change: replace-hardcoded-provider-lists.
397
+ */
398
+ export interface RequestProvidersMessage {
399
+ type: "request_providers";
400
+ sessionId: string;
401
+ }
402
+
346
403
  export interface SetThinkingLevelMessage {
347
404
  type: "set_thinking_level";
348
405
  sessionId: string;
@@ -500,6 +557,7 @@ export type ServerToExtensionMessage =
500
557
  | ListFilesMessage
501
558
  | RenameSessionExtensionMessage
502
559
  | RequestModelsMessage
560
+ | RequestProvidersMessage
503
561
  | SetThinkingLevelMessage
504
562
  | ListSessionsExtensionMessage
505
563
  | SetModelMessage
@@ -255,6 +255,10 @@ export interface ProviderAuthStatus {
255
255
  authenticated: boolean;
256
256
  expires?: number;
257
257
  maskedKey?: string;
258
+ /** Name of the env var pi-ai consults for this provider (api-key rows only). */
259
+ envVar?: string;
260
+ /** True when configured via ambient credential chain (AWS profile / GCP ADC). */
261
+ ambient?: boolean;
258
262
  }
259
263
 
260
264
  export interface AuthorizeResponse {
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Skill block parser & builder.
3
+ *
4
+ * Pi's `_expandSkillCommand` (in `@mariozechner/pi-coding-agent`) wraps skill
5
+ * expansions in a `<skill name="..." location="...">…</skill>\n\nargs` envelope.
6
+ * The dashboard's bridge expander (`packages/extension/src/prompt-expander.ts`)
7
+ * aligns to the same byte format. This module is the single source of truth for
8
+ * both producing and recovering that envelope.
9
+ *
10
+ * See change: render-skill-invocations-collapsibly.
11
+ */
12
+
13
+ export interface SkillBlock {
14
+ /** Bare skill name (no `skill:` prefix), e.g. `"openspec-explore"`. */
15
+ name: string;
16
+ /** Absolute path to `SKILL.md`. */
17
+ location: string;
18
+ /**
19
+ * Skill body with the `References are relative to <baseDir>.\n\n` preamble
20
+ * stripped — what users see in the card. The preamble is bridge-internal
21
+ * orientation for the LLM and is not interesting to users.
22
+ *
23
+ * Pi's own `parseSkillBlock` returns the unstripped form (calls it `content`).
24
+ * We strip here so the renderer doesn't have to. If the preamble shape ever
25
+ * changes upstream and stripping fails, `body` falls back to the captured
26
+ * content verbatim.
27
+ */
28
+ body: string;
29
+ /** User text after the skill name. `undefined` when no args were typed. */
30
+ args: string | undefined;
31
+ /** Slash-command form: `"/skill:" + name + (args ? " " + args : "")`. */
32
+ condensed: string;
33
+ }
34
+
35
+ /**
36
+ * Anchored, non-greedy match of a wrapped skill block.
37
+ *
38
+ * Why anchored: prevents a literal `<skill>` substring elsewhere in user text
39
+ * from matching. Why non-greedy + optional trailing args at end-of-string:
40
+ * forces the regex engine to extend the body to the last valid
41
+ * `\n</skill>(\n\nargs)?$` boundary, so SKILL.md bodies that document the
42
+ * `<skill>` tag (e.g. include the literal text in code samples) do not
43
+ * terminate the match prematurely.
44
+ */
45
+ const SKILL_BLOCK_RE =
46
+ /^<skill name="([^"]+)" location="([^"]+)">\n([\s\S]*?)\n<\/skill>(?:\n\n([\s\S]+))?$/;
47
+
48
+ /**
49
+ * Parse a skill block from message text. Returns `null` when the input is not
50
+ * a well-formed skill envelope.
51
+ */
52
+ /**
53
+ * Strip the `References are relative to <baseDir>.\n\n` preamble from a captured
54
+ * `<skill>` content block. Returns the stripped body, or the input unchanged if
55
+ * the preamble shape doesn't match.
56
+ */
57
+ function stripReferencesPreamble(content: string): string {
58
+ const m = content.match(/^References are relative to [^\n]+\.\n\n([\s\S]*)$/);
59
+ return m ? m[1] : content;
60
+ }
61
+
62
+ export function parseSkillBlock(text: string): SkillBlock | null {
63
+ const m = text.match(SKILL_BLOCK_RE);
64
+ if (!m) return null;
65
+ const name = m[1];
66
+ const location = m[2];
67
+ const body = stripReferencesPreamble(m[3]);
68
+ const args = m[4];
69
+ const condensed = `/skill:${name}${args ? " " + args : ""}`;
70
+ return { name, location, body, args, condensed };
71
+ }
72
+
73
+ export interface BuildSkillBlockArgs {
74
+ /** Bare skill name (no `skill:` prefix). */
75
+ name: string;
76
+ /** Absolute path to `SKILL.md`. */
77
+ filePath: string;
78
+ /** Skill base directory — `dirname(filePath)`. */
79
+ baseDir: string;
80
+ /** Skill body, frontmatter already stripped. Should be `.trim()`-ed. */
81
+ body: string;
82
+ /** Optional user-typed args appended after the closing tag. */
83
+ userArgs?: string;
84
+ }
85
+
86
+ /**
87
+ * Build a skill block in the exact byte format pi's `_expandSkillCommand`
88
+ * produces. The output is byte-identical to pi's output for the same inputs;
89
+ * `parseSkillBlock(buildSkillBlock(x))` round-trips for `name`, `body`, `args`.
90
+ */
91
+ export function buildSkillBlock(input: BuildSkillBlockArgs): string {
92
+ const wrapper =
93
+ `<skill name="${input.name}" location="${input.filePath}">\n` +
94
+ `References are relative to ${input.baseDir}.\n\n` +
95
+ `${input.body}\n` +
96
+ `</skill>`;
97
+ return input.userArgs ? `${wrapper}\n\n${input.userArgs}` : wrapper;
98
+ }
99
+
100
+ /**
101
+ * Condense a user-message content string for `firstMessage` / display purposes.
102
+ *
103
+ * If `text` parses as a `<skill>` envelope, returns the slash-command form
104
+ * (`/skill:name args`) truncated to `maxLen` chars. Otherwise returns
105
+ * `text.slice(0, maxLen)`. Used by session-scanner / session-discovery so the
106
+ * 200-char `firstMessage` shows the recognisable slash form instead of the
107
+ * front of an opaque wrapper.
108
+ *
109
+ * See change: render-skill-invocations-collapsibly.
110
+ */
111
+ export function condenseForFirstMessage(text: string, maxLen: number): string {
112
+ const block = parseSkillBlock(text);
113
+ if (block) return block.condensed.slice(0, maxLen);
114
+ return text.slice(0, maxLen);
115
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Chain-order tests for the managed-Node runtime strategy.
3
+ *
4
+ * After change `embed-managed-node-runtime`, the `node` and `npm`
5
+ * strategy chains gain a `managedRuntimeStrategy` between
6
+ * `overrideStrategy` and the existing PATH/where lookup. These tests
7
+ * pin the precedence:
8
+ *
9
+ * 1. override (tool-overrides.json) — wins
10
+ * 2. managed runtime (<managedDir>/node/) — preferred over PATH
11
+ * 3. where / PATH — fallback
12
+ *
13
+ * `exists` is injected so no real filesystem is touched.
14
+ *
15
+ * See change: embed-managed-node-runtime (task 3.3).
16
+ */
17
+ import os from "node:os";
18
+ import path from "node:path";
19
+ import { describe, expect, it } from "vitest";
20
+ import {
21
+ ToolRegistry,
22
+ registerDefaultTools,
23
+ OverridesStore,
24
+ } from "../index.js";
25
+
26
+ function freshRegistry(opts: {
27
+ exists?: (p: string) => boolean;
28
+ which?: (name: string) => string | null;
29
+ overrides?: Record<string, string>;
30
+ platform?: NodeJS.Platform;
31
+ }) {
32
+ const store = new OverridesStore({
33
+ filePath: path.join(os.tmpdir(), `mr-test-${Math.random()}.json`),
34
+ warn: () => {},
35
+ });
36
+ for (const [k, v] of Object.entries(opts.overrides ?? {})) store.set(k, v);
37
+
38
+ const r = new ToolRegistry({
39
+ overrides: store,
40
+ platform: opts.platform ?? "linux",
41
+ });
42
+ registerDefaultTools(r, {
43
+ exists: opts.exists ?? (() => false),
44
+ which: opts.which ?? (() => null),
45
+ npmRootGlobal: () => "",
46
+ });
47
+ return r;
48
+ }
49
+
50
+ const HOME = os.homedir();
51
+ const MANAGED_NODE_UNIX = path.join(HOME, ".pi-dashboard", "node", "bin", "node");
52
+ const MANAGED_NPM_UNIX = path.join(HOME, ".pi-dashboard", "node", "bin", "npm");
53
+ const MANAGED_NODE_WIN = path.join(HOME, ".pi-dashboard", "node", "node.exe");
54
+ const MANAGED_NPM_WIN = path.join(HOME, ".pi-dashboard", "node", "npm.cmd");
55
+
56
+ describe("node: managed-runtime strategy precedence", () => {
57
+ it("managed runtime present → returned over PATH (Unix)", () => {
58
+ const r = freshRegistry({
59
+ platform: "linux",
60
+ exists: (p) => p === MANAGED_NODE_UNIX,
61
+ which: () => "/usr/bin/node",
62
+ });
63
+ const res = r.resolve("node");
64
+ expect(res.ok).toBe(true);
65
+ expect(res.path).toBe(MANAGED_NODE_UNIX);
66
+ expect(res.source).toBe("managed");
67
+ });
68
+
69
+ it("managed runtime present → returned over PATH (Windows)", () => {
70
+ const r = freshRegistry({
71
+ platform: "win32",
72
+ exists: (p) => p === MANAGED_NODE_WIN,
73
+ which: () => "C:\\node\\node.exe",
74
+ });
75
+ const res = r.resolve("node");
76
+ expect(res.ok).toBe(true);
77
+ expect(res.path).toBe(MANAGED_NODE_WIN);
78
+ expect(res.source).toBe("managed");
79
+ });
80
+
81
+ it("override wins over managed runtime", () => {
82
+ const custom = "/opt/custom/node";
83
+ const r = freshRegistry({
84
+ platform: "linux",
85
+ overrides: { node: custom },
86
+ exists: (p) => p === custom || p === MANAGED_NODE_UNIX,
87
+ });
88
+ const res = r.resolve("node");
89
+ expect(res.ok).toBe(true);
90
+ expect(res.path).toBe(custom);
91
+ expect(res.source).toBe("override");
92
+ });
93
+
94
+ it("both absent → falls through to PATH lookup", () => {
95
+ const r = freshRegistry({
96
+ platform: "linux",
97
+ exists: () => false,
98
+ which: (name) => (name === "node" ? "/usr/bin/node" : null),
99
+ });
100
+ const res = r.resolve("node");
101
+ expect(res.ok).toBe(true);
102
+ expect(res.path).toBe("/usr/bin/node");
103
+ expect(res.source).toBe("system");
104
+ });
105
+
106
+ it("nothing present → ok:false with diagnostic trail", () => {
107
+ const r = freshRegistry({ platform: "linux", exists: () => false, which: () => null });
108
+ const res = r.resolve("node");
109
+ expect(res.ok).toBe(false);
110
+ const trailNames = res.tried.map((t) => t.strategy);
111
+ expect(trailNames).toContain("override");
112
+ expect(trailNames).toContain("managed");
113
+ expect(trailNames).toContain("where");
114
+ });
115
+ });
116
+
117
+ describe("npm: managed-runtime strategy precedence", () => {
118
+ it("managed npm present → returned over PATH (Unix)", () => {
119
+ const r = freshRegistry({
120
+ platform: "linux",
121
+ exists: (p) => p === MANAGED_NPM_UNIX,
122
+ which: () => "/usr/bin/npm",
123
+ });
124
+ const res = r.resolve("npm");
125
+ expect(res.ok).toBe(true);
126
+ expect(res.path).toBe(MANAGED_NPM_UNIX);
127
+ expect(res.source).toBe("managed");
128
+ });
129
+
130
+ it("managed npm present → returned over PATH (Windows)", () => {
131
+ const r = freshRegistry({
132
+ platform: "win32",
133
+ exists: (p) => p === MANAGED_NPM_WIN,
134
+ which: () => "C:\\node\\npm.cmd",
135
+ });
136
+ const res = r.resolve("npm");
137
+ expect(res.ok).toBe(true);
138
+ expect(res.path).toBe(MANAGED_NPM_WIN);
139
+ expect(res.source).toBe("managed");
140
+ });
141
+
142
+ it("override wins over managed npm", () => {
143
+ const custom = "/opt/custom/npm";
144
+ const r = freshRegistry({
145
+ platform: "linux",
146
+ overrides: { npm: custom },
147
+ exists: (p) => p === custom || p === MANAGED_NPM_UNIX,
148
+ });
149
+ const res = r.resolve("npm");
150
+ expect(res.ok).toBe(true);
151
+ expect(res.path).toBe(custom);
152
+ expect(res.source).toBe("override");
153
+ });
154
+
155
+ it("npm: managed absent + PATH present → falls through (Unix)", () => {
156
+ const r = freshRegistry({
157
+ platform: "linux",
158
+ exists: () => false,
159
+ which: (name) => (name === "npm" ? "/usr/bin/npm" : null),
160
+ });
161
+ const res = r.resolve("npm");
162
+ expect(res.ok).toBe(true);
163
+ expect(res.path).toBe("/usr/bin/npm");
164
+ expect(res.source).toBe("system");
165
+ });
166
+ });
@@ -18,6 +18,7 @@ import {
18
18
  bareImportStrategy,
19
19
  managedBinStrategy,
20
20
  managedModuleStrategy,
21
+ managedRuntimeStrategy,
21
22
  npmGlobalStrategy,
22
23
  overrideStrategy,
23
24
  whereStrategy,
@@ -39,14 +40,20 @@ function classify(strategyName: string): Source {
39
40
  // ── Binary definitions ──────────────────────────────────────────────────────
40
41
 
41
42
  function binaryDef(binaryName: string, deps?: StrategyDeps): ToolDefinition {
43
+ // The `node` binary gets the managed-Node runtime strategy prepended
44
+ // (after override) so the persistent <managedDir>/node/ install wins
45
+ // over PATH lookup. See change: embed-managed-node-runtime.
46
+ const isNode = binaryName === "node";
47
+ const strategies = [
48
+ overrideStrategy(binaryName, deps),
49
+ ...(isNode ? [managedRuntimeStrategy("node", deps)] : []),
50
+ managedBinStrategy(binaryName, deps),
51
+ whereStrategy(binaryName, deps),
52
+ ];
42
53
  return {
43
54
  name: binaryName,
44
55
  kind: "binary",
45
- strategies: [
46
- overrideStrategy(binaryName, deps),
47
- managedBinStrategy(binaryName, deps),
48
- whereStrategy(binaryName, deps),
49
- ],
56
+ strategies,
50
57
  classify,
51
58
  };
52
59
  }
@@ -283,14 +290,20 @@ function npmExecutorDef(deps?: StrategyDeps): ToolDefinition {
283
290
  },
284
291
  };
285
292
 
293
+ // Managed-Node runtime: prefer <managedDir>/node/{npm.cmd,bin/npm}
294
+ // when the runtime is installed. See change: embed-managed-node-runtime.
295
+ const managedNpm = managedRuntimeStrategy("npm", deps);
296
+
286
297
  const winStrategies = [
287
298
  overrideStrategy("npm", deps),
299
+ managedNpm,
288
300
  npmCliBesideNodeStrategy,
289
301
  whereStrategy("npm", deps),
290
302
  ];
291
303
 
292
304
  const unixStrategies = [
293
305
  overrideStrategy("npm", deps),
306
+ managedNpm,
294
307
  whereStrategy("npm", deps),
295
308
  ];
296
309
 
@@ -354,6 +367,7 @@ export function registerDefaultTools(registry: ToolRegistry, deps?: StrategyDeps
354
367
  // Native binaries — no interpreter needed.
355
368
  registry.register(binaryDef("node", deps));
356
369
  registry.register(binaryDef("git", deps));
370
+ registry.register(binaryDef("jj", deps));
357
371
  registry.register(binaryDef("zrok", deps));
358
372
 
359
373
  // Platform-conditional process-inspection utilities. These are only
@@ -13,6 +13,7 @@ import { createRequire } from "node:module";
13
13
  import path from "node:path";
14
14
  import { ToolResolver, isAppImageSelfHit } from "../platform/binary-lookup.js";
15
15
  import { getManagedBin, getManagedDir } from "../managed-paths.js";
16
+ import { getManagedNodeBinDir } from "../platform/managed-node-path.js";
16
17
  import * as npm from "../platform/npm.js";
17
18
  import type { Strategy, StrategyCtx, StrategyResult } from "./types.js";
18
19
 
@@ -86,6 +87,47 @@ export function overrideStrategy(toolName: string, deps?: StrategyDeps): Strateg
86
87
  };
87
88
  }
88
89
 
90
+ /**
91
+ * Managed Node runtime: `<managedDir>/node/{node.exe,npm.cmd,npx.cmd}`
92
+ * on Windows or `<managedDir>/node/bin/{node,npm,npx}` on Unix.
93
+ *
94
+ * Lets `ToolRegistry.resolve("node")` and `resolve("npm")` prefer the
95
+ * persistent runtime under `~/.pi-dashboard/node/` (installed by
96
+ * `installManagedNode`) over the system PATH lookup, while still
97
+ * deferring to `tool-overrides.json`.
98
+ *
99
+ * Returns `null` when the managed Node runtime is not present, so the
100
+ * standalone-CLI / no-Electron-resources case falls through cleanly to
101
+ * the existing `where`/PATH strategy.
102
+ *
103
+ * See change: embed-managed-node-runtime (spec: managed-node-runtime,
104
+ * Requirement: ToolRegistry resolves managed runtime first).
105
+ */
106
+ export function managedRuntimeStrategy(
107
+ toolName: "node" | "npm" | "npx",
108
+ deps?: StrategyDeps,
109
+ ): Strategy {
110
+ const { exists } = d(deps);
111
+ return {
112
+ name: "managed",
113
+ run(ctx): StrategyResult {
114
+ const dir = getManagedNodeBinDir(ctx.env, ctx.platform);
115
+ const isWin = ctx.platform === "win32";
116
+ const fileName =
117
+ toolName === "node"
118
+ ? isWin
119
+ ? "node.exe"
120
+ : "node"
121
+ : isWin
122
+ ? `${toolName}.cmd`
123
+ : toolName;
124
+ const candidate = path.join(dir, fileName);
125
+ if (exists(candidate)) return { ok: true, path: candidate };
126
+ return { ok: false, reason: `missing: ${candidate}` };
127
+ },
128
+ };
129
+ }
130
+
89
131
  /**
90
132
  * Managed install: `~/.pi-dashboard/node_modules/.bin/<name>(.cmd)` for
91
133
  * binaries, or any explicit relative path under `MANAGED_DIR` for