agent-canvas 0.1.39 → 0.1.41

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.
@@ -151,7 +151,7 @@ Goal: detailed implementation plan with file-level changes. The user approves be
151
151
 
152
152
  ## Phase 4: Summary (`summary.jsx`)
153
153
 
154
- Push AFTER implementation is complete. Always do this, even briefly. **After pushing the summary, start a background watch** — the user may want to provide feedback (follow-up requests, corrections, questions about what was done).
154
+ Push AFTER implementation is complete. Always do this, even briefly. **After pushing the summary, start the host-specific watch described in the overview** — the user may want to provide feedback (follow-up requests, corrections, questions about what was done).
155
155
 
156
156
  ```jsx
157
157
  <Section title="Implementation Summary: [Feature Name]">
@@ -58,7 +58,7 @@ Goal: detailed implementation plan with file-level changes. The user approves be
58
58
 
59
59
  ## Phase 2: Summary (`summary.jsx`)
60
60
 
61
- Push AFTER implementation is complete. Always do this, even briefly. **After pushing the summary, start a background watch** — the user may want to provide feedback (follow-up requests, corrections, questions about what was done).
61
+ Push AFTER implementation is complete. Always do this, even briefly. **After pushing the summary, start the host-specific watch described in the overview** — the user may want to provide feedback (follow-up requests, corrections, questions about what was done).
62
62
 
63
63
  ```jsx
64
64
  <Section title="Implementation Summary: [Feature Name]">
@@ -4,16 +4,17 @@ Canvas opens a rich, annotatable document in the user's browser. You write JSX,
4
4
 
5
5
  Use it whenever structured visual communication beats inline chat: plans, reviews, decisions, explanations, discovery interviews.
6
6
 
7
- **Note**: Replace all `<session-id>` references below with your actual canvas session ID (provided in the skill header).
7
+ **Note**: Replace all `<session-id>` references below with the stable canvas
8
+ session ID resolved by the skill.
8
9
 
9
10
  ## Core Workflow
10
11
 
11
12
  ### 1. Write JSX
12
13
 
13
- Use the **Write** tool to create `.jsx` files in `$TMPDIR/agent-canvas/<session-id>/`:
14
+ Use the **Write** tool to create `.jsx` files in `${TMPDIR:-/tmp}/agent-canvas/<session-id>/`:
14
15
 
15
16
  ```jsx
16
- // $TMPDIR/agent-canvas/<session-id>/plan.jsx
17
+ // ${TMPDIR:-/tmp}/agent-canvas/<session-id>/plan.jsx
17
18
 
18
19
  <Section title="Authentication Redesign">
19
20
  A proposal to replace session-based auth with JWT tokens.
@@ -41,7 +42,7 @@ Components are auto-available — no imports needed. The file can be a JSX fragm
41
42
  Push the directory to open all canvases in the browser:
42
43
 
43
44
  ```bash
44
- bunx agent-canvas push $TMPDIR/agent-canvas/<session-id>/ --session <session-id> --label "Implementation Plan"
45
+ bunx agent-canvas push ${TMPDIR:-/tmp}/agent-canvas/<session-id>/ --session <session-id> --label "Implementation Plan"
45
46
  ```
46
47
 
47
48
  All `*.jsx` files in the directory are pushed as a snapshot. Each file appears as a separate tab. **Always show the `browserUrl` from the output to the user.**
@@ -52,16 +53,32 @@ Options:
52
53
 
53
54
  ### 3. Watch for feedback
54
55
 
55
- Run the watch command **in the background** using the Bash tool's `run_in_background` parameter:
56
+ Start watching immediately after every push:
56
57
 
57
58
  ```bash
58
- # Use Bash tool with run_in_background: true
59
59
  bunx agent-canvas watch --session <session-id>
