@1agh/maude 0.53.2 → 0.54.0

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 (46) hide show
  1. package/apps/studio/api.ts +13 -7
  2. package/apps/studio/bin/screenshot.sh +43 -3
  3. package/apps/studio/build.ts +37 -3
  4. package/apps/studio/client/app.jsx +208 -25
  5. package/apps/studio/client/export-center.jsx +10 -2
  6. package/apps/studio/client/panels/SettingsPanel.jsx +6 -1
  7. package/apps/studio/client/styles/4-components.css +40 -0
  8. package/apps/studio/cloud-build.ts +208 -0
  9. package/apps/studio/collab/room.ts +43 -7
  10. package/apps/studio/context.ts +12 -5
  11. package/apps/studio/dist/client.bundle.js +1007 -1007
  12. package/apps/studio/dist/comment-mount.js +2 -2
  13. package/apps/studio/dist/styles.css +1 -1
  14. package/apps/studio/git/repo-lock.ts +305 -0
  15. package/apps/studio/git/service.ts +171 -21
  16. package/apps/studio/http.ts +145 -12
  17. package/apps/studio/inspect.ts +66 -6
  18. package/apps/studio/server.ts +114 -17
  19. package/apps/studio/session-scope.ts +101 -0
  20. package/apps/studio/sync/autocommit.ts +84 -46
  21. package/apps/studio/sync/index.ts +20 -0
  22. package/apps/studio/test/canvas-origin-gate.test.ts +61 -0
  23. package/apps/studio/test/canvas-shell-base.test.ts +107 -0
  24. package/apps/studio/test/cloud-build.test.ts +95 -0
  25. package/apps/studio/test/cloud-shell-surfaces.test.ts +31 -2
  26. package/apps/studio/test/collab-readonly-gate.test.ts +194 -0
  27. package/apps/studio/test/git-system-engine.test.ts +135 -0
  28. package/apps/studio/test/read-only-gate.test.ts +8 -0
  29. package/apps/studio/test/repo-concurrency.test.ts +146 -0
  30. package/apps/studio/test/repo-lock.test.ts +350 -0
  31. package/apps/studio/test/server-lifecycle.test.ts +11 -1
  32. package/apps/studio/test/session-runtime-state.test.ts +226 -0
  33. package/apps/studio/test/session-scope.test.ts +100 -0
  34. package/apps/studio/test/shell-importmap.test.ts +8 -1
  35. package/apps/studio/test/sync-autocommit.test.ts +76 -0
  36. package/apps/studio/test/sync-hardening.test.ts +75 -0
  37. package/apps/studio/use-collab.tsx +21 -1
  38. package/apps/studio/whats-new.json +36 -0
  39. package/apps/studio/ws.ts +44 -8
  40. package/cli/commands/kg.mjs +49 -19
  41. package/cli/lib/gitignore-block.mjs +3 -0
  42. package/package.json +11 -9
  43. package/plugins/design/dependencies.json +1 -1
  44. package/plugins/design/templates/_shell.html +66 -32
  45. package/plugins/flow/.claude-plugin/config.schema.json +1 -1
  46. package/plugins/flow/dependencies.json +1 -1
