@1agh/maude 1.3.0 → 1.4.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 (36) hide show
  1. package/apps/studio/annotation-edit-base.ts +36 -0
  2. package/apps/studio/annotations-layer.tsx +13 -2
  3. package/apps/studio/api.ts +51 -2
  4. package/apps/studio/canvas-build.ts +49 -8
  5. package/apps/studio/canvas-edit.ts +77 -0
  6. package/apps/studio/canvas-lib.tsx +25 -10
  7. package/apps/studio/canvas-list-watch.ts +33 -2
  8. package/apps/studio/client/app.jsx +99 -4
  9. package/apps/studio/client/canvas-url.js +39 -1
  10. package/apps/studio/client/panels/TimelinePanel.jsx +75 -62
  11. package/apps/studio/client/photo-knobs.jsx +3 -3
  12. package/apps/studio/client/styles/3-shell-maude.css +1 -0
  13. package/apps/studio/collab/awareness-bridge.ts +65 -14
  14. package/apps/studio/comments-overlay.tsx +86 -2
  15. package/apps/studio/context.ts +93 -1
  16. package/apps/studio/dist/client.bundle.js +619 -619
  17. package/apps/studio/dist/comment-mount.js +2 -2
  18. package/apps/studio/dist/styles.css +1 -1
  19. package/apps/studio/git/accepted-guard.ts +36 -0
  20. package/apps/studio/hmr-broadcast.ts +34 -1
  21. package/apps/studio/http.ts +15 -1
  22. package/apps/studio/managed-projects.ts +22 -0
  23. package/apps/studio/server.ts +3 -0
  24. package/apps/studio/sync/accepted-link.ts +3 -0
  25. package/apps/studio/sync/file-plane.ts +15 -0
  26. package/apps/studio/sync/index.ts +138 -12
  27. package/apps/studio/sync/presentation.ts +30 -0
  28. package/apps/studio/sync/status.ts +5 -1
  29. package/apps/studio/sync/transaction-client.ts +29 -0
  30. package/apps/studio/use-tool-mode.tsx +44 -0
  31. package/apps/studio/whats-new.json +72 -0
  32. package/apps/studio/ws.ts +9 -2
  33. package/package.json +8 -8
  34. package/plugins/design/templates/_shell.html +48 -7
  35. package/plugins/flow/.claude-plugin/config.schema.json +15 -2
  36. package/plugins/flow/README.md +1 -1
@@ -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
  },
@@ -5518,9 +5529,12 @@ export function createHttp(
5518
5529
  if (!body || typeof body.body !== 'string' || !body.body.trim()) {
5519
5530
  return new Response('body.body required', { status: 400 });
5520
5531
  }
5532
+ // In a cell the proxy vouches who is replying; a body cannot claim
5533
+ // somebody else (and the cell's git identity is the machine's).
5534
+ const vouched = isWorkspaceMode() ? req.headers.get('x-maude-user') : null;
5521
5535
  const next = await api.commentsAddReply(id, {
5522
5536
  body: body.body,
5523
- author: typeof body.author === 'string' ? body.author : undefined,
5537
+ author: vouched || (typeof body.author === 'string' ? body.author : undefined),
5524
5538
  });
5525
5539
  if (!next) return new Response('Not found', { status: 404 });
5526
5540
  return Response.json(next, { headers: { 'Cache-Control': 'no-store' } });
@@ -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, '-');
@@ -468,6 +468,9 @@ function startServer(port: number): BunServer {
468
468
  // D3 — whose socket this is, from the proxy's vouched header. Same
469
469
  // handshake-time reasoning as `readOnly` below; `''` on a desktop.
470
470
  session: WORKSPACE ? normalizeSessionKey(req.headers.get(SESSION_HEADER)) : '',
471
+ // Who, for attribution (a comment's author) — the proxy's vouched
472
+ // member; a desktop leaves it to the project's git identity.
473
+ user: WORKSPACE ? (req.headers.get('x-maude-user') ?? '').slice(0, 120) : '',
471
474
  // Cloud Phase 27 — stamp the role onto the socket at the handshake,
472
475
  // the one moment the session is unambiguous. Fails CLOSED in a cell
473
476
  // for the same reason the HTTP gate does: an absent header is an
@@ -41,6 +41,8 @@ export interface AcceptedLinkOptions {
41
41
  client?: TransactionClient;
42
42
  /** T16 — an AI action opened, was held, or ended. */
43
43
  onStage?: (summary: StageSummary | null) => void;
44
+ /** Every bootstrap the project answered (its manifest and its own config). */
45
+ onBootstrap?: (b: Bootstrap) => void;
44
46
  }
45
47
 
46
48
  export type StructuralOutcome = ProposalOutcome & { queued?: boolean };
@@ -96,6 +98,7 @@ export function createAcceptedLink(opts: AcceptedLinkOptions) {
96
98
  }
97
99
  mode = next;
98
100
  manifest = b;
101
+ opts.onBootstrap?.(b);
99
102
  return b;
100
103
  } catch (err) {
101
104
  if (err instanceof TransactionError && err.code === 'absent') mode = 'legacy';
@@ -1600,6 +1600,21 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
1600
1600
  holdOversized(out, ledger, here);
1601
1601
  continue;
1602
1602
  }
