agent-canvas 0.1.27 → 0.1.28

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 (49) hide show
  1. package/.github/workflows/ci.yml +25 -0
  2. package/.github/workflows/deploy-worker-prod.yml +20 -0
  3. package/.github/workflows/deploy-worker-stage.yml +22 -0
  4. package/.github/workflows/deploy-worker.yml +73 -0
  5. package/bun.lock +193 -0
  6. package/daemon/build.ts +22 -0
  7. package/daemon/client/AnnotationEditor.tsx +12 -2
  8. package/daemon/client/AnnotationProvider.tsx +20 -6
  9. package/daemon/client/AnnotationSidebar.tsx +72 -35
  10. package/daemon/client/App.tsx +219 -10
  11. package/daemon/client/FileBrowser.tsx +16 -0
  12. package/daemon/client/FileViewer.tsx +18 -0
  13. package/daemon/client/PlanRenderer.tsx +3 -2
  14. package/daemon/client/ReviewerIdentityDialog.tsx +95 -0
  15. package/daemon/client/SessionSwitcher.tsx +5 -0
  16. package/daemon/client/ShareDialog.tsx +230 -0
  17. package/daemon/client/clientApi.ts +142 -0
  18. package/daemon/client/runtime.ts +22 -0
  19. package/daemon/dist/client.css +1 -1
  20. package/daemon/dist/client.js +74 -74
  21. package/daemon/dist/components.js +50 -50
  22. package/daemon/src/handlers/api.ts +63 -0
  23. package/daemon/src/remote-feedback.test.ts +179 -0
  24. package/daemon/src/remote-feedback.ts +128 -0
  25. package/daemon/src/server.ts +5 -0
  26. package/daemon/src/session.ts +139 -6
  27. package/daemon/src/share.test.ts +221 -0
  28. package/daemon/src/share.ts +187 -0
  29. package/daemon/src/websocket.ts +15 -2
  30. package/package.json +5 -1
  31. package/workers/canvas-share/README.md +159 -0
  32. package/workers/canvas-share/oblaka.ts +56 -0
  33. package/workers/canvas-share/package.json +25 -0
  34. package/workers/canvas-share/public/assets/.gitkeep +0 -0
  35. package/workers/canvas-share/public/assets/client.css +2 -0
  36. package/workers/canvas-share/public/assets/client.js +91 -0
  37. package/workers/canvas-share/public/assets/components.js +63 -0
  38. package/workers/canvas-share/public/assets/jsx-runtime.js +3 -0
  39. package/workers/canvas-share/public/assets/preact-compat.js +1 -0
  40. package/workers/canvas-share/public/assets/runtime.js +1 -0
  41. package/workers/canvas-share/public/index.html +385 -0
  42. package/workers/canvas-share/public/screenshot-dark.png +0 -0
  43. package/workers/canvas-share/public/screenshot-light.png +0 -0
  44. package/workers/canvas-share/src/index.test.ts +165 -0
  45. package/workers/canvas-share/src/index.ts +510 -0
  46. package/workers/canvas-share/src/types.ts +97 -0
  47. package/workers/canvas-share/src/util.ts +156 -0
  48. package/workers/canvas-share/src/validation.ts +117 -0
  49. package/workers/canvas-share/tsconfig.json +16 -0
@@ -3,6 +3,7 @@ import type { SessionManager } from "../session";
3
3
  import { compilePlan } from "../compiler";
4
4
  import { watchSession } from "../watcher";
5
5
  import { jsonResponse } from "./utils";
6
+ import { loadShareConfig, shareRevision, revokeShare } from "../share";
6
7
  import type { Route } from "../router";
7
8
 
8
9
  export interface ApiContext {
@@ -154,9 +155,67 @@ export function createApiHandlers(ctx: ApiContext): Route[] {
154
155
  canvasFiles: session.canvasFiles,
155
156
  createdAt: session.createdAt,
156
157
  updatedAt: session.updatedAt,
158
+ shares: session.shares ?? [],
159
+ shareEnabled: !!process.env.CANVAS_SHARE_ENDPOINT,
157
160
  });
158
161
  }
159
162
 
