@blackbelt-technology/pi-agent-dashboard 0.4.1 → 0.4.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 (108) hide show
  1. package/AGENTS.md +79 -32
  2. package/README.md +7 -3
  3. package/docs/architecture.md +361 -12
  4. package/package.json +7 -7
  5. package/packages/extension/package.json +7 -2
  6. package/packages/extension/src/__tests__/ask-user-schema-discriminator.test.ts +141 -0
  7. package/packages/extension/src/__tests__/ask-user-tool.test.ts +51 -7
  8. package/packages/extension/src/__tests__/multiselect-dashboard-routing.test.ts +203 -0
  9. package/packages/extension/src/__tests__/multiselect-polyfill.test.ts +92 -0
  10. package/packages/extension/src/__tests__/no-tui-multiselect-arm-regression.test.ts +81 -0
  11. package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +37 -0
  12. package/packages/extension/src/__tests__/ui-decorators.test.ts +309 -0
  13. package/packages/extension/src/__tests__/ui-modules.test.ts +293 -0
  14. package/packages/extension/src/ask-user-tool.ts +165 -57
  15. package/packages/extension/src/bridge.ts +97 -4
  16. package/packages/extension/src/multiselect-decode.ts +40 -0
  17. package/packages/extension/src/multiselect-polyfill.ts +38 -8
  18. package/packages/extension/src/ui-modules.ts +272 -0
  19. package/packages/server/package.json +9 -3
  20. package/packages/server/src/__tests__/auto-attach.test.ts +61 -8
  21. package/packages/server/src/__tests__/browse-endpoint.test.ts +295 -19
  22. package/packages/server/src/__tests__/cli-bootstrap.test.ts +36 -0
  23. package/packages/server/src/__tests__/directory-service-refresh-force.test.ts +163 -0
  24. package/packages/server/src/__tests__/directory-service-specs-mtime.test.ts +315 -0
  25. package/packages/server/src/__tests__/directory-service-toctou.test.ts +303 -0
  26. package/packages/server/src/__tests__/directory-service.test.ts +174 -0
  27. package/packages/server/src/__tests__/installed-package-enricher.test.ts +225 -0
  28. package/packages/server/src/__tests__/package-manager-wrapper-move.test.ts +414 -0
  29. package/packages/server/src/__tests__/package-routes.test.ts +136 -3
  30. package/packages/server/src/__tests__/package-source-helpers.test.ts +101 -0
  31. package/packages/server/src/__tests__/pending-attach-registry.test.ts +123 -0
  32. package/packages/server/src/__tests__/pending-resume-intent-registry.test.ts +138 -0
  33. package/packages/server/src/__tests__/pi-core-checker.test.ts +73 -30
  34. package/packages/server/src/__tests__/pi-gateway-consume-pending-attach.test.ts +112 -0
  35. package/packages/server/src/__tests__/post-install-openspec-refresh.test.ts +180 -0
  36. package/packages/server/src/__tests__/post-install-rescan.test.ts +134 -0
  37. package/packages/server/src/__tests__/proposal-attach-naming.test.ts +79 -0
  38. package/packages/server/src/__tests__/session-action-handler-spawn-with-attach.test.ts +108 -0
  39. package/packages/server/src/__tests__/session-order-manager.test.ts +55 -0
  40. package/packages/server/src/__tests__/session-order-reboot.test.ts +242 -0
  41. package/packages/server/src/__tests__/session-scanner.test.ts +44 -0
  42. package/packages/server/src/__tests__/subscription-handler.test.ts +40 -0
  43. package/packages/server/src/__tests__/translate-path-source.test.ts +77 -0
  44. package/packages/server/src/__tests__/ui-decorators-replay.test.ts +209 -0
  45. package/packages/server/src/__tests__/ui-modules-replay.test.ts +221 -0
  46. package/packages/server/src/browse.ts +118 -13
  47. package/packages/server/src/browser-gateway.ts +19 -0
  48. package/packages/server/src/browser-handlers/__tests__/session-meta-handler.test.ts +183 -0
  49. package/packages/server/src/browser-handlers/directory-handler.ts +7 -1
  50. package/packages/server/src/browser-handlers/handler-context.ts +15 -0
  51. package/packages/server/src/browser-handlers/session-action-handler.ts +29 -3
  52. package/packages/server/src/browser-handlers/session-meta-handler.ts +46 -12
  53. package/packages/server/src/browser-handlers/subscription-handler.ts +46 -1
  54. package/packages/server/src/cli.ts +5 -6
  55. package/packages/server/src/directory-service.ts +156 -15
  56. package/packages/server/src/event-wiring.ts +111 -10
  57. package/packages/server/src/installed-package-enricher.ts +143 -0
  58. package/packages/server/src/package-manager-wrapper.ts +305 -8
  59. package/packages/server/src/package-source-helpers.ts +104 -0
  60. package/packages/server/src/pending-attach-registry.ts +112 -0
  61. package/packages/server/src/pending-resume-intent-registry.ts +107 -0
  62. package/packages/server/src/pi-core-checker.ts +9 -14
  63. package/packages/server/src/pi-gateway.ts +14 -0
  64. package/packages/server/src/proposal-attach-naming.ts +47 -0
  65. package/packages/server/src/routes/file-routes.ts +29 -3
  66. package/packages/server/src/routes/package-routes.ts +72 -3
  67. package/packages/server/src/routes/plugin-config-routes.ts +129 -0
  68. package/packages/server/src/routes/system-routes.ts +2 -0
  69. package/packages/server/src/server.ts +339 -10
  70. package/packages/server/src/session-api.ts +30 -5
  71. package/packages/server/src/session-order-manager.ts +22 -0
  72. package/packages/server/src/session-scanner.ts +10 -1
  73. package/packages/shared/package.json +9 -2
  74. package/packages/shared/src/__tests__/browser-protocol-types.test.ts +59 -0
  75. package/packages/shared/src/__tests__/config-plugins.test.ts +68 -0
  76. package/packages/shared/src/__tests__/extension-ui-module-shape.test.ts +265 -0
  77. package/packages/shared/src/__tests__/no-raw-openspec-status-in-skills.test.ts +81 -0
  78. package/packages/shared/src/__tests__/openspec-design-evidence.test.ts +288 -0
  79. package/packages/shared/src/__tests__/openspec-effective-status-script.test.ts +174 -0
  80. package/packages/shared/src/__tests__/openspec-poller-design-override.test.ts +225 -0
  81. package/packages/shared/src/__tests__/openspec-poller-specs-override.test.ts +284 -0
  82. package/packages/shared/src/__tests__/openspec-specs-evidence.test.ts +144 -0
  83. package/packages/shared/src/__tests__/platform/is-appimage-self-hit.test.ts +164 -0
  84. package/packages/shared/src/__tests__/plugin-bridge-register-extended.test.ts +72 -0
  85. package/packages/shared/src/__tests__/plugin-bridge-register.test.ts +113 -0
  86. package/packages/shared/src/__tests__/plugin-config-update-protocol.test.ts +41 -0
  87. package/packages/shared/src/__tests__/recommended-extensions.test.ts +5 -1
  88. package/packages/shared/src/__tests__/spawn-session-attach-proposal.test.ts +47 -0
  89. package/packages/shared/src/__tests__/tool-registry-strategies-appimage.test.ts +118 -0
  90. package/packages/shared/src/browser-protocol.ts +110 -4
  91. package/packages/shared/src/config.ts +45 -0
  92. package/packages/shared/src/dashboard-plugin/index.ts +11 -0
  93. package/packages/shared/src/dashboard-plugin/manifest-types.ts +58 -0
  94. package/packages/shared/src/dashboard-plugin/plugin-status.ts +26 -0
  95. package/packages/shared/src/dashboard-plugin/slot-props.ts +92 -0
  96. package/packages/shared/src/dashboard-plugin/slot-types.ts +151 -0
  97. package/packages/shared/src/openspec-activity-detector.ts +18 -22
  98. package/packages/shared/src/openspec-design-evidence.ts +109 -0
  99. package/packages/shared/src/openspec-poller.ts +117 -3
  100. package/packages/shared/src/openspec-specs-evidence.ts +79 -0
  101. package/packages/shared/src/platform/binary-lookup.ts +96 -1
  102. package/packages/shared/src/plugin-bridge-register.ts +139 -0
  103. package/packages/shared/src/protocol.ts +56 -2
  104. package/packages/shared/src/recommended-extensions.ts +7 -1
  105. package/packages/shared/src/rest-api.ts +68 -3
  106. package/packages/shared/src/state-replay.ts +11 -1
  107. package/packages/shared/src/tool-registry/strategies.ts +17 -3
  108. package/packages/shared/src/types.ts +160 -0
