@blackbelt-technology/pi-agent-dashboard 0.4.0 → 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 +104 -35
- package/README.md +390 -494
- package/docs/architecture.md +423 -20
- package/package.json +11 -8
- package/packages/extension/package.json +11 -4
- package/packages/extension/src/__tests__/ask-user-schema-discriminator.test.ts +141 -0
- package/packages/extension/src/__tests__/ask-user-tool.test.ts +91 -15
- package/packages/extension/src/__tests__/bridge-entry-id-pi-070.test.ts +174 -0
- package/packages/extension/src/__tests__/event-forwarder.test.ts +30 -0
- package/packages/extension/src/__tests__/fork-entryid-timing.test.ts +64 -76
- package/packages/extension/src/__tests__/multiselect-dashboard-routing.test.ts +203 -0
- package/packages/extension/src/__tests__/multiselect-list.test.ts +137 -0
- package/packages/extension/src/__tests__/multiselect-polyfill.test.ts +92 -0
- package/packages/extension/src/__tests__/no-session-replacement-calls.test.ts +99 -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 +170 -61
- package/packages/extension/src/bridge.ts +199 -19
- package/packages/extension/src/multiselect-decode.ts +40 -0
- package/packages/extension/src/multiselect-list.ts +146 -0
- package/packages/extension/src/multiselect-polyfill.ts +73 -0
- package/packages/extension/src/server-launcher.ts +15 -3
- package/packages/extension/src/ui-modules.ts +272 -0
- package/packages/server/package.json +11 -5
- 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__/fixtures/fork-jsonl-roundtrip.jsonl +8 -0
- package/packages/server/src/__tests__/fork-jsonl-roundtrip.test.ts +49 -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__/pi-version-skew.test.ts +72 -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__/restart-helper.test.ts +34 -6
- 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 +61 -15
- 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/pi-version-skew.ts +12 -1
- package/packages/server/src/proposal-attach-naming.ts +47 -0
- package/packages/server/src/restart-helper.ts +13 -2
- 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-hardcoded-node-modules-paths.test.ts +176 -0
- package/packages/shared/src/__tests__/no-raw-node-import.test.ts +146 -0
- package/packages/shared/src/__tests__/no-raw-openspec-status-in-skills.test.ts +81 -0
- package/packages/shared/src/__tests__/node-spawn.test.ts +210 -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__/resolve-tool-cli.test.ts +105 -0
- package/packages/shared/src/__tests__/spawn-session-attach-proposal.test.ts +47 -0
- package/packages/shared/src/__tests__/state-replay-entry-id.test.ts +69 -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/platform/index.ts +1 -0
- package/packages/shared/src/platform/node-spawn.ts +154 -0
- package/packages/shared/src/plugin-bridge-register.ts +139 -0
- package/packages/shared/src/protocol.ts +79 -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 +20 -1
- package/packages/shared/src/tool-registry/definitions.ts +92 -0
- package/packages/shared/src/tool-registry/strategies.ts +17 -3
- package/packages/shared/src/types.ts +160 -0
|
@@ -168,7 +168,7 @@ export async function handleSendPrompt(
|
|
|
168
168
|
msg: Extract<BrowserToServerMessage, { type: "send_prompt" }>,
|
|
169
169
|
ctx: BrowserHandlerContext,
|
|
170
170
|
): Promise<void> {
|
|
171
|
-
const { sessionManager, piGateway, headlessPidRegistry, pendingResumeRegistry, pendingDashboardSpawns, broadcast } = ctx;
|
|
171
|
+
const { sessionManager, piGateway, headlessPidRegistry, pendingResumeRegistry, pendingResumeIntents, pendingDashboardSpawns, broadcast } = ctx;
|
|
172
172
|
|
|
173
173
|
// Intercept `/reload` on active headless sessions — forward the request to
|
|
174
174
|
// our kill-and-respawn handler instead of routing the prompt to the bridge
|
|
@@ -194,6 +194,11 @@ export async function handleSendPrompt(
|
|
|
194
194
|
sessionFile: promptSession.sessionFile,
|
|
195
195
|
});
|
|
196
196
|
if (alreadyResuming) return;
|
|
197
|
+
// Tag the resume intent as "front" so the upcoming ended→alive
|
|
198
|
+
// transition surfaces this card at the top of the alive tier. The
|
|
199
|
+
// user is actively typing into this session; surfacing it matches
|
|
200
|
+
// their mental model. See change: differentiate-resume-intent-by-trigger.
|
|
201
|
+
pendingResumeIntents?.record(msg.sessionId, "front");
|
|
197
202
|
sessionManager.update(msg.sessionId, { resuming: true });
|
|
198
203
|
broadcast({ type: "session_updated", sessionId: msg.sessionId, updates: { resuming: true } });
|
|
199
204
|
const autoResumeConfig = loadConfig();
|
|
@@ -231,12 +236,17 @@ export async function handleResumeSession(
|
|
|
231
236
|
msg: Extract<BrowserToServerMessage, { type: "resume_session" }>,
|
|
232
237
|
ctx: BrowserHandlerContext,
|
|
233
238
|
): Promise<void> {
|
|
234
|
-
const { ws, sessionManager, pendingForkRegistry, headlessPidRegistry, pendingDashboardSpawns, sendTo } = ctx;
|
|
239
|
+
const { ws, sessionManager, pendingForkRegistry, headlessPidRegistry, pendingDashboardSpawns, pendingResumeIntents, sendTo } = ctx;
|
|
235
240
|
const session = sessionManager.get(msg.sessionId);
|
|
236
241
|
if (!session) {
|
|
237
242
|
sendTo(ws, { type: "resume_result", sessionId: msg.sessionId, success: false, message: "Session not found" });
|
|
238
243
|
return;
|
|
239
244
|
}
|
|
245
|
+
// Resolve placement intent. Old browsers omit the field; default to
|
|
246
|
+
// "front" so they keep getting today's behavior. Drag-to-resume sends
|
|
247
|
+
// "keep" so the dropped slot is preserved through the resume round-trip.
|
|
248
|
+
// See change: differentiate-resume-intent-by-trigger.
|
|
249
|
+
const placement: "front" | "keep" = msg.placement ?? "front";
|
|
240
250
|
if (!session.sessionFile) {
|
|
241
251
|
sendTo(ws, { type: "resume_result", sessionId: msg.sessionId, success: false, message: "Session file is unknown (pre-migration session)" });
|
|
242
252
|
return;
|
|
@@ -264,6 +274,15 @@ export async function handleResumeSession(
|
|
|
264
274
|
}
|
|
265
275
|
}
|
|
266
276
|
|
|
277
|
+
// Tag the user-resume intent BEFORE spawning so the `onChange`
|
|
278
|
+
// ended→alive branch in `server.ts` can distinguish a user-initiated
|
|
279
|
+
// resume from a bridge auto-reattach on dashboard reboot, and choose
|
|
280
|
+
// placement (front vs. keep) appropriately. The fork path also tags
|
|
281
|
+
// but the tag is harmless: forks create new session ids that never
|
|
282
|
+
// appear in the ended→alive branch.
|
|
283
|
+
// See changes: preserve-session-order-on-reboot,
|
|
284
|
+
// differentiate-resume-intent-by-trigger.
|
|
285
|
+
pendingResumeIntents?.record(msg.sessionId, placement);
|
|
267
286
|
const resumeConfig = loadConfig();
|
|
268
287
|
const result = await spawnPiSession(session.cwd, {
|
|
269
288
|
sessionFile: forkSessionFile,
|
|
@@ -283,10 +302,17 @@ export async function handleSpawnSession(
|
|
|
283
302
|
msg: Extract<BrowserToServerMessage, { type: "spawn_session" }>,
|
|
284
303
|
ctx: BrowserHandlerContext,
|
|
285
304
|
): Promise<void> {
|
|
286
|
-
const { ws, headlessPidRegistry, pendingDashboardSpawns, sendTo } = ctx;
|
|
305
|
+
const { ws, headlessPidRegistry, pendingDashboardSpawns, pendingAttachRegistry, sendTo } = ctx;
|
|
287
306
|
const config = loadConfig();
|
|
288
307
|
const strategy = config.spawnStrategy ?? "tmux";
|
|
289
308
|
|
|
309
|
+
// Queue the optional attach intent BEFORE awaiting the spawn so a fast
|
|
310
|
+
// bridge `session_register` cannot lose the intent. See change:
|
|
311
|
+
// add-folder-task-checker-and-spawn-attach.
|
|
312
|
+
if (typeof msg.attachProposal === "string" && msg.attachProposal.length > 0) {
|
|
313
|
+
pendingAttachRegistry?.enqueue(msg.cwd, msg.attachProposal);
|
|
314
|
+
}
|
|
315
|
+
|
|
290
316
|
// Catch both thrown exceptions and { success: false } results; surface as
|
|
291
317
|
// spawn_error so the UI can render a retryable banner instead of failing
|
|
292
318
|
// silently. Previous behaviour left the user staring at an empty state
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import type { BrowserToServerMessage } from "@blackbelt-technology/pi-dashboard-shared/browser-protocol.js";
|
|
5
5
|
import type { BrowserHandlerContext } from "./handler-context.js";
|
|
6
|
+
import { attachRenameTarget, detachShouldClearName } from "../proposal-attach-naming.js";
|
|
6
7
|
|
|
7
8
|
export function handleRenameSession(
|
|
8
9
|
msg: Extract<BrowserToServerMessage, { type: "rename_session" }>,
|
|
@@ -33,28 +34,61 @@ export function handleUnhideSession(
|
|
|
33
34
|
ctx.broadcast({ type: "session_updated", sessionId: msg.sessionId, updates });
|
|
34
35
|
}
|
|
35
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Shared attach-proposal apply logic. Used by both:
|
|
39
|
+
* - the browser-initiated `handleAttachProposal` flow, and
|
|
40
|
+
* - the spawn-with-attach pop-on-register flow in `pi-gateway.ts`
|
|
41
|
+
* (see change: add-folder-task-checker-and-spawn-attach).
|
|
42
|
+
*
|
|
43
|
+
* Idempotent: calling twice with the same `changeName` is safe — the auto-rename
|
|
44
|
+
* is gated by `attachRenameTarget` which short-circuits when the witness equality
|
|
45
|
+
* already holds (see ./proposal-attach-naming.ts).
|
|
46
|
+
*/
|
|
47
|
+
export function applyAttachProposal(
|
|
48
|
+
sessionId: string,
|
|
49
|
+
changeName: string,
|
|
50
|
+
ctx: Pick<BrowserHandlerContext, "sessionManager" | "piGateway" | "broadcast">,
|
|
51
|
+
): void {
|
|
52
|
+
const { sessionManager, piGateway, broadcast } = ctx;
|
|
53
|
+
const session = sessionManager.get(sessionId);
|
|
54
|
+
const updates: Record<string, unknown> = { attachedProposal: changeName };
|
|
55
|
+
|
|
56
|
+
const newName = attachRenameTarget(session, changeName);
|
|
57
|
+
if (newName !== undefined) {
|
|
58
|
+
updates.name = newName;
|
|
59
|
+
piGateway.sendToSession(sessionId, { type: "rename_session", sessionId, name: newName });
|
|
60
|
+
}
|
|
61
|
+
sessionManager.update(sessionId, updates);
|
|
62
|
+
broadcast({ type: "session_updated", sessionId, updates });
|
|
63
|
+
}
|
|
64
|
+
|
|
36
65
|
export function handleAttachProposal(
|
|
37
66
|
msg: Extract<BrowserToServerMessage, { type: "attach_proposal" }>,
|
|
38
67
|
ctx: BrowserHandlerContext,
|
|
39
68
|
): void {
|
|
40
|
-
|
|
41
|
-
const updates: Record<string, unknown> = { attachedProposal: msg.changeName };
|
|
42
|
-
const session = sessionManager.get(msg.sessionId);
|
|
43
|
-
if (session && !session.name?.trim()) {
|
|
44
|
-
updates.name = msg.changeName;
|
|
45
|
-
piGateway.sendToSession(msg.sessionId, { type: "rename_session", sessionId: msg.sessionId, name: msg.changeName });
|
|
46
|
-
}
|
|
47
|
-
sessionManager.update(msg.sessionId, updates);
|
|
48
|
-
broadcast({ type: "session_updated", sessionId: msg.sessionId, updates });
|
|
69
|
+
applyAttachProposal(msg.sessionId, msg.changeName, ctx);
|
|
49
70
|
}
|
|
50
71
|
|
|
51
72
|
export function handleDetachProposal(
|
|
52
73
|
msg: Extract<BrowserToServerMessage, { type: "detach_proposal" }>,
|
|
53
74
|
ctx: BrowserHandlerContext,
|
|
54
75
|
): void {
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
76
|
+
const { sessionManager, piGateway, broadcast } = ctx;
|
|
77
|
+
const session = sessionManager.get(msg.sessionId);
|
|
78
|
+
|
|
79
|
+
// Idempotent auto-revert (see change: fix-mobile-attach-proposal-display).
|
|
80
|
+
// See design.md decision matrix and ./proposal-attach-naming.ts.
|
|
81
|
+
const updates: Record<string, unknown> = {
|
|
82
|
+
attachedProposal: null,
|
|
83
|
+
openspecPhase: null,
|
|
84
|
+
openspecChange: null,
|
|
85
|
+
};
|
|
86
|
+
if (detachShouldClearName(session)) {
|
|
87
|
+
updates.name = undefined;
|
|
88
|
+
piGateway.sendToSession(msg.sessionId, { type: "rename_session", sessionId: msg.sessionId, name: "" });
|
|
89
|
+
}
|
|
90
|
+
sessionManager.update(msg.sessionId, updates);
|
|
91
|
+
broadcast({ type: "session_updated", sessionId: msg.sessionId, updates });
|
|
58
92
|
}
|
|
59
93
|
|
|
60
94
|
export function handleFetchContent(
|
|
@@ -55,6 +55,47 @@ async function sendEventBatches(
|
|
|
55
55
|
return stored.length > 0 ? stored[stored.length - 1].seq : 0;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Replay extension-declared UI state to a single browser. Sends:
|
|
60
|
+
*
|
|
61
|
+
* 1. one `ui_modules_list` (when modules exist) — Phase 1
|
|
62
|
+
* 2. one `ui_data_list` per cached `(event, items)` entry — Phase 1
|
|
63
|
+
* 3. one `ext_ui_decorator` per cached `Session.uiDecorators` entry — Phase 2
|
|
64
|
+
*
|
|
65
|
+
* Replay decorator messages NEVER carry `removed: true` — only live entries
|
|
66
|
+
* are replayed; deleted entries are already absent from the cache.
|
|
67
|
+
*
|
|
68
|
+
* Called immediately after every `replayPendingUiRequests` site so the full
|
|
69
|
+
* replay ordering is:
|
|
70
|
+
*
|
|
71
|
+
* events → pending UI requests → ui_modules_list → ui_data_list → ext_ui_decorator
|
|
72
|
+
*
|
|
73
|
+
* Exported so unit tests can drive it without standing up a full subscribe
|
|
74
|
+
* pipeline. See changes: add-extension-ui-modal, add-extension-ui-decorations.
|
|
75
|
+
*/
|
|
76
|
+
export function replayUiState(
|
|
77
|
+
ws: WebSocket,
|
|
78
|
+
sessionId: string,
|
|
79
|
+
ctx: Pick<BrowserHandlerContext, "sessionManager" | "sendTo">,
|
|
80
|
+
): void {
|
|
81
|
+
const { sessionManager, sendTo } = ctx;
|
|
82
|
+
const session = sessionManager.get(sessionId);
|
|
83
|
+
if (!session) return;
|
|
84
|
+
if (session.uiModules && session.uiModules.length > 0) {
|
|
85
|
+
sendTo(ws, { type: "ui_modules_list", sessionId, modules: session.uiModules } as any);
|
|
86
|
+
}
|
|
87
|
+
if (session.uiDataMap) {
|
|
88
|
+
for (const [event, items] of Object.entries(session.uiDataMap)) {
|
|
89
|
+
sendTo(ws, { type: "ui_data_list", sessionId, event, items } as any);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (session.uiDecorators) {
|
|
93
|
+
for (const descriptor of Object.values(session.uiDecorators)) {
|
|
94
|
+
sendTo(ws, { type: "ext_ui_decorator", sessionId, descriptor } as any);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
58
99
|
export function handleSubscribe(
|
|
59
100
|
msg: Extract<BrowserToServerMessage, { type: "subscribe" }>,
|
|
60
101
|
subs: Set<string>,
|
|
@@ -85,6 +126,7 @@ export function handleSubscribe(
|
|
|
85
126
|
sendEventBatches(ws, msg.sessionId, events, sendTo).then((lastSent) => {
|
|
86
127
|
clearReplaying(ws, msg.sessionId, lastSent);
|
|
87
128
|
replayPendingUiRequests(ws, msg.sessionId);
|
|
129
|
+
replayUiState(ws, msg.sessionId, ctx);
|
|
88
130
|
});
|
|
89
131
|
} else {
|
|
90
132
|
let events = eventStore.getEvents(msg.sessionId, lastSeq + 1);
|
|
@@ -97,10 +139,12 @@ export function handleSubscribe(
|
|
|
97
139
|
sendEventBatches(ws, msg.sessionId, events, sendTo).then((lastSent) => {
|
|
98
140
|
clearReplaying(ws, msg.sessionId, lastSent);
|
|
99
141
|
replayPendingUiRequests(ws, msg.sessionId);
|
|
142
|
+
replayUiState(ws, msg.sessionId, ctx);
|
|
100
143
|
});
|
|
101
144
|
} else {
|
|
102
145
|
sendEventBatches(ws, msg.sessionId, events, sendTo).then(() => {
|
|
103
146
|
replayPendingUiRequests(ws, msg.sessionId);
|
|
147
|
+
replayUiState(ws, msg.sessionId, ctx);
|
|
104
148
|
});
|
|
105
149
|
}
|
|
106
150
|
}
|
|
@@ -113,7 +157,7 @@ export function handleSubscribe(
|
|
|
113
157
|
events: [],
|
|
114
158
|
isLast: false,
|
|
115
159
|
});
|
|
116
|
-
directoryService.loadSessionEvents(msg.sessionId, session.sessionFile).then(async (result) => {
|
|
160
|
+
directoryService.loadSessionEvents(msg.sessionId, session.sessionFile, session.contextWindow).then(async (result) => {
|
|
117
161
|
if (result.success) {
|
|
118
162
|
for (const evt of result.events) {
|
|
119
163
|
eventStore.insertEvent(msg.sessionId, evt);
|
|
@@ -130,6 +174,7 @@ export function handleSubscribe(
|
|
|
130
174
|
for (const sub of subscribers) {
|
|
131
175
|
await sendEventBatches(sub, msg.sessionId, stored, sendTo);
|
|
132
176
|
replayPendingUiRequests(sub, msg.sessionId);
|
|
177
|
+
replayUiState(sub, msg.sessionId, ctx);
|
|
133
178
|
}
|
|
134
179
|
} else {
|
|
135
180
|
sendTo(ws, { type: "event_replay", sessionId: msg.sessionId, events: [], isLast: true });
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
import { createServer, type ServerConfig } from "./server.js";
|
|
19
19
|
import { loadConfig, ensureConfig } from "@blackbelt-technology/pi-dashboard-shared/config.js";
|
|
20
20
|
import { spawn } from "@blackbelt-technology/pi-dashboard-shared/platform/exec.js";
|
|
21
|
+
import { spawnNodeScript } from "@blackbelt-technology/pi-dashboard-shared/platform/node-spawn.js";
|
|
21
22
|
import { createRequire } from "node:module";
|
|
22
23
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
23
24
|
import fs from "node:fs";
|
|
@@ -51,6 +52,37 @@ import {
|
|
|
51
52
|
} from "@blackbelt-technology/pi-dashboard-shared/bridge-register.js";
|
|
52
53
|
import type { DashboardServer } from "./server.js";
|
|
53
54
|
import { updateBootstrapCompatibility } from "./pi-version-skew.js";
|
|
55
|
+
import type { BootstrapStateStore } from "./bootstrap-state.js";
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Emit a stderr warning at CLI startup when the resolved pi version is
|
|
59
|
+
* below `piCompatibility.minimum` (blocking) or below `.recommended`
|
|
60
|
+
* (advisory). Reads from the already-populated `bootstrapState` so no
|
|
61
|
+
* additional I/O happens here. See change: warn-pi-version-skew-in-cli.
|
|
62
|
+
*/
|
|
63
|
+
function logCompatibilityWarning(store: BootstrapStateStore): void {
|
|
64
|
+
const s = store.get();
|
|
65
|
+
const c = s.compatibility;
|
|
66
|
+
if (!c || !c.current) return;
|
|
67
|
+
// Below minimum: `updateBootstrapCompatibility` sets `error.message`.
|
|
68
|
+
// We treat the presence of a blocking error + upgradeRecommended as the
|
|
69
|
+
// below-minimum signal; `upgradeRecommended` alone means below-recommended.
|
|
70
|
+
if (s.error?.message && c.upgradeRecommended) {
|
|
71
|
+
console.error(
|
|
72
|
+
`[bootstrap] ⚠ pi ${c.current} is below the required minimum ${c.minimum}.`,
|
|
73
|
+
);
|
|
74
|
+
console.error(
|
|
75
|
+
`[bootstrap] All pi-dependent features (sessions, resources, openspec) will return 503.`,
|
|
76
|
+
);
|
|
77
|
+
console.error(`[bootstrap] Run: pi-dashboard upgrade-pi`);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (c.upgradeRecommended) {
|
|
81
|
+
console.warn(
|
|
82
|
+
`[bootstrap] pi ${c.current} is below the recommended ${c.recommended} — consider running \`pi-dashboard upgrade-pi\``,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
54
86
|
|
|
55
87
|
const SUBCOMMANDS = ["start", "stop", "restart", "status", "upgrade-pi"] as const;
|
|
56
88
|
type Subcommand = (typeof SUBCOMMANDS)[number];
|
|
@@ -191,6 +223,7 @@ async function runDegradedModeBootstrap(server: DashboardServer): Promise<void>
|
|
|
191
223
|
"package.json",
|
|
192
224
|
);
|
|
193
225
|
updateBootstrapCompatibility(server.bootstrapState, serverPkg);
|
|
226
|
+
logCompatibilityWarning(server.bootstrapState);
|
|
194
227
|
} catch (err) {
|
|
195
228
|
console.warn("[bootstrap] version-skew check failed (non-fatal):", err);
|
|
196
229
|
}
|
|
@@ -226,12 +259,11 @@ async function runDegradedModeBootstrap(server: DashboardServer): Promise<void>
|
|
|
226
259
|
return;
|
|
227
260
|
}
|
|
228
261
|
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
if (typeof maybeRescan === "function") maybeRescan.call(registry, "pi");
|
|
262
|
+
// Post-install registry rescan + openspec/pi-resources force-refresh
|
|
263
|
+
// are now centralized in server.ts's bootstrapState.subscribe hook,
|
|
264
|
+
// which fires on every installing → ready transition (this caller +
|
|
265
|
+
// triggerUpgradePi + triggerRetry).
|
|
266
|
+
// See change: fix-openspec-buttons-after-bootstrap-install.
|
|
235
267
|
|
|
236
268
|
// Attempt bridge registration. Failures are non-fatal per spec §10.3.
|
|
237
269
|
let bridgeErr: string | undefined;
|
|
@@ -260,6 +292,7 @@ async function runDegradedModeBootstrap(server: DashboardServer): Promise<void>
|
|
|
260
292
|
"package.json",
|
|
261
293
|
);
|
|
262
294
|
updateBootstrapCompatibility(server.bootstrapState, serverPkg);
|
|
295
|
+
logCompatibilityWarning(server.bootstrapState);
|
|
263
296
|
} catch (err) {
|
|
264
297
|
console.warn("[bootstrap] version-skew check failed (non-fatal):", err);
|
|
265
298
|
}
|
|
@@ -339,21 +372,31 @@ async function cmdStart(config: ServerConfig): Promise<void> {
|
|
|
339
372
|
`\n[${new Date().toISOString()}] pi-dashboard start (parent pid ${process.pid}, port ${config.port})\n`,
|
|
340
373
|
);
|
|
341
374
|
|
|
342
|
-
// tsLoader
|
|
343
|
-
//
|
|
344
|
-
const child =
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
375
|
+
// Both tsLoader and cliPath are wrapped as file:// URLs by spawnNodeScript.
|
|
376
|
+
// Required on Windows for node --import (see change: fix-windows-entry-script-url).
|
|
377
|
+
const child = spawnNodeScript({
|
|
378
|
+
loader: tsLoader,
|
|
379
|
+
entry: cliPath,
|
|
380
|
+
args,
|
|
381
|
+
spawnOptions: {
|
|
382
|
+
detached: true,
|
|
383
|
+
stdio: ["ignore", logFd, logFd],
|
|
384
|
+
env: { ...process.env },
|
|
385
|
+
},
|
|
348
386
|
});
|
|
349
387
|
child.unref();
|
|
350
388
|
// Close the parent's copy of the fd — child has its own via stdio inheritance.
|
|
351
389
|
try { fs.closeSync(logFd); } catch { /* ignore */ }
|
|
352
390
|
|
|
353
|
-
// Wait for dashboard to become available
|
|
354
|
-
|
|
391
|
+
// Wait for dashboard to become available. Windows + jiti cold-start can
|
|
392
|
+
// take 10s+ (TS compile on first boot, native module loads). 30s is the
|
|
393
|
+
// outer bound — if the server isn't up by then, something's genuinely wrong.
|
|
394
|
+
const READINESS_TIMEOUT_MS = 30_000;
|
|
395
|
+
const deadline = Date.now() + READINESS_TIMEOUT_MS;
|
|
355
396
|
let started = false;
|
|
356
397
|
while (Date.now() < deadline) {
|
|
398
|
+
// Also bail if the child has already exited (fast-path crash detection).
|
|
399
|
+
if (child.exitCode !== null) break;
|
|
357
400
|
await new Promise((r) => setTimeout(r, 300));
|
|
358
401
|
const status = await isDashboardRunning(config.port);
|
|
359
402
|
if (status.running) {
|
|
@@ -366,7 +409,10 @@ async function cmdStart(config: ServerConfig): Promise<void> {
|
|
|
366
409
|
const pid = readPid();
|
|
367
410
|
console.log(`Dashboard server started (pid ${pid ?? child.pid}) at http://localhost:${config.port}`);
|
|
368
411
|
} else {
|
|
369
|
-
|
|
412
|
+
const reason = child.exitCode !== null
|
|
413
|
+
? `child process exited with code ${child.exitCode}`
|
|
414
|
+
: `timed out after ${READINESS_TIMEOUT_MS / 1000}s`;
|
|
415
|
+
console.error(`Failed to start dashboard server (${reason})`);
|
|
370
416
|
console.error(`Check logs at ${path.join(logDir, "server.log")}`);
|
|
371
417
|
process.exit(1);
|
|
372
418
|
}
|
|
@@ -18,6 +18,8 @@ import {
|
|
|
18
18
|
pollOpenSpecAsync,
|
|
19
19
|
runOpenSpecList,
|
|
20
20
|
runOpenSpecStatus,
|
|
21
|
+
createFsProbeFactory,
|
|
22
|
+
createFsSpecsProbeFactory,
|
|
21
23
|
} from "@blackbelt-technology/pi-dashboard-shared/openspec-poller.js";
|
|
22
24
|
import { DEFAULT_OPENSPEC_POLL, type OpenSpecPollConfig } from "@blackbelt-technology/pi-dashboard-shared/config.js";
|
|
23
25
|
import { createSemaphore, type Semaphore } from "@blackbelt-technology/pi-dashboard-shared/semaphore.js";
|
|
@@ -46,7 +48,7 @@ export interface DirectoryAddedResult {
|
|
|
46
48
|
export interface DirectoryService {
|
|
47
49
|
knownDirectories(): string[];
|
|
48
50
|
discoverSessions(cwd: string): DiscoveredSession[];
|
|
49
|
-
loadSessionEvents(sessionId: string, sessionFile: string): Promise<LoadResult>;
|
|
51
|
+
loadSessionEvents(sessionId: string, sessionFile: string, knownContextWindow?: number): Promise<LoadResult>;
|
|
50
52
|
getOpenSpecData(cwd: string): OpenSpecData | undefined;
|
|
51
53
|
/** Force refresh: bypasses the mtime gate. Still honors the semaphore. */
|
|
52
54
|
refreshOpenSpec(cwd: string): Promise<OpenSpecData>;
|
|
@@ -86,6 +88,66 @@ function statMtimeOr(p: string): number | undefined {
|
|
|
86
88
|
}
|
|
87
89
|
}
|
|
88
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Maximum mtime across a fixed list of paths. Missing paths (ENOENT) are
|
|
93
|
+
* skipped — they don't poison the result. Returns `undefined` only when
|
|
94
|
+
* every input is missing.
|
|
95
|
+
*
|
|
96
|
+
* Used by the change-detection gate to catch in-place file edits that
|
|
97
|
+
* don't bump any parent directory's mtime on POSIX. See change:
|
|
98
|
+
* fix-openspec-mtime-gate-blind-spots.
|
|
99
|
+
*/
|
|
100
|
+
export function effectiveMtimeOr(paths: string[]): number | undefined {
|
|
101
|
+
let max: number | undefined;
|
|
102
|
+
for (const p of paths) {
|
|
103
|
+
const m = statMtimeOr(p);
|
|
104
|
+
if (m === undefined) continue;
|
|
105
|
+
if (max === undefined || m > max) max = m;
|
|
106
|
+
}
|
|
107
|
+
return max;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* File set tracked by the per-change effective-mtime computation.
|
|
112
|
+
*
|
|
113
|
+
* The base set covers the change directory itself plus the three top-level
|
|
114
|
+
* artifact files. The `specs/` fan-out catches multi-spec authoring:
|
|
115
|
+
*
|
|
116
|
+
* - `<change>/specs/` — advances on capability dir create/remove
|
|
117
|
+
* - `<change>/specs/<cap>/` — advances when `spec.md` is created inside
|
|
118
|
+
* - `<change>/specs/<cap>/spec.md` — advances on in-place edits
|
|
119
|
+
*
|
|
120
|
+
* `readdirSync` is wrapped in try/catch so missing `specs/` (or any fs error)
|
|
121
|
+
* yields an empty fan-out rather than throwing.
|
|
122
|
+
*
|
|
123
|
+
* See change: fix-openspec-specs-mtime-gate-blind-spot.
|
|
124
|
+
*/
|
|
125
|
+
function perChangeArtifactPaths(changesRoot: string, name: string): string[] {
|
|
126
|
+
const dir = path.join(changesRoot, name);
|
|
127
|
+
const base = [
|
|
128
|
+
dir,
|
|
129
|
+
path.join(dir, "tasks.md"),
|
|
130
|
+
path.join(dir, "proposal.md"),
|
|
131
|
+
path.join(dir, "design.md"),
|
|
132
|
+
];
|
|
133
|
+
const specsDir = path.join(dir, "specs");
|
|
134
|
+
const specsExtras: string[] = [specsDir];
|
|
135
|
+
try {
|
|
136
|
+
const entries = fs.readdirSync(specsDir, { withFileTypes: true });
|
|
137
|
+
for (const e of entries) {
|
|
138
|
+
if (e.isDirectory()) {
|
|
139
|
+
const capDir = path.join(specsDir, e.name);
|
|
140
|
+
specsExtras.push(capDir);
|
|
141
|
+
specsExtras.push(path.join(capDir, "spec.md"));
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
} catch {
|
|
145
|
+
// ENOENT, permission denied, etc. — leave specsExtras with just specsDir
|
|
146
|
+
// (its own statMtimeOr will return undefined and be excluded from max).
|
|
147
|
+
}
|
|
148
|
+
return [...base, ...specsExtras];
|
|
149
|
+
}
|
|
150
|
+
|
|
89
151
|
// ── Per-directory cache ────────────────────────────────────────────
|
|
90
152
|
type PerChangeEntry = {
|
|
91
153
|
mtimeMs: number | undefined;
|
|
@@ -137,7 +199,7 @@ export function createDirectoryService(
|
|
|
137
199
|
return discoverSessionsForCwd(cwd);
|
|
138
200
|
}
|
|
139
201
|
|
|
140
|
-
async function loadSessionEvents(sessionId: string, sessionFile: string): Promise<LoadResult> {
|
|
202
|
+
async function loadSessionEvents(sessionId: string, sessionFile: string, knownContextWindow?: number): Promise<LoadResult> {
|
|
141
203
|
if (loadingSet.has(sessionId)) {
|
|
142
204
|
return { success: false, events: [], error: "already_loading" };
|
|
143
205
|
}
|
|
@@ -145,7 +207,9 @@ export function createDirectoryService(
|
|
|
145
207
|
try {
|
|
146
208
|
const { loadSessionEntries } = await import("./session-file-reader.js");
|
|
147
209
|
const entries = loadSessionEntries(sessionFile);
|
|
148
|
-
|
|
210
|
+
// Pass persisted contextWindow so replay's stats_update events use the
|
|
211
|
+
// real value instead of inferContextWindow(modelId)'s 200k Claude default.
|
|
212
|
+
const eventMessages = replayEntriesAsEvents(sessionId, entries, knownContextWindow);
|
|
149
213
|
const events = eventMessages.map((m) => m.event);
|
|
150
214
|
return { success: true, events };
|
|
151
215
|
} catch (err: any) {
|
|
@@ -181,8 +245,21 @@ export function createDirectoryService(
|
|
|
181
245
|
}
|
|
182
246
|
|
|
183
247
|
// ── Step 1: list (gated) ──
|
|
248
|
+
//
|
|
249
|
+
// The list-step gate signal must catch in-place edits to <change>/tasks.md
|
|
250
|
+
// because `completedTasks` / `totalTasks` are derived from those files. POSIX
|
|
251
|
+
// dir-mtime alone misses these edits (it only advances on entry create/
|
|
252
|
+
// delete/rename), so we union the parent-dir mtime with each known
|
|
253
|
+
// tasks.md file's mtime. See change: fix-openspec-mtime-gate-blind-spots.
|
|
184
254
|
let listResult: typeof cache.listResult = cache.listResult;
|
|
185
|
-
|
|
255
|
+
let listSignal: number | undefined = rootMtime;
|
|
256
|
+
if (cache.listResult !== undefined) {
|
|
257
|
+
const taskFiles = cache.listResult.map((c) =>
|
|
258
|
+
path.join(changesRoot, c.name, "tasks.md"),
|
|
259
|
+
);
|
|
260
|
+
listSignal = effectiveMtimeOr([changesRoot, ...taskFiles]) ?? rootMtime;
|
|
261
|
+
}
|
|
262
|
+
const listCacheValid = gateEnabled && cache.listMtimeMs === listSignal && cache.listResult !== undefined;
|
|
186
263
|
if (!listCacheValid) {
|
|
187
264
|
const raw = await semaphore.run(() => runOpenSpecList(cwd));
|
|
188
265
|
if (!raw || !Array.isArray(raw.changes)) {
|
|
@@ -195,7 +272,12 @@ export function createDirectoryService(
|
|
|
195
272
|
return empty;
|
|
196
273
|
}
|
|
197
274
|
listResult = raw.changes;
|
|
198
|
-
|
|
275
|
+
// Recompute the signal against the freshly returned change set so the
|
|
276
|
+
// cache stamps the same shape we'll compare against on the next tick.
|
|
277
|
+
const taskFiles = (listResult ?? []).map((c) =>
|
|
278
|
+
path.join(changesRoot, c.name, "tasks.md"),
|
|
279
|
+
);
|
|
280
|
+
cache.listMtimeMs = effectiveMtimeOr([changesRoot, ...taskFiles]) ?? rootMtime;
|
|
199
281
|
cache.listResult = listResult;
|
|
200
282
|
}
|
|
201
283
|
|
|
@@ -206,14 +288,27 @@ export function createDirectoryService(
|
|
|
206
288
|
}
|
|
207
289
|
|
|
208
290
|
// ── Step 2: per-change status (gated) ──
|
|
291
|
+
//
|
|
292
|
+
// TOCTOU note: we capture the file-aware effective mtime BEFORE invoking
|
|
293
|
+
// `openspec status` and stamp THAT value into the cache. If a tracked
|
|
294
|
+
// artifact file is written during the CLI invocation, the post-call mtime
|
|
295
|
+
// will differ from `preCallMtime` and we discard this tick's status for
|
|
296
|
+
// that change — leaving the prior cache entry (if any) untouched so the
|
|
297
|
+
// next gated tick re-polls naturally. See change:
|
|
298
|
+
// fix-openspec-mtime-gate-toctou.
|
|
209
299
|
const statusResults = new Map<string, { artifacts?: Array<{ id: string; status: string }>; isComplete?: boolean } | null>();
|
|
300
|
+
const preCallMtimes = new Map<string, number | undefined>();
|
|
301
|
+
const racyNames = new Set<string>();
|
|
210
302
|
|
|
211
303
|
await Promise.all((listResult ?? []).map(async (c) => {
|
|
212
|
-
|
|
213
|
-
|
|
304
|
+
// File-aware effective mtime: catches in-place edits to tasks.md /
|
|
305
|
+
// proposal.md / design.md that POSIX dir-mtime misses. See change:
|
|
306
|
+
// fix-openspec-mtime-gate-blind-spots.
|
|
307
|
+
const preCallMtime = effectiveMtimeOr(perChangeArtifactPaths(changesRoot, c.name));
|
|
308
|
+
preCallMtimes.set(c.name, preCallMtime);
|
|
214
309
|
const cached = cache.changes.get(c.name);
|
|
215
310
|
|
|
216
|
-
if (gateEnabled && cached && cached.mtimeMs !== undefined && cached.mtimeMs ===
|
|
311
|
+
if (gateEnabled && cached && cached.mtimeMs !== undefined && cached.mtimeMs === preCallMtime) {
|
|
217
312
|
// Cache hit. Reuse the artifacts/isComplete from the cached OpenSpecChange.
|
|
218
313
|
statusResults.set(c.name, {
|
|
219
314
|
artifacts: cached.change.artifacts.map((a) => ({ id: a.id, status: a.status })),
|
|
@@ -223,17 +318,51 @@ export function createDirectoryService(
|
|
|
223
318
|
}
|
|
224
319
|
|
|
225
320
|
const status = await semaphore.run(() => runOpenSpecStatus(cwd, c.name));
|
|
321
|
+
|
|
322
|
+
// TOCTOU check. If any tracked artifact path was written between the
|
|
323
|
+
// pre-call stat and now, the CLI's view of disk is stale relative to
|
|
324
|
+
// the mtime we'd stamp — discard. See change: fix-openspec-mtime-gate-toctou.
|
|
325
|
+
const postCallMtime = effectiveMtimeOr(perChangeArtifactPaths(changesRoot, c.name));
|
|
326
|
+
if (preCallMtime !== postCallMtime) {
|
|
327
|
+
if (typeof process !== "undefined" && /pi-dashboard|openspec-poll/.test(process.env?.DEBUG ?? "")) {
|
|
328
|
+
// eslint-disable-next-line no-console
|
|
329
|
+
console.warn(
|
|
330
|
+
`[fix-openspec-mtime-gate-toctou] discarded racy status for ${c.name} (pre=${preCallMtime} post=${postCallMtime})`,
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
racyNames.add(c.name);
|
|
334
|
+
if (cached) {
|
|
335
|
+
// Reuse the prior cached status so buildOpenSpecData doesn't render
|
|
336
|
+
// an empty artifact list for this tick. Cache entry is preserved
|
|
337
|
+
// unchanged below by skipping the stamping loop for racy names.
|
|
338
|
+
statusResults.set(c.name, {
|
|
339
|
+
artifacts: cached.change.artifacts.map((a) => ({ id: a.id, status: a.status })),
|
|
340
|
+
...(cached.change.isComplete !== undefined ? { isComplete: cached.change.isComplete } : {}),
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
226
345
|
statusResults.set(c.name, status);
|
|
227
346
|
}));
|
|
228
347
|
|
|
229
348
|
// ── Step 3: build + cache + return ──
|
|
230
|
-
const data = buildOpenSpecData(
|
|
231
|
-
|
|
232
|
-
|
|
349
|
+
const data = buildOpenSpecData(
|
|
350
|
+
{ changes: listResult ?? [] },
|
|
351
|
+
statusResults,
|
|
352
|
+
createFsProbeFactory(cwd),
|
|
353
|
+
createFsSpecsProbeFactory(cwd),
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
// Stamp the cache with the pre-call mtime — i.e. the mtime that
|
|
357
|
+
// demonstrably reflects the file state observed by the CLI. Skip racy
|
|
358
|
+
// names so the next gated tick re-polls. See change:
|
|
359
|
+
// fix-openspec-mtime-gate-toctou.
|
|
233
360
|
for (const change of data.changes) {
|
|
234
|
-
|
|
235
|
-
const
|
|
236
|
-
|
|
361
|
+
if (racyNames.has(change.name)) continue;
|
|
362
|
+
const stampMtime = preCallMtimes.has(change.name)
|
|
363
|
+
? preCallMtimes.get(change.name)
|
|
364
|
+
: effectiveMtimeOr(perChangeArtifactPaths(changesRoot, change.name));
|
|
365
|
+
cache.changes.set(change.name, { mtimeMs: stampMtime, change });
|
|
237
366
|
}
|
|
238
367
|
cache.data = data;
|
|
239
368
|
caches.set(cwd, cache);
|
|
@@ -242,6 +371,14 @@ export function createDirectoryService(
|
|
|
242
371
|
|
|
243
372
|
async function refreshOpenSpec(cwd: string): Promise<OpenSpecData> {
|
|
244
373
|
try {
|
|
374
|
+
// User-initiated refresh bypasses the gate. The gate is heuristic; the
|
|
375
|
+
// CLI is authoritative. When the user clicks the OpenSpec refresh icon
|
|
376
|
+
// they expect fresh data, never silently-cached data — force-mode is
|
|
377
|
+
// the user's escape hatch when the gate's heuristic is wrong, while
|
|
378
|
+
// periodic paths (`pollDirectoryGated`, `onDirectoryAdded`,
|
|
379
|
+
// `handleOpenSpecBulkArchive` post-archive refresh) stay gated.
|
|
380
|
+
// See changes: fix-openspec-mtime-gate-toctou (re-introduced force=true),
|
|
381
|
+
// fix-openspec-mtime-gate-blind-spots (initial removal of force=true).
|
|
245
382
|
return await pollOne(cwd, true);
|
|
246
383
|
} catch {
|
|
247
384
|
// Fall back to the legacy monolithic path so "refresh" never silently fails.
|
|
@@ -383,9 +520,13 @@ export function createDirectoryService(
|
|
|
383
520
|
},
|
|
384
521
|
|
|
385
522
|
async onDirectoryAdded(cwd: string): Promise<DirectoryAddedResult> {
|
|
523
|
+
// Internal path — use the gated poll, not the user-facing
|
|
524
|
+
// `refreshOpenSpec` (which now bypasses the gate). For a freshly-added
|
|
525
|
+
// directory the cache is empty, so the gate lets the CLI run anyway.
|
|
526
|
+
// See change: fix-openspec-mtime-gate-toctou.
|
|
386
527
|
const [sessions, openspecData] = await Promise.all([
|
|
387
528
|
discoverSessions(cwd),
|
|
388
|
-
|
|
529
|
+
pollDirectoryGated(cwd),
|
|
389
530
|
]);
|
|
390
531
|
return { sessions, openspecData };
|
|
391
532
|
},
|