60
60
  ```
61
61
 
62
- You will be **automatically notified** when the user submits feedback. Do NOT poll, sleep, or proactively check. Just stop and wait.
62
+ How to run the watcher depends on the host:
63
63
 
64
- **Important**: The push → watch sequence is atomic. Never push without watching. After starting the background watch, do not continue with other work unless instructed.
64
+ - **Codex**: Keep the watcher attached to the current agent turn. Run it without
65
+ detaching or backgrounding it. If the shell tool yields a process/session
66
+ handle, call `write_stdin` on that same handle until the command returns the
67
+ feedback. Do not end the agent turn while the watcher is still running.
68
+ - **Claude Code**: Run the watcher in the background using the Bash tool's
69
+ `run_in_background` parameter. You will be **automatically notified** when the
70
+ user submits feedback. Do NOT poll, sleep, or proactively check. Just stop and
71
+ wait.
72
+ - **Other hosts**: Keep the watcher in the foreground unless the host explicitly
73
+ guarantees that background-process completion wakes the agent.
74
+
75
+ In Codex, a wait may yield before feedback arrives. Continue waiting on the same
76
+ process handle; do not start another watcher. Keep user-facing progress updates
77
+ brief and no more than 60 seconds apart while waiting.
78
+
79
+ **Important**: The push → watch sequence is atomic. Never push without starting
80
+ the host-specific watcher immediately afterward. Do not continue with unrelated
81
+ work while waiting for feedback.
65
82
 
66
83
  ### 4. Iterate
67
84
 
@@ -82,7 +99,7 @@ Returns immediately — prints feedback if available, otherwise no output.
82
99
  Use different files for different phases or concerns:
83
100
 
84
101
  ```
85
- $TMPDIR/agent-canvas/<session-id>/
102
+ ${TMPDIR:-/tmp}/agent-canvas/<session-id>/
86
103
  discovery.jsx # Discovery interview
87
104
  requirements.jsx # Requirements spec
88
105
  plan.jsx # Implementation plan
@@ -92,7 +109,7 @@ Write new files as phases progress, then push the directory. Previous files rema
92
109
 
93
110
  ## File Location
94
111
 
95
- All canvas files go in `$TMPDIR/agent-canvas/<session-id>/`. This is a system temp directory — no `.gitignore` needed.
112
+ All canvas files go in `${TMPDIR:-/tmp}/agent-canvas/<session-id>/`. This is a system temp directory — no `.gitignore` needed.
96
113
 
97
114
  ## Components
98
115
 
@@ -167,14 +184,14 @@ User wants to make a decision? → DECISION flow
167
184
  2. **Announce** briefly: "I'll start with discovery, then create a detailed plan."
168
185
  3. **Write canvas JSX** with the Write tool
169
186
  4. **Push + show browserUrl** to the user
170
- 5. **IMMEDIATELY watch in background** — the push → watch sequence is atomic
187
+ 5. **IMMEDIATELY watch using the host-specific strategy above** — the push → watch sequence is atomic
171
188
  6. **Read feedback** — check for annotations, answers, context files
172
189
  7. **Edit and re-push + watch**, or advance to next phase
173
190
  8. **After implementation**, push a summary canvas and **watch for feedback** — the user may want to respond
174
191
 
175
192
  ## Important Rules
176
193
 
177
- - **Never `cd`** before running `bunx agent-canvas` commands. The daemon uses the current working directory to resolve project file paths (`<FilePreview>`, file browser). Always run commands from the project root. Since canvas files are in `$TMPDIR`, use absolute paths to reference them — e.g. `bunx agent-canvas push $TMPDIR/agent-canvas/<session-id>/`.
194
+ - **Never `cd`** before running `bunx agent-canvas` commands. The daemon uses the current working directory to resolve project file paths (`<FilePreview>`, file browser). Always run commands from the project root. Since canvas files are in `${TMPDIR:-/tmp}`, use absolute paths to reference them — e.g. `bunx agent-canvas push ${TMPDIR:-/tmp}/agent-canvas/<session-id>/`.
178
195
  - **Write** canvas files using the Write tool. Never use bash heredocs.