@@ -0,0 +1,100 @@
1
+ // Cloud Phase 27 D3 — whose runtime state is this?
2
+ //
3
+ // `_active.json` and `_canvas-state/<slug>.view.json` are per-MACHINE
4
+ // singletons by design (DDR-115), which is right for a desktop and wrong for a
5
+ // cell: one studio process serves an owner and a viewer, so a colleague opening
6
+ // a canvas switched YOUR tab and their pan moved YOUR camera. Silently — it
7
+ // read as flakiness, not as two people sharing one file.
8
+ //
9
+ // The first test is the one that matters most: OUTSIDE a cell nothing changes.
10
+
11
+ import { describe, expect, test } from 'bun:test';
12
+
13
+ import {
14
+ currentSession,
15
+ normalizeSessionKey,
16
+ runInSession,
17
+ sessionDir,
18
+ sessionFile,
19
+ } from '../session-scope.ts';
20
+
21
+ describe('session scope', () => {
22
+ test('with no session every path is byte-for-byte what it was', () => {
23
+ expect(currentSession()).toBe('');
24
+ expect(sessionDir('/repo/.design/_canvas-state')).toBe('/repo/.design/_canvas-state');
25
+ expect(sessionFile('/repo/.design/_active.json')).toBe('/repo/.design/_active.json');
26
+ });
27
+
28
+ test('a session gives each member their own subtree and their own active file', () => {
29
+ runInSession('a1b2c3d4e5f60718', () => {
30
+ expect(currentSession()).toBe('a1b2c3d4e5f60718');
31
+ expect(sessionDir('/repo/.design/_canvas-state')).toBe(
32
+ '/repo/.design/_canvas-state/a1b2c3d4e5f60718'
33
+ );
34
+ // A SIBLING of `_active.json`, not a rename: the runtime-state taxonomy,
35
+ // the gitignore and `isMaudeRuntimeState` all match that stem by name,
36
+ // and a sibling keeps every one of them correct with no fourth list.
37
+ expect(sessionFile('/repo/.design/_active.json')).toBe(
38
+ '/repo/.design/_active.a1b2c3d4e5f60718.json'
39
+ );
40
+ });
41
+ });
42
+
43
+ test('the scope does not leak past the call', () => {
44
+ runInSession('deadbeef', () => expect(currentSession()).toBe('deadbeef'));
45
+ expect(currentSession()).toBe('');
46
+ });
47
+
48
+ test('two sessions do not see each other', () => {
49
+ runInSession('one', () => {
50
+ expect(currentSession()).toBe('one');
51
+ runInSession('two', () => expect(currentSession()).toBe('two'));
52
+ expect(currentSession()).toBe('one');
53
+ });
54
+ });
55
+
56
+ test('the scope survives an await — a request is not one tick', async () => {
57
+ await runInSession('async1', async () => {
58
+ await new Promise((r) => setTimeout(r, 5));
59
+ expect(currentSession()).toBe('async1');
60
+ await Promise.resolve();
61
+ expect(currentSession()).toBe('async1');
62
+ });
63
+ });
64
+
65
+ test('a key that is not proxy-shaped is refused, not sanitized', () => {
66
+ // The value becomes a path segment, so this is the traversal gate — and it
67
+ // REJECTS rather than strips, because a key we had to repair is not one
68
+ // whose intent we should be guessing. Falling back to the shared singleton
69
+ // is the same behaviour a desktop already has.
70
+ for (const bad of [
71
+ '../../etc/passwd',
72
+ 'a/b',
73
+ 'a\\b',
74
+ '.',
75
+ '..',
76
+ 'has space',
77
+ 'x'.repeat(65),
78
+ '',
79
+ null,
80
+ undefined,
81
+ ]) {
82
+ expect(normalizeSessionKey(bad as string)).toBe('');
83
+ }
84
+ for (const good of ['abc123', 'a1b2c3d4e5f60718', 'A_b-9']) {
85
+ expect(normalizeSessionKey(good)).toBe(good);
86
+ }
87
+ // …and a refused key means the shared path, never a traversed one.
88
+ runInSession('../../etc', () => {
89
+ expect(sessionDir('/repo/.design/_canvas-state')).toBe('/repo/.design/_canvas-state');
90
+ });
91
+ });
92
+
93
+ test('a file with no extension still gets a distinct per-session name', () => {
94
+ runInSession('k', () => {
95
+ expect(sessionFile('/repo/.design/_active')).toBe('/repo/.design/_active.k');
96
+ // A dot in a PARENT directory must not be mistaken for the extension.
97
+ expect(sessionFile('/repo/my.dir/_active')).toBe('/repo/my.dir/_active.k');
98
+ });
99
+ });
100
+ });
@@ -43,7 +43,14 @@ describe('_shell.html importmap agrees with RUNTIME_PACKAGES', () => {
43
43
 
44
44
  for (const pkg of RUNTIME_PACKAGES) {
45
45
  test(`"${pkg}" resolves to its /_canvas-runtime/ bundle`, () => {
46
- expect(imports[pkg]).toBe(`/_canvas-runtime/${slugFor(pkg)}.js`);
46
+ // RELATIVE (`./`), and that leading dot is load-bearing (Cloud Phase 27).
47
+ // An importmap value resolves against the DOCUMENT's base URL, so a
48
+ // root-absolute `/_canvas-runtime/…` is correct only where the shell is
49
+ // served from the origin root. On the fleet's shared canvas host the
50
+ // shell lives under `/<project>/`, and every bundle 404'd there — the
51
+ // grey-canvas failure the per-project origin was introduced to end.
52
+ // Relative is right in BOTH shapes, so it is the one form to hold.
53
+ expect(imports[pkg]).toBe(`./_canvas-runtime/${slugFor(pkg)}.js`);
47
54
  });
48
55
  }
49
56
  });
@@ -12,6 +12,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
12
12
  import { tmpdir } from 'node:os';
13
13
  import path from 'node:path';
14
14
 
