@1agh/maude 1.3.0 → 1.3.1

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.
@@ -0,0 +1,36 @@
1
+ // Plan T34 / writer registry H09 — the checkout of a project that saves through
2
+ // accepted revisions (DDR-241) is a PROJECTION of the shared history, not a
3
+ // second author.
4
+ //
5
+ // A Git operation that rewrites the checkout's files — switching or adding a
6
+ // draft, discarding, getting latest, resolving a merge — would change files
7
+ // under the sync runtime, and its watcher would propose the result as this
8
+ // person's edit: a local Git graph silently overriding what the team accepted.
9
+ // In that mode these routes refuse, and say where the same intent lives:
10
+ // History (restore an earlier version as a new action). Commit, branch, push
11
+ // and fetch stay — they touch the Git graph, not the files (H10).
12
+
13
+ /** The `/_api/git/*` routes that rewrite checkout files. */
14
+ export const CHECKOUT_REWRITING_GIT_ROUTES = [
15
+ '/_api/git/checkout',
16
+ '/_api/git/fold',
17
+ '/_api/git/discard',
18
+ '/_api/git/pull',
19
+ '/_api/git/resolve',
20
+ ] as const;
21
+
22
+ export const ACCEPTED_GIT_REFUSAL = {
23
+ ok: false,
24
+ code: 'accepted-project',
25
+ error:
26
+ 'This project saves through its shared history, so its files cannot be rewritten from Git here. To go back to an earlier version, restore it from History — it becomes a new change everyone sees.',
27
+ } as const;
28
+
29
+ /** A refusal when the linked project is in accepted-revisions mode, else null. */
30
+ export function refuseCheckoutRewrite(acceptedMode: (() => boolean) | undefined): Response | null {
31
+ if (acceptedMode?.() !== true) return null;
32
+ return Response.json(ACCEPTED_GIT_REFUSAL, {
33
+ status: 409,
34
+ headers: { 'Cache-Control': 'no-store' },
35
+ });
36
+ }
@@ -46,8 +46,19 @@ export interface HmrMessage {
46
46
  version: number;
47
47
  /** Echo of `_lib`-scoped changes for debug + scope reasoning. */
48
48
  scope?: 'lib' | 'canvas';
49
+ /**
50
+ * The change was written by sync — the project's version landing on this
51
+ * disk — not by this person's own edit. The iframe may skip a reload it
52
+ * believes is the echo of an optimistic edit it already shows; it must never
53
+ * skip one of these, because a teammate's change can ride the same write (a
54
+ * lost race re-applied onto theirs).
55
+ */
56
+ remote?: boolean;
49
57
  }
50
58
 
59
+ /** How long after a sync write its file's change still counts as remote. */
60
+ export const REMOTE_WRITE_WINDOW_MS = 3_000;
61
+
51
62
  export interface HmrBroadcaster {
52
63
  /** Stop subscribing to fs-watch events. */
53
64
  stop(): void;
@@ -103,9 +114,28 @@ export function createHmrBroadcaster(
103
114
  });
104
115
  }
105
116
 
