@dimina-kit/devtools 0.4.0-dev.20260728080958 → 0.4.0-dev.20260728095034

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 (43) hide show
  1. package/dist/main/app/app.js +11 -3
  2. package/dist/main/index.bundle.js +319 -81
  3. package/dist/main/ipc/settings.js +4 -0
  4. package/dist/main/services/mcp/server.d.ts +2 -1
  5. package/dist/main/services/mcp/server.js +6 -3
  6. package/dist/main/services/mcp/tools/project-tools.d.ts +49 -0
  7. package/dist/main/services/mcp/tools/project-tools.js +121 -0
  8. package/dist/main/services/notifications/renderer-notifier.d.ts +11 -0
  9. package/dist/main/services/notifications/renderer-notifier.js +3 -0
  10. package/dist/main/services/workbench-context.d.ts +14 -0
  11. package/dist/main/services/workbench-context.js +9 -1
  12. package/dist/main/services/workspace/compile-log-buffer.d.ts +40 -0
  13. package/dist/main/services/workspace/compile-log-buffer.js +41 -0
  14. package/dist/main/services/workspace/session-status-store.d.ts +47 -0
  15. package/dist/main/services/workspace/session-status-store.js +73 -0
  16. package/dist/main/services/workspace/status-tap.d.ts +20 -0
  17. package/dist/main/services/workspace/status-tap.js +32 -0
  18. package/dist/native-host/common/common.js +58 -154
  19. package/dist/native-host/container/pageFrame.css +1 -1
  20. package/dist/native-host/render/render.js +3297 -6602
  21. package/dist/native-host/service/service.js +2 -2
  22. package/dist/preload/windows/host-toolbar-runtime.cjs.map +2 -2
  23. package/dist/preload/windows/simulator.cjs.map +1 -1
  24. package/dist/renderer/assets/index-BJlFbXHb.js +49 -0
  25. package/dist/renderer/assets/{input-dsgeTmVX.js → input-xdmfv-eH.js} +2 -2
  26. package/dist/renderer/assets/{ipc-transport-BgFdT9bT.js → ipc-transport-B3EpmfNS.js} +2 -2
  27. package/dist/renderer/assets/popover-B88e2hkg.js +2 -0
  28. package/dist/renderer/assets/{select-DAw1hzkl.js → select-CD9CJyhI.js} +2 -2
  29. package/dist/renderer/assets/settings-44hf0QtO.js +2 -0
  30. package/dist/renderer/assets/settings-api-pJKPuP1M.js +2 -0
  31. package/dist/renderer/assets/workbenchSettings-DKD5U1Ql.js +8 -0
  32. package/dist/renderer/entries/main/index.html +5 -5
  33. package/dist/renderer/entries/popover/index.html +4 -4
  34. package/dist/renderer/entries/settings/index.html +4 -4
  35. package/dist/renderer/entries/workbench-settings/index.html +3 -3
  36. package/dist/shared/ipc-channels.d.ts +2 -0
  37. package/dist/shared/ipc-channels.js +2 -0
  38. package/package.json +5 -5
  39. package/dist/renderer/assets/index-M5NDq_vi.js +0 -49
  40. package/dist/renderer/assets/popover-Disfu1cx.js +0 -2
  41. package/dist/renderer/assets/settings-M_8GFW2M.js +0 -2
  42. package/dist/renderer/assets/settings-api-DFR5eDl_.js +0 -2
  43. package/dist/renderer/assets/workbenchSettings-CxdXeoOu.js +0 -8
