@1agh/maude 0.58.1 → 0.58.3

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 (119) hide show
  1. package/apps/studio/.ai/cache/_stats.json +1 -1
  2. package/apps/studio/acp/bridge.ts +165 -15
  3. package/apps/studio/annotations-bindings.ts +83 -4
  4. package/apps/studio/api.ts +6 -1
  5. package/apps/studio/bin/_fetch-asset.mjs +169 -5
  6. package/apps/studio/bin/_import-asset.mjs +72 -0
  7. package/apps/studio/bin/_import-figma.mjs +1121 -0
  8. package/apps/studio/bin/_video-playwright.mjs +86 -3
  9. package/apps/studio/bin/import-figma.sh +38 -0
  10. package/apps/studio/bin/read-annotations.mjs +11 -1
  11. package/apps/studio/bun.lock +16 -22
  12. package/apps/studio/canvas-edit.ts +29 -5
  13. package/apps/studio/client/app.jsx +129 -23
  14. package/apps/studio/client/export-center.jsx +42 -4
  15. package/apps/studio/client/panels/ChatPanel.jsx +25 -2
  16. package/apps/studio/client/panels/CloudBar.jsx +92 -1
  17. package/apps/studio/client/panels/FigmaImportPanel.jsx +264 -0
  18. package/apps/studio/client/panels/GitPanel.jsx +26 -6
  19. package/apps/studio/client/panels/SettingsPanel.jsx +181 -0
  20. package/apps/studio/client/panels/SetupChecklist.jsx +26 -2
  21. package/apps/studio/client/panels/TimelinePanel.jsx +2 -2
  22. package/apps/studio/client/panels/timeline-parse.js +3 -3
  23. package/apps/studio/client/styles/3-shell-maude.css +7 -0
  24. package/apps/studio/client/styles/4-components.css +134 -0
  25. package/apps/studio/client/styles/6-acp-chat.css +12 -0
  26. package/apps/studio/clip-ops.ts +93 -17
  27. package/apps/studio/cloud/endpoints.ts +78 -10
  28. package/apps/studio/cloud/renew.ts +183 -0
  29. package/apps/studio/context.ts +2 -1
  30. package/apps/studio/dist/client.bundle.js +1491 -1491
  31. package/apps/studio/dist/runtime/@remotion_media.js +56 -136
  32. package/apps/studio/dist/runtime/@remotion_player.js +18 -18
  33. package/apps/studio/dist/runtime/@remotion_transitions.js +9 -9
  34. package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -1
  35. package/apps/studio/dist/runtime/remotion.js +12 -12
  36. package/apps/studio/dist/styles.css +1 -1
  37. package/apps/studio/exporters/_browser-bundles.ts +20 -6
  38. package/apps/studio/exporters/_runtime.ts +19 -0
  39. package/apps/studio/exporters/degraded.ts +92 -0
  40. package/apps/studio/exporters/index.ts +5 -0
  41. package/apps/studio/exporters/jobs.ts +19 -0
  42. package/apps/studio/exporters/unsupported-media.ts +170 -0
  43. package/apps/studio/exporters/video-encode-lib.ts +27 -1
  44. package/apps/studio/exporters/video-render-lib.ts +6 -0
  45. package/apps/studio/exporters/video.ts +62 -1
  46. package/apps/studio/figma/assets.test.ts +372 -0
  47. package/apps/studio/figma/assets.ts +398 -0
  48. package/apps/studio/figma/client.test.ts +395 -0
  49. package/apps/studio/figma/client.ts +513 -0
  50. package/apps/studio/figma/comments-to-strokes.test.ts +194 -0
  51. package/apps/studio/figma/comments-to-strokes.ts +173 -0
  52. package/apps/studio/figma/endpoints.ts +200 -0
  53. package/apps/studio/figma/sanitize.test.ts +256 -0
  54. package/apps/studio/figma/sanitize.ts +315 -0
  55. package/apps/studio/figma/style-map.ts +352 -0
  56. package/apps/studio/figma/to-artboard.test.ts +808 -0
  57. package/apps/studio/figma/to-artboard.ts +701 -0
  58. package/apps/studio/figma/to-render.test.ts +180 -0
  59. package/apps/studio/figma/to-render.ts +306 -0
  60. package/apps/studio/figma/to-strokes-roundtrip.test.ts +152 -0
  61. package/apps/studio/figma/to-strokes.test.ts +705 -0
  62. package/apps/studio/figma/to-strokes.ts +749 -0
  63. package/apps/studio/figma/to-tokens.test.ts +321 -0
  64. package/apps/studio/figma/to-tokens.ts +305 -0
  65. package/apps/studio/figma/types.ts +539 -0
  66. package/apps/studio/figma/url.test.ts +167 -0
  67. package/apps/studio/figma/url.ts +160 -0
  68. package/apps/studio/http.ts +129 -0
  69. package/apps/studio/sync/asset-push.ts +124 -0
  70. package/apps/studio/sync/canvas-path.ts +329 -0
  71. package/apps/studio/sync/codec.ts +42 -0
  72. package/apps/studio/sync/connection-state.ts +11 -0
  73. package/apps/studio/sync/hub-link.ts +63 -7
  74. package/apps/studio/sync/hubs-config.ts +31 -3
  75. package/apps/studio/sync/index.ts +755 -32
  76. package/apps/studio/sync/migrate-flat-fallback.ts +121 -0
  77. package/apps/studio/sync/presentation.ts +45 -1
  78. package/apps/studio/sync/projection.ts +11 -1
  79. package/apps/studio/sync/remote-docs.ts +122 -15
  80. package/apps/studio/sync/supervisor.ts +5 -1
  81. package/apps/studio/sync/workspace-signin.ts +7 -3
  82. package/apps/studio/test/acp-bridge-lifetime.test.ts +106 -0
  83. package/apps/studio/test/annotations-bindings.test.ts +150 -12
  84. package/apps/studio/test/canvas-create-api.test.ts +4 -1
  85. package/apps/studio/test/canvas-origin-gate.test.ts +13 -0
  86. package/apps/studio/test/capture-determinism-shape.test.ts +135 -0
  87. package/apps/studio/test/clip-addressing.test.ts +6 -1
  88. package/apps/studio/test/clip-ops.test.ts +5 -1
  89. package/apps/studio/test/cloud-endpoints.test.ts +96 -0
  90. package/apps/studio/test/cloud-renew.test.ts +205 -0
  91. package/apps/studio/test/cloud-shell-surfaces.test.ts +11 -2
  92. package/apps/studio/test/comment-relay-origin-gate.test.ts +117 -0
  93. package/apps/studio/test/comments-fs-rebroadcast.test.ts +155 -0
  94. package/apps/studio/test/exporters/degraded-propagation.test.ts +123 -0
  95. package/apps/studio/test/exporters/unsupported-media.test.ts +123 -0
  96. package/apps/studio/test/fetch-asset-gate.test.ts +189 -0
  97. package/apps/studio/test/figma-provenance.test.ts +108 -0
  98. package/apps/studio/test/figma-routes.test.ts +294 -0
  99. package/apps/studio/test/fixtures/mock-acp-agent-wedged.mjs +37 -0
  100. package/apps/studio/test/git-cloud-posture.test.ts +50 -0
  101. package/apps/studio/test/hub-link.test.ts +11 -0
  102. package/apps/studio/test/import-figma.test.ts +479 -0
  103. package/apps/studio/test/sync-asset-push.test.ts +124 -0
  104. package/apps/studio/test/sync-canvas-path.test.ts +200 -0
  105. package/apps/studio/test/sync-connection-state.test.ts +13 -0
  106. package/apps/studio/test/sync-hubs-config.test.ts +5 -0
  107. package/apps/studio/test/sync-migrate-flat-fallback.test.ts +98 -0
  108. package/apps/studio/test/sync-path-pull.test.ts +465 -0
  109. package/apps/studio/test/sync-presentation.test.ts +77 -0
  110. package/apps/studio/test/sync-remote-docs.test.ts +55 -3
  111. package/apps/studio/test/sync-runtime.test.ts +434 -1
  112. package/apps/studio/test/video-comp.test.ts +23 -1
  113. package/apps/studio/test/workspace-containment.test.ts +1 -0
  114. package/apps/studio/video-comp.tsx +70 -6
  115. package/apps/studio/whats-new.json +36 -0
  116. package/apps/studio/workspace-mode.ts +4 -0
  117. package/cli/commands/design.mjs +8 -0
  118. package/package.json +8 -8
  119. package/plugins/flow/.claude-plugin/config.schema.json +3 -3