1603
+ // THE FILE THAT WAS TOO BIG IS GONE. It never reached the project and
1604
+ // is no longer on this machine, so nothing is held any more — the panel
1605
+ // kept saying "1 file — too big" for a file that no longer existed.
1606
+ const kept = ledger.row(rel);
1607
+ if (
1608
+ !here &&
1609
+ kept?.state === 'refused' &&
1610
+ kept.blockedClass === 'too-large' &&
1611
+ !kept.syncedHash &&
1612
+ !(row && !row.deleted) &&
1613
+ !ledger.remoteOf(rel)
1614
+ ) {
1615
+ ledger.forget(rel);
1616
+ continue;
1617
+ }
1603
1618
  // What the hub holds: this page when it spoke about the path, otherwise
1604
1619
  // what we last learned. `undefined` (never learned) reads as null only
1605
1620
  // after a full read has had the chance to say so.
@@ -36,6 +36,7 @@ import * as Y from 'yjs';
36
36
  import { renewHubCredential } from '../cloud/renew.ts';
37
37
  import { Y_TYPES } from '../collab/persistence.ts';
38
38
  import type { Context, LinkedHub } from '../context.ts';
39
+ import { PROJECT_CONFIG_CACHE_REL, reloadConfig, sanitizeProjectConfig } from '../context.ts';
39
40
  import { createHistory } from '../history.ts';
40
41
  import { SYNTHETIC_FS_DELAY_MS } from '../hmr-broadcast.ts';
41
42
  import { acceptedColdStart } from './accepted-cold-start.ts';
@@ -924,8 +925,39 @@ export function createSyncRuntime(
924
925
  retryMs: opts.transactionRetryMs,
925
926
  onStats: (stats) => statusStore?.updateAccepted?.(stats),
926
927
  onStage: (summary) => statusStore?.updateAiAction?.(summary),
928
+ onBootstrap: (b) => noteProjectConfig(b.projectConfig),
927
929
  })
928
930
  : null;