179
196
  - **Edit** canvas files using the Edit tool. Never rewrite entire files.
180
197
  - Every `<Item>` and interactive component needs a unique `id`.
@@ -21,6 +21,7 @@ import { ShareDialog, ShareButton, type ShareEntry } from "./ShareDialog";
21
21
  import { ReviewerIdentityDialog } from "./ReviewerIdentityDialog";
22
22
  import type { Annotation } from "#canvas/runtime";
23
23
  import { MODE, fetchMeta, FS_AVAILABLE, WS_AVAILABLE, submitSharedFeedback, getReviewerIdentity, setReviewerIdentity } from "./clientApi";
24
+ import { carryUnsubmittedDraft } from "./annotationDraft";
24
25
 
25
26
  export type ActiveView = { type: "overview" } | { type: "canvas"; filename: string } | { type: "file"; path: string };
26
27
 
@@ -312,9 +313,11 @@ function App() {
312
313
  fetchMeta()
313
314
  .then((data: any) => {
314
315
  if (data.currentRevision) {
316
+ const nextRevisions: RevisionInfo[] = data.revisions || [];
317
+ carryUnsubmittedDraft(localStorage, sessionId, data.currentRevision, nextRevisions);
315
318
  setCurrentRevision(data.currentRevision);
316
319
  setSelectedRevision(data.currentRevision);
317
- setRevisions(data.revisions || []);
320
+ setRevisions(nextRevisions);
318
321
  }
319
322
  if (data.canvasFiles) {
320
323
  const files = (data.canvasFiles as string[]).sort();
@@ -378,11 +381,12 @@ function App() {
378
381
  try {
379
382
  const data = JSON.parse(event.data);
380
383
  if (data.type === "plan-updated") {
384
+ const allRevisions: RevisionInfo[] = Array.isArray(data.revisions) ? data.revisions : [];
385
+ carryUnsubmittedDraft(localStorage, sessionId, data.currentRevision, allRevisions);
381
386
  setCurrentRevision(data.currentRevision);
382
387
  setSelectedRevision(data.currentRevision);
383
388
  setCompareRevision(null);
384
- if (data.revisions) {
385
- const allRevisions = data.revisions as RevisionInfo[];
389
+ if (allRevisions.length > 0) {
386
390
  setRevisions(allRevisions);
387
391
  const latest = allRevisions.find((r: RevisionInfo) => r.revision === data.currentRevision);
388
392
  if (latest?.canvasFiles) {
@@ -0,0 +1,134 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { carryUnsubmittedDraft } from "./annotationDraft";
3
+
4
+ class MemoryStorage {
5
+ private readonly values = new Map<string, string>();
6
+
7
+ getItem(key: string): string | null {
8
+ return this.values.get(key) ?? null;
9
+ }
10
+
11
+ setItem(key: string, value: string): void {
12
+ this.values.set(key, value);
13
+ }
14
+ }
15
+
16
+ const sessionId = "session";
17
+ const key = (revision: number) => `canvas:${sessionId}:rev:${revision}`;
18
+
19
+ describe("carryUnsubmittedDraft", () => {
20
+ test("carries the latest unsent draft into a new revision", () => {
21
+ const storage = new MemoryStorage();
22
+ storage.setItem(key(2), JSON.stringify({
23
+ annotations: [{ id: "annotation-1" }],
24
+ generalNote: "Review in progress",
25
+ responses: [["choice", { value: "keep" }]],
26
+ feedbackEntries: [["old", { markdown: "stale generated content" }]],
27
+ }));
28
+ storage.setItem(key(3), JSON.stringify({
29
+ annotations: [],
30
+ generalNote: "",
31
+ responses: [["new-choice", { value: null }]],
32
+ feedbackEntries: [["new", { markdown: "generated content" }]],
33
+ }));
34
+
35
+ const sourceRevision = carryUnsubmittedDraft(storage, sessionId, 3, [
36
+ { revision: 1, hasFeedback: true },
37
+ { revision: 2, hasFeedback: false },
38
+ { revision: 3, hasFeedback: false },
39
+ ]);
40
+
41
+ expect(sourceRevision).toBe(2);
42
+ expect(JSON.parse(storage.getItem(key(3)) ?? "")).toEqual({
43
+ annotations: [{ id: "annotation-1" }],
44
+ generalNote: "Review in progress",
45
+ responses: [["choice", { value: "keep" }]],
46
+ feedbackEntries: [],
47
+ });
48
+ });
49
+
50
+ test("does not cross a submitted revision", () => {
51
+ const storage = new MemoryStorage();
52
+ storage.setItem(key(1), JSON.stringify({
53
+ annotations: [{ id: "stale-annotation" }],
54
+ generalNote: "",
55
+ responses: [],
56
+ }));
57
+
58
+ expect(carryUnsubmittedDraft(storage, sessionId, 3, [
59
+ { revision: 1, hasFeedback: false },
60
+ { revision: 2, hasFeedback: true },
61
+ { revision: 3, hasFeedback: false },
62
+ ])).toBeNull();
63
+ expect(storage.getItem(key(3))).toBeNull();
64
+ });
65
+
66
+ test("does not overwrite meaningful target data", () => {
67
+ const storage = new MemoryStorage();
68
+ storage.setItem(key(2), JSON.stringify({
69
+ annotations: [{ id: "old-annotation" }],
70
+ generalNote: "",
71
+ responses: [],
72
+ }));
73
+ const current = JSON.stringify({
74
+ annotations: [{ id: "current-annotation" }],
75
+ generalNote: "",
76
+ responses: [],
77
+ });
78
+ storage.setItem(key(3), current);
79
+
80
+ expect(carryUnsubmittedDraft(storage, sessionId, 3, [
81
+ { revision: 2, hasFeedback: false },
82
+ { revision: 3, hasFeedback: false },
83
+ ])).toBeNull();
84
+ expect(storage.getItem(key(3))).toBe(current);
85
+ });
86
+
87
+ test("skips empty intermediate revision state", () => {
88
+ const storage = new MemoryStorage();
89
+ storage.setItem(key(2), JSON.stringify({
90
+ annotations: [],
91
+ generalNote: "Still drafting",
92
+ responses: [],
93
+ }));
94
+ storage.setItem(key(3), JSON.stringify({
95
+ annotations: [],
96
+ generalNote: "",
97
+ responses: [["choice", { value: [] }]],
98
+ feedbackEntries: [["generated", { markdown: "not user input" }]],
99
+ }));
100
+
101
+ expect(carryUnsubmittedDraft(storage, sessionId, 4, [
102
+ { revision: 1, hasFeedback: true },
103
+ { revision: 2, hasFeedback: false },
104
+ { revision: 3, hasFeedback: false },
105
+ { revision: 4, hasFeedback: false },
106
+ ])).toBe(2);
107
+ });
108
+
109
+ test("does not resurrect a carried draft after it is cleared", () => {
110
+ const storage = new MemoryStorage();
111
+ storage.setItem(key(2), JSON.stringify({
112
+ annotations: [{ id: "annotation-1" }],
113
+ generalNote: "",
114
+ responses: [],
115
+ }));
116
+ const revisions = [
117
+ { revision: 1, hasFeedback: true },
118
+ { revision: 2, hasFeedback: false },
119
+ { revision: 3, hasFeedback: false },
120
+ ];
121
+
122
+ expect(carryUnsubmittedDraft(storage, sessionId, 3, revisions)).toBe(2);
123
+ const cleared = JSON.stringify({ annotations: [], generalNote: "", responses: [] });
124
+ storage.setItem(key(3), cleared);
125
+
126
+ expect(carryUnsubmittedDraft(storage, sessionId, 3, revisions)).toBeNull();
127
+ expect(storage.getItem(key(3))).toBe(cleared);
128
+ expect(carryUnsubmittedDraft(storage, sessionId, 4, [
129
+ ...revisions,
130
+ { revision: 4, hasFeedback: false },
131
+ ])).toBeNull();
132
+ expect(storage.getItem(key(4))).toBeNull();
133
+ });
134
+ });
@@ -0,0 +1,95 @@
1
+ interface RevisionState {
2
+ revision: number;
3
+ hasFeedback: boolean;
4
+ }
5
+
6
+ interface AnnotationStorage {
7
+ getItem(key: string): string | null;
8
+ setItem(key: string, value: string): void;
9
+ }
10
+
11
+ function storageKey(sessionId: string, revision: number): string {
12
+ return `canvas:${sessionId}:rev:${revision}`;
13
+ }
14
+
15
+ function handledKey(sessionId: string, revision: number): string {
16
+ return `${storageKey(sessionId, revision)}:draft-handled`;
17
+ }
18
+
19
+ function parseState(raw: string | null): object | null {
20
+ if (!raw) return null;
21
+ try {
22
+ const value: unknown = JSON.parse(raw);
23
+ return typeof value === "object" && value !== null && !Array.isArray(value)
24
+ ? value
25
+ : null;
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+
31
+ function hasResponseValue(entry: unknown): boolean {
32
+ if (!Array.isArray(entry) || entry.length < 2) return false;
33
+ const response: unknown = entry[1];
34
+ if (typeof response !== "object" || response === null || Array.isArray(response)) return false;
35
+
36
+ const note = Reflect.get(response, "note");
37
+ if (typeof note === "string" && note.trim().length > 0) return true;
38
+
39
+ const value = Reflect.get(response, "value");
40
+ if (typeof value === "string") return value.trim().length > 0;
41
+ if (Array.isArray(value)) return value.length > 0;
42
+ return value !== null && value !== undefined;
43
+ }
44
+
45
+ function hasDraftContent(state: object | null): boolean {
46
+ if (!state) return false;
47
+ const annotations = Reflect.get(state, "annotations");
48
+ if (Array.isArray(annotations) && annotations.length > 0) return true;
49
+
50
+ const generalNote = Reflect.get(state, "generalNote");
51
+ if (typeof generalNote === "string" && generalNote.trim().length > 0) return true;
52
+
53
+ const responses = Reflect.get(state, "responses");
54
+ return Array.isArray(responses) && responses.some(hasResponseValue);
55
+ }
56
+
57
+ /** Carry an unsent draft forward without crossing submitted revisions. */
58
+ export function carryUnsubmittedDraft(
59
+ storage: AnnotationStorage,
60
+ sessionId: string,
61
+ targetRevision: number,
62
+ revisions: RevisionState[],
63
+ ): number | null {
64
+ try {
65
+ const targetKey = storageKey(sessionId, targetRevision);
66
+ if (hasDraftContent(parseState(storage.getItem(targetKey)))) {
67
+ storage.setItem(handledKey(sessionId, targetRevision), "1");
68
+ return null;
69
+ }
70
+ if (storage.getItem(handledKey(sessionId, targetRevision)) !== null) return null;
71
+
72
+ const previous = revisions
73
+ .filter((revision) => revision.revision < targetRevision)
74
+ .sort((left, right) => right.revision - left.revision);
75
+
76
+ for (const revision of previous) {
77
+ if (revision.hasFeedback) return null;
78
+
79
+ const source = parseState(storage.getItem(storageKey(sessionId, revision.revision)));
80
+ if (!hasDraftContent(source)) {
81
+ if (storage.getItem(handledKey(sessionId, revision.revision)) !== null) return null;
82
+ continue;
83
+ }
84
+
85
+ storage.setItem(targetKey, JSON.stringify({
86
+ ...source,
87
+ feedbackEntries: [],
88
+ }));
89
+ storage.setItem(handledKey(sessionId, targetRevision), "1");
90
+ return revision.revision;
91
+ }
92
+ } catch {}
93
+
94
+ return null;
95
+ }