@1agh/maude 1.4.5 → 1.4.6

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,229 @@
1
+ // Per-machine comment ledger — issue #133.
2
+ //
3
+ // Records, per canvas slug, the comment identities (`commentKey`) that were on
4
+ // this machine's `_comments/<slug>.json` AND in the shared document at the last
5
+ // point the two agreed. It answers the one question neither side can answer
6
+ // alone after a restart: an id that is on disk but not in the document — is it
7
+ // a comment still on its way TO the document (keep it), or one the document
8
+ // DROPPED while this machine was away (a remote delete — let it go)?
9
+ //
10
+ // - in the ledger → it was synced before, the document no longer has it: a
11
+ // delete. The projection may write the file without it, and a cold-start
12
+ // union must not put it back.
13
+ // - not in the ledger → never synced from here: still in flight (a mutation
14
+ // whose import has not landed, an external edit — how /design:edit resolves
15
+ // comments). Keep it; defer, exactly as before.
16
+ //
17
+ // Before this, the "ever carried" knowledge lived only in memory
18
+ // (collab/persistence.ts), so it started empty on every launch. A desktop that
19
+ // was closed while a comment was deleted on the web then read that id as "in
20
+ // flight" forever and never wrote its comments file again (accepted mode: the
21
+ // file froze), and the legacy cold-start union re-added it for everyone.
22
+ //
23
+ // Per hub, like the sync journal (DDR-102): the file carries the hub URL it was
24
+ // recorded against, and relinking to a different hub wipes it — "synced
25
+ // before" against one hub says nothing about another. Best-effort and never
26
+ // throws: a missing or corrupt ledger reads as empty, which degrades to the old
27
+ // behaviour (defer), the safe direction. Lives under `_state/`, which is
28
+ // already per-machine runtime state (DDR-115 taxonomy), so no new path.
29
+
30
+ import { createHash } from 'node:crypto';
31
+ import { existsSync, readFileSync } from 'node:fs';
32
+ import path from 'node:path';
33
+
34
+ import { atomicWrite } from './atomic-write.ts';
35
+ import { commentKey } from './comment-identity.ts';
36
+
37
+ const LEDGER_FILE = 'comment-ledger.json';
38
+ /**
39
+ * Written immediately, not debounced: a record follows a comments-file write
40
+ * (rare), and a peer quit right after syncing must not lose it — a debounced
41
+ * ledger lost exactly that record in the E2E relaunch run and left the file
42
+ * frozen. Order is file first, ledger second, so a crash between the two
43
+ * leaves ids the ledger does not know: deferred, the safe direction.
44
+ */
45
+ const FLUSH_MS = 0;
46
+ /** Same ceiling as collab/persistence.ts MAX_SEEN_COMMENT_IDS (DDR-054 §2d). */
47
+ const MAX_IDS_PER_SLUG = 10_000;
48
+
49
+ interface LedgerFileShape {
50
+ hubUrl: string | null;
51
+ updatedAt: number;
52
+ slugs: Record<string, string[]>;
53
+ }
54
+
55
+ /** A read-only view of the identities recorded for one canvas. */
56
+ export interface SyncedSet {
57
+ readonly size: number;
58
+ /** Takes a raw `commentKey`; the ledger stores only its hash. */
59
+ has(key: string): boolean;
60
+ }
61
+
62
+ /**
63
+ * The stored form of a comment key. Hashed (security review F3): an id-less
64
+ * comment's key is its whole JSON body, which a peer controls — the ledger must
65
+ * not grow with comment bodies, only with how many there are.
66
+ */
67
+ export function ledgerKey(key: string): string {
68
+ return createHash('sha256').update(key).digest('base64url').slice(0, 22);
69
+ }
70
+
71
+ export interface CommentLedger {
72
+ /** Identities last known synced for `slug` (empty when never recorded). */
73
+ get(slug: string): SyncedSet;
74
+ /** Has `slug` ever been recorded (under the current hub)? Distinguishes
75
+ * "synced, and had no comments" from "no knowledge" (first launch after
76
+ * upgrading, a fresh link) — only the first may act on a difference. */
77
+ known(slug: string): boolean;
78
+ /** Replace `slug`'s record with the identities of a list both sides now agree on. */
79
+ record(slug: string, keys: Iterable<string>): void;
80
+ /**
81
+ * Linked to a (different) hub → drop every record. The identity must name
82
+ * the document namespace, not just the URL: one hub serves several
83
+ * workspaces (and branches), and "synced before" in one says nothing about
84
+ * another (security review F1c). `null` = no hub.
85
+ */
86
+ invalidateIfHubChanged(identity: string | null): void;
87
+ /** Persist now. Best-effort. */
88
+ flush(): void;
89
+ }
90
+
91
+ export function commentLedgerPath(designRoot: string): string {
92
+ return path.join(designRoot, '_state', LEDGER_FILE);
93
+ }
94
+
95
+ export interface LoadCommentLedgerOptions {
96
+ /** Tests use 0 to persist synchronously. */
97
+ flushMs?: number;
98
+ writer?: (file: string, bytes: string) => void;
99
+ }
100
+
101
+ export function loadCommentLedger(
102
+ designRoot: string,
103
+ opts: LoadCommentLedgerOptions = {}
104
+ ): CommentLedger {
105
+ const file = commentLedgerPath(designRoot);
106
+ const flushMs = opts.flushMs ?? FLUSH_MS;
107
+ const writer = opts.writer ?? ((p: string, bytes: string) => void atomicWrite(p, bytes));
108
+
109
+ let hubUrl: string | null = null;
110
+ const slugs = new Map<string, Set<string>>();
111
+ let timer: ReturnType<typeof setTimeout> | null = null;
112
+ let dirty = false;
113
+
114
+ try {
115
+ if (existsSync(file)) {
116
+ const parsed = JSON.parse(readFileSync(file, 'utf8')) as Partial<LedgerFileShape>;
117
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
118
+ hubUrl = typeof parsed.hubUrl === 'string' ? parsed.hubUrl : null;
119
+ const raw = parsed.slugs;
120
+ if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
121
+ for (const [slug, ids] of Object.entries(raw)) {
122
+ if (!Array.isArray(ids)) continue;
123
+ const keys = ids.filter((k): k is string => typeof k === 'string');
124
+ slugs.set(slug, new Set(keys.slice(-MAX_IDS_PER_SLUG)));
125
+ }
126
+ }
127
+ }
128
+ }
129
+ } catch {
130
+ /* corrupt → empty; every id then reads as "in flight", the old behaviour */
131
+ }
132
+
133
+ function persist(): void {
134
+ if (timer) {
135
+ clearTimeout(timer);
136
+ timer = null;
137
+ }
138
+ if (!dirty) return;
139
+ dirty = false;
140
+ const payload: LedgerFileShape = {
141
+ hubUrl,
142
+ updatedAt: Date.now(),
143
+ slugs: Object.fromEntries([...slugs].map(([s, ids]) => [s, [...ids]])),
144
+ };
145
+ try {
146
+ writer(file, `${JSON.stringify(payload)}\n`);
147
+ } catch (err) {
148
+ console.warn(
149
+ '[sync/comment-ledger] persist failed:',
150
+ err instanceof Error ? err.message : err
151
+ );
152
+ }
153
+ }
154
+
155
+ function schedule(): void {
156
+ dirty = true;
157
+ if (flushMs === 0) {
158
+ persist();
159
+ return;
160
+ }
161
+ if (!timer) timer = setTimeout(persist, flushMs);
162
+ }
163
+
164
+ return {
165
+ get(slug) {
166
+ const set = slugs.get(slug);
167
+ return {
168
+ size: set?.size ?? 0,
169
+ has: (key: string) => !!set && set.has(ledgerKey(key)),
170
+ };
171
+ },
172
+ known(slug) {
173
+ return slugs.has(slug);
174
+ },
175
+ record(slug, keys) {
176
+ const next = new Set<string>();
177
+ for (const k of keys) {
178
+ next.add(ledgerKey(k));
179
+ if (next.size >= MAX_IDS_PER_SLUG) break;
180
+ }
181
+ const prev = slugs.get(slug);
182
+ if (prev && prev.size === next.size && [...next].every((k) => prev.has(k))) return;
183
+ slugs.set(slug, next);
184
+ schedule();
185
+ },
186
+ invalidateIfHubChanged(url) {
187
+ if (url === hubUrl) return;
188
+ hubUrl = url;
189
+ slugs.clear();
190
+ schedule();
191
+ },
192
+ flush: persist,
193
+ };
194
+ }
195
+
196
+ /**
197
+ * The local half of a cold-start comments union, minus the comments a peer
198
+ * deleted while this machine was away: those the ledger knows were synced from
199
+ * here and the document no longer holds. Everything else — comments never
200
+ * synced from here — still unions up, exactly as before. Shared by both
201
+ * cold-start paths (sync/agent.ts reconcile, sync/migrate-seed.ts), which must
202
+ * agree or one would resurrect what the other let go.
203
+ */
204
+ export function withoutRemotelyDeleted(
205
+ local: unknown[],
206
+ docList: unknown[],
207
+ syncedBefore: Pick<SyncedSet, 'size' | 'has'> | undefined
208
+ ): unknown[] {
209
+ if (!syncedBefore || syncedBefore.size === 0) return local;
210
+ const inDoc = new Set(docList.map(commentKey));
211
+ return local.filter((c) => {
212
+ const k = commentKey(c);
213
+ return inDoc.has(k) || !syncedBefore.has(k);
214
+ });
215
+ }
216
+
217
+ // One ledger per design root per process: the room projection (collab/) and the
218
+ // sync agent (sync/) must share it, or two writers would overwrite each other's
219
+ // file and each would only know half of what was synced.
220
+ const shared = new Map<string, CommentLedger>();
221
+
222
+ export function commentLedgerFor(designRoot: string): CommentLedger {
223
+ let l = shared.get(designRoot);
224
+ if (!l) {
225
+ l = loadCommentLedger(designRoot);
226
+ shared.set(designRoot, l);
227
+ }
228
+ return l;
229
+ }
@@ -640,6 +640,21 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
640
640
  let reanchorsInARow = 0;