@@ -0,0 +1,121 @@
1
+ // One-shot quarantine of the pre-fix-5 flat fallback twins — fix 4 of the
2
+ // 2026-08-10 sync RCA (v0.58.2's `f2a38c46` changed the fallback rule with no
3
+ // migration of what the old rule had already written).
4
+ //
5
+ // Until then the no-path fallback wrote a pulled canvas FLAT at the design root
6
+ // (`ui-welcome.tsx`); the group-aware fallback plus the stamp-race fix (phase
7
+ // sync-2) stopped producing new ones, but whatever the old rule wrote is still
8
+ // on disk NEXT TO the real grouped file — two files, one slug, one document
9
+ // (DDR-064 A4's collision class), and the tree lists both.
10
+ //
11
+ // The rules, in migrate-seed.ts's posture (idempotent, best-effort, never
12
+ // throws into boot):
13
+ //
14
+ // - Only a flat file WITH a grouped twin of the same slug moves — the flat
15
+ // copy is provably the redundant one. A flat file with NO twin is a
16
+ // genuinely-flat canvas somebody authored; moving it would lose work, so
17
+ // it stays (fix 5 + a future re-sync relocates it).
18
+ // - Quarantine, never delete: the loser goes to `_trash/<slug>-flat-<ts>/`
19
+ // (the established runtime quarantine, DDR-115), `.meta.json`/`.css`
20
+ // siblings riding along. The `<slug>.annotations.svg` sidecar does NOT
21
+ // move — annotations are keyed by the flat slug at the design root and
22
+ // serve the surviving grouped twin.
23
+ // - One log line per move, so a tenant can find their bytes.
24
+
25
+ import { type Dirent, existsSync, mkdirSync, readdirSync, renameSync } from 'node:fs';
26
+ import path from 'node:path';
27
+
28
+ import { canvasSlugFromRel } from '../canvas-slug.ts';
29
+
30
+ export interface FlatFallbackMove {
31
+ slug: string;
32
+ /** Design-root-relative flat body that moved. */
33
+ from: string;
34
+ /** Design-root-relative grouped body that made it redundant. */
35
+ keptTwin: string;
36
+ /** Design-root-relative quarantine dir the flat set landed in. */
37
+ trashedTo: string;
38
+ }
39
+
40
+ /**
41
+ * Scan the design root, quarantine every flat body whose slug a grouped file
42
+ * also claims. Returns what moved (empty on a clean tree — the idempotent
43
+ * steady state). Never throws.
44
+ */
45
+ export function migrateFlatFallback(opts: {
46
+ designRoot: string;
47
+ /** Design root relative to the repo root (`.design`) — for slug derivation. */
48
+ designRel: string;
49
+ log?: (line: string) => void;
50
+ }): FlatFallbackMove[] {
51
+ const { designRoot, designRel } = opts;
52
+ const log = opts.log ?? ((line: string) => console.log(line));
53
+ const moves: FlatFallbackMove[] = [];
54
+ try {
55
+ // slug → every body path claiming it, design-root-relative.
56
+ const claims = new Map<string, string[]>();
57
+ for (const rel of walk(designRoot)) {
58
+ if (!/\.tsx$/i.test(rel)) continue;
59
+ const slug = canvasSlugFromRel(rel, designRel);
60
+ const list = claims.get(slug);
61
+ if (list) list.push(rel);
62
+ else claims.set(slug, [rel]);
63
+ }
64
+
65
+ for (const [slug, rels] of claims) {
66
+ const flat = rels.find((rel) => !rel.includes('/'));
67
+ const twin = rels.find((rel) => rel.includes('/'));
68
+ if (!flat || !twin) continue;
69
+
70
+ const trashedTo = `_trash/${slug}-flat-${Date.now()}`;
71
+ const base = flat.replace(/\.tsx$/i, '');
72
+ const movedLanes: string[] = [];
73
+ try {
74
+ mkdirSync(path.join(designRoot, trashedTo), { recursive: true });
75
+ for (const lane of [flat, `${base}.meta.json`, `${base}.css`]) {
76
+ const src = path.join(designRoot, lane);
77
+ if (!existsSync(src)) continue;
78
+ renameSync(src, path.join(designRoot, trashedTo, path.basename(lane)));
79
+ movedLanes.push(lane);
80
+ }
81
+ } catch (err) {
82
+ log(
83
+ `[sync/migrate-flat-fallback] could not quarantine ${flat}: ${(err as Error).message} — leaving it in place`
84
+ );
85
+ continue;
86
+ }
87
+ moves.push({ slug, from: flat, keptTwin: twin, trashedTo });
88
+ log(
89
+ `[sync/migrate-flat-fallback] ${flat} duplicated ${twin} (slug ${slug}) — moved ${movedLanes.join(
90
+ ', '
91
+ )} to ${trashedTo}/`
92
+ );
93
+ }
94
+ } catch {
95
+ /* best-effort — a scan failure must never cost the boot */
96
+ }
97
+ return moves;
98
+ }
99
+
100
+ /** Files under `dir`, relative to it, skipping runtime-state (`_*`) and repo
101
+ * internals — the same exclusions the hub's checkout scan applies. */
102
+ function walk(dir: string, prefix = '', out: string[] = []): string[] {
103
+ let entries: Dirent[];
104
+ try {
105
+ entries = readdirSync(dir, { withFileTypes: true });
106
+ } catch {
107
+ return out;
108
+ }
109
+ for (const entry of entries) {
110
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
111
+ if (entry.isDirectory()) {
112
+ if (entry.name.startsWith('_') || entry.name === '.git' || entry.name === 'node_modules') {
113
+ continue;
114
+ }
115
+ walk(path.join(dir, entry.name), rel, out);
116
+ } else if (entry.isFile()) {
117
+ out.push(rel);
118
+ }
119
+ }
120
+ return out;
121
+ }
@@ -22,10 +22,22 @@ export type SyncPhase =
22
22
  | 'connecting'
