From 805cc126784d3d50162779f6babf568e7bb1f1c0 Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Mon, 20 Jul 2026 11:59:13 -0400 Subject: [PATCH 1/6] feat(canvas): edit canvases as scratch files via canvas_checkout/publish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Canvas generation previously required the agent to regenerate and emit the entire React source every turn. Add a canvas_checkout / canvas_publish local tool pair (available to both the Claude and Codex adapters) that moves the file I/O tool-side: checkout writes the live source to a scratch path and records the base version, the agent applies targeted edits with its native file tools, and publish reads the file from disk and appends a version — refusing when the canvas moved past the checkout base (concurrent edit or undo) instead of clobbering it. The generation prompt now routes through checkout → edit → publish and no longer embeds the current source. Storage stays wholesale full-file snapshots; a server-side base_version check on the desktop-fs PATCH remains a follow-up. Generated-By: PostHog Code Task-Id: 2b3d176e-3023-41d2-92e8-81eda7c12a8c --- .../agent/src/adapters/local-tools/index.ts | 3 + .../adapters/local-tools/tools/canvas.test.ts | 132 ++++++++ .../src/adapters/local-tools/tools/canvas.ts | 294 ++++++++++++++++++ packages/agent/src/posthog-api.ts | 37 +++ packages/ui/src/features/canvas/AGENTS.md | 13 +- .../features/canvas/freeformPrompt.test.ts | 36 ++- .../ui/src/features/canvas/freeformPrompt.ts | 73 ++++- 7 files changed, 566 insertions(+), 22 deletions(-) create mode 100644 packages/agent/src/adapters/local-tools/tools/canvas.test.ts create mode 100644 packages/agent/src/adapters/local-tools/tools/canvas.ts diff --git a/packages/agent/src/adapters/local-tools/index.ts b/packages/agent/src/adapters/local-tools/index.ts index c2aab7139d..716bbb478e 100644 --- a/packages/agent/src/adapters/local-tools/index.ts +++ b/packages/agent/src/adapters/local-tools/index.ts @@ -1,4 +1,5 @@ import type { LocalTool, LocalToolCtx, LocalToolGateMeta } from "./registry"; +import { canvasCheckoutTool, canvasPublishTool } from "./tools/canvas"; import { cloneRepoTool } from "./tools/clone-repo"; import { listReposTool } from "./tools/list-repos"; import { signedCommitTool } from "./tools/signed-commit"; @@ -23,6 +24,8 @@ export const LOCAL_TOOLS: LocalTool[] = [ listReposTool, cloneRepoTool, speakTool, + canvasCheckoutTool, + canvasPublishTool, ]; /** Tools whose gate passes for the given context — the set to actually expose. */ diff --git a/packages/agent/src/adapters/local-tools/tools/canvas.test.ts b/packages/agent/src/adapters/local-tools/tools/canvas.test.ts new file mode 100644 index 0000000000..bf563c6c5c --- /dev/null +++ b/packages/agent/src/adapters/local-tools/tools/canvas.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "vitest"; +import { + canvasScratchDir, + canvasScratchFile, + composePublishedMeta, +} from "./canvas"; + +describe("canvas scratch paths", () => { + it("keys the scratch dir and file by canvas id, outside any workspace", () => { + expect(canvasScratchDir("dash-1")).toBe("/tmp/posthog-canvas/dash-1"); + expect(canvasScratchFile("dash-1")).toBe( + "/tmp/posthog-canvas/dash-1/canvas.tsx", + ); + }); +}); + +describe("composePublishedMeta", () => { + const v1 = { id: "v1", code: "one", createdAt: 1 }; + const v2 = { id: "v2", code: "two", createdAt: 2 }; + const v3 = { id: "v3", code: "three", createdAt: 3 }; + + it("appends a new head version when the base matches", () => { + const result = composePublishedMeta({ + freshMeta: { code: "two", versions: [v1, v2], currentVersionId: "v2" }, + baseVersionId: "v2", + code: "edited", + prompt: "tweak the chart", + now: 10, + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.meta.code).toBe("edited"); + expect(result.meta.currentVersionId).toBe(result.versionId); + expect(result.meta.versions).toHaveLength(3); + expect(result.meta.versions?.at(-1)).toMatchObject({ + code: "edited", + prompt: "tweak the chart", + createdAt: 10, + }); + expect(result.meta.updatedAt).toBe(10); + }); + + it("preserves unrelated meta keys", () => { + const result = composePublishedMeta({ + freshMeta: { + code: "one", + versions: [v1], + currentVersionId: "v1", + templateId: "freeform", + pinnedAt: 123, + }, + baseVersionId: "v1", + code: "edited", + now: 10, + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.meta.templateId).toBe("freeform"); + expect(result.meta.pinnedAt).toBe(123); + }); + + it("rejects when the canvas moved past the base (concurrent edit)", () => { + const result = composePublishedMeta({ + freshMeta: { + code: "three", + versions: [v1, v2, v3], + currentVersionId: "v3", + }, + baseVersionId: "v2", + code: "edited", + now: 10, + }); + expect(result.ok).toBe(false); + }); + + it("rejects a based publish onto a canvas with no versions", () => { + const result = composePublishedMeta({ + freshMeta: {}, + baseVersionId: "v1", + code: "edited", + now: 10, + }); + expect(result.ok).toBe(false); + }); + + it("truncates the redo tail when publishing from an undone version", () => { + // The user undid to v1 (pointer mid-history), then the agent edited from + // that checkout: the redo tail (v2, v3) is discarded — the same + // linear-discard the client's undo/redo uses. + const result = composePublishedMeta({ + freshMeta: { + code: "one", + versions: [v1, v2, v3], + currentVersionId: "v1", + }, + baseVersionId: "v1", + code: "edited", + now: 10, + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.meta.versions?.map((v) => v.id)).toEqual([ + "v1", + result.versionId, + ]); + }); + + it("seeds an empty canvas as the first version (no base)", () => { + const result = composePublishedMeta({ + freshMeta: { templateId: "freeform" }, + baseVersionId: undefined, + code: "first build", + now: 10, + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.meta.versions).toHaveLength(1); + expect(result.meta.currentVersionId).toBe(result.versionId); + expect(result.meta.code).toBe("first build"); + }); + + it("rejects an un-based publish onto a canvas that gained versions", () => { + // Checked out empty, but a concurrent first build published meanwhile. + const result = composePublishedMeta({ + freshMeta: { code: "one", versions: [v1], currentVersionId: "v1" }, + baseVersionId: undefined, + code: "edited", + now: 10, + }); + expect(result.ok).toBe(false); + }); +}); diff --git a/packages/agent/src/adapters/local-tools/tools/canvas.ts b/packages/agent/src/adapters/local-tools/tools/canvas.ts new file mode 100644 index 0000000000..ee0465fc1e --- /dev/null +++ b/packages/agent/src/adapters/local-tools/tools/canvas.ts @@ -0,0 +1,294 @@ +import { randomUUID } from "node:crypto"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import * as path from "node:path"; +import { z } from "zod"; +import { PostHogAPIClient } from "../../../posthog-api"; +import { resolveSandboxPosthogApi } from "../../../signed-commit-artefacts"; +import { defineLocalTool, type LocalToolResult } from "../registry"; + +/** + * Local tools for working on freeform canvases (desktop-fs `dashboard` rows) + * as scratch files, so the agent edits the source incrementally with its + * native file tools instead of regenerating (or transcribing) the whole file: + * + * - `canvas_checkout` fetches the canvas, writes `meta.code` to a + * deterministic scratch path tool-side (no model transcription), and stashes + * the fetched `currentVersionId` as the publish-time concurrency base. + * - `canvas_publish` reads the scratch file from disk (again, no + * transcription), verifies the canvas hasn't moved past the stashed base + * (a concurrent edit or user undo), appends a full-file version snapshot, + * and PATCHes the merged meta — mirroring `dashboardsService.saveFreeform` + * in `@posthog/core`, including the linear-discard of any redo tail. + * + * The check-then-PATCH guard is tool-side and therefore not atomic; it shrinks + * the clobber window from "the whole generation turn" to milliseconds. A + * server-side `base_version` check on the desktop-fs PATCH remains the + * follow-up that closes it completely. + * + * Credentials come from the sandbox environment (see + * `resolveSandboxPosthogApi`), so this works identically from the Claude + * in-process server and the Codex stdio child. + */ + +// Deliberately outside any workspace: scratch files never show up in +// changed-file diff panels or `git status` (a canvas task can lazily attach a +// repo), and the canvas id keeps concurrent generations from colliding. +export const CANVAS_SCRATCH_ROOT = "/tmp/posthog-canvas"; + +export function canvasScratchDir(canvasId: string): string { + return path.join(CANVAS_SCRATCH_ROOT, canvasId); +} + +export function canvasScratchFile(canvasId: string): string { + return path.join(canvasScratchDir(canvasId), "canvas.tsx"); +} + +function baseVersionMarkerFile(canvasId: string): string { + return path.join(canvasScratchDir(canvasId), ".base-version.json"); +} + +interface CanvasVersion { + id: string; + code: string; + context?: string; + prompt?: string; + createdAt: number; +} + +interface CanvasMeta { + code?: string; + versions?: CanvasVersion[]; + currentVersionId?: string; + updatedAt?: number; + [key: string]: unknown; +} + +interface CanvasFsEntry { + id: string; + path: string; + type?: string; + meta?: CanvasMeta | null; +} + +interface BaseVersionMarker { + versionId?: string; + fetchedAt: number; +} + +function createClient(): PostHogAPIClient | undefined { + const api = resolveSandboxPosthogApi(); + if (!api) return undefined; + return new PostHogAPIClient({ + apiUrl: api.apiUrl, + projectId: api.projectId, + getApiKey: () => api.apiKey, + }); +} + +async function fetchCanvasEntry(canvasId: string): Promise { + const client = createClient(); + if (!client) { + throw new Error("No PostHog credentials available in this session."); + } + const entry = await client.getDesktopFsEntry(canvasId); + if (!entry) { + throw new Error( + `Canvas ${canvasId} not found. Check the id — it should be a desktop-fs dashboard row id.`, + ); + } + if (entry.type !== "dashboard") { + throw new Error( + `Entry ${canvasId} is type "${entry.type}", not a canvas ("dashboard").`, + ); + } + return entry; +} + +async function patchCanvasMeta( + canvasId: string, + meta: CanvasMeta, +): Promise { + const client = createClient(); + if (!client) { + throw new Error("No PostHog credentials available in this session."); + } + await client.patchDesktopFsEntryMeta(canvasId, meta); +} + +function readMarker(canvasId: string): BaseVersionMarker | undefined { + try { + return JSON.parse( + readFileSync(baseVersionMarkerFile(canvasId), "utf8"), + ) as BaseVersionMarker; + } catch { + return undefined; + } +} + +function writeMarker(canvasId: string, marker: BaseVersionMarker): void { + mkdirSync(canvasScratchDir(canvasId), { recursive: true }); + writeFileSync(baseVersionMarkerFile(canvasId), JSON.stringify(marker)); +} + +/** + * Compose the published meta from a freshly-fetched entry: verify the base, + * truncate any redo tail past the current pointer (linear-discard, matching + * the client's undo semantics in `freeformSchemas.ts`), and append the new + * full-file snapshot. Pure — exported for tests. + */ +export function composePublishedMeta(input: { + freshMeta: CanvasMeta; + baseVersionId: string | undefined; + code: string; + prompt?: string; + now: number; +}): { ok: true; meta: CanvasMeta; versionId: string } | { ok: false } { + const { freshMeta, baseVersionId, code, prompt, now } = input; + if ((freshMeta.currentVersionId ?? undefined) !== baseVersionId) { + return { ok: false }; + } + const versions = freshMeta.versions ?? []; + const pointer = baseVersionId + ? versions.findIndex((v) => v.id === baseVersionId) + : -1; + const kept = pointer >= 0 ? versions.slice(0, pointer + 1) : []; + const version: CanvasVersion = { + id: randomUUID(), + code, + ...(prompt ? { prompt } : {}), + createdAt: now, + }; + return { + ok: true, + versionId: version.id, + meta: { + ...freshMeta, + code, + versions: [...kept, version], + currentVersionId: version.id, + updatedAt: now, + }, + }; +} + +function errorResult(text: string): LocalToolResult { + return { content: [{ type: "text", text }], isError: true }; +} + +const CONFLICT_MESSAGE = (canvasId: string) => + `version-conflict: the canvas changed since your checkout (a concurrent edit, or the user's undo). ` + + `Recover: call canvas_checkout with id "${canvasId}" again (it re-seeds the scratch file from the live source), ` + + `re-apply your edits to it, then call canvas_publish again.`; + +export const canvasCheckoutTool = defineLocalTool({ + name: "canvas_checkout", + description: + "Check out a PostHog canvas (a freeform React desktop-fs dashboard) for editing: fetches the live " + + "source, writes it to a local scratch file, and records the version your edits are based on. " + + "Returns the file path. Edit that file with your normal file-editing tools (targeted edits — do not " + + "regenerate it wholesale), then call canvas_publish to save. Always start canvas work with this tool.", + schema: { + id: z.string().describe("The canvas (desktop-fs dashboard row) id."), + }, + alwaysLoad: true, + isEnabled: () => resolveSandboxPosthogApi() !== undefined, + handler: async (_ctx, args): Promise => { + try { + const entry = await fetchCanvasEntry(args.id); + const file = canvasScratchFile(args.id); + const code = entry.meta?.code ?? ""; + mkdirSync(canvasScratchDir(args.id), { recursive: true }); + writeFileSync(file, code); + writeMarker(args.id, { + versionId: entry.meta?.currentVersionId, + fetchedAt: Date.now(), + }); + const lines = code ? code.split("\n").length : 0; + const text = code + ? `Checked out canvas "${entry.path}" to ${file} (${lines} lines, base version ${ + entry.meta?.currentVersionId ?? "none" + }).\nApply your changes by EDITING that file (targeted edits), then call canvas_publish with id "${args.id}".` + : `Canvas "${entry.path}" is empty. Author the complete single-file React app at ${file}, then call canvas_publish with id "${args.id}".`; + return { content: [{ type: "text", text }] }; + } catch (err) { + return errorResult( + `canvas_checkout failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + }, +}); + +export const canvasPublishTool = defineLocalTool({ + name: "canvas_publish", + description: + "Publish the checked-out canvas: reads the scratch file written by canvas_checkout, verifies the " + + "canvas hasn't changed since checkout, and saves the file as the canvas's new live version. Call " + + "exactly once when the edit is complete. On a version-conflict error, re-run canvas_checkout, " + + "re-apply your edits, and publish again.", + schema: { + id: z.string().describe("The canvas (desktop-fs dashboard row) id."), + prompt: z + .string() + .optional() + .describe( + "One short sentence describing the change, stored on the version history entry.", + ), + }, + alwaysLoad: true, + isEnabled: () => resolveSandboxPosthogApi() !== undefined, + handler: async (_ctx, args): Promise => { + let code: string; + try { + code = readFileSync(canvasScratchFile(args.id), "utf8"); + } catch { + return errorResult( + `canvas_publish failed: no scratch file for canvas "${args.id}". Call canvas_checkout first, edit the file it returns, then publish.`, + ); + } + if (!code.trim()) { + return errorResult( + `canvas_publish failed: the scratch file for canvas "${args.id}" is empty.`, + ); + } + const marker = readMarker(args.id); + if (!marker) { + return errorResult( + `canvas_publish failed: no checkout record for canvas "${args.id}". Call canvas_checkout first.`, + ); + } + try { + const fresh = await fetchCanvasEntry(args.id); + const composed = composePublishedMeta({ + freshMeta: fresh.meta ?? {}, + baseVersionId: marker.versionId, + code, + prompt: args.prompt, + now: Date.now(), + }); + if (!composed.ok) { + return errorResult( + `canvas_publish failed: ${CONFLICT_MESSAGE(args.id)}`, + ); + } + await patchCanvasMeta(args.id, composed.meta); + // Advance the base so a follow-up publish in the same session works + // without a re-checkout. + writeMarker(args.id, { + versionId: composed.versionId, + fetchedAt: Date.now(), + }); + return { + content: [ + { + type: "text", + text: `Published canvas "${args.id}" (new version ${composed.versionId}). The canvas is live; do not paste the code into chat.`, + }, + ], + }; + } catch (err) { + return errorResult( + `canvas_publish failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + }, +}); diff --git a/packages/agent/src/posthog-api.ts b/packages/agent/src/posthog-api.ts index b3596fe424..65421a6b91 100644 --- a/packages/agent/src/posthog-api.ts +++ b/packages/agent/src/posthog-api.ts @@ -338,6 +338,43 @@ export class PostHogAPIClient { .filter((artifact): artifact is TaskRunArtifact => !!artifact); } + /** + * Fetch a desktop file system entry (e.g. a canvas "dashboard" row) by id. + * Returns null on 404 so callers can produce a friendly not-found message. + */ + async getDesktopFsEntry(entryId: string): Promise { + const teamId = this.getTeamId(); + const response = await this.performRequestWithRetry( + `/api/projects/${teamId}/desktop_file_system/${encodeURIComponent(entryId)}/`, + ); + if (response.status === 404) { + return null; + } + if (!response.ok) { + throw new Error(`Failed to fetch desktop-fs entry (${response.status})`); + } + return response.json() as Promise; + } + + /** + * PATCH a desktop file system entry's meta blob (the endpoint stores the + * sent meta verbatim, so callers merge into the previously-fetched meta — + * the same read-modify-write contract as `dashboardsService` in core). + */ + async patchDesktopFsEntryMeta( + entryId: string, + meta: Record, + ): Promise { + const teamId = this.getTeamId(); + return this.apiRequest( + `/api/projects/${teamId}/desktop_file_system/${encodeURIComponent(entryId)}/`, + { + method: "PATCH", + body: JSON.stringify({ meta }), + }, + ); + } + /** Signal reports the given task is associated with (via report task associations). */ async getSignalReportIdsForTask(taskId: string): Promise { const teamId = this.getTeamId(); diff --git a/packages/ui/src/features/canvas/AGENTS.md b/packages/ui/src/features/canvas/AGENTS.md index 0ea928dfbf..ba42a82f2b 100644 --- a/packages/ui/src/features/canvas/AGENTS.md +++ b/packages/ui/src/features/canvas/AGENTS.md @@ -76,9 +76,16 @@ The root `AGENTS.md` architecture rules still apply. and channel names in sync with the backend — the same surface that owns channels (top-level `folder` rows, see `hooks/useChannels.ts`). - `meta` is **last-write-wins, unversioned** at the fs layer (no `base_version`). - Freeform autosaves the whole file each agent turn, so a concurrent edit from - another client can clobber. Acceptable for now; revisit with optimistic - concurrency if multi-client editing becomes real. + The **agent publish path is guarded**: generation works the source as a local + scratch file via the `canvas_checkout` / `canvas_publish` local tools + (`@posthog/agent`, `adapters/local-tools/tools/canvas.ts`) — checkout records + the fetched `currentVersionId`, and publish re-fetches and refuses when the + canvas moved past that base (a concurrent edit or undo), instead of + clobbering it. The check is tool-side (check-then-PATCH, not atomic); a + server-side `base_version` check on the fs PATCH is the follow-up that closes + it completely. **User-side saves** (`saveFreeform`) are still last-write-wins; + revisit with the same optimistic concurrency if multi-client editing becomes + real. ## Channel sidebar preloading diff --git a/packages/ui/src/features/canvas/freeformPrompt.test.ts b/packages/ui/src/features/canvas/freeformPrompt.test.ts index 647f4cb465..cb7b3fdae2 100644 --- a/packages/ui/src/features/canvas/freeformPrompt.test.ts +++ b/packages/ui/src/features/canvas/freeformPrompt.test.ts @@ -21,19 +21,49 @@ describe("buildFreeformGenerationPrompt", () => { expect(extracted?.stripped).toBe("add a retention chart"); // The authoring contract + publishing rules are collapsed into the tag body. expect(extracted?.body).toContain("PUBLISHING"); + expect(extracted?.body).toContain("canvas_publish"); + }); + + it("routes all builds through checkout → edit file → publish", () => { + const prompt = buildFreeformGenerationPrompt(base); + const extracted = extractCanvasInstructions(prompt); + expect(extracted?.body).toContain("Build a freeform React canvas"); + expect(extracted?.body).toContain("canvas_checkout"); + expect(extracted?.body).toContain(`id: "dash-1"`); + // Publishing goes through the local tool; the remote MCP publish tool is + // named only to steer the agent away from it. + expect(extracted?.body).toContain("canvas_publish"); expect(extracted?.body).toContain( - "desktop-file-system-canvas-partial-update", + "`desktop-file-system-canvas-partial-update` directly", ); }); - it("folds the current code into the tag when editing", () => { + it("has edits work the scratch file instead of embedding the source", () => { const prompt = buildFreeformGenerationPrompt({ ...base, currentCode: "export const App = () => null;", }); const extracted = extractCanvasInstructions(prompt); expect(extracted?.stripped).toBe("add a retention chart"); - expect(extracted?.body).toContain("export const App = () => null;"); expect(extracted?.body).toContain("Edit the freeform React canvas"); + // The source is no longer folded into the prompt — canvas_checkout fetches + // the live code tool-side and the agent edits the scratch file in place. + expect(extracted?.body).not.toContain("export const App = () => null;"); + expect(extracted?.body).toContain("canvas_checkout"); + expect(extracted?.body).toContain("TARGETED edits"); + // The stale-publish recovery loop is spelled out. + expect(extracted?.body).toContain("version-conflict"); + }); + + it("seeds the starter scaffold into the checked-out file on a first build", () => { + const prompt = buildFreeformGenerationPrompt({ + ...base, + useStarter: true, + }); + const extracted = extractCanvasInstructions(prompt); + expect(extracted?.body).toContain("[Starter scaffold]"); + expect(extracted?.body).toContain("canvas_checkout"); + // The scaffold rides in the prompt (there is nothing to fetch yet). + expect(extracted?.body).toContain("```tsx"); }); }); diff --git a/packages/ui/src/features/canvas/freeformPrompt.ts b/packages/ui/src/features/canvas/freeformPrompt.ts index df252d8ed6..6690362ab3 100644 --- a/packages/ui/src/features/canvas/freeformPrompt.ts +++ b/packages/ui/src/features/canvas/freeformPrompt.ts @@ -7,16 +7,24 @@ import { FREEFORM_STARTER_CODE } from "@posthog/shared/canvas-freeform-starter"; // authoring contract // (imports, the `ph` data shim, Quill/style rules) therefore has to live in the // task's content (its first user message). The canvas is not a file on disk — it -// lives in PostHog — so the agent publishes the result via the PostHog MCP tool -// `desktop-file-system-canvas-partial-update` rather than replying with code or -// writing a file. +// lives in PostHog — but the agent WORKS on it as a local scratch file through +// the `canvas_checkout` / `canvas_publish` local tools (posthog-code-tools): +// checkout writes the live source to a scratch path tool-side and records the +// base version; the agent applies the change with its native file-editing tools +// (targeted edits, not a full regeneration — and no transcribing the source +// through chat); publish reads the file from disk and saves it as the new +// version, rejecting a publish whose base is stale (a concurrent edit or undo) +// instead of silently clobbering it. export function buildFreeformGenerationPrompt(input: { dashboardId: string; name: string; channelName: string; templateId?: string; instruction: string; - // The current source, when editing an existing canvas. Omitted for a first build. + // Present when editing an existing canvas; omitted for a first build. Only + // its presence matters — `canvas_checkout` fetches the authoritative source + // itself (fresher than anything embedded at task-creation time), so the + // content is no longer folded into the prompt. currentCode?: string; // Default on (opt out via the generate bar): seed a known-good starter // scaffold as the agent's baseline on a FIRST build, so it edits a compiling @@ -43,8 +51,17 @@ export function buildFreeformGenerationPrompt(input: { ? `Edit the freeform React canvas "${name}" in the channel "${channelName}", per the user's request at the start of this message.` : `Build a freeform React canvas "${name}" for the channel "${channelName}", per the user's request at the start of this message.`; - const currentBlock = isEdit - ? `\n[Current code] — the canvas as it stands now. Rewrite the WHOLE file with the change applied; do not output a partial file.\n\n\`\`\`tsx\n${currentCode}\n\`\`\`\n` + const checkoutStep = ` +[Working copy] — FIRST, check out the canvas: call the \`canvas_checkout\` tool +(posthog-code-tools) with id "${dashboardId}". It writes the canvas's live +source to a local scratch file and returns the path.`; + + const editStep = isEdit + ? ` +Then apply the user's request by EDITING that file with your file-editing +tools. Make TARGETED edits — do not rewrite the whole file for a small change, +and do not paste the source into chat. +` : ""; // First-build only: hand the agent a working scaffold to build ON instead of @@ -52,7 +69,26 @@ export function buildFreeformGenerationPrompt(input: { // picker, theme tokens, loading skeletons, typed-node result reading). const starterBlock = !isEdit && useStarter - ? `\n[Starter scaffold] — begin from this WORKING baseline instead of authoring from scratch. It already wires the things that are easy to get wrong: the date picker, theme-aware tokens, per-card loading skeletons, and reading a typed-node result correctly. KEEP that wiring; replace the sample "total events" metric and the layout with what the user asked for, and output the COMPLETE rewritten file.\n\n\`\`\`tsx\n${FREEFORM_STARTER_CODE}\n\`\`\`\n` + ? ` +[Starter scaffold] — the canvas is empty, so write this WORKING baseline to the +checked-out path, then build by EDITING that file. It already wires the things +that are easy to get wrong: the date picker, theme-aware tokens, per-card +loading skeletons, and reading a typed-node result correctly. KEEP that wiring; +replace the sample "total events" metric and the layout with what the user +asked for. + +\`\`\`tsx +${FREEFORM_STARTER_CODE} +\`\`\` +` + : ""; + + const freshBuildStep = + !isEdit && !useStarter + ? ` +The canvas is empty: author the complete app at the checked-out path and +iterate on it there with your file-editing tools. +` : ""; // The standing authoring contract + publishing/data rules are the same @@ -62,22 +98,27 @@ export function buildFreeformGenerationPrompt(input: { // inline (see extractCanvasInstructions). Kept after the user's instruction so // the request leads, mirroring how channel CONTEXT.md is appended. const instructions = `${header} -${currentBlock}${starterBlock} +${checkoutStep}${editStep}${starterBlock}${freshBuildStep} Follow this authoring contract for the canvas (imports, the \`ph\` data shim, and style rules): ${contract} -PUBLISHING — this OVERRIDES any instruction above about replying with the code in -a fenced \`\`\`tsx block. In this task you do NOT reply with the code. When the -canvas is ready, PUBLISH it by calling the PostHog MCP tool -\`desktop-file-system-canvas-partial-update\` exactly once with: +PUBLISHING — the scratch file is only your working copy; the canvas lives in +PostHog. When it is ready, call the \`canvas_publish\` tool (posthog-code-tools) +exactly once with: - id: "${dashboardId}" -- code: the COMPLETE single-file React source for the canvas. +- prompt: one short sentence describing the change. + +It reads the scratch file from disk and saves it as the canvas's new version — +do not paste the code into the tool call or into chat, and do not call +\`desktop-file-system-canvas-partial-update\` directly. If it fails with a +version-conflict error, the canvas changed while you worked (another edit, or +the user's undo): call \`canvas_checkout\` again, re-apply your edits to the +re-seeded file, then publish again. -The canvas lives in PostHog, not on disk — calling that MCP tool is what saves it. -Do not write a local file. Verify event/property names via the PostHog MCP before -using them, and operate only on this project. +Verify event/property names via the PostHog MCP before using them, and operate +only on this project. DATA — for each metric, first SAVE an insight via the PostHog MCP insight tools (prefer an insight query type — Trends, Funnels, Retention, web-analytics kinds — From 4d4604928c7ee5b07f13c0b684da79bd44d34bd5 Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Mon, 20 Jul 2026 12:21:36 -0400 Subject: [PATCH 2/6] refactor(canvas): delegate publish composition to the desktop-fs canvas action canvas_publish now calls the desktop-fs canvas action, which owns version composition server-side, passing the checkout's version as expected_current_version_id so a stale publish is rejected atomically (409 version_conflict) instead of guarded by a non-atomic tool-side check-then-PATCH. Deletes the tool-side compose logic and the raw meta PATCH; backends predating the guard field ignore it and publish unguarded, so the tool degrades gracefully. Server side: PostHog/posthog#72365. Generated-By: PostHog Code Task-Id: 2b3d176e-3023-41d2-92e8-81eda7c12a8c --- .../adapters/local-tools/tools/canvas.test.ts | 126 +----------------- .../src/adapters/local-tools/tools/canvas.ts | 120 ++++------------- packages/agent/src/posthog-api.ts | 58 ++++++-- packages/ui/src/features/canvas/AGENTS.md | 24 ++-- 4 files changed, 93 insertions(+), 235 deletions(-) diff --git a/packages/agent/src/adapters/local-tools/tools/canvas.test.ts b/packages/agent/src/adapters/local-tools/tools/canvas.test.ts index bf563c6c5c..7cf32f9e2c 100644 --- a/packages/agent/src/adapters/local-tools/tools/canvas.test.ts +++ b/packages/agent/src/adapters/local-tools/tools/canvas.test.ts @@ -1,10 +1,9 @@ import { describe, expect, it } from "vitest"; -import { - canvasScratchDir, - canvasScratchFile, - composePublishedMeta, -} from "./canvas"; +import { canvasScratchDir, canvasScratchFile } from "./canvas"; +// Version composition and the stale-base rejection live server-side in the +// desktop-fs canvas action (and are tested there); the tool's own logic is +// the scratch-file plumbing. describe("canvas scratch paths", () => { it("keys the scratch dir and file by canvas id, outside any workspace", () => { expect(canvasScratchDir("dash-1")).toBe("/tmp/posthog-canvas/dash-1"); @@ -13,120 +12,3 @@ describe("canvas scratch paths", () => { ); }); }); - -describe("composePublishedMeta", () => { - const v1 = { id: "v1", code: "one", createdAt: 1 }; - const v2 = { id: "v2", code: "two", createdAt: 2 }; - const v3 = { id: "v3", code: "three", createdAt: 3 }; - - it("appends a new head version when the base matches", () => { - const result = composePublishedMeta({ - freshMeta: { code: "two", versions: [v1, v2], currentVersionId: "v2" }, - baseVersionId: "v2", - code: "edited", - prompt: "tweak the chart", - now: 10, - }); - expect(result.ok).toBe(true); - if (!result.ok) return; - expect(result.meta.code).toBe("edited"); - expect(result.meta.currentVersionId).toBe(result.versionId); - expect(result.meta.versions).toHaveLength(3); - expect(result.meta.versions?.at(-1)).toMatchObject({ - code: "edited", - prompt: "tweak the chart", - createdAt: 10, - }); - expect(result.meta.updatedAt).toBe(10); - }); - - it("preserves unrelated meta keys", () => { - const result = composePublishedMeta({ - freshMeta: { - code: "one", - versions: [v1], - currentVersionId: "v1", - templateId: "freeform", - pinnedAt: 123, - }, - baseVersionId: "v1", - code: "edited", - now: 10, - }); - expect(result.ok).toBe(true); - if (!result.ok) return; - expect(result.meta.templateId).toBe("freeform"); - expect(result.meta.pinnedAt).toBe(123); - }); - - it("rejects when the canvas moved past the base (concurrent edit)", () => { - const result = composePublishedMeta({ - freshMeta: { - code: "three", - versions: [v1, v2, v3], - currentVersionId: "v3", - }, - baseVersionId: "v2", - code: "edited", - now: 10, - }); - expect(result.ok).toBe(false); - }); - - it("rejects a based publish onto a canvas with no versions", () => { - const result = composePublishedMeta({ - freshMeta: {}, - baseVersionId: "v1", - code: "edited", - now: 10, - }); - expect(result.ok).toBe(false); - }); - - it("truncates the redo tail when publishing from an undone version", () => { - // The user undid to v1 (pointer mid-history), then the agent edited from - // that checkout: the redo tail (v2, v3) is discarded — the same - // linear-discard the client's undo/redo uses. - const result = composePublishedMeta({ - freshMeta: { - code: "one", - versions: [v1, v2, v3], - currentVersionId: "v1", - }, - baseVersionId: "v1", - code: "edited", - now: 10, - }); - expect(result.ok).toBe(true); - if (!result.ok) return; - expect(result.meta.versions?.map((v) => v.id)).toEqual([ - "v1", - result.versionId, - ]); - }); - - it("seeds an empty canvas as the first version (no base)", () => { - const result = composePublishedMeta({ - freshMeta: { templateId: "freeform" }, - baseVersionId: undefined, - code: "first build", - now: 10, - }); - expect(result.ok).toBe(true); - if (!result.ok) return; - expect(result.meta.versions).toHaveLength(1); - expect(result.meta.currentVersionId).toBe(result.versionId); - expect(result.meta.code).toBe("first build"); - }); - - it("rejects an un-based publish onto a canvas that gained versions", () => { - // Checked out empty, but a concurrent first build published meanwhile. - const result = composePublishedMeta({ - freshMeta: { code: "one", versions: [v1], currentVersionId: "v1" }, - baseVersionId: undefined, - code: "edited", - now: 10, - }); - expect(result.ok).toBe(false); - }); -}); diff --git a/packages/agent/src/adapters/local-tools/tools/canvas.ts b/packages/agent/src/adapters/local-tools/tools/canvas.ts index ee0465fc1e..2e391deea8 100644 --- a/packages/agent/src/adapters/local-tools/tools/canvas.ts +++ b/packages/agent/src/adapters/local-tools/tools/canvas.ts @@ -1,8 +1,10 @@ -import { randomUUID } from "node:crypto"; import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import * as path from "node:path"; import { z } from "zod"; -import { PostHogAPIClient } from "../../../posthog-api"; +import { + DesktopCanvasVersionConflictError, + PostHogAPIClient, +} from "../../../posthog-api"; import { resolveSandboxPosthogApi } from "../../../signed-commit-artefacts"; import { defineLocalTool, type LocalToolResult } from "../registry"; @@ -15,15 +17,11 @@ import { defineLocalTool, type LocalToolResult } from "../registry"; * deterministic scratch path tool-side (no model transcription), and stashes * the fetched `currentVersionId` as the publish-time concurrency base. * - `canvas_publish` reads the scratch file from disk (again, no - * transcription), verifies the canvas hasn't moved past the stashed base - * (a concurrent edit or user undo), appends a full-file version snapshot, - * and PATCHes the merged meta — mirroring `dashboardsService.saveFreeform` - * in `@posthog/core`, including the linear-discard of any redo tail. - * - * The check-then-PATCH guard is tool-side and therefore not atomic; it shrinks - * the clobber window from "the whole generation turn" to milliseconds. A - * server-side `base_version` check on the desktop-fs PATCH remains the - * follow-up that closes it completely. + * transcription) and publishes through the desktop-fs canvas action, which + * owns version composition server-side. The stashed base rides along as + * `expected_current_version_id`, so a publish based on a stale read (a + * concurrent edit, or a user's undo) is rejected atomically with a version + * conflict instead of clobbering the newer head. * * Credentials come from the sandbox environment (see * `resolveSandboxPosthogApi`), so this works identically from the Claude @@ -47,19 +45,9 @@ function baseVersionMarkerFile(canvasId: string): string { return path.join(canvasScratchDir(canvasId), ".base-version.json"); } -interface CanvasVersion { - id: string; - code: string; - context?: string; - prompt?: string; - createdAt: number; -} - interface CanvasMeta { code?: string; - versions?: CanvasVersion[]; currentVersionId?: string; - updatedAt?: number; [key: string]: unknown; } @@ -104,17 +92,6 @@ async function fetchCanvasEntry(canvasId: string): Promise { return entry; } -async function patchCanvasMeta( - canvasId: string, - meta: CanvasMeta, -): Promise { - const client = createClient(); - if (!client) { - throw new Error("No PostHog credentials available in this session."); - } - await client.patchDesktopFsEntryMeta(canvasId, meta); -} - function readMarker(canvasId: string): BaseVersionMarker | undefined { try { return JSON.parse( @@ -130,47 +107,6 @@ function writeMarker(canvasId: string, marker: BaseVersionMarker): void { writeFileSync(baseVersionMarkerFile(canvasId), JSON.stringify(marker)); } -/** - * Compose the published meta from a freshly-fetched entry: verify the base, - * truncate any redo tail past the current pointer (linear-discard, matching - * the client's undo semantics in `freeformSchemas.ts`), and append the new - * full-file snapshot. Pure — exported for tests. - */ -export function composePublishedMeta(input: { - freshMeta: CanvasMeta; - baseVersionId: string | undefined; - code: string; - prompt?: string; - now: number; -}): { ok: true; meta: CanvasMeta; versionId: string } | { ok: false } { - const { freshMeta, baseVersionId, code, prompt, now } = input; - if ((freshMeta.currentVersionId ?? undefined) !== baseVersionId) { - return { ok: false }; - } - const versions = freshMeta.versions ?? []; - const pointer = baseVersionId - ? versions.findIndex((v) => v.id === baseVersionId) - : -1; - const kept = pointer >= 0 ? versions.slice(0, pointer + 1) : []; - const version: CanvasVersion = { - id: randomUUID(), - code, - ...(prompt ? { prompt } : {}), - createdAt: now, - }; - return { - ok: true, - versionId: version.id, - meta: { - ...freshMeta, - code, - versions: [...kept, version], - currentVersionId: version.id, - updatedAt: now, - }, - }; -} - function errorResult(text: string): LocalToolResult { return { content: [{ type: "text", text }], isError: true }; } @@ -221,8 +157,8 @@ export const canvasCheckoutTool = defineLocalTool({ export const canvasPublishTool = defineLocalTool({ name: "canvas_publish", description: - "Publish the checked-out canvas: reads the scratch file written by canvas_checkout, verifies the " + - "canvas hasn't changed since checkout, and saves the file as the canvas's new live version. Call " + + "Publish the checked-out canvas: reads the scratch file written by canvas_checkout and saves it as " + + "the canvas's new live version, guarded against the canvas having changed since checkout. Call " + "exactly once when the edit is complete. On a version-conflict error, re-run canvas_checkout, " + "re-apply your edits, and publish again.", schema: { @@ -256,36 +192,38 @@ export const canvasPublishTool = defineLocalTool({ `canvas_publish failed: no checkout record for canvas "${args.id}". Call canvas_checkout first.`, ); } + const client = createClient(); + if (!client) { + return errorResult( + "canvas_publish failed: no PostHog credentials available in this session.", + ); + } try { - const fresh = await fetchCanvasEntry(args.id); - const composed = composePublishedMeta({ - freshMeta: fresh.meta ?? {}, - baseVersionId: marker.versionId, + const entry = await client.publishDesktopCanvas(args.id, { code, prompt: args.prompt, - now: Date.now(), + expectedCurrentVersionId: marker.versionId ?? null, }); - if (!composed.ok) { - return errorResult( - `canvas_publish failed: ${CONFLICT_MESSAGE(args.id)}`, - ); - } - await patchCanvasMeta(args.id, composed.meta); // Advance the base so a follow-up publish in the same session works // without a re-checkout. - writeMarker(args.id, { - versionId: composed.versionId, - fetchedAt: Date.now(), - }); + const newVersionId = entry.meta?.currentVersionId; + writeMarker(args.id, { versionId: newVersionId, fetchedAt: Date.now() }); return { content: [ { type: "text", - text: `Published canvas "${args.id}" (new version ${composed.versionId}). The canvas is live; do not paste the code into chat.`, + text: `Published canvas "${args.id}"${ + newVersionId ? ` (new version ${newVersionId})` : "" + }. The canvas is live; do not paste the code into chat.`, }, ], }; } catch (err) { + if (err instanceof DesktopCanvasVersionConflictError) { + return errorResult( + `canvas_publish failed: ${CONFLICT_MESSAGE(args.id)}`, + ); + } return errorResult( `canvas_publish failed: ${err instanceof Error ? err.message : String(err)}`, ); diff --git a/packages/agent/src/posthog-api.ts b/packages/agent/src/posthog-api.ts index 65421a6b91..9d5f425a66 100644 --- a/packages/agent/src/posthog-api.ts +++ b/packages/agent/src/posthog-api.ts @@ -63,6 +63,14 @@ export type TaskRunUpdate = Partial< state_remove_keys?: string[]; }; +/** A guarded canvas publish was based on a stale version (409 from the API). */ +export class DesktopCanvasVersionConflictError extends Error { + constructor(readonly currentVersionId: string | null) { + super("Canvas version conflict: the canvas changed since it was read."); + this.name = "DesktopCanvasVersionConflictError"; + } +} + export class PostHogAPIClient { private config: PostHogAPIConfig; @@ -357,22 +365,50 @@ export class PostHogAPIClient { } /** - * PATCH a desktop file system entry's meta blob (the endpoint stores the - * sent meta verbatim, so callers merge into the previously-fetched meta — - * the same read-modify-write contract as `dashboardsService` in core). + * Publish a freeform canvas's source via the desktop-fs canvas action. The + * server owns version composition (appends the full-file snapshot, moves + * `currentVersionId`, truncates any redo tail) and rejects a publish whose + * `expectedCurrentVersionId` no longer matches the live head with a 409 — + * surfaced as DesktopCanvasVersionConflictError. Backends predating the + * guard ignore the field and publish unguarded, so this degrades gracefully. */ - async patchDesktopFsEntryMeta( + async publishDesktopCanvas( entryId: string, - meta: Record, + input: { + code: string; + prompt?: string; + /** The head version the code was based on; null when it was empty. */ + expectedCurrentVersionId: string | null; + }, ): Promise { const teamId = this.getTeamId(); - return this.apiRequest( - `/api/projects/${teamId}/desktop_file_system/${encodeURIComponent(entryId)}/`, - { - method: "PATCH", - body: JSON.stringify({ meta }), - }, + const body: Record = { + code: input.code, + expected_current_version_id: input.expectedCurrentVersionId, + }; + if (input.prompt) { + body.prompt = input.prompt; + } + const response = await this.performRequestWithRetry( + `/api/projects/${teamId}/desktop_file_system/${encodeURIComponent(entryId)}/canvas/`, + { method: "PATCH", body: JSON.stringify(body) }, ); + if (response.status === 409) { + let currentVersionId: string | null = null; + try { + const parsed = (await response.json()) as { + current_version_id?: string | null; + }; + currentVersionId = parsed.current_version_id ?? null; + } catch { + // Conflict body unavailable — the status alone carries the signal. + } + throw new DesktopCanvasVersionConflictError(currentVersionId); + } + if (!response.ok) { + throw new Error(`Failed to publish canvas (${response.status})`); + } + return response.json() as Promise; } /** Signal reports the given task is associated with (via report task associations). */ diff --git a/packages/ui/src/features/canvas/AGENTS.md b/packages/ui/src/features/canvas/AGENTS.md index ba42a82f2b..a21bc3a65a 100644 --- a/packages/ui/src/features/canvas/AGENTS.md +++ b/packages/ui/src/features/canvas/AGENTS.md @@ -75,17 +75,19 @@ The root `AGENTS.md` architecture rules still apply. documented as `DashboardFileMeta` in `dashboardSchemas.ts`. This keeps canvas and channel names in sync with the backend — the same surface that owns channels (top-level `folder` rows, see `hooks/useChannels.ts`). -- `meta` is **last-write-wins, unversioned** at the fs layer (no `base_version`). - The **agent publish path is guarded**: generation works the source as a local - scratch file via the `canvas_checkout` / `canvas_publish` local tools - (`@posthog/agent`, `adapters/local-tools/tools/canvas.ts`) — checkout records - the fetched `currentVersionId`, and publish re-fetches and refuses when the - canvas moved past that base (a concurrent edit or undo), instead of - clobbering it. The check is tool-side (check-then-PATCH, not atomic); a - server-side `base_version` check on the fs PATCH is the follow-up that closes - it completely. **User-side saves** (`saveFreeform`) are still last-write-wins; - revisit with the same optimistic concurrency if multi-client editing becomes - real. +- `meta` is **last-write-wins, unversioned** at the fs layer (no `base_version`) + — except the **canvas publish action** (`PATCH …/:id/canvas/`), which owns + version composition server-side and accepts an optional + `expected_current_version_id`: a publish based on a stale version (a + concurrent edit or undo) is rejected 409 inside the row lock instead of + clobbering the newer head. The **agent publish path uses that guard**: + generation works the source as a local scratch file via the + `canvas_checkout` / `canvas_publish` local tools (`@posthog/agent`, + `adapters/local-tools/tools/canvas.ts`) — checkout records the fetched + `currentVersionId`, publish passes it as the expected version (backends + predating the field ignore it and publish unguarded). **User-side saves** + (`saveFreeform`) are still last-write-wins; adopt the same guard if + multi-client editing becomes real. ## Channel sidebar preloading From 53fbd70a57a68230c38572fe93754f4f4ca20343 Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Mon, 20 Jul 2026 13:05:09 -0400 Subject: [PATCH 3/6] refactor(canvas): drop vestigial anti-rewrite prompting from canvas tools The "do not regenerate wholesale" phrasing in the checkout tool description, its runtime message, and the generation prompt warned against a workflow a fresh agent never sees, and could bias against legitimately large rewrites. The one forward-looking economy nudge (prefer targeted edits over rewriting the whole file for a small change) stays in the authoring contract in canvasTemplates.ts, stated once. Generated-By: PostHog Code Task-Id: 2b3d176e-3023-41d2-92e8-81eda7c12a8c --- packages/agent/src/adapters/local-tools/tools/canvas.ts | 6 +++--- packages/ui/src/features/canvas/freeformPrompt.test.ts | 2 +- packages/ui/src/features/canvas/freeformPrompt.ts | 5 ++--- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/agent/src/adapters/local-tools/tools/canvas.ts b/packages/agent/src/adapters/local-tools/tools/canvas.ts index 2e391deea8..d846678dfd 100644 --- a/packages/agent/src/adapters/local-tools/tools/canvas.ts +++ b/packages/agent/src/adapters/local-tools/tools/canvas.ts @@ -121,8 +121,8 @@ export const canvasCheckoutTool = defineLocalTool({ description: "Check out a PostHog canvas (a freeform React desktop-fs dashboard) for editing: fetches the live " + "source, writes it to a local scratch file, and records the version your edits are based on. " + - "Returns the file path. Edit that file with your normal file-editing tools (targeted edits — do not " + - "regenerate it wholesale), then call canvas_publish to save. Always start canvas work with this tool.", + "Returns the file path. Edit that file with your normal file-editing tools, then call " + + "canvas_publish to save. Always start canvas work with this tool.", schema: { id: z.string().describe("The canvas (desktop-fs dashboard row) id."), }, @@ -143,7 +143,7 @@ export const canvasCheckoutTool = defineLocalTool({ const text = code ? `Checked out canvas "${entry.path}" to ${file} (${lines} lines, base version ${ entry.meta?.currentVersionId ?? "none" - }).\nApply your changes by EDITING that file (targeted edits), then call canvas_publish with id "${args.id}".` + }).\nApply your changes by editing that file, then call canvas_publish with id "${args.id}".` : `Canvas "${entry.path}" is empty. Author the complete single-file React app at ${file}, then call canvas_publish with id "${args.id}".`; return { content: [{ type: "text", text }] }; } catch (err) { diff --git a/packages/ui/src/features/canvas/freeformPrompt.test.ts b/packages/ui/src/features/canvas/freeformPrompt.test.ts index cb7b3fdae2..741a16c874 100644 --- a/packages/ui/src/features/canvas/freeformPrompt.test.ts +++ b/packages/ui/src/features/canvas/freeformPrompt.test.ts @@ -50,7 +50,7 @@ describe("buildFreeformGenerationPrompt", () => { // the live code tool-side and the agent edits the scratch file in place. expect(extracted?.body).not.toContain("export const App = () => null;"); expect(extracted?.body).toContain("canvas_checkout"); - expect(extracted?.body).toContain("TARGETED edits"); + expect(extracted?.body).toContain("editing that file"); // The stale-publish recovery loop is spelled out. expect(extracted?.body).toContain("version-conflict"); }); diff --git a/packages/ui/src/features/canvas/freeformPrompt.ts b/packages/ui/src/features/canvas/freeformPrompt.ts index 6690362ab3..b2295dd5a9 100644 --- a/packages/ui/src/features/canvas/freeformPrompt.ts +++ b/packages/ui/src/features/canvas/freeformPrompt.ts @@ -58,9 +58,8 @@ source to a local scratch file and returns the path.`; const editStep = isEdit ? ` -Then apply the user's request by EDITING that file with your file-editing -tools. Make TARGETED edits — do not rewrite the whole file for a small change, -and do not paste the source into chat. +Then apply the user's request by editing that file with your file-editing +tools. Do not paste the source into chat. ` : ""; From 0aca45b569a9068caa8248e575591a80ab83ac49 Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Mon, 20 Jul 2026 15:35:53 -0400 Subject: [PATCH 4/6] feat(canvas): create-if-missing checkout, authoring contract, discovery + allowlist Build on the canvas_checkout/canvas_publish tools so canvas work is reachable and correct from any task, not just the channel generate bar: - canvas_checkout takes an optional id (edit) OR a name (create-if-missing), so the agent starts a canvas in one call instead of chaining desktop-fs create. - checkout returns the freeform authoring contract (from @posthog/shared), so an agent editing a canvas from a normal task authors valid source. - a channelMode-gated "## Canvases" block in the base system prompt names the tools + the create->checkout->publish sequence (fixes ToolSearch flakiness). - add a declarative autoApprove flag to local tools; canvas_checkout (read-only) and speak are auto-approved, canvas_publish (write) still prompts. - createDesktopCanvas API helper + allowlist regression test. Generated-By: PostHog Code Task-Id: 2ed89933-091b-446c-af91-5cc72d939b3b --- .../claude/permissions/permission-handlers.ts | 13 +-- .../agent/src/adapters/local-tools/index.ts | 18 +++- .../src/adapters/local-tools/registry.ts | 9 ++ .../adapters/local-tools/tools/canvas.test.ts | 16 +++ .../src/adapters/local-tools/tools/canvas.ts | 102 +++++++++++++++--- .../src/adapters/local-tools/tools/speak.ts | 1 + packages/agent/src/posthog-api.ts | 25 +++++ packages/core/src/canvas/freeformSchemas.ts | 3 +- packages/shared/src/canvas-freeform-prompt.ts | 5 +- packages/ui/src/features/canvas/AGENTS.md | 12 ++- .../src/services/agent/agent.ts | 8 ++ 11 files changed, 181 insertions(+), 31 deletions(-) diff --git a/packages/agent/src/adapters/claude/permissions/permission-handlers.ts b/packages/agent/src/adapters/claude/permissions/permission-handlers.ts index 979f62140e..5c8fc681ef 100644 --- a/packages/agent/src/adapters/claude/permissions/permission-handlers.ts +++ b/packages/agent/src/adapters/claude/permissions/permission-handlers.ts @@ -9,8 +9,7 @@ import type { } from "@anthropic-ai/claude-agent-sdk"; import { text } from "../../../utils/acp-content"; import type { Logger } from "../../../utils/logger"; -import { qualifiedLocalToolName } from "../../local-tools"; -import { SPEAK_TOOL_NAME } from "../../local-tools/tools/speak"; +import { AUTO_APPROVED_LOCAL_TOOL_IDS } from "../../local-tools"; import { toolInfoFromToolUse } from "../conversion/tool-use-to-acp"; import { getMcpToolApprovalState, @@ -40,8 +39,6 @@ import { isPostHogExecTool, } from "./posthog-exec-gate"; -const SPEAK_TOOL_ID = qualifiedLocalToolName(SPEAK_TOOL_NAME); - export type ToolPermissionResult = | { behavior: "allow"; @@ -761,10 +758,10 @@ export async function canUseTool( return { behavior: "deny", message, interrupt: false }; } - // Narration is a fire-and-forget no-op on the agent side; a permission - // prompt for it interrupts the user to approve a line they may never hear. - // An explicit do_not_use block above still wins. - if (toolName === SPEAK_TOOL_ID) { + // Auto-approve tools flagged `autoApprove` — read-only or fire-and-forget + // (canvas checkout, speak narration) where a permission prompt is pure + // friction. An explicit do_not_use block above still wins. + if (AUTO_APPROVED_LOCAL_TOOL_IDS.has(toolName)) { return { behavior: "allow", updatedInput: toolInput as Record, diff --git a/packages/agent/src/adapters/local-tools/index.ts b/packages/agent/src/adapters/local-tools/index.ts index 716bbb478e..761f132ff9 100644 --- a/packages/agent/src/adapters/local-tools/index.ts +++ b/packages/agent/src/adapters/local-tools/index.ts @@ -1,4 +1,9 @@ -import type { LocalTool, LocalToolCtx, LocalToolGateMeta } from "./registry"; +import { + type LocalTool, + type LocalToolCtx, + type LocalToolGateMeta, + qualifiedLocalToolName, +} from "./registry"; import { canvasCheckoutTool, canvasPublishTool } from "./tools/canvas"; import { cloneRepoTool } from "./tools/clone-repo"; import { listReposTool } from "./tools/list-repos"; @@ -28,6 +33,17 @@ export const LOCAL_TOOLS: LocalTool[] = [ canvasPublishTool, ]; +/** + * Qualified ids of local tools flagged `autoApprove` — the Claude permission + * handler allows these without a prompt. Codex does not prompt for MCP tool + * calls, so it does not consult this. + */ +export const AUTO_APPROVED_LOCAL_TOOL_IDS: ReadonlySet = new Set( + LOCAL_TOOLS.filter((t) => t.autoApprove).map((t) => + qualifiedLocalToolName(t.name), + ), +); + /** Tools whose gate passes for the given context — the set to actually expose. */ export function enabledLocalTools( ctx: LocalToolCtx, diff --git a/packages/agent/src/adapters/local-tools/registry.ts b/packages/agent/src/adapters/local-tools/registry.ts index d40c327f3b..bfda4d2afb 100644 --- a/packages/agent/src/adapters/local-tools/registry.ts +++ b/packages/agent/src/adapters/local-tools/registry.ts @@ -53,6 +53,13 @@ export interface LocalToolDef { * by default in the Claude adapter (ENABLE_TOOL_SEARCH). Ignored by Codex. */ alwaysLoad?: boolean; + /** + * Auto-approve this tool's calls without a permission prompt — for read-only + * or fire-and-forget tools where prompting is pure friction (an explicit + * do_not_use block still wins). Honored by the Claude permission handler; + * Codex does not prompt for MCP tool calls, so it is a no-op there. + */ + autoApprove?: boolean; isEnabled(ctx: LocalToolCtx, meta: LocalToolGateMeta | undefined): boolean; handler( ctx: LocalToolCtx, @@ -66,6 +73,8 @@ export interface LocalTool { description: string; schema: z.ZodRawShape; alwaysLoad?: boolean; + /** See {@link LocalToolDef.autoApprove}. */ + autoApprove?: boolean; isEnabled(ctx: LocalToolCtx, meta: LocalToolGateMeta | undefined): boolean; handler( ctx: LocalToolCtx, diff --git a/packages/agent/src/adapters/local-tools/tools/canvas.test.ts b/packages/agent/src/adapters/local-tools/tools/canvas.test.ts index 7cf32f9e2c..efbaf363c5 100644 --- a/packages/agent/src/adapters/local-tools/tools/canvas.test.ts +++ b/packages/agent/src/adapters/local-tools/tools/canvas.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { AUTO_APPROVED_LOCAL_TOOL_IDS } from "../index"; import { canvasScratchDir, canvasScratchFile } from "./canvas"; // Version composition and the stale-base rejection live server-side in the @@ -12,3 +13,18 @@ describe("canvas scratch paths", () => { ); }); }); + +describe("canvas tool permissions", () => { + it("auto-approves canvas_checkout (read-only) but not canvas_publish (write)", () => { + expect( + AUTO_APPROVED_LOCAL_TOOL_IDS.has( + "mcp__posthog-code-tools__canvas_checkout", + ), + ).toBe(true); + expect( + AUTO_APPROVED_LOCAL_TOOL_IDS.has( + "mcp__posthog-code-tools__canvas_publish", + ), + ).toBe(false); + }); +}); diff --git a/packages/agent/src/adapters/local-tools/tools/canvas.ts b/packages/agent/src/adapters/local-tools/tools/canvas.ts index d846678dfd..c2e4410860 100644 --- a/packages/agent/src/adapters/local-tools/tools/canvas.ts +++ b/packages/agent/src/adapters/local-tools/tools/canvas.ts @@ -1,5 +1,7 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import * as path from "node:path"; +import { freeformSystemPromptFor } from "@posthog/shared/canvas-freeform-prompt"; +import { FREEFORM_STARTER_CODE } from "@posthog/shared/canvas-freeform-starter"; import { z } from "zod"; import { DesktopCanvasVersionConflictError, @@ -48,6 +50,7 @@ function baseVersionMarkerFile(canvasId: string): string { interface CanvasMeta { code?: string; currentVersionId?: string; + templateId?: string; [key: string]: unknown; } @@ -92,6 +95,55 @@ async function fetchCanvasEntry(canvasId: string): Promise { return entry; } +// Create-if-missing for `canvas_checkout`: an agent working from a normal task +// (not the channel generate bar) can start a canvas in one call instead of +// chaining the raw desktop-fs create tool first. +async function createCanvasEntry( + name: string | undefined, + parentPath: string | undefined, +): Promise { + if (!name?.trim()) { + throw new Error( + "pass `id` to edit an existing canvas, or `name` to create a new one.", + ); + } + // A canvas must live under a channel folder — creating it at the top level + // (no parentPath) makes an orphan that never shows in any channel. The task's + // channel name is in the `` element of the + // prompt; the agent passes it as parentPath. Refuse rather than orphan. + if (!parentPath?.trim()) { + throw new Error( + "a new canvas needs a channel to live in: pass `parentPath` set to the " + + 'channel this task is in (its name, from the `` ' + + "element of your prompt). Without a channel the canvas can't be placed. If " + + "there is no channel context, ask the user which channel to create it in.", + ); + } + const client = createClient(); + if (!client) { + throw new Error("No PostHog credentials available in this session."); + } + return client.createDesktopCanvas({ + name: name.trim(), + parentPath: parentPath.trim(), + }); +} + +// The freeform authoring rules (allowed imports, the `ph` data shim, style +// rules) the channel generate bar injects up front. Returned on checkout so an +// agent editing a canvas from any task authors valid source; an empty canvas +// also gets the known-good starter scaffold to build on. +function authoringContract( + templateId: string | undefined, + isEmpty: boolean, +): string { + const contract = freeformSystemPromptFor(templateId); + const starter = isEmpty + ? `\n\nStarter scaffold — write this working baseline to the scratch file first, then build by editing it:\n\n\`\`\`tsx\n${FREEFORM_STARTER_CODE}\n\`\`\`` + : ""; + return `Authoring contract for this canvas (imports, the \`ph\` data shim, and style rules):\n\n${contract}${starter}`; +} + function readMarker(canvasId: string): BaseVersionMarker | undefined { try { return JSON.parse( @@ -119,32 +171,56 @@ const CONFLICT_MESSAGE = (canvasId: string) => export const canvasCheckoutTool = defineLocalTool({ name: "canvas_checkout", description: - "Check out a PostHog canvas (a freeform React desktop-fs dashboard) for editing: fetches the live " + - "source, writes it to a local scratch file, and records the version your edits are based on. " + - "Returns the file path. Edit that file with your normal file-editing tools, then call " + - "canvas_publish to save. Always start canvas work with this tool.", + "Check out a PostHog canvas (a freeform React desktop-fs dashboard) for editing. Pass `id` to edit " + + "an existing canvas, or omit `id` and pass `name` to create a fresh one. Fetches (or creates) the " + + "canvas, writes its source to a local scratch file, records the base version for the publish-time " + + "concurrency guard, and returns the scratch path plus the authoring contract to follow. Edit that " + + "file with your normal file-editing tools, then call canvas_publish. Always start canvas work with " + + "this tool.", schema: { - id: z.string().describe("The canvas (desktop-fs dashboard row) id."), + id: z + .string() + .optional() + .describe( + "Existing canvas (desktop-fs dashboard row) id to edit. Omit to create a new canvas via `name`.", + ), + name: z + .string() + .optional() + .describe( + "Name for a NEW canvas when `id` is omitted — creates it, then checks it out.", + ), + parentPath: z + .string() + .optional() + .describe( + 'Required when creating (no `id`): the channel the canvas belongs to — its name, from the `` element of your prompt. The canvas is created at "/"; omitting it is rejected (a canvas must live under a channel).', + ), }, alwaysLoad: true, + autoApprove: true, isEnabled: () => resolveSandboxPosthogApi() !== undefined, handler: async (_ctx, args): Promise => { try { - const entry = await fetchCanvasEntry(args.id); - const file = canvasScratchFile(args.id); + const entry = args.id + ? await fetchCanvasEntry(args.id) + : await createCanvasEntry(args.name, args.parentPath); + const canvasId = entry.id; + const file = canvasScratchFile(canvasId); const code = entry.meta?.code ?? ""; - mkdirSync(canvasScratchDir(args.id), { recursive: true }); + mkdirSync(canvasScratchDir(canvasId), { recursive: true }); writeFileSync(file, code); - writeMarker(args.id, { + writeMarker(canvasId, { versionId: entry.meta?.currentVersionId, fetchedAt: Date.now(), }); const lines = code ? code.split("\n").length : 0; - const text = code - ? `Checked out canvas "${entry.path}" to ${file} (${lines} lines, base version ${ + const header = code + ? `Checked out canvas "${entry.path}" (id ${canvasId}) to ${file} (${lines} lines, base version ${ entry.meta?.currentVersionId ?? "none" - }).\nApply your changes by editing that file, then call canvas_publish with id "${args.id}".` - : `Canvas "${entry.path}" is empty. Author the complete single-file React app at ${file}, then call canvas_publish with id "${args.id}".`; + }). Edit that file, then call canvas_publish with id "${canvasId}".` + : `Canvas "${entry.path}" (id ${canvasId}) is empty — author the complete single-file React app at ${file}, then call canvas_publish with id "${canvasId}".`; + const text = `${header}\n\n${authoringContract(entry.meta?.templateId, !code)}`; return { content: [{ type: "text", text }] }; } catch (err) { return errorResult( diff --git a/packages/agent/src/adapters/local-tools/tools/speak.ts b/packages/agent/src/adapters/local-tools/tools/speak.ts index 44f31320a0..24db99541f 100644 --- a/packages/agent/src/adapters/local-tools/tools/speak.ts +++ b/packages/agent/src/adapters/local-tools/tools/speak.ts @@ -59,6 +59,7 @@ export const speakTool = defineLocalTool({ description: SPEAK_TOOL_DESCRIPTION, schema: speakSchema, alwaysLoad: true, + autoApprove: true, isEnabled: (_ctx, meta) => meta?.spokenNarration === true, handler: async (): Promise => { return { content: [{ type: "text", text: "ok" }] }; diff --git a/packages/agent/src/posthog-api.ts b/packages/agent/src/posthog-api.ts index 9d5f425a66..60c9e0616d 100644 --- a/packages/agent/src/posthog-api.ts +++ b/packages/agent/src/posthog-api.ts @@ -364,6 +364,31 @@ export class PostHogAPIClient { return response.json() as Promise; } + /** + * Create a new freeform canvas ("dashboard" row) on the desktop surface and + * return the created entry (with its id). `parentPath` nests it under a + * folder (e.g. a channel); omit it for a top-level canvas. + */ + async createDesktopCanvas(input: { + name: string; + parentPath?: string; + }): Promise { + const teamId = this.getTeamId(); + const parent = input.parentPath?.replace(/\/+$/, ""); + const path = parent ? `${parent}/${input.name}` : input.name; + const response = await this.performRequestWithRetry( + `/api/projects/${teamId}/desktop_file_system/`, + { + method: "POST", + body: JSON.stringify({ path, type: "dashboard", meta: {} }), + }, + ); + if (!response.ok) { + throw new Error(`Failed to create canvas (${response.status})`); + } + return response.json() as Promise; + } + /** * Publish a freeform canvas's source via the desktop-fs canvas action. The * server owns version composition (appends the full-file snapshot, moves diff --git a/packages/core/src/canvas/freeformSchemas.ts b/packages/core/src/canvas/freeformSchemas.ts index ed6829bfb0..5c35b62b87 100644 --- a/packages/core/src/canvas/freeformSchemas.ts +++ b/packages/core/src/canvas/freeformSchemas.ts @@ -4,7 +4,8 @@ import { z } from "zod"; // generation path can resolve the right system prompt. // A single point in a freeform canvas's edit history. Every agent turn appends -// one full-file snapshot (Q7: full-file rewrite); the user can revert to any of +// one full-file snapshot (the agent edits a local scratch copy incrementally, +// then publishes the finished file wholesale); the user can revert to any of // them and the `currentVersionId` pointer is what publishes. We keep whole-file // snapshots rather than diffs because canvases are small and a snapshot can // never fail to reconstruct. diff --git a/packages/shared/src/canvas-freeform-prompt.ts b/packages/shared/src/canvas-freeform-prompt.ts index 1bf8c39c2e..460321dd3f 100644 --- a/packages/shared/src/canvas-freeform-prompt.ts +++ b/packages/shared/src/canvas-freeform-prompt.ts @@ -21,10 +21,9 @@ const FREEFORM_WHITELIST_NAMES = FREEFORM_WHITELIST.map((e) => e.name).join( const FREEFORM_BASE = [ "You are PostHog Canvas, an agent that builds a freeform React app for the user's current PostHog project. The app runs in a sandboxed iframe.", "", - "OUTPUT FORMAT — every turn:", - "- Write a SHORT sentence of prose, then the COMPLETE app as ONE fenced code block tagged tsx (```tsx ... ```).", - "- FULL-FILE REWRITE: always output the entire file, even for a tiny change. Never output a partial file, a diff, or multiple code blocks.", + "OUTPUT FORMAT — the app is ONE complete file:", "- The file MUST `export default` a single React component that takes no props.", + "- Maintain it as a working file with your file-editing tools: seed it once, then apply changes as TARGETED edits. Do not regenerate the whole file for a small change, and do not paste the source into chat — a short sentence describing the change is enough.", "", "IMPORTS — allowed packages ONLY:", `- You may import ONLY from: ${FREEFORM_WHITELIST_NAMES}.`, diff --git a/packages/ui/src/features/canvas/AGENTS.md b/packages/ui/src/features/canvas/AGENTS.md index a21bc3a65a..a9832c4096 100644 --- a/packages/ui/src/features/canvas/AGENTS.md +++ b/packages/ui/src/features/canvas/AGENTS.md @@ -80,11 +80,13 @@ The root `AGENTS.md` architecture rules still apply. version composition server-side and accepts an optional `expected_current_version_id`: a publish based on a stale version (a concurrent edit or undo) is rejected 409 inside the row lock instead of - clobbering the newer head. The **agent publish path uses that guard**: - generation works the source as a local scratch file via the - `canvas_checkout` / `canvas_publish` local tools (`@posthog/agent`, - `adapters/local-tools/tools/canvas.ts`) — checkout records the fetched - `currentVersionId`, publish passes it as the expected version (backends + clobbering the newer head. The **agent edit path uses that guard**: the agent + works the source as a local scratch file via the `canvas_checkout` / + `canvas_publish` local tools (`@posthog/agent`, + `adapters/local-tools/tools/canvas.ts`) — `canvas_checkout` fetches an + existing canvas (or creates one from a `name`), writes the scratch file, + records the fetched `currentVersionId`, and returns the authoring contract; + `canvas_publish` passes that version as the expected version (backends predating the field ignore it and publish unguarded). **User-side saves** (`saveFreeform`) are still last-write-wins; adopt the same guard if multi-client editing becomes real. diff --git a/packages/workspace-server/src/services/agent/agent.ts b/packages/workspace-server/src/services/agent/agent.ts index 9af21e716d..a3395b00af 100644 --- a/packages/workspace-server/src/services/agent/agent.ts +++ b/packages/workspace-server/src/services/agent/agent.ts @@ -673,6 +673,14 @@ If a repository IS genuinely required, attach one in this priority order: 1. **Reuse a folder the user already has locally.** ${localFolders.length ? "Pick the one that best matches the request and the channel CONTEXT.md, then `cd` into its absolute path and do all git and file work there. It is already on disk — do NOT clone it again." : "If the user names a folder or path, `cd` into that absolute path and work there."} 2. **If you can't confidently pick one** (none clearly match, or it's ambiguous), use the AskUserQuestion tool to ask the user which local folder to use, or for the path where the folder lives on this machine. Do not guess. 3. **Only as a last resort** — when the user has no local copy, or explicitly wants a fresh checkout — clone from remote. Call \`list_repos\` to see what's available (prefer repos named in CONTEXT.md), then **confirm with the user via AskUserQuestion before cloning**, and use \`clone_repo\` (pass \`owner/repo\`); it clones into a subdirectory of your working directory and returns the path to \`cd\` into.${localFoldersBlock}`; + + prompt += ` + +## Canvases +A canvas is an agent-authored single-file React app that lives in PostHog (a desktop-fs "dashboard" row), not a file on disk. Build and edit canvases only through the \`canvas_checkout\` / \`canvas_publish\` tools (posthog-code-tools) — never publish canvas source through the raw PostHog MCP: +- Edit an existing canvas: \`canvas_checkout\` with its id, edit the scratch file it writes, then \`canvas_publish\`. +- Create a new canvas: \`canvas_checkout\` with a \`name\` AND \`parentPath\` set to the channel this task is in (its name, from the \`\` element above), omit the id, author the scratch file, then \`canvas_publish\`. A canvas must live under a channel — creating without \`parentPath\` is rejected; if you don't know the channel, ask the user. +\`canvas_checkout\` returns the authoring contract (allowed imports, the \`ph\` data shim, style rules) — follow it. Editing through these tools is what applies the scratch-file workflow and the publish-time concurrency guard.`; } if (customInstructions) { From f295975fa0370c1fa8796c28d5e7b6de9f8e7081 Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Mon, 20 Jul 2026 18:58:58 -0400 Subject: [PATCH 5/6] fix(agent): ship the Codex local-tools MCP server with the desktop app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Codex adapter injects the local tools (canvas_checkout/publish, list_repos, clone_repo, speak, signed-git) as a stdio MCP child resolved via resolveBundledMcpScript — but nothing ever copied that script into the Electron build, so resolution threw ("Could not locate ... relative to .../.vite/build") and Codex silently lost ALL local tools in the desktop app, dev and packaged alike (cloud harness layouts were unaffected). - copyLocalToolsMcpServer vite plugin mirrors packages/agent/dist/adapters/ codex-app-server/local-tools-mcp-server.js into .vite/build with its dist-relative layout intact, so the existing walk-up resolution finds it. - asarUnpack .vite/build/adapters/** — Codex is an external binary spawning the child as plain node, which can't read inside app.asar. - resolveBundledMcpScript returns the app.asar.unpacked mirror when the found candidate sits inside the archive. Validated live over CDP: a Codex channel task now logs hasLocalTools: true and the model lists canvas_checkout/canvas_publish/list_repos/clone_repo. Generated-By: PostHog Code Task-Id: 09832237-2f58-4ab7-adf6-231aeb82e1fe --- apps/code/electron-builder.ts | 2 ++ apps/code/electron.vite.config.ts | 2 ++ apps/code/vite-main-plugins.mts | 25 +++++++++++++++++++ .../agent/src/utils/resolve-bundled-script.ts | 24 ++++++++++++++---- 4 files changed, 48 insertions(+), 5 deletions(-) diff --git a/apps/code/electron-builder.ts b/apps/code/electron-builder.ts index d0fd293eb3..2bde04dfcb 100644 --- a/apps/code/electron-builder.ts +++ b/apps/code/electron-builder.ts @@ -51,6 +51,8 @@ const config: Configuration = { ".vite/build/grammars/**", ".vite/build/rpc-host.js", ".vite/build/rpc-host.js.map", + // Spawned by Codex (an external binary that can't read inside asar). + ".vite/build/adapters/**", ...asarUnpackGlobs, ], diff --git a/apps/code/electron.vite.config.ts b/apps/code/electron.vite.config.ts index 16bce4a5c2..d746c4495b 100644 --- a/apps/code/electron.vite.config.ts +++ b/apps/code/electron.vite.config.ts @@ -22,6 +22,7 @@ import { copyCodexAcpBinaries, copyDrizzleMigrations, copyEnricherGrammars, + copyLocalToolsMcpServer, copyPiRpcHost, copyPosthogPlugin, fixFilenameCircularRef, @@ -95,6 +96,7 @@ export default defineConfig(({ mode }) => { fixFilenameCircularRef(), copyClaudeExecutable(), copyPiRpcHost(), + copyLocalToolsMcpServer(), copyPosthogPlugin(isDev), copyDrizzleMigrations(), copyCodexAcpBinaries(), diff --git a/apps/code/vite-main-plugins.mts b/apps/code/vite-main-plugins.mts index 8d3d23e295..abb2d133c5 100644 --- a/apps/code/vite-main-plugins.mts +++ b/apps/code/vite-main-plugins.mts @@ -186,6 +186,31 @@ export function copyPiRpcHost(): Plugin { }; } +export function copyLocalToolsMcpServer(): Plugin { + return { + name: "copy-local-tools-mcp-server", + writeBundle() { + const rel = "adapters/codex-app-server/local-tools-mcp-server.js"; + const candidates = [ + join(__dirname, "node_modules/@posthog/agent/dist", rel), + join(__dirname, "../../node_modules/@posthog/agent/dist", rel), + join(__dirname, "../../packages/agent/dist", rel), + ]; + const source = candidates.find((candidate) => existsSync(candidate)); + if (!source) { + throw new Error( + `[copy-local-tools-mcp-server] Unable to find the Codex local-tools MCP server, spawned at runtime via resolveBundledMcpScript. Build @posthog/agent first. Checked:\n ${candidates.join("\n ")}`, + ); + } + // Keep the dist-relative layout: resolveBundledMcpScript resolves this + // exact relative path against the running bundle's directory. + const dest = join(__dirname, ".vite/build", rel); + mkdirSync(dirname(dest), { recursive: true }); + copyFileSync(source, dest); + }, + }; +} + export function copyClaudeExecutable(): Plugin { return { name: "copy-claude-executable", diff --git a/packages/agent/src/utils/resolve-bundled-script.ts b/packages/agent/src/utils/resolve-bundled-script.ts index c1c4fa36a8..99ca7e4df9 100644 --- a/packages/agent/src/utils/resolve-bundled-script.ts +++ b/packages/agent/src/utils/resolve-bundled-script.ts @@ -1,21 +1,35 @@ import { existsSync } from "node:fs"; -import { resolve as resolvePath } from "node:path"; +import { resolve as resolvePath, sep } from "node:path"; /** * Resolve a shared dist asset relative to the compiled adapter location. When * bundled into different entry points (dist/agent.js, dist/server/bin.cjs, - * dist/server/harness/bin.js, etc), `import.meta.dirname` sits at different - * depths — and is unavailable in the CJS bin bundle, where `__dirname` takes - * over. Walk up until the script is found so each bundle locates the asset. + * dist/server/harness/bin.js, the Electron main bundle at .vite/build, etc), + * `import.meta.dirname` sits at different depths — and is unavailable in the + * CJS bin bundle, where `__dirname` takes over. Walk up until the script is + * found so each bundle locates the asset. */ export function resolveBundledMcpScript(rel: string): string { let dir = import.meta.dirname ?? __dirname; for (let i = 0; i < 5; i++) { const candidate = resolvePath(dir, rel); - if (existsSync(candidate)) return candidate; + if (existsSync(candidate)) return toSpawnablePath(candidate); dir = resolvePath(dir, ".."); } throw new Error( `Could not locate ${rel} relative to ${import.meta.dirname ?? __dirname}.`, ); } + +/** + * In the packaged Electron app the main bundle lives inside app.asar, where + * Electron's patched fs makes the path "exist" — but the script is spawned by + * an external process (Codex) as a plain Node child, which cannot read inside + * the archive. asarUnpack mirrors it on disk; return that real path instead. + */ +function toSpawnablePath(candidate: string): string { + const marker = `${sep}app.asar${sep}`; + if (!candidate.includes(marker)) return candidate; + const unpacked = candidate.replace(marker, `${sep}app.asar.unpacked${sep}`); + return existsSync(unpacked) ? unpacked : candidate; +} From bdc15e2fb9f689f0be02d0b127b430e86890f9cd Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Mon, 20 Jul 2026 18:59:42 -0400 Subject: [PATCH 6/6] fix(canvas): place created canvases in the task's channel deterministically MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit canvas_checkout's create-if-missing asked the model to relay the channel as parentPath from the element — which doesn't exist when the channel has no CONTEXT.md (the model then stalls asking the user), and a mistyped name silently minted a phantom top-level folder because the desktop- fs POST auto-creates missing parents. Canvases landed orphaned at depth 1, invisible in every channel grid. Resolve the destination tool-side instead: the app files every channel task as a desktop-fs task row at / with ref=<taskId>, so the tool joins ctx.taskId -> filing row -> parent folder. Id-based, so it survives channel renames and duplicate names, and works identically from the Claude in-process server and the Codex stdio child (cloud included — only ctx.taskId and API creds are needed). The created row now carries the same meta the app stamps (channelId/templateId/createdAt/updatedAt), so agent- created canvases list and open exactly like UI-created ones. parentPath remains as an explicit override but must name an EXISTING channel folder — never a guessed one — and the not-in-a-channel error names the real channels to offer the user. Local sessions never actually carried a task id: workspace-server's newSession _meta omitted it (and load/resume only tucked it inside the logUrl-gated persistence blob), so resolveTaskId() came up undefined and the join couldn't run. Pass taskId explicitly on all three session-start paths. Validated E2E over CDP against the local backend: a Sonnet 5 channel task with no CONTEXT.md called canvas_checkout with just a name, the canvas landed at demo-channel/Placement Check with the channel folder id in meta, published on the first try, and renders in the channel grid and canvas view. Generated-By: PostHog Code Task-Id: 09832237-2f58-4ab7-adf6-231aeb82e1fe --- .../adapters/local-tools/tools/canvas.test.ts | 159 +++++++++++++++++- .../src/adapters/local-tools/tools/canvas.ts | 140 ++++++++++++--- packages/agent/src/posthog-api.ts | 36 +++- packages/ui/src/features/canvas/AGENTS.md | 5 +- .../src/services/agent/agent.ts | 7 +- 5 files changed, 309 insertions(+), 38 deletions(-) diff --git a/packages/agent/src/adapters/local-tools/tools/canvas.test.ts b/packages/agent/src/adapters/local-tools/tools/canvas.test.ts index efbaf363c5..938bb21083 100644 --- a/packages/agent/src/adapters/local-tools/tools/canvas.test.ts +++ b/packages/agent/src/adapters/local-tools/tools/canvas.test.ts @@ -1,10 +1,14 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { AUTO_APPROVED_LOCAL_TOOL_IDS } from "../index"; -import { canvasScratchDir, canvasScratchFile } from "./canvas"; +import { + canvasCheckoutTool, + canvasScratchDir, + canvasScratchFile, +} from "./canvas"; // Version composition and the stale-base rejection live server-side in the // desktop-fs canvas action (and are tested there); the tool's own logic is -// the scratch-file plumbing. +// the scratch-file plumbing and create-time channel placement. describe("canvas scratch paths", () => { it("keys the scratch dir and file by canvas id, outside any workspace", () => { expect(canvasScratchDir("dash-1")).toBe("/tmp/posthog-canvas/dash-1"); @@ -28,3 +32,152 @@ describe("canvas tool permissions", () => { ).toBe(false); }); }); + +// Create-if-missing placement: the channel is resolved tool-side from the +// task's desktop-fs filing row (`type=task&ref=<taskId>`), never from a +// model-relayed channel name. Requests are matched on their query string, so +// the credential source (env vs a live /tmp/agent-env) doesn't matter. +describe("canvas_checkout create-if-missing placement", () => { + const ctx = { cwd: "/tmp", taskId: "task-1" }; + let requests: { url: string; method: string; body?: unknown }[]; + let routes: ((url: string, method: string) => unknown | undefined)[]; + + beforeEach(() => { + vi.stubEnv("POSTHOG_API_URL", "http://posthog.test"); + vi.stubEnv("POSTHOG_PERSONAL_API_KEY", "phx_test"); + vi.stubEnv("POSTHOG_PROJECT_ID", "1"); + requests = []; + routes = []; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string, init?: RequestInit) => { + const method = init?.method ?? "GET"; + const body = init?.body ? JSON.parse(init.body as string) : undefined; + requests.push({ url, method, body }); + for (const route of routes) { + const result = route(url, method); + if (result !== undefined) { + return new Response(JSON.stringify(result), { status: 200 }); + } + } + return new Response("{}", { status: 404 }); + }), + ); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + }); + + it("places the canvas in the task's channel folder with the app's meta shape", async () => { + routes.push((url, method) => { + if (url.includes("type=task&ref=task-1")) { + return { + results: [ + { id: "fs-home", path: "Unfiled/Tasks/My task", type: "task" }, + { id: "fs-filed", path: "demo-channel/My task", type: "task" }, + ], + }; + } + if ( + url.includes(`type=folder&path=${encodeURIComponent("demo-channel")}`) + ) { + return { + results: [{ id: "folder-1", path: "demo-channel", type: "folder" }], + }; + } + if (method === "POST" && url.endsWith("/desktop_file_system/")) { + return { + id: "canvas-1", + path: "demo-channel/My Canvas", + type: "dashboard", + meta: {}, + }; + } + return undefined; + }); + + const result = await canvasCheckoutTool.handler(ctx, { + name: "My Canvas", + }); + + expect(result.isError).toBeUndefined(); + const create = requests.find((r) => r.method === "POST"); + expect(create?.body).toMatchObject({ + path: "demo-channel/My Canvas", + type: "dashboard", + meta: { channelId: "folder-1", templateId: "freeform" }, + }); + }); + + it("refuses (naming existing channels) when the task isn't filed in one", async () => { + routes.push((url) => { + if (url.includes("type=task&ref=task-1")) return { results: [] }; + if (url.includes("type=folder&depth=1")) { + return { results: [{ id: "f1", path: "demo-channel" }] }; + } + return undefined; + }); + + const result = await canvasCheckoutTool.handler(ctx, { name: "Orphan" }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('"demo-channel"'); + expect(requests.some((r) => r.method === "POST")).toBe(false); + }); + + it("refuses a parentPath that matches no existing folder instead of minting one", async () => { + routes.push((url) => { + if ( + url.includes(`type=folder&path=${encodeURIComponent("Demo Channel")}`) + ) { + return { results: [] }; + } + if (url.includes("type=folder&depth=1")) { + return { results: [{ id: "f1", path: "demo-channel" }] }; + } + return undefined; + }); + + const result = await canvasCheckoutTool.handler(ctx, { + name: "My Canvas", + parentPath: "Demo Channel", + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Demo Channel"); + expect(requests.some((r) => r.method === "POST")).toBe(false); + }); + + it("honors an explicit parentPath that resolves to a real folder", async () => { + routes.push((url, method) => { + if (url.includes(`type=folder&path=${encodeURIComponent("other")}`)) { + return { results: [{ id: "folder-2", path: "other", type: "folder" }] }; + } + if (method === "POST" && url.endsWith("/desktop_file_system/")) { + return { + id: "canvas-2", + path: "other/Board", + type: "dashboard", + meta: {}, + }; + } + return undefined; + }); + + const result = await canvasCheckoutTool.handler(ctx, { + name: "Board", + parentPath: "other", + }); + + expect(result.isError).toBeUndefined(); + const create = requests.find((r) => r.method === "POST"); + expect(create?.body).toMatchObject({ + path: "other/Board", + meta: { channelId: "folder-2" }, + }); + // The task-filing lookup is skipped entirely on an explicit override. + expect(requests.some((r) => r.url.includes("type=task"))).toBe(false); + }); +}); diff --git a/packages/agent/src/adapters/local-tools/tools/canvas.ts b/packages/agent/src/adapters/local-tools/tools/canvas.ts index c2e4410860..7b2c295db9 100644 --- a/packages/agent/src/adapters/local-tools/tools/canvas.ts +++ b/packages/agent/src/adapters/local-tools/tools/canvas.ts @@ -1,6 +1,9 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import * as path from "node:path"; -import { freeformSystemPromptFor } from "@posthog/shared/canvas-freeform-prompt"; +import { + FREEFORM_TEMPLATE_ID, + freeformSystemPromptFor, +} from "@posthog/shared/canvas-freeform-prompt"; import { FREEFORM_STARTER_CODE } from "@posthog/shared/canvas-freeform-starter"; import { z } from "zod"; import { @@ -95,10 +98,77 @@ async function fetchCanvasEntry(canvasId: string): Promise<CanvasFsEntry> { return entry; } +// The app files every channel task as a desktop-fs `task` row at +// `<channelFolder>/<title>` with `ref=<taskId>`, alongside a home row under +// this prefix. The channel row is the deterministic task→channel join. +const UNFILED_PREFIX = "Unfiled/"; + +interface ChannelPlacement { + folderId: string; + folderPath: string; +} + +function parentOf(path: string): string { + const i = path.lastIndexOf("/"); + return i === -1 ? "" : path.slice(0, i); +} + +// Path segments are "/"-separated on the backend, so a name can't contain one. +function sanitizeSegment(name: string): string { + const cleaned = name.replace(/\//g, " ").replace(/\s+/g, " ").trim(); + return cleaned || "Untitled canvas"; +} + +async function folderByPath( + client: PostHogAPIClient, + path: string, +): Promise<ChannelPlacement | undefined> { + const folders = await client.listDesktopFsEntries<CanvasFsEntry>( + `type=folder&path=${encodeURIComponent(path)}`, + ); + const folder = folders[0]; + return folder ? { folderId: folder.id, folderPath: folder.path } : undefined; +} + +// Resolve the channel this task was created in from its desktop-fs filing row +// (`type=task&ref=<taskId>`, written by the app at task creation). An id-based +// join — no name matching, so it survives channel renames and duplicate names. +async function channelPlacementForTask( + client: PostHogAPIClient, + taskId: string | undefined, +): Promise<ChannelPlacement | undefined> { + if (!taskId) return undefined; + const rows = await client.listDesktopFsEntries<CanvasFsEntry>( + `type=task&ref=${encodeURIComponent(taskId)}`, + ); + const filed = rows.find( + (r) => r.path.includes("/") && !r.path.startsWith(UNFILED_PREFIX), + ); + if (!filed) return undefined; + return folderByPath(client, parentOf(filed.path)); +} + +// For the not-in-a-channel error: name the channels the agent could offer the +// user instead of leaving it to guess what a valid `parentPath` looks like. +async function channelPathsForError(client: PostHogAPIClient): Promise<string> { + try { + const folders = await client.listDesktopFsEntries<CanvasFsEntry>( + "type=folder&depth=1", + ); + const paths = folders.slice(0, 20).map((f) => `"${f.path}"`); + return paths.length ? ` Existing channels: ${paths.join(", ")}.` : ""; + } catch { + return ""; + } +} + // Create-if-missing for `canvas_checkout`: an agent working from a normal task // (not the channel generate bar) can start a canvas in one call instead of -// chaining the raw desktop-fs create tool first. +// chaining the raw desktop-fs create tool first. The canvas is placed in the +// task's own channel, resolved tool-side from the task id — the model never +// has to know (or correctly relay) the channel. async function createCanvasEntry( + ctx: { taskId?: string }, name: string | undefined, parentPath: string | undefined, ): Promise<CanvasFsEntry> { @@ -107,25 +177,43 @@ async function createCanvasEntry( "pass `id` to edit an existing canvas, or `name` to create a new one.", ); } - // A canvas must live under a channel folder — creating it at the top level - // (no parentPath) makes an orphan that never shows in any channel. The task's - // channel name is in the `<channel_context channel="…">` element of the - // prompt; the agent passes it as parentPath. Refuse rather than orphan. - if (!parentPath?.trim()) { - throw new Error( - "a new canvas needs a channel to live in: pass `parentPath` set to the " + - 'channel this task is in (its name, from the `<channel_context channel="…">` ' + - "element of your prompt). Without a channel the canvas can't be placed. If " + - "there is no channel context, ask the user which channel to create it in.", - ); - } const client = createClient(); if (!client) { throw new Error("No PostHog credentials available in this session."); } + // Resolve the destination to an EXISTING channel folder before creating — + // the backend auto-creates missing parents, so an unresolved path would + // silently mint a phantom top-level folder instead of failing. + let placement: ChannelPlacement | undefined; + const overridePath = parentPath?.trim().replace(/\/+$/, ""); + if (overridePath) { + placement = await folderByPath(client, overridePath); + if (!placement) { + throw new Error( + `no channel folder exists at "${overridePath}".${await channelPathsForError(client)} ` + + "Pass one of these as `parentPath`, or omit it to use this task's own channel.", + ); + } + } else { + placement = await channelPlacementForTask(client, ctx.taskId); + if (!placement) { + throw new Error( + `this task isn't filed in a channel, so the canvas has nowhere to live.${await channelPathsForError(client)} ` + + "Ask the user which channel to create it in, then pass that folder path as `parentPath`.", + ); + } + } + const now = Date.now(); return client.createDesktopCanvas<CanvasFsEntry>({ - name: name.trim(), - parentPath: parentPath.trim(), + path: `${placement.folderPath}/${sanitizeSegment(name)}`, + // The same meta shape the app stamps on UI-created canvases, so the canvas + // opens and lists identically to one made from the channel grid. + meta: { + channelId: placement.folderId, + templateId: FREEFORM_TEMPLATE_ID, + createdAt: now, + updatedAt: now, + }, }); } @@ -172,11 +260,11 @@ export const canvasCheckoutTool = defineLocalTool({ name: "canvas_checkout", description: "Check out a PostHog canvas (a freeform React desktop-fs dashboard) for editing. Pass `id` to edit " + - "an existing canvas, or omit `id` and pass `name` to create a fresh one. Fetches (or creates) the " + - "canvas, writes its source to a local scratch file, records the base version for the publish-time " + - "concurrency guard, and returns the scratch path plus the authoring contract to follow. Edit that " + - "file with your normal file-editing tools, then call canvas_publish. Always start canvas work with " + - "this tool.", + "an existing canvas, or omit `id` and pass `name` to create a fresh one — it is placed in this " + + "task's own channel automatically. Fetches (or creates) the canvas, writes its source to a local " + + "scratch file, records the base version for the publish-time concurrency guard, and returns the " + + "scratch path plus the authoring contract to follow. Edit that file with your normal file-editing " + + "tools, then call canvas_publish. Always start canvas work with this tool.", schema: { id: z .string() @@ -188,23 +276,25 @@ export const canvasCheckoutTool = defineLocalTool({ .string() .optional() .describe( - "Name for a NEW canvas when `id` is omitted — creates it, then checks it out.", + "Name for a NEW canvas when `id` is omitted — creates it in this task's channel, then checks it out.", ), parentPath: z .string() .optional() .describe( - 'Required when creating (no `id`): the channel the canvas belongs to — its name, from the `<channel_context channel="…">` element of your prompt. The canvas is created at "<parentPath>/<name>"; omitting it is rejected (a canvas must live under a channel).', + "Override the destination channel when creating: the exact folder path of an EXISTING channel. " + + "Normally omit it — the new canvas lands in this task's own channel automatically. Only pass it " + + "when the user explicitly names a different channel (or this task isn't in one).", ), }, alwaysLoad: true, autoApprove: true, isEnabled: () => resolveSandboxPosthogApi() !== undefined, - handler: async (_ctx, args): Promise<LocalToolResult> => { + handler: async (ctx, args): Promise<LocalToolResult> => { try { const entry = args.id ? await fetchCanvasEntry(args.id) - : await createCanvasEntry(args.name, args.parentPath); + : await createCanvasEntry(ctx, args.name, args.parentPath); const canvasId = entry.id; const file = canvasScratchFile(canvasId); const code = entry.meta?.code ?? ""; diff --git a/packages/agent/src/posthog-api.ts b/packages/agent/src/posthog-api.ts index 60c9e0616d..045c15597c 100644 --- a/packages/agent/src/posthog-api.ts +++ b/packages/agent/src/posthog-api.ts @@ -365,22 +365,42 @@ export class PostHogAPIClient { } /** - * Create a new freeform canvas ("dashboard" row) on the desktop surface and - * return the created entry (with its id). `parentPath` nests it under a - * folder (e.g. a channel); omit it for a top-level canvas. + * List desktop file system entries matching a raw query string (e.g. + * `type=task&ref=<taskId>` or `type=folder&path=<path>`). Returns one page + * of results — callers here only need exact-match lookups. + */ + async listDesktopFsEntries<T>(query: string): Promise<T[]> { + const teamId = this.getTeamId(); + const response = await this.performRequestWithRetry( + `/api/projects/${teamId}/desktop_file_system/?${query}`, + ); + if (!response.ok) { + throw new Error(`Failed to list desktop-fs entries (${response.status})`); + } + const page = (await response.json()) as { results?: T[] }; + return page.results ?? []; + } + + /** + * Create a new freeform canvas ("dashboard" row) on the desktop surface at + * the given path and return the created entry (with its id). The backend + * auto-creates missing parent folders, so callers must resolve the parent to + * an existing folder first — a typo'd path silently mints a phantom one. */ async createDesktopCanvas<T>(input: { - name: string; - parentPath?: string; + path: string; + meta?: Record<string, unknown>; }): Promise<T> { const teamId = this.getTeamId(); - const parent = input.parentPath?.replace(/\/+$/, ""); - const path = parent ? `${parent}/${input.name}` : input.name; const response = await this.performRequestWithRetry( `/api/projects/${teamId}/desktop_file_system/`, { method: "POST", - body: JSON.stringify({ path, type: "dashboard", meta: {} }), + body: JSON.stringify({ + path: input.path, + type: "dashboard", + meta: input.meta ?? {}, + }), }, ); if (!response.ok) { diff --git a/packages/ui/src/features/canvas/AGENTS.md b/packages/ui/src/features/canvas/AGENTS.md index a9832c4096..534c118c8f 100644 --- a/packages/ui/src/features/canvas/AGENTS.md +++ b/packages/ui/src/features/canvas/AGENTS.md @@ -84,7 +84,10 @@ The root `AGENTS.md` architecture rules still apply. works the source as a local scratch file via the `canvas_checkout` / `canvas_publish` local tools (`@posthog/agent`, `adapters/local-tools/tools/canvas.ts`) — `canvas_checkout` fetches an - existing canvas (or creates one from a `name`), writes the scratch file, + existing canvas (or creates one from a `name`, placed in the task's own + channel by resolving the task's desktop-fs filing row — `type=task&ref= + <taskId>` — to its parent folder tool-side, never from a model-relayed + channel name), writes the scratch file, records the fetched `currentVersionId`, and returns the authoring contract; `canvas_publish` passes that version as the expected version (backends predating the field ignore it and publish unguarded). **User-side saves** diff --git a/packages/workspace-server/src/services/agent/agent.ts b/packages/workspace-server/src/services/agent/agent.ts index a3395b00af..564108ab83 100644 --- a/packages/workspace-server/src/services/agent/agent.ts +++ b/packages/workspace-server/src/services/agent/agent.ts @@ -679,7 +679,7 @@ If a repository IS genuinely required, attach one in this priority order: ## Canvases A canvas is an agent-authored single-file React app that lives in PostHog (a desktop-fs "dashboard" row), not a file on disk. Build and edit canvases only through the \`canvas_checkout\` / \`canvas_publish\` tools (posthog-code-tools) — never publish canvas source through the raw PostHog MCP: - Edit an existing canvas: \`canvas_checkout\` with its id, edit the scratch file it writes, then \`canvas_publish\`. -- Create a new canvas: \`canvas_checkout\` with a \`name\` AND \`parentPath\` set to the channel this task is in (its name, from the \`<channel_context channel="…">\` element above), omit the id, author the scratch file, then \`canvas_publish\`. A canvas must live under a channel — creating without \`parentPath\` is rejected; if you don't know the channel, ask the user. +- Create a new canvas: \`canvas_checkout\` with a \`name\` (omit the id), author the scratch file, then \`canvas_publish\`. It is placed in this task's channel automatically — don't pass \`parentPath\` unless the user explicitly names a different channel. \`canvas_checkout\` returns the authoring contract (allowed imports, the \`ph\` data shim, style rules) — follow it. Editing through these tools is what applies the scratch-file workflow and the publish-time concurrency guard.`; } @@ -996,6 +996,7 @@ A canvas is an agent-authored single-file React app that lives in PostHog (a des ...(logUrl && { persistence: { taskId, runId: taskRunId, logUrl }, }), + taskId, taskRunId, environment: "local", sessionId: importedSessionId, @@ -1071,6 +1072,7 @@ A canvas is an agent-authored single-file React app that lives in PostHog (a des ...(logUrl && { persistence: { taskId, runId: taskRunId, logUrl }, }), + taskId, taskRunId, environment: "local", sessionId: existingSessionId, @@ -1101,6 +1103,9 @@ A canvas is an agent-authored single-file React app that lives in PostHog (a des cwd: repoPath, mcpServers: sessionMcpServers, _meta: { + // taskId feeds the local tools' ctx (resolveTaskId) — canvas + // placement resolves the task's channel from it. + taskId, taskRunId, environment: "local", systemPrompt,