@1agh/maude 1.4.0 → 1.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -599,20 +599,26 @@ export function localDepsFromSource(
599
599
  /** mtime signature over the .tsx + every inlined relative dep (`.css` + local
600
600
  * modules). A missing/unreadable file contributes 0 — a delete is itself a
601
601
  * change (and a later create too), so the signature differs. */
602
- function canvasFreshnessSig(tsxAbsPath: string, deps: string[]): string {
602
+ function canvasFreshnessSig(tsxAbsPath: string, deps: string[], tsxMtime?: number): string {
603
603
  const parts: string[] = [];
604
604
  for (const p of [tsxAbsPath, ...deps]) {
605
- const mt = Bun.file(p).lastModified;
605
+ const mt = p === tsxAbsPath && tsxMtime !== undefined ? tsxMtime : Bun.file(p).lastModified;
606
606
  parts.push(`${p}@${Number.isFinite(mt) ? mt : 0}`);
607
607
  }
608
608
  return parts.join('|');
609
609
  }
610
610
 
611
- async function serveCanvasTsx(
611
+ /** @internal Test seam — runs between reading the source and storing the build. */
612
+ export interface ServeCanvasTestHooks {
613
+ afterRead?: () => void | Promise<void>;
614
+ }
615
+
616
+ export async function serveCanvasTsx(
612
617
  absPath: string,
613
618
  req: Request,
614
619
  ctx: Context,
615
- locatorAbsPath: string
620
+ locatorAbsPath: string,
621
+ testHooks?: ServeCanvasTestHooks
616
622
  ): Promise<Response> {
617
623
  // Phase 27 (E2) — DiffView "before" pane. `?sha=<ref>` builds the canvas from
618
624
  // its source AT a past version (git show) instead of the working-tree file, so
@@ -634,8 +640,21 @@ async function serveCanvasTsx(
634
640
  const sig = canvasFreshnessSig(absPath, cached?.deps ?? []);
635
641
 
636
642
  if (!cached || cached.sig !== sig) {
643
+ // THE SIGNATURE OF WHAT WAS READ, taken before the read.
644
+ //
645
+ // It used to be recomputed after the build. A build is slow — out of
646
+ // process on a cell — and a canvas that changed again meanwhile stored the
647
+ // OLD module under the NEW file's signature, so the next request (the HMR
648
+ // for that very change) was a cache hit and served the previous body: a
649
+ // view stuck one edit behind while the file was right (surface row L23 on
650
+ // the hub, 2026-09-16). Stamped before reading, a change during the build
651
+ // leaves the stored signature older than the file, and the next request
652
+ // rebuilds. A dep is stamped before the build reads it, for the same reason.
653
+ const tsxMtime = file.lastModified;
637
654
  const source = await file.text();
638
655
  const deps = localDepsFromSource(source, absPath, ctx.paths.designRoot);
656
+ const builtSig = canvasFreshnessSig(absPath, deps, tsxMtime);
657
+ await testHooks?.afterRead?.();
639
658
  let result: Awaited<ReturnType<typeof buildCanvasModule>>;
640
659
  // DDR-209 A′2 — SAME ENGINE, DIFFERENT HOST. On a desktop the process that
641
660
  // parses your canvas is the process you own, so an in-process build costs
@@ -662,7 +681,7 @@ async function serveCanvasTsx(
662
681
  etag: built.etag,
663
682
  } as Awaited<ReturnType<typeof buildCanvasModule>>;
664
683
  cached = {
665
- sig: canvasFreshnessSig(absPath, deps),
684
+ sig: builtSig,
666
685
  etag: `${result.etag}-${RUNTIME_BOOT_ID}-${CHROME_EPOCH}`,
667
686
  js: result.js,
668
687
  deps,
@@ -694,10 +713,10 @@ async function serveCanvasTsx(
694
713
  headers: { 'Content-Type': 'text/plain; charset=utf-8' },
695
714
  });
696
715
  }
697
- // Recompute the signature against the freshly-parsed deps — this very edit
698
- // may have added or removed a `.css` import.
716
+ // The signature against the freshly-parsed deps — this very edit may have
717
+ // added or removed a `.css` import — stamped before the build (above).
699
718
  cached = {
700
- sig: canvasFreshnessSig(absPath, deps),
719
+ sig: builtSig,
701
720
  // Fold in the boot id (restart) + chrome epoch (live edit) so a chrome
702
721
  // change busts the browser's cached transpile even when the canvas source
703
722
  // (hence result.etag) is unchanged. See RUNTIME_BOOT_ID / CHROME_EPOCH.
@@ -547,6 +547,16 @@ export interface FilePlane {
547
547
  doruceka(): Record<string, DeliveryState>;
548
548
  /** How many rows the ledger holds, before the doručenka's cap. */
549
549
  dorucekaTotal(): number;
550
+ /**
551
+ * The media this machine cannot place, in BYTES as well as files — T29.
552
+ * `null` when nothing is blocked, so a healthy project carries no field.
553
+ */
554
+ blocked(): {
555
+ files: number;
556
+ bytes: number;
557
+ unmeasured: number;
558
+ byClass: Record<string, { files: number; bytes: number }>;
559
+ } | null;
550
560
  }
551
561
 
552
562
  export function createFilePlane(opts: FilePlaneOptions): FilePlane {
@@ -2223,6 +2233,39 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
2223
2233
  dorucekaTotal() {
2224
2234
  return Object.keys(ledger.rows()).length;
2225
2235
  },
2236
+ /**
2237
+ * HOW MUCH MEDIA IS NOT HERE — plan T29.
2238
+ *
2239
+ * `failed` counts files, and a count is the wrong unit for the question
2240
+ * an operator is actually asking. "Nine files failed" is unreadable: nine
2241
+ * CSS sidecars is nothing, nine videos is the project. The bytes are what
2242
+ * says whether a person is looking at a broken thumbnail or at a project
2243
+ * whose media never arrived.
2244
+ *
2245
+ * Bounded by construction — one entry per blocked CLASS, and the classes
2246
+ * are a fixed set (`file-ledger.ts`), never per path. Sizes come from the
2247
+ * ledger's stat cache; a row we have never measured contributes to the
2248
+ * file count and nothing to the bytes, which is honest about not knowing
2249
+ * rather than guessing a size.
2250
+ */
2251
+ blocked() {
2252
+ const byClass: Record<string, { files: number; bytes: number }> = {};
2253
+ let files = 0;
2254
+ let bytes = 0;
2255
+ let unmeasured = 0;
2256
+ for (const row of Object.values(ledger.rows())) {
2257
+ if (row.state !== 'refused' && row.state !== 'stuck') continue;
2258
+ const cls = row.blockedClass ?? (row.state === 'stuck' ? 'unreachable' : 'refused');
2259
+ const size = typeof row.size === 'number' ? row.size : 0;
2260
+ if (!row.size) unmeasured += 1;
2261
+ byClass[cls] ??= { files: 0, bytes: 0 };
2262
+ byClass[cls].files += 1;
2263
+ byClass[cls].bytes += size;
2264
+ files += 1;
2265
+ bytes += size;
2266
+ }
2267
+ return files === 0 ? null : { files, bytes, unmeasured, byClass };
2268
+ },
2226
2269
  };
2227
2270
  }
2228
2271
 
@@ -2009,6 +2009,30 @@ export function createSyncRuntime(
2009
2009
  * canvas accepted a moment ago is pulled on the next poll, not the one after
2010
2010
  * its row is written.
2011
2011
  */
2012
+ /**
2013
+ * Where the PROJECT says a document lives, under accepted revisions — or
2014
+ * null when the project is not the authority (legacy mode, no bootstrap yet,
2015
+ * a retired entry, or a path it never recorded).
2016
+ *
2017
+ * A pulled canvas used to start at the slug-derived fallback
2018
+ * (`ui/surfaceboards-peer.tsx`) and wait for its document's own path to
2019
+ * correct it after the handshake. A fresh copy that reached the handshake
2020
+ * before that path was readable kept the fallback, and on a case-insensitive
2021
+ * disk the lowercase file then sat beside nothing and matched nothing on its
2022
+ * peers (surface run 2026-09-16, L24 final parity). The manifest already
2023
+ * knows the answer before anything is fetched.
2024
+ */
2025
+ const manifestPathFor = (docName: string, slug?: string): string | null => {
2026
+ if (!acceptedOn()) return null;
2027
+ // By slug when there is one: the listing and the runtime name the same
2028
+ // document in more than one spelling (`ui-x`, `ws/<w>/<b>/ui-x`).
2029
+ const key = slug ?? slugFromDocName(docName);
2030
+ const entry = acceptedLink?.manifest?.docs.find(
2031
+ (d) => !d.retired && (d.doc === docName || slugFromDocName(d.doc) === key)
2032
+ );
2033
+ return typeof entry?.path === 'string' && entry.path.length > 0 ? entry.path : null;
2034
+ };
2035
+
2012
2036
  function withAcceptedDocs(
2013
2037
  listing: Awaited<ReturnType<typeof fetchRemoteListing>>
2014
2038
  ): Awaited<ReturnType<typeof fetchRemoteListing>> {
@@ -2310,7 +2334,7 @@ export function createSyncRuntime(
2310
2334
  path.join,
2311
2335
  path.resolve,
2312
2336
  path.sep,
2313
- { ...pathOpts, realpath: realpathOfDeepestExisting }
2337
+ { ...pathOpts, realpath: realpathOfDeepestExisting, pathFor: manifestPathFor }
2314
2338
  );
2315
2339
  const pullNote = describeRemoteDiff(remoteDiff);
2316
2340
  if (pullNote) console.log(`[sync] ${pullNote}`);
@@ -3322,7 +3346,9 @@ export function createSyncRuntime(
3322
3346
  if (movedToFromDoc(doc) !== null) return true;
3323
3347
  const resolved = resolvePulledTarget({
3324
3348
  slug: canvas.slug,
3325
- path: canvasPathFromDoc(doc),
3349
+ // The document's own record first (it is what a move rewrites), the
3350
+ // project's manifest when the document has not carried one yet.
3351
+ path: canvasPathFromDoc(doc) ?? manifestPathFor(docNameFor(canvas.slug), canvas.slug),
3326
3352
  designRoot: ctx.paths.designRoot,
3327
3353
  designRel: ctx.paths.designRel,
3328
3354
  canvasGroups: ctx.cfg.canvasGroups,
@@ -3507,8 +3533,23 @@ export function createSyncRuntime(
3507
3533
  );
3508
3534
  }
3509
3535
  // Cold-start reconcile fires once the provider has hub state.
3510
- const synced = provider.onceSynced().then(() => {
3536
+ const synced = provider.onceSynced().then(async () => {
3511
3537
  if (deferSetup) {
3538
+ // A DOCUMENT THAT DOES NOT NAME ITS PATH YET, UNDER A MANIFEST THAT
3539
+ // PREDATES IT. A canvas created a moment ago is listed before this
3540
+ // copy's last bootstrap knew it, and its document can answer the
3541
+ // handshake before the accepted write that carries its path — so
3542
+ // neither source names the path and the slug fallback (lowercase)
3543
+ // would be written for good (surface run 2026-09-16: two long-running
3544
+ // copies held `ui/surfaceassetsbusy.tsx`). Ask the project once.
3545
+ if (
3546
+ acceptedOn() &&
3547
+ canvasPathFromDoc(provider.document) === null &&
3548
+ manifestPathFor(docNameFor(canvas.slug), canvas.slug) === null
3549
+ ) {
3550
+ await acceptedLink?.refresh().catch(() => null);
3551
+ }
3552
+ if (stopped) return;
3512
3553
  // Settled either way below — run, or the canvas is released.
3513
3554
  owedSetups.delete(canvas.slug);
3514
3555
  // Abandon before `setup?.()`, so no projection and no agent is ever
@@ -3892,7 +3933,12 @@ export function createSyncRuntime(
3892
3933
  // days. Once the body is on disk the ordinary rules apply to it.
3893
3934
  for (const slug of [...pulledSlugs]) {
3894
3935
  const body = descriptors.get(slug)?.html;
3895
- if (body && existsSync(body)) pulledSlugs.delete(slug);
3936
+ // …and only once THIS scan has seen it. A scan taken just before the
3937
+ // body landed does not list it; dropping the pin anyway released a
3938
+ // canvas pulled a moment ago and re-adopted it as a local file, whose
3939
+ // cold start then read its own first write as a local edit and held
3940
+ // the canvas ("source sync blocked (local-edit)" on a fresh copy).
3941
+ if (body && existsSync(body) && bySlug.has(slug)) pulledSlugs.delete(slug);
3896
3942
  }
3897
3943
  const { added, removed } = diffCanvasSet(
3898
3944
  [...agents.keys(), ...projections.keys()],
@@ -4043,6 +4089,14 @@ export function createSyncRuntime(
4043
4089
  // not told the document exists.
4044
4090
  const pullRemoteOnce = async (): Promise<void> => {
4045
4091
  if (stopped) return;
4092
+ // Read `token` at call time: a silent renewal swaps it in place.
4093
+ //
4094
+ // The listing FIRST, the manifest after it. A document the storage
4095
+ // listing shows was accepted before it was stored, so a manifest fetched
4096
+ // afterwards names it — and its path. The other order left a window in
4097
+ // which a canvas created between the two requests was pulled with no
4098
+ // path from either source (see `manifestPathFor`).
4099
+ const rawListing = await fetchRemoteListing(linkedHub.url, token);
4046
4100
  // Accepted revisions: the save mode can change under a running peer (the
4047
4101
  // hub fences every socket when it does), and folders are manifest
4048
4102
  // entries — neither is in the document listing.
@@ -4050,8 +4104,7 @@ export function createSyncRuntime(
4050
4104
  await acceptedLink.refresh();
4051
4105
  if (acceptedOn()) applyProjectDirs(acceptedLink.manifest?.dirs ?? []);
4052
4106
  }
4053
- // Read `token` at call time: a silent renewal swaps it in place.
4054
- const listing = withAcceptedDocs(await fetchRemoteListing(linkedHub.url, token));
4107
+ const listing = withAcceptedDocs(rawListing);
4055
4108
  // null = unreachable, refused, or a hub without the route. Not an error
4056
4109
  // here any more than it is at boot — sync continues, we ask again later.
4057
4110
  if (stopped || listing === null) return;
@@ -4078,6 +4131,7 @@ export function createSyncRuntime(
4078
4131
  // NEVER the fresh-link relaxation after boot. See `strictPullSlugs`.
4079
4132
  allowUndeclaredGroup: false,
4080
4133
  realpath: realpathOfDeepestExisting,
4134
+ pathFor: manifestPathFor,
4081
4135
  }
4082
4136
  );
4083
4137
  const admitted = targets
@@ -4304,6 +4358,9 @@ export function createSyncRuntime(
4304
4358
  lastSeedProgress = progress;
4305
4359
  reportSeedProgress(progress, result);
4306
4360
  }
4361
+ // Read once: calling it inside the spread evaluated it twice and made
4362
+ // its `null` case the object's type.
4363
+ const blockedMedia = filePlane?.blocked() ?? null;
4307
4364
  statusStore?.updateFiles?.({
4308
4365
  ...fileTotals,
4309
4366
  pushed: filePushed,
@@ -4314,6 +4371,9 @@ export function createSyncRuntime(
4314
4371
  ...(filePlane.dorucekaTotal() > MAX_DORUCEKA_ROWS
4315
4372
  ? { deliveryTruncated: filePlane.dorucekaTotal() - MAX_DORUCEKA_ROWS }
4316
4373
  : {}),
4374
+ // T29 — the bytes, not just the count. Omitted entirely when
4375
+ // nothing is blocked, so a healthy project carries no field.
4376
+ ...(blockedMedia ? { blocked: blockedMedia } : {}),
4317
4377
  }
4318
4378
  : {}),
4319
4379
  ...(held.length > 0 ? { held } : {}),
@@ -5498,9 +5558,26 @@ export interface DisposableProviderFactory extends ProviderFactory {
5498
5558
  }
5499
5559
 
5500
5560
  /**
5501
- * The production provider factory — DDR-102: ONE shared
5502
- * `HocuspocusProviderWebsocket` per hub URL, with every canvas's
5503
- * `HocuspocusProvider` attached to it, instead of one socket per canvas.
5561
+ * Most documents one shared socket carries. Hocuspocus 4.3+ closes a socket
5562
+ * with more than 100 documents mid-authentication (`maxPendingDocuments`), so
5563
+ * this stays well under it with room for the refused documents a hub keeps
5564
+ * counting — see the socket choice in `createDefaultProviderFactory`.
5565
+ */
5566
+ export const SOCKET_DOCUMENT_LIMIT = 64;
5567
+
5568
+ /** `SOCKET_DOCUMENT_LIMIT`, or a smaller positive override from
5569
+ * `MAUDE_SYNC_SOCKET_DOCUMENTS` — so a harness can put a small project on
5570
+ * several sockets without having to build a large one first. */
5571
+ function socketDocumentLimit(): number {
5572
+ const n = Number.parseInt(process.env.MAUDE_SYNC_SOCKET_DOCUMENTS ?? '', 10);
5573
+ return Number.isFinite(n) && n > 0 && n < SOCKET_DOCUMENT_LIMIT ? n : SOCKET_DOCUMENT_LIMIT;
5574
+ }
5575
+
5576
+ /**
5577
+ * The production provider factory — DDR-102: shared
5578
+ * `HocuspocusProviderWebsocket`s per hub URL, each carrying up to
5579
+ * `SOCKET_DOCUMENT_LIMIT` canvases' `HocuspocusProvider`s, instead of one
5580
+ * socket per canvas.
5504
5581
  * An 83-canvas project used to open 83 WebSockets at boot — the auth burst
5505
5582
  * tripped the hub's per-token rate limit (100/min) the moment two peers
5506
5583
  * booted together, and the per-socket retry storm then pinned the bucket
@@ -5518,10 +5595,13 @@ export function createDefaultProviderFactory(
5518
5595
  ): DisposableProviderFactory {
5519
5596
  // biome-ignore lint/suspicious/noExplicitAny: provider runtime typed at call site.
5520
5597
  let mod: any = null;
5521
- // wsUrl → shared HocuspocusProviderWebsocket (one per hub URL; in practice a
5522
- // runtime only ever talks to one hub, but the map keeps the contract exact).
5598
+ // wsUrl → the shared sockets to that hub, each carrying at most
5599
+ // SOCKET_DOCUMENT_LIMIT documents (in practice a runtime only ever talks to
5600
+ // one hub, but the map keeps the contract exact).
5523
5601
  // biome-ignore lint/suspicious/noExplicitAny: provider runtime typed at call site.
5524
- const sockets = new Map<string, any>();
5602
+ const sockets = new Map<string, Array<{ socket: any; names: Set<string> }>>();
5603
+ const allSockets = () => [...sockets.values()].flat().map((s) => s.socket);
5604
+ const perSocket = socketDocumentLimit();
5525
5605
 
5526
5606
  const factory = async (args: {
5527
5607
  url: string;
@@ -5542,8 +5622,26 @@ export function createDefaultProviderFactory(
5542
5622
  // the scheme. The provider also accepts http(s):// and upgrades internally
5543
5623
  // in newer versions, but ws:// is explicit + portable.
5544
5624
  const wsUrl = toWsUrl(args.url);
5545
- let socket = sockets.get(wsUrl);
5546
- if (!socket) {
5625
+ // A DOCUMENT KEEPS ITS SOCKET; A FULL SOCKET TAKES NO NEW ONES.
5626
+ //
5627
+ // Hocuspocus (4.3+) closes a socket that has more than 100 documents
5628
+ // mid-authentication, and every document on a socket authenticates at
5629
+ // once — when it opens, and again on every reconnect. One socket for a
5630
+ // 123-canvas project was therefore cut off, reopened and cut off again
5631
+ // for as long as the copy ran: it never synced, and each lap spent the
5632
+ // designer's whole per-label allowance (2026-09-16 certification run,
5633
+ // 4 618 closes). Newer hubs raise the limit; deployed ones keep it.
5634
+ //
5635
+ // Names are never taken back off a socket. The hub keeps a pending slot
5636
+ // for a refused document that sends anything after its refusal — a
5637
+ // detach's Close included — until the socket closes, so what a socket has
5638
+ // EVER carried is what counts against the limit, not what it carries now.
5639
+ const shards = sockets.get(wsUrl) ?? [];
5640
+ sockets.set(wsUrl, shards);
5641
+ let shard =
5642
+ shards.find((s) => s.names.has(args.documentName)) ??
5643
+ shards.find((s) => s.names.size < perSocket);
5644
+ if (!shard) {
5547
5645
  // CONFIGURED, not defaulted (issue #118). The socket used to be built
5548
5646
  // from the URL alone, which inherited `timeout: 0` — no per-attempt
5549
5647
  // deadline — and that is the property that let one parked connection
@@ -5564,13 +5662,28 @@ export function createDefaultProviderFactory(
5564
5662
  //
5565
5663
  // Liveness is the runtime's job instead — see the stall watchdog in
5566
5664
  // `start()`, which is a layer we control and can make safe.
5567
- socket = new mod.HocuspocusProviderWebsocket({
5568
- url: wsUrl,
5569
- messageReconnectTimeout: 30_000,
5570
- maxDelay: 30_000,
5571
- });
5572
- sockets.set(wsUrl, socket);
5665
+ shard = {
5666
+ socket: new mod.HocuspocusProviderWebsocket({
5667
+ url: wsUrl,
5668
+ messageReconnectTimeout: 30_000,
5669
+ maxDelay: 30_000,
5670
+ }),
5671
+ names: new Set<string>(),
5672
+ };
5673
+ shards.push(shard);
5674
+ if (process.env.MAUDE_SYNC_DEBUG === '1') {
5675
+ const tag = `[sync/socket ${shards.length}]`;
5676
+ const own = shard;
5677
+ own.socket.on('open', () => console.log(`${tag} open · ${own.names.size} document(s)`));
5678
+ own.socket.on('close', (e: { event?: { code?: number; reason?: string } }) =>
5679
+ console.log(
5680
+ `${tag} close ${e?.event?.code ?? '?'} ${e?.event?.reason ?? ''} · ${own.names.size} document(s)`
5681
+ )
5682
+ );
5683
+ }
5573
5684
  }
5685
+ shard.names.add(args.documentName);
5686
+ const socket = shard.socket;
5574
5687
  // Phase 9.2 (DDR-064) — attach to the shared room doc when the runtime
5575
5688
  // injected one; otherwise own a fresh doc (the legacy two-doc path).
5576
5689
  const document = args.document ?? new Y.Doc();
@@ -5717,7 +5830,7 @@ export function createDefaultProviderFactory(
5717
5830
 
5718
5831
  return Object.assign(factory, {
5719
5832
  dispose(): void {
5720
- for (const socket of sockets.values()) {
5833
+ for (const socket of allSockets()) {
5721
5834
  try {
5722
5835
  socket.destroy();
5723
5836
  } catch {
@@ -5727,7 +5840,7 @@ export function createDefaultProviderFactory(
5727
5840
  sockets.clear();
5728
5841
  },
5729
5842
  reconnect(): void {
5730
- for (const socket of sockets.values()) {
5843
+ for (const socket of allSockets()) {
5731
5844
  try {
5732
5845
  // NOT `disconnect()` + `connect()` — that pair WEDGES THE SOCKET SHUT,
5733
5846
  // deterministically, in exactly the state this is called from
@@ -159,6 +159,23 @@ export interface FilePlaneStatus {
159
159
  * added to cure, one lane over.
160
160
  */
161
161
  failed?: number;
162
+ /**
163
+ * HOW MUCH is not here — plan T29, the unit `failed` gets wrong.
164
+ *
165
+ * A count cannot answer the operator's question: nine blocked CSS sidecars
166
+ * is nothing and nine blocked videos is the project. `bytes` is what tells
167
+ * those apart. `byClass` is bounded by the ledger's fixed blocked-class set
168
+ * and never keyed by path; `unmeasured` says how many of the files have no
169
+ * size on record, so an under-count is visible rather than silent.
170
+ *
171
+ * Absent — not zero — when nothing is blocked.
172
+ */
173
+ blocked?: {
174
+ files: number;
175
+ bytes: number;
176
+ unmeasured: number;
177
+ byClass: Record<string, { files: number; bytes: number }>;
178
+ };
162
179
  /**
163
180
  * The hub asked us to slow down and the WHOLE LANE is paused until `until`.
164
181
  *
@@ -1,6 +1,24 @@
1
1
  {
2
2
  "$schema": "./whats-new.schema.json",
3
3
  "entries": [
4
+ {
5
+ "id": "steadier-team-projects",
6
+ "version": "1.4.2",
7
+ "date": "2026-09-16",
8
+ "kind": "fix",
9
+ "title": "Steadier team projects",
10
+ "summary": "Large projects (over a hundred canvases) stay connected, the desktop app no longer freezes after switching canvases back and forth, and an open canvas always shows the newest change — including photo edits — instead of sometimes lagging one behind.",
11
+ "surface": "design-ui"
12
+ },
13
+ {
14
+ "id": "history-on-a-team-project",
15
+ "version": "1.4.1",
16
+ "date": "2026-09-16",
17
+ "kind": "fix",
18
+ "title": "Your project history, from the status bar",
19
+ "summary": "On a project you were invited to, the status bar now opens the project history — every change with who made it, and Undo on the ones that are yours. It was only reachable from the View menu before.",
20
+ "surface": "design-ui"
21
+ },
4
22
  {
5
23
  "id": "design-system-on-every-desktop",
6
24
  "version": "1.4.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1agh/maude",
3
- "version": "1.4.0",
3
+ "version": "1.4.2",
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": {
@@ -29,7 +29,8 @@
29
29
  "test:e2e:desktop:git": "pnpm --filter @maude/desktop-e2e e2e:git",
30
30
  "build": "pnpm -r --if-present --filter '!@maude/desktop' '!@maude/desktop-e2e' run build",
31
31
  "build:binary": "bun run apps/studio/build.ts --release",
32
- "test": "node --test --test-reporter=spec cli/**/*.test.mjs && pnpm --filter @maude/hub test",
32
+ "test": "node --test --test-reporter=spec cli/**/*.test.mjs && pnpm --filter @maude/hub test && pnpm run test:harness",
33
+ "test:harness": "node --test $(find scripts/dev/sync-e2e -maxdepth 1 -name 'surface-*.test.mjs' ! -name 'surface-source-audit.test.mjs') && bun test scripts/dev/sync-e2e/surface-source-audit.test.mjs",
33
34
  "test:dev-server": "cd apps/studio && bun test --timeout 20000",
34
35
  "lint": "biome check .",
35
36
  "format": "biome format --write .",
@@ -53,16 +54,17 @@
53
54
  "test:e2e:desktop:parity": "pnpm --filter @maude/desktop-e2e e2e:parity",
54
55
  "test:e2e:desktop:sidecar-respawn": "pnpm --filter @maude/desktop-e2e e2e:sidecar-respawn",
55
56
  "test:e2e:desktop:team-project": "pnpm --filter @maude/desktop-e2e e2e:team-project",
57
+ "test:e2e:desktop:s20": "pnpm --filter @maude/desktop-e2e e2e:s20",
56
58
  "test:e2e:desktop:parity:cloud": "pnpm --filter @maude/desktop-e2e e2e:parity:cloud"
57
59
  },
58
60
  "optionalDependencies": {
59
- "@1agh/maude-darwin-arm64": "1.4.0",
60
- "@1agh/maude-darwin-x64": "1.4.0",
61
- "@1agh/maude-linux-arm64": "1.4.0",
62
- "@1agh/maude-linux-arm64-musl": "1.4.0",
63
- "@1agh/maude-linux-x64": "1.4.0",
64
- "@1agh/maude-linux-x64-musl": "1.4.0",
65
- "@1agh/maude-win32-x64": "1.4.0"
61
+ "@1agh/maude-darwin-arm64": "1.4.2",
62
+ "@1agh/maude-darwin-x64": "1.4.2",
63
+ "@1agh/maude-linux-arm64": "1.4.2",
64
+ "@1agh/maude-linux-arm64-musl": "1.4.2",
65
+ "@1agh/maude-linux-x64": "1.4.2",
66
+ "@1agh/maude-linux-x64-musl": "1.4.2",
67
+ "@1agh/maude-win32-x64": "1.4.2"
66
68
  },
67
69
  "files": [
68
70
  "cli",
@@ -272,12 +272,24 @@
272
272
  // the runtime is mounted — agent edits, manual edits, AND cross-peer synced
273
273
  // writes — so presence + in-canvas state survive. Falls back to the plain
274
274
  // location.reload() below only when no runtime is present.
275
+ //
276
+ // THE NEWEST REQUEST WINS, NOT THE LAST RESPONSE. Two changes in quick
277
+ // succession start two imports, and nothing makes them finish in the
278
+ // order they started — the first can be the slower build. Remounting
279
+ // whichever resolved last put the OLDER body on screen over the newer
280
+ // one while the file on disk was right (surface row L21, a peer's view
281
+ // stuck one edit behind). Only the latest request may remount or hold.
282
+ // soft-reload:begin
283
+ let softReloadSeq = 0;
284
+ const loadCanvasModule = (url) => import(url);
275
285
  function softReload(version) {
276
286
  if (!canvasUrl) { location.reload(); return; }
277
287
  const rt = window.__maudeCanvasRuntime;
278
288
  if (!rt || !rt.remount) { location.reload(); return; }
279
- import(canvasUrl + (canvasUrl.includes('?') ? '&' : '?') + 'v=' + (version || Date.now()))
289
+ const seq = ++softReloadSeq;
290
+ loadCanvasModule(canvasUrl + (canvasUrl.includes('?') ? '&' : '?') + 'v=' + (version || Date.now()))
280
291
  .then((mod) => {
292
+ if (seq !== softReloadSeq) return;
281
293
  if (mod && typeof mod.default === 'function') {
282
294
  rt.remount(mod.default);
283
295
  if (rt.setHolding) rt.setHolding(false);
@@ -287,16 +299,19 @@
287
299
  }
288
300
  })
289
301
  .catch((err) => {
302
+ if (seq !== softReloadSeq) return;
290
303
  // Build/transpile/import error → keep the current render, surface a
291
304
  // non-destructive "holding" toast. Next good build swaps it in.
292
305
  // Same M8 diagnosis as the mount path: the 422 body names the
293
306
  // culprit, the TypeError never does.
294
307
  diagnoseImportFailure(canvasUrl, err).then((msg) => {
308
+ if (seq !== softReloadSeq) return;
295
309
  if (rt.setHolding) rt.setHolding(true, msg || 'build error');
296
310
  });
297
311
  console.warn('[canvas-shell] soft-reload held last good (build error):', err);
298
312
  });
299
313
  }
314
+ // soft-reload:end
300
315
  // Phase 6.5 — exporters pass ?hide-chrome=1 to flip the export-mode
301
316
  // stylesheet from `media="not all"` to `media="all"`, hiding the
302
317
  // dev-server overlays during capture. See `<style id="canvas-hide-chrome">`.
@@ -634,6 +649,20 @@
634
649
  }
635
650
  connectHmr();
636
651
 
652
+ // NO TOP-LEVEL AWAIT IN THIS MODULE.
653
+ //
654
+ // The boot below used to `await` at the top level, which made this the
655
+ // one asynchronously-evaluated module on the page — suspended, with its
656
+ // own dynamic `import()`s in flight, for as long as the canvas took to
657
+ // build. On the desktop (WKWebView) switching canvases away and straight
658
+ // back, which tears the half-loaded frame down in exactly that state,
659
+ // eventually wedged the whole web content process inside JavaScriptCore's
660
+ // module loader: 100 % CPU in `moduleLoaderEvaluate` microtasks, no script
661
+ // ever running again, every canvas and the shell frozen (surface row L23,
662
+ // 2026-09-16 — reproduced in a full run and in a ten-minute soak). An
663
+ // async function that nothing awaits gives the same boot with a module
664
+ // that finishes evaluating at once, the path every engine exercises most.
665
+ const boot = async () => {
637
666
  if (!canvasRel) {
638
667
  showError('Missing ?canvas= query parameter. Add ?canvas=ui/<slug>.tsx.');
639
668
  } else {
@@ -711,6 +740,8 @@
711
740
  diagnoseImportFailure(canvasUrl, err).then(showError);
712
741
  }
713
742
  }
743
+ };
744
+ boot().catch((err) => showError(String((err && err.stack) || err)));
714
745
  </script>
715
746
  </body>
716
747
  </html>