@1agh/maude 0.56.0 → 0.58.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 (58) hide show
  1. package/README.md +5 -1
  2. package/apps/studio/acp/bridge.ts +385 -26
  3. package/apps/studio/acp/index.ts +498 -102
  4. package/apps/studio/acp/running.ts +97 -0
  5. package/apps/studio/acp/transcript.ts +64 -0
  6. package/apps/studio/acp/write-scope.ts +459 -0
  7. package/apps/studio/api.ts +69 -4
  8. package/apps/studio/build.ts +28 -1
  9. package/apps/studio/client/app.jsx +187 -39
  10. package/apps/studio/client/panels/ChatPanel.jsx +19 -1
  11. package/apps/studio/client/panels/CloudBar.jsx +72 -13
  12. package/apps/studio/client/panels/PermissionPrompt.jsx +146 -6
  13. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +145 -6
  14. package/apps/studio/client/panels/acp-runtime.js +96 -5
  15. package/apps/studio/client/panels/chat-markdown.jsx +76 -5
  16. package/apps/studio/client/panels/file-preview.jsx +115 -0
  17. package/apps/studio/client/styles/3-shell-maude.css +56 -2
  18. package/apps/studio/client/styles/6-acp-chat.css +79 -0
  19. package/apps/studio/dist/client.bundle.js +1436 -1436
  20. package/apps/studio/dist/comment-mount.js +2 -2
  21. package/apps/studio/dist/styles.css +1 -1
  22. package/apps/studio/http.ts +59 -2
  23. package/apps/studio/input-router.tsx +39 -1
  24. package/apps/studio/server.ts +11 -0
  25. package/apps/studio/sync/connection-state.ts +116 -6
  26. package/apps/studio/sync/index.ts +50 -1
  27. package/apps/studio/sync/presentation.ts +273 -0
  28. package/apps/studio/sync/remote-docs.ts +191 -0
  29. package/apps/studio/sync/status.ts +4 -1
  30. package/apps/studio/test/acp-activity-endpoint.test.ts +183 -0
  31. package/apps/studio/test/acp-branch-guard.test.ts +123 -0
  32. package/apps/studio/test/acp-bridge-lifetime.test.ts +369 -0
  33. package/apps/studio/test/acp-caps-bridge.test.ts +5 -0
  34. package/apps/studio/test/acp-commands.test.ts +5 -0
  35. package/apps/studio/test/acp-elicitation-bridge.test.ts +5 -0
  36. package/apps/studio/test/acp-permission-prompt.test.ts +175 -1
  37. package/apps/studio/test/acp-permission.test.ts +18 -2
  38. package/apps/studio/test/acp-session-allowed-tools.test.ts +62 -14
  39. package/apps/studio/test/acp-usage-bridge.test.ts +5 -0
  40. package/apps/studio/test/acp-write-gate.test.ts +323 -0
  41. package/apps/studio/test/acp-write-scope.test.ts +533 -0
  42. package/apps/studio/test/bundle-smoke.test.ts +35 -18
  43. package/apps/studio/test/canvas-origin-gate.test.ts +7 -0
  44. package/apps/studio/test/cloud-connect-note.test.ts +135 -0
  45. package/apps/studio/test/cloud-endpoints.test.ts +11 -4
  46. package/apps/studio/test/cloud-shell-surfaces.test.ts +5 -2
  47. package/apps/studio/test/config-version.test.ts +88 -0
  48. package/apps/studio/test/fixtures/mock-acp-agent-slow.mjs +45 -0
  49. package/apps/studio/test/fixtures/mock-acp-agent-write.mjs +118 -0
  50. package/apps/studio/test/input-router.test.ts +56 -0
  51. package/apps/studio/test/sync-connect-honesty.test.ts +114 -0
  52. package/apps/studio/test/sync-connection-state.test.ts +45 -1
  53. package/apps/studio/test/sync-presentation.test.ts +185 -0
  54. package/apps/studio/test/sync-remote-docs.test.ts +171 -0
  55. package/apps/studio/test/whats-new.test.ts +13 -1
  56. package/apps/studio/whats-new.json +27 -0
  57. package/apps/studio/whats-new.ts +34 -10
  58. package/package.json +8 -8