117
+ // Files sync just wrote (see HmrMessage.remote), with the time of the write.
118
+ const projectedAt = new Map<string, number>();
119
+ const isRemote = (rel: string | undefined): boolean => {
120
+ if (!rel) return false;
121
+ const at = projectedAt.get(rel);
122
+ return at !== undefined && Date.now() - at < REMOTE_WRITE_WINDOW_MS;
123
+ };
124
+ const offProjected = ctx.bus.on('sync:projected', (rel: unknown) => {
125
+ if (typeof rel !== 'string' || !rel) return;
126
+ const now = Date.now();
127
+ projectedAt.set(rel.replace(/\\/g, '/'), now);
128
+ if (projectedAt.size > 256) {
129
+ for (const [k, at] of projectedAt)
130
+ if (now - at >= REMOTE_WRITE_WINDOW_MS) projectedAt.delete(k);
131
+ }
132
+ });
133
+
106
134
  function enqueue(msg: HmrMessage) {
107
135
  const key = msg.mode === 'hard' ? GLOBAL_KEY : (msg.file ?? GLOBAL_KEY);
108
136
  const prev = pendingByKey.get(key);
137
+ // Coalescing never loses "a teammate's change is in here".
138
+ if (prev?.remote) msg.remote = true;
109
139
  // Same-key coalescing keeps the strongest mode (refreshing the payload for
110
140
  // equal rank, so the latest version token wins).
111
141
  if (!prev || rank[msg.mode] >= rank[prev.mode]) pendingByKey.set(key, msg);
@@ -115,12 +145,15 @@ export function createHmrBroadcaster(
115
145
 
116
146
  const offAny = ctx.bus.on('fs:any', (rel: string) => {
117
147
  const msg = classify(rel);
118
- if (msg) enqueue(msg);
148
+ if (!msg) return;
149
+ if (isRemote(rel.replace(/\\/g, '/')) || isRemote(msg.file)) msg.remote = true;
150
+ enqueue(msg);
119
151
  });
120
152
 
121
153
  return {
122
154
  stop() {
123
155
  offAny();
156
+ offProjected();
124
157
  if (pending) clearTimeout(pending);
125
158
  pending = null;
126
159
  pendingByKey.clear();
@@ -98,6 +98,7 @@ import {
98
98
  resolveAutoEngine,
99
99
  whisperSetup,
100
100
  } from './generation/whisper-models.ts';
101
+ import { refuseCheckoutRewrite } from './git/accepted-guard.ts';
101
102
  import { createGitEndpoints } from './git/endpoints.ts';
102
103
  import { gitShowFile } from './git/service.ts';
103
104
  import { createGitHubEndpoints } from './github/endpoints.ts';
@@ -2632,6 +2633,8 @@ export function createHttp(
2632
2633
  return new Response('cross-origin write rejected', { status: 403 });
2633
2634
  if (!isTrustedRequestHost(req))
2634
2635
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2636
+ const rewrite = refuseCheckoutRewrite(ctx.syncControl?.current?.()?.acceptedMode);
2637
+ if (rewrite) return rewrite;
2635
2638
  const body = await readJson<unknown>(req, 8 * 1024);
2636
2639
  return gitJson(await gitApi.checkout(body));
2637
2640
  },
@@ -2644,6 +2647,8 @@ export function createHttp(
2644
2647
  return new Response('cross-origin write rejected', { status: 403 });
2645
2648
  if (!isTrustedRequestHost(req))
2646
2649
  return new Response('adding a draft requires a local request', { status: 403 });
2650
+ const rewrite = refuseCheckoutRewrite(ctx.syncControl?.current?.()?.acceptedMode);
2651
+ if (rewrite) return rewrite;
2647
2652
  const body = await readJson<unknown>(req, 8 * 1024);
2648
2653
  return gitJson(await gitApi.fold(body));
2649
2654
  },
@@ -2676,6 +2681,8 @@ export function createHttp(
2676
2681
  return new Response('cross-origin write rejected', { status: 403 });
2677
2682
  if (!isTrustedRequestHost(req))
2678
2683
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
2684
+ const rewrite = refuseCheckoutRewrite(ctx.syncControl?.current?.()?.acceptedMode);
2685
+ if (rewrite) return rewrite;
2679
2686
  const body = await readJson<unknown>(req, 256 * 1024);
2680
2687
  return gitJson(await gitApi.discard(body));
2681
2688
  },
@@ -2700,6 +2707,8 @@ export function createHttp(
2700
2707
  return new Response('cross-origin write rejected', { status: 403 });
2701
2708
  if (!isTrustedRequestHost(req))
2702
2709
  return new Response('get latest requires a local request', { status: 403 });
2710
+ const rewrite = refuseCheckoutRewrite(ctx.syncControl?.current?.()?.acceptedMode);
2711
+ if (rewrite) return rewrite;
2703
2712
  const body = await readJson<unknown>(req, 8 * 1024);
2704
2713
  return gitJson(await gitApi.pull(body));
2705
2714
  },
@@ -2714,6 +2723,8 @@ export function createHttp(
2714
2723
  return new Response('cross-origin write rejected', { status: 403 });
2715
2724
  if (!isTrustedRequestHost(req))
2716
2725
  return new Response('resolve requires a local request', { status: 403 });
2726
+ const rewrite = refuseCheckoutRewrite(ctx.syncControl?.current?.()?.acceptedMode);
2727
+ if (rewrite) return rewrite;
2717
2728
  const body = await readJson<unknown>(req, 8 * 1024);
2718
2729
  return gitJson(await gitApi.resolve(body));
2719
2730
  },
@@ -69,6 +69,15 @@ export function groupsFromBootstrap(boot: {
69
69
  return [...out].slice(0, 32);
70
70
  }
71
71
 
72
+ function sameServer(a: string | undefined, b: string): boolean {
73
+ if (!a) return false;
74
+ try {
75
+ return normalizeUrl(a) === normalizeUrl(b);
76
+ } catch {
77
+ return false;
78
+ }
79
+ }
80
+
72
81
  async function bootstrapOf(url: string, token: string): Promise<Record<string, unknown> | null> {
73
82
  try {
74
83
  const res = await fetch(`${url}/api/projects/current/v1/bootstrap`, {
@@ -135,6 +144,19 @@ export async function prepareManagedProject(
135
144
  return { ok: false, error: 'The sign-in could not be saved on this computer.', status: 500 };
136
145
  const boot = await bootstrapOf(serverUrl, token);
137
146
  if (boot && typeof boot.projectId === 'string') projectId ??= boot.projectId;
147
+ // SIGN IN AGAIN (T20): a new credential for the server this studio is
148
+ // already syncing with. Reopening the same project attaches to this very
149
+ // process (the shell never starts a second one for a running project), and
150
+ // the running runtime still holds the credential the project refused — so
151
+ // cycle it onto the one just stored. The link is unchanged and never re-read
152
+ // from disk; only the credential is new, and this process wrote it.
153
+ if (input.kind !== 'known-hub' && sameServer(ctx.cfg?.linkedHub?.url, serverUrl)) {
154
+ try {
155
+ await ctx.syncControl?.restart();
156
+ } catch (err) {
157
+ console.error('[managed] could not restart sync on the new sign-in:', err);
158
+ }
159
+ }
138
160
  const host = new URL(serverUrl).host;
139
161
  projectId ??= host;
140
162
  if (!/^[A-Za-z0-9._-]{1,128}$/.test(projectId)) projectId = host.replace(/[^A-Za-z0-9._-]/g, '-');
@@ -1222,6 +1222,9 @@ export function createSyncRuntime(
1222
1222
  /** The outbound delete-lane subscriptions — see `noteToHub`. */
1223
1223
  let deletedUnsub: (() => void) | null = null;
1224
1224
  let createdUnsub: (() => void) | null = null;
1225
+ /** Folders made outside the app while running — see `proposeLocalFolders`. */
1226
+ let foldersUnsub: (() => void) | null = null;
1227
+ let foldersTimer: ReturnType<typeof setTimeout> | null = null;
1225
1228
  /** Periodic remote-document poll — the hub-side half of discovery. */
1226
1229
  let remotePollTimer: ReturnType<typeof setInterval> | null = null;
1227
1230
  /** The stall watchdog's tick — see `stallCheck` in `start()`. */
@@ -1312,6 +1315,15 @@ export function createSyncRuntime(
1312
1315
  * work), and passes are floored `MIN_PASS_INTERVAL_MS` apart.
1313
1316
  */
1314
1317
  let planePassInFlight = false;
1318
+ /**
1319
+ * News that arrived WHILE a pass ran. The running pass may already have read
1320
+ * the disk and the hub's listing, so it cannot carry a file written — or a
1321
+ * row a poke announced — after that point; absorbing the request silently
1322
+ * parked such a file until the next 20 s tick (plan T17/L03: a new image
1323
+ * took 15–20 s to reach a teammate while an edit took one). One more pass,
1324
+ * floored like any other, answers it.
1325
+ */
1326
+ let planePassAgain = false;
1315
1327
  let lastPlanePassAt = 0;
1316
1328
  let planePassWaiter: Promise<void> | null = null;
1317
1329
  let lastPlaneResult: import('./file-plane.ts').FilePlaneResult | null = null;
@@ -1323,7 +1335,10 @@ export function createSyncRuntime(
1323
1335
  // A pass already running IS this pass: it reads the same disk and the same
1324
1336
  // cursor, so the caller waits for its answer rather than racing it. The
1325
1337
  // poll's `await` therefore still means "a pass has happened".
1326
- if (planePassInFlight) return await (planePassWaiter ?? Promise.resolve());
1338
+ if (planePassInFlight) {
1339
+ planePassAgain = true;
1340
+ return await (planePassWaiter ?? Promise.resolve());
1341
+ }
1327
1342
  const since = Date.now() - lastPlanePassAt;
1328
1343
  // The floor governs POKES. The 20 s poll and the explicit `pullRemoteNow`
1329
1344
  // seam are already bounded by their own callers, and silently deferring a
@@ -1346,6 +1361,10 @@ export function createSyncRuntime(
1346
1361
  } finally {
1347
1362
  planePassInFlight = false;
1348
1363
  planePassWaiter = null;
1364
+ if (planePassAgain) {
1365
+ planePassAgain = false;
1366
+ schedulePlanePass();
1367
+ }
1349
1368
  }
1350
1369
  })();
1351
1370
  planePassWaiter = run;
@@ -1867,9 +1886,33 @@ export function createSyncRuntime(
1867
1886
  return abs;
1868
1887
  };
1869
1888
  const designRelPosix = ctx.paths.designRel.replace(/^\.\//, '').replace(/^\/+|\/+$/g, '');
1889
+ /**
1890
+ * A folder the project removed or moved away is taken off this disk only
1891
+ * once it is empty — but the canvases inside leave AFTER the pull that
1892
+ * learned of the action (their moves and deletions land through their own
1893
+ * documents a beat later). Waiting for the next poke or 20 s tick left the
1894
+ * old folder in a teammate's tree for up to 20 s (plan T31/L02). So a
1895
+ * deferred removal asks for another pass shortly, a bounded number of times.
1896
+ */
1897
+ let dirsRetryTimer: ReturnType<typeof setTimeout> | null = null;
1898
+ let dirsRetries = 0;
1899
+ const DIRS_RETRY_MS = 1_500;
1900
+ const DIRS_RETRY_MAX = 12;
1901
+ function retryDeferredDirs(): void {
1902
+ if (stopped || dirsRetryTimer !== null || dirsRetries >= DIRS_RETRY_MAX) return;
1903
+ dirsRetries += 1;
1904
+ dirsRetryTimer = setTimeout(() => {
1905
+ dirsRetryTimer = null;
1906
+ if (stopped || !acceptedOn()) return;
1907
+ if (documentDiscovery) documentDiscovery.schedule();
1908
+ else applyProjectDirs(acceptedLink?.manifest?.dirs ?? []);
1909
+ }, DIRS_RETRY_MS);
1910
+ dirsRetryTimer.unref?.();
1911
+ }
1870
1912
  function applyProjectDirs(dirs: readonly string[]): void {
1871
1913
  const next = new Set(dirs);
1872
1914
  let changed = false;
1915
+ let deferred = 0;
1873
1916
  for (const rel of next) {
1874
1917
  if (knownProjectDirs.has(rel)) continue;
1875
1918
  const abs = projectDirAbs(rel);
@@ -1903,8 +1946,11 @@ export function createSyncRuntime(
1903
1946
  continue;
1904
1947
  }
1905
1948
  // Canvases inside leave through their own deletion/move first; until
1906
- // then the folder stays and the next poll tries again.
1907
- if (entries.some((e) => e !== '.gitkeep')) continue;
1949
+ // then the folder stays and a pass shortly tries again.
1950
+ if (entries.some((e) => e !== '.gitkeep')) {
1951
+ deferred += 1;
1952
+ continue;
1953
+ }
1908
1954
  try {
1909
1955
  for (const e of readdirSync(abs)) rmSync(path.join(abs, e), { force: true });
1910
1956
  rmdirSync(abs);
@@ -1920,6 +1966,8 @@ export function createSyncRuntime(
1920
1966
  changed = true;
1921
1967
  }
1922
1968
  if (changed) saveProjectDirs();
1969
+ if (deferred > 0) retryDeferredDirs();
1970
+ else dirsRetries = 0;
1923
1971
  }
1924
1972
 
1925
1973
  /**
@@ -1975,9 +2023,17 @@ export function createSyncRuntime(
1975
2023
  * project as ONE action at cold start. Only additive: a folder the manifest
1976
2024
  * lists and this disk lacks is never proposed as a deletion from here.
1977
2025
  */
2026
+ let folderProposal: Promise<void> | null = null;
2027
+ let folderProposalAgain = false;
1978
2028
  function proposeLocalFolders(): void {
1979
2029
  if (!acceptedLink) return;
1980
- const listed = new Set(acceptedLink.manifest?.dirs ?? []);
2030
+ // One proposal at a time: a second walk while the first is unanswered
2031
+ // would offer the same folders twice.
2032
+ if (folderProposal) {
2033
+ folderProposalAgain = true;
2034
+ return;
2035
+ }
2036
+ const listed = new Set([...(acceptedLink.manifest?.dirs ?? []), ...knownProjectDirs]);
1981
2037
  const found: string[] = [];
1982
2038
  const walk = (abs: string, rel: string, depth: number) => {
1983
2039
  if (depth > 16 || found.length >= 200) return;
@@ -2006,13 +2062,25 @@ export function createSyncRuntime(
2006
2062
  if (rel) walk(path.join(ctx.paths.designRoot, ...rel.split('/')), rel, 1);
2007
2063
  }
2008
2064
  if (found.length === 0) return;
2009
- void acceptedLink.dirsCreate(found).then((r) => {
2010
- if (r.status === 'accepted') {
2011
- for (const d of found) knownProjectDirs.add(d);
2012
- saveProjectDirs();
2013
- console.log(`[sync] added ${found.length} local folder(s) to the project.`);
2014
- }
2015
- });
2065
+ folderProposal = acceptedLink
2066
+ .dirsCreate(found)
2067
+ .then((r) => {
2068
+ if (r.status === 'accepted') {
2069
+ for (const d of found) knownProjectDirs.add(d);
2070
+ saveProjectDirs();
2071
+ console.log(`[sync] added ${found.length} local folder(s) to the project.`);
2072
+ }
2073
+ })
2074
+ .catch(() => {
2075
+ /* the next folder event or cold start offers them again */
2076
+ })
2077
+ .finally(() => {
2078
+ folderProposal = null;
2079
+ if (folderProposalAgain) {
2080
+ folderProposalAgain = false;
2081
+ proposeLocalFolders();
2082
+ }
2083
+ });
2016
2084
  }
2017
2085
 
2018
2086
  function proposeFolder(
@@ -3535,7 +3603,15 @@ export function createSyncRuntime(
3535
3603
  // the HMR broadcaster's per-file coalescing collapses the pair
3536
3604
  // into one `canvas-hmr`. The projector's own echo guard drops the
3537
3605
  // resulting file→doc read, so this cannot loop.
3538
- ...(cellPairing ? { onWrote: announceWrite } : {}),
3606
+ onWrote: (abs: string) => {
3607
+ // Tell the canvas layer this write carries the PROJECT's
3608
+ // version — possibly a teammate's change merged under this
3609
+ // person's own — so an open canvas never discards its reload
3610
+ // as the echo of an optimistic edit (hmr-broadcast `remote`).
3611
+ const rel = path.relative(ctx.paths.designRoot, abs).split(path.sep).join('/');
3612
+ if (rel && !rel.startsWith('..')) ctx.bus.emit('sync:projected', rel);
3613
+ if (cellPairing) announceWrite(abs);
3614
+ },
3539
3615
  });
3540
3616
  projection.start();
3541
3617
  projections.set(canvas.slug, projection);
@@ -3818,6 +3894,18 @@ export function createSyncRuntime(
3818
3894
  });
3819
3895
  discoveryRescan = rescan;
3820
3896
  discoveryUnsub = ctx.bus.on('canvas-list-update', () => rescan.schedule());
3897
+ // A folder made OUTSIDE the app while it runs — an agent's `mkdir` plus
3898
+ // `.gitkeep`, Finder, a `git checkout` — joins the project the way a cold
3899
+ // start adds one: additively, never as a deletion. Without this it stayed
3900
+ // on this machine until the next launch.
3901
+ foldersUnsub = ctx.bus.on('fs:any', (rel: unknown) => {
3902
+ if (typeof rel !== 'string' || !rel.endsWith('/.gitkeep') || !acceptedOn()) return;
3903
+ if (foldersTimer) clearTimeout(foldersTimer);
3904
+ foldersTimer = setTimeout(() => {
3905
+ foldersTimer = null;
3906
+ if (acceptedOn()) proposeLocalFolders();
3907
+ }, 300);
3908
+ });
3821
3909
 
3822
3910
  // The OUTBOUND half of the delete lane. `api.ts` emits these two only from
3823
3911
  // its privileged create/delete routes, never from the filesystem watcher —
@@ -4518,6 +4606,12 @@ export function createSyncRuntime(
4518
4606
  attachOne = null;
4519
4607
  discoveryUnsub?.();
4520
4608
  discoveryUnsub = null;
4609
+ foldersUnsub?.();
4610
+ foldersUnsub = null;
4611
+ if (foldersTimer) clearTimeout(foldersTimer);
4612
+ foldersTimer = null;
4613
+ if (dirsRetryTimer) clearTimeout(dirsRetryTimer);
4614
+ dirsRetryTimer = null;
4521
4615
  deletedUnsub?.();
4522
4616
  deletedUnsub = null;
4523
4617
  createdUnsub?.();
@@ -427,6 +427,27 @@ export function syncPresentation(
427
427
  };
428
428
  }
429
429
 
430
+ // Accepted revisions: the project answered "sign in first" to a change —
431
+ // the sign-in was revoked or its person removed. The change is kept on this
432
+ // device and retried, but it cannot reach anyone until someone signs in
433
+ // again, so this is a refusal, never "Saving … check your connection".
434
+ const refusedSignIn = readAcceptedRefused(status.accepted);
435
+ if (refusedSignIn) {
436
+ const n = refusedSignIn.pending;
437
+ return {
438
+ phase: 'refused',
439
+ online: false,
440
+ label: 'sign in again',
441
+ title:
442
+ `${project} no longer accepts your sign-in — ` +
443
+ (n > 0
444
+ ? `${n} change${n === 1 ? ' is' : 's are'} kept on this device and not shared.`
445
+ : 'your changes are not shared.'),
446
+ next: 'Sign in again to share them.',
447
+ names: [],
448
+ };
449
+ }
450
+
430
451
  // A refused SOURCE change outranks an unreachable hub for the same reason a
431
452
  // refusal does: it is sticky, and it is the part that needs a person.
432
453
  // (With the hub reachable, the same sentence lists every other lane too.)
@@ -634,6 +655,15 @@ function readAcceptedPending(
634
655
  };
635
656
  }
636
657
 
658
+ /** `status.accepted.credentialRefused`, validated — only a literal `true` counts. */
659
+ function readAcceptedRefused(raw: unknown): { pending: number } | null {
660
+ if (!raw || typeof raw !== 'object') return null;
661
+ const r = raw as { credentialRefused?: unknown; pending?: unknown };
662
+ if (r.credentialRefused !== true) return null;
663
+ const p = r.pending;
664
+ return { pending: typeof p === 'number' && Number.isInteger(p) && p > 0 && p <= 1e6 ? p : 0 };
665
+ }
666
+
637
667
  /** `status.aiAction`, validated — never trusted off disk. */
638
668
  function readAiAction(raw: unknown): { state: 'open' | 'held'; canvases: number } | null {
639
669
  if (!raw || typeof raw !== 'object') return null;
@@ -103,6 +103,8 @@ export interface AcceptedSaveStatus {
103
103
  oldestPendingAt: number | null;
104
104
  ackMs: { last: number | null; p95: number | null; n: number };
105
105
  rejected: number;
106
+ /** The project refuses this sign-in — changes wait for signing in again. */
107
+ credentialRefused?: boolean;
106
108
  }
107
109
 
108
110
  export interface SyncNotice {
@@ -406,7 +408,9 @@ export function createSyncStatusStore(opts: SyncStatusStoreOptions): SyncStatusS
406
408
  accepted = next;
407
409
  // Going from waiting to caught-up (or back) is news; a latency tick is not.
408
410
  flush(
409
- ((was?.pending ?? 0) === 0) !== (next.pending === 0) || next.rejected !== was?.rejected
411
+ ((was?.pending ?? 0) === 0) !== (next.pending === 0) ||
412
+ next.rejected !== was?.rejected ||
413
+ !!next.credentialRefused !== !!was?.credentialRefused
410
414
  );
411
415
  },
412
416
  updateFiles(next) {
@@ -98,6 +98,14 @@ export interface TransactionStats {
98
98
  ackMs: { last: number | null; p95: number | null; n: number };
99
99
  /** Final rejections this session (conflicts, invalid source, rights…). */
100
100
  rejected: number;
101
+ /**
102
+ * The project answered the last attempt "sign in first": this sign-in was
103
+ * revoked or its person disabled. The change stays in the outbox and is
104
+ * retried with whatever credential `token()` returns next — it is not lost,
105
+ * but it cannot be shared until someone signs in again, which is what a
106
+ * person must be told instead of "check your connection".
107
+ */
108
+ credentialRefused: boolean;
101
109
  }
102
110
 
103
111
  const sha256 = (s: string) => createHash('sha256').update(s, 'utf8').digest('hex');
@@ -139,6 +147,12 @@ export function createTransactionClient(opts: TransactionClientOptions) {
139
147
  const waiting = new Map<string, number>();
140
148
  const acks: number[] = [];
141
149
  let rejectedCount = 0;
150
+ let credentialRefused = false;
151
+ const setCredentialRefused = (next: boolean) => {
152
+ if (credentialRefused === next) return;
153
+ credentialRefused = next;
154
+ opts.onStats?.(stats());
155
+ };
142
156
  function stats(): TransactionStats {
143
157
  const sorted = [...acks].sort((a, b) => a - b);
144
158
  return {
@@ -152,6 +166,7 @@ export function createTransactionClient(opts: TransactionClientOptions) {
152
166
  n: acks.length,
153
167
  },
154
168
  rejected: rejectedCount,
169
+ credentialRefused,
155
170
  };
156
171
  }
157
172
  const setPending = (delta: number) => {
@@ -239,13 +254,24 @@ export function createTransactionClient(opts: TransactionClientOptions) {
239
254
  if (attempt > 0) {
240
255
  const known = await request('GET', `transactions/${entry.transactionId}`);
241
256
  if (known.status === 200 && known.json) {
257
+ setCredentialRefused(false);
242
258
  rmSync(file, { force: true });
243
259
  return known.json as ProposalResult;
244
260
  }
245
261
  }
246
262
  const { status, json } = await request('POST', 'proposals', entry.bytes);
263
+ // Not an answer about the change — about who is asking. Kept and
264
+ // retried (a new sign-in supplies a new token), and said out loud.
265
+ if (status === 401) {
266
+ setCredentialRefused(true);
267
+ throw new TransactionError(
268
+ 'the project no longer accepts this sign-in',
269
+ 'unauthenticated'
270
+ );
271
+ }
247
272
  const result = json as ProposalResult | null;
248
273
  if (result && (result.status === 'accepted' || result.status === 'rejected')) {
274
+ setCredentialRefused(false);
249
275
  if (result.status === 'rejected' && (result.code === 'retryable' || status >= 500)) {
250
276
  throw new TransactionError('hub asked to retry', 'retryable');
251
277
  }
@@ -18,6 +18,7 @@ import {
18
18
  useContext,
19
19
  useEffect,
20
20
  useMemo,
21
+ useRef,
21
22
  useState,
22
23
  } from 'react';
23
24
 
@@ -53,6 +54,9 @@ export type ShapeKind = 'square' | 'rounded' | 'circle' | 'diamond' | 'triangle'
53
54
  */
54
55
  export type CanvasMode = 'preview' | 'edit';
55
56
 
57
+ /** Document event two provider instances keep one active tool through. */
58
+ export const TOOL_SYNC_EVENT = 'maude:tool-sync';
59
+
56
60
  /** The resting tool each mode arms (and returns to via `resetTool`). */
57
61
  export const MODE_DEFAULT_TOOL: Readonly<Record<CanvasMode, Tool>> = Object.freeze({
58
62
  preview: 'browse',
@@ -231,6 +235,46 @@ export function ToolProvider({
231
235
  // FigJam look); sharp squares stay one popover click away.
232
236
  const [shapeKind, setShapeKind] = useState<ShapeKind>('rounded');
233
237
 
238
+ // ONE ACTIVE TOOL PER CANVAS DOCUMENT, across provider instances.
239
+ //
240
+ // A UI canvas carries TWO providers: the comment-mount layer's (it owns the
241
+ // comment drop) and canvas-lib's own (it owns the palette) — separate
242
+ // bundles, so separate contexts. They used to converge only through a tool
243
+ // KEYDOWN, which both routers see; a palette CLICK reached canvas-lib's
244
+ // provider alone. So "Comment" in the palette lit up while the comment
245
+ // layer stayed in browse, and clicking the canvas dropped nothing — you had
246
+ // to know to press C (plan T31/L11). A change here is announced on the
247
+ // document and every other instance adopts it. The boot posture is NOT
248
+ // announced (the two instances deliberately boot differently — see
249
+ // `initial`); only a change after mount is.
250
+ const syncId = useRef(Math.random().toString(36).slice(2));
251
+ const toolRef = useRef(tool);
252
+ toolRef.current = tool;
253
+ const announced = useRef(false);
254
+ useEffect(() => {
255
+ if (!announced.current) {
256
+ announced.current = true;
257
+ return;
258
+ }
259
+ if (typeof document === 'undefined') return;
260
+ document.dispatchEvent(
261
+ new CustomEvent(TOOL_SYNC_EVENT, { detail: { tool, from: syncId.current } })
262
+ );
263
+ }, [tool]);
264
+ useEffect(() => {
265
+ if (typeof document === 'undefined') return;
266
+ const onSync = (e: Event) => {
267
+ const d = (e as CustomEvent<{ tool?: unknown; from?: unknown }>).detail;
268
+ if (!d || d.from === syncId.current || typeof d.tool !== 'string') return;
269
+ // Already there: nothing to do, and — for a sticky-locked tool — not
270
+ // touching it keeps the lock.
271
+ if (d.tool === toolRef.current) return;
272
+ setTool(d.tool as Tool);
273
+ };
274
+ document.addEventListener(TOOL_SYNC_EVENT, onSync);
275
+ return () => document.removeEventListener(TOOL_SYNC_EVENT, onSync);
276
+ }, [setTool]);
277
+
234
278
  // Cursor sync — applied inside the canvas (this hook runs in the canvas
235
279
  // context). The active tool's cursor is set on <body> AND forced across the
236
280
  // whole canvas working area via an `!important` rule, so the custom cursor
@@ -1,6 +1,42 @@
1
1
  {
2
2
  "$schema": "./whats-new.schema.json",
3
3
  "entries": [
4
+ {
5
+ "id": "live-canvases-long-sessions",
6
+ "version": "1.3.1",
7
+ "date": "2026-09-15",
8
+ "kind": "fix",
9
+ "title": "Canvases stay live in long sessions",
10
+ "summary": "A canvas open in your browser on a Maude Cloud project kept working for about 15 minutes and then quietly stopped showing teammates’ changes until you reloaded the page — it now stays live however long you work. And editing a design system’s tokens.css restyles every open canvas that uses it, straight away.",
11
+ "surface": "design-ui"
12
+ },
13
+ {
14
+ "id": "team-access-and-presence",
15
+ "version": "1.3.1",
16
+ "date": "2026-09-15",
17
+ "kind": "fix",
18
+ "title": "Lost access? Maude asks you to sign in again",
19
+ "summary": "If an owner removes your access or your sign-in expires, the Sync panel now tells you to sign in again — instead of \"check your connection\" — and keeps your changes on this computer until you do. Signing in again sends them. And when a teammate closes a canvas, their avatar and cursor now leave everyone else’s screen straight away.",
20
+ "surface": "design-ui"
21
+ },
22
+ {
23
+ "id": "comment-tool-and-live-tree",
24
+ "version": "1.3.1",
25
+ "date": "2026-09-15",
26
+ "kind": "fix",
27
+ "title": "The Comment tool works from the toolbar; new images and folders show up for everyone at once",
28
+ "summary": "Picking Comment in the canvas toolbar now lets you click an element to comment — before, only the C key did. An image saved next to your canvases appears in the file tree straight away, on your machine and your teammates’. A folder you create or move outside Maude, or move in the tree, reaches the team in seconds instead of on the next launch.",
29
+ "surface": "design-ui"
30
+ },
31
+ {
32
+ "id": "inspector-keeps-number-types",
33
+ "version": "1.3.1",
34
+ "date": "2026-09-15",
35
+ "kind": "fix",
36
+ "title": "Editing a number in the inspector keeps it a number",
37
+ "summary": "Changing a value like an artboard height or a video’s length used to turn it into text in the canvas source — some components then misread it. Numbers and on/off values now stay what they were, and leaving a value unchanged no longer rewrites the file.",
38
+ "surface": "design-ui"
39
+ },
4
40
  {
5
41
  "id": "artboard-rename-and-files",
6
42
  "version": "1.3.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1agh/maude",
3
- "version": "1.3.0",
3
+ "version": "1.3.1",
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": {
@@ -56,13 +56,13 @@
56
56
  "test:e2e:desktop:parity:cloud": "pnpm --filter @maude/desktop-e2e e2e:parity:cloud"
57
57
  },
58
58
  "optionalDependencies": {
59
- "@1agh/maude-darwin-arm64": "1.3.0",
60
- "@1agh/maude-darwin-x64": "1.3.0",
61
- "@1agh/maude-linux-arm64": "1.3.0",
62
- "@1agh/maude-linux-arm64-musl": "1.3.0",
63
- "@1agh/maude-linux-x64": "1.3.0",
64
- "@1agh/maude-linux-x64-musl": "1.3.0",
65
- "@1agh/maude-win32-x64": "1.3.0"
59
+ "@1agh/maude-darwin-arm64": "1.3.1",
60
+ "@1agh/maude-darwin-x64": "1.3.1",
61
+ "@1agh/maude-linux-arm64": "1.3.1",
62
+ "@1agh/maude-linux-arm64-musl": "1.3.1",
63
+ "@1agh/maude-linux-x64": "1.3.1",
64
+ "@1agh/maude-linux-x64-musl": "1.3.1",
65
+ "@1agh/maude-win32-x64": "1.3.1"
66
66
  },
67
67
  "files": [
68
68
  "cli",