agent-canvas 0.1.44 → 0.2.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 (115) hide show
  1. package/README.md +14 -0
  2. package/bin/lib/commands/daemon.ts +3 -6
  3. package/bin/lib/commands/push.ts +1 -1
  4. package/bin/lib/daemon-lifecycle.ts +65 -72
  5. package/bin/lib/helpers.test.ts +1 -0
  6. package/bin/lib/helpers.ts +30 -74
  7. package/daemon/build.ts +37 -16
  8. package/daemon/client/App.tsx +27 -61
  9. package/daemon/client/CompareView.tsx +4 -4
  10. package/daemon/client/FileBrowser.tsx +4 -4
  11. package/daemon/client/FileViewer.tsx +4 -4
  12. package/daemon/client/OverviewView.tsx +2 -2
  13. package/daemon/client/RevisionSelect.tsx +1 -1
  14. package/daemon/client/canvas-runtime.ts +1 -0
  15. package/daemon/client/clientApi.ts +18 -0
  16. package/daemon/client/components/index.ts +3 -16
  17. package/daemon/client/styles.css +4 -457
  18. package/daemon/dist/annotations-runtime.js +1 -0
  19. package/daemon/dist/client.css +1 -1
  20. package/daemon/dist/client.js +76 -76
  21. package/daemon/dist/components.js +59 -59
  22. package/daemon/dist/index.html +1 -0
  23. package/daemon/dist/preact-compat.js +1 -1
  24. package/daemon/dist/runtime.js +1 -1
  25. package/daemon/src/components.ts +13 -0
  26. package/daemon/src/handlers/api.ts +13 -9
  27. package/daemon/src/handlers/files.ts +2 -3
  28. package/daemon/src/handlers/static.ts +1 -1
  29. package/daemon/src/handlers/uploads.ts +2 -3
  30. package/daemon/src/paths.ts +10 -24
  31. package/daemon/src/remote-feedback.test.ts +1 -1
  32. package/daemon/src/remote-feedback.ts +1 -1
  33. package/daemon/src/secrets-api.test.ts +2 -2
  34. package/daemon/src/server.ts +11 -10
  35. package/daemon/src/share-e2e.test.ts +1 -1
  36. package/daemon/src/share.test.ts +1 -1
  37. package/daemon/src/share.ts +1 -1
  38. package/daemon/tsconfig.json +1 -3
  39. package/package.json +11 -4
  40. package/.github/dark.png +0 -0
  41. package/.github/light.png +0 -0
  42. package/.github/workflows/ci.yml +0 -48
  43. package/.github/workflows/deploy-worker-prod.yml +0 -19
  44. package/.github/workflows/deploy-worker-stage.yml +0 -21
  45. package/.github/workflows/deploy-worker.yml +0 -72
  46. package/.github/workflows/release.yml +0 -36
  47. package/CLAUDE.md +0 -86
  48. package/bun.lock +0 -377
  49. package/daemon/client/AnnotationEditor.tsx +0 -315
  50. package/daemon/client/AnnotationProvider.tsx +0 -195
  51. package/daemon/client/AnnotationSidebar.tsx +0 -627
  52. package/daemon/client/FileIcon.tsx +0 -76
  53. package/daemon/client/PlanRenderer.tsx +0 -614
  54. package/daemon/client/Popover.tsx +0 -350
  55. package/daemon/client/RenderErrorContext.ts +0 -11
  56. package/daemon/client/ResponsePreview.tsx +0 -186
  57. package/daemon/client/SessionContext.tsx +0 -3
  58. package/daemon/client/annotationContext.ts +0 -114
  59. package/daemon/client/annotationDom.ts +0 -104
  60. package/daemon/client/annotationDraft.test.ts +0 -188
  61. package/daemon/client/annotationDraft.ts +0 -130
  62. package/daemon/client/compat/preact-all.ts +0 -4
  63. package/daemon/client/components/Callout.tsx +0 -26
  64. package/daemon/client/components/Checklist.tsx +0 -37
  65. package/daemon/client/components/Choice.tsx +0 -176
  66. package/daemon/client/components/CodeBlock.tsx +0 -35
  67. package/daemon/client/components/Diff.tsx +0 -65
  68. package/daemon/client/components/FilePreview.tsx +0 -88
  69. package/daemon/client/components/Image.tsx +0 -45
  70. package/daemon/client/components/Markdown.tsx +0 -44
  71. package/daemon/client/components/Mermaid.tsx +0 -565
  72. package/daemon/client/components/Note.tsx +0 -14
  73. package/daemon/client/components/Priority.tsx +0 -19
  74. package/daemon/client/components/ResponseNote.tsx +0 -19
  75. package/daemon/client/components/Section.tsx +0 -33
  76. package/daemon/client/components/Table.tsx +0 -35
  77. package/daemon/client/components/Task.tsx +0 -76
  78. package/daemon/client/components/UserInput.tsx +0 -174
  79. package/daemon/client/components/renderMarkdownChildren.tsx +0 -97
  80. package/daemon/client/generateMarkdown.ts +0 -273
  81. package/daemon/client/highlightRange.ts +0 -224
  82. package/daemon/client/popoverPosition.ts +0 -31
  83. package/daemon/client/runtime.ts +0 -123
  84. package/daemon/client/theme.css +0 -66
  85. package/daemon/client/useTextAnnotation.tsx +0 -188
  86. package/daemon/client/utils.ts +0 -13
  87. package/daemon/src/compiler.test.ts +0 -57
  88. package/daemon/src/compiler.ts +0 -360
  89. package/daemon/src/handlers/utils.ts +0 -14
  90. package/daemon/src/router.ts +0 -16
  91. package/daemon/src/session-secrets.test.ts +0 -83
  92. package/daemon/src/session.ts +0 -612
  93. package/daemon/src/watcher.ts +0 -71
  94. package/daemon/src/websocket.test.ts +0 -99
  95. package/daemon/src/websocket.ts +0 -211
  96. package/tsconfig.json +0 -14
  97. package/workers/canvas-share/README.md +0 -159
  98. package/workers/canvas-share/oblaka.ts +0 -56
  99. package/workers/canvas-share/package.json +0 -25
  100. package/workers/canvas-share/public/assets/.gitkeep +0 -0
  101. package/workers/canvas-share/public/assets/client.css +0 -2
  102. package/workers/canvas-share/public/assets/client.js +0 -91
  103. package/workers/canvas-share/public/assets/components.js +0 -65
  104. package/workers/canvas-share/public/assets/jsx-runtime.js +0 -3
  105. package/workers/canvas-share/public/assets/preact-compat.js +0 -1
  106. package/workers/canvas-share/public/assets/runtime.js +0 -1
  107. package/workers/canvas-share/public/index.html +0 -385
  108. package/workers/canvas-share/public/screenshot-dark.png +0 -0
  109. package/workers/canvas-share/public/screenshot-light.png +0 -0
  110. package/workers/canvas-share/src/index.test.ts +0 -209
  111. package/workers/canvas-share/src/index.ts +0 -518
  112. package/workers/canvas-share/src/types.ts +0 -119
  113. package/workers/canvas-share/src/util.ts +0 -156
  114. package/workers/canvas-share/src/validation.ts +0 -160
  115. package/workers/canvas-share/tsconfig.json +0 -16
