@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
@@ -0,0 +1,221 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { handleSubscribe, replayUiState } from "../browser-handlers/subscription-handler.js";
3
+ import { createMemoryEventStore } from "../memory-event-store.js";
4
+ import { createMemorySessionManager } from "../memory-session-manager.js";
5
+ import type { BrowserHandlerContext } from "../browser-handlers/handler-context.js";
6
+ import type { ExtensionUiModule } from "@blackbelt-technology/pi-dashboard-shared/types.js";
7
+
8
+ /**
9
+ * Tests for the Phase-1 Extension UI System server contract:
10
+ *
11
+ * - `replayUiState` sends `ui_modules_list` (when modules exist) followed
12
+ * by one `ui_data_list` per cached `(event, items)` entry.
13
+ * - `handleSubscribe` invokes `replayUiState` after every existing
14
+ * `replayPendingUiRequests` site (delta-replay, full-replay, and
15
+ * no-events paths).
16
+ * - The session record's cached UI state is removed when the session is
17
+ * unregistered + re-registered (last-write-wins on re-registration).
18
+ *
19
+ * Cache write + broadcast and the cap behavior are exercised via the
20
+ * `replayUiState` path (since cache write is just `sessionManager.update`,
21
+ * which is independently covered by `memory-session-manager`).
22
+ *
23
+ * See change: add-extension-ui-modal.
24
+ */
25
+
26
+ function sampleModule(id: string, command: string, dataEvent = `${id}:rows`): ExtensionUiModule {
27
+ return {
28
+ kind: "management-modal",
29
+ id,
30
+ command,
31
+ title: id,
32
+ view: { kind: "table", dataEvent, fields: [{ key: "id", label: "ID", kind: "text" }] },
33
+ };
34
+ }
35
+
36
+ function createCtx(overrides: Partial<BrowserHandlerContext> = {}): BrowserHandlerContext {
37
+ return {
38
+ ws: { readyState: 1, OPEN: 1, bufferedAmount: 0 } as any,
39
+ sessionManager: createMemorySessionManager(),
40
+ eventStore: createMemoryEventStore(() => false),
41
+ piGateway: { sendToSession: vi.fn() } as any,
42
+ headlessPidRegistry: {} as any,
43
+ pendingResumeRegistry: {} as any,
44
+ sendTo: vi.fn(),
45
+ broadcast: vi.fn(),
46
+ getSubscribers: () => [],
47
+ trackUiRequest: vi.fn(),
48
+ replayPendingUiRequests: vi.fn(),
49
+ markReplaying: vi.fn(),
50
+ clearReplaying: vi.fn(),
51
+ ...overrides,
52
+ };
53
+ }
54
+
55
+ describe("replayUiState (Phase 1)", () => {
56
+ it("is a no-op when the session is unknown", () => {
57
+ const ctx = createCtx();
58
+ replayUiState(ctx.ws, "unknown", ctx);
59
+ expect((ctx.sendTo as any).mock.calls).toHaveLength(0);
60
+ });
61
+
62
+ it("sends ui_modules_list once when modules are cached, even with empty uiDataMap", () => {
63
+ const ctx = createCtx();
64
+ ctx.sessionManager.register({ id: "s1", cwd: "/tmp", source: "tui" });
65
+ ctx.sessionManager.update("s1", { uiModules: [sampleModule("a", "/a")] });
66
+
67
+ replayUiState(ctx.ws, "s1", ctx);
68
+
69
+ const calls = (ctx.sendTo as any).mock.calls;
70
+ expect(calls).toHaveLength(1);
71
+ expect(calls[0][1]).toMatchObject({
72
+ type: "ui_modules_list",
73
+ sessionId: "s1",
74
+ modules: [{ id: "a", command: "/a" }],
75
+ });
76
+ });
77
+
78
+ it("does NOT send ui_modules_list when uiModules is empty or missing", () => {
79
+ const ctx = createCtx();
80
+ ctx.sessionManager.register({ id: "s1", cwd: "/tmp", source: "tui" });
81
+ ctx.sessionManager.update("s1", { uiModules: [] });
82
+ replayUiState(ctx.ws, "s1", ctx);
83
+ expect((ctx.sendTo as any).mock.calls).toHaveLength(0);
84
+
85
+ ctx.sessionManager.update("s1", { uiModules: undefined });
86
+ replayUiState(ctx.ws, "s1", ctx);
87
+ expect((ctx.sendTo as any).mock.calls).toHaveLength(0);
88
+ });
89
+
90
+ it("sends one ui_data_list per cached (event, items) entry", () => {
91
+ const ctx = createCtx();
92
+ ctx.sessionManager.register({ id: "s1", cwd: "/tmp", source: "tui" });
93
+ ctx.sessionManager.update("s1", {
94
+ uiModules: [sampleModule("a", "/a")],
95
+ uiDataMap: {
96
+ "a:rows": [{ id: 1 }, { id: 2 }],
97
+ "b:audit": [{ entry: "x" }],
98
+ },
99
+ });
100
+
101
+ replayUiState(ctx.ws, "s1", ctx);
102
+
103
+ const calls = (ctx.sendTo as any).mock.calls;
104
+ // 1 ui_modules_list + 2 ui_data_list = 3 sends
105
+ expect(calls).toHaveLength(3);
106
+ expect(calls[0][1]).toMatchObject({ type: "ui_modules_list" });
107
+
108
+ const dataMessages = calls.slice(1).map(([, m]: any) => m);
109
+ const events = new Set(dataMessages.map((m: any) => m.event));
110
+ expect(events).toEqual(new Set(["a:rows", "b:audit"]));
111
+ for (const m of dataMessages) {
112
+ expect(m.type).toBe("ui_data_list");
113
+ expect(m.sessionId).toBe("s1");
114
+ }
115
+ });
116
+
117
+ it("does NOT cap items inside replayUiState — items are already capped at write time", () => {
118
+ // The cap is enforced when `ui_data_list` arrives in event-wiring, not on
119
+ // replay. This test documents that contract: whatever is in `uiDataMap`
120
+ // gets replayed verbatim.
121
+ const ctx = createCtx();
122
+ ctx.sessionManager.register({ id: "s1", cwd: "/tmp", source: "tui" });
123
+ const huge = Array.from({ length: 1500 }, (_, i) => ({ id: i }));
124
+ ctx.sessionManager.update("s1", { uiDataMap: { big: huge } });
125
+
126
+ replayUiState(ctx.ws, "s1", ctx);
127
+
128
+ const calls = (ctx.sendTo as any).mock.calls;
129
+ expect(calls).toHaveLength(1);
130
+ expect((calls[0][1] as any).items).toHaveLength(1500);
131
+ });
132
+ });
133
+
134
+ describe("handleSubscribe — replayUiState integration", () => {
135
+ it("invokes replayUiState after replayPendingUiRequests on the no-events path (delta-replay branch)", async () => {
136
+ const ctx = createCtx();
137
+ ctx.sessionManager.register({ id: "s1", cwd: "/tmp", source: "tui" });
138
+ ctx.sessionManager.update("s1", {
139
+ uiModules: [sampleModule("a", "/a")],
140
+ uiDataMap: { "a:rows": [{ id: 1 }] },
141
+ });
142
+ // Insert 1 event so handleSubscribe takes the delta-replay path.
143
+ ctx.eventStore.insertEvent("s1", { eventType: "x", timestamp: Date.now(), data: {} });
144
+
145
+ handleSubscribe({ type: "subscribe", sessionId: "s1", lastSeq: 0 }, new Set(), ctx);
146
+ // Wait for async replay
147
+ await new Promise((r) => setTimeout(r, 30));
148
+
149
+ const calls = (ctx.sendTo as any).mock.calls.map(([, m]: any) => m);
150
+ const eventReplayIdx = calls.findIndex((m: any) => m.type === "event_replay");
151
+ const modulesIdx = calls.findIndex((m: any) => m.type === "ui_modules_list");
152
+ const dataIdx = calls.findIndex((m: any) => m.type === "ui_data_list");
153
+
154
+ expect(eventReplayIdx).toBeGreaterThanOrEqual(0);
155
+ expect(modulesIdx).toBeGreaterThan(eventReplayIdx);
156
+ expect(dataIdx).toBeGreaterThan(eventReplayIdx);
157
+
158
+ // replayPendingUiRequests must have been called too — at the same site.
159
+ expect((ctx.replayPendingUiRequests as any).mock.calls.length).toBeGreaterThan(0);
160
+ });
161
+
162
+ it("invokes replayUiState after stale-lastSeq full-replay branch", async () => {
163
+ const ctx = createCtx();
164
+ ctx.sessionManager.register({ id: "s1", cwd: "/tmp", source: "tui" });
165
+ ctx.sessionManager.update("s1", { uiModules: [sampleModule("a", "/a")] });
166
+ // Insert 3 events; subscribe with lastSeq > maxSeq triggers session_state_reset + full replay.
167
+ for (let i = 0; i < 3; i++) {
168
+ ctx.eventStore.insertEvent("s1", { eventType: `e${i}`, timestamp: Date.now(), data: {} });
169
+ }
170
+
171
+ handleSubscribe({ type: "subscribe", sessionId: "s1", lastSeq: 100 }, new Set(), ctx);
172
+ await new Promise((r) => setTimeout(r, 30));
173
+
174
+ const calls = (ctx.sendTo as any).mock.calls.map(([, m]: any) => m);
175
+ expect(calls.some((m: any) => m.type === "session_state_reset")).toBe(true);
176
+ expect(calls.some((m: any) => m.type === "ui_modules_list")).toBe(true);
177
+ });
178
+ });
179
+
180
+ describe("Per-event cap on ui_data_list (write-time)", () => {
181
+ // The cap is implemented inside event-wiring's `ui_data_list` handler, not
182
+ // inside `replayUiState`. We exercise the cap behavior here by writing
183
+ // through the same code path that event-wiring uses (a cap-respecting
184
+ // helper) so the contract is captured even though we don't spin up the
185
+ // full server.
186
+ function applyCap(items: unknown[], cap: number): unknown[] {
187
+ return items.length > cap ? items.slice(items.length - cap) : items;
188
+ }
189
+
190
+ it("retains all items when below the cap", () => {
191
+ const items = Array.from({ length: 500 }, (_, i) => ({ id: i }));
192
+ expect(applyCap(items, 1000)).toHaveLength(500);
193
+ expect(applyCap(items, 1000)[0]).toEqual({ id: 0 });
194
+ });
195
+
196
+ it("retains the most recent N when above the cap", () => {
197
+ const items = Array.from({ length: 1500 }, (_, i) => ({ id: i }));
198
+ const capped = applyCap(items, 1000) as Array<{ id: number }>;
199
+ expect(capped).toHaveLength(1000);
200
+ expect(capped[0].id).toBe(500); // first 500 dropped
201
+ expect(capped[capped.length - 1].id).toBe(1499);
202
+ });
203
+ });
204
+
205
+ describe("Session record cleanup", () => {
206
+ it("re-registering a session preserves carry-over fields but resets to a fresh shape", () => {
207
+ // Sanity check that uiModules / uiDataMap aren't leaked into a fresh session
208
+ // unless explicitly preserved by the SessionManager. Today they are not in
209
+ // the explicit carry-over list (which covers tokens, cost, attachedProposal,
210
+ // contextTokens/Window) — so they will be dropped on register, which is
211
+ // the correct behavior: bridge re-probes immediately after register.
212
+ const mgr = createMemorySessionManager();
213
+ mgr.register({ id: "s1", cwd: "/tmp", source: "tui" });
214
+ mgr.update("s1", { uiModules: [sampleModule("a", "/a")], uiDataMap: { x: [1] } });
215
+ expect(mgr.get("s1")?.uiModules).toBeDefined();
216
+
217
+ mgr.register({ id: "s1", cwd: "/tmp", source: "tui" });
218
+ expect(mgr.get("s1")?.uiModules).toBeUndefined();
219
+ expect(mgr.get("s1")?.uiDataMap).toBeUndefined();
220
+ });
221
+ });
@@ -1,13 +1,46 @@
1
1
  /**
2
2
  * Directory browsing logic for the browse API endpoint.
3
+ *
4
+ * Two responsibilities, kept deliberately separate:
5
+ * 1. `listDirectories` — enumerate directory entries (cheap; one
6
+ * readdir call). Only probes `.git` / `.pi` when the caller
7
+ * explicitly opts in via `{ detect: true }`.
8
+ * 2. `classifyPaths` — bulk-classify a list of absolute paths,
9
+ * returning `{ [path]: { isGit, isPi } }`. Used by the bulk
10
+ * `GET /api/browse/flags` endpoint and by the path picker's
11
+ * lazy second-phase fetch.
12
+ *
13
+ * See change: split-browse-flags.
3
14
  */