@@ -17,7 +17,14 @@ import {
17
17
  startSignin,
18
18
  } from './acp/login-state.ts';
19
19
  import { probeAcpAvailabilityAuthed } from './acp/probe.ts';
20
- import { deleteChat, listChats, readChatMessages, writeChatMeta } from './acp/transcript.ts';
20
+ import { activitySnapshot, runningChats } from './acp/running.ts';
21
+ import {
22
+ chatTranscriptSeq,
23
+ deleteChat,
24
+ listChats,
25
+ readChatMessages,
26
+ writeChatMeta,
27
+ } from './acp/transcript.ts';
21
28
  import { type Api, ASSET_MAX_BYTES, ASSET_MAX_VIDEO_BYTES } from './api.ts';
22
29
  import { ImportAssetError, importSvg, SVG_MAX_BYTES } from './bin/_import-asset.mjs';
23
30
  import { ImportBrandError, importBrand } from './bin/_import-brand.mjs';
@@ -1319,6 +1326,40 @@ export function createHttp(
1319
1326
  return Response.json({ ok: true }, { headers: { 'Cache-Control': 'no-store' } });
1320
1327
  },
1321
1328
 
1329
+ // Addendum Task 9 — "is a chat mid-turn right now?", asked by
1330
+ // RepoBranchSwitcher.jsx before any of its three `window.location.reload()`
1331
+ // call sites. A `git checkout` moves the worktree under a running agent
1332
+ // (read on `draft-a`, written back after the checkout to `main` — a silent
1333
+ // cross-branch clobber that `_history/`, being per-canvas-slug with no
1334
+ // branch awareness, cannot undo), so this gates a confirm rather than
1335
+ // reloading silently. MAIN-ORIGIN ONLY, read-only, no chat CONTENT — just
1336
+ // how many are busy, so it says nothing the canvas origin could want.
1337
+ '/_api/acp/running': (req: Request) => {
1338
+ if (!isTrustedRequestHost(req))
1339
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1340
+ const running = runningChats();
1341
+ return Response.json(
1342
+ { running: running.length, chats: running },
1343
+ { headers: { 'Cache-Control': 'no-store' } }
1344
+ );
1345
+ },
1346
+
1347
+ // feature-acp-turn-notifications Task 3 — the native shell's poller reads
1348
+ // this to decide whether to fire an OS notification for a NON-visible
1349
+ // project (Decision C). A sibling of `/_api/acp/running` above rather than
1350
+ // an extension of it — that route stays exactly as-is because the reaper's
1351
+ // `has_running_chat` (sidecar.rs) already depends on its `{running,chats}`
1352
+ // shape and has no reason to grow one. MAIN-ORIGIN ONLY, same gate, and —
1353
+ // load-bearing (Decision D) — NEVER a chat title, message text, or any
1354
+ // transcript content: just chat ids (opaque, already exposed by `running`
1355
+ // above) and a three-value state. Anyone adding a field here should ask
1356
+ // what it would look like rendered on a lock screen.
1357
+ '/_api/acp/activity': (req: Request) => {
1358
+ if (!isTrustedRequestHost(req))
1359
+ return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1360
+ return Response.json(activitySnapshot(), { headers: { 'Cache-Control': 'no-store' } });
1361
+ },
1362
+
1322
1363
  // Phase 31 — repo-level chat list + history (for the chat switcher +
1323
1364
  // hydration). MAIN-ORIGIN ONLY. Read-only; ids are sanitized before disk.
1324
1365
  '/_api/acp/chats': () =>
@@ -1365,8 +1406,16 @@ export function createHttp(
1365
1406
  return Response.json(meta, { headers: { 'Cache-Control': 'no-store' } });
1366
1407
  }
1367
1408
  if (!id) return Response.json([], { headers: { 'Cache-Control': 'no-store' } });