641
641
  let reanchorHeldSince = 0;
642
642
 
643
+ /**
644
+ * A conflict row is waiting on a remote only a FULL read can tell it.
645
+ *
646
+ * A push answered `conflict` defers to "the next pass, against what the hub
647
+ * holds now" — but for a path the hub then leaves alone, a cursor read says
648
+ * nothing, the remembered remote stays unknown, and the row sat in
649
+ * `conflict` for good: Resync restarted the plane and read the same silence
650
+ * (rca issue-file-ledger-orphaned-rows). So a conflict owes one full read.
651
+ * Seeded from the ledger, which is what makes Resync (a restart) pay it.
652
+ * Capped at one per `REANCHOR_HOLD_RECOVERY_MS`, like the re-anchor storm,
653
+ * so a hub answering `conflict` to every push cannot farm full reads.
654
+ */
655
+ let fullReadOwed = Object.values(ledger.rows()).some((r) => r.state === 'conflict');
656
+ let lastOwedFullReadAt = Number.NEGATIVE_INFINITY;
657
+
643
658
  /**
644
659
  * When the plane may talk to the hub again. Issue #109.
645
660
  *
@@ -1497,7 +1512,14 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
1497
1512
  requestsThisPass = 0;
1498
1513
 
1499
1514
  // ── 1. The hub's side ────────────────────────────────────────────────
1500
- const startedFrom = ledger.cursor();
1515
+ const payOwedRead = fullReadOwed && now() - lastOwedFullReadAt >= REANCHOR_HOLD_RECOVERY_MS;
1516
+ if (payOwedRead) {
1517
+ fullReadOwed = false;
1518
+ lastOwedFullReadAt = now();
1519
+ }
1520
+ // An owed read is a compaction read in the SAME epoch: the ancestors still
1521
+ // describe this log, so nothing is degraded — it only refreshes remotes.
1522
+ const startedFrom = payOwedRead ? 0 : ledger.cursor();
1501
1523
  let fullRead = startedFrom === 0;
1502
1524
  let page = await fetchJournal({
1503
1525
  hubUrl: opts.hubUrl,
@@ -1648,6 +1670,45 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
1648
1670
  ledger.forget(rel);
1649
1671
  continue;
1650
1672
  }
1673
+ // NOT OURS ON EITHER SIDE ANY MORE — forget it, do not keep refusing it.
1674
+ //
1675
+ // A path can leave the file plane while the ledger still tracks it: a
1676
+ // `.css` written before its `.tsx` becomes the canvas's sidecar, or the
1677
+ // canvas groups change. The local scan stops offering it, the hub stops
1678
+ // listing it, and the row stayed behind forever — re-stamped `stuck` by
1679
+ // the admission drop against a remembered remote, or frozen in
1680
+ // `conflict` with nothing left to decide — counted as "waiting" in the
1681
+ // panel (rca issue-file-ledger-orphaned-rows). Only when this page did
1682
+ // not offer it: a path the hub is actively naming still goes through
1683
+ // admission, which reports the refusal.
1684
+ if (!here && !row && kept) {
1685
+ const cls = classifyProjectFile(rel, {
1686
+ canvasGroups: opts.canvasGroups,
1687
+ hasFile: (r) => local.has(r) || existsSync(path.join(designRoot, r)),
1688
+ });
1689
+ if (!isFilePlaneClass(cls)) {
1690
+ ledger.forget(rel);
1691
+ continue;
1692
+ }
1693
+ }
1694
+ // A DELETE IS A TRANSFER TOO. The code-module gate below only sees a
1695
+ // non-null remote, so a tombstone walked past it: an ancestor this peer
1696
+ // recorded (by push, or by agreement) was all `tombstone-agreed` needed
1697
+ // to quarantine a code module on the word of a hub that may not move
1698
+ // one. Refuse it here, reported, with the file and its row untouched.
1699
+ if (row?.deleted && here && !opts.allowCodeModules) {
1700
+ const cls = classifyProjectFile(rel, {
1701
+ canvasGroups: opts.canvasGroups,
1702
+ hasFile: (r) => local.has(r) || existsSync(path.join(designRoot, r)),
1703
+ });
1704
+ if (cls === 'code-module') {
1705
+ out.dropped.push({
1706
+ rel,
1707
+ reason: 'code modules are removed only by an owner-vouched or loopback hub',
1708
+ });
1709
+ continue;
1710
+ }
1711
+ }
1651
1712
  // What the hub holds: this page when it spoke about the path, otherwise
1652
1713
  // what we last learned. `undefined` (never learned) reads as null only
1653
1714
  // after a full read has had the chance to say so.
@@ -1673,6 +1734,20 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
1673
1734
  continue;
1674
1735
  }
1675
1736
  if (cls === 'code-module' && !opts.allowCodeModules) {
1737
+ // AGREEMENT IS NOT A TRANSFER. The gate refuses to move a code
1738
+ // module from an unvouched hub (DDR-054); identical bytes on both
1739
+ // sides move nothing, and reporting them as refused left a
1740
+ // converged file permanently "stuck".
1741
+ if (here && here.hash === remoteHash) {
1742
+ void ledger.adoptAfter(rel, here.hash, () => {}, {
1743
+ ...(row ? { remoteSeq: row.seq } : {}),
1744
+ size: here.size,
1745
+ mtimeMs: here.mtimeMs,
1746
+ state: 'on-hub',
1747
+ });
1748
+ out.synced += 1;
1749
+ continue;
1750
+ }
1676
1751
  drop(
1677
1752
  out,
1678
1753
  rel,
@@ -2082,6 +2157,7 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
2082
2157
  ledger.setState(rel, 'conflict', {
2083
2158
  reason: 'the hub changed this file while the upload was in flight',
2084
2159
  });
2160
+ fullReadOwed = true;
2085
2161
  out.conflicts.push({ rel, copy: null });
2086
2162
  return true;
2087
2163
  }
@@ -53,6 +53,7 @@ import {
53
53
  stampCanvasPath,
54
54
  stampMovedTo,
55
55
  } from './codec.ts';
56
+ import { commentLedgerFor } from './comment-ledger.ts';
56
57
  import {
57
58
  type ConnectionMonitor,
58
59
  createConnectionMonitor,
@@ -520,6 +521,14 @@ export interface SyncRuntime {
520
521
  ): Promise<{ status: 'accepted' | 'rejected'; code?: string; queued?: boolean }> | null;
521
522
  /** True while the linked project is in accepted-revisions mode. */