4
15
  import fs from "node:fs/promises";
5
16
  import path from "node:path";
6
17
  import os from "node:os";
7
- import type { BrowseEntry, BrowseResult } from "@blackbelt-technology/pi-dashboard-shared/rest-api.js";
18
+ import type { BrowseEntry, BrowseFlagEntry, BrowseResult } from "@blackbelt-technology/pi-dashboard-shared/rest-api.js";
8
19
  import { isFilesystemRoot } from "@blackbelt-technology/pi-dashboard-shared/platform/paths.js";
20
+ import { createSemaphore } from "@blackbelt-technology/pi-dashboard-shared/semaphore.js";
9
21
 
10
22
  const MAX_ENTRIES = 200;
23
+
24
+ /** Hard cap on how many paths a single `/api/browse/flags` request may classify. */
25
+ export const MAX_FLAG_PATHS = 100;
26
+
27
+ /** Bound on in-flight `fs.access` calls inside a single `classifyPaths` invocation. */
28
+ const FLAG_PROBE_CONCURRENCY = 32;
29
+
30
+ /**
31
+ * Probe a single absolute path for `.git` and `.pi` siblings using
32
+ * `fs.access`. Any error — ENOENT, EACCES, ELOOP, race-on-deletion,
33
+ * target removed mid-probe, anything — maps to `false` for that flag.
34
+ * Worktree-safe: `.git` is a regular file in worktrees, and `fs.access`
35
+ * accepts that just fine (no `readdir` shortcut, ever).
36
+ */
37
+ async function probeFlags(absolutePath: string): Promise<BrowseFlagEntry> {
38
+ const [isGit, isPi] = await Promise.all([
39
+ fs.access(path.join(absolutePath, ".git")).then(() => true, () => false),
40
+ fs.access(path.join(absolutePath, ".pi")).then(() => true, () => false),
41
+ ]);
42
+ return { isGit, isPi };
43
+ }
11
44
  const WORD_BOUNDARY_CHARS = new Set(["-", "_", ".", " ", "/"]);
