agent-canvas 0.1.43 → 0.1.45

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 (110) hide show
  1. package/bin/lib/commands/daemon.ts +3 -6
  2. package/bin/lib/commands/push.ts +1 -1
  3. package/bin/lib/daemon-lifecycle.ts +65 -72
  4. package/bin/lib/helpers.test.ts +1 -0
  5. package/bin/lib/helpers.ts +30 -74
  6. package/daemon/build.ts +16 -13
  7. package/daemon/client/App.tsx +38 -62
  8. package/daemon/client/CompareView.tsx +4 -4
  9. package/daemon/client/FileBrowser.tsx +4 -4
  10. package/daemon/client/FileViewer.tsx +4 -4
  11. package/daemon/client/OverviewView.tsx +2 -2
  12. package/daemon/client/RevisionSelect.tsx +1 -1
  13. package/daemon/client/clientApi.ts +18 -0
  14. package/daemon/client/components/index.ts +3 -16
  15. package/daemon/client/styles.css +4 -457
  16. package/daemon/dist/client.css +1 -1
  17. package/daemon/dist/client.js +76 -76
  18. package/daemon/dist/components.js +29 -29
  19. package/daemon/dist/runtime.js +1 -1
  20. package/daemon/src/components.ts +13 -0
  21. package/daemon/src/handlers/api.ts +14 -9
  22. package/daemon/src/handlers/files.ts +1 -3
  23. package/daemon/src/handlers/static.ts +1 -1
  24. package/daemon/src/handlers/uploads.ts +1 -3
  25. package/daemon/src/paths.ts +10 -24
  26. package/daemon/src/remote-feedback.test.ts +1 -1
  27. package/daemon/src/remote-feedback.ts +1 -1
  28. package/daemon/src/secrets-api.test.ts +1 -2
  29. package/daemon/src/server.ts +13 -10
  30. package/daemon/src/share-e2e.test.ts +1 -1
  31. package/daemon/src/share.test.ts +1 -1
  32. package/daemon/src/share.ts +1 -1
  33. package/daemon/tsconfig.json +1 -3
  34. package/package.json +10 -2
  35. package/.github/dark.png +0 -0
  36. package/.github/light.png +0 -0
  37. package/.github/workflows/ci.yml +0 -48
  38. package/.github/workflows/deploy-worker-prod.yml +0 -19
  39. package/.github/workflows/deploy-worker-stage.yml +0 -21
  40. package/.github/workflows/deploy-worker.yml +0 -72
  41. package/.github/workflows/release.yml +0 -36
  42. package/CLAUDE.md +0 -86
  43. package/bun.lock +0 -377
  44. package/daemon/client/AnnotationEditor.tsx +0 -315
  45. package/daemon/client/AnnotationProvider.tsx +0 -191
  46. package/daemon/client/AnnotationSidebar.tsx +0 -627
  47. package/daemon/client/FileIcon.tsx +0 -76
  48. package/daemon/client/PlanRenderer.tsx +0 -614
  49. package/daemon/client/Popover.tsx +0 -350
  50. package/daemon/client/RenderErrorContext.ts +0 -11
  51. package/daemon/client/ResponsePreview.tsx +0 -186
  52. package/daemon/client/SessionContext.tsx +0 -3
  53. package/daemon/client/annotationContext.ts +0 -114
  54. package/daemon/client/annotationDom.ts +0 -104
  55. package/daemon/client/annotationDraft.test.ts +0 -149
  56. package/daemon/client/annotationDraft.ts +0 -110
  57. package/daemon/client/compat/preact-all.ts +0 -4
  58. package/daemon/client/components/Callout.tsx +0 -26
  59. package/daemon/client/components/Checklist.tsx +0 -37
  60. package/daemon/client/components/Choice.tsx +0 -176
  61. package/daemon/client/components/CodeBlock.tsx +0 -35
  62. package/daemon/client/components/Diff.tsx +0 -65
  63. package/daemon/client/components/FilePreview.tsx +0 -88
  64. package/daemon/client/components/Image.tsx +0 -45
  65. package/daemon/client/components/Markdown.tsx +0 -44
  66. package/daemon/client/components/Mermaid.tsx +0 -565
  67. package/daemon/client/components/Note.tsx +0 -14
  68. package/daemon/client/components/Priority.tsx +0 -19
  69. package/daemon/client/components/ResponseNote.tsx +0 -19
  70. package/daemon/client/components/Section.tsx +0 -33
  71. package/daemon/client/components/Table.tsx +0 -35
  72. package/daemon/client/components/Task.tsx +0 -76
  73. package/daemon/client/components/UserInput.tsx +0 -174
  74. package/daemon/client/components/renderMarkdownChildren.tsx +0 -97
  75. package/daemon/client/generateMarkdown.ts +0 -273
  76. package/daemon/client/highlightRange.ts +0 -224
  77. package/daemon/client/popoverPosition.ts +0 -31
  78. package/daemon/client/runtime.ts +0 -123
  79. package/daemon/client/theme.css +0 -66
  80. package/daemon/client/useTextAnnotation.tsx +0 -188
  81. package/daemon/client/utils.ts +0 -13
  82. package/daemon/src/compiler.test.ts +0 -57
  83. package/daemon/src/compiler.ts +0 -360
  84. package/daemon/src/handlers/utils.ts +0 -14
  85. package/daemon/src/router.ts +0 -16
  86. package/daemon/src/session-secrets.test.ts +0 -83
  87. package/daemon/src/session.ts +0 -612
  88. package/daemon/src/watcher.ts +0 -71
  89. package/daemon/src/websocket.test.ts +0 -99
  90. package/daemon/src/websocket.ts +0 -211
  91. package/tsconfig.json +0 -14
  92. package/workers/canvas-share/README.md +0 -159
  93. package/workers/canvas-share/oblaka.ts +0 -56
  94. package/workers/canvas-share/package.json +0 -25
  95. package/workers/canvas-share/public/assets/.gitkeep +0 -0
  96. package/workers/canvas-share/public/assets/client.css +0 -2
  97. package/workers/canvas-share/public/assets/client.js +0 -91
  98. package/workers/canvas-share/public/assets/components.js +0 -65
  99. package/workers/canvas-share/public/assets/jsx-runtime.js +0 -3
  100. package/workers/canvas-share/public/assets/preact-compat.js +0 -1
  101. package/workers/canvas-share/public/assets/runtime.js +0 -1
  102. package/workers/canvas-share/public/index.html +0 -385
  103. package/workers/canvas-share/public/screenshot-dark.png +0 -0
  104. package/workers/canvas-share/public/screenshot-light.png +0 -0
  105. package/workers/canvas-share/src/index.test.ts +0 -209
  106. package/workers/canvas-share/src/index.ts +0 -518
  107. package/workers/canvas-share/src/types.ts +0 -119
  108. package/workers/canvas-share/src/util.ts +0 -156
  109. package/workers/canvas-share/src/validation.ts +0 -160
  110. 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
- }