522
523
  acceptedMode?(): boolean;
524
+ /**
525
+ * Issue #133 — may the comment ledger record `slug`'s current comments as
526
+ * synced? Only when the hub is known to hold them: accepted mode (the doc IS
527
+ * the accepted replica), or a synced provider with nothing unacknowledged. A
528
+ * comment added offline and not yet delivered must never read as "synced",
529
+ * or the next cold start would take it for a remote delete.
530
+ */
531
+ commentsConfirmedOnHub?(slug: string): boolean;
523
532
  /** Tripwire count: local writes that reached an accepted replica. */
524
533
  acceptedWriteViolations?(): number;
525
534
  /** Accepted revisions: the project's logical history (T27). Null when legacy. */
@@ -2722,6 +2731,11 @@ export function createSyncRuntime(
2722
2731
  // in `_history/<slug>/` via history.ts so /design:rollback recovers them.
2723
2732
  journal = loadJournal(ctx.paths.designRoot);
2724
2733
  journal.invalidateIfHubChanged(linkedHub.url);
2734
+ // Issue #133 — same per-hub rule for the comment ledger: "synced before"
2735
+ // against one hub says nothing about another.
2736
+ commentLedgerFor(ctx.paths.designRoot).invalidateIfHubChanged(
2737
+ `${linkedHub.url} ${docNameFor('_')}`
2738
+ );
2725
2739
  const history = createHistory(ctx);
2726
2740
 
2727
2741
  // ---- DDR-102 helpers: auth aggregation, re-probe, settle bookkeeping ----
@@ -3123,6 +3137,7 @@ export function createSyncRuntime(
3123
3137
  projection,
3124
3138
  historyDir: path.join(ctx.paths.historyDir, canvas.slug),
3125
3139
  journal: journal ?? undefined,
3140
+ commentLedger: commentLedgerFor(ctx.paths.designRoot),
3126
3141
  ownAccepted,
3127
3142
  wasAccepted: (content) => acceptedLink.client.holdsValue(content),
3128
3143
  createDoc: (lanes) => acceptedLink.createDoc(canvas.slug, rel, lanes),
@@ -3142,6 +3157,7 @@ export function createSyncRuntime(
3142
3157
  paths: canvasPaths,
3143
3158
  historyDir: path.join(ctx.paths.historyDir, canvas.slug),
3144
3159
  journal: journal ?? undefined,
3160
+ commentLedger: commentLedgerFor(ctx.paths.designRoot),
3145
3161
  snapshot: async (content, reason) => {
3146
3162
  try {
3147
3163
  const snap = await history.writeSnapshot(relBody, content, reason);
@@ -3755,6 +3771,14 @@ export function createSyncRuntime(
3755
3771
  echoGuard,
3756
3772
  adopt: adoptOnce,
3757
3773
  journal: journal ?? undefined,
3774
+ commentLedger: commentLedgerFor(ctx.paths.designRoot),
3775
+ commentsConfirmed: () => {
3776
+ const p = provider as unknown as {
3777
+ synced?: boolean;
3778
+ hasUnsyncedChanges?: boolean;
3779
+ };
3780
+ return p.synced === true && p.hasUnsyncedChanges === false;
3781
+ },
3758
3782
  snapshot: async (content, reason) => {
3759
3783
  try {
3760
3784
  const snap = await history.writeSnapshot(relBody, content, reason);
@@ -4936,6 +4960,19 @@ export function createSyncRuntime(
4936
4960
  },
4937
4961
  proposeFolder,
4938
4962
  acceptedMode: acceptedOn,
4963
+ commentsConfirmedOnHub: (slug) => {
4964
+ // The canvas must be live on the hub right now: a provider that has
4965
+ // synced. In accepted mode its doc is the accepted replica (local writes
4966
+ // are refused), but only once THIS canvas's projection exists — before
4967
+ // that, a comment falls back to the local room and is not on the hub
4968
+ // (security review F1b). In legacy mode nothing may be unacknowledged.
4969
+ const p = providers.get(slug) as unknown as
4970
+ | { synced?: boolean; hasUnsyncedChanges?: boolean }
4971
+ | undefined;
4972
+ if (!p || p.synced !== true) return false;
4973
+ if (acceptedOn()) return projections.has(slug);
4974
+ return p.hasUnsyncedChanges === false;
4975
+ },
4939
4976
  acceptedWriteViolations: () => acceptedWriteViolations,
4940
4977
  acceptedHistory: async (q) => {
4941
4978
  if (!acceptedOn() || !acceptedLink) return null;
@@ -58,6 +58,7 @@ import {
58
58
  unionCommentsById,
59
59
  } from './cold-start.ts';
60
60
  import { applyColdStart, type ColdStartSnapshotReason } from './cold-start-apply.ts';
61
+ import { type CommentLedger, withoutRemotelyDeleted } from './comment-ledger.ts';
61
62
  import { hashBytes } from './echo-guard.ts';
62
63
  import type { SyncJournal } from './journal.ts';
63
64
  import { ORIGINS } from './origins.ts';
@@ -85,6 +86,8 @@ export interface MigrateSeedOptions {
85
86
  historyDir?: string;
86
87
  /** DDR-102 — per-machine journal; gates fast-forward vs conflict. */
87
88
  journal?: SyncJournal;
89
+ /** Issue #133 — comment ids synced from here before (sync/comment-ledger.ts). */
90
+ commentLedger?: CommentLedger;
88
91
  /** DDR-102 — body snapshot writer (history.ts), same contract as the agent's. */
89
92
  snapshot?: (content: string, reason: ColdStartSnapshotReason) => Promise<string | null>;
90
93
  /**
@@ -414,9 +417,13 @@ export async function migrateSeed(opts: MigrateSeedOptions): Promise<MigrateSeed
414
417
  // delete-then-insert codec — same-id entries keep the doc's version, so the
415
418
  // duplication trap stays closed; local-only comments survive.
416
419
  if (localComments) {
417
- const parsed = tryParseJsonArray(localComments);
420
+ const docList = doc.getArray(Y_TYPES.comments).toArray();
421
+ const read = tryParseJsonArray(localComments);
422
+ // Issue #133 — never union back a comment a peer deleted while we were away.
423
+ const parsed = read
424
+ ? withoutRemotelyDeleted(read, docList, opts.commentLedger?.get(slug))
425
+ : null;
418
426
  if (parsed && parsed.length > 0) {
419
- const docList = doc.getArray(Y_TYPES.comments).toArray();
420
427
  const merged = unionCommentsById(docList, parsed);
421
428
  if (merged.length !== docList.length) {
422
429
  doc.transact(() => {
@@ -323,6 +323,27 @@ export function useCollab(): CollabValue | null {
323
323
  // ─────────────────────────────────────────────────────────────────────────────
324
324
  // Hook: foreign awareness peers (the cursor overlay subscribes to this).
325
325
 
326
+ export interface AwarenessChanges {
327
+ added: number[];
328
+ updated: number[];
329
+ removed: number[];
330
+ }
331
+
332
+ /**
333
+ * Could this awareness `change` alter the foreign peers we show? False only
334
+ * when every touched client is the local one (our own cursor publish — #131).
335
+ * Unknown shape → true, the safe direction.
336
+ */
337
+ export function touchesForeignClient(changes: AwarenessChanges | undefined, myId: number): boolean {
338
+ if (!changes) return true;
339
+ const touched = [
340
+ ...(changes.added ?? []),
341
+ ...(changes.updated ?? []),
342
+ ...(changes.removed ?? []),
343
+ ];
344
+ return touched.length === 0 || touched.some((id) => id !== myId);
345
+ }
346
+
326
347
  /**
327
348
  * Returns the current set of foreign peers (excludes the local client). The
328
349
  * returned array is stable-reference between awareness updates — useful for
@@ -351,7 +372,13 @@ export function useForeignAwareness(): ForeignAwareness[] {
351
372
  return out;
352
373
  }
353
374
  setPeers(compute());
354
- const onChange = () => setPeers(compute());
375
+ // Issue #131 — `change` fires for OUR OWN publishes too (the 30 Hz cursor),
376
+ // and each one rebuilt every peer object and re-rendered every consumer.
377
+ // Only a change that touches another client can change what we show.
378
+ const onChange = (changes?: AwarenessChanges) => {
379
+ if (!touchesForeignClient(changes, awareness.clientID)) return;
380
+ setPeers(compute());
381
+ };
355
382
  awareness.on('change', onChange);
356
383
  return () => {
357
384
  awareness.off('change', onChange);
@@ -67,6 +67,10 @@ export interface Selection {
67
67
  worldW?: number;
68
68
  worldH?: number;
69
69
  html?: string;
70
+ /** Comment drops only — the annotation the click landed on, and the click's
71
+ * world point (comment-anchor.ts). */
72
+ annotationId?: string;
73
+ world?: { x: number; y: number };
70
74
  /** Phase 12.2 — authored inline-style values (knob pre-fill) + resolved computed (placeholder hint). */
71
75
  authored?: Record<string, string>;
72
76
  computed?: Record<string, string>;
@@ -1,6 +1,42 @@
1
1
  {
2
2
  "$schema": "./whats-new.schema.json",
3
3
  "entries": [
4
+ {
5
+ "id": "comments-stay-where-you-put-them",
6
+ "version": "1.4.6",
7
+ "date": "2026-09-30",
8
+ "kind": "fix",
9
+ "title": "Comments stay where you put them",
10
+ "summary": "A comment on a sticky, a drawing or empty canvas no longer disappears a few seconds after you save it. A comment on a sticky moves with it, and if the thing it points at is deleted the comment stays, marked as detached, until you remove it.",
11
+ "surface": "design-ui"
12
+ },
13
+ {
14
+ "id": "comments-reach-every-peer",
15
+ "version": "1.4.6",
16
+ "date": "2026-09-30",
17
+ "kind": "fix",
18
+ "title": "Web comments reach the desktop app",
19
+ "summary": "Comments added on the web while your desktop app was closed now show up when you reopen it, and a comment deleted elsewhere no longer comes back.",
20
+ "surface": "design-ui"
21
+ },
22
+ {
23
+ "id": "large-boards-pan-smoothly",
24
+ "version": "1.4.6",
25
+ "date": "2026-09-30",
26
+ "kind": "improvement",
27
+ "title": "Large boards pan smoothly again",
28
+ "summary": "Panning a board with many artboards stays smooth while something is selected or a teammate is on the canvas — on the desktop app it could drop to a frame a second.",
29
+ "surface": "design-ui"
30
+ },
31
+ {
32
+ "id": "sync-no-phantom-waiting-files",
33
+ "version": "1.4.6",
34
+ "date": "2026-09-30",
35
+ "kind": "fix",
36
+ "title": "Sync stops counting files that are done",
37
+ "summary": "The Sync panel no longer shows files as waiting or stuck when there is nothing left to deliver, and Resync now brings down a file the project changed while you were uploading it.",
38
+ "surface": "design-ui"
39
+ },
4
40
  {
5
41
  "id": "team-projects-hold-up",
6
42
  "version": "1.4.5",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1agh/maude",
3
- "version": "1.4.5",
3
+ "version": "1.4.6",
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": {
@@ -58,13 +58,13 @@
58
58
  "test:e2e:desktop:parity:cloud": "pnpm --filter @maude/desktop-e2e e2e:parity:cloud"
59
59
  },
60
60
  "optionalDependencies": {
61
- "@1agh/maude-darwin-arm64": "1.4.5",
62
- "@1agh/maude-darwin-x64": "1.4.5",
63
- "@1agh/maude-linux-arm64": "1.4.5",
64
- "@1agh/maude-linux-arm64-musl": "1.4.5",
65
- "@1agh/maude-linux-x64": "1.4.5",
66
- "@1agh/maude-linux-x64-musl": "1.4.5",
67
- "@1agh/maude-win32-x64": "1.4.5"
61
+ "@1agh/maude-darwin-arm64": "1.4.6",
62
+ "@1agh/maude-darwin-x64": "1.4.6",
63
+ "@1agh/maude-linux-arm64": "1.4.6",
64
+ "@1agh/maude-linux-arm64-musl": "1.4.6",
65
+ "@1agh/maude-linux-x64": "1.4.6",
66
+ "@1agh/maude-linux-x64-musl": "1.4.6",
67
+ "@1agh/maude-win32-x64": "1.4.6"
68
68
  },
69
69
  "files": [
70
70
  "cli",