@@ -1,612 +0,0 @@
1
- import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync, rmSync, renameSync, copyFileSync } from "fs";
2
- import { join } from "path";
3
- import { SESSIONS_DIR } from "./paths";
4
-
5
- export interface DiffStats {
6
- added: number;
7
- removed: number;
8
- }
9
-
10
- export interface CanvasFileInfo {
11
- filename: string;
12
- diffStats?: DiffStats;
13
- }
14
-
15
- export interface RevisionInfo {
16
- revision: number;
17
- label?: string;
18
- canvasFiles: CanvasFileInfo[];
19
- createdAt: string;
20
- hasFeedback: boolean;
21
- feedbackConsumed: boolean;
22
- response?: string;
23
- }
24
-
25
- /**
26
- * A single piece of feedback received from a remote (shared) reviewer.
27
- * Structured so it can be merged into the local annotation system alongside
28
- * the author's own annotations. `annotations` is the structured list of
29
- * highlighted-text comments; `generalNote` is the free-form markdown.
30
- */
31
- export interface RemoteFeedbackEntry {
32
- /** Stable unique id assigned by the worker; used to dedupe on re-poll */
33
- id: string;
34
- shareId: string;
35
- revision: number;
36
- /** ISO timestamp when the reviewer submitted */
37
- submittedAt: string;
38
- author: { id: string; name: string };
39
- annotations: RemoteAnnotation[];
40
- generalNote?: string;
41
- }
42
-
43
- /**
44
- * Mirror of the client-side Annotation shape, minus fields that only make
45
- * sense locally. Kept intentionally narrow so the wire format is stable.
46
- */
47
- export interface RemoteAnnotation {
48
- id: string;
49
- snippet: string;
50
- note: string;
51
- createdAt: string;
52
- filePath?: string;
53
- canvasFile?: string;
54
- context?: {
55
- before: string;
56
- after: string;
57
- hierarchy: string[];
58
- lineStart?: number;
59
- lineEnd?: number;
60
- };
61
- attachments?: { url: string; mime?: string }[];
62
- }
63
-
64
- /**
65
- * A share is a snapshot of a specific revision that has been pushed to a
66
- * remote endpoint (CF Worker) for external review. The `shareId` is the
67
- * opaque capability token returned by the worker; the `url` is the full
68
- * public URL the user shared with reviewers.
69
- */
70
- export interface ShareEntry {
71
- shareId: string;
72
- url: string;
73
- revision: number;
74
- createdAt: string;
75
- /** Bearer token returned by the worker — proves ownership for revoke. */
76
- ownerToken?: string;
77
- /** ISO timestamp when the share will expire on the worker side. */
78
- expiresAt?: string;
79
- /** ISO timestamp of the most recent remote feedback we've seen */
80
- lastFeedbackAt?: string;
81
- /** Base64url-encoded AES-GCM key for end-to-end encrypted shares. The
82
- * same key is also embedded in `url` as a `#k=...` fragment so reviewers
83
- * can decrypt; the daemon keeps a copy here so it can decrypt feedback
84
- * it polls back from the worker. Absent on legacy unencrypted shares. */
85
- encryptionKey?: string;
86
- }
87
-
88
- export interface SessionData {
89
- id: string;
90
- projectRoot: string;
91
- canvasFiles: string[];
92
- currentRevision: number;
93
- revisions: RevisionInfo[];
94
- createdAt: string;
95
- updatedAt: string;
96
- shares?: ShareEntry[];
97
- }
98
-
99
- export type SecretResolution =
100
- | { ok: true; values: Map<string, string> }
101
- | { ok: false; missing: string[] };
102
-
103
- interface SessionMeta {
104
- projectRoot: string;
105
- createdAt: string;
106
- updatedAt: string;
107
- currentRevision: number;
108
- revisions: RevisionInfo[];
109
- shares?: ShareEntry[];
110
- }
111
-
112
- // Legacy format (flat files, pre-revision)
113
- interface LegacyMeta {
114
- projectRoot: string;
115
- createdAt: string;
116
- updatedAt: string;
117
- version: number;
118
- }
119
-
120
- // Previous format (single sourceFile per revision)
121
- interface LegacyRevisionInfo {
122
- revision: number;
123
- label?: string;
124
- sourceFile?: string;
125
- createdAt: string;
126
- hasFeedback: boolean;
127
- feedbackConsumed: boolean;
128
- response?: string;
129
- diffStats?: DiffStats;
130
- }
131
-
132
- function computeLineDiffStats(oldText: string, newText: string): DiffStats {
133
- const oldLines = oldText.split("\n");
134
- const newLines = newText.split("\n");
135
- const m = oldLines.length, n = newLines.length;
136
-
137
- // LCS length via two-row DP (O(n) space)
138
- let prev = new Uint32Array(n + 1);
139
- let curr = new Uint32Array(n + 1);
140
- for (let i = 1; i <= m; i++) {
141
- for (let j = 1; j <= n; j++) {
142
- curr[j] = oldLines[i - 1] === newLines[j - 1]
143
- ? prev[j - 1] + 1
144
- : Math.max(prev[j], curr[j - 1]);
145
- }
146
- [prev, curr] = [curr, prev];
147
- curr.fill(0);
148
- }
149
- const lcsLen = prev[n];
150
- return { added: n - lcsLen, removed: m - lcsLen };
151
- }
152
-
153
- export class SessionManager {
154
- private sessions = new Map<string, SessionData>();
155
- private sessionSecrets = new Map<string, { revision: number; values: Map<string, string> }>();
156
- private readonly sessionsDir: string;
157
-
158
- /**
159
- * @param sessionsDir Override the on-disk sessions root. Defaults to
160
- * the global SESSIONS_DIR (`~/.claude/agent-canvas/sessions`).
161
- * Tests pass an isolated temp directory so they don't interfere with
162
- * the user's real sessions.
163
- */
164
- constructor(sessionsDir?: string) {
165
- this.sessionsDir = sessionsDir ?? SESSIONS_DIR;
166
- mkdirSync(this.sessionsDir, { recursive: true });
167
- this.loadFromDisk();
168
- }
169
-
170
- private sessionDir(id: string): string {
171
- return join(this.sessionsDir, id);
172
- }
173
-
174
- private revisionDir(id: string, rev: number): string {
175
- return join(this.sessionDir(id), "revisions", String(rev));
176
- }
177
-
178
- /** Compute file order from revision history: first-appearance order, alpha for ties */
179
- private deriveFileOrder(revisions: RevisionInfo[], currentFiles: string[]): string[] {
180
- const seen = new Set<string>();
181
- const ordered: string[] = [];
182
- for (const rev of revisions) {
183
- for (const cf of rev.canvasFiles) {
184
- if (!seen.has(cf.filename)) {
185
- seen.add(cf.filename);
186
- ordered.push(cf.filename);
187
- }
188
- }
189
- }
190
- // Keep only files that still exist, then append any unknown ones
191
- const current = new Set(currentFiles);
192
- const result = ordered.filter(f => current.has(f));
193
- const remaining = currentFiles.filter(f => !seen.has(f)).sort();
194
- return [...result, ...remaining];
195
- }
196
-
197
- /** List *.jsx filenames in a revision directory */
198
- getRevisionCanvasFiles(id: string, rev: number): string[] {
199
- const dir = this.revisionDir(id, rev);
200
- if (!existsSync(dir)) return [];
201
- return readdirSync(dir).filter(f => f.endsWith(".jsx")).sort();
202
- }
203
-
204
- private loadFromDisk() {
205
- if (!existsSync(this.sessionsDir)) return;
206
- for (const name of readdirSync(this.sessionsDir, { withFileTypes: true })) {
207
- if (!name.isDirectory()) continue;
208
- const dir = join(this.sessionsDir, name.name);
209
- const metaPath = join(dir, "meta.json");
210
- if (!existsSync(metaPath)) continue;
211
-
212
- try {
213
- const raw = JSON.parse(readFileSync(metaPath, "utf-8"));
214
-
215
- // Detect and migrate legacy flat-file format
216
- if ("version" in raw && !("currentRevision" in raw)) {
217
- this.migrateLegacy(name.name, raw as LegacyMeta);
218
- const migrated: SessionMeta = JSON.parse(readFileSync(metaPath, "utf-8"));
219
- const canvasFiles = this.getRevisionCanvasFiles(name.name, migrated.currentRevision);
220
- if (canvasFiles.length === 0) continue;
221
- this.sessions.set(name.name, {
222
- id: name.name,
223
- projectRoot: migrated.projectRoot,
224
- canvasFiles,
225
- currentRevision: migrated.currentRevision,
226
- revisions: migrated.revisions,
227
- createdAt: migrated.createdAt,
228
- updatedAt: migrated.updatedAt,
229
- });
230
- continue;
231
- }
232
-
233
- let meta = raw as SessionMeta;
234
-
235
- // Migrate single-sourceFile revision format to canvasFiles
236
- if (meta.revisions.length > 0 && !("canvasFiles" in meta.revisions[0])) {
237
- meta = {
238
- ...meta,
239
- revisions: (meta.revisions as unknown as LegacyRevisionInfo[]).map(r => ({
240
- revision: r.revision,
241
- label: r.label,
242
- canvasFiles: [{ filename: r.sourceFile || "plan.jsx", diffStats: r.diffStats }],
243
- createdAt: r.createdAt,
244
- hasFeedback: r.hasFeedback,
245
- feedbackConsumed: r.feedbackConsumed,
246
- response: r.response,
247
- })),
248
- };
249
- writeFileSync(metaPath, JSON.stringify(meta, null, 2));
250
- }
251
-
252
- const diskFiles = this.getRevisionCanvasFiles(name.name, meta.currentRevision);
253
- if (diskFiles.length === 0) continue;
254
- // Derive file order from revision history (first-appearance order)
255
- const canvasFiles = this.deriveFileOrder(meta.revisions, diskFiles);
256
-
257
- this.sessions.set(name.name, {
258
- id: name.name,
259
- projectRoot: meta.projectRoot,
260
- canvasFiles,
261
- currentRevision: meta.currentRevision,
262
- revisions: meta.revisions,
263
- createdAt: meta.createdAt,
264
- updatedAt: meta.updatedAt,
265
- ...(meta.shares ? { shares: meta.shares } : {}),
266
- });
267
- } catch {}
268
- }
269
- }
270
-
271
- private migrateLegacy(id: string, legacy: LegacyMeta) {
272
- const dir = this.sessionDir(id);
273
- const flatJsx = join(dir, "plan.jsx");
274
- const flatCompiled = join(dir, "plan.compiled.js");
275
- const historyDir = join(dir, "history");
276
-
277
- const revisions: RevisionInfo[] = [];
278
-
279
- // Migrate history files
280
- if (existsSync(historyDir)) {
281
- const files = readdirSync(historyDir).filter((f) => f.endsWith(".jsx")).sort();
282
- for (const file of files) {
283
- const num = parseInt(file.replace(".jsx", ""), 10);
284
- if (isNaN(num)) continue;
285
- const revDir = this.revisionDir(id, num);
286
- mkdirSync(revDir, { recursive: true });
287
- renameSync(join(historyDir, file), join(revDir, "plan.jsx"));
288
- revisions.push({
289
- revision: num,
290
- canvasFiles: [{ filename: "plan.jsx" }],
291
- createdAt: legacy.createdAt,
292
- hasFeedback: false,
293
- feedbackConsumed: false,
294
- });
295
- }
296
- rmSync(historyDir, { recursive: true, force: true });
297
- }
298
-
299
- // Migrate current version
300
- const currentRev = legacy.version;
301
- const revDir = this.revisionDir(id, currentRev);
302
- mkdirSync(revDir, { recursive: true });
303
- if (existsSync(flatJsx)) {
304
- renameSync(flatJsx, join(revDir, "plan.jsx"));
305
- }
306
- if (existsSync(flatCompiled)) {
307
- renameSync(flatCompiled, join(revDir, "plan.compiled.js"));
308
- }
309
- revisions.push({
310
- revision: currentRev,
311
- canvasFiles: [{ filename: "plan.jsx" }],
312
- createdAt: legacy.updatedAt,
313
- hasFeedback: false,
314
- feedbackConsumed: false,
315
- });
316
-
317
- const meta: SessionMeta = {
318
- projectRoot: legacy.projectRoot,
319
- createdAt: legacy.createdAt,
320
- updatedAt: legacy.updatedAt,
321
- currentRevision: currentRev,
322
- revisions,
323
- };
324
- writeFileSync(join(dir, "meta.json"), JSON.stringify(meta, null, 2));
325
- }
326
-
327
- readRevisionJsx(id: string, rev: number, filename: string): string | null {
328
- try {
329
- return readFileSync(join(this.revisionDir(id, rev), filename), "utf-8");
330
- } catch {
331
- return null;
332
- }
333
- }
334
-
335
- /**
336
- * Create or update a session with a set of canvas files.
337
- * @param canvasFiles Map of filename -> JSX content
338
- */
339
- upsert(id: string, canvasFiles: Map<string, string>, projectRoot: string, label?: string, response?: string): SessionData {
340
- const existing = this.sessions.get(id);
341
- const now = new Date().toISOString();
342
- // Stable session IDs must never recycle revision numbers used as browser draft keys.
343
- const revision = existing ? existing.currentRevision + 1 : 1;
344
-
345
- // Compute per-file diffStats against the previous revision
346
- const prevRev = existing ? existing.currentRevision : 0;
347
- const canvasFileInfos: CanvasFileInfo[] = [];
348
- for (const [filename, jsx] of canvasFiles) {
349
- let diffStats: DiffStats | undefined;
350
- if (prevRev > 0) {
351
- const prevJsx = this.readRevisionJsx(id, prevRev, filename);
352
- if (prevJsx) {
353
- diffStats = computeLineDiffStats(prevJsx, jsx);
354
- }
355
- }
356
- canvasFileInfos.push({
357
- filename,
358
- ...(diffStats ? { diffStats } : {}),
359
- });
360
- }
361
-
362
- const revInfo: RevisionInfo = {
363
- revision,
364
- canvasFiles: canvasFileInfos,
365
- createdAt: now,
366
- hasFeedback: false,
367
- feedbackConsumed: false,
368
- ...(label ? { label } : {}),
369
- ...(response ? { response } : {}),
370
- };
371
- const revisions = existing ? [...existing.revisions, revInfo] : [revInfo];
372
- // Order: files from previous revision first (preserving their order), then new files alphabetically
373
- const prevOrder = existing?.canvasFiles ?? [];
374
- const currentNames = new Set(canvasFiles.keys());
375
- const filenames = [
376
- ...prevOrder.filter(f => currentNames.has(f)),
377
- ...[...currentNames].filter(f => !prevOrder.includes(f)).sort(),
378
- ];
379
-
380
- const session: SessionData = {
381
- id,
382
- projectRoot,
383
- canvasFiles: filenames,
384
- currentRevision: revision,
385
- revisions,
386
- createdAt: existing?.createdAt ?? now,
387
- updatedAt: now,
388
- };
389
-
390
- // Secrets belong to one revision and must never carry into a new runbook.
391
- this.sessionSecrets.delete(id);
392
- this.sessions.set(id, session);
393
- this.persistToDisk(session, canvasFiles);
394
- return session;
395
- }
396
-
397
- private persistMeta(session: SessionData) {
398
- const meta: SessionMeta = {
399
- projectRoot: session.projectRoot,
400
- createdAt: session.createdAt,
401
- updatedAt: session.updatedAt,
402
- currentRevision: session.currentRevision,
403
- revisions: session.revisions,
404
- ...(session.shares?.length ? { shares: session.shares } : {}),
405
- };
406
- writeFileSync(join(this.sessionDir(session.id), "meta.json"), JSON.stringify(meta, null, 2));
407
- }
408
-
409
- /** Record a new share for a session revision. */
410
- addShare(id: string, entry: ShareEntry): void {
411
- const session = this.sessions.get(id);
412
- if (!session) return;
413
- session.shares = [...(session.shares ?? []), entry];
414
- session.updatedAt = new Date().toISOString();
415
- this.persistMeta(session);
416
- }
417
-
418
- getShares(id: string): ShareEntry[] {
419
- return this.sessions.get(id)?.shares ?? [];
420
- }
421
-
422
- removeShare(id: string, shareId: string): void {
423
- const session = this.sessions.get(id);
424
- if (!session?.shares) return;
425
- session.shares = session.shares.filter((s) => s.shareId !== shareId);
426
- session.updatedAt = new Date().toISOString();
427
- this.persistMeta(session);
428
- }
429
-
430
- updateShareLastFeedback(id: string, shareId: string, at: string): void {
431
- const session = this.sessions.get(id);
432
- if (!session?.shares) return;
433
- const entry = session.shares.find((s) => s.shareId === shareId);
434
- if (!entry) return;
435
- entry.lastFeedbackAt = at;
436
- this.persistMeta(session);
437
- }
438
-
439
- /** List all sessions that have at least one share. Used by the remote feedback poller. */
440
- listSessionsWithShares(): SessionData[] {
441
- return Array.from(this.sessions.values()).filter((s) => (s.shares?.length ?? 0) > 0);
442
- }
443
-
444
- private persistToDisk(session: SessionData, canvasFiles: Map<string, string>) {
445
- const revDir = this.revisionDir(session.id, session.currentRevision);
446
- mkdirSync(revDir, { recursive: true });
447
- for (const [filename, jsx] of canvasFiles) {
448
- writeFileSync(join(revDir, filename), jsx);
449
- }
450
- this.persistMeta(session);
451
- }
452
-
453
- get(id: string): SessionData | undefined {
454
- return this.sessions.get(id);
455
- }
456
-
457
- list(): SessionData[] {
458
- return Array.from(this.sessions.values());
459
- }
460
-
461
- remove(id: string) {
462
- this.sessions.delete(id);
463
- this.sessionSecrets.delete(id);
464
- rmSync(this.sessionDir(id), { recursive: true, force: true });
465
- }
466
-
467
- setSecret(id: string, fieldId: string, value: string): boolean {
468
- const session = this.sessions.get(id);
469
- if (!session) return false;
470
-
471
- let secrets = this.sessionSecrets.get(id);
472
- if (!secrets || secrets.revision !== session.currentRevision) {
473
- secrets = { revision: session.currentRevision, values: new Map() };
474
- this.sessionSecrets.set(id, secrets);
475
- }
476
- secrets.values.set(fieldId, value);
477
- return true;
478
- }
479
-
480
- hasSecret(id: string, fieldId: string): boolean {
481
- const session = this.sessions.get(id);
482
- const secrets = this.sessionSecrets.get(id);
483
- return !!session
484
- && secrets?.revision === session.currentRevision
485
- && secrets.values.has(fieldId);
486
- }
487
-
488
- clearSecret(id: string, fieldId: string): boolean {
489
- const session = this.sessions.get(id);
490
- const secrets = this.sessionSecrets.get(id);
491
- if (!session || secrets?.revision !== session.currentRevision) return false;
492
- return secrets.values.delete(fieldId);
493
- }
494
-
495
- resolveSecrets(id: string, fieldIds: string[]): SecretResolution | null {
496
- const session = this.sessions.get(id);
497
- if (!session) return null;
498
-
499
- const secrets = this.sessionSecrets.get(id);
500
- const missing = fieldIds.filter((fieldId) =>
501
- secrets?.revision !== session.currentRevision || !secrets.values.has(fieldId)
502
- );
503
- if (missing.length > 0) return { ok: false, missing };
504
-
505
- const values = new Map<string, string>();
506
- for (const fieldId of fieldIds) {
507
- const value = secrets?.values.get(fieldId);
508
- if (value !== undefined) values.set(fieldId, value);
509
- }
510
- return { ok: true, values };
511
- }
512
-
513
- saveCompiled(id: string, filename: string, js: string, rev?: number) {
514
- const session = this.sessions.get(id);
515
- const revision = rev ?? session?.currentRevision;
516
- if (!revision) return;
517
- const revDir = this.revisionDir(id, revision);
518
- mkdirSync(revDir, { recursive: true });
519
- const compiledName = filename.replace(/\.jsx$/, ".compiled.js");
520
- writeFileSync(join(revDir, compiledName), js);
521
- }
522
-
523
- getCompiled(id: string, filename: string, rev?: number): string | null {
524
- const session = this.sessions.get(id);
525
- const revision = rev ?? session?.currentRevision;
526
- if (!revision) return null;
527
- const compiledName = filename.replace(/\.jsx$/, ".compiled.js");
528
- try {
529
- return readFileSync(join(this.revisionDir(id, revision), compiledName), "utf-8");
530
- } catch {
531
- return null;
532
- }
533
- }
534
-
535
- saveFeedback(id: string, rev: number, markdown: string) {
536
- const revDir = this.revisionDir(id, rev);
537
- mkdirSync(revDir, { recursive: true });
538
- writeFileSync(join(revDir, "feedback.md"), markdown);
539
-
540
- const session = this.sessions.get(id);
541
- if (session) {
542
- const ri = session.revisions.find((r) => r.revision === rev);
543
- if (ri) {
544
- ri.hasFeedback = true;
545
- ri.feedbackConsumed = false;
546
- }
547
- this.persistMeta(session);
548
- }
549
- }
550
-
551
- getFeedback(id: string, rev: number): string | null {
552
- try {
553
- return readFileSync(join(this.revisionDir(id, rev), "feedback.md"), "utf-8");
554
- } catch {
555
- return null;
556
- }
557
- }
558
-
559
- consumeFeedback(id: string, rev: number) {
560
- const session = this.sessions.get(id);
561
- if (!session) return;
562
- const ri = session.revisions.find((r) => r.revision === rev);
563
- if (!ri || !ri.hasFeedback || ri.feedbackConsumed) return;
564
- ri.feedbackConsumed = true;
565
- this.persistMeta(session);
566
- }
567
-
568
- getLatestUnconsumedFeedback(id: string): { revision: number; feedback: string } | null {
569
- const session = this.sessions.get(id);
570
- if (!session) return null;
571
- for (let i = session.revisions.length - 1; i >= 0; i--) {
572
- const ri = session.revisions[i];
573
- if (ri.hasFeedback && !ri.feedbackConsumed) {
574
- const feedback = this.getFeedback(id, ri.revision);
575
- if (feedback) return { revision: ri.revision, feedback };
576
- }
577
- }
578
- return null;
579
- }
580
-
581
- getRevisionJsxPath(id: string, rev: number, filename: string): string {
582
- return join(this.revisionDir(id, rev), filename);
583
- }
584
-
585
- /**
586
- * Remote feedback from shared views. Stored as one JSON file per revision
587
- * at `revisions/{rev}/remote_feedback.json`, containing an array of
588
- * RemoteFeedbackEntry. Appended to (not replaced) as new feedback arrives.
589
- */
590
- appendRemoteFeedback(id: string, rev: number, entries: RemoteFeedbackEntry[]): void {
591
- if (entries.length === 0) return;
592
- const revDir = this.revisionDir(id, rev);
593
- mkdirSync(revDir, { recursive: true });
594
- const file = join(revDir, "remote_feedback.json");
595
- let existing: RemoteFeedbackEntry[] = [];
596
- try {
597
- existing = JSON.parse(readFileSync(file, "utf-8"));
598
- } catch {}
599
- const seen = new Set(existing.map((e) => e.id));
600
- const merged = [...existing];
601
- for (const e of entries) if (!seen.has(e.id)) merged.push(e);
602
- writeFileSync(file, JSON.stringify(merged, null, 2));
603
- }
604
-
605
- getRemoteFeedback(id: string, rev: number): RemoteFeedbackEntry[] {
606
- try {
607
- return JSON.parse(readFileSync(join(this.revisionDir(id, rev), "remote_feedback.json"), "utf-8"));
608
- } catch {
609
- return [];
610
- }
611
- }
612
- }
@@ -1,71 +0,0 @@
1
- import { watch, existsSync, readFileSync, readdirSync } from "fs";
2
- import { join } from "path";
3
- import { compilePlan } from "./compiler";
4
- import type { SessionManager } from "./session";
5
-
6
- type BroadcastFn = (sessionId: string) => void;
7
-
8
- const watchers = new Map<string, ReturnType<typeof watch>[]>();
9
-
10
- export function watchSession(
11
- sessionId: string,
12
- sessionManager: SessionManager,
13
- broadcast: BroadcastFn,
14
- ) {
15
- // Close existing watchers for this session
16
- unwatchSession(sessionId);
17
-
18
- const session = sessionManager.get(sessionId);
19
- if (!session) return;
20
-
21
- const rev = session.currentRevision;
22
- const canvasFiles = sessionManager.getRevisionCanvasFiles(sessionId, rev);
23
- if (canvasFiles.length === 0) return;
24
-
25
- const fileWatchers: ReturnType<typeof watch>[] = [];
26
- const debounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
27
-
28
- for (const filename of canvasFiles) {
29
- const filePath = sessionManager.getRevisionJsxPath(sessionId, rev, filename);
30
- if (!existsSync(filePath)) continue;
31
-
32
- const watcher = watch(filePath, async (event) => {
33
- if (event !== "change") return;
34
-
35
- // Debounce per-file
36
- const existing = debounceTimers.get(filename);
37
- if (existing) clearTimeout(existing);
38
-
39
- debounceTimers.set(filename, setTimeout(async () => {
40
- debounceTimers.delete(filename);
41
- try {
42
- const jsx = readFileSync(filePath, "utf-8");
43
- const result = await compilePlan(jsx, session.projectRoot);
44
- if (result.ok) {
45
- sessionManager.saveCompiled(sessionId, filename, result.js, rev);
46
- broadcast(sessionId);
47
- }
48
- } catch {}
49
- }, 200));
50
- });
51
-
52
- fileWatchers.push(watcher);
53
- }
54
-
55
- watchers.set(sessionId, fileWatchers);
56
- }
57
-
58
- export function unwatchSession(sessionId: string) {
59
- const sessionWatchers = watchers.get(sessionId);
60
- if (sessionWatchers) {
61
- for (const w of sessionWatchers) w.close();
62
- watchers.delete(sessionId);
63
- }
64
- }
65
-
66
- export function unwatchAll() {
67
- for (const [, sessionWatchers] of watchers) {
68
- for (const w of sessionWatchers) w.close();
69
- }
70
- watchers.clear();
71
- }