931
+ /**
932
+ * The project's own group labels and design systems, kept for this copy's
933
+ * config to fill in what it lacks (context.ts `withProjectConfig`) — so a
934
+ * managed desktop copy shows the project's design system. Written only when
935
+ * it changed; the live config reloads and the shells refetch it.
936
+ */
937
+ function noteProjectConfig(raw: unknown): void {
938
+ const clean = sanitizeProjectConfig(raw);
939
+ if (!clean) return;
940
+ const file = path.join(ctx.paths.designRoot, PROJECT_CONFIG_CACHE_REL);
941
+ const text = `${JSON.stringify(clean, null, 2)}\n`;
942
+ try {
943
+ if (readFileSync(file, 'utf8') === text) return;
944
+ } catch {
945
+ /* first answer */
946
+ }
947
+ try {
948
+ mkdirSync(path.dirname(file), { recursive: true });
949
+ writeFileSync(file, text);
950
+ } catch {
951
+ return; // best-effort — the next bootstrap tries again
952
+ }
953
+ if (reloadConfig(ctx)) {
954
+ console.log(
955
+ `[sync] the project's design systems: ${clean.designSystems.map((d) => d.name).join(', ') || 'none'}`
956
+ );
957
+ ctx.bus.emit('config-updated');
958
+ ctx.bus.emit('canvas-list-update', { action: 'config' });
959
+ }
960
+ }
929
961
  const acceptedOn = (): boolean => acceptedLink?.on() === true;
930
962
  /**
931
963
  * T26 — which accepted action carried a given canvas content of ours, so the
@@ -1222,6 +1254,9 @@ export function createSyncRuntime(
1222
1254
  /** The outbound delete-lane subscriptions — see `noteToHub`. */
1223
1255
  let deletedUnsub: (() => void) | null = null;
1224
1256
  let createdUnsub: (() => void) | null = null;
1257
+ /** Folders made outside the app while running — see `proposeLocalFolders`. */
1258
+ let foldersUnsub: (() => void) | null = null;
1259
+ let foldersTimer: ReturnType<typeof setTimeout> | null = null;
1225
1260
  /** Periodic remote-document poll — the hub-side half of discovery. */
1226
1261
  let remotePollTimer: ReturnType<typeof setInterval> | null = null;
1227
1262
  /** The stall watchdog's tick — see `stallCheck` in `start()`. */
@@ -1312,6 +1347,15 @@ export function createSyncRuntime(
1312
1347
  * work), and passes are floored `MIN_PASS_INTERVAL_MS` apart.
1313
1348
  */
1314
1349
  let planePassInFlight = false;
1350
+ /**
1351
+ * News that arrived WHILE a pass ran. The running pass may already have read
1352
+ * the disk and the hub's listing, so it cannot carry a file written — or a
1353
+ * row a poke announced — after that point; absorbing the request silently
1354
+ * parked such a file until the next 20 s tick (plan T17/L03: a new image
1355
+ * took 15–20 s to reach a teammate while an edit took one). One more pass,
1356
+ * floored like any other, answers it.
1357
+ */
1358
+ let planePassAgain = false;
1315
1359
  let lastPlanePassAt = 0;
1316
1360
  let planePassWaiter: Promise<void> | null = null;
1317
1361
  let lastPlaneResult: import('./file-plane.ts').FilePlaneResult | null = null;
@@ -1323,7 +1367,10 @@ export function createSyncRuntime(
1323
1367
  // A pass already running IS this pass: it reads the same disk and the same
1324
1368
  // cursor, so the caller waits for its answer rather than racing it. The
1325
1369
  // poll's `await` therefore still means "a pass has happened".
1326
- if (planePassInFlight) return await (planePassWaiter ?? Promise.resolve());
1370
+ if (planePassInFlight) {
1371
+ planePassAgain = true;
1372
+ return await (planePassWaiter ?? Promise.resolve());
1373
+ }
1327
1374
  const since = Date.now() - lastPlanePassAt;
1328
1375
  // The floor governs POKES. The 20 s poll and the explicit `pullRemoteNow`
1329
1376
  // seam are already bounded by their own callers, and silently deferring a
@@ -1346,6 +1393,10 @@ export function createSyncRuntime(
1346
1393
  } finally {
1347
1394
  planePassInFlight = false;
1348
1395
  planePassWaiter = null;
1396
+ if (planePassAgain) {
1397
+ planePassAgain = false;
1398
+ schedulePlanePass();
1399
+ }
1349
1400
  }
1350
1401
  })();