@@ -20,6 +20,10 @@ export function registerSettingsIpc(ctx) {
20
20
  .handle(WorkbenchSettingsChannel.SetTheme, (_, ...args) => {
21
21
  const [theme] = validate(WorkbenchSettingsChannel.SetTheme, WorkbenchSettingsSetThemeSchema, args);
22
22
  applyTheme(theme);
23
+ })
24
+ .handle(WorkbenchSettingsChannel.Restart, () => {
25
+ app.relaunch();
26
+ app.quit();
23
27
  })
24
28
  .handle(WorkbenchSettingsChannel.GetCdpStatus, () => {
25
29
  const settings = loadWorkbenchSettings();
@@ -7,5 +7,6 @@
7
7
  * only wires up transports and lifecycle.
8
8
  */
9
9
  import { type Disposable } from '@dimina-kit/electron-deck/main';
10
- export declare function startMcpServer(resolvedCdpPort: number, mcpPort: number): Disposable;
10
+ import { type McpProjectHost } from './tools/project-tools.js';
11
+ export declare function startMcpServer(resolvedCdpPort: number, mcpPort: number, projectHost: McpProjectHost): Disposable;
11
12
  //# sourceMappingURL=server.d.ts.map
@@ -15,11 +15,12 @@ import { connectTarget, setCdpPort } from './target-manager.js';
15
15
  import { recordMcpFailed, recordMcpStarted, recordMcpStopped } from './status.js';
16
16
  import { registerCommonTargetTools } from './tool-registry.js';
17
17
  import { registerContextTools } from './tools/context-tools.js';
18
+ import { registerProjectTools } from './tools/project-tools.js';
18
19
  import { registerSimulatorTools } from './tools/simulator-tools.js';
19
20
  import { registerWorkbenchTools } from './tools/workbench-tools.js';
20
21
  const require = createRequire(import.meta.url);
21
22
  const pkg = require('../../../../package.json');
22
- function buildServer() {
23
+ function buildServer(projectHost) {
23
24
  const server = new McpServer({
24
25
  name: '@dimina-kit/devtools',
25
26
  version: pkg.version,
@@ -32,9 +33,11 @@ function buildServer() {
32
33
  registerWorkbenchTools(server);
33
34
  // Cross-target: AI orientation overview
34
35
  registerContextTools(server);
36
+ // Project lifecycle (open/close/status/wait/compile logs).
37
+ registerProjectTools(server, projectHost);
35
38
  return server;
36
39
  }
37
- export function startMcpServer(resolvedCdpPort, mcpPort) {
40
+ export function startMcpServer(resolvedCdpPort, mcpPort, projectHost) {
38
41
  setCdpPort(resolvedCdpPort);
39
42
  // Connect to both targets (non-blocking)
40
43
  connectTarget('simulator').catch(() => { });
@@ -55,7 +58,7 @@ export function startMcpServer(resolvedCdpPort, mcpPort) {
55
58
  // closures over already-shared module-level state (target-manager's
56
59
  // CDP connections/listeners) — it has no per-call side effects, so
57
60
  // building N servers is cheap and duplicates nothing.
58
- await buildServer().connect(transport);
61
+ await buildServer(projectHost).connect(transport);
59
62
  }
60
63
  catch (err) {
61
64
  transports.delete(transport.sessionId);
@@ -0,0 +1,49 @@
1
+ /**
2
+ * MCP project lifecycle tools: open (launch the mini-program in the
3
+ * simulator), close, query compile status, await the compile settling, and
4
+ * pull compile logs incrementally.
5
+ *
6
+ * `project_open` does NOT call `workspace.openProject` directly: the renderer
7
+ * owns the open path (mounting ProjectRuntime is what compiles AND attaches
8
+ * the simulator), so the tool pushes a `window:openProject` event and then
9
+ * awaits the session-status store — the same main-process authority the
10
+ * notifier tap feeds from the renderer-driven compile.
11
+ */
12
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
13
+ import type { CompileLogBuffer } from '../../workspace/compile-log-buffer.js';
14
+ import type { SessionStatusStore } from '../../workspace/session-status-store.js';
15
+ /**
16
+ * Narrow structural slice of WorkspaceService the project tools need.
17
+ * Structural (not a Pick of the real interface) so tests can hand in a
18
+ * plain object and this module never imports workbench-context.
19
+ */
20
+ export interface McpProjectWorkspace {
21
+ validateProjectDir(dirPath: string): Promise<string | null>;
22
+ hasProject(dirPath: string): Promise<boolean>;
23
+ addProject(dirPath: string): Promise<{
24
+ name: string;
25
+ path: string;
26
+ }>;
27
+ listProjects(): Promise<{
28
+ name: string;
29
+ path: string;
30
+ }[]>;
31
+ closeProject(): Promise<void>;
32
+ getProjectPath(): string;
33
+ hasActiveSession(): boolean;
34
+ }
35
+ /** Everything `registerProjectTools` needs, assembled by app bootstrap. */
36
+ export interface McpProjectHost {
37
+ workspace: McpProjectWorkspace;
38
+ sessionStatus: SessionStatusStore;
39
+ compileLogs: CompileLogBuffer;
40
+ /** Push the renderer to open the project (the user-click-equivalent path). */
41
+ requestOpenInUi(project: {
42
+ name: string;
43
+ path: string;
44
+ }): void;
45
+ /** Push the renderer back to the project list after a close. */
46
+ requestNavigateBack(): void;
47
+ }
48
+ export declare function registerProjectTools(server: McpServer, host: McpProjectHost): void;
49
+ //# sourceMappingURL=project-tools.d.ts.map
@@ -0,0 +1,121 @@
1
+ /**
2
+ * MCP project lifecycle tools: open (launch the mini-program in the
3
+ * simulator), close, query compile status, await the compile settling, and
4
+ * pull compile logs incrementally.
5
+ *
6
+ * `project_open` does NOT call `workspace.openProject` directly: the renderer
7
+ * owns the open path (mounting ProjectRuntime is what compiles AND attaches
8
+ * the simulator), so the tool pushes a `window:openProject` event and then
9
+ * awaits the session-status store — the same main-process authority the
10
+ * notifier tap feeds from the renderer-driven compile.
11
+ */
12
+ import path from 'node:path';
13
+ import { z } from 'zod';
14
+ const DEFAULT_OPEN_TIMEOUT_MS = 180_000;
15
+ const DEFAULT_WAIT_TIMEOUT_MS = 60_000;
16
+ function text(payload) {
17
+ return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
18
+ }
19
+ function errorText(message) {
20
+ return { content: [{ type: 'text', text: `Error: ${message}` }], isError: true };
21
+ }
22
+ /**
23
+ * Public phase: a settled store snapshot with no live session means no
24
+ * project is open ('idle'), while 'error'/'compiling' pass through as-is —
25
+ * `hasActiveSession()` is false during a compile and after a failed open,
26
+ * so those phases are the truthful answer even without a session.
27
+ */
28
+ function publicPhase(snapshot, sessionActive) {
29
+ if (!sessionActive && snapshot.phase === 'ready')
30
+ return 'idle';
31
+ return snapshot.phase;
32
+ }
33
+ function statusPayload(host) {
34
+ const snapshot = host.sessionStatus.get();
35
+ const sessionActive = host.workspace.hasActiveSession();
36
+ return {
37
+ phase: publicPhase(snapshot, sessionActive),
38
+ message: snapshot.message,
39
+ projectPath: host.workspace.getProjectPath(),
40
+ sessionActive,
41
+ watcherAlive: snapshot.watcherAlive,
42
+ updatedAt: snapshot.updatedAt,
43
+ generation: snapshot.generation,
44
+ };
45
+ }
46
+ export function registerProjectTools(server, host) {
47
+ server.tool('project_open', 'Open a mini-program project: registers the directory if needed, launches it in the simulator (compile + attach), and waits until the compile settles. Returns the settled status; on compile error, pull stderr via compile_logs.', {
48
+ path: z.string().describe('Absolute path to the mini-program project directory'),
49
+ timeoutMs: z.number().optional().default(DEFAULT_OPEN_TIMEOUT_MS)
50
+ .describe('Max time to wait for the compile to settle'),
51
+ }, async ({ path: rawPath, timeoutMs }) => {
52
+ const dir = path.resolve(rawPath);
53
+ // Already open and settled: report instead of re-triggering a compile.
54
+ const current = host.sessionStatus.get();
55
+ if (host.workspace.getProjectPath() === dir
56
+ && host.workspace.hasActiveSession()
57
+ && current.phase === 'ready') {
58
+ return text({ ...statusPayload(host), note: 'project already open' });
59
+ }
60
+ const invalid = await host.workspace.validateProjectDir(dir);
61
+ if (invalid)
62
+ return errorText(invalid);
63
+ let project;
64
+ if (await host.workspace.hasProject(dir)) {
65
+ const known = (await host.workspace.listProjects()).find((p) => p.path === dir);
66
+ project = known ?? { name: path.basename(dir), path: dir };
67
+ }
68
+ else {
69
+ project = await host.workspace.addProject(dir);
70
+ }
71
+ // Snapshot the generation BEFORE the trigger so a previous session's
72
+ // settled state can never be mistaken for this open's result.
73
+ const afterGeneration = host.sessionStatus.get().generation;
74
+ host.requestOpenInUi({ name: project.name, path: project.path });
75
+ let settled;
76
+ try {
77
+ settled = await host.sessionStatus.waitForSettled({ afterGeneration, timeoutMs });
78
+ }
79
+ catch (err) {
80
+ return errorText(err instanceof Error ? err.message : String(err));
81
+ }
82
+ if (settled.phase === 'error') {
83
+ return errorText(`compile failed: ${settled.message} — call compile_logs (stream: "stderr") for details`);
84
+ }
85
+ return text(statusPayload(host));
86
+ });
87
+ server.tool('project_close', 'Close the currently open project session and return the workbench to the project list.', {}, async () => {
88
+ if (!host.workspace.getProjectPath() && !host.workspace.hasActiveSession()) {
89
+ return text({ closed: false, note: 'no project is open' });
90
+ }
91
+ await host.workspace.closeProject();
92
+ host.requestNavigateBack();
93
+ return text({ closed: true });
94
+ });
95
+ server.tool('project_status', 'Query the current project compile status (phase: idle | compiling | ready | error). Includes `generation` — pass it to project_wait_ready to await the NEXT settled state.', {}, async () => text(statusPayload(host)));
96
+ server.tool('project_wait_ready', 'Wait until the compile pipeline settles (phase leaves "compiling"). Watcher rebuilds emit no "compiling" phase, so to await a rebuild after editing files pass the `generation` from a prior project_status.', {
97
+ afterGeneration: z.number().optional()
98
+ .describe('Only accept states recorded after this generation (from project_status). Omit to accept the current state when already settled.'),
99
+ timeoutMs: z.number().optional().default(DEFAULT_WAIT_TIMEOUT_MS)
100
+ .describe('Max time to wait'),
101
+ }, async ({ afterGeneration, timeoutMs }) => {
102
+ try {
103
+ await host.sessionStatus.waitForSettled({ afterGeneration, timeoutMs });
104
+ }
105
+ catch (err) {
106
+ return errorText(err instanceof Error ? err.message : String(err));
107
+ }
108
+ return text(statusPayload(host));
109
+ });
110
+ server.tool('compile_logs', 'Pull compile log lines incrementally. Pass the previous call\'s nextCursor as `cursor` to receive only new lines. The buffer resets on each fresh project open; watcher rebuilds append to the same timeline.', {
111
+ cursor: z.number().optional().default(0)
112
+ .describe('Return only lines after this cursor (0 = from the start of the buffer)'),
113
+ limit: z.number().optional().default(100).describe('Max lines to return'),
114
+ stream: z.enum(['stdout', 'stderr']).optional()
115
+ .describe('Only lines from this stream (stderr carries compile errors)'),
116
+ }, async ({ cursor, limit, stream }) => {
117
+ const read = host.compileLogs.read({ afterSeq: cursor, limit, stream });
118
+ return text(read);
119
+ });
120
+ }
121
+ //# sourceMappingURL=project-tools.js.map
@@ -94,6 +94,17 @@ export interface RendererNotifier {
94
94
  compileLog(payload: CompileLogPayload): void;
95
95
  /** Ask the main renderer to navigate back to its landing screen. */
96
96
  windowNavigateBack(): void;
97
+ /**
98
+ * Ask the main renderer to open a project (mount the project runtime and
99
+ * start the compile), exactly as if the user clicked it in the list. The
100
+ * renderer owns the open path — the simulator only mounts when the
101
+ * ProjectRuntime component does — so main-side callers (MCP `project_open`)
102
+ * push this instead of calling `workspace.openProject` directly.
103
+ */
104
+ windowOpenProject(payload: {
105
+ name: string;
106
+ path: string;
107
+ }): void;
97
108
  /** Tell the main renderer the compile popover has been closed. */
98
109
  popoverClosed(): void;
99
110
  /** Ask the main renderer to relaunch the simulator with a new config. */
@@ -33,6 +33,9 @@ export function createRendererNotifier(ctx) {
33
33
  windowNavigateBack() {
34
34
  sendToMain(WindowChannel.NavigateBack);
35
35
  },
36
+ windowOpenProject(payload) {
37
+ sendToMain(WindowChannel.OpenProject, payload);
38
+ },
36
39
  popoverClosed() {
37
40
  sendToMain(PopoverChannel.Closed);
38
41
  },
@@ -12,6 +12,8 @@ import type { SyncStorageChange } from '../../shared/ipc-channels.js';
12
12
  import { DisposableRegistry, type ConnectionRegistry } from '@dimina-kit/electron-deck/main';
13
13
  import type { SenderPolicy } from '../utils/ipc-registry.js';
14
14
  import { type RendererNotifier } from './notifications/renderer-notifier.js';
15
+ import { type CompileLogBuffer } from './workspace/compile-log-buffer.js';
16
+ import { type SessionStatusStore } from './workspace/session-status-store.js';
15
17
  import { type ViewManager } from './views/view-manager.js';
16
18
  import { type WindowService } from './window-service.js';
17
19
  import { type WorkspaceService } from './workspace/workspace-service.js';
@@ -47,6 +49,18 @@ export interface WorkbenchContext {
47
49
  windows: WindowService;
48
50
  /** Unified main → renderer event dispatcher */
49
51
  notify: RendererNotifier;
52
+ /**
53
+ * Authoritative, queryable compile status of the active session. Fed by the
54
+ * notifier tap (every `notify.projectStatus` is recorded here first), read
55
+ * by MCP `project_status` / awaited by `project_open` / `project_wait_ready`.
56
+ */
57
+ sessionStatus: SessionStatusStore;
58
+ /**
59
+ * Cursor-readable ring buffer over the compile log lines. Fed by the
60
+ * notifier tap (every `notify.compileLog` is appended here first), read
61
+ * incrementally by MCP `compile_logs`.
62
+ */
63
+ compileLogBuffer: CompileLogBuffer;
50
64
  /**
51
65
  * Open (or re-focus) the standalone workbench-settings window. First-class
52
66
  * member so contract holders (`MiniappRuntime` / `MenuContext`) can open
@@ -3,6 +3,9 @@ import { createConnectionRegistry, DisposableRegistry, } from '@dimina-kit/elect
3
3
  import { createWorkbenchSenderPolicy } from '../utils/sender-policy.js';
4
4
  import { defaultAdapter } from './default-adapter.js';
5
5
  import { createRendererNotifier, } from './notifications/renderer-notifier.js';
6
+ import { createCompileLogBuffer, } from './workspace/compile-log-buffer.js';
7
+ import { createSessionStatusStore, } from './workspace/session-status-store.js';
8
+ import { tapNotifierIntoStores } from './workspace/status-tap.js';
6
9
  import { createViewManager } from './views/view-manager.js';
7
10
  import { createWindowService } from './window-service.js';
8
11
  import { openSettingsWindow } from '../windows/settings-window/index.js';
@@ -38,7 +41,12 @@ export function createWorkbenchContext(opts) {
38
41
  // the one place that releases the HOST-scoped toolbar (its view and the
39
42
  // ref-counted session-runtime preload) when the app/context winds down.
40
43
  ctx.registry.add(() => ctx.views.disposeAll());
41
- ctx.notify = createRendererNotifier(ctx);
44
+ ctx.sessionStatus = createSessionStatusStore();
45
+ ctx.compileLogBuffer = createCompileLogBuffer();
46
+ // Tap the notify gate: the two compile-state pushes land in the
47
+ // main-process authorities above BEFORE the renderer broadcast, so MCP
48
+ // reads and the renderer view derive from the same single site.
49
+ ctx.notify = tapNotifierIntoStores(createRendererNotifier(ctx), ctx.sessionStatus, ctx.compileLogBuffer);
42
50
  // Lazy closure (not a bound snapshot): reads ctx.windows/notify/rendererDir
43
51
  // at call time through the live context, which structurally satisfies the
44
52
  // helper's narrow OpenSettingsWindowDeps.
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Ring buffer over the per-line dmcc compile log, readable by cursor.
3
+ *
4
+ * The renderer gets each line pushed (`notify.compileLog`); MCP clients pull
5
+ * incrementally instead: pass the previous read's `nextCursor` to receive
6
+ * only lines that arrived since. `seq` is monotonic for the buffer's whole
7
+ * lifetime — `clear()` (a fresh project open) drops the entries but never
8
+ * resets the counter, so a stale cursor from before the clear simply reads
9
+ * the new compile's lines instead of duplicating old ones.
10
+ */
11
+ export interface CompileLogRecord {
12
+ /** Monotonic id (1-based); never reused, survives clear(). */
13
+ seq: number;
14
+ /** Main-process capture time of the line. */
15
+ at: number;
16
+ stream: 'stdout' | 'stderr';
17
+ text: string;
18
+ }
19
+ export interface CompileLogRead {
20
+ /** Oldest-first entries with seq > afterSeq (post stream-filter), capped at limit. */
21
+ entries: CompileLogRecord[];
22
+ /** Pass back as the next read's afterSeq; unchanged when nothing new matched. */
23
+ nextCursor: number;
24
+ }
25
+ export interface CompileLogBuffer {
26
+ append(entry: {
27
+ at: number;
28
+ stream: 'stdout' | 'stderr';
29
+ text: string;
30
+ }): void;
31
+ read(opts?: {
32
+ afterSeq?: number;
33
+ limit?: number;
34
+ stream?: 'stdout' | 'stderr';
35
+ }): CompileLogRead;
36
+ /** Drop all entries (fresh compile timeline); seq keeps growing. */
37
+ clear(): void;
38
+ }
39
+ export declare function createCompileLogBuffer(capacity?: number): CompileLogBuffer;
40
+ //# sourceMappingURL=compile-log-buffer.d.ts.map
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Ring buffer over the per-line dmcc compile log, readable by cursor.
3
+ *
4
+ * The renderer gets each line pushed (`notify.compileLog`); MCP clients pull
5
+ * incrementally instead: pass the previous read's `nextCursor` to receive
6
+ * only lines that arrived since. `seq` is monotonic for the buffer's whole
7
+ * lifetime — `clear()` (a fresh project open) drops the entries but never
8
+ * resets the counter, so a stale cursor from before the clear simply reads
9
+ * the new compile's lines instead of duplicating old ones.
10
+ */
11
+ const DEFAULT_CAPACITY = 1000;
12
+ const DEFAULT_READ_LIMIT = 100;
13
+ export function createCompileLogBuffer(capacity = DEFAULT_CAPACITY) {
14
+ const entries = [];
15
+ let seq = 0;
16
+ return {
17
+ append(entry) {
18
+ entries.push({ seq: ++seq, ...entry });
19
+ if (entries.length > capacity)
20
+ entries.splice(0, entries.length - capacity);
21
+ },
22
+ read({ afterSeq = 0, limit = DEFAULT_READ_LIMIT, stream } = {}) {
23
+ const matched = [];
24
+ for (const entry of entries) {
25
+ if (entry.seq <= afterSeq)
26
+ continue;
27
+ if (stream && entry.stream !== stream)
28
+ continue;
29
+ matched.push(entry);
30
+ if (matched.length >= limit)
31
+ break;
32
+ }
33
+ const last = matched[matched.length - 1];
34
+ return { entries: matched, nextCursor: last ? last.seq : afterSeq };
35
+ },
36
+ clear() {
37
+ entries.length = 0;
38
+ },
39
+ };
40
+ }
41
+ //# sourceMappingURL=compile-log-buffer.js.map
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Authoritative, queryable record of the active project's compile status in
3
+ * the main process.
4
+ *
5
+ * `notify.projectStatus` is a fire-and-forget push to the renderer; MCP
6
+ * clients instead need to QUERY the latest state and AWAIT the next settled
7
+ * state. The notifier tap (see `status-tap.ts`) records every payload here
8
+ * BEFORE it is broadcast — the store is the authority, the renderer push is
9
+ * derived from the same single gate.
10
+ *
11
+ * "Settled" means the compile pipeline is not mid-flight: every phase except
12
+ * `'compiling'`. `generation` is a monotonic per-record counter so a waiter
13
+ * can ignore states recorded before its own trigger (e.g. `project_open`
14
+ * must not accept the PREVIOUS session's `'ready'` as its own result).
15
+ */
16
+ export type SessionPhase = 'idle' | 'compiling' | 'ready' | 'error';
17
+ export interface SessionStatusSnapshot {
18
+ phase: SessionPhase;
19
+ message: string;
20
+ /** False once the file watcher died mid-session (saves no longer trigger a rebuild). Reset when the next compile starts. */
21
+ watcherAlive: boolean;
22
+ /** Wall-clock time of the last recorded transition; 0 before the first one. */
23
+ updatedAt: number;
24
+ /** Monotonic counter bumped on every record. */
25
+ generation: number;
26
+ }
27
+ export interface SessionStatusStore {
28
+ get(): SessionStatusSnapshot;
29
+ /** Record one `projectStatus` payload (the notifier tap is the only expected caller). */
30
+ record(payload: {
31
+ status: string;
32
+ message: string;
33
+ watcher?: 'dead';
34
+ }): void;
35
+ /**
36
+ * Resolve with the first snapshot whose phase is settled (not 'compiling')
37
+ * AND whose generation is greater than `afterGeneration` (default: accept
38
+ * the current snapshot when it is already settled). Rejects after
39
+ * `timeoutMs` with the last observed phase in the message.
40
+ */
41
+ waitForSettled(opts: {
42
+ afterGeneration?: number;
43
+ timeoutMs: number;
44
+ }): Promise<SessionStatusSnapshot>;
45
+ }
46
+ export declare function createSessionStatusStore(): SessionStatusStore;
47
+ //# sourceMappingURL=session-status-store.d.ts.map
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Authoritative, queryable record of the active project's compile status in
3
+ * the main process.
4
+ *
5
+ * `notify.projectStatus` is a fire-and-forget push to the renderer; MCP
6
+ * clients instead need to QUERY the latest state and AWAIT the next settled
7
+ * state. The notifier tap (see `status-tap.ts`) records every payload here
8
+ * BEFORE it is broadcast — the store is the authority, the renderer push is
9
+ * derived from the same single gate.
10
+ *
11
+ * "Settled" means the compile pipeline is not mid-flight: every phase except
12
+ * `'compiling'`. `generation` is a monotonic per-record counter so a waiter
13
+ * can ignore states recorded before its own trigger (e.g. `project_open`
14
+ * must not accept the PREVIOUS session's `'ready'` as its own result).
15
+ */
16
+ function toPhase(status) {
17
+ if (status === 'compiling')
18
+ return 'compiling';
19
+ if (status === 'error')
20
+ return 'error';
21
+ // 'ready' plus any future non-error chatter: the pipeline is not mid-flight.
22
+ return 'ready';
23
+ }
24
+ export function createSessionStatusStore() {
25
+ let snapshot = {
26
+ phase: 'idle',
27
+ message: '',
28
+ watcherAlive: true,
29
+ updatedAt: 0,
30
+ generation: 0,
31
+ };
32
+ const waiters = new Set();
33
+ return {
34
+ get: () => snapshot,
35
+ record(payload) {
36
+ const phase = toPhase(payload.status);
37
+ snapshot = {
38
+ phase,
39
+ message: payload.message,
40
+ // A dead watcher is a session-lifetime fact; only a NEW compile
41
+ // (fresh open) resets it. Rebuild 'ready' chatter keeps it dead.
42
+ watcherAlive: payload.watcher === 'dead'
43
+ ? false
44
+ : phase === 'compiling' ? true : snapshot.watcherAlive,
45
+ updatedAt: Date.now(),
46
+ generation: snapshot.generation + 1,
47
+ };
48
+ for (const waiter of [...waiters])
49
+ waiter(snapshot);
50
+ },
51
+ waitForSettled({ afterGeneration = -1, timeoutMs }) {
52
+ const isSettled = (s) => s.phase !== 'compiling' && s.generation > afterGeneration;
53
+ if (isSettled(snapshot))
54
+ return Promise.resolve(snapshot);
55
+ return new Promise((resolve, reject) => {
56
+ const timer = setTimeout(() => {
57
+ waiters.delete(onRecord);
58
+ reject(new Error(`timed out after ${timeoutMs}ms waiting for the compile to settle `
59
+ + `(last phase: ${snapshot.phase})`));
60
+ }, timeoutMs);
61
+ const onRecord = (s) => {
62
+ if (!isSettled(s))
63
+ return;
64
+ clearTimeout(timer);
65
+ waiters.delete(onRecord);
66
+ resolve(s);
67
+ };
68
+ waiters.add(onRecord);
69
+ });
70
+ },
71
+ };
72
+ }
73
+ //# sourceMappingURL=session-status-store.js.map
@@ -0,0 +1,20 @@
1
+ import type { RendererNotifier } from '../notifications/renderer-notifier.js';
2
+ import type { CompileLogBuffer } from './compile-log-buffer.js';
3
+ import type { SessionStatusStore } from './session-status-store.js';
4
+ /**
5
+ * Wraps the renderer notifier so the two compile-state pushes ALSO land in
6
+ * the main-process authorities (session-status store + compile-log buffer)
7
+ * before the renderer broadcast.
8
+ *
9
+ * `ctx.notify` is the single outlet every status transition and log line
10
+ * already flows through (`workspace-service`'s `sendStatus` / `onLog`
11
+ * closures), so tapping at this gate keeps ONE bookkeeping site instead of a
12
+ * second call next to every producer — and the stale-session generation
13
+ * guard upstream of the notify call protects the store/buffer for free.
14
+ *
15
+ * A fresh open (`'compiling'` without `hotReload`) starts a new compile
16
+ * timeline, so it clears the buffer; watcher rebuilds never pass through
17
+ * `'compiling'` and keep appending to the same timeline.
18
+ */
19
+ export declare function tapNotifierIntoStores(notify: RendererNotifier, status: SessionStatusStore, compileLogs: CompileLogBuffer): RendererNotifier;
20
+ //# sourceMappingURL=status-tap.d.ts.map
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Wraps the renderer notifier so the two compile-state pushes ALSO land in
3
+ * the main-process authorities (session-status store + compile-log buffer)
4
+ * before the renderer broadcast.
5
+ *
6
+ * `ctx.notify` is the single outlet every status transition and log line
7
+ * already flows through (`workspace-service`'s `sendStatus` / `onLog`
8
+ * closures), so tapping at this gate keeps ONE bookkeeping site instead of a
9
+ * second call next to every producer — and the stale-session generation
10
+ * guard upstream of the notify call protects the store/buffer for free.
11
+ *
12
+ * A fresh open (`'compiling'` without `hotReload`) starts a new compile
13
+ * timeline, so it clears the buffer; watcher rebuilds never pass through
14
+ * `'compiling'` and keep appending to the same timeline.
15
+ */
16
+ export function tapNotifierIntoStores(notify, status, compileLogs) {
17
+ return {
18
+ ...notify,
19
+ projectStatus(payload) {
20
+ if (payload.status === 'compiling' && payload.hotReload !== true) {
21
+ compileLogs.clear();
22
+ }
23
+ status.record(payload);
24
+ notify.projectStatus(payload);
25
+ },
26
+ compileLog(payload) {
27
+ compileLogs.append(payload);
28
+ notify.compileLog(payload);
29
+ },
30
+ };
31
+ }
32
+ //# sourceMappingURL=status-tap.js.map