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/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 c2aab7139d..761f132ff9 100644 --- a/packages/agent/src/adapters/local-tools/index.ts +++ b/packages/agent/src/adapters/local-tools/index.ts @@ -1,4 +1,10 @@ -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"; import { signedCommitTool } from "./tools/signed-commit"; @@ -23,8 +29,21 @@ export const LOCAL_TOOLS: LocalTool[] = [ listReposTool, cloneRepoTool, speakTool, + canvasCheckoutTool, + 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 new file mode 100644 index 0000000000..938bb21083 --- /dev/null +++ b/packages/agent/src/adapters/local-tools/tools/canvas.test.ts @@ -0,0 +1,183 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { AUTO_APPROVED_LOCAL_TOOL_IDS } from "../index"; +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 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"); + expect(canvasScratchFile("dash-1")).toBe( + "/tmp/posthog-canvas/dash-1/canvas.tsx", + ); + }); +}); + +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); + }); +}); + +// Create-if-missing placement: the channel is resolved tool-side from the +// task's desktop-fs filing row (`type=task&ref=`), 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 new file mode 100644 index 0000000000..7b2c295db9 --- /dev/null +++ b/packages/agent/src/adapters/local-tools/tools/canvas.ts @@ -0,0 +1,398 @@ +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import * as path from "node:path"; +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 { + DesktopCanvasVersionConflictError, + 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) 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 + * 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 CanvasMeta { + code?: string; + currentVersionId?: string; + templateId?: string; + [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; +} + +// The app files every channel task as a desktop-fs `task` row at +// `/` 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. 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> { + if (!name?.trim()) { + throw new Error( + "pass `id` to edit an existing canvas, or `name` to create a new one.", + ); + } + 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>({ + 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, + }, + }); +} + +// 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( + 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)); +} + +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. Pass `id` to edit " + + "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() + .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 in this task's channel, then checks it out.", + ), + parentPath: z + .string() + .optional() + .describe( + "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> => { + try { + const entry = args.id + ? await fetchCanvasEntry(args.id) + : await createCanvasEntry(ctx, args.name, args.parentPath); + const canvasId = entry.id; + const file = canvasScratchFile(canvasId); + const code = entry.meta?.code ?? ""; + mkdirSync(canvasScratchDir(canvasId), { recursive: true }); + writeFileSync(file, code); + writeMarker(canvasId, { + versionId: entry.meta?.currentVersionId, + fetchedAt: Date.now(), + }); + const lines = code ? code.split("\n").length : 0; + const header = code + ? `Checked out canvas "${entry.path}" (id ${canvasId}) to ${file} (${lines} lines, base version ${ + entry.meta?.currentVersionId ?? "none" + }). 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( + `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 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: { + 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<LocalToolResult> => { + 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.`, + ); + } + const client = createClient(); + if (!client) { + return errorResult( + "canvas_publish failed: no PostHog credentials available in this session.", + ); + } + try { + const entry = await client.publishDesktopCanvas<CanvasFsEntry>(args.id, { + code, + prompt: args.prompt, + expectedCurrentVersionId: marker.versionId ?? null, + }); + // Advance the base so a follow-up publish in the same session works + // without a re-checkout. + const newVersionId = entry.meta?.currentVersionId; + writeMarker(args.id, { versionId: newVersionId, fetchedAt: Date.now() }); + return { + content: [ + { + type: "text", + 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/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<LocalToolResult> => { return { content: [{ type: "text", text: "ok" }] }; diff --git a/packages/agent/src/posthog-api.ts b/packages/agent/src/posthog-api.ts index b3596fe424..045c15597c 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; @@ -338,6 +346,116 @@ 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<T>(entryId: string): Promise<T | null> { + 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<T>; + } + + /** + * 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: { + path: string; + meta?: Record<string, unknown>; + }): Promise<T> { + const teamId = this.getTeamId(); + const response = await this.performRequestWithRetry( + `/api/projects/${teamId}/desktop_file_system/`, + { + method: "POST", + body: JSON.stringify({ + path: input.path, + type: "dashboard", + meta: input.meta ?? {}, + }), + }, + ); + if (!response.ok) { + throw new Error(`Failed to create canvas (${response.status})`); + } + return response.json() as Promise<T>; + } + + /** + * 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 publishDesktopCanvas<T>( + entryId: string, + input: { + code: string; + prompt?: string; + /** The head version the code was based on; null when it was empty. */ + expectedCurrentVersionId: string | null; + }, + ): Promise<T> { + const teamId = this.getTeamId(); + const body: Record<string, unknown> = { + 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<T>; + } + /** Signal reports the given task is associated with (via report task associations). */ async getSignalReportIdsForTask(taskId: string): Promise<string[]> { const teamId = this.getTeamId(); 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; +} 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 0ea928dfbf..534c118c8f 100644 --- a/packages/ui/src/features/canvas/AGENTS.md +++ b/packages/ui/src/features/canvas/AGENTS.md @@ -75,10 +75,24 @@ 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`). - 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. +- `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 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`, 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** + (`saveFreeform`) are still last-write-wins; adopt the same guard 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..741a16c874 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("editing that file"); + // 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..b2295dd5a9 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,16 @@ 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. Do not paste the source into chat. +` : ""; // First-build only: hand the agent a working scaffold to build ON instead of @@ -52,7 +68,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 +97,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 — diff --git a/packages/workspace-server/src/services/agent/agent.ts b/packages/workspace-server/src/services/agent/agent.ts index 9af21e716d..564108ab83 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\` (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.`; } if (customInstructions) { @@ -988,6 +996,7 @@ If a repository IS genuinely required, attach one in this priority order: ...(logUrl && { persistence: { taskId, runId: taskRunId, logUrl }, }), + taskId, taskRunId, environment: "local", sessionId: importedSessionId, @@ -1063,6 +1072,7 @@ If a repository IS genuinely required, attach one in this priority order: ...(logUrl && { persistence: { taskId, runId: taskRunId, logUrl }, }), + taskId, taskRunId, environment: "local", sessionId: existingSessionId, @@ -1093,6 +1103,9 @@ If a repository IS genuinely required, attach one in this priority order: 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,