1351
1402
  planePassWaiter = run;
@@ -1867,9 +1918,33 @@ export function createSyncRuntime(
1867
1918
  return abs;
1868
1919
  };
1869
1920
  const designRelPosix = ctx.paths.designRel.replace(/^\.\//, '').replace(/^\/+|\/+$/g, '');
1921
+ /**
1922
+ * A folder the project removed or moved away is taken off this disk only
1923
+ * once it is empty — but the canvases inside leave AFTER the pull that
1924
+ * learned of the action (their moves and deletions land through their own
1925
+ * documents a beat later). Waiting for the next poke or 20 s tick left the
1926
+ * old folder in a teammate's tree for up to 20 s (plan T31/L02). So a
1927
+ * deferred removal asks for another pass shortly, a bounded number of times.
1928
+ */
1929
+ let dirsRetryTimer: ReturnType<typeof setTimeout> | null = null;
1930
+ let dirsRetries = 0;
1931
+ const DIRS_RETRY_MS = 1_500;
1932
+ const DIRS_RETRY_MAX = 12;
1933
+ function retryDeferredDirs(): void {
1934
+ if (stopped || dirsRetryTimer !== null || dirsRetries >= DIRS_RETRY_MAX) return;
1935
+ dirsRetries += 1;
1936
+ dirsRetryTimer = setTimeout(() => {
1937
+ dirsRetryTimer = null;
1938
+ if (stopped || !acceptedOn()) return;
1939
+ if (documentDiscovery) documentDiscovery.schedule();
1940
+ else applyProjectDirs(acceptedLink?.manifest?.dirs ?? []);
1941
+ }, DIRS_RETRY_MS);
1942
+ dirsRetryTimer.unref?.();
1943
+ }
1870
1944
  function applyProjectDirs(dirs: readonly string[]): void {
1871
1945
  const next = new Set(dirs);
1872
1946
  let changed = false;
1947
+ let deferred = 0;
1873
1948
  for (const rel of next) {
1874
1949
  if (knownProjectDirs.has(rel)) continue;
1875
1950
  const abs = projectDirAbs(rel);
@@ -1903,8 +1978,11 @@ export function createSyncRuntime(
1903
1978
  continue;
1904
1979
  }
1905
1980
  // 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;
1981
+ // then the folder stays and a pass shortly tries again.
1982
+ if (entries.some((e) => e !== '.gitkeep')) {
1983
+ deferred += 1;
1984
+ continue;
1985
+ }
1908
1986
  try {
1909
1987
  for (const e of readdirSync(abs)) rmSync(path.join(abs, e), { force: true });
1910
1988
  rmdirSync(abs);
@@ -1920,6 +1998,8 @@ export function createSyncRuntime(
1920
1998
  changed = true;
1921
1999
  }
1922
2000
  if (changed) saveProjectDirs();
2001
+ if (deferred > 0) retryDeferredDirs();
2002
+ else dirsRetries = 0;
1923
2003
  }
1924
2004
 
1925
2005
  /**
@@ -1975,9 +2055,17 @@ export function createSyncRuntime(
1975
2055
  * project as ONE action at cold start. Only additive: a folder the manifest
1976
2056
  * lists and this disk lacks is never proposed as a deletion from here.
1977
2057
  */
2058
+ let folderProposal: Promise<void> | null = null;
2059
+ let folderProposalAgain = false;
1978
2060
  function proposeLocalFolders(): void {
1979
2061
  if (!acceptedLink) return;
1980
- const listed = new Set(acceptedLink.manifest?.dirs ?? []);
2062
+ // One proposal at a time: a second walk while the first is unanswered
2063
+ // would offer the same folders twice.
2064
+ if (folderProposal) {
2065
+ folderProposalAgain = true;
2066
+ return;
2067
+ }
2068
+ const listed = new Set([...(acceptedLink.manifest?.dirs ?? []), ...knownProjectDirs]);
1981
2069
  const found: string[] = [];
1982
2070
  const walk = (abs: string, rel: string, depth: number) => {
1983
2071
  if (depth > 16 || found.length >= 200) return;
@@ -2006,13 +2094,25 @@ export function createSyncRuntime(
2006
2094
  if (rel) walk(path.join(ctx.paths.designRoot, ...rel.split('/')), rel, 1);
2007
2095
  }
2008
2096
  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
- });
2097
+ folderProposal = acceptedLink
2098
+ .dirsCreate(found)
2099
+ .then((r) => {
2100
+ if (r.status === 'accepted') {
2101
+ for (const d of found) knownProjectDirs.add(d);
2102
+ saveProjectDirs();
2103
+ console.log(`[sync] added ${found.length} local folder(s) to the project.`);
2104
+ }
2105
+ })
2106
+ .catch(() => {
2107
+ /* the next folder event or cold start offers them again */
2108
+ })
2109
+ .finally(() => {
2110
+ folderProposal = null;
2111
+ if (folderProposalAgain) {
2112
+ folderProposalAgain = false;
2113
+ proposeLocalFolders();
2114
+ }
2115
+ });
2016
2116
  }
2017
2117
 
2018
2118
  function proposeFolder(
@@ -3535,7 +3635,15 @@ export function createSyncRuntime(
3535
3635
  // the HMR broadcaster's per-file coalescing collapses the pair
3536
3636
  // into one `canvas-hmr`. The projector's own echo guard drops the
3537
3637
  // resulting file→doc read, so this cannot loop.
3538
- ...(cellPairing ? { onWrote: announceWrite } : {}),
3638
+ onWrote: (abs: string) => {
3639
+ // Tell the canvas layer this write carries the PROJECT's
3640
+ // version — possibly a teammate's change merged under this
3641
+ // person's own — so an open canvas never discards its reload
3642
+ // as the echo of an optimistic edit (hmr-broadcast `remote`).
3643
+ const rel = path.relative(ctx.paths.designRoot, abs).split(path.sep).join('/');
3644
+ if (rel && !rel.startsWith('..')) ctx.bus.emit('sync:projected', rel);
3645
+ if (cellPairing) announceWrite(abs);
3646
+ },
3539
3647
  });
3540
3648
  projection.start();
3541
3649
  projections.set(canvas.slug, projection);
@@ -3818,6 +3926,18 @@ export function createSyncRuntime(
3818
3926
  });
3819
3927
  discoveryRescan = rescan;
3820
3928
  discoveryUnsub = ctx.bus.on('canvas-list-update', () => rescan.schedule());
3929
+ // A folder made OUTSIDE the app while it runs — an agent's `mkdir` plus
3930
+ // `.gitkeep`, Finder, a `git checkout` — joins the project the way a cold
3931
+ // start adds one: additively, never as a deletion. Without this it stayed
3932
+ // on this machine until the next launch.
3933
+ foldersUnsub = ctx.bus.on('fs:any', (rel: unknown) => {
3934
+ if (typeof rel !== 'string' || !rel.endsWith('/.gitkeep') || !acceptedOn()) return;
3935
+ if (foldersTimer) clearTimeout(foldersTimer);
3936
+ foldersTimer = setTimeout(() => {
3937
+ foldersTimer = null;
3938
+ if (acceptedOn()) proposeLocalFolders();
3939
+ }, 300);
3940
+ });
3821
3941
 
3822
3942
  // The OUTBOUND half of the delete lane. `api.ts` emits these two only from
3823
3943
  // its privileged create/delete routes, never from the filesystem watcher —
@@ -4518,6 +4638,12 @@ export function createSyncRuntime(
4518
4638
  attachOne = null;
4519
4639
  discoveryUnsub?.();
4520
4640
  discoveryUnsub = null;
4641
+ foldersUnsub?.();
4642
+ foldersUnsub = null;
4643
+ if (foldersTimer) clearTimeout(foldersTimer);
4644
+ foldersTimer = null;
4645
+ if (dirsRetryTimer) clearTimeout(dirsRetryTimer);
4646
+ dirsRetryTimer = null;
4521
4647
  deletedUnsub?.();
4522
4648
  deletedUnsub = null;
4523
4649
  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) {
@@ -51,6 +51,9 @@ export interface Bootstrap {
51
51
  }[];
52
52
  dirs: string[];
53
53
  you?: { actor: string; readOnly: boolean };
54
+ /** What the project's own config says about itself (UNTRUSTED — a copy
55
+ * re-validates it; see context.ts `withProjectConfig`). */
56
+ projectConfig?: unknown;
54
57
  }
55
58
 
56
59
  interface OutboxAction {
@@ -98,6 +101,14 @@ export interface TransactionStats {
98
101
  ackMs: { last: number | null; p95: number | null; n: number };
99
102
  /** Final rejections this session (conflicts, invalid source, rights…). */
100
103
  rejected: number;
104
+ /**
105
+ * The project answered the last attempt "sign in first": this sign-in was
106
+ * revoked or its person disabled. The change stays in the outbox and is
107
+ * retried with whatever credential `token()` returns next — it is not lost,
108
+ * but it cannot be shared until someone signs in again, which is what a
109
+ * person must be told instead of "check your connection".
110
+ */
111
+ credentialRefused: boolean;
101
112
  }
102
113
 
103
114
  const sha256 = (s: string) => createHash('sha256').update(s, 'utf8').digest('hex');
@@ -139,6 +150,12 @@ export function createTransactionClient(opts: TransactionClientOptions) {
139
150
  const waiting = new Map<string, number>();
140
151
  const acks: number[] = [];
141
152
  let rejectedCount = 0;
153
+ let credentialRefused = false;
154
+ const setCredentialRefused = (next: boolean) => {
155
+ if (credentialRefused === next) return;
156
+ credentialRefused = next;
157
+ opts.onStats?.(stats());
158
+ };
142
159
  function stats(): TransactionStats {
143
160
  const sorted = [...acks].sort((a, b) => a - b);
144
161
  return {
@@ -152,6 +169,7 @@ export function createTransactionClient(opts: TransactionClientOptions) {
152
169
  n: acks.length,
153
170
  },
154
171
  rejected: rejectedCount,
172
+ credentialRefused,
155
173
  };
156
174
  }
157
175
  const setPending = (delta: number) => {
@@ -239,13 +257,24 @@ export function createTransactionClient(opts: TransactionClientOptions) {
239
257
  if (attempt > 0) {
240
258
  const known = await request('GET', `transactions/${entry.transactionId}`);
241
259
  if (known.status === 200 && known.json) {
260
+ setCredentialRefused(false);
242
261
  rmSync(file, { force: true });
243
262
  return known.json as ProposalResult;
244
263
  }
245
264
  }
246
265
  const { status, json } = await request('POST', 'proposals', entry.bytes);
266
+ // Not an answer about the change — about who is asking. Kept and
267
+ // retried (a new sign-in supplies a new token), and said out loud.
268
+ if (status === 401) {
269
+ setCredentialRefused(true);
270
+ throw new TransactionError(
271
+ 'the project no longer accepts this sign-in',
272
+ 'unauthenticated'
273
+ );
274
+ }
247
275
  const result = json as ProposalResult | null;
248
276
  if (result && (result.status === 'accepted' || result.status === 'rejected')) {
277
+ setCredentialRefused(false);
249
278
  if (result.status === 'rejected' && (result.code === 'retryable' || status >= 500)) {
250
279
  throw new TransactionError('hub asked to retry', 'retryable');
251
280
  }