@1agh/maude 0.55.0 → 0.57.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 (85) hide show
  1. package/apps/studio/acp/bridge.ts +385 -26
  2. package/apps/studio/acp/index.ts +498 -102
  3. package/apps/studio/acp/running.ts +97 -0
  4. package/apps/studio/acp/transcript.ts +64 -0
  5. package/apps/studio/acp/write-scope.ts +459 -0
  6. package/apps/studio/bin/_smart-frames.mjs +187 -20
  7. package/apps/studio/bin/_smart-frames.test.mjs +59 -4
  8. package/apps/studio/bin/_transcribe.mjs +40 -3
  9. package/apps/studio/bin/smoke.sh +7 -1
  10. package/apps/studio/build.ts +28 -1
  11. package/apps/studio/client/app.jsx +78 -33
  12. package/apps/studio/client/panels/ChatPanel.jsx +19 -1
  13. package/apps/studio/client/panels/CloudBar.jsx +104 -6
  14. package/apps/studio/client/panels/GitPanel.jsx +38 -25
  15. package/apps/studio/client/panels/PermissionPrompt.jsx +146 -6
  16. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +145 -6
  17. package/apps/studio/client/panels/SettingsPanel.jsx +239 -27
  18. package/apps/studio/client/panels/acp-runtime.js +96 -5
  19. package/apps/studio/client/styles/3-shell-maude.css +11 -2
  20. package/apps/studio/client/styles/4-components.css +72 -0
  21. package/apps/studio/client/styles/6-acp-chat.css +51 -0
  22. package/apps/studio/cloud/endpoints.ts +56 -3
  23. package/apps/studio/collab/persistence.ts +29 -2
  24. package/apps/studio/config.schema.json +3 -3
  25. package/apps/studio/context.ts +41 -0
  26. package/apps/studio/dist/client.bundle.js +1330 -1330
  27. package/apps/studio/dist/comment-mount.js +2 -2
  28. package/apps/studio/dist/styles.css +1 -1
  29. package/apps/studio/generation/gemma-models.ts +312 -14
  30. package/apps/studio/generation/prefs.ts +7 -2
  31. package/apps/studio/generation/runtime-probe.ts +50 -0
  32. package/apps/studio/generation/whisper-models.ts +124 -0
  33. package/apps/studio/hmr-broadcast.ts +67 -0
  34. package/apps/studio/http.ts +247 -111
  35. package/apps/studio/input-router.tsx +55 -2
  36. package/apps/studio/server.ts +22 -9
  37. package/apps/studio/sync/autocommit.ts +61 -2
  38. package/apps/studio/sync/cell-pairing.ts +174 -0
  39. package/apps/studio/sync/codec.ts +11 -5
  40. package/apps/studio/sync/connection-state.ts +116 -6
  41. package/apps/studio/sync/index.ts +288 -26
  42. package/apps/studio/sync/limits.ts +49 -0
  43. package/apps/studio/sync/loopback.ts +21 -0
  44. package/apps/studio/sync/presentation.ts +273 -0
  45. package/apps/studio/sync/projection.ts +47 -12
  46. package/apps/studio/sync/remote-docs.ts +191 -0
  47. package/apps/studio/sync/status.ts +4 -1
  48. package/apps/studio/sync/supervisor.ts +178 -0
  49. package/apps/studio/test/acp-activity-endpoint.test.ts +183 -0
  50. package/apps/studio/test/acp-branch-guard.test.ts +123 -0
  51. package/apps/studio/test/acp-bridge-lifetime.test.ts +369 -0
  52. package/apps/studio/test/acp-caps-bridge.test.ts +5 -0
  53. package/apps/studio/test/acp-commands.test.ts +5 -0
  54. package/apps/studio/test/acp-elicitation-bridge.test.ts +5 -0
  55. package/apps/studio/test/acp-permission-prompt.test.ts +175 -1
  56. package/apps/studio/test/acp-permission.test.ts +18 -2
  57. package/apps/studio/test/acp-session-allowed-tools.test.ts +62 -14
  58. package/apps/studio/test/acp-usage-bridge.test.ts +5 -0
  59. package/apps/studio/test/acp-write-gate.test.ts +323 -0
  60. package/apps/studio/test/acp-write-scope.test.ts +533 -0
  61. package/apps/studio/test/bundle-smoke.test.ts +35 -18
  62. package/apps/studio/test/canvas-origin-gate.test.ts +7 -0
  63. package/apps/studio/test/cloud-connect-note.test.ts +135 -0
  64. package/apps/studio/test/cloud-endpoints.test.ts +102 -0
  65. package/apps/studio/test/cloud-shell-surfaces.test.ts +5 -2
  66. package/apps/studio/test/csrf-write-guard.test.ts +19 -2
  67. package/apps/studio/test/fixtures/mock-acp-agent-slow.mjs +45 -0
  68. package/apps/studio/test/fixtures/mock-acp-agent-write.mjs +118 -0
  69. package/apps/studio/test/gemma-models.test.ts +245 -0
  70. package/apps/studio/test/hmr-broadcast.test.ts +57 -1
  71. package/apps/studio/test/input-router.test.ts +95 -0
  72. package/apps/studio/test/shared-doc-cell-pairing.test.ts +639 -0
  73. package/apps/studio/test/sync-autocommit.test.ts +47 -0
  74. package/apps/studio/test/sync-connect-honesty.test.ts +114 -0
  75. package/apps/studio/test/sync-connection-state.test.ts +45 -1
  76. package/apps/studio/test/sync-presentation.test.ts +185 -0
  77. package/apps/studio/test/sync-remote-docs.test.ts +171 -0
  78. package/apps/studio/test/sync-supervisor.test.ts +212 -0
  79. package/apps/studio/test/trusted-request-host.test.ts +66 -0
  80. package/apps/studio/test/whisper-setup.test.ts +97 -0
  81. package/apps/studio/whats-new.json +45 -0
  82. package/apps/studio/ws.ts +9 -1
  83. package/cli/commands/kg.mjs +9 -2
  84. package/package.json +8 -8
  85. package/plugins/design/dependencies.json +21 -3