@@ -4,11 +4,106 @@
4
4
  * with a single configurable resolver.
5
5
  */
6
6
  import { execSync, spawnSync, buildSafeArgv } from "./exec.js";
7
- import { existsSync } from "node:fs";
7
+ import { existsSync, realpathSync } from "node:fs";
8
8
  import path from "node:path";
9
9
  import os from "node:os";
10
10
  import { MANAGED_BIN, MANAGED_DIR } from "../managed-paths.js";
11
11
 
12
+ // ── AppImage self-hit guard (Linux power-user mode safety) ────────────────
13
+
14
+ /**
15
+ * Optional environment overrides for {@link isAppImageSelfHit}. Tests
16
+ * inject explicit values so the helper can exercise both branches
17
+ * without mutating `process.env` or `process.execPath`. Production
18
+ * callers omit `opts` and the helper reads from the live process.
19
+ *
20
+ * See change: fix-electron-appimage-cli-self-detection (D1).
21
+ */
22
+ export interface AppImageSelfHitOpts {
23
+ /** Override `process.execPath`. Default: `process.execPath`. */
24
+ execPath?: string;
25
+ /** Override `process.env.APPDIR`. Default: `process.env.APPDIR`. */
26
+ appDir?: string | undefined;
27
+ /** Override `process.env.APPIMAGE`. Default: `process.env.APPIMAGE`. */
28
+ appImage?: string | undefined;
29
+ }
30
+
31
+ /** Defensive realpath — returns the input on any error (broken symlink / ENOENT). */
32
+ function safeRealpath(p: string): string {
33
+ try {
34
+ return realpathSync(p);
35
+ } catch {
36
+ return p;
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Returns `true` when `candidatePath` is the running process's own
42
+ * Electron launcher binary — the bug class that motivates this helper:
43
+ * AppImage's runtime prepends its squashfs mount (`/tmp/.mount_*`) to
44
+ * `PATH` of the Electron child, and `packagerConfig.executableName =
45
+ * "pi-dashboard"` makes the launcher a name-collision with the dashboard
46
+ * CLI. Trusting the first `which pi-dashboard` hit therefore spawns the
47
+ * Electron app recursively as if it were the CLI.
48
+ *
49
+ * A path is considered a self-hit when ANY of the following is true:
50
+ * - `realpath(candidatePath) === realpath(execPath)`, OR
51
+ * - `candidatePath` lives under the directory named by `appDir`, OR
52
+ * - `realpath(candidatePath) === realpath(appImage)`.
53
+ *
54
+ * `realpath` calls are wrapped in try/catch so broken symlinks / ENOENT
55
+ * fall back to literal string comparisons. The helper never throws.
56
+ *
57
+ * Production callers (`whereStrategy`, `detectPiDashboardCli`,
58
+ * `detectPi`, `detectSystemNode`) omit `opts`. Tests pass explicit
59
+ * overrides via `opts`.
60
+ *
61
+ * See change: fix-electron-appimage-cli-self-detection (D1).
62
+ */
63
+ export function isAppImageSelfHit(
64
+ candidatePath: string,
65
+ opts?: AppImageSelfHitOpts,
66
+ ): boolean {
67
+ if (!candidatePath) return false;
68
+
69
+ const execPath = opts && "execPath" in opts ? opts.execPath : process.execPath;
70
+ const appDir = opts && "appDir" in opts ? opts.appDir : process.env.APPDIR;
71
+ const appImage = opts && "appImage" in opts ? opts.appImage : process.env.APPIMAGE;
72
+
73
+ const realCandidate = safeRealpath(candidatePath);
74
+
75
+ // Rule 1: realpath equals process.execPath
76
+ if (execPath) {
77
+ const realExec = safeRealpath(execPath);
78
+ if (realCandidate === realExec) return true;
79
+ if (candidatePath === execPath) return true;
80
+ }
81
+
82
+ // Rule 2: candidate lives under APPDIR (the AppImage squashfs mount).
83
+ // We compare the candidate's realpath against APPDIR's realpath so a
84
+ // symlink under the mount is still recognized as a self-hit.
85
+ if (appDir) {
86
+ const realAppDir = safeRealpath(appDir);
87
+ const sep = path.sep;
88
+ // Append separator so /tmp/.mount_PI doesn't accidentally match
89
+ // /tmp/.mount_PIxx-elsewhere via prefix.
90
+ const prefix = realAppDir.endsWith(sep) ? realAppDir : realAppDir + sep;
91
+ if (realCandidate === realAppDir || realCandidate.startsWith(prefix)) return true;
92
+ // Literal fallback (broken symlinks / ENOENT keep a useful answer).
93
+ const litPrefix = appDir.endsWith(sep) ? appDir : appDir + sep;
94
+ if (candidatePath === appDir || candidatePath.startsWith(litPrefix)) return true;
95
+ }
96
+
97
+ // Rule 3: realpath equals APPIMAGE (the .AppImage file the user clicked).
98
+ if (appImage) {
99
+ const realAppImage = safeRealpath(appImage);
100
+ if (realCandidate === realAppImage) return true;
101
+ if (candidatePath === appImage) return true;
102
+ }
103
+
104
+ return false;
105
+ }
106
+
12
107
  /**
13
108
  * Well-known globalThis symbol for the default `ToolRegistry`.
14
109
  *
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Plugin bridge entry management in pi's settings.json.
3
+ *
4
+ * Manages `dashboard-<plugin-id>` keys in a dedicated
5
+ * `dashboardPluginBridges` object inside settings.json.
6
+ *
7
+ * Rules:
8
+ * - Only touches entries under the `dashboardPluginBridges` key.
9
+ * - NEVER modifies user-owned `packages[]` entries.
10
+ * - Uses atomic write (tmp + rename) for all updates.
11
+ * - Detects path conflicts (existing entry with mismatched path).
12
+ */
13
+ import fs from "node:fs";
14
+ import path from "node:path";
15
+ import os from "node:os";
16
+
17
+ export interface PluginBridgeRegisterOptions {
18
+ homedir?: string;
19
+ }
20
+
21
+ export type PluginBridgeConflict =
22
+ | { type: "ok" }
23
+ | { type: "conflict"; existingPath: string; newPath: string };
24
+
25
+ function getSettingsPath(homedir?: string): string {
26
+ const home = homedir ?? process.env.HOME ?? process.env.USERPROFILE ?? os.homedir();
27
+ return path.join(home, ".pi", "agent", "settings.json");
28
+ }
29
+
30
+ function readSettings(settingsPath: string): Record<string, unknown> {
31
+ try {
32
+ if (!fs.existsSync(settingsPath)) return {};
33
+ const raw = fs.readFileSync(settingsPath, "utf-8").trim();
34
+ if (!raw) return {};
35
+ return JSON.parse(raw);
36
+ } catch {
37
+ return {};
38
+ }
39
+ }
40
+
41
+ function writeSettings(settingsPath: string, settings: Record<string, unknown>): void {
42
+ fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
43
+ const tmp = settingsPath + ".tmp." + process.pid;
44
+ fs.writeFileSync(tmp, JSON.stringify(settings, null, 2) + "\n");
45
+ fs.renameSync(tmp, settingsPath);
46
+ }
47
+
48
+ function getManagedBridges(
49
+ settings: Record<string, unknown>,
50
+ ): Record<string, string> {
51
+ const val = settings.dashboardPluginBridges;
52
+ if (val && typeof val === "object" && !Array.isArray(val)) {
53
+ return val as Record<string, string>;
54
+ }
55
+ return {};
56
+ }
57
+
58
+ const MANAGED_PREFIX = "dashboard-";
59
+
60
+ /**
61
+ * Register a plugin's bridge entry in pi's settings.json.
62
+ *
63
+ * Returns { type: "conflict", existingPath, newPath } if a
64
+ * `dashboard-<pluginId>` key already exists but points to a different path.
65
+ * In that case the settings.json is NOT modified.
66
+ *
67
+ * Returns { type: "ok" } on success (including when the entry already matches).
68
+ */
69
+ export function registerPluginBridge(
70
+ pluginId: string,
71
+ bridgePath: string,
72
+ opts: PluginBridgeRegisterOptions = {},
73
+ ): PluginBridgeConflict {
74
+ const settingsPath = getSettingsPath(opts.homedir);
75
+ const settings = readSettings(settingsPath);
76
+ const managed = getManagedBridges(settings);
77
+ const key = MANAGED_PREFIX + pluginId;
78
+
79
+ const existing = managed[key];
80
+ if (existing) {
81
+ if (existing === bridgePath) return { type: "ok" }; // already registered
82
+ return { type: "conflict", existingPath: existing, newPath: bridgePath };
83
+ }
84
+
85
+ managed[key] = bridgePath;
86
+ settings.dashboardPluginBridges = managed;
87
+ writeSettings(settingsPath, settings);
88
+ console.info(`[plugin-bridge] Registered bridge for plugin "${pluginId}": ${bridgePath}`);
89
+ return { type: "ok" };
90
+ }
91
+
92
+ /**
93
+ * Remove a plugin's bridge entry from pi's settings.json.
94
+ * No-op if the entry does not exist.
95
+ * NEVER touches entries without the `dashboard-` prefix.
96
+ */
97
+ export function deregisterPluginBridge(
98
+ pluginId: string,
99
+ opts: PluginBridgeRegisterOptions = {},
100
+ ): void {
101
+ const settingsPath = getSettingsPath(opts.homedir);
102
+ const settings = readSettings(settingsPath);
103
+ const managed = getManagedBridges(settings);
104
+ const key = MANAGED_PREFIX + pluginId;
105
+
106
+ if (!(key in managed)) return; // nothing to remove
107
+
108
+ delete managed[key];
109
+ settings.dashboardPluginBridges = managed;
110
+ writeSettings(settingsPath, settings);
111
+ console.info(`[plugin-bridge] Deregistered bridge for plugin "${pluginId}"`);
112
+ }
113
+
114
+ /**
115
+ * Register all plugins with bridge entries from the discovery list.
116
+ * Returns a map of pluginId → conflict/ok result.
117
+ * Plugins with conflicts are NOT registered; caller should surface via /api/health.
118
+ */
119
+ export function registerAllPluginBridges(
120
+ plugins: Array<{ pluginId: string; bridgePath: string }>,
121
+ opts: PluginBridgeRegisterOptions = {},
122
+ ): Record<string, PluginBridgeConflict> {
123
+ const results: Record<string, PluginBridgeConflict> = {};
124
+ for (const { pluginId, bridgePath } of plugins) {
125
+ results[pluginId] = registerPluginBridge(pluginId, bridgePath, opts);
126
+ }
127
+ return results;
128
+ }
129
+
130
+ /**
131
+ * List all currently managed plugin bridge entries.
132
+ */
133
+ export function listManagedBridges(
134
+ opts: PluginBridgeRegisterOptions = {},
135
+ ): Record<string, string> {
136
+ const settingsPath = getSettingsPath(opts.homedir);
137
+ const settings = readSettings(settingsPath);
138
+ return getManagedBridges(settings);
139
+ }
@@ -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 } from "./types.js";
4
+ import type { DashboardEvent, CommandInfo, FlowInfo, SessionSource, ImageContent, FileEntry, TurnUsage, ContextUsage, ModelInfo, PiSessionInfo, OpenSpecPhase, RoleInfo, ExtensionUiModule, DecoratorDescriptor } from "./types.js";
5
5
 
6
6
  // ── Extension → Server ──────────────────────────────────────────────
7
7
 
@@ -228,6 +228,39 @@ export interface ProcessListMessage {
228
228
 
229
229
  // LoadSessionEventsResultMessage and LoadSessionEventsErrorMessage removed — server loads directly
230
230
 
231
+ // ── Extension UI System (Phase 1) ──
232
+ // Pull-discovered, schema-driven UI modules. See change: add-extension-ui-modal.
233
+
234
+ export interface UiModulesListMessage {
235
+ type: "ui_modules_list";
236
+ sessionId: string;
237
+ modules: ExtensionUiModule[];
238
+ }
239
+
240
+ export interface UiDataListMessage {
241
+ type: "ui_data_list";
242
+ sessionId: string;
243
+ /** Matches some `module.view.dataEvent`. */
244
+ event: string;
245
+ items: unknown[];
246
+ }
247
+
248
+ // ── Extension UI System (Phase 2: live in-page decorations) ──
249
+ // See change: add-extension-ui-decorations.
250
+
251
+ /**
252
+ * Extension → server: a single live decorator descriptor. Cache key
253
+ * `${kind}:${namespace}:${id}` MUST be unique within a session; `removed: true`
254
+ * deletes the cache entry instead of upserting.
255
+ */
256
+ export interface ExtUiDecoratorMessage {
257
+ type: "ext_ui_decorator";
258
+ sessionId: string;
259
+ descriptor: DecoratorDescriptor;
260
+ /** When true, server deletes the cached descriptor under the matching key. */
261
+ removed?: boolean;
262
+ }
263
+
231
264
  export type ExtensionToServerMessage =
232
265
  | SessionRegisterMessage
233
266
  | SessionUnregisterMessage
@@ -250,7 +283,10 @@ export type ExtensionToServerMessage =
250
283
  | FirstMessageUpdateMessage
251
284
  | RolesListMessage
252
285
  | SpawnNewSessionMessage
253
- | ProcessListMessage;
286
+ | ProcessListMessage
287
+ | UiModulesListMessage
288
+ | UiDataListMessage
289
+ | ExtUiDecoratorMessage;
254
290
 
255
291
  // ── Server → Extension ──────────────────────────────────────────────
256
292
 
@@ -401,6 +437,23 @@ export interface ExtensionUiResponseMessage {
401
437
  cancelled?: boolean;
402
438
  }
403
439
 
440
+ /**
441
+ * Server → extension: a browser invoked an action / requested data on a
442
+ * Phase-1 management-modal module. The bridge re-emits this on `pi.events`
443
+ * as `pi.events.emit(msg.event, { ...msg.params, action: msg.action, _reply })`.
444
+ * Extensions either populate `data.items` synchronously (for `action: "list"`
445
+ * data fetches) or perform side-effects and emit `ui:invalidate` to refresh.
446
+ */
447
+ export interface UiManagementMessage {
448
+ type: "ui_management";
449
+ sessionId: string;
450
+ /** Action id (matches some `UiAction.id`) or `"list"` for data fetch. */
451
+ action: string;
452
+ /** Event name to emit (matches `view.dataEvent` or `UiAction.event`). */
453
+ event: string;
454
+ params?: Record<string, unknown>;
455
+ }
456
+
404
457
  export interface PromptResponseServerMessage {
405
458
  type: "prompt_response";
406
459
  sessionId: string;
@@ -435,4 +488,5 @@ export type ServerToExtensionMessage =
435
488
  | RolePresetSaveExtensionMessage
436
489
  | RolePresetDeleteExtensionMessage
437
490
  | RequestRolesMessage
491
+ | UiManagementMessage
438
492
  | KillProcessMessage;
@@ -180,7 +180,13 @@ export const RECOMMENDED_EXTENSIONS: readonly RecommendedExtension[] = [
180
180
  */
181
181
  export const BUNDLED_EXTENSION_IDS: readonly string[] = [
182
182
  "pi-anthropic-messages",
183
- "pi-flows",
183
+ // "pi-flows" is intentionally NOT bundled until the upstream repo declares
184
+ // an SPDX-conformant license (`LICENSE` file or `package.json#license`).
185
+ // The bundle-recommended-extensions.sh license allowlist enforcement
186
+ // (MIT/Apache-2.0/BSD-2-Clause/BSD-3-Clause/ISC) correctly rejects it.
187
+ // Re-add this entry once https://github.com/BlackBeltTechnology/pi-flows
188
+ // has a license declared. See: openspec/changes/archive/
189
+ // 2026-04-21-bundle-first-party-extensions/design.md §"License blockers".
184
190
  ];
185
191
 
186
192
  /** Retrieve a recommended entry by id, or `undefined`. */
@@ -64,17 +64,35 @@ export type FileReadResponse = ApiResponse<FileReadResult>;
64
64
  export interface BrowseEntry {
65
65
  name: string;
66
66
  path: string;
67
- isGit: boolean;
68
- isPi: boolean;
67
+ /**
68
+ * Set only when the request used `detect=1`. When the response was
69
+ * produced without `detect=1`, this field is absent (undefined) —
70
+ * meaning "not classified", NOT "classified as not-git". Consumers
71
+ * that need badges SHOULD call `GET /api/browse/flags` to fill in
72
+ * the flags lazily.
73
+ *
74
+ * See change: split-browse-flags.
75
+ */
76
+ isGit?: boolean;
77
+ /** See `isGit` — same opt-in / detect-gated semantics. */
78
+ isPi?: boolean;
69
79
  }
70
80
 
71
81
  /**
72
- * Response shape for `GET /api/browse?path=<dir>&q=<query>`.
82
+ * Response shape for `GET /api/browse?path=<dir>&q=<query>&detect=<0|1>`.
73
83
  *
74
84
  * The optional `q` query parameter, when present and non-empty, causes the
75
85
  * server to filter entries by case-insensitive substring on `name` and rank
76
86
  * them (exact → prefix → word-boundary → substring) before the 200-entry cap.
77
87
  * When omitted or whitespace-only, entries are sorted alphabetically.
88
+ *
89
+ * The optional `detect` query parameter (only the literal string `"1"` is
90
+ * truthy) opts into eager `.git` / `.pi` classification on every entry. When
91
+ * absent (the default), per-entry `isGit` / `isPi` are omitted and no
92
+ * filesystem probes run — use the bulk `GET /api/browse/flags` endpoint to
93
+ * classify entries lazily.
94
+ *
95
+ * See change: split-browse-flags.
78
96
  */
79
97
  export interface BrowseResult {
80
98
  entries: BrowseEntry[];
@@ -94,6 +112,43 @@ export interface BrowseResult {
94
112
 
95
113
  export type BrowseResponse = ApiResponse<BrowseResult>;
96
114
 
115
+ // ── Browse flags (bulk classifier) ──────────────────────────────────
116
+
117
+ /**
118
+ * Per-path classification record returned by `GET /api/browse/flags`.
119
+ * Booleans only — any probe failure (ENOENT, EACCES, ELOOP, race-on-
120
+ * deletion, …) maps to `false` for that flag, never an error.
121
+ *
122
+ * See change: split-browse-flags.
123
+ */
124
+ export interface BrowseFlagEntry {
125
+ isGit: boolean;
126
+ isPi: boolean;
127
+ }
128
+
129
+ /**
130
+ * Wire shape passed via the `paths` query parameter on
131
+ * `GET /api/browse/flags?paths=<json-array>`. The value MUST be a
132
+ * URL-encoded JSON array of absolute path strings (length ≤ 100).
133
+ * Provided here for type-only documentation — the request itself is a
134
+ * GET, so this interface is not serialized as a body.
135
+ */
136
+ export interface BrowseFlagsRequest {
137
+ paths: string[];
138
+ }
139
+
140
+ /** Successful response payload for `GET /api/browse/flags`. */
141
+ export interface BrowseFlagsResult {
142
+ /**
143
+ * Map keyed by the absolute paths that were requested. The key set
144
+ * SHALL equal the input `paths` set — one classification per input
145
+ * path, no extras, no omissions.
146
+ */
147
+ flags: Record<string, BrowseFlagEntry>;
148
+ }
149
+
150
+ export type BrowseFlagsResponse = ApiResponse<BrowseFlagsResult>;
151
+
97
152
  /** Request body for `POST /api/browse/mkdir`. */
98
153
  export interface MkdirRequest {
99
154
  parent: string;
@@ -254,6 +309,16 @@ export interface InstalledPackage {
254
309
  installedPath?: string;
255
310
  /** Set after check-updates: true if newer version available */
256
311
  updateAvailable?: boolean;
312
+ /** Version read from `<installedPath>/package.json#version`. Undefined if missing/unreadable. */
313
+ version?: string;
314
+ /** Description read from `<installedPath>/package.json#description`. */
315
+ description?: string;
316
+ /** Friendly name. From RECOMMENDED_EXTENSIONS displayName when matched, else basename of source. */
317
+ displayName?: string;
318
+ /** True when this row matches a RECOMMENDED_EXTENSIONS entry (via sourcesMatch). */
319
+ isRecommended?: boolean;
320
+ /** True when isRecommended AND id is in BUNDLED_EXTENSION_IDS AND bundle subtree exists. */
321
+ isBundled?: boolean;
257
322
  }
258
323
 
259
324
  export type InstalledPackagesResponse = ApiResponse<InstalledPackage[]>;
@@ -23,9 +23,19 @@ import type { EventForwardMessage } from "./protocol.js";
23
23
  * pi 0.69+, where the bridge sees `message_start` before pi has assigned
24
24
  * the entry id. Replay has no such gap.
25
25
  */
26
+ /**
27
+ * @param knownContextWindow Optional override for the context window size,
28
+ * typically `session.contextWindow` from `.meta.json` (which was persisted
29
+ * from a live `turn_end` event). When provided, it is used in place of the
30
+ * `inferContextWindow(modelId)` heuristic for every synthesized
31
+ * `stats_update` event. The heuristic ignores Sonnet's 1M variant and
32
+ * pins Claude to 200k, so passing the persisted value avoids a brief
33
+ * 200k flicker on reload before the next live `turn_end` arrives.
34
+ */
26
35
  export function replayEntriesAsEvents(
27
36
  sessionId: string,
28
37
  entries: any[],
38
+ knownContextWindow?: number,
29
39
  ): EventForwardMessage[] {
30
40
  const messages: EventForwardMessage[] = [];
31
41
  const openToolCalls = new Set<string>(); // track tool calls without results
@@ -86,7 +96,7 @@ export function replayEntriesAsEvents(
86
96
  if (totalTokens && totalTokens > 0) {
87
97
  statsData.contextUsage = {
88
98
  tokens: totalTokens,
89
- contextWindow: inferContextWindow(currentModel),
99
+ contextWindow: knownContextWindow ?? inferContextWindow(currentModel),
90
100
  };
91
101
  }
92
102
  messages.push(makeEvent(sessionId, "stats_update", ts, statsData));
@@ -11,7 +11,7 @@
11
11
  import { existsSync } from "node:fs";
12
12
  import { createRequire } from "node:module";
13
13
  import path from "node:path";
14
- import { ToolResolver } from "../platform/binary-lookup.js";
14
+ import { ToolResolver, isAppImageSelfHit } from "../platform/binary-lookup.js";
15
15
  import { getManagedBin, getManagedDir } from "../managed-paths.js";
16
16
  import * as npm from "../platform/npm.js";
17
17
  import type { Strategy, StrategyCtx, StrategyResult } from "./types.js";
@@ -152,6 +152,17 @@ export function npmGlobalStrategy(
152
152
  /**
153
153
  * PATH search via `ToolResolver.which()`. This is the plain-old "is it
154
154
  * on PATH" strategy and should appear last in most chains.
155
+ *
156
+ * Filters AppImage self-hits via `isAppImageSelfHit` — when the host
157
+ * runs as a Linux AppImage with `executableName: "pi-dashboard"`, the
158
+ * AppImage runtime prepends its squashfs mount to PATH, so the first
159
+ * `which pi-dashboard` hit can be the Electron launcher itself.
160
+ * Trusting that result spawns the Electron app recursively as if it
161
+ * were the dashboard CLI, which never opens the dashboard port and
162
+ * causes the loading screen to hang. Every tool registered via
163
+ * `whereStrategy` inherits this guard transparently.
164
+ *
165
+ * See change: fix-electron-appimage-cli-self-detection (D2).
155
166
  */
156
167
  export function whereStrategy(binaryName: string, deps?: StrategyDeps): Strategy {
157
168
  const { which } = d(deps);
@@ -159,8 +170,11 @@ export function whereStrategy(binaryName: string, deps?: StrategyDeps): Strategy
159
170
  name: "where",
160
171
  run(): StrategyResult {
161
172
  const p = which(binaryName);
162
- if (p) return { ok: true, path: p };
163
- return { ok: false, reason: `not found on PATH` };
173
+ if (!p) return { ok: false, reason: `not found on PATH` };
174
+ if (isAppImageSelfHit(p)) {
175
+ return { ok: false, reason: `appimage-self-hit: ${p}` };
176
+ }
177
+ return { ok: true, path: p };
164
178
  },
165
179
  };
166
180
  }
@@ -61,8 +61,168 @@ export interface DashboardSession {
61
61
  /** Timestamp when metrics were last received */
62
62
  updatedAt: number;
63
63
  };
64
+ /** Extension-declared UI modules (Phase 1: management-modal slot). */
65
+ uiModules?: ExtensionUiModule[];
66
+ /** Cached row data per `view.dataEvent` for table/grid views. Per-event item cap is enforced server-side. */
67
+ uiDataMap?: Record<string, unknown[]>;
68
+ /**
69
+ * Phase-2 live in-page decorations (footer-segment, agent-metric, breadcrumb,
70
+ * gate, toast). Keyed by `${kind}:${namespace}:${id}`. Last-write-wins on
71
+ * upsert; explicit removal via `ext_ui_decorator { removed: true }` deletes
72
+ * the entry. See change: add-extension-ui-decorations.
73
+ */
74
+ uiDecorators?: Record<string, DecoratorDescriptor>;
75
+ }
76
+
77
+ // ── Extension UI System (Phase 1: management-modal slot) ───────────
78
+ // Per `extension-ui-system` design + `add-extension-ui-modal` change.
79
+ // Field/type names match PR #15 verbatim so any later archival diff stays small.
80
+
81
+ export type UiViewKind = "table" | "grid" | "form";
82
+
83
+ export type UiFieldKind =
84
+ | "text"
85
+ | "number"
86
+ | "boolean"
87
+ | "select"
88
+ | "code"
89
+ | "datetime"
90
+ | "textarea";
91
+
92
+ export interface UiField {
93
+ /** Dot-path into row / form-state. */
94
+ key: string;
95
+ label: string;
96
+ kind: UiFieldKind;
97
+ /** For kind: "select". */
98
+ options?: string[];
99
+ placeholder?: string;
100
+ required?: boolean;
101
+ readOnly?: boolean;
102
+ /** Legacy alias for kind: "textarea". Prefer `kind: "textarea"`. */
103
+ multiline?: boolean;
104
+ /** Display-only: table column width. */
105
+ width?: string | number;
106
+ /** For kind: "code". Hint to syntax highlighter. */
107
+ language?: string;
64
108
  }
65
109
 
110
+ export interface UiAction {
111
+ /** Action id, echoed back as the `action` field on the `ui_management` message. */
112
+ id: string;
113
+ label: string;
114
+ /** MDI icon key from `@mdi/js` (e.g. `"mdiCheckCircle"`). Unknown keys render no icon. */
115
+ icon?: string;
116
+ variant?: "primary" | "secondary" | "danger";
117
+ /** Event name re-emitted on the extension's `pi.events` bus when the action fires. */
118
+ event: string;
119
+ params?: Record<string, unknown>;
120
+ /** If present, dashboard mounts ConfirmDialog with this message before dispatching. */
121
+ confirm?: string;
122
+ }
123
+
124
+ export interface UiSection {
125
+ id: string;
126
+ title?: string;
127
+ description?: string;
128
+ fields: UiField[];
129
+ }
130
+
131
+ export interface UiView {
132
+ kind: UiViewKind;
133
+ /** Table/grid columns; form fields when no `sections` provided. */
134
+ fields?: UiField[];
135
+ /** For form view: grouped fields. Mutually exclusive with top-level `fields`. */
136
+ sections?: UiSection[];
137
+ /** Event name to request rows; required for `table`/`grid`. */
138
+ dataEvent?: string;
139
+ /** Unique-row field for `table`/`grid` (default: `"id"`). */
140
+ rowKey?: string;
141
+ /** Per-row actions for `table`/`grid`. */
142
+ rowActions?: UiAction[];
143
+ /** Shown when `items.length === 0`. */
144
+ emptyState?: string;
145
+ /** Top-of-modal toolbar actions. */
146
+ actions?: UiAction[];
147
+ }
148
+
149
+ export interface ExtensionUiModule {
150
+ /** Phase 1: only `"management-modal"`. */
151
+ kind: "management-modal";
152
+ /** Unique within the session. Last-write-wins on collision. */
153
+ id: string;
154
+ /** Exact slash command (case-sensitive). */
155
+ command: string;
156
+ title: string;
157
+ description?: string;
158
+ /** MDI icon key from `@mdi/js`. */
159
+ icon?: string;
160
+ /** Free-form group label (sidebar grouping in future). */
161
+ category?: string;
162
+ view: UiView;
163
+ }
164
+
165
+ // ── Extension UI System (Phase 2: live in-page decorations) ──────
166
+ // Per `extension-ui-system` design + `add-extension-ui-decorations` change.
167
+ // Single discriminated union forwarded as one `ext_ui_decorator` message per
168
+ // descriptor. Cache key: `${kind}:${namespace}:${id}`. `namespace` MUST match
169
+ // `/^[a-z0-9-]+$/`; the bridge drops malformed namespaces with a warning.
170
+
171
+ export type DecoratorKind =
172
+ | "footer-segment"
173
+ | "agent-metric"
174
+ | "breadcrumb"
175
+ | "gate"
176
+ | "toast";
177
+
178
+ export interface FooterSegmentPayload {
179
+ text: string;
180
+ tooltip?: string;
181
+ /** MDI icon key from `@mdi/js`. Unknown keys render no icon. */
182
+ icon?: string;
183
+ }
184
+
185
+ export interface AgentMetricPayload {
186
+ /** Matches the agent id rendered by `FlowAgentCard`. */
187
+ agentId: string;
188
+ text: string;
189
+ tooltip?: string;
190
+ }
191
+
192
+ export interface BreadcrumbStep {
193
+ id: string;
194
+ label: string;
195
+ status: "pending" | "active" | "done" | "error";
196
+ }
197
+
198
+ export interface BreadcrumbPayload {
199
+ steps: BreadcrumbStep[];
200
+ /** Step id of the currently-active step (overrides `status: "active"` selection). */
201
+ current?: string;
202
+ }
203
+
204
+ export interface GatePayload {
205
+ /** Matches the flow id rendered in `FlowLaunchDialog`. */
206
+ flowId: string;
207
+ available: boolean;
208
+ /** Reason rendered as a tooltip when `available: false`. */
209
+ reason?: string;
210
+ }
211
+
212
+ export interface ToastPayload {
213
+ level: "info" | "success" | "warn" | "error";
214
+ message: string;
215
+ /** Auto-dismiss after this many ms. Default 5000; `0` = sticky. */
216
+ durationMs?: number;
217
+ }
218
+
219
+ export type DecoratorDescriptor =
220
+ | { kind: "footer-segment"; namespace: string; id: string; payload: FooterSegmentPayload }
221
+ | { kind: "agent-metric"; namespace: string; id: string; payload: AgentMetricPayload }
222
+ | { kind: "breadcrumb"; namespace: string; id: string; payload: BreadcrumbPayload }
223
+ | { kind: "gate"; namespace: string; id: string; payload: GatePayload }
224
+ | { kind: "toast"; namespace: string; id: string; payload: ToastPayload };
225
+
66
226
  /** An event forwarded from a pi session */
67
227
  export interface DashboardEvent {
68
228
  eventType: string;