163
+ async function handleShareRevision(_req: Request, _url: URL, match: URLPatternResult): Promise<Response> {
164
+ const sessionId = match.pathname.groups.id!;
165
+ const rev = parseInt(match.pathname.groups.rev!, 10);
166
+
167
+ const session = sessionManager.get(sessionId);
168
+ if (!session) return jsonResponse({ error: "Session not found" }, 404);
169
+ if (!session.revisions.find((r) => r.revision === rev)) {
170
+ return jsonResponse({ error: `Revision ${rev} not found` }, 404);
171
+ }
172
+
173
+ const config = loadShareConfig(ctx.version);
174
+ if (!config) {
175
+ return jsonResponse({
176
+ error: "Sharing disabled. Set CANVAS_SHARE_ENDPOINT to the deployed canvas-share worker URL.",
177
+ }, 501);
178
+ }
179
+
180
+ try {
181
+ const entry = await shareRevision(sessionManager, sessionId, rev, config);
182
+ broadcastRevisionUpdate(sessionId);
183
+ return jsonResponse({ ok: true, share: entry });
184
+ } catch (e: any) {
185
+ return jsonResponse({ error: e.message || String(e) }, 502);
186
+ }
187
+ }
188
+
189
+ async function handleShareRevoke(_req: Request, _url: URL, match: URLPatternResult): Promise<Response> {
190
+ const sessionId = match.pathname.groups.id!;
191
+ const shareId = match.pathname.groups.shareId!;
192
+ if (!sessionManager.get(sessionId)) return jsonResponse({ error: "Session not found" }, 404);
193
+ const config = loadShareConfig(ctx.version);
194
+ if (!config) return jsonResponse({ error: "Sharing disabled" }, 501);
195
+ try {
196
+ await revokeShare(sessionManager, sessionId, shareId, config);
197
+ broadcastRevisionUpdate(sessionId);
198
+ return jsonResponse({ ok: true });
199
+ } catch (e: any) {
200
+ return jsonResponse({ error: e.message || String(e) }, 502);
201
+ }
202
+ }
203
+
204
+ function handleSharesList(_req: Request, _url: URL, match: URLPatternResult): Response {
205
+ const sessionId = match.pathname.groups.id!;
206
+ const session = sessionManager.get(sessionId);
207
+ if (!session) return jsonResponse({ error: "Session not found" }, 404);
208
+ return jsonResponse({ shares: session.shares ?? [] });
209
+ }
210
+
211
+ function handleRemoteFeedbackGet(_req: Request, _url: URL, match: URLPatternResult): Response {
212
+ const sessionId = match.pathname.groups.id!;
213
+ const rev = parseInt(match.pathname.groups.rev!, 10);
214
+ if (!sessionManager.get(sessionId)) return jsonResponse({ error: "Session not found" }, 404);
215
+ const entries = sessionManager.getRemoteFeedback(sessionId, rev);
216
+ return jsonResponse({ entries });
217
+ }
218
+
160
219
  function handleSessions(): Response {
161
220
  return jsonResponse(sessionManager.list().map((s) => ({
162
221
  id: s.id,
@@ -190,6 +249,10 @@ export function createApiHandlers(ctx: ApiContext): Route[] {
190
249
  { method: "GET", pattern: new URLPattern({ pathname: "/api/session/:id/meta" }), handler: handleMeta },
191
250
  { method: "GET", pattern: new URLPattern({ pathname: "/api/session/:id/revision/:rev/feedback" }), handler: handleFeedbackGet },
192
251
  { method: "POST", pattern: new URLPattern({ pathname: "/api/session/:id/feedback/consume" }), handler: handleFeedbackConsume },
252
+ { method: "POST", pattern: new URLPattern({ pathname: "/api/session/:id/revision/:rev/share" }), handler: handleShareRevision },
253
+ { method: "POST", pattern: new URLPattern({ pathname: "/api/session/:id/shares/:shareId/revoke" }), handler: handleShareRevoke },
254
+ { method: "GET", pattern: new URLPattern({ pathname: "/api/session/:id/shares" }), handler: handleSharesList },
255
+ { method: "GET", pattern: new URLPattern({ pathname: "/api/session/:id/revision/:rev/remote-feedback" }), handler: handleRemoteFeedbackGet },
193
256
  { method: "GET", pattern: new URLPattern({ pathname: "/api/sessions" }), handler: handleSessions },
194
257
  ];
195
258
  }
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Tests for the remote feedback poller. Uses a real SessionManager with
3
+ * a real share recorded on a session, and mocks `fetch` to simulate
4
+ * worker responses. Verifies persistence, broadcast, dedup, and backoff.
5
+ */
6
+
7
+ import { describe, expect, test, afterEach, beforeEach } from "bun:test";
8
+ import { mkdtempSync, rmSync } from "fs";
9
+ import { join } from "path";
10
+ import { tmpdir } from "os";
11
+ import { SessionManager } from "./session";
12
+ import { startRemoteFeedbackPoller } from "./remote-feedback";
13
+
14
+ let testDir: string;
15
+ beforeEach(() => {
16
+ testDir = mkdtempSync(join(tmpdir(), "canvas-rf-test-"));
17
+ });
18
+ afterEach(() => {
19
+ try { rmSync(testDir, { recursive: true, force: true }); } catch {}
20
+ });
21
+
22
+ function newSessionManager(): SessionManager {
23
+ return new SessionManager(testDir);
24
+ }
25
+
26
+ const originalFetch = globalThis.fetch;
27
+ const originalEndpoint = process.env.CANVAS_SHARE_ENDPOINT;
28
+
29
+ beforeEach(() => {
30
+ process.env.CANVAS_SHARE_ENDPOINT = "https://mock.example.com";
31
+ });
32
+
33
+ afterEach(() => {
34
+ globalThis.fetch = originalFetch;
35
+ if (originalEndpoint !== undefined) process.env.CANVAS_SHARE_ENDPOINT = originalEndpoint;
36
+ else delete process.env.CANVAS_SHARE_ENDPOINT;
37
+ });
38
+
39
+ function setupSession(sm: SessionManager): { sessionId: string; shareId: string } {
40
+ const sessionId = `test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
41
+ const shareId = "abc123def456";
42
+ sm.upsert(sessionId, new Map([["plan.jsx", "x"]]), "/tmp");
43
+ sm.saveCompiled(sessionId, "plan.jsx", "x");
44
+ sm.addShare(sessionId, {
45
+ shareId,
46
+ url: "https://mock.example.com/s/" + shareId,
47
+ revision: 1,
48
+ createdAt: new Date().toISOString(),
49
+ });
50
+ return { sessionId, shareId };
51
+ }
52
+
53
+ describe("startRemoteFeedbackPoller", () => {
54
+ test("persists fetched entries and updates lastFeedbackAt", async () => {
55
+ const sm = newSessionManager();
56
+ const { sessionId, shareId } = setupSession(sm);
57
+
58
+ let calls = 0;
59
+ (globalThis as any).fetch = async (url: any) => {
60
+ calls++;
61
+ expect(String(url)).toContain(`/shares/${shareId}/feedback`);
62
+ return new Response(JSON.stringify({
63
+ entries: [{
64
+ id: "fb1",
65
+ shareId,
66
+ revision: 1,
67
+ submittedAt: "2026-04-09T10:00:00.000Z",
68
+ author: { id: "u1", name: "Alice" },
69
+ annotations: [{
70
+ id: "a1",
71
+ snippet: "x",
72
+ note: "good",
73
+ createdAt: "2026-04-09T10:00:00.000Z",
74
+ }],
75
+ }],
76
+ latestAt: "2026-04-09T10:00:00.000Z",
77
+ }), { status: 200 });
78
+ };
79
+
80
+ let broadcasted: { sessionId: string; revision: number; count: number }[] = [];
81
+ const broadcast = (sid: string, rev: number, entries: any[]) => {
82
+ broadcasted.push({ sessionId: sid, revision: rev, count: entries.length });
83
+ };
84
+
85
+ const poller = startRemoteFeedbackPoller(sm, broadcast, "0.0.0");
86
+ // Wait for at least one poll cycle
87
+ await new Promise((r) => setTimeout(r, 100));
88
+ poller.stop();
89
+
90
+ expect(calls).toBeGreaterThanOrEqual(1);
91
+ expect(broadcasted.length).toBeGreaterThanOrEqual(1);
92
+ expect(broadcasted[0].sessionId).toBe(sessionId);
93
+ expect(broadcasted[0].revision).toBe(1);
94
+ expect(broadcasted[0].count).toBe(1);
95
+
96
+ const persisted = sm.getRemoteFeedback(sessionId, 1);
97
+ expect(persisted).toHaveLength(1);
98
+ expect(persisted[0].id).toBe("fb1");
99
+ expect(persisted[0].author.name).toBe("Alice");
100
+
101
+ const updatedShare = sm.getShares(sessionId).find((s) => s.shareId === shareId);
102
+ expect(updatedShare?.lastFeedbackAt).toBe("2026-04-09T10:00:00.000Z");
103
+
104
+ sm.remove(sessionId);
105
+ });
106
+
107
+ test("dedupes entries by id across polls", async () => {
108
+ const sm = newSessionManager();
109
+ const { sessionId } = setupSession(sm);
110
+
111
+ const sameEntry = {
112
+ id: "fb-same",
113
+ shareId: "abc123def456",
114
+ revision: 1,
115
+ submittedAt: "2026-04-09T10:00:00.000Z",
116
+ author: { id: "u1", name: "Bob" },
117
+ annotations: [],
118
+ };
119
+
120
+ (globalThis as any).fetch = async () => new Response(JSON.stringify({ entries: [sameEntry] }), { status: 200 });
121
+
122
+ const poller = startRemoteFeedbackPoller(sm, () => {}, "0.0.0");
123
+ await new Promise((r) => setTimeout(r, 200));
124
+ poller.stop();
125
+
126
+ // Even after multiple polls returning the same entry, only one should be persisted.
127
+ const persisted = sm.getRemoteFeedback(sessionId, 1);
128
+ expect(persisted).toHaveLength(1);
129
+
130
+ sm.remove(sessionId);
131
+ });
132
+
133
+ test("404 stops further polls (revoked share)", async () => {
134
+ const sm = newSessionManager();
135
+ const { sessionId, shareId } = setupSession(sm);
136
+
137
+ let calls = 0;
138
+ (globalThis as any).fetch = async () => {
139
+ calls++;
140
+ return new Response("not found", { status: 404 });
141
+ };
142
+
143
+ const poller = startRemoteFeedbackPoller(sm, () => {}, "0.0.0");
144
+ await new Promise((r) => setTimeout(r, 200));
145
+ poller.stop();
146
+
147
+ // After the first 404 the share is marked dead — calls should stay at 1.
148
+ expect(calls).toBe(1);
149
+ void shareId;
150
+ sm.remove(sessionId);
151
+ });
152
+
153
+ test("does nothing when no shares exist", async () => {
154
+ const sm = newSessionManager();
155
+ let calls = 0;
156
+ (globalThis as any).fetch = async () => { calls++; return new Response("", { status: 200 }); };
157
+
158
+ const poller = startRemoteFeedbackPoller(sm, () => {}, "0.0.0");
159
+ await new Promise((r) => setTimeout(r, 100));
160
+ poller.stop();
161
+
162
+ expect(calls).toBe(0);
163
+ });
164
+
165
+ test("does nothing when CANVAS_SHARE_ENDPOINT not set", async () => {
166
+ delete process.env.CANVAS_SHARE_ENDPOINT;
167
+
168
+ const sm = newSessionManager();
169
+ setupSession(sm);
170
+ let calls = 0;
171
+ (globalThis as any).fetch = async () => { calls++; return new Response("", { status: 200 }); };
172
+
173
+ const poller = startRemoteFeedbackPoller(sm, () => {}, "0.0.0");
174
+ await new Promise((r) => setTimeout(r, 100));
175
+ poller.stop();
176
+
177
+ expect(calls).toBe(0);
178
+ });
179
+ });
@@ -0,0 +1,128 @@
1
+ import type { SessionManager, RemoteFeedbackEntry } from "./session";
2
+ import { loadShareConfig } from "./share";
3
+
4
+ /**
5
+ * Background polling loop for remote feedback.
6
+ *
7
+ * For every session that has at least one share, periodically asks the
8
+ * CF Worker for feedback submitted since we last checked. New entries are
9
+ * persisted locally (as `remote_feedback.json` per revision) and broadcast
10
+ * to any connected browser WebSockets so the UI can merge them into the
11
+ * annotation layer in real time.
12
+ *
13
+ * The polling loop is cheap: one HTTP call per share per 5 seconds. If no
14
+ * shares exist anywhere, the loop sleeps. If the worker is unreachable,
15
+ * errors are logged but the loop keeps running so feedback is picked up
16
+ * as soon as the worker comes back.
17
+ */
18
+
19
+ const POLL_INTERVAL_MS = 5_000;
20
+ const MAX_BACKOFF_MS = 60_000;
21
+
22
+ interface WorkerFeedbackResponse {
23
+ entries: RemoteFeedbackEntry[];
24
+ /** Server-reported high water mark — echo back in the next `since` param */
25
+ latestAt?: string;
26
+ }
27
+
28
+ export function startRemoteFeedbackPoller(
29
+ sessionManager: SessionManager,
30
+ broadcastRemoteFeedback: (sessionId: string, revision: number, entries: RemoteFeedbackEntry[]) => void,
31
+ version: string,
32
+ ): { stop: () => void } {
33
+ let stopped = false;
34
+
35
+ // Per-share consecutive error count → exponential backoff so a dead
36
+ // worker doesn't generate a fire-hose of failed requests.
37
+ const errorCounts = new Map<string, number>();
38
+ function backoffFor(shareId: string): number {
39
+ const errors = errorCounts.get(shareId) || 0;
40
+ if (errors === 0) return 0;
41
+ return Math.min(MAX_BACKOFF_MS, POLL_INTERVAL_MS * Math.pow(2, errors - 1));
42
+ }
43
+
44
+ async function pollShare(sessionId: string, share: { shareId: string; lastFeedbackAt?: string }, endpoint: string) {
45
+ try {
46
+ const since = share.lastFeedbackAt ?? "";
47
+ const url = `${endpoint}/shares/${share.shareId}/feedback${since ? `?since=${encodeURIComponent(since)}` : ""}`;
48
+ const res = await fetch(url);
49
+ if (!res.ok) {
50
+ if (res.status === 404) {
51
+ // Share no longer exists on the worker — likely revoked or expired.
52
+ // Stop trying so we don't hammer it forever.
53
+ errorCounts.set(share.shareId, 99);
54
+ return;
55
+ }
56
+ throw new Error(`HTTP ${res.status}`);
57
+ }
58
+ const data = (await res.json()) as WorkerFeedbackResponse;
59
+ errorCounts.delete(share.shareId);
60
+
61
+ if (!data.entries || data.entries.length === 0) return;
62
+
63
+ const byRev = new Map<number, RemoteFeedbackEntry[]>();
64
+ for (const e of data.entries) {
65
+ if (!byRev.has(e.revision)) byRev.set(e.revision, []);
66
+ byRev.get(e.revision)!.push(e);
67
+ }
68
+
69
+ for (const [rev, entries] of byRev) {
70
+ sessionManager.appendRemoteFeedback(sessionId, rev, entries);
71
+ broadcastRemoteFeedback(sessionId, rev, entries);
72
+ }
73
+
74
+ const latest = data.latestAt
75
+ ?? data.entries.reduce((acc, e) => (e.submittedAt > acc ? e.submittedAt : acc), share.lastFeedbackAt ?? "");
76
+ if (latest) {
77
+ sessionManager.updateShareLastFeedback(sessionId, share.shareId, latest);
78
+ }
79
+ } catch (e: any) {
80
+ errorCounts.set(share.shareId, (errorCounts.get(share.shareId) || 0) + 1);
81
+ console.warn(`[remote-feedback] ${share.shareId}: ${e.message || e} (will retry with backoff)`);
82
+ }
83
+ }
84
+
85
+ // Per-share next-poll timestamps so backoffs are independent.
86
+ const nextPollAt = new Map<string, number>();
87
+
88
+ async function pollOnce() {
89
+ const config = loadShareConfig(version);
90
+ if (!config) return;
91
+
92
+ const sessions = sessionManager.listSessionsWithShares();
93
+ if (sessions.length === 0) return;
94
+
95
+ const now = Date.now();
96
+ const tasks: Promise<void>[] = [];
97
+ for (const session of sessions) {
98
+ for (const share of session.shares ?? []) {
99
+ const next = nextPollAt.get(share.shareId) || 0;
100
+ if (now < next) continue;
101
+ // Skip "permanently dead" shares (after a 404).
102
+ if ((errorCounts.get(share.shareId) || 0) >= 99) continue;
103
+ nextPollAt.set(share.shareId, now + Math.max(POLL_INTERVAL_MS, backoffFor(share.shareId)));
104
+ tasks.push(pollShare(session.id, share, config.endpoint));
105
+ }
106
+ }
107
+ await Promise.all(tasks);
108
+ }
109
+
110
+ async function loop() {
111
+ while (!stopped) {
112
+ try {
113
+ await pollOnce();
114
+ } catch (e: any) {
115
+ console.warn(`[remote-feedback] loop error: ${e.message || e}`);
116
+ }
117
+ await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
118
+ }
119
+ }
120
+
121
+ loop();
122
+
123
+ return {
124
+ stop: () => {
125
+ stopped = true;
126
+ },
127
+ };
128
+ }
@@ -9,6 +9,7 @@ import { createApiHandlers } from "./handlers/api";
9
9
  import { createFileHandlers } from "./handlers/files";
10
10
  import { createStaticHandlers } from "./handlers/static";
11
11
  import { createUploadHandlers } from "./handlers/uploads";
12
+ import { startRemoteFeedbackPoller } from "./remote-feedback";
12
13
 
13
14
  const PORT = parseInt(process.env.CANVAS_PORT || "19400", 10);
14
15
  const VERSION = JSON.parse(readFileSync(join(dirname(import.meta.dir), "..", "package.json"), "utf-8")).version as string;
@@ -82,4 +83,8 @@ export const broadcastPlanUpdate = wsManager.broadcastPlanUpdate;
82
83
 
83
84
  setInterval(() => sessionManager.cleanupStale(), 60 * 60 * 1000);
84
85
 
86
+ // Start polling for remote feedback from shared canvases.
87
+ // No-ops if CANVAS_SHARE_ENDPOINT is not set.
88
+ startRemoteFeedbackPoller(sessionManager, wsManager.broadcastRemoteFeedback, VERSION);
89
+
85
90
  console.log(`Planner daemon listening on http://localhost:${PORT}`);
@@ -22,6 +22,64 @@ export interface RevisionInfo {
22
22
  response?: string;
23
23
  }
24
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
+ }
82
+
25
83
  export interface SessionData {
26
84
  id: string;
27
85
  projectRoot: string;
@@ -30,6 +88,7 @@ export interface SessionData {
30
88
  revisions: RevisionInfo[];
31
89
  createdAt: string;
32
90
  updatedAt: string;
91
+ shares?: ShareEntry[];
33
92
  }
34
93
 
35
94
  interface SessionMeta {
@@ -38,6 +97,7 @@ interface SessionMeta {
38
97
  updatedAt: string;
39
98
  currentRevision: number;
40
99
  revisions: RevisionInfo[];
100
+ shares?: ShareEntry[];
41
101
  }
42
102
 
43
103
  // Legacy format (flat files, pre-revision)
@@ -85,14 +145,22 @@ function computeLineDiffStats(oldText: string, newText: string): DiffStats {
85
145
 
86
146
  export class SessionManager {
87
147
  private sessions = new Map<string, SessionData>();
148
+ private readonly sessionsDir: string;
88
149
 
89
- constructor() {
90
- mkdirSync(SESSIONS_DIR, { recursive: true });
150
+ /**
151
+ * @param sessionsDir Override the on-disk sessions root. Defaults to
152
+ * the global SESSIONS_DIR (`~/.claude/agent-canvas/sessions`).
153
+ * Tests pass an isolated temp directory so they don't interfere with
154
+ * the user's real sessions.
155
+ */
156
+ constructor(sessionsDir?: string) {
157
+ this.sessionsDir = sessionsDir ?? SESSIONS_DIR;
158
+ mkdirSync(this.sessionsDir, { recursive: true });
91
159
  this.loadFromDisk();
92
160
  }
93
161
 
94
162
  private sessionDir(id: string): string {
95
- return join(SESSIONS_DIR, id);
163
+ return join(this.sessionsDir, id);
96
164
  }
97
165
 
98
166
  private revisionDir(id: string, rev: number): string {
@@ -126,10 +194,10 @@ export class SessionManager {
126
194
  }
127
195
 
128
196
  private loadFromDisk() {
129
- if (!existsSync(SESSIONS_DIR)) return;
130
- for (const name of readdirSync(SESSIONS_DIR, { withFileTypes: true })) {
197
+ if (!existsSync(this.sessionsDir)) return;
198
+ for (const name of readdirSync(this.sessionsDir, { withFileTypes: true })) {
131
199
  if (!name.isDirectory()) continue;
132
- const dir = join(SESSIONS_DIR, name.name);
200
+ const dir = join(this.sessionsDir, name.name);
133
201
  const metaPath = join(dir, "meta.json");
134
202
  if (!existsSync(metaPath)) continue;
135
203
 
@@ -186,6 +254,7 @@ export class SessionManager {
186
254
  revisions: meta.revisions,
187
255
  createdAt: meta.createdAt,
188
256
  updatedAt: meta.updatedAt,
257
+ ...(meta.shares ? { shares: meta.shares } : {}),
189
258
  });
190
259
  } catch {}
191
260
  }
@@ -321,10 +390,46 @@ export class SessionManager {
321
390
  updatedAt: session.updatedAt,
322
391
  currentRevision: session.currentRevision,
323
392
  revisions: session.revisions,
393
+ ...(session.shares?.length ? { shares: session.shares } : {}),
324
394
  };
325
395
  writeFileSync(join(this.sessionDir(session.id), "meta.json"), JSON.stringify(meta, null, 2));
326
396
  }
327
397
 
398
+ /** Record a new share for a session revision. */
399
+ addShare(id: string, entry: ShareEntry): void {
400
+ const session = this.sessions.get(id);
401
+ if (!session) return;
402
+ session.shares = [...(session.shares ?? []), entry];
403
+ session.updatedAt = new Date().toISOString();
404
+ this.persistMeta(session);
405
+ }
406
+
407
+ getShares(id: string): ShareEntry[] {
408
+ return this.sessions.get(id)?.shares ?? [];
409
+ }
410
+
411
+ removeShare(id: string, shareId: string): void {
412
+ const session = this.sessions.get(id);
413
+ if (!session?.shares) return;
414
+ session.shares = session.shares.filter((s) => s.shareId !== shareId);
415
+ session.updatedAt = new Date().toISOString();
416
+ this.persistMeta(session);
417
+ }
418
+
419
+ updateShareLastFeedback(id: string, shareId: string, at: string): void {
420
+ const session = this.sessions.get(id);
421
+ if (!session?.shares) return;
422
+ const entry = session.shares.find((s) => s.shareId === shareId);
423
+ if (!entry) return;
424
+ entry.lastFeedbackAt = at;
425
+ this.persistMeta(session);
426
+ }
427
+
428
+ /** List all sessions that have at least one share. Used by the remote feedback poller. */
429
+ listSessionsWithShares(): SessionData[] {
430
+ return Array.from(this.sessions.values()).filter((s) => (s.shares?.length ?? 0) > 0);
431
+ }
432
+
328
433
  private persistToDisk(session: SessionData, canvasFiles: Map<string, string>) {
329
434
  const revDir = this.revisionDir(session.id, session.currentRevision);
330
435
  mkdirSync(revDir, { recursive: true });
@@ -419,6 +524,34 @@ export class SessionManager {
419
524
  return join(this.revisionDir(id, rev), filename);
420
525
  }
421
526
 
527
+ /**
528
+ * Remote feedback from shared views. Stored as one JSON file per revision
529
+ * at `revisions/{rev}/remote_feedback.json`, containing an array of
530
+ * RemoteFeedbackEntry. Appended to (not replaced) as new feedback arrives.
531
+ */
532
+ appendRemoteFeedback(id: string, rev: number, entries: RemoteFeedbackEntry[]): void {
533
+ if (entries.length === 0) return;
534
+ const revDir = this.revisionDir(id, rev);
535
+ mkdirSync(revDir, { recursive: true });
536
+ const file = join(revDir, "remote_feedback.json");
537
+ let existing: RemoteFeedbackEntry[] = [];
538
+ try {
539
+ existing = JSON.parse(readFileSync(file, "utf-8"));
540
+ } catch {}
541
+ const seen = new Set(existing.map((e) => e.id));
542
+ const merged = [...existing];
543
+ for (const e of entries) if (!seen.has(e.id)) merged.push(e);
544
+ writeFileSync(file, JSON.stringify(merged, null, 2));
545
+ }
546
+
547
+ getRemoteFeedback(id: string, rev: number): RemoteFeedbackEntry[] {
548
+ try {
549
+ return JSON.parse(readFileSync(join(this.revisionDir(id, rev), "remote_feedback.json"), "utf-8"));
550
+ } catch {
551
+ return [];
552
+ }
553
+ }
554
+
422
555
  cleanupStale(maxAge = STALE_TIMEOUT_MS) {
423
556
  const now = Date.now();
424
557
  for (const [id, session] of this.sessions) {