@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.
- package/AGENTS.md +79 -32
- package/README.md +7 -3
- package/docs/architecture.md +361 -12
- package/package.json +7 -7
- package/packages/extension/package.json +7 -2
- package/packages/extension/src/__tests__/ask-user-schema-discriminator.test.ts +141 -0
- package/packages/extension/src/__tests__/ask-user-tool.test.ts +51 -7
- package/packages/extension/src/__tests__/multiselect-dashboard-routing.test.ts +203 -0
- package/packages/extension/src/__tests__/multiselect-polyfill.test.ts +92 -0
- package/packages/extension/src/__tests__/no-tui-multiselect-arm-regression.test.ts +81 -0
- package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +37 -0
- package/packages/extension/src/__tests__/ui-decorators.test.ts +309 -0
- package/packages/extension/src/__tests__/ui-modules.test.ts +293 -0
- package/packages/extension/src/ask-user-tool.ts +165 -57
- package/packages/extension/src/bridge.ts +97 -4
- package/packages/extension/src/multiselect-decode.ts +40 -0
- package/packages/extension/src/multiselect-polyfill.ts +38 -8
- package/packages/extension/src/ui-modules.ts +272 -0
- package/packages/server/package.json +9 -3
- package/packages/server/src/__tests__/auto-attach.test.ts +61 -8
- package/packages/server/src/__tests__/browse-endpoint.test.ts +295 -19
- package/packages/server/src/__tests__/cli-bootstrap.test.ts +36 -0
- package/packages/server/src/__tests__/directory-service-refresh-force.test.ts +163 -0
- package/packages/server/src/__tests__/directory-service-specs-mtime.test.ts +315 -0
- package/packages/server/src/__tests__/directory-service-toctou.test.ts +303 -0
- package/packages/server/src/__tests__/directory-service.test.ts +174 -0
- package/packages/server/src/__tests__/installed-package-enricher.test.ts +225 -0
- package/packages/server/src/__tests__/package-manager-wrapper-move.test.ts +414 -0
- package/packages/server/src/__tests__/package-routes.test.ts +136 -3
- package/packages/server/src/__tests__/package-source-helpers.test.ts +101 -0
- package/packages/server/src/__tests__/pending-attach-registry.test.ts +123 -0
- package/packages/server/src/__tests__/pending-resume-intent-registry.test.ts +138 -0
- package/packages/server/src/__tests__/pi-core-checker.test.ts +73 -30
- package/packages/server/src/__tests__/pi-gateway-consume-pending-attach.test.ts +112 -0
- package/packages/server/src/__tests__/post-install-openspec-refresh.test.ts +180 -0
- package/packages/server/src/__tests__/post-install-rescan.test.ts +134 -0
- package/packages/server/src/__tests__/proposal-attach-naming.test.ts +79 -0
- package/packages/server/src/__tests__/session-action-handler-spawn-with-attach.test.ts +108 -0
- package/packages/server/src/__tests__/session-order-manager.test.ts +55 -0
- package/packages/server/src/__tests__/session-order-reboot.test.ts +242 -0
- package/packages/server/src/__tests__/session-scanner.test.ts +44 -0
- package/packages/server/src/__tests__/subscription-handler.test.ts +40 -0
- package/packages/server/src/__tests__/translate-path-source.test.ts +77 -0
- package/packages/server/src/__tests__/ui-decorators-replay.test.ts +209 -0
- package/packages/server/src/__tests__/ui-modules-replay.test.ts +221 -0
- package/packages/server/src/browse.ts +118 -13
- package/packages/server/src/browser-gateway.ts +19 -0
- package/packages/server/src/browser-handlers/__tests__/session-meta-handler.test.ts +183 -0
- package/packages/server/src/browser-handlers/directory-handler.ts +7 -1
- package/packages/server/src/browser-handlers/handler-context.ts +15 -0
- package/packages/server/src/browser-handlers/session-action-handler.ts +29 -3
- package/packages/server/src/browser-handlers/session-meta-handler.ts +46 -12
- package/packages/server/src/browser-handlers/subscription-handler.ts +46 -1
- package/packages/server/src/cli.ts +5 -6
- package/packages/server/src/directory-service.ts +156 -15
- package/packages/server/src/event-wiring.ts +111 -10
- package/packages/server/src/installed-package-enricher.ts +143 -0
- package/packages/server/src/package-manager-wrapper.ts +305 -8
- package/packages/server/src/package-source-helpers.ts +104 -0
- package/packages/server/src/pending-attach-registry.ts +112 -0
- package/packages/server/src/pending-resume-intent-registry.ts +107 -0
- package/packages/server/src/pi-core-checker.ts +9 -14
- package/packages/server/src/pi-gateway.ts +14 -0
- package/packages/server/src/proposal-attach-naming.ts +47 -0
- package/packages/server/src/routes/file-routes.ts +29 -3
- package/packages/server/src/routes/package-routes.ts +72 -3
- package/packages/server/src/routes/plugin-config-routes.ts +129 -0
- package/packages/server/src/routes/system-routes.ts +2 -0
- package/packages/server/src/server.ts +339 -10
- package/packages/server/src/session-api.ts +30 -5
- package/packages/server/src/session-order-manager.ts +22 -0
- package/packages/server/src/session-scanner.ts +10 -1
- package/packages/shared/package.json +9 -2
- package/packages/shared/src/__tests__/browser-protocol-types.test.ts +59 -0
- package/packages/shared/src/__tests__/config-plugins.test.ts +68 -0
- package/packages/shared/src/__tests__/extension-ui-module-shape.test.ts +265 -0
- package/packages/shared/src/__tests__/no-raw-openspec-status-in-skills.test.ts +81 -0
- package/packages/shared/src/__tests__/openspec-design-evidence.test.ts +288 -0
- package/packages/shared/src/__tests__/openspec-effective-status-script.test.ts +174 -0
- package/packages/shared/src/__tests__/openspec-poller-design-override.test.ts +225 -0
- package/packages/shared/src/__tests__/openspec-poller-specs-override.test.ts +284 -0
- package/packages/shared/src/__tests__/openspec-specs-evidence.test.ts +144 -0
- package/packages/shared/src/__tests__/platform/is-appimage-self-hit.test.ts +164 -0
- package/packages/shared/src/__tests__/plugin-bridge-register-extended.test.ts +72 -0
- package/packages/shared/src/__tests__/plugin-bridge-register.test.ts +113 -0
- package/packages/shared/src/__tests__/plugin-config-update-protocol.test.ts +41 -0
- package/packages/shared/src/__tests__/recommended-extensions.test.ts +5 -1
- package/packages/shared/src/__tests__/spawn-session-attach-proposal.test.ts +47 -0
- package/packages/shared/src/__tests__/tool-registry-strategies-appimage.test.ts +118 -0
- package/packages/shared/src/browser-protocol.ts +110 -4
- package/packages/shared/src/config.ts +45 -0
- package/packages/shared/src/dashboard-plugin/index.ts +11 -0
- package/packages/shared/src/dashboard-plugin/manifest-types.ts +58 -0
- package/packages/shared/src/dashboard-plugin/plugin-status.ts +26 -0
- package/packages/shared/src/dashboard-plugin/slot-props.ts +92 -0
- package/packages/shared/src/dashboard-plugin/slot-types.ts +151 -0
- package/packages/shared/src/openspec-activity-detector.ts +18 -22
- package/packages/shared/src/openspec-design-evidence.ts +109 -0
- package/packages/shared/src/openspec-poller.ts +117 -3
- package/packages/shared/src/openspec-specs-evidence.ts +79 -0
- package/packages/shared/src/platform/binary-lookup.ts +96 -1
- package/packages/shared/src/plugin-bridge-register.ts +139 -0
- package/packages/shared/src/protocol.ts +56 -2
- package/packages/shared/src/recommended-extensions.ts +7 -1
- package/packages/shared/src/rest-api.ts +68 -3
- package/packages/shared/src/state-replay.ts +11 -1
- package/packages/shared/src/tool-registry/strategies.ts +17 -3
- 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(
|
|
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
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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!.
|
|
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 */
|