1409
+ // Addendum Task 8 — the re-attach seam. Read the sequence marker BEFORE
1410
+ // the messages: a line appended between the two reads then shows up in
1411
+ // neither, and the client's `attach` will replay it. Reading it after
1412
+ // would risk the opposite (a line counted but not returned), which the
1413
+ // client would silently drop as "already hydrated" — a hole in the feed.
1414
+ // Shipped as a header rather than a body field so the response SHAPE
1415
+ // stays the plain message array every existing consumer expects.
1416
+ const seq = chatTranscriptSeq(ctx.paths.designRoot, id);
1368
1417
  return Response.json(readChatMessages(ctx.paths.designRoot, id), {
1369
- headers: { 'Cache-Control': 'no-store' },
1418
+ headers: { 'Cache-Control': 'no-store', 'X-Maude-Chat-Seq': String(seq) },
1370
1419
  });
1371
1420
  },
1372
1421
 
@@ -1458,6 +1507,14 @@ export function createHttp(
1458
1507
  ...ctx.cfg,
1459
1508
  canvasOrigin: ctx.canvasOrigin,
1460
1509
  readOnly: projectReadOnly(req),
1510
+ // WHICH RELEASE THIS IS. `/_config` reaches the browser in cloud mode,
1511
+ // so the bar for adding a field here is "would I publish it" — and a
1512
+ // version is the git tag, which is public. Nothing else rides along.
1513
+ //
1514
+ // Resolved through `resolveMaudeVersion`, the same function the What's
1515
+ // New feed uses: one answer, so the chip in the status bar and the feed
1516
+ // can never name different versions.
1517
+ version: resolveMaudeVersion(),
1461
1518
  // Cloud Phase 27 (DDR-209) — the capability that opens the cookieless
1462
1519
  // canvas origin, minted per session by the proxy. Absent on a desktop,
1463
1520
  // where the canvas origin is loopback and needs none. `canvasUrl()`
@@ -446,6 +446,34 @@ export function yieldsToArtboardDrag(
446
446
  );
447
447
  }
448
448
 