23
23
  | 'syncing'
24
24
  | 'synced'
25
+ | 'stalled'
25
26
  | 'refused'
26
27
  | 'offline'
27
28
  | 'nothing-syncable';
28
29
 
30
+ /**
31
+ * How long `connecting…` may honestly stay on screen with ZERO documents
32
+ * synced before it becomes a different sentence. A real handshake settles in
33
+ * seconds; five minutes of nothing is not a connection in progress, it is a
34
+ * link that needs a person (the alligators incident sat in `connecting…` for
35
+ * DAYS while every document was being refused). Matches the auth re-probe
36
+ * cadence so the claim "nothing is moving" has had at least one full retry
37
+ * cycle behind it.
38
+ */
39
+ export const STALL_AFTER_MS = 5 * 60 * 1000;
40
+
29
41
  export interface SyncPresentation {
30
42
  phase: SyncPhase;
31
43
  /** Green dot — reserved for a link that is genuinely carrying edits. */
@@ -119,10 +131,39 @@ function readCounts(
119
131
  */
120
132
  export function syncPresentation(
121
133
  status: SyncStatusLike | null | undefined,
122
- opts: { project?: string | null } = {}
134
+ opts: { project?: string | null; now?: number } = {}
123
135
  ): SyncPresentation | null {
124
136
  if (!status || status.linked === false) return null;
125
137
  const project = safeName(opts.project, 'the workspace');
138
+ const now = typeof opts.now === 'number' ? opts.now : Date.now();
139
+
140
+ // ZERO PROGRESS HAS A DEADLINE. Every `connecting…` below is honest for a
141
+ // handshake and a lie after five minutes of it — the phase reads as "in
142
+ // progress" while nothing is moving and nothing will. Once the link has
143
+ // been trying since `startedAt` with not one document synced, the sentence
144
+ // changes to the one that names the move. Validated like everything else
145
+ // read off disk; an absent/garbage stamp simply never stalls (old payloads).
146
+ const startedAt =
147
+ typeof status.startedAt === 'number' &&
148
+ Number.isFinite(status.startedAt) &&
149
+ status.startedAt > 0
150
+ ? status.startedAt
151
+ : null;
152
+ const stalled = (): SyncPresentation => {
153
+ // `?? now` rather than a cast: every call site is behind `isStalled`, which
154
+ // has already proved `startedAt` is a number — and a future one that isn't
155
+ // reads "1 minute" instead of "NaN minutes".
156
+ const min = Math.max(1, Math.round((now - (startedAt ?? now)) / 60_000));
157
+ return {
158
+ phase: 'stalled',
159
+ online: false,
160
+ label: 'stalled',
161
+ title: `Nothing has synced with ${project} in the ${min} minute${min === 1 ? '' : 's'} since Maude started trying.`,
162
+ next: 'Reconnect the workspace — the sign-in may have expired.',
163
+ names: [],
164
+ };
165
+ };
166
+ const isStalled = startedAt !== null && now - startedAt > STALL_AFTER_MS;
126
167
 
127
168
  if (status.notSyncable) {
128
169
  const tsx = status.tsxCount ?? 0;
@@ -203,6 +244,7 @@ export function syncPresentation(
203
244
  // Pre-DDR-102 payload: `state` is all there is. Report it as the weaker
204
245
  // evidence it is, rather than promoting it to a document-level claim.
205
246
  const online = status.state === 'online' || status.flash === 'synced';
247
+ if (!online && isStalled) return stalled();
206
248
  return {
207
249
  phase: online ? 'synced' : 'connecting',
208
250
  online,
@@ -218,6 +260,7 @@ export function syncPresentation(
218
260
  const total = docs.synced + docs.pending + docs.rejected;
219
261
 
220
262
  if (total === 0) {
263
+ if (isStalled) return stalled();
221
264
  return {
222
265
  phase: 'connecting',
223
266
  online: false,
@@ -232,6 +275,7 @@ export function syncPresentation(
232
275
  // Zero settled yet is a different fact from some settled: one is a
233
276
  // handshake in flight, the other is visible progress.
234
277
  const started = docs.synced > 0;
278
+ if (!started && isStalled) return stalled();
235
279
  return {
236
280
  phase: started ? 'syncing' : 'connecting',
237
281
  online: false,
@@ -144,7 +144,17 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
144
144
  if (stopped) return;
145
145
  // Skip our own file→doc import — the file is already current, re-projecting
146
146
  // would be a redundant write. (Migration seed is on disk already too.)
147
- if (origin === ORIGINS.FILE_IMPORT || origin === ORIGINS.MIGRATION) return;
147
+ // DISK_PROJECTION is the `syncMeta.path` stamp — bookkeeping ABOUT the
148
+ // file, derived from where the file already is, so it can never make the
149
+ // file stale. (Its own doc comment promised this filterability; this is the
150
+ // step that needed it.)
151
+ if (
152
+ origin === ORIGINS.FILE_IMPORT ||
153
+ origin === ORIGINS.MIGRATION ||
154
+ origin === ORIGINS.DISK_PROJECTION
155
+ ) {
156
+ return;
157
+ }
148
158
  scheduleFlush();
149
159
  }
150
160
 
@@ -24,6 +24,8 @@
24
24
  // file outside the design root. That is not policy filtering, it is the
25
25
  // difference between writing your project and writing your filesystem.
26
26
 
27
+ import { type CanvasGroupLike, resolveCanvasBodyRel } from './canvas-path.ts';
28
+
27
29
  /** One document as the hub reports it. */
28
30
  export interface RemoteDoc {
29
31
  name: string;
@@ -147,31 +149,66 @@ export function slugFromDocName(docName: string): string | null {
147
149
  export interface PullTarget {
148
150
  slug: string;
149
151
  docName: string;
150
- /** Absolute path the body will be written to, flat under the design root. */
152
+ /** Absolute path the body will be written to. */
151
153
  bodyAbs: string;
154
+ /** True when the path came from the document's own `syncMeta.path` and was
155
+ * accepted — false when it was absent or refused and the fallback applied. */
156
+ fromPath: boolean;
157
+ }
158
+
159
+ export interface PullTargetOptions {
160
+ /** Design root, relative to the repo root. Rule 7 of the path check. */
161
+ designRel?: string;
162
+ /** Declared canvas groups — the fallback places the body inside one. */
163
+ canvasGroups?: readonly CanvasGroupLike[];
164
+ /** Fresh-link relaxation — see `validateCanvasPath`. */
165
+ allowUndeclaredGroup?: boolean;
166
+ /**
167
+ * The document's own `syncMeta.path`, if it is already known.
168
+ *
169
+ * USUALLY NULL AT THIS POINT, and that is not an oversight. The listing
170
+ * (`GET /api/documents`) carries names and byte counts only — the path lives
171
+ * INSIDE the document, so it cannot be resolved until that document has
172
+ * synced. The runtime therefore calls this to get a provisional target, then
173
+ * `resolvePulledTarget` again per document once its doc is populated (see
174
+ * sync/index.ts). Tests pass it directly, which is the whole reason it is a
175
+ * parameter rather than a fetch.
176
+ */
177
+ pathFor?: (docName: string, slug: string) => string | null | undefined;
178
+ /** See `resolvePulledTarget`. */
179
+ realpath?: (p: string) => string;
180
+ /** Called with (slug, reason) when a PRESENT path is refused. */
181
+ onRefused?: (slug: string, reason: string) => void;
152
182
  }
153
183
 
154
184
  /**
155
185
  * Where a hub-only document lands on disk.
156
186
  *
157
- * FLAT, directly under the design root — the same convention the hub applies in
158
- * the mirror direction (`workspace-files.mjs defaultBodyPath`) and for the same
159
- * reason: a slug is lossy, `ui-card` cannot be un-flattened into `ui/Card.tsx`
160
- * without guessing, and guessing wrong scatters files into directories the user
161
- * never made. A flat file is trivially moved; an invented tree is not. Moving it
162
- * later does not break sync — both paths slug to the same document.
187
+ * It used to be FLAT, directly under the design root, because a slug is lossy —
188
+ * `ui-card` cannot be un-flattened into `ui/Card.tsx` without guessing, and
189
+ * guessing wrong scatters files into directories the user never made. The
190
+ * comment here said a flat file "is trivially moved"; the hub's twin said a
191
+ * desktop peer "will move it on its next sync". Neither was a mechanism, and
192
+ * nothing moved it — and a file at the design root is inside no canvas group,
193
+ * so the tree never listed it and `scanCanvases` never synced it onward.
194
+ *
195
+ * So the path now travels with the document (`syncMeta.path`) and is checked
196
+ * rather than trusted — see `sync/canvas-path.ts` for the eight rules and for
197
+ * why rule 7 is the one that makes it safe. Absent or refused, the fallback
198
+ * still applies, but inside a canvas group.
163
199
  *
164
- * Returns only targets that resolve INSIDE the design root. A document name is
165
- * hub-controlled input, and this is the last point before a create.
200
+ * Returns only targets that resolve INSIDE the design root. A document name and
201
+ * a document's path are both hub-controlled input, and this is the last point
202
+ * before a create — so the containment check stays even for a validated path.
166
203
  */
167
204
  export function pullTargets(
168
205
  hubOnly: readonly RemoteDoc[],
169
206
  designRoot: string,
170
207
  join: (...parts: string[]) => string,
171
208
  resolve: (p: string) => string,
172
- sep: string
209
+ sep: string,
210
+ opts: PullTargetOptions = {}
173
211
  ): PullTarget[] {
174
- const rootResolved = resolve(designRoot);
175
212
  const out: PullTarget[] = [];
176
213
  // One slug, one target. `slugFromDocName` lowercases, so a hub advertising
177
214
  // `Foo`, `foo` and `ws/w/main/foo` yields three targets for ONE file — three
@@ -182,10 +219,80 @@ export function pullTargets(
182
219
  const slug = slugFromDocName(doc.name);
183
220
  if (!slug || seen.has(slug)) continue;
184
221
  seen.add(slug);
185
- const bodyAbs = join(designRoot, `${slug}.tsx`);
186
- const target = resolve(bodyAbs);
187
- if (target !== rootResolved && !target.startsWith(rootResolved + sep)) continue;
188
- out.push({ slug, docName: doc.name, bodyAbs });
222
+ const target = resolvePulledTarget({
223
+ slug,
224
+ path: opts.pathFor?.(doc.name, slug) ?? null,
225
+ designRoot,
226
+ designRel: opts.designRel,
227
+ canvasGroups: opts.canvasGroups,
228
+ allowUndeclaredGroup: opts.allowUndeclaredGroup,
229
+ join,
230
+ resolve,
231
+ sep,
232
+ realpath: opts.realpath,
233
+ onRefused: opts.onRefused ? (reason) => opts.onRefused?.(slug, reason) : undefined,
234
+ });
235
+ if (!target) continue;
236
+ out.push({ slug, docName: doc.name, ...target });
189
237
  }
190
238
  return out;
191
239
  }
240
+
241
+ /**
242
+ * One document's body path — the whole receiver-side decision, in one call.
243
+ *
244
+ * Separate from `pullTargets` because the runtime needs it TWICE: once from the
245
+ * listing (where no path is known yet, so every target is a fallback) and again
246
+ * per document once that document has synced and its `syncMeta.path` is
247
+ * readable. Both hops go through the same function, so "where does this canvas
248
+ * go" cannot have two answers.
249
+ *
250
+ * Returns null when even the containment check refuses — the only case in which
251
+ * a canvas is dropped rather than degraded.
252
+ */
253
+ export function resolvePulledTarget(args: {
254
+ slug: string;
255
+ path: unknown;
256
+ designRoot: string;
257
+ designRel?: string;
258
+ canvasGroups?: readonly CanvasGroupLike[];
259
+ allowUndeclaredGroup?: boolean;
260
+ join: (...parts: string[]) => string;
261
+ resolve: (p: string) => string;
262
+ sep: string;
263
+ /**
264
+ * Resolve symlinks on the deepest EXISTING ancestor of a path.
265
+ *
266
+ * `resolve()` is purely lexical — it never follows a symlink — and the
267
+ * receivers create parent directories with `mkdirSync(recursive: true)`, which
268
+ * happily traverses one that already exists. While the only reachable target
269
+ * was `<designRoot>/<slug>.tsx` that only mattered for a symlinked design root
270
+ * (operator-controlled); now the sender picks the directory, so any symlink
271
+ * committed anywhere under a canvas group is a write-outside primitive.
272
+ * Injected rather than imported because this module is dependency-free.
273
+ */
274
+ realpath?: (p: string) => string;
275
+ onRefused?: (reason: string) => void;
276
+ }): { bodyAbs: string; fromPath: boolean } | null {
277
+ const { rel, fromPath } = resolveCanvasBodyRel({
278
+ path: args.path,
279
+ slug: args.slug,
280
+ designRel: args.designRel,
281
+ canvasGroups: args.canvasGroups,
282
+ allowUndeclaredGroup: args.allowUndeclaredGroup,
283
+ onRefused: args.onRefused,
284
+ });
285
+ const bodyAbs = args.join(args.designRoot, rel);
286
+ // Belt and braces at a create. The validator already refuses everything that
287
+ // could escape lexically; this catches whatever a platform's own `resolve`
288
+ // makes of a string neither of us anticipated.
289
+ const rootResolved = args.resolve(args.designRoot);
290
+ const target = args.resolve(bodyAbs);
291
+ if (target !== rootResolved && !target.startsWith(rootResolved + args.sep)) return null;
292
+ if (args.realpath) {
293
+ const realRoot = args.realpath(rootResolved);
294
+ const realTarget = args.realpath(target);
295
+ if (realTarget !== realRoot && !realTarget.startsWith(realRoot + args.sep)) return null;
296
+ }
297
+ return { bodyAbs, fromPath };
298
+ }
@@ -52,8 +52,11 @@ export interface SyncSupervisor {
52
52
  /**
53
53
  * Adopt a just-authorized link and cycle the runtime. `linkedHub` is passed
54
54
  * BY VALUE from the attach lane on purpose — never re-read from disk.
55
+ * `null` is the detach lane: clear the in-memory link (the config write on
56
+ * disk is invisible to a cfg captured at boot) and cycle back to solo.
57
+ * `undefined` keeps whatever the config already holds.
55
58
  */
56
- restart(linkedHub?: LinkedHub): Promise<SyncStartOutcome>;
59
+ restart(linkedHub?: LinkedHub | null): Promise<SyncStartOutcome>;
57
60
  stop(): Promise<void>;
58
61
  /** The live runtime, or null in solo mode. Test/inspection surface. */
59
62
  current(): SyncRuntime | null;
@@ -166,6 +169,7 @@ export function createSyncSupervisor(
166
169
  }
167
170
  runtime = null;
168
171
  if (linkedHub) adoptLinkedHub(ctx, linkedHub);
172
+ else if (linkedHub === null) delete ctx.cfg.linkedHub;
169
173
  return boot();
170
174
  }),
171
175
  stop: () =>
@@ -67,7 +67,7 @@ export interface SignInInput {
67
67
  /** Injected in tests. */
68
68
  export interface SignInDeps {
69
69
  fetchImpl?: typeof fetch;
70
- save?: (normUrl: string, token: string, role?: string) => void;
70
+ save?: (normUrl: string, token: string, role?: string, expiresAt?: number) => void;
71
71
  }
72
72
 
73
73
  /**
@@ -186,8 +186,12 @@ export async function signInToWorkspace(
186
186
  // viewer is known to be one at BOOT. Learning it from the first refusal
187
187
  // instead means showing somebody an editor and then taking it away.
188
188
  const vouchedRole = typeof body.user?.role === 'string' ? body.user.role : 'member';
189
+ // …and WHEN it dies. The expiry was already read (it is echoed to the UI
190
+ // below) — discarding it from the store is what left the sync runtime
191
+ // with no deadline to renew against.
192
+ const expiresAt = typeof body.expiresAt === 'number' ? body.expiresAt : undefined;
189
193
  try {
190
- save(norm, body.token, vouchedRole);
194
+ save(norm, body.token, vouchedRole, expiresAt);
191
195
  } catch {
192
196
  return fail(500, "Couldn't save the workspace connection on this computer.", 'save-failed');
193
197
  }
@@ -201,7 +205,7 @@ export async function signInToWorkspace(
201
205
  email: typeof body.user?.email === 'string' ? body.user.email : String(input.email).trim(),
202
206
  role: vouchedRole,
203
207
  },
204
- expiresAt: typeof body.expiresAt === 'number' ? body.expiresAt : null,
208
+ expiresAt: expiresAt ?? null,
205
209
  version: probe.version,
206
210
  },
207
211
  };
@@ -28,6 +28,7 @@ import type { WsData } from '../ws.ts';
28
28
 
29
29
  const FIXTURE_SLOW = join(import.meta.dir, 'fixtures', 'mock-acp-agent-slow.mjs');
30
30
  const FIXTURE_PERM = join(import.meta.dir, 'fixtures', 'mock-acp-agent-permission.mjs');
31
+ const FIXTURE_WEDGED = join(import.meta.dir, 'fixtures', 'mock-acp-agent-wedged.mjs');
31
32
  const TEST_ENV_KEYS = ['MAUDE_ACP_ADAPTER_ENTRY', 'MAUDE_ACP_RUNTIME', 'MAUDE_CLAUDE_BIN'];
32
33
 
33
34
  function useMockAgent(fixture: string) {
@@ -333,6 +334,111 @@ describe('bridge lifetime — the attach seq is client-supplied and must be boun
333
334
  }, 30000);
334
335
  });
335
336
 
337
+ describe('cancel() must not hang forever on a non-cooperating agent (issue-82)', () => {
338
+ // The reported bug: "I clicked STOP and nothing happened. Probably stuck."
339
+ // The old cancel() just `await`ed the cooperative ACP `session/cancel`
340
+ // request with no bound — a wedged subprocess (or one that never
341
+ // implemented session/cancel at all, as here) left it, and the pending
342
+ // prompt() call, hanging forever. `bridge.cancel()` below must always
343
+ // return, and it must force the stuck turn to actually end.
344
+ test('cancel() escalates and force-ends the turn when the agent never responds', async () => {
345
+ useMockAgent(FIXTURE_WEDGED);
346
+ const ctx = freshCtx();
347
+ const bridge = new AcpBridge({
348
+ repoRoot: ctx.paths.repoRoot,
349
+ cancelEscalationMs: 200, // real default is 5s; keep the test fast
350
+ onUpdate: () => {},
351
+ });
352
+ try {
353
+ const promptPromise = bridge.prompt('go', 'c1');
354
+ // Let the turn actually reach the wedged conn.prompt() call — the same
355
+ // race a real Stop click lands in — before cancelling.
356
+ await until(() => (bridge.sessionId ? true : undefined));
357
+
358
+ // This is the assertion that matters: without the fix, this call never
359
+ // resolves and the test times out.
360
+ await bridge.cancel();
361
+
362
+ await expect(promptPromise).rejects.toThrow(/did not respond/);
363
+ // The subprocess was actually killed, not just the promise resolved —
364
+ // otherwise a "cancelled" chat could keep silently running/burning
365
+ // tokens in the background.
366
+ expect(bridge.connected).toBe(false);
367
+ } finally {
368
+ await bridge.stop();
369
+ }
370
+ }, 15000);
371
+
372
+ // Ethical-hacker finding on the first cut of this fix: `handlePrompt` has
373
+ // no reentrancy guard, so a duplicate/replayed `prompt` frame for the same
374
+ // chat can start a SECOND, overlapping turn on one bridge. A single shared
375
+ // "pending turn" field would be overwritten by that second turn and then
376
+ // nulled by whichever turn settles FIRST — even an ordinary, uncancelled
377
+ // completion — silently leaving an earlier, genuinely wedged turn
378
+ // permanently unescalatable and reopening this exact bug from a different
379
+ // angle. Pin that a normal turn finishing does not blind cancel() to a
380
+ // still-stuck one that started before it.
381
+ test('an overlapping turn finishing normally does not blind cancel() to an earlier wedged one', async () => {
382
+ useMockAgent(FIXTURE_WEDGED);
383
+ const ctx = freshCtx();
384
+ const bridge = new AcpBridge({
385
+ repoRoot: ctx.paths.repoRoot,
386
+ cancelEscalationMs: 200,
387
+ onUpdate: () => {},
388
+ });
389
+ try {
390
+ // Turn A — the fixture's FIRST session/prompt call, which wedges forever.
391
+ const turnA = bridge.prompt('go', 'c1');
392
+ await until(() => (bridge.sessionId ? true : undefined));
393
+
394
+ // Turn B — a second, overlapping call for the SAME chat while A is
395
+ // still in flight. The fixture resolves every call after the first
396
+ // quickly, so this settles normally with no cancellation involved.
397
+ const turnB = bridge.prompt('go again', 'c1');
398
+ await expect(turnB).resolves.toMatchObject({ stopReason: 'end_turn' });
399
+
400
+ // Turn B's ordinary completion must not have cleared turn A's
401
+ // escalation registration — cancel() must still find and force-end A.
402
+ await bridge.cancel();
403
+ await expect(turnA).rejects.toThrow(/did not respond/);
404
+ expect(bridge.connected).toBe(false);
405
+ } finally {
406
+ await bridge.stop();
407
+ }
408
+ }, 15000);
409
+
410
+ // A second-round ethical-hacker finding on THIS fix: cancel()'s escalation
411
+ // calls this.stop() directly, which does NOT go through index.ts's reap()
412
+ // (the only place that deletes a bridge from the `bridges` map). Without a
413
+ // guard, the SAME now-`stopped` instance would stay live and silently
414
+ // respawn a working-looking subprocess on the next prompt — except its
415
+ // requestPermission/unstable_createElicitation handlers auto-deny forever
416
+ // (the `stopped` check never resets), a silent permission-gate regression
417
+ // with no visible error. `ensureStarted()` must refuse to resurrect it.
418
+ test('after cancel() escalates and self-stops, the bridge refuses to silently respawn', async () => {
419
+ useMockAgent(FIXTURE_WEDGED);
420
+ const ctx = freshCtx();
421
+ const bridge = new AcpBridge({
422
+ repoRoot: ctx.paths.repoRoot,
423
+ cancelEscalationMs: 200,
424
+ onUpdate: () => {},
425
+ });
426
+ try {
427
+ const promptPromise = bridge.prompt('go', 'c1');
428
+ await until(() => (bridge.sessionId ? true : undefined));
429
+ await bridge.cancel(); // escalates → self-stop()
430
+ await expect(promptPromise).rejects.toThrow(/did not respond/);
431
+ expect(bridge.connected).toBe(false);
432
+
433
+ // The assertion that matters: a LOUD, immediate error — never a silent
434
+ // respawn into a permanently permission-denying session.
435
+ await expect(bridge.prompt('again', 'c1')).rejects.toThrow(/stopped and cannot be reused/);
436
+ } finally {
437
+ await bridge.stop();
438
+ }
439
+ }, 15000);
440
+ });
441
+
336
442
  describe('set-mode is attachment-scoped too (ethical-hacker A2)', () => {
337
443
  test('a socket CANNOT flip another chat into bypassPermissions', async () => {
338
444
  // A2 was the one control frame that escaped the attachment-scoping sweep,