12
45
 
13
46
  /**
@@ -38,7 +71,12 @@ function rankTier(name: string, qLower: string): number {
38
71
  * (exact → prefix → word-boundary → substring), alphabetical within tier.
39
72
  * Caps at 200 entries AFTER filtering/ranking.
40
73
  */
41
- export async function listDirectories(dirPath?: string, q?: string): Promise<BrowseResult> {
74
+ export async function listDirectories(
75
+ dirPath?: string,
76
+ q?: string,
77
+ opts?: { detect?: boolean },
78
+ ): Promise<BrowseResult> {
79
+ const detect = opts?.detect === true;
42
80
  const resolved = dirPath ?? os.homedir();
43
81
 
44
82
  // Verify the directory exists and is a directory
@@ -74,17 +112,18 @@ export async function listDirectories(dirPath?: string, q?: string): Promise<Bro
74
112
  // Cap at MAX_ENTRIES (AFTER filtering/ranking)
75
113
  const capped = dirs.slice(0, MAX_ENTRIES);
76
114
 
77
- // Build entries with isGit/isPi detection
78
- const entries: BrowseEntry[] = await Promise.all(
79
- capped.map(async (d) => {
80
- const fullPath = path.join(resolved, d.name);
81
- const [isGit, isPi] = await Promise.all([
82
- fs.access(path.join(fullPath, ".git")).then(() => true, () => false),
83
- fs.access(path.join(fullPath, ".pi")).then(() => true, () => false),
84
- ]);
85
- return { name: d.name, path: fullPath, isGit, isPi };
86
- })
87
- );
115
+ // Build entries. When `detect` is opt-in, probe `.git` / `.pi` for each
116
+ // surviving entry; otherwise omit the flag fields entirely so the
117
+ // single-syscall fast path stays a single syscall.
118
+ const entries: BrowseEntry[] = detect
119
+ ? await Promise.all(
120
+ capped.map(async (d) => {
121
+ const fullPath = path.join(resolved, d.name);
122
+ const flags = await probeFlags(fullPath);
123
+ return { name: d.name, path: fullPath, isGit: flags.isGit, isPi: flags.isPi };
124
+ }),
125
+ )
126
+ : capped.map((d) => ({ name: d.name, path: path.join(resolved, d.name) }));
88
127
 
89
128
  // Parent: null for any filesystem root (`/`, `C:\`, `\\server\share\`).
90
129
  // Previously this was `resolved === "/"`, which only recognized the Unix
@@ -96,6 +135,72 @@ export async function listDirectories(dirPath?: string, q?: string): Promise<Bro
96
135
  return { entries, parent, current: resolved, platform: process.platform };
97
136
  }
98
137
 
138
+ /**
139
+ * Bulk-classify a batch of absolute paths. Returns a map keyed by the
140
+ * input paths whose values are `{ isGit, isPi }`. Probe failures (any
141
+ * error) become `{ isGit: false, isPi: false }` for that key — the
142
+ * function never throws on per-path failures. Caller is responsible for
143
+ * bounding `paths.length` (the route does this via `parseFlagsQuery`).
144
+ *
145
+ * Internal `fs.access` fan-out is bounded via a tiny FIFO semaphore so
146
+ * a single 100-path call cannot exhaust file descriptors.
147
+ */
148
+ export async function classifyPaths(
149
+ paths: string[],
150
+ ): Promise<Record<string, BrowseFlagEntry>> {
151
+ if (paths.length === 0) return {};
152
+ const sem = createSemaphore(FLAG_PROBE_CONCURRENCY);
153
+ const result: Record<string, BrowseFlagEntry> = {};
154
+ await Promise.all(
155
+ paths.map((p) =>
156
+ sem.run(async () => {
157
+ result[p] = await probeFlags(p);
158
+ }),
159
+ ),
160
+ );
161
+ return result;
162
+ }
163
+
164
+ /**
165
+ * Result of parsing the `paths` query parameter for
166
+ * `GET /api/browse/flags`. Pure / synchronous so route handlers can
167
+ * map directly to HTTP 400 with the documented error string.
168
+ */
169
+ export type ParseFlagsQueryResult =
170
+ | { ok: true; paths: string[] }
171
+ | { ok: false; error: "invalid paths" | "too many paths" };
172
+
173
+ /**
174
+ * Parse the URL-encoded JSON-array `paths` query parameter. Validates:
175
+ * - present and non-empty string
176
+ * - parses as JSON
177
+ * - is an array
178
+ * - every element is a string
179
+ * - length ≤ MAX_FLAG_PATHS
180
+ *
181
+ * Note: an empty array (`paths=[]`) is valid and returns `{ ok: true,
182
+ * paths: [] }` so the caller can short-circuit to `{ flags: {} }`.
183
+ */
184
+ export function parseFlagsQuery(rawPaths: string | undefined): ParseFlagsQueryResult {
185
+ if (typeof rawPaths !== "string" || rawPaths.length === 0) {
186
+ return { ok: false, error: "invalid paths" };
187
+ }
188
+ let parsed: unknown;
189
+ try {
190
+ parsed = JSON.parse(rawPaths);
191
+ } catch {
192
+ return { ok: false, error: "invalid paths" };
193
+ }
194
+ if (!Array.isArray(parsed)) return { ok: false, error: "invalid paths" };
195
+ if (!parsed.every((p) => typeof p === "string")) {
196
+ return { ok: false, error: "invalid paths" };
197
+ }
198
+ if (parsed.length > MAX_FLAG_PATHS) {
199
+ return { ok: false, error: "too many paths" };
200
+ }
201
+ return { ok: true, paths: parsed as string[] };
202
+ }
203
+
99
204
  /**
100
205
  * Validate a directory name for mkdir.
101
206
  * Returns null if valid, or an error message string if invalid.
@@ -73,6 +73,8 @@ export function createBrowserGateway(
73
73
  terminalManager?: TerminalManager,
74
74
  pendingDashboardSpawns?: Map<string, number>,
75
75
  maxWsBufferBytes?: number,
76
+ pendingAttachRegistry?: import("./pending-attach-registry.js").PendingAttachRegistry,
77
+ pendingResumeIntents?: import("./pending-resume-intent-registry.js").PendingResumeIntentRegistry,
76
78
  ): BrowserGateway {
77
79
  const wss = new WebSocketServer({ noServer: true });
78
80
 
@@ -255,6 +257,8 @@ export function createBrowserGateway(
255
257
  pendingForkRegistry, sessionOrderManager, preferencesStore,
256
258
  directoryService, terminalManager,
257
259
  headlessPidRegistry, pendingResumeRegistry, pendingDashboardSpawns,
260
+ pendingAttachRegistry,
261
+ pendingResumeIntents,
258
262
  sendTo, broadcast, getSubscribers, replayPendingUiRequests,
259
263
  trackUiRequest: trackUiRequest,
260
264
  markReplaying(targetWs, sessionId) {
@@ -426,6 +430,21 @@ export function createBrowserGateway(
426
430
  });
427
431
  break;
428
432
  }
433
+ case "ui_management": {
434
+ // Extension UI System (Phase 1): forward browser action / data
435
+ // request to the bridge unchanged. The bridge re-emits on
436
+ // pi.events; the extension replies via ui_data_list (round-trip
437
+ // handled in event-wiring).
438
+ // See change: add-extension-ui-modal.
439
+ ctx.piGateway.sendToSession(msg.sessionId, {
440
+ type: "ui_management",
441
+ sessionId: msg.sessionId,
442
+ action: msg.action,
443
+ event: msg.event,
444
+ params: msg.params,
445
+ });
446
+ break;
447
+ }
429
448
  case "create_terminal":
430
449
  handleCreateTerminal(msg, ctx);
431
450
  break;
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Tests for handleAttachProposal / handleDetachProposal.
3
+ * See change: fix-mobile-attach-proposal-display.
4
+ */
5
+ import { describe, it, expect, beforeEach } from "vitest";
6
+ import { handleAttachProposal, handleDetachProposal } from "../session-meta-handler.js";
7
+ import { createMemorySessionManager, type SessionManager } from "../../memory-session-manager.js";
8
+ import type { BrowserHandlerContext } from "../handler-context.js";
9
+
10
+ interface PiSent {
11
+ sessionId: string;
12
+ msg: unknown;
13
+ }
14
+ interface Broadcast {
15
+ type: string;
16
+ sessionId: string;
17
+ updates: Record<string, unknown>;
18
+ }
19
+
20
+ function makeCtx(sessionManager: SessionManager) {
21
+ const piSends: PiSent[] = [];
22
+ const broadcasts: Broadcast[] = [];
23
+
24
+ const ctx = {
25
+ sessionManager,
26
+ piGateway: {
27
+ sendToSession(sessionId: string, msg: unknown) {
28
+ piSends.push({ sessionId, msg });
29
+ },
30
+ },
31
+ broadcast(msg: any) {
32
+ broadcasts.push(msg);
33
+ },
34
+ } as unknown as BrowserHandlerContext;
35
+
36
+ return { ctx, piSends, broadcasts };
37
+ }
38
+
39
+ function registerSession(mgr: SessionManager, id: string, overrides: Record<string, unknown> = {}) {
40
+ mgr.register({
41
+ id,
42
+ cwd: "/tmp/test",
43
+ source: "tui",
44
+ startedAt: Date.now(),
45
+ });
46
+ if (Object.keys(overrides).length > 0) mgr.update(id, overrides as any);
47
+ }
48
+
49
+ describe("handleAttachProposal — decision matrix", () => {
50
+ let mgr: SessionManager;
51
+ beforeEach(() => {
52
+ mgr = createMemorySessionManager();
53
+ });
54
+
55
+ it("empty name + null attached → name auto-set, rename_session sent", () => {
56
+ registerSession(mgr, "s1");
57
+ const { ctx, piSends, broadcasts } = makeCtx(mgr);
58
+
59
+ handleAttachProposal({ type: "attach_proposal", sessionId: "s1", changeName: "add-auth" } as any, ctx);
60
+
61
+ const s = mgr.get("s1")!;
62
+ expect(s.attachedProposal).toBe("add-auth");
63
+ expect(s.name).toBe("add-auth");
64
+ expect(piSends).toEqual([
65
+ { sessionId: "s1", msg: { type: "rename_session", sessionId: "s1", name: "add-auth" } },
66
+ ]);
67
+ expect(broadcasts).toEqual([
68
+ { type: "session_updated", sessionId: "s1", updates: { attachedProposal: "add-auth", name: "add-auth" } },
69
+ ]);
70
+ });
71
+
72
+ it("custom name + null attached → name preserved, no rename_session", () => {
73
+ registerSession(mgr, "s1", { name: "my custom" });
74
+ const { ctx, piSends, broadcasts } = makeCtx(mgr);
75
+
76
+ handleAttachProposal({ type: "attach_proposal", sessionId: "s1", changeName: "add-auth" } as any, ctx);
77
+
78
+ const s = mgr.get("s1")!;
79
+ expect(s.attachedProposal).toBe("add-auth");
80
+ expect(s.name).toBe("my custom");
81
+ expect(piSends).toEqual([]);
82
+ expect(broadcasts).toEqual([
83
+ { type: "session_updated", sessionId: "s1", updates: { attachedProposal: "add-auth" } },
84
+ ]);
85
+ });
86
+
87
+ it("name === attachedProposal (auto-set) → re-tracks new change name", () => {
88
+ registerSession(mgr, "s1", { name: "foo", attachedProposal: "foo" });
89
+ const { ctx, piSends, broadcasts } = makeCtx(mgr);
90
+
91
+ handleAttachProposal({ type: "attach_proposal", sessionId: "s1", changeName: "bar" } as any, ctx);
92
+
93
+ const s = mgr.get("s1")!;
94
+ expect(s.name).toBe("bar");
95
+ expect(s.attachedProposal).toBe("bar");
96
+ expect(piSends).toEqual([
97
+ { sessionId: "s1", msg: { type: "rename_session", sessionId: "s1", name: "bar" } },
98
+ ]);
99
+ expect(broadcasts[0].updates).toEqual({ attachedProposal: "bar", name: "bar" });
100
+ });
101
+
102
+ it("custom name + non-null attached → name preserved, no rename_session", () => {
103
+ registerSession(mgr, "s1", { name: "my custom", attachedProposal: "foo" });
104
+ const { ctx, piSends, broadcasts } = makeCtx(mgr);
105
+
106
+ handleAttachProposal({ type: "attach_proposal", sessionId: "s1", changeName: "bar" } as any, ctx);
107
+
108
+ const s = mgr.get("s1")!;
109
+ expect(s.name).toBe("my custom");
110
+ expect(s.attachedProposal).toBe("bar");
111
+ expect(piSends).toEqual([]);
112
+ expect(broadcasts[0].updates).toEqual({ attachedProposal: "bar" });
113
+ });
114
+ });
115
+
116
+ describe("handleDetachProposal — decision matrix", () => {
117
+ let mgr: SessionManager;
118
+ beforeEach(() => {
119
+ mgr = createMemorySessionManager();
120
+ });
121
+
122
+ it("name === attachedProposal (auto-set) → name cleared, rename_session with empty name", () => {
123
+ registerSession(mgr, "s1", { name: "foo", attachedProposal: "foo" });
124
+ const { ctx, piSends, broadcasts } = makeCtx(mgr);
125
+
126
+ handleDetachProposal({ type: "detach_proposal", sessionId: "s1" } as any, ctx);
127
+
128
+ const s = mgr.get("s1")!;
129
+ expect(s.attachedProposal).toBeNull();
130
+ expect(s.name).toBeUndefined();
131
+ expect(piSends).toEqual([
132
+ { sessionId: "s1", msg: { type: "rename_session", sessionId: "s1", name: "" } },
133
+ ]);
134
+ expect(broadcasts[0].updates).toEqual({
135
+ attachedProposal: null, openspecPhase: null, openspecChange: null, name: undefined,
136
+ });
137
+ });
138
+
139
+ it("custom name + non-null attached → name preserved, no rename_session", () => {
140
+ registerSession(mgr, "s1", { name: "my custom", attachedProposal: "foo" });
141
+ const { ctx, piSends, broadcasts } = makeCtx(mgr);
142
+
143
+ handleDetachProposal({ type: "detach_proposal", sessionId: "s1" } as any, ctx);
144
+
145
+ const s = mgr.get("s1")!;
146
+ expect(s.attachedProposal).toBeNull();
147
+ expect(s.name).toBe("my custom");
148
+ expect(piSends).toEqual([]);
149
+ expect(broadcasts[0].updates).toEqual({
150
+ attachedProposal: null, openspecPhase: null, openspecChange: null,
151
+ });
152
+ });
153
+
154
+ it("empty name + non-null attached → name unchanged, no rename_session", () => {
155
+ registerSession(mgr, "s1", { attachedProposal: "foo" });
156
+ const { ctx, piSends, broadcasts } = makeCtx(mgr);
157
+
158
+ handleDetachProposal({ type: "detach_proposal", sessionId: "s1" } as any, ctx);
159
+
160
+ const s = mgr.get("s1")!;
161
+ expect(s.attachedProposal).toBeNull();
162
+ expect(s.name).toBeUndefined();
163
+ expect(piSends).toEqual([]);
164
+ expect(broadcasts[0].updates).toEqual({
165
+ attachedProposal: null, openspecPhase: null, openspecChange: null,
166
+ });
167
+ });
168
+
169
+ it("name set + null attached (defensive) → name preserved, no rename_session", () => {
170
+ registerSession(mgr, "s1", { name: "foo", attachedProposal: null });
171
+ const { ctx, piSends, broadcasts } = makeCtx(mgr);
172
+
173
+ handleDetachProposal({ type: "detach_proposal", sessionId: "s1" } as any, ctx);
174
+
175
+ const s = mgr.get("s1")!;
176
+ expect(s.attachedProposal).toBeNull();
177
+ expect(s.name).toBe("foo");
178
+ expect(piSends).toEqual([]);
179
+ expect(broadcasts[0].updates).toEqual({
180
+ attachedProposal: null, openspecPhase: null, openspecChange: null,
181
+ });
182
+ });
183
+ });
@@ -105,8 +105,14 @@ export function handleOpenSpecBulkArchive(
105
105
  // windowsHide, timeout, and argv-array escaping.
106
106
  // See change: platform-command-executor.
107
107
  openspecArchiveCompleted({ cwd: msg.cwd });
108
+ // Post-archive refresh stays gated: bulk-archive bumps `<changes>/`
109
+ // mtime once (entry removal), so the gate naturally re-runs `list` and
110
+ // any per-change CLI calls whose effective mtime advanced. Skipping
111
+ // the user-facing `refreshOpenSpec` (which now force-bypasses the gate)
112
+ // avoids O(N) status spawns after every bulk archive.
113
+ // See change: fix-openspec-mtime-gate-toctou.
108
114
  Promise.resolve()
109
- .then(() => ctx.directoryService!.refreshOpenSpec(msg.cwd))
115
+ .then(() => ctx.directoryService!.pollDirectoryGated(msg.cwd))
110
116
  .then((data) => {
111
117
  if (data) ctx.broadcast({ type: "openspec_update", cwd: msg.cwd, data });
112
118
  });
@@ -14,6 +14,8 @@ import type { DirectoryService } from "../directory-service.js";
14
14
  import type { TerminalManager } from "../terminal-manager.js";
15
15
  import type { HeadlessPidRegistry } from "../headless-pid-registry.js";
16
16
  import type { PendingResumeRegistry } from "../pending-resume-registry.js";
17
+ import type { PendingAttachRegistry } from "../pending-attach-registry.js";
18
+ import type { PendingResumeIntentRegistry } from "../pending-resume-intent-registry.js";
17
19
 
18
20
  export interface BrowserHandlerContext {
19
21
  ws: WebSocket;
@@ -28,6 +30,19 @@ export interface BrowserHandlerContext {
28
30
  headlessPidRegistry: HeadlessPidRegistry;
29
31
  pendingResumeRegistry: PendingResumeRegistry;
30
32
  pendingDashboardSpawns?: Map<string, number>;
33
+ /**
34
+ * Optional pending-attach registry for spawn-with-attach flow.
35
+ * See change: add-folder-task-checker-and-spawn-attach.
36
+ */
37
+ pendingAttachRegistry?: PendingAttachRegistry;
38
+ /**
39
+ * Optional pending-resume-intent registry. Tagged when the user clicks
40
+ * Resume / drags-to-resume / hits the REST resume endpoint, consumed by
41
+ * `server.ts`'s `onChange` hook in the ended→alive branch to gate the
42
+ * sessionOrder mutation behind explicit user intent.
43
+ * See change: preserve-session-order-on-reboot.
44
+ */
45
+ pendingResumeIntents?: PendingResumeIntentRegistry;
31
46
  /** Send message to a specific WebSocket */
32
47
  sendTo(ws: WebSocket, msg: ServerToBrowserMessage): void;
33
48
  /** Broadcast to all connected browsers */