449
+ /**
450
+ * Click-phase counterpart to `yieldsToArtboardDrag` (issue-78). The onClick
451
+ * handler below suppresses the browser's native `click` for every gesture it
452
+ * already routed as `select` on the matching pointerdown, so a just-selected
453
+ * element's own click handler doesn't also fire. But an artboard-chrome
454
+ * control — e.g. the video-timeline badge (`.dc-artboard-video-badge`,
455
+ * DDR-148) rendered as a sibling of `.dc-artboard-body` — never goes through
456
+ * `select`; it's an interactive button that needs its own `onClick` to reach
457
+ * React. Without this exemption a bare click on it while in Move (or a
458
+ * Cmd/Ctrl+click from Browse/an annotation tool) is preventDefault +
459
+ * stopImmediatePropagation'd in the capture phase before React's delegated
460
+ * listener ever runs, so the button silently does nothing. Mirrors
461
+ * `yieldsToArtboardDrag`'s modifier gate: a held Cmd/Ctrl is reserved for
462
+ * deep-select and stays unexempted.
463
+ */
464
+ export function yieldsClickToArtboardChrome(
465
+ wouldRouteKind: RouterAction['kind'] | null,
466
+ target: EventTarget | null,
467
+ modifiers: { metaKey: boolean; ctrlKey: boolean }
468
+ ): boolean {
469
+ return (
470
+ wouldRouteKind === 'select' &&
471
+ !modifiers.metaKey &&
472
+ !modifiers.ctrlKey &&
473
+ isArtboardDragChrome(target)
474
+ );
475
+ }
476
+
449
477
  export function useInputRouter(opts: UseInputRouterOptions): void {
450
478
  const { hostRef, getActiveTool, isSpaceHeld, callbacks, enabled = true, claimableActions } = opts;
451
479
 
@@ -631,8 +659,18 @@ export function useInputRouter(opts: UseInputRouterOptions): void {
631
659
  ? 'context-menu'
632
660
  : null;
633
661
  if (wouldRouteKind && (!claimableActions || claimableActions.has(wouldRouteKind))) {
662
+ // issue-78 — a bare click on an artboard-chrome control (the video-
663
+ // timeline badge) must still reach its own onClick; see
664
+ // yieldsClickToArtboardChrome for why. Mirrors the onPointerDown/
665
+ // onMouseDown pattern above: preventDefault always runs (so a forged
666
+ // chrome-alike element inside untrusted canvas content can't use this
667
+ // exemption to let its own native click default action — e.g. an
668
+ // <a>'s navigation or an <input type="file">'s picker — fire), but
669
+ // stopImmediatePropagation is skipped so the real chrome button's own
670
+ // React onClick still gets the (now-inert-by-default) event.
671
+ const yields = yieldsClickToArtboardChrome(wouldRouteKind, e.target, e);
634
672
  e.preventDefault();
635
- e.stopImmediatePropagation();
673
+ if (!yields) e.stopImmediatePropagation();
636
674
  }
637
675
  };
638
676
 
@@ -735,6 +735,17 @@ async function shutdown() {
735
735
  // previously-unanswered "how does app-quit reach the grandchild" question.
736
736
  cancelSignin();
737
737
  cancelInstall();
738
+ // Addendum Task 8 — bridges now outlive their WebSocket, so socket-close is
739
+ // no longer the reaper it used to be. App quit / dev-server shutdown is the
740
+ // ONE lifetime boundary the detached model deliberately does NOT survive
741
+ // (DDR-166's SIGTERM-first path): extending a session across a project or
742
+ // branch *switch* is the point; extending it across a quit is not, and a
743
+ // surviving `claude` subprocess after quit would be an orphan.
744
+ try {
745
+ acp.stopAll();
746
+ } catch {
747
+ /* best-effort — the process is exiting either way */
748
+ }
738
749
  fsWatch.stop();
739
750
  try {
740
751
  gitWatch.stop();
@@ -47,6 +47,22 @@ export interface SyncStatusSnapshot {
47
47
  /** DDR-102 — slugs currently auth-rejected, capped at 20 (see docs.rejected
48
48
  * for the true count). Treat as text, never HTML. */
49
49
  rejectedSlugs?: string[];
50
+ /**
51
+ * Canvases this run brought DOWN from the project — documents that existed
52
+ * only on the hub and are now real local files.
53
+ *
54
+ * This field was briefly `remoteGap`, "what the project has that this machine
55
+ * does not". That name stopped being true the moment the pull landed: the
56
+ * diff is computed before providers are built and recorded after, so it
57
+ * enumerated exactly the documents that had just arrived. Observed live —
58
+ * `_sync.json` naming two canvases that were sitting on disk.
59
+ *
60
+ * "What arrived this run" is both true and the more useful fact: it is what
61
+ * the Synced state tells the user to go and open. Absent when the hub could
62
+ * not be asked (old hub, offline) or when nothing was pulled. Names are
63
+ * hub-controlled — treat as text, never HTML, and see the cap in `notePulled`.
64
+ */
65
+ pulled?: { names: string[]; count: number };
50
66
  }
51
67
 
52
68
  type TimerHandle = ReturnType<typeof setTimeout>;
@@ -75,6 +91,8 @@ export interface ConnectionMonitor {
75
91
  /** DDR-102 — real sync activity for a slug (reconcile done, hub-pushed flush
76
92
  * applied): bumps `lastSyncAt` to now. */
77
93
  noteSyncActivity(slug: string): void;
94
+ /** Record the canvases this run pulled down from the project (see `pulled`). */
95
+ notePulled(slugs: readonly string[]): void;
78
96
  /** Current snapshot (defensive copy). */
79
97
  snapshot(): SyncStatusSnapshot;
80
98
  /** Tear down timers. */
@@ -100,7 +118,14 @@ export function createConnectionMonitor(opts: ConnectionMonitorOptions = {}): Co
100
118
  // DDR-102 — per-doc states (pending/connected/auth-rejected).
101
119
  const docStates = new Map<string, DocSyncState>();
102
120
 
103
- let state: SyncState = 'online';
121
+ // NOT born connected. The monitor used to start `online`, so from the instant
122
+ // a link was created — before a socket, before a token was accepted, before a
123
+ // byte moved — every surface reading `state` reported success. On a healthy
124
+ // fast connect that was harmless (the truth arrived milliseconds later); on a
125
+ // refused or unreachable one it was the whole bug: the user was shown the
126
+ // intention and read it as the result. `connecting` is the honest seed, and
127
+ // it is the state a link genuinely occupies until a provider says otherwise.
128
+ let state: SyncState = 'connecting';
104
129
  let queuedOps = 0;
105
130
  let lastSyncAt: number | null = null;
106
131
  let offlineSince: number | null = null;
@@ -111,6 +136,25 @@ export function createConnectionMonitor(opts: ConnectionMonitorOptions = {}): Co
111
136
  let flashTimer: TimerHandle | null = null;
112
137
  let stopped = false;
113
138
 
139
+ /** Canvases pulled down from the project this run. */
140
+ let pulled: SyncStatusSnapshot['pulled'];
141
+
142
+ /**
143
+ * Start the clock at construction, not at the first provider event.
144
+ *
145
+ * `enterGrace` is reachable ONLY from `noteProviderStatus`, so a link where
146
+ * no provider ever reports — every provider rejected during boot, a hub that
147
+ * never completes an upgrade — would have sat in `connecting` forever and
148
+ * never reached the offline banner. Seeding `state` honestly is not enough on
149
+ * its own; something has to be counting from the moment the link exists.
150
+ */
151
+ function armInitialGrace(): void {
152
+ graceTimer = setTimer(() => {
153
+ graceTimer = null;
154
+ goOffline();
155
+ }, graceMs);
156
+ }
157
+
114
158
  function snapshot(): SyncStatusSnapshot {
115
159
  const docs = { synced: 0, pending: 0, rejected: 0 };
116
160
  const rejectedSlugs: string[] = [];
@@ -130,9 +174,31 @@ export function createConnectionMonitor(opts: ConnectionMonitorOptions = {}): Co
130
174
  updatedAt: now(),
131
175
  docs,
132
176
  rejectedSlugs,
177
+ ...(pulled ? { pulled } : {}),
133
178
  };
134
179
  }
135
180
 
181
+ /**
182
+ * Record the canvases this run pulled down from the project.
183
+ *
184
+ * `names` is capped like `rejectedSlugs` — this reaches a UI and a JSON file,
185
+ * and an unbounded list of hub-chosen names is a hub-controlled payload size.
186
+ * `count` keeps the true total, so the cap never falsifies the number the
187
+ * user is shown. An empty list clears the field rather than recording a zero:
188
+ * a run that pulled nothing has nothing to say.
189
+ */
190
+ function notePulled(slugs: readonly string[]): void {
191
+ // Every other mutator carries this guard; without it a late call could
192
+ // write `_sync.json` and broadcast for a monitor that has been torn down.
193
+ // Unreachable today (the sole caller checks `stopped` too) — kept so the
194
+ // invariant does not depend on the caller remembering it.
195
+ if (stopped) return;
196
+ pulled = slugs.length
197
+ ? { names: slugs.slice(0, MAX_REJECTED_SLUGS), count: slugs.length }
198
+ : undefined;
199
+ emit();
200
+ }
201
+
136
202
  function emit(): void {
137
203
  onChange?.(snapshot());
138
204
  }
@@ -181,15 +247,20 @@ export function createConnectionMonitor(opts: ConnectionMonitorOptions = {}): Co
181
247
  }
182
248
 
183
249
  function enterGrace(): void {
184
- // Already counting down or already offline — don't restart the clock.
185
- if (state !== 'online') return;
250
+ // Already offline — stay there until a provider reports 'connected'.
251
+ if (state === 'offline' || state === 'offline-long') return;
252
+ // Already counting down — don't restart the clock. The guard used to be
253
+ // `state !== 'online'`; the timer, not the state, is what says whether a
254
+ // countdown is running, and with the `connecting` seed those are no longer
255
+ // the same question.
256
+ if (graceTimer !== null) return;
186
257
  state = 'connecting';
187
- if (graceTimer !== null) clearTimer(graceTimer);
188
258
  graceTimer = setTimer(() => {
189
259
  graceTimer = null;
190
260
  goOffline();
191
261
  }, graceMs);
192
- emit();
262
+ // No emit here — the sole caller emits once for the whole update, so a
263
+ // demotion that does not change `state` still reaches the readers.
193
264
  }
194
265
 
195
266
  function goOffline(): void {
@@ -204,19 +275,56 @@ export function createConnectionMonitor(opts: ConnectionMonitorOptions = {}): Co
204
275
  emit();
205
276
  }
206
277
 
278
+ armInitialGrace();
279
+
207
280
  return {
208
281
  noteProviderStatus(providerId, status) {
209
282
  if (stopped) return;
283
+
284
+ // EMIT ONLY ON A REAL CHANGE.
285
+ //
286
+ // Every emit is a synchronous `_sync.json` write plus a WS broadcast to
287
+ // every open tab. Provider status is driven by the socket lifecycle,
288
+ // which the hub controls, and there is one provider per canvas — 75+ in
289
+ // the reported project. An unconditional emit here turns a flapping (or
290
+ // deliberately hostile) hub into a sustained disk-write and fan-out loop
291
+ // on the dev-server's main thread. The pre-existing code emitted only on
292
+ // a transition; adding the demotion below must not cost that property.
293
+ const prevStatus = providerStatuses.get(providerId);
210
294
  providerStatuses.set(providerId, status);
295
+ let changed = prevStatus !== status;
296
+
297
+ // A document whose socket is gone is not a synced document.
298
+ //
299
+ // `docs.synced` used to only ever RISE — nothing demoted a `connected`
300
+ // doc, so a hub that died left the last count frozen on screen, which is
301
+ // the most convincing form of the lie: it was true a moment ago. The
302
+ // provider id IS the slug (`index.ts` passes `canvas.slug`), so the
303
+ // monitor already knows which document just lost its socket.
304
+ //
305
+ // `auth-rejected` is deliberately NOT demoted. The hub gave an answer;
306
+ // losing the socket afterwards does not turn that answer back into
307
+ // "still trying", and letting it would hide a rotated credential behind
308
+ // a spinner.
309
+ if (status !== 'connected' && docStates.get(providerId) === 'connected') {
310
+ docStates.set(providerId, 'pending');
311
+ changed = true;
312
+ }
313
+
211
314
  const agg = aggregate();
212
315
  if (agg === 'connected') {
316
+ // goOnline() emits for the transition; otherwise only a real change
317
+ // (this provider's status moved, or a document was demoted) is news.
213
318
  if (state !== 'online') goOnline();
319
+ else if (changed) emit();
214
320
  return;
215
321
  }
216
322
  // Aggregate is connecting or disconnected → start (or continue) the
217
323
  // grace countdown. Once offline/offline-long we stay there until a
218
324
  // provider reports 'connected' again.
219
- if (state === 'online') enterGrace();
325
+ const before = state;
326
+ enterGrace();
327
+ if (changed || state !== before) emit();
220
328
  },
221
329
 
222
330
  noteLocalEdit() {
@@ -245,6 +353,8 @@ export function createConnectionMonitor(opts: ConnectionMonitorOptions = {}): Co
245
353
  emit();
246
354
  },
247
355
 
356
+ notePulled,
357
+
248
358
  snapshot,
249
359
 
250
360
  stop() {
@@ -45,6 +45,7 @@ import { loadJournal, type SyncJournal } from './journal.ts';
45
45
  import { isLoopbackHost } from './loopback.ts';
46
46
  import { migrateSeed } from './migrate-seed.ts';
47
47
  import { createDocProjection, type DocProjection } from './projection.ts';
48
+ import { describeRemoteDiff, diffRemoteDocs, fetchRemoteDocs, pullTargets } from './remote-docs.ts';
48
49
  import { createSyncStatusStore, type SyncStatusStore } from './status.ts';
49
50
  import { writeUntrustedMarkers } from './untrusted.ts';
50
51
 
@@ -424,7 +425,46 @@ export function createSyncRuntime(
424
425
  const scan = opts.canvases ? { canvases: opts.canvases, tsxCount: 0 } : await scanCanvases(ctx);
425
426
  // DDR-064 pre-cutover A4 + A6 — two files must never share a document, and
426
427
  // the pinned set must be bounded. See `admitCanvases`.
427
- const canvases = admitCanvases(scan.canvases, useSharedDoc);
428
+ const localCanvases = admitCanvases(scan.canvases, useSharedDoc);
429
+
430
+ // PULL THE REST OF THE PROJECT DOWN.
431
+ //
432
+ // The scan above only sees this machine's disk, and Yjs cannot enumerate —
433
+ // so before this, a document that existed only on the hub was invisible to
434
+ // a peer forever. A desktop carrying 72 of a project's 75 canvases synced
435
+ // 72, reported "72/72 synced", and was accurate about the wrong universe.
436
+ // That is what "Open in Maude does nothing" was.
437
+ //
438
+ // A project you have access to is a project you get, in full and in both
439
+ // directions. Hub-only documents become real local files (flat under the
440
+ // design root — see `pullTargets` for why flat); local-only canvases go up
441
+ // as they always did. Best-effort: an older hub without the listing route,
442
+ // or an unreachable one, syncs exactly as before.
443
+ const remoteDocs = await fetchRemoteDocs(linkedHub.url, resolvedToken);
444
+ const remoteDiff = diffRemoteDocs(
445
+ localCanvases.map((c) => docNameFor(c.slug)),
446
+ remoteDocs
447
+ );
448
+ const pulled = pullTargets(
449
+ remoteDiff.hubOnly,
450
+ ctx.paths.designRoot,
451
+ path.join,
452
+ path.resolve,
453
+ path.sep
454
+ );
455
+ const pullNote = describeRemoteDiff(remoteDiff);
456
+ if (pullNote) console.log(`[sync] ${pullNote}`);
457
+ const canvases = [
458
+ ...localCanvases,
459
+ ...pulled.map((t) => ({
460
+ slug: t.slug,
461
+ html: t.bodyAbs,
462
+ comments: path.join(ctx.paths.commentsDir, `${t.slug}.json`),
463
+ annotations: path.join(ctx.paths.designRoot, `${t.slug}.annotations.svg`),
464
+ meta: t.bodyAbs.replace(/\.tsx$/i, '.meta.json'),
465
+ css: t.bodyAbs.replace(/\.tsx$/i, '.css'),
466
+ })),
467
+ ];
428
468
  // T4.5 (DDR-054 §3 F3) — every syncable canvas can receive hub-pushed
429
469
  // content, so the whole set is untrusted Claude-context. Mark it (writes
430
470
  // `_untrusted/INDEX.json` + a managed `.claudeignore` block; clears both
@@ -981,6 +1021,15 @@ export function createSyncRuntime(
981
1021
  console.log(
982
1022
  `[sync] ${linkedHub.url}: ${parts.join(' · ')} · shared-doc:${useSharedDoc ? 'on' : 'off'}`
983
1023
  );
1024
+ // What this run brought DOWN, recorded for `_sync.json` and the UI.
1025
+ //
1026
+ // This used to record `remoteDiff` under the name `remoteGap` — "what the
1027
+ // project has that this machine does not". By the time it ran, that was
1028
+ // false: the diff is taken BEFORE providers are built and recorded after
1029
+ // the pull, so it named exactly the canvases that had just arrived and
1030
+ // were sitting on disk. `pulled` is the same list under the name that is
1031
+ // true, and it is the fact the user is told to act on.
1032
+ mon.notePulled(pulled.map((t) => t.slug));
984
1033
  });
985
1034
  }
986
1035