15
+ import { REPO_LOCK_FILE } from '../git/repo-lock.ts';
15
16
  import {
16
17
  commitMessage,
17
18
  createAutoCommit,
@@ -332,3 +333,78 @@ describe('mirror push — never rewrites someone else’s work', () => {
332
333
  function silent() {
333
334
  return { warn: () => {}, error: () => {}, log: () => {} };
334
335
  }
336
+
337
+ describe('a contended lock must not lose the queue or the process (Phase 27 D2)', () => {
338
+ test('a lock timeout re-queues the files instead of dropping them', async () => {
339
+ // `flush()` clears `touched` BEFORE it commits, and `withRepoLock` THROWS
340
+ // when it cannot acquire. Without a catch, a contended autosave dropped its
341
+ // file list silently — the bytes stay on disk, uncommitted, with nothing to
342
+ // retry them — and rejected a promise the debounce timer discards, which on
343
+ // Node >= 15 takes the process down with it. Post-D2 the trigger is
344
+ // ordinary: `pull` and `checkout` hold this lock across a NETWORK call.
345
+ const dir = mkdtempSync(path.join(tmpdir(), 'autocommit-lock-'));
346
+ try {
347
+ mkdirSync(path.join(dir, '.git'), { recursive: true });
348
+ // A live holder that is not stale — every acquire attempt will time out.
349
+ writeFileSync(
350
+ path.join(dir, '.git', REPO_LOCK_FILE),
351
+ JSON.stringify({ pid: process.pid, holder: 'studio:pull', at: Date.now(), token: 'held' })
352
+ );
353
+
354
+ const ran: string[][] = [];
355
+ const auto = createAutoCommit({
356
+ repoRoot: dir,
357
+ debounceMs: 5,
358
+ log: { warn() {}, error() {}, log() {} },
359
+ run: async (args) => {
360
+ ran.push(args);
361
+ return { code: 0, stdout: '', stderr: '' };
362
+ },
363
+ });
364
+
365
+ auto.note('.design/ui/Home.tsx', { name: 'A', email: 'a@b.c' });
366
+ const outcome = await auto.flush();
367
+
368
+ expect(outcome?.ok).toBe(false);
369
+ // Nothing reached git — the lock was never acquired.
370
+ expect(ran).toEqual([]);
371
+ // THE POINT: the file is still queued, so the next quiescence retries it.
372
+ expect(auto.pending()).toEqual(['.design/ui/Home.tsx']);
373
+ auto.stop();
374
+ } finally {
375
+ rmSync(dir, { recursive: true, force: true });
376
+ }
377
+ }, 30_000);
378
+
379
+ test('the debounce timer never surfaces an unhandled rejection', async () => {
380
+ // The timer fires unattended; a rejection it discards is a process exit.
381
+ const dir = mkdtempSync(path.join(tmpdir(), 'autocommit-timer-'));
382
+ try {
383
+ mkdirSync(path.join(dir, '.git'), { recursive: true });
384
+ writeFileSync(
385
+ path.join(dir, '.git', REPO_LOCK_FILE),
386
+ JSON.stringify({ pid: process.pid, holder: 'studio:fold', at: Date.now(), token: 'held' })
387
+ );
388
+ const rejections: unknown[] = [];
389
+ const onUnhandled = (err: unknown) => rejections.push(err);
390
+ process.on('unhandledRejection', onUnhandled);
391
+ try {
392
+ const auto = createAutoCommit({
393
+ repoRoot: dir,
394
+ debounceMs: 5,
395
+ log: { warn() {}, error() {}, log() {} },
396
+ run: async () => ({ code: 0, stdout: '', stderr: '' }),
397
+ });
398
+ auto.note('.design/ui/Home.tsx', { name: 'A', email: 'a@b.c' });
399
+ // Long enough for the debounce to fire AND the lock wait to expire.
400
+ await new Promise((r) => setTimeout(r, 1000));
401
+ auto.stop();
402
+ } finally {
403
+ process.off('unhandledRejection', onUnhandled);
404
+ }
405
+ expect(rejections).toEqual([]);
406
+ } finally {
407
+ rmSync(dir, { recursive: true, force: true });
408
+ }
409
+ }, 30_000);
410
+ });
@@ -137,6 +137,81 @@ describe('DDR-054 §2a — CI environment gate', () => {
137
137
  });
138
138
  });
139
139
 
140
+ /* ------------------------------------------- Phase 27 D2 — a cell owns its history */
141
+
142
+ describe('Cloud Phase 27 D2 — a workspace cell ignores the tenant´s linkedHub', () => {
143
+ let saved: string | undefined;
144
+
145
+ beforeEach(() => {
146
+ saved = process.env.MAUDE_WORKSPACE_MODE;
147
+ });
148
+ afterEach(() => {
149
+ if (saved === undefined) {
150
+ delete process.env.MAUDE_WORKSPACE_MODE;
151
+ } else process.env.MAUDE_WORKSPACE_MODE = saved;
152
+ });
153
+
154
+ function linkedCtx() {
155
+ const designRoot = join(dir, 'design');
156
+ mkdirSync(join(designRoot, 'ui'), { recursive: true });
157
+ return {
158
+ cfg: {
159
+ name: 't',
160
+ projectLabel: null,
161
+ designRoot: 'design',
162
+ canvasGroups: [{ label: 'Canvases', path: 'ui' }],
163
+ rootClass: 'app',
164
+ themeDefault: 'dark' as const,
165
+ tokensCssRel: 'system/x.css',
166
+ teamAccentDefault: null,
167
+ handoffTargets: [],
168
+ newCanvasDir: 'ui',
169
+ newComponentDir: 'ui/components',
170
+ // The TENANT's committed config, carrying whatever hub their desktop
171
+ // was linked to. It is versioned, so it arrives in the cell with the
172
+ // checkout.
173
+ linkedHub: { url: 'https://someone-elses-hub.example.com', linkedAt: 1 },
174
+ _source: 'defaults' as const,
175
+ },
176
+ projectLabel: 't',
177
+ paths: {
178
+ repoRoot: dir,
179
+ designRel: 'design',
180
+ designRoot,
181
+ serverInfoFile: '',
182
+ activeFile: '',
183
+ commentsDir: join(designRoot, '_comments'),
184
+ canvasStateDir: '',
185
+ historyDir: '',
186
+ tokensUrlRel: '',
187
+ systemDirRel: 'system',
188
+ },
189
+ // biome-ignore lint/suspicious/noExplicitAny: minimal bus stub
190
+ bus: { on: () => () => {}, emit: () => {} } as any,
191
+ };
192
+ }
193
+
194
+ test('in a cell it refuses, and says which authority won', () => {
195
+ // Honouring the tenant's `linkedHub` inside a cell would dial OUT to a
196
+ // third-party hub carrying their canvases, and start a SECOND autocommit
197
+ // over the tree the hub is already committing. Both were unreachable by
198
+ // accident (no `~/.config/maude/hubs.json` in a cell), and an accident is
199
+ // not an invariant.
200
+ const warned: string[] = [];
201
+ const original = console.warn;
202
+ console.warn = (...args: unknown[]) => warned.push(args.map(String).join(' '));
203
+ process.env.MAUDE_WORKSPACE_MODE = '1';
204
+ try {
205
+ expect(createSyncRuntime(linkedCtx())).toBeNull();
206
+ // Not just null — null for THIS reason. Without the message this test
207
+ // would pass on the missing-token path it is meant to precede.
208
+ expect(warned.join(' ')).toContain('the hub owns history');
209
+ } finally {
210
+ console.warn = original;
211
+ }
212
+ });
213
+ });
214
+
140
215
  /* ---------------------------------------------------------------- §2b .tsx refusal */
141
216
 
142
217
  // Coverage moved to sync-runtime.test.ts > discoverCanvases >
@@ -592,6 +592,13 @@ function createSession(slug: string): CollabSession {
592
592
  let cancelled = false;
593
593
  let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
594
594
  let wsRef: WebSocket | null = null;
595
+ // Consecutive failed connects. A server that refuses the upgrade (403/401 —
596
+ // e.g. a deployment whose proxy does not carry the collab lane) used to be
597
+ // retried every second, forever, silently — which is indistinguishable from
598
+ // "nobody else is here" (the cloud-collab RCA shipped invisible exactly this
599
+ // way). Back off exponentially and say so once.
600
+ let failedConnects = 0;
601
+ let failureWarned = false;
595
602
 
596
603
  function sendFrame(ws: WebSocket, payload: Uint8Array) {
597
604
  try {
@@ -624,6 +631,8 @@ function createSession(slug: string): CollabSession {
624
631
  wsRef = ws;
625
632
 
626
633
  ws.addEventListener('open', () => {
634
+ failedConnects = 0;
635
+ failureWarned = false;
627
636
  session.connected = true;
628
637
  notifySession(session);
629
638
  // Sync step 1 — announce our state vector so the server can send the
@@ -637,11 +646,22 @@ function createSession(slug: string): CollabSession {
637
646
  });
638
647
 
639
648
  ws.addEventListener('close', () => {
649
+ const hadConnected = session.connected;
640
650
  session.connected = false;
641
651
  notifySession(session);
642
652
  wsRef = null;
643
653
  if (cancelled) return;
644
- reconnectTimer = setTimeout(connect, 1000);
654
+ failedConnects = hadConnected ? 0 : failedConnects + 1;
655
+ if (failedConnects >= 5 && !failureWarned) {
656
+ failureWarned = true;
657
+ console.warn(
658
+ '[collab] live sync unavailable — the collab socket keeps being refused. ' +
659
+ 'Presence, live annotations and cross-peer updates are off until it connects.'
660
+ );
661
+ }
662
+ // 1s → 2s → 4s → … capped at 15s; a healthy drop (was connected) retries fast.
663
+ const delay = Math.min(1000 * 2 ** Math.min(failedConnects, 4), 15000);
664
+ reconnectTimer = setTimeout(connect, delay);
645
665
  });
646
666
 
647
667
  ws.addEventListener('error', () => {
@@ -1,6 +1,42 @@
1
1
  {
2
2
  "$schema": "./whats-new.schema.json",
3
3
  "entries": [
4
+ {
5
+ "id": "your-own-place-in-a-shared-project",
6
+ "version": "0.54.0",
7
+ "date": "2026-08-04",
8
+ "kind": "improvement",
9
+ "title": "A shared project keeps your place",
10
+ "summary": "Open a cloud project and you land on a canvas, not an empty pane, with one line telling you what you can do there. Your open canvas and your pan and zoom are now yours alone — a teammate moving around no longer moves you. And reading a project no longer means reading it blind: reviewers can open the Inspector and Layers panels, which quietly refused to appear before.",
11
+ "surface": "design-ui"
12
+ },
13
+ {
14
+ "id": "the-real-studio-in-your-browser",
15
+ "version": "0.54.0",
16
+ "date": "2026-08-04",
17
+ "kind": "feature",
18
+ "title": "The full studio, in your browser",
19
+ "summary": "A cloud project opened in a browser now gives you the same application as the desktop — Files, Layers, Inspector, search, the toolbar and the live status — and your designs look like your designs: photographs, logos and webfonts load on the canvas. The browser tab also names the account you are signed in as, and lets you sign out.",
20
+ "surface": "design-ui"
21
+ },
22
+ {
23
+ "id": "design-system-component-styles-load",
24
+ "version": "0.54.0",
25
+ "date": "2026-08-04",
26
+ "kind": "fix",
27
+ "title": "Design-system styles render on canvases",
28
+ "summary": "Canvases in a project with a bootstrapped design system were drawing without their component styles — buttons, cards, inputs and the rest fell back to unstyled defaults. They render correctly again, on the desktop as well as in the browser.",
29
+ "surface": "design-ui"
30
+ },
31
+ {
32
+ "id": "cloud-live-collaboration-connects",
33
+ "version": "0.54.0",
34
+ "date": "2026-08-04",
35
+ "kind": "fix",
36
+ "title": "Live collaboration works in the browser",
37
+ "summary": "Opening a cloud project in two browsers now actually collaborates: you see each other's cursors with names, annotations and comments appear live for everyone, and every change is saved to the project. Before, the live channel never connected — selections crossed over anonymously and annotations silently stayed on the machine that drew them.",
38
+ "surface": "design-ui"
39
+ },
4
40
  {
5
41
  "id": "bug-reports-see-the-maude-window",
6
42
  "version": "0.53.2",
package/apps/studio/ws.ts CHANGED
@@ -9,7 +9,7 @@ import type { Api } from './api.ts';
9
9
  import type { Collab, RoomConn } from './collab/index.ts';
10
10
  import type { Context } from './context.ts';
11
11
  import { createHmrBroadcaster } from './hmr-broadcast.ts';
12
- import type { Inspect } from './inspect.ts';
12
+ import type { InspectRegistry } from './inspect.ts';
13
13
 
14
14
  /**
15
15
  * Per-connection state. `kind` discriminates between the legacy JSON
@@ -29,6 +29,11 @@ export type WsData =
29
29
  * per session, and a handshake is the one moment the session is
30
30
  * unambiguous. */
31
31
  readOnly: boolean;
32
+ /** WHOSE socket this is — Cloud Phase 27 D3. Same reasoning as `readOnly`
33
+ * one line up, for the other thing a cell has more than one of. `''` on
34
+ * a desktop, which is what makes every broadcast below reach everybody
35
+ * exactly as it always did. */
36
+ session: string;
32
37
  }
33
38
  | {
34
39
  id: string;
@@ -39,6 +44,14 @@ export type WsData =
39
44
  * canvas iframe origin, whose sync frames go through the DDR-122
40
45
  * origin gate in collab/room.ts. Set at upgrade time in server.ts. */
41
46
  realm: 'main' | 'canvas';
47
+ /** Cloud collab lane (RCA issue-cloud-live-collaboration-dead) — the
48
+ * role this socket was opened under, stamped at upgrade time from the
49
+ * proxy's injected header (fail-closed in a cell, always false on a
50
+ * loopback desktop). A read-only collab socket receives everything and
51
+ * publishes awareness (presence is the point), but its sync writes are
52
+ * gated to the comment lane in collab/room.ts — the WS mirror of the
53
+ * role matrix's `viewer.comment === true`. */
54
+ readOnly?: boolean;
42
55
  }
43
56
  | {
44
57
  // T2 (9.1-A) — HMR-only socket for the segregated canvas origin. Receives
@@ -139,7 +152,10 @@ export interface Ws {
139
152
  export function createWs(
140
153
  ctx: Context,
141
154
  api: Api,
142
- inspect: Inspect,
155
+ /** Cloud Phase 27 D3 — one inspector per member, not one per process. Every
156
+ * read/write below resolves through the socket's own session key, so a
157
+ * desktop (key `''`) keeps hitting the single instance it always had. */
158
+ inspects: InspectRegistry,
143
159
  collab: Collab,
144
160
  activity: Activity,
145
161
  acp: Acp
@@ -156,10 +172,16 @@ export function createWs(
156
172
 
157
173
  // Privileged inspector feed — comments, selection, ai-activity, git-lifecycle,
158
174
  // sync:status, fs:*. ONLY the same-origin inspector clients (the shell) get it.
159
- function broadcast(payload: unknown) {
175
+ function broadcast(payload: unknown, session?: string) {
160
176
  const msg = typeof payload === 'string' ? payload : JSON.stringify(payload);
161
177
  for (const ws of clients) {
162
178
  if (ws.data.kind !== 'inspector') continue;
179
+ // D3 — an event ABOUT one member goes only to that member. `session` is
180
+ // set by the emitter (inspect.ts stamps its own key); everything else —
181
+ // fs changes, comments, git lifecycle, sync status — is about the
182
+ // PROJECT and reaches everyone, which is why the audience is opt-in
183
+ // rather than the default.
184
+ if (session && ws.data.session !== session) continue;
163
185
  send(ws, msg);
164
186
  }
165
187
  }
@@ -177,8 +199,10 @@ export function createWs(
177
199
 
178
200
  // Wire bus -> WS broadcasts. inspect.ts emits 'selected' / 'active' after every
179
201
  // state write; fs-watch.ts emits 'fs:*' on every save.
180
- ctx.bus.on('selected', (sel) => broadcast({ type: 'selected', selected: sel }));
181
- ctx.bus.on('active', (file) => broadcast({ type: 'active', file }));
202
+ ctx.bus.on('selected', (sel, meta) =>
203
+ broadcast({ type: 'selected', selected: sel }, meta?.session)
204
+ );
205
+ ctx.bus.on('active', (file, meta) => broadcast({ type: 'active', file }, meta?.session));
182
206
  ctx.bus.on('fs:html', (file) => broadcast({ type: 'fs:html', file }));
183
207
  ctx.bus.on('fs:css', (file) => broadcast({ type: 'fs:css', file }));
184
208
  ctx.bus.on('fs:json', (file) => broadcast({ type: 'fs:json', file }));
@@ -259,11 +283,13 @@ export function createWs(
259
283
  function bindCollab(
260
284
  ws: ServerWebSocket<WsData>,
261
285
  slug: string,
262
- realm: 'main' | 'canvas'
286
+ realm: 'main' | 'canvas',
287
+ readOnly: boolean
263
288
  ): RoomConn {
264
289
  const conn: RoomConn = {
265
290
  id: ws.data.id,
266
291
  realm,
292
+ readOnly,
267
293
  send(payload: Uint8Array) {
268
294
  try {
269
295
  // Bun's ws.send accepts Uint8Array directly as binary.
@@ -281,7 +307,12 @@ export function createWs(
281
307
  async open(ws) {
282
308
  if (ws.data.kind === 'collab') {
283
309
  const room = collab.registry.get(ws.data.slug);
284
- const conn = bindCollab(ws, ws.data.slug, ws.data.realm ?? 'main');
310
+ const conn = bindCollab(
311
+ ws,
312
+ ws.data.slug,
313
+ ws.data.realm ?? 'main',
314
+ ws.data.readOnly === true
315
+ );
285
316
  await room.connect(conn);
286
317
  return;
287
318
  }
@@ -299,7 +330,11 @@ export function createWs(
299
330
  // opening mid-edit seeds its overlay state (Phase 13). Inspector-origin
300
331
  // sockets only — `canvas-hmr` sockets get no snapshot by design and rely
301
332
  // on live `activity` broadcasts.
302
- send(ws, { type: 'snapshot', state: inspect.state, activity: activity.state });
333
+ send(ws, {
334
+ type: 'snapshot',
335
+ state: inspects.for(ws.data.kind === 'inspector' ? ws.data.session : '').state,
336
+ activity: activity.state,
337
+ });
303
338
  },
304
339
  async close(ws) {
305
340
  if (ws.data.kind === 'acp') {
@@ -377,6 +412,7 @@ export function createWs(
377
412
  });
378
413
  return;
379
414
  }
415
+ const inspect = inspects.for(ws.data.kind === 'inspector' ? ws.data.session : '');
380
416
  if (msg.type === 'active' && typeof msg.file === 'string') inspect.setActive(msg.file);
381
417
  else if (msg.type === 'tabs' && Array.isArray(msg.tabs)) inspect.setOpenTabs(msg.tabs);
382
418
  else if (msg.type === 'select' && msg.selection) inspect.setSelected(msg.selection);
@@ -18,7 +18,7 @@ import { basename, join, resolve } from 'node:path';
18
18
  import { parseArgs } from '../lib/argv.mjs';
19
19
 
20
20
  const CONFIG_PATH = '.ai/workflows.config.json';
21
- const DEFAULT_ENGINE_VERSION = 'v0.1.9';
21
+ const DEFAULT_ENGINE_VERSION = 'v1.0.0';
22
22
  const KGAI_REPO = 'kgaidev/kgai';
23
23
 
24
24
  const VERBS = new Set([
@@ -195,21 +195,32 @@ function verbDoctor(state) {
195
195
  async function verbCheckUpstream(state) {
196
196
  const pinned = state.engineVersion;
197
197
  process.stdout.write(`maude kg check-upstream\n\n pinned (engineVersion): ${pinned}\n`);
198
- let latest = null;
199
- let assets = [];
200
- try {
201
- const res = await fetch(`https://api.github.com/repos/${KGAI_REPO}/releases/latest`, {
202
- headers: { 'user-agent': 'maude-kg', accept: 'application/vnd.github+json' },
203
- signal: AbortSignal.timeout(15000),
204
- });
205
- if (res.ok) {
198
+ const ghHeaders = { 'user-agent': 'maude-kg', accept: 'application/vnd.github+json' };
199
+ /** Asset names for one release ref, or null when the fetch itself failed. */
200
+ const assetsFor = async (path) => {
201
+ try {
202
+ const res = await fetch(`https://api.github.com/repos/${KGAI_REPO}/releases/${path}`, {
203
+ headers: ghHeaders,
204
+ signal: AbortSignal.timeout(15000),
205
+ });
206
+ if (!res.ok) return { ok: false, tag: null, assets: [] };
206
207
  const rel = await res.json();
207
- latest = rel.tag_name;
208
- assets = (rel.assets ?? []).map((a) => a.name);
208
+ return { ok: true, tag: rel.tag_name, assets: (rel.assets ?? []).map((a) => a.name) };
209
+ } catch {
210
+ return null; // offline — best-effort
209
211
  }
210
- } catch {
211
- /* offline — best-effort */
212
- }
212
+ };
213
+
214
+ const latestRel = await assetsFor('latest');
215
+ const latest = latestRel?.tag ?? null;
216
+ // The PINNED release is the one that matters: sync-kg.mjs downloads the
217
+ // desktop sidecars from `releases/download/<pinned>`. Reporting `latest`'s
218
+ // assets here said "✓" while the pinned release had been stripped of every
219
+ // asset upstream, so this check stayed green through a desktop build that was
220
+ // failing on exactly what it claims to verify (v0.53.2, kgai v0.1.9). A tag is
221
+ // a name, not a content hash — upstream can rewrite what it points at.
222
+ const pinnedRel = await assetsFor(`tags/${encodeURIComponent(pinned)}`);
223
+ const assets = pinnedRel?.assets ?? [];
213
224
  if (!latest) {
214
225
  process.stdout.write(' latest: unknown (offline) — using pinned.\n');
215
226
  return 0;
@@ -218,12 +229,31 @@ async function verbCheckUpstream(state) {
218
229
  process.stdout.write(
219
230
  ` status: ${latest === pinned ? '✓ up to date' : `⚠ upstream moved ${pinned} → ${latest}`}\n`
220
231
  );
221
- // Capability diff — flags/commands the plan's assumptions hinge on.
232
+ // Prebuilt assets ON THE PIN — what the desktop build actually downloads.
222
233
  const hasDarwin = assets.some((a) => /kg-darwin/.test(a));
223
234
  const hasKuzu = assets.some((a) => /libkuzu/.test(a));
224
- process.stdout.write('\n prebuilt assets:\n');
225
- process.stdout.write(` macOS kg binary: ${hasDarwin ? '✓' : '✗'}\n`);
226
- process.stdout.write(` libkuzu dylib/so: ${hasKuzu ? '✓' : '✗'}\n`);
235
+ const hasLinux = assets.some((a) => /kg-linux/.test(a));
236
+ process.stdout.write(`\n prebuilt assets on the PIN (${pinned}):\n`);
237
+ if (pinnedRel === null) {
238
+ process.stdout.write(' (offline — not checked)\n');
239
+ } else if (!pinnedRel.ok) {
240
+ process.stdout.write(` ✗ release ${pinned} not found upstream\n`);
241
+ } else {
242
+ process.stdout.write(` macOS kg binary: ${hasDarwin ? '✓' : '✗'}\n`);
243
+ process.stdout.write(` linux kg binary: ${hasLinux ? '✓' : '✗'}\n`);
244
+ process.stdout.write(` libkuzu dylib/so: ${hasKuzu ? '✓' : '✗'}\n`);
245
+ }
246
+ // A pin whose assets are gone breaks `sync-kg.mjs`, which fails the desktop
247
+ // build for macOS and Linux. Exit non-zero so this can gate a release.
248
+ const pinBroken = pinnedRel !== null && (!pinnedRel.ok || !hasDarwin || !hasKuzu || !hasLinux);
249
+ if (pinBroken) {
250
+ process.stdout.write(
251
+ `\n ✗ the pinned release cannot build the desktop app — sync-kg.mjs downloads from\n` +
252
+ ` releases/download/${pinned}, and those assets are missing. Upstream deleted or\n` +
253
+ ` rewrote the release. Bump the pin to one that still carries them (verify\n` +
254
+ ` compatibility first: CLI surface diff + a canonical-export comparison).\n`
255
+ );
256
+ }
227
257
  if (latest !== pinned) {
228
258
  process.stdout.write(
229
259
  '\n → Re-scan the capability surface (native --scope filter? kg import? Stop-hook/guessActor changes?),\n' +
@@ -231,7 +261,7 @@ async function verbCheckUpstream(state) {
231
261
  ' config.knowledgeGraph.engineVersion deliberately (never float — supply-chain surface, DDR-054/056).\n'
232
262
  );
233
263
  }
234
- return 0;
264
+ return pinBroken ? 1 : 0;
235
265
  }
236
266
 
237
267
  // ── verb: session-sync (SessionStart hook — non-blocking pull) ──────────────
@@ -34,6 +34,9 @@ export function buildBlock(designRel = '.design', { s3Assets = false } = {}) {
34
34
  `${root}/_server.log`,
35
35
  `${root}/_server.lock`,
36
36
  `${root}/_active.json`,
37
+ // Per-member siblings in a cloud cell (Phase 27 D3) — same runtime state,
38
+ // one file per person, and no more committable than the singleton.
39
+ `${root}/_active.*.json`,
37
40
  `${root}/_sync.json`, // linked-mode offline/sync status (Task 8)
38
41
  `${root}/_preflight.json`,
39
42
  `${root}/_locator.json`, // regenerable slug→path index
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1agh/maude",
3
- "version": "0.53.2",
3
+ "version": "0.54.0",
4
4
  "description": "Marketplace of Claude Code plugins by Michal Dovrtěl: `design` (canvas-first design iteration) + `flow` (generic agentic workflow loop with .ai second brain). Ships the `maude` CLI (with `mdcc` legacy alias) to scaffold workspace, run the design dev server, and manage configs.",
5
5
  "type": "module",
6
6
  "engines": {
@@ -49,16 +49,18 @@
49
49
  "test:e2e:desktop:lifecycle": "pnpm --filter @maude/desktop-e2e e2e:lifecycle",
50
50
  "test:e2e:desktop:switchrepos": "pnpm --filter @maude/desktop-e2e e2e:switchrepos",
51
51
  "test:e2e:desktop:onboarding": "pnpm --filter @maude/desktop-e2e e2e:onboarding",
52
- "test:e2e:desktop:acp-cold-start": "pnpm --filter @maude/desktop-e2e e2e:acp-cold-start"
52
+ "test:e2e:desktop:acp-cold-start": "pnpm --filter @maude/desktop-e2e e2e:acp-cold-start",
53
+ "test:e2e:desktop:parity": "pnpm --filter @maude/desktop-e2e e2e:parity",
54
+ "test:e2e:desktop:parity:cloud": "pnpm --filter @maude/desktop-e2e e2e:parity:cloud"
53
55
  },
54
56
  "optionalDependencies": {
55
- "@1agh/maude-darwin-arm64": "0.53.2",
56
- "@1agh/maude-darwin-x64": "0.53.2",
57
- "@1agh/maude-linux-arm64": "0.53.2",
58
- "@1agh/maude-linux-arm64-musl": "0.53.2",
59
- "@1agh/maude-linux-x64": "0.53.2",
60
- "@1agh/maude-linux-x64-musl": "0.53.2",
61
- "@1agh/maude-win32-x64": "0.53.2"
57
+ "@1agh/maude-darwin-arm64": "0.54.0",
58
+ "@1agh/maude-darwin-x64": "0.54.0",
59
+ "@1agh/maude-linux-arm64": "0.54.0",
60
+ "@1agh/maude-linux-arm64-musl": "0.54.0",
61
+ "@1agh/maude-linux-x64": "0.54.0",
62
+ "@1agh/maude-linux-x64-musl": "0.54.0",
63
+ "@1agh/maude-win32-x64": "0.54.0"
62
64
  },
63
65
  "files": [
64
66
  "cli",
@@ -156,7 +156,7 @@
156
156
  },
157
157
  "install": {
158
158
  "preferred": "See docs/kgai-onboarding.md — download the pinned kgai release binary (kgai is capability-gated + opt-out; nothing to install to use classic .ai/ mode)",
159
- "darwin": "curl -fsSL https://github.com/kgaidev/kgai/releases/download/v0.1.9/kg-darwin-arm64 -o /usr/local/bin/kg && chmod +x /usr/local/bin/kg"
159
+ "darwin": "curl -fsSL https://github.com/kgaidev/kgai/releases/download/v1.0.0/kg-darwin-arm64 -o /usr/local/bin/kg && chmod +x /usr/local/bin/kg"
160
160
  },
161
161
  "autoInstall": false,
162
162
  "fallbackBehavior": "Design DS/canvas/brand decisions are not recorded to the kgai graph; the classic .ai/ path (via /flow:record-ddr) and on-disk sidecars are unchanged.",