@@ -0,0 +1,212 @@
1
+ // The sync supervisor — the piece that makes "Connect" mean "syncing", not
2
+ // "now go restart something".
3
+ //
4
+ // The runtime itself is injected here (a fake factory): what is under test is
5
+ // the SUPERVISION — that a just-authorized link is adopted in memory and
6
+ // applied by a real stop→start cycle, that two Connects can't interleave, and
7
+ // that a refusal comes back as a sentence a person can act on instead of a
8
+ // silent null.
9
+
10
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
11
+ import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
12
+ import { tmpdir } from 'node:os';
13
+ import { join } from 'node:path';
14
+ import * as Y from 'yjs';
15
+
16
+ import { type Context, createBus, type DevServerConfig } from '../context.ts';
17
+ import type { SyncProvider, SyncRuntime } from '../sync/index.ts';
18
+ import { createSyncSupervisor } from '../sync/supervisor.ts';
19
+
20
+ let hubsEnv: string | undefined;
21
+
22
+ beforeEach(() => {
23
+ hubsEnv = process.env.HUBS_CONFIG_PATH;
24
+ });
25
+
26
+ afterEach(() => {
27
+ // Assigning undefined would store the literal string "undefined".
28
+ if (hubsEnv === undefined) delete process.env.HUBS_CONFIG_PATH;
29
+ else process.env.HUBS_CONFIG_PATH = hubsEnv;
30
+ });
31
+
32
+ function makeCtx(linkedHub?: DevServerConfig['linkedHub']): Context {
33
+ const dir = mkdtempSync(join(tmpdir(), 'sync-supervisor-'));
34
+ const designRoot = join(dir, '.design');
35
+ return {
36
+ cfg: {
37
+ name: 'test',
38
+ projectLabel: null,
39
+ designRoot: '.design',
40
+ canvasGroups: [{ label: 'Canvases', path: 'ui' }],
41
+ rootClass: 'app',
42
+ themeDefault: 'dark',
43
+ tokensCssRel: 'system/colors.css',
44
+ teamAccentDefault: null,
45
+ handoffTargets: [],
46
+ newCanvasDir: 'ui',
47
+ newComponentDir: 'ui/components',
48
+ linkedHub,
49
+ _source: 'defaults',
50
+ },
51
+ projectLabel: 'test',
52
+ paths: {
53
+ repoRoot: dir,
54
+ designRel: '.design',
55
+ designRoot,
56
+ serverInfoFile: join(designRoot, '_server.json'),
57
+ activeFile: join(designRoot, '_active.json'),
58
+ commentsDir: join(designRoot, '_comments'),
59
+ canvasStateDir: join(designRoot, '_canvas-state'),
60
+ historyDir: join(designRoot, '_history'),
61
+ tokensUrlRel: '.design/system/colors.css',
62
+ systemDirRel: 'system',
63
+ },
64
+ bus: createBus(),
65
+ };
66
+ }
67
+
68
+ /** A runtime that records its lifecycle and reports `size` canvases. */
69
+ function fakeRuntime(log: string[], id: string, size = 3): SyncRuntime {
70
+ return {
71
+ start: async () => {
72
+ log.push(`start:${id}`);
73
+ },
74
+ stop: async () => {
75
+ log.push(`stop:${id}`);
76
+ },
77
+ size: () => size,
78
+ agentFor: () => undefined,
79
+ status: () => null,
80
+ };
81
+ }
82
+
83
+ describe('createSyncSupervisor', () => {
84
+ test('an unlinked project starts nothing and says so in words', async () => {
85
+ const ctx = makeCtx();
86
+ const sup = createSyncSupervisor(ctx, {}, () => null);
87
+ const out = await sup.start();
88
+ expect(out.syncing).toBe(false);
89
+ expect(out.reason).toBe('unlinked');
90
+ expect(out.detail).toMatch(/not linked/i);
91
+ expect(sup.current()).toBeNull();
92
+ rmSync(ctx.paths.repoRoot, { recursive: true, force: true });
93
+ });
94
+
95
+ test('restart adopts the link it is HANDED and cycles the runtime', async () => {
96
+ const ctx = makeCtx();
97
+ const log: string[] = [];
98
+ let n = 0;
99
+ const sup = createSyncSupervisor(ctx, {}, (c) =>
100
+ c.cfg.linkedHub ? fakeRuntime(log, `r${++n}`) : null
101
+ );
102
+
103
+ // Boot unlinked — solo mode, exactly as before.
104
+ expect((await sup.start()).syncing).toBe(false);
105
+ expect(log).toEqual([]);
106
+
107
+ // The attach lane hands the value it just wrote; the supervisor adopts it
108
+ // (never re-reading the committed config file) and starts syncing.
109
+ const link = { url: 'https://hub.example', linkedAt: 1, syncTsx: false };
110
+ const out = await sup.restart(link);
111
+ expect(out).toEqual({ syncing: true, canvases: 3 });
112
+ expect(ctx.cfg.linkedHub).toEqual(link);
113
+ expect(log).toEqual(['start:r1']);
114
+
115
+ // A second Connect tears the first runtime down before opening the next.
116
+ await sup.restart({ url: 'https://other.example', linkedAt: 2 });
117
+ expect(log).toEqual(['start:r1', 'stop:r1', 'start:r2']);
118
+ expect(ctx.cfg.linkedHub?.url).toBe('https://other.example');
119
+ rmSync(ctx.paths.repoRoot, { recursive: true, force: true });
120
+ });
121
+
122
+ test('two Connects in flight at once do not interleave stop with start', async () => {
123
+ const ctx = makeCtx({ url: 'https://hub.example', linkedAt: 1 });
124
+ const log: string[] = [];
125
+ let n = 0;
126
+ const sup = createSyncSupervisor(ctx, {}, () => fakeRuntime(log, `r${++n}`));
127
+ await sup.start();
128
+ await Promise.all([
129
+ sup.restart({ url: 'https://a.example', linkedAt: 2 }),
130
+ sup.restart({ url: 'https://b.example', linkedAt: 3 }),
131
+ ]);
132
+ expect(log).toEqual(['start:r1', 'stop:r1', 'start:r2', 'stop:r2', 'start:r3']);
133
+ rmSync(ctx.paths.repoRoot, { recursive: true, force: true });
134
+ });
135
+
136
+ test('linked but zero syncable canvases is reported, not mistaken for syncing', async () => {
137
+ const ctx = makeCtx({ url: 'https://hub.example', linkedAt: 1 });
138
+ const log: string[] = [];
139
+ const empty: SyncRuntime = {
140
+ ...fakeRuntime(log, 'empty', 0),
141
+ status: () => ({ reason: 'no canvases found under .design' }) as never,
142
+ };
143
+ const sup = createSyncSupervisor(ctx, {}, () => empty);
144
+ const out = await sup.start();
145
+ expect(out.syncing).toBe(false);
146
+ expect(out.reason).toBe('nothing-syncable');
147
+ // The runtime already wrote the full explanation into _sync.json — the
148
+ // panel repeats THAT, so the two can never drift apart.
149
+ expect(out.detail).toBe('no canvases found under .design');
150
+ rmSync(ctx.paths.repoRoot, { recursive: true, force: true });
151
+ });
152
+
153
+ test('END TO END with the REAL runtime: an unlinked server starts syncing on Connect', async () => {
154
+ // No fake runtime here — this is the actual `createSyncRuntime`, reached
155
+ // exactly as the attach lane reaches it: the server booted solo (no
156
+ // linkedHub anywhere), and one restart() with the value the cloud endpoint
157
+ // just wrote turns the project into a syncing one, in place.
158
+ const ctx = makeCtx();
159
+ mkdirSync(join(ctx.paths.designRoot, 'ui'), { recursive: true });
160
+ mkdirSync(ctx.paths.commentsDir, { recursive: true });
161
+ writeFileSync(join(ctx.paths.designRoot, 'ui', 'home.html'), '<main>home</main>');
162
+ writeFileSync(join(ctx.paths.designRoot, 'ui', 'pricing.html'), '<main>pricing</main>');
163
+
164
+ const url = 'https://hub.example.com';
165
+ process.env.HUBS_CONFIG_PATH = join(ctx.paths.repoRoot, 'hubs.json');
166
+ writeFileSync(
167
+ process.env.HUBS_CONFIG_PATH,
168
+ JSON.stringify({ hubs: { [url]: { token: 'mau_test', linkedAt: 1 } } })
169
+ );
170
+ chmodSync(process.env.HUBS_CONFIG_PATH, 0o600);
171
+
172
+ const opened: string[] = [];
173
+ const providerFactory = (args: { documentName: string; document?: Y.Doc }): SyncProvider => {
174
+ opened.push(args.documentName);
175
+ const document = args.document ?? new Y.Doc();
176
+ return { document, onceSynced: () => Promise.resolve(), destroy: () => {} };
177
+ };
178
+
179
+ const sup = createSyncSupervisor(ctx, { providerFactory });
180
+ expect((await sup.start()).syncing).toBe(false); // solo — nothing linked yet
181
+
182
+ const out = await sup.restart({ url, linkedAt: Date.now() });
183
+ expect(out).toEqual({ syncing: true, canvases: 2 });
184
+ expect(opened.sort()).toEqual(['ui-home', 'ui-pricing']);
185
+ expect(sup.current()).not.toBeNull();
186
+
187
+ await sup.stop();
188
+ rmSync(ctx.paths.repoRoot, { recursive: true, force: true });
189
+ });
190
+
191
+ test('a throwing start leaves the server up and answers with the reason', async () => {
192
+ const ctx = makeCtx({ url: 'https://hub.example', linkedAt: 1 });
193
+ const sup = createSyncSupervisor(ctx, {}, () => ({
194
+ start: async () => {
195
+ throw new Error('hub refused the handshake');
196
+ },
197
+ stop: async () => {},
198
+ size: () => 0,
199
+ agentFor: () => undefined,
200
+ status: () => null,
201
+ }));
202
+ const out = await sup.start();
203
+ expect(out.syncing).toBe(false);
204
+ expect(out.reason).toBe('error');
205
+ expect(out.detail).toContain('hub refused the handshake');
206
+ // And the NEXT attach still runs — a failed cycle must not poison the chain.
207
+ const log: string[] = [];
208
+ expect((await sup.restart()).syncing).toBe(false);
209
+ expect(log).toEqual([]);
210
+ rmSync(ctx.paths.repoRoot, { recursive: true, force: true });
211
+ });
212
+ });
@@ -0,0 +1,66 @@
1
+ // `isTrustedRequestHost` — the mode-aware DNS-rebind guard.
2
+ // RCA: issue-cloud-inspector-edits-refused-by-loopback-guard.
3
+ //
4
+ // The bug: 97 studio route handlers gated on `isLoopbackHost(Host)`, and in a
5
+ // cell the hub proxy rewrites Host to the project's PUBLIC name (D4), so that
6
+ // check could never pass — every Inspector/artboard edit 403'd while annotations
7
+ // (no such guard) synced. The fix keeps the loopback check verbatim OUTSIDE
8
+ // workspace mode and, INSIDE a cell, accepts the proxy's unforgeable
9
+ // `x-maude-role` vouch instead (the proxy strips every inbound `x-maude-*`
10
+ // before injecting its own, so the header's presence proves hub provenance).
11
+
12
+ import { afterEach, describe, expect, test } from 'bun:test';
13
+
14
+ import { isTrustedRequestHost } from '../http.ts';
15
+
16
+ const WS = 'MAUDE_WORKSPACE_MODE';
17
+ const prior = process.env[WS];
18
+ afterEach(() => {
19
+ if (prior === undefined) delete process.env[WS];
20
+ else process.env[WS] = prior;
21
+ });
22
+
23
+ const req = (host: string | null, extra: Record<string, string> = {}) =>
24
+ new Request('http://x/_api/edit-css', {
25
+ headers: { ...(host ? { host } : {}), ...extra },
26
+ });
27
+
28
+ describe('local mode — loopback Host only (verbatim DNS-rebind guard)', () => {
29
+ test('loopback Hosts pass', () => {
30
+ delete process.env[WS];
31
+ expect(isTrustedRequestHost(req('localhost:4399'))).toBe(true);
32
+ expect(isTrustedRequestHost(req('127.0.0.1:4399'))).toBe(true);
33
+ expect(isTrustedRequestHost(req('[::1]:4399'))).toBe(true);
34
+ });
35
+
36
+ test('a rebound foreign Host is refused — even carrying x-maude-role', () => {
37
+ delete process.env[WS];
38
+ expect(isTrustedRequestHost(req('evil.example:4399'))).toBe(false);
39
+ // Outside a cell the injected header means nothing: there is no proxy in
40
+ // front to have stamped it, so it is attacker-supplied and ignored.
41
+ expect(isTrustedRequestHost(req('evil.example:4399', { 'x-maude-role': 'owner' }))).toBe(false);
42
+ });
43
+ });
44
+
45
+ describe('workspace mode — the hub proxy vouch is accepted', () => {
46
+ test('a public Host WITH an injected role is trusted (the cell shell-edit path)', () => {
47
+ process.env[WS] = '1';
48
+ expect(
49
+ isTrustedRequestHost(req('alligators.cloud.maude.sh', { 'x-maude-role': 'owner' }))
50
+ ).toBe(true);
51
+ expect(
52
+ isTrustedRequestHost(req('alligators.cloud.maude.sh', { 'x-maude-role': 'member' }))
53
+ ).toBe(true);
54
+ });
55
+
56
+ test('a public Host WITHOUT an injected role is still refused', () => {
57
+ process.env[WS] = '1';
58
+ // No proxy vouch ⇒ no proof of hub provenance ⇒ the guard holds.
59
+ expect(isTrustedRequestHost(req('alligators.cloud.maude.sh'))).toBe(false);
60
+ });
61
+
62
+ test('loopback still passes in workspace mode (the studio binds 127.0.0.1)', () => {
63
+ process.env[WS] = '1';
64
+ expect(isTrustedRequestHost(req('127.0.0.1:4399'))).toBe(true);
65
+ });
66
+ });
@@ -0,0 +1,97 @@
1
+ // whisper-setup.test.ts — the subtitle stack's runtime detection and the `auto`
2
+ // engine choice. Both exist to close the same gap the scout card had: the card
3
+ // used to print `brew install whisper-cpp` unconditionally, never checked
4
+ // whether the engine was there, and never adapted to the keys you'd set.
5
+
6
+ import { describe, expect, test } from 'bun:test';
7
+ import { resolveAutoEngine, whisperSetup } from '../generation/whisper-models.ts';
8
+
9
+ describe('whisperSetup', () => {
10
+ test('either installed with nothing to do, or a route to fix it', () => {
11
+ const s = whisperSetup();
12
+ if (s.installed) expect(s.options).toHaveLength(0);
13
+ else expect(s.options.length).toBeGreaterThan(0);
14
+ });
15
+
16
+ test('every offered route is actionable as-is', () => {
17
+ for (const o of whisperSetup().options)
18
+ expect(o.kind === 'command' ? o.command : o.url).toBeTruthy();
19
+ });
20
+
21
+ test('a source-build route is always among the options when not installed', () => {
22
+ const s = whisperSetup();
23
+ if (!s.installed) expect(s.options.some((o) => o.id === 'source')).toBe(true);
24
+ });
25
+ });
26
+
27
+ // DDR-164's rule is "no engine switch behind your back". `auto` keeps it by
28
+ // being a SELECTED mode whose resolution is stated — these tests pin the
29
+ // resolution itself, including the part that costs money.
30
+ describe('resolveAutoEngine', () => {
31
+ test('no keys → local whisper, and it says nothing leaves the machine', () => {
32
+ const r = resolveAutoEngine([], true);
33
+ expect(r.engine).toBe('whisper');
34
+ expect(r.cloud).toBe(false);
35
+ expect(r.reason).toContain('No cloud key');
36
+ });
37
+
38
+ test('an ElevenLabs key wins — Scribe beats a local base model', () => {
39
+ const r = resolveAutoEngine(['elevenlabs'], true);
40
+ expect(r.engine).toBe('elevenlabs');
41
+ expect(r.cloud).toBe(true);
42
+ });
43
+
44
+ test('Groq is used when it is the only key', () => {
45
+ expect(resolveAutoEngine(['groq'], true).engine).toBe('groq');
46
+ });
47
+
48
+ test('ElevenLabs outranks Groq when both keys are set', () => {
49
+ expect(resolveAutoEngine(['groq', 'elevenlabs'], true).engine).toBe('elevenlabs');
50
+ });
51
+
52
+ test('an unrelated provider key never routes audio to the cloud', () => {
53
+ // A Google key is for image generation — it must not drag transcription
54
+ // off-machine.
55
+ const r = resolveAutoEngine(['google'], true);
56
+ expect(r.engine).toBe('whisper');
57
+ expect(r.cloud).toBe(false);
58
+ });
59
+
60
+ test('a cloud resolution is always flagged as cloud (the billing/egress tell)', () => {
61
+ for (const keys of [['elevenlabs'], ['groq']])
62
+ expect(resolveAutoEngine(keys, true).cloud).toBe(true);
63
+ });
64
+
65
+ test('without the whisper binary it still resolves local, but says "once installed"', () => {
66
+ const r = resolveAutoEngine([], false);
67
+ expect(r.engine).toBe('whisper');
68
+ expect(r.reason).toContain('once its binary is installed');
69
+ });
70
+ });
71
+
72
+ // The server-side probe and the transcriber's own resolution MUST agree, or the
73
+ // card claims an engine the CLI can't find. Same drift guard the scout has.
74
+ describe('server ↔ CLI whisper resolution parity', () => {
75
+ test('the same candidate names, and `main` is excluded in both', async () => {
76
+ const cli = await import('../bin/_transcribe.mjs');
77
+ const src = await Bun.file(`${import.meta.dir}/../bin/_transcribe.mjs`).text();
78
+ const serverSrc = await Bun.file(`${import.meta.dir}/../generation/whisper-models.ts`).text();
79
+ // Both probe whisper-cli then whisper, and neither probes the bare `main`
80
+ // (executing a seeded `main` from `.` on $PATH was a security finding).
81
+ for (const s of [src, serverSrc]) {
82
+ expect(s).toContain("'whisper-cli'");
83
+ expect(s).toContain("'whisper'");
84
+ expect(s).not.toContain("'main'");
85
+ }
86
+ expect(typeof cli.resolveProvider).toBe('function');
87
+ });
88
+
89
+ test('both accept the same engine vocabulary incl. auto', async () => {
90
+ const cli = await import('../bin/_transcribe.mjs');
91
+ const { TRANSCRIPTION_PROVIDERS } = await import('../generation/prefs.ts');
92
+ // resolveProvider rejects anything outside the vocabulary, so a value the
93
+ // UI can persist must survive it.
94
+ for (const p of TRANSCRIPTION_PROVIDERS)
95
+ expect(cli.resolveProvider(p, '/nonexistent').provider).toBe(p);
96
+ });
97
+ });
@@ -1,6 +1,51 @@
1
1
  {
2
2
  "$schema": "./whats-new.schema.json",
3
3
  "entries": [
4
+ {
5
+ "id": "cloud-connect-tells-you-what-happened",
6
+ "version": "0.57.0",
7
+ "date": "2026-08-07",
8
+ "kind": "fix",
9
+ "title": "Connecting a cloud project tells you what happened",
10
+ "summary": "The line you see after Connect is now live and true: it moves from Connecting, through Syncing 40 of 75, to Synced — and names any canvases that came down from the project. Every outcome says what to do next, so a refused connection tells you to reconnect instead of sitting on “syncing” forever.",
11
+ "surface": "design-ui"
12
+ },
13
+ {
14
+ "id": "acp-turn-notifications",
15
+ "version": "0.57.0",
16
+ "date": "2026-08-07",
17
+ "kind": "feature",
18
+ "title": "Get notified when a background chat finishes",
19
+ "summary": "Maude now sends a real notification when a chat finishes or needs your input — even in a project you're not currently viewing. The notification never shows what was said, only which project needs you.",
20
+ "surface": "design-ui"
21
+ },
22
+ {
23
+ "id": "cloud-projects-save-themselves",
24
+ "version": "0.56.0",
25
+ "date": "2026-08-06",
26
+ "kind": "fix",
27
+ "title": "Cloud projects save themselves",
28
+ "summary": "A project open in Maude Cloud now records every edit as you make it — from a browser tab or from the app — so there is nothing to save by hand. The browser panel that used to ask you to Save and Publish is now simply History, showing what was already kept. Drawn annotations are stored where they are read from again, so they survive a reload.",
29
+ "surface": "design-ui"
30
+ },
31
+ {
32
+ "id": "desktop-cloud-live-pairing",
33
+ "version": "0.56.0",
34
+ "date": "2026-08-06",
35
+ "kind": "feature",
36
+ "title": "Desktop and browser now meet live in the cloud",
37
+ "summary": "Open a Maude Cloud project in the app and in a browser tab at the same time, and you now see each other — cursors, edits, everything, live. Rolling out to pilot projects first.",
38
+ "surface": "design-ui"
39
+ },
40
+ {
41
+ "id": "gemma-scout-ollama-easy-install",
42
+ "version": "0.56.0",
43
+ "date": "2026-08-06",
44
+ "kind": "improvement",
45
+ "title": "Easier Gemma scout setup — Ollama supported",
46
+ "summary": "Scene-aware keyframes' Gemma scout now also runs on Ollama — one app and `ollama pull gemma3:4b`, no Python required. The Settings card hands you copy/paste install commands for both runtimes and unlocks itself automatically once one is installed.",
47
+ "surface": "design-ui"
48
+ },
4
49
  {
5
50
  "id": "cloud-connect-browser-open-and-decision-modal",
6
51
  "version": "0.55.0",
package/apps/studio/ws.ts CHANGED
@@ -8,8 +8,9 @@ import type { Activity } from './activity.ts';
8
8
  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
- import { createHmrBroadcaster } from './hmr-broadcast.ts';
11
+ import { createContainerWriteBridge, createHmrBroadcaster } from './hmr-broadcast.ts';
12
12
  import type { InspectRegistry } from './inspect.ts';
13
+ import { isWorkspaceMode } from './workspace-mode.ts';
13
14
 
14
15
  /**
15
16
  * Per-connection state. `kind` discriminates between the legacy JSON
@@ -268,6 +269,13 @@ export function createWs(
268
269
  // Uses broadcastHmr so the segregated canvas origin's HMR-only sockets get it.
269
270
  createHmrBroadcaster(ctx, (msg) => broadcastHmr(msg));
270
271
 
272
+ // In a cell the container's recursive fs.watch misses our atomic tmp+rename
273
+ // writes, so the broadcaster above never sees an edit and a peer's canvas
274
+ // iframe stays stale until a manual reload (inspector-edits-live-render RCA).
275
+ // Synthesise the fs:any the watcher owes us from each write's activity:suppress
276
+ // arm. Workspace-mode only — locally fs.watch fires and this would double-load.
277
+ if (isWorkspaceMode()) createContainerWriteBridge(ctx);
278
+
271
279
  // Phase 13 / DDR-029 — canvas activity overlay. activity.ts emits
272
280
  // `activity:change` per file as edits land + go idle. The overlay renders
273
281
  // INSIDE the canvas iframe, which (with the default origin split, DDR-054)
@@ -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 = 'v1.0.0';
21
+ const DEFAULT_ENGINE_VERSION = 'v1.4.0';
22
22
  const KGAI_REPO = 'kgaidev/kgai';
23
23
 
24
24
  const VERBS = new Set([
@@ -268,7 +268,14 @@ async function verbCheckUpstream(state) {
268
268
  function verbSessionSync(state, flags) {
269
269
  // Silent no-op unless active AND a remote store is set (a pull needs a remote).
270
270
  if (!state.active || !state.store) return 0;
271
- const status = runKg(state, ['sync'], { timeoutMs: 20000 });
271
+ // `--auto` (kgai v1.2.0+) is the engine's own fire-and-forget mode: safe no-op
272
+ // without a store/remote, honors a 60s cooldown so rapid session restarts don't
273
+ // hammer the remote, and skips (never blocks) when another sync/write holds the
274
+ // store lock. That's a strict improvement over a plain blocking `kg sync` here —
275
+ // this call already runs backgrounded from the SessionStart hook (see
276
+ // plugins/flow/hooks/hooks.json), but the engine-level non-blocking guarantee is
277
+ // what actually prevents a queued/blocked spawn from piling up across sessions.
278
+ const status = runKg(state, ['sync', '--auto'], { timeoutMs: 20000 });
272
279
  if (status !== 0 && flags['warn-only']) {
273
280
  process.stderr.write('maude kg: session sync-pull failed — working on the local cache.\n');
274
281
  return 0; // never block session start
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1agh/maude",
3
- "version": "0.55.0",
3
+ "version": "0.57.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": {
@@ -54,13 +54,13 @@
54
54
  "test:e2e:desktop:parity:cloud": "pnpm --filter @maude/desktop-e2e e2e:parity:cloud"
55
55
  },
56
56
  "optionalDependencies": {
57
- "@1agh/maude-darwin-arm64": "0.55.0",
58
- "@1agh/maude-darwin-x64": "0.55.0",
59
- "@1agh/maude-linux-arm64": "0.55.0",
60
- "@1agh/maude-linux-arm64-musl": "0.55.0",
61
- "@1agh/maude-linux-x64": "0.55.0",
62
- "@1agh/maude-linux-x64-musl": "0.55.0",
63
- "@1agh/maude-win32-x64": "0.55.0"
57
+ "@1agh/maude-darwin-arm64": "0.57.0",
58
+ "@1agh/maude-darwin-x64": "0.57.0",
59
+ "@1agh/maude-linux-arm64": "0.57.0",
60
+ "@1agh/maude-linux-arm64-musl": "0.57.0",
61
+ "@1agh/maude-linux-x64": "0.57.0",
62
+ "@1agh/maude-linux-x64-musl": "0.57.0",
63
+ "@1agh/maude-win32-x64": "0.57.0"
64
64
  },
65
65
  "files": [
66
66
  "cli",
@@ -254,17 +254,35 @@
254
254
  "type": "cli",
255
255
  "hardness": "soft",
256
256
  "check": {
257
- "command": "python3 -c \"import mlx_vlm\"",
257
+ "command": "MAUDE_VENV=\"${XDG_CACHE_HOME:+$XDG_CACHE_HOME/maude}\"; MAUDE_VENV=\"${MAUDE_VENV:-$HOME/.maude}/mlx-venv\"; \"$MAUDE_VENV/bin/python3\" -c \"import mlx_vlm\" 2>/dev/null || python3 -c \"import mlx_vlm\"",
258
258
  "expectExit": 0
259
259
  },
260
260
  "install": {
261
- "darwin": "pip install mlx-vlm",
261
+ "darwin": "MAUDE_VENV=\"${XDG_CACHE_HOME:+$XDG_CACHE_HOME/maude}\"; MAUDE_VENV=\"${MAUDE_VENV:-$HOME/.maude}/mlx-venv\"; python3 -m venv \"$MAUDE_VENV\" && \"$MAUDE_VENV/bin/pip\" install -U mlx-vlm",
262
262
  "preferred": "https://github.com/Blaizzy/mlx-vlm"
263
263
  },
264
264
  "autoInstall": false,
265
- "fallbackBehavior": "feature-scene-aware-keyframes — OPT-IN. The `gemma` tier of `maude design smart-frames` runs a local Gemma-4 MLX scout (Apple Silicon only) to flag semantic action beats. Without it, smart-frames uses the ffmpeg or blind tier — no model download required. The scout also needs a Gemma-4 MLX model (--engine gemma / $MAUDE_GEMMA_MODEL, one-click in Studio Settings → Scene-aware keyframes).",
265
+ "fallbackBehavior": "feature-scene-aware-keyframes — OPT-IN. The `gemma` tier of `maude design smart-frames` runs a local Gemma-4 MLX scout (Apple Silicon only) to flag semantic action beats. Without it, smart-frames uses Ollama (if a gemma3 vision model is pulled), else the ffmpeg or blind tier — no model download required. The scout also needs a Gemma-4 MLX model (--engine gemma / $MAUDE_GEMMA_MODEL, one-click in Studio Settings → Video).",
266
266
  "usedBy": ["dev-server/bin/_smart-frames.mjs"],
267
267
  "docsUrl": "https://github.com/Blaizzy/mlx-vlm"
268
+ },
269
+ {
270
+ "id": "ollama",
271
+ "type": "cli",
272
+ "hardness": "soft",
273
+ "check": {
274
+ "command": "which ollama",
275
+ "expectExit": 0
276
+ },
277
+ "install": {
278
+ "darwin": "curl -fsSL https://ollama.com/install.sh | sh && ollama pull gemma3:4b",
279
+ "linux": "curl -fsSL https://ollama.com/install.sh | sh && ollama pull gemma3:4b",
280
+ "preferred": "https://ollama.com/download"
281
+ },
282
+ "autoInstall": false,
283
+ "fallbackBehavior": "feature-scene-aware-keyframes — OPT-IN alternative scout runtime. The `gemma` tier of `maude design smart-frames` can run its semantic-beat scout through a local Ollama server with a vision-capable gemma3 model (`ollama pull gemma3:4b`) instead of mlx-vlm — the simpler install story (one app, no Python; works beyond Apple Silicon). mlx-vlm is preferred when both are present. Without either, smart-frames uses the ffmpeg or blind tier.",
284
+ "usedBy": ["dev-server/bin/_smart-frames.mjs"],
285
+ "docsUrl": "https://ollama.com"
268
286
  }
269
287
  ]
270
288
  }