From fd534605b285d4f649a4ca150694608a7a8a8075 Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Sun, 27 Sep 2026 02:44:59 -0400 Subject: [PATCH 1/6] An unnamed project's folder takes the name its agent gives it The agent wrote a name into project.json but the folder stayed Untitled N. Renaming it from inside the turn breaks the next message (no such project), so the server renames it once the turn ends and moves the open page and tab. Co-Authored-By: Claude Opus 5.5 --- canvas/server/agent.ts | 17 ++++++++- canvas/server/projects.test.ts | 25 ++++++++++++- canvas/server/projects.ts | 50 ++++++++++++++++++++++++++ canvas/server/sp.ts | 22 +++++++----- canvas/src/AppShell.tsx | 21 +++++++++-- canvas/src/canvasIndex.ts | 9 +++++ docs/2026-09-27-project-folder-name.md | 29 +++++++++++++++ 7 files changed, 161 insertions(+), 12 deletions(-) create mode 100644 docs/2026-09-27-project-folder-name.md diff --git a/canvas/server/agent.ts b/canvas/server/agent.ts index fb47016f..11a017b0 100644 --- a/canvas/server/agent.ts +++ b/canvas/server/agent.ts @@ -45,12 +45,14 @@ export function createAgentServer(options: { examplesDir: string; /** Every project by the name its address carries, `/p//`. */ projects: () => Map; + /** Moves a project the agent has just named into a folder of that name (projects.ts). */ + named: (dir: string) => string | undefined; /** This plugin's checkout, whose skills the sessions are given. */ repoRoot: string; /** `/.workspaces`: a folder per session, a record beside each, and the skills. */ workspaces: string; }) { - const { examplesDir, projects, repoRoot, workspaces } = options; + const { examplesDir, named, projects, repoRoot, workspaces } = options; // One process per message — Claude Code or Codex, by the panel's choice, looked up in // agents.ts — its output kept here and streamed to the page. The runs are held in memory, the // newest twenty, for the panel to follow and for the history to say which session is running, @@ -637,6 +639,19 @@ export function createAgentServer(options: { finish(error.code === "ENOENT" ? def.missing : String(error)); }); run.child.on("close", (code, signal) => { + // The turn that named an unnamed project is over, so its folder can take the name. + // Not when another turn has already started on it: the next one to end moves it. + if ( + dir !== undefined && + code === 0 && + ![...runs.values()].some((r) => r !== run && !ended(r)) + ) { + const moved = named(dir); + if (moved) + record.projects = record.projects.map((had) => + had === dir ? moved : had, + ); + } settle(); if (ended(run)) return; const tail = stderr.trim().split("\n").slice(-5).join("\n"); diff --git a/canvas/server/projects.test.ts b/canvas/server/projects.test.ts index 0408ede9..eb78290a 100644 --- a/canvas/server/projects.test.ts +++ b/canvas/server/projects.test.ts @@ -4,7 +4,7 @@ import type { AddressInfo } from "node:net"; import os from "node:os"; import path from "node:path"; import { expect, it } from "vitest"; -import { createProjectsServer, withoutFiles } from "./projects.ts"; +import { createProjectsServer, namedFolder, withoutFiles } from "./projects.ts"; // One server for every project: `/` goes to the one opened, else home, each is at `/p//`, the // root has the examples and no project, and a page of the server's own can make one. The folder @@ -248,3 +248,26 @@ it("keeps a community board it draws off file: addresses, after its doctype", () ); expect(withoutFiles("

")).toMatch(/^]+>

$/); }); + +// A project made without a name takes the one its agent writes, and only then. +it("names an Untitled project's folder from its project.json", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "sp-named-")); + const project = (folder: string, json: object) => { + fs.mkdirSync(path.join(tmp, folder)); + fs.writeFileSync( + path.join(tmp, folder, "project.json"), + JSON.stringify(json), + ); + return path.join(tmp, folder); + }; + expect(namedFolder(project("Untitled 3", { name: " Kasra " }))).toBe( + path.join(tmp, "Kasra"), + ); + expect(namedFolder(project("Untitled", { format: 1 }))).toBeUndefined(); + expect(namedFolder(project("Mine", { name: "Other" }))).toBeUndefined(); + expect(namedFolder(project("Untitled 2", { name: "Mine" }))).toBeUndefined(); + expect(namedFolder(project("Untitled 4", { name: "a/b" }))).toBeUndefined(); + expect( + namedFolder(project("Untitled 5", { name: ".hidden" })), + ).toBeUndefined(); +}); diff --git a/canvas/server/projects.ts b/canvas/server/projects.ts index 5e8aacd8..85f5777e 100644 --- a/canvas/server/projects.ts +++ b/canvas/server/projects.ts @@ -20,6 +20,7 @@ import { createAgentServer } from "./agent.ts"; import { CANVASES, readJson } from "./boards.ts"; import { createSpServer, + readProjectJson, reveal, SANDBOX, sameOrigin, @@ -109,6 +110,24 @@ export function loopbackHost(host: string | undefined) { ); } +/** + * Where an "Untitled" project's folder goes once its agent has named it in project.json: a folder + * of that name beside it. Undefined for a folder the person named, a name no folder can have, and + * a name another project already has. + */ +export function namedFolder(dir: string) { + const name = readProjectJson(dir).name?.trim(); + if ( + !/^Untitled( \d+)?$/.test(path.basename(dir)) || + !name || + name.startsWith(".") || + path.basename(name) !== name + ) + return undefined; + const to = path.join(path.dirname(dir), name); + return fs.existsSync(to) ? undefined : to; +} + export function createProjectsServer(options: { /** Where every project is listed from, and where `POST /__sp/projects` makes one. */ projectsDir: string; @@ -157,10 +176,41 @@ export function createProjectsServer(options: { repoRoot, }); + // A project made without a name is an "Untitled" folder until its agent writes one into + // project.json (AppShell.tsx). Once that turn is over, with no process holding the old path, + // the folder takes the name, and the pages open on it follow (canvasIndex.ts). A name no folder + // can have, or one another project has, leaves it where it is. A folder the person named keeps + // its name whatever project.json says. Answers the new folder. + const named = (dir: string) => { + const to = namedFolder(dir); + if (to === undefined) return undefined; + const name = path.basename(to); + // Its server goes first: Windows will not move a folder that is being watched. A page asking + // again, after a move that failed, gets a new one. + const sp = sps.get(dir); + sps.delete(dir); + sp?.unwatch(); + try { + fs.renameSync(dir, to); + } catch (error) { + console.error( + `[projects] ${dir} could not be renamed to “${name}”: ${error}`, + ); + sp?.close(); + return undefined; + } + sp?.close({ + from: `/p/${encodeURIComponent(path.basename(dir))}/`, + to: `/p/${encodeURIComponent(name)}/`, + }); + return to; + }; + // The agent, once for the whole server: its sessions and the skills they read are kept in a dot // folder of the projects directory, which the list above skips. const agent = createAgentServer({ examplesDir, + named, projects, repoRoot, workspaces: path.join(projectsDir, ".workspaces"), diff --git a/canvas/server/sp.ts b/canvas/server/sp.ts index a385a9fc..d78c9b92 100644 --- a/canvas/server/sp.ts +++ b/canvas/server/sp.ts @@ -83,8 +83,8 @@ export function folderOf( /** * The project's own settings, beside its canvases: which cover it chose, and the name it is shown * by when that is not its folder's. A project made without a name is an "Untitled" folder whose - * agent writes `name` here (AppShell.tsx), since renaming the folder would move every address the - * open tab and the chat hold. + * agent writes `name` here (AppShell.tsx). The folder takes that name once the turn that wrote it + * is over (projects.ts, `named`), since during it the agent holds the folder's path. */ const PROJECT_JSON = "project.json"; @@ -263,7 +263,7 @@ export function shoot(board: string, size: number[], res: ServerResponse) { } /** A project's project.json, or nothing in it when it has none or it does not parse. */ -const readProjectJson = (dir: string) => +export const readProjectJson = (dir: string) => (readJson(path.join(dir, PROJECT_JSON)) ?? {}) as { cover?: ChosenCover; name?: string; @@ -330,7 +330,7 @@ export function createSpServer(options: { // edited. canvasIndex.ts listens. const pages = new Set(); const broadcast = ( - event: "reload" | "index" | "layout" | "docs" | "content", + event: "reload" | "index" | "layout" | "docs" | "content" | "moved", data: unknown, ) => { const frame = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; @@ -1298,12 +1298,18 @@ export function createSpServer(options: { docWatcher.unref(); } + const unwatch = () => { + watcher?.close(); + docWatcher?.close(); + clearTimeout(docBatch); + }; return { handle, - close() { - watcher?.close(); - docWatcher?.close(); - clearTimeout(docBatch); + unwatch, + /** Ends the pages' streams, telling them first when the project has moved (projects.ts). */ + close(moved?: { from: string; to: string }) { + unwatch(); + if (moved) broadcast("moved", moved); for (const page of pages) page.end(); pages.clear(); }, diff --git a/canvas/src/AppShell.tsx b/canvas/src/AppShell.tsx index 2c16aadd..7d5ca23a 100644 --- a/canvas/src/AppShell.tsx +++ b/canvas/src/AppShell.tsx @@ -39,6 +39,8 @@ declare global { /** The window's side of the frame (here): what the canvas has in front, at what address. */ spShell?: { shown(tab: ProjectTab, href: string): void; + /** A project's address changed with its folder's name, from `from` to `to`. */ + moved(from: string, to: string): void; /** What the panel's running turn is writing to; `sp:working` on this window when it * changes. The canvas's strip dots those tabs. */ working: Working; @@ -151,6 +153,20 @@ export function AppShell() { setShown({ tab, href }); setTabs((tabs) => withTab(tabs, tab)); }, + moved(from, to) { + setTabs((tabs) => + tabs.map((tab) => + tab.kind === "project" && tab.url === from + ? { + ...tab, + url: to, + name: decodeURIComponent(to.slice("/p/".length, -1)), + icon: tab.icon && to + tab.icon.slice(from.length), + } + : tab, + ), + ); + }, working: { slugs: [] }, }; }, []); @@ -304,12 +320,13 @@ export function AppShell() { ); dialog.current!.close(); // Before anything else its agent names the project, when it was left unnamed, into its - // project.json, which the bar and the home page show it by (server/sp.ts). Then it makes and + // project.json, which the bar and the home page show it by (server/sp.ts), and which its + // "Untitled" folder is renamed to once that turn is over (server/projects.ts). Then it makes and // names the first canvas, which the blank view the project opens on gives way to (App.tsx). // The skill's command still opens the message, since only there is it one. const first = [ name.trim() === "" && - `name this project: add a short name for it as "name" to project.json at the project's root, keeping the keys already there (if you cannot tell yet what it is, make that your first question to me)`, + `name this project: add a short name for it as "name" to project.json at the project's root, keeping the keys already there (if you cannot tell yet what it is, make that your first question to me). Leave the folder as it is: the app renames it to that name once your turn ends`, // Empty: the skill says when a board is due, after the product or the measurements. `make the canvas the work goes in and name it: a folder under canvases/ with its "name" in layout.json, so it opens on my screen, and no board in it until the work reaches one`, ].filter(Boolean); diff --git a/canvas/src/canvasIndex.ts b/canvas/src/canvasIndex.ts index bff73e67..01495ba6 100644 --- a/canvas/src/canvasIndex.ts +++ b/canvas/src/canvasIndex.ts @@ -169,6 +169,15 @@ export async function loadCanvasIndex(live = true, bridge = false) { `${import.meta.env.BASE_URL}__sp/events${bridge ? "?bridge=1" : ""}`, ); events.addEventListener("reload", () => window.location.reload()); + // The project's folder took the name its agent gave it (server/projects.ts, `named`): the same + // view at the new address, in the same tab, which the window moves first. + events.addEventListener("moved", (event) => { + const { from, to } = JSON.parse(event.data) as { from: string; to: string }; + window.parent.spShell?.moved(from, to); + const url = new URL(window.location.href); + url.pathname = to + url.pathname.slice(from.length); + window.location.replace(url.href); + }); events.addEventListener("command", (event) => { const command: AgentCommand = JSON.parse(event.data); if (Array.isArray(onCommand)) onCommand.push(command); diff --git a/docs/2026-09-27-project-folder-name.md b/docs/2026-09-27-project-folder-name.md new file mode 100644 index 00000000..4b81c01f --- /dev/null +++ b/docs/2026-09-27-project-folder-name.md @@ -0,0 +1,29 @@ +# An unnamed project's folder takes its agent's name after the turn + +2026-09-27. A project made from an idea with no name is an `Untitled N` +folder. Its agent's first message asks it to write a name into +`project.json`, which the bar and the home page show, but the folder kept +`Untitled N` in Finder. + +## Why not ask the agent to rename the folder + +We tried it. The agent renamed the folder, and the next chat message failed +with "no such project". Everything open on the project holds the old folder's +name: the page's address `/p//`, its tab, the chat's `SP_PROJECT` and +`--add-dir`, and the session's `projects[]`. The agent can't reach any of +these, and it is still running inside the folder it would be moving. + +## What the app does + +When a turn ends cleanly and no other turn is running, `named` in +`canvas/server/projects.ts` renames an `Untitled( N)?` folder to the +`project.json` name. It skips a name that is empty, starts with a dot, +contains a slash, or is already another folder's. It closes the project's +file watchers first, because Windows won't rename a watched folder. Then it +sends the open pages a `moved` event. The page reloads at the new address, +and the window moves its tab first (`spShell.moved`). The session's +`projects[]` switches to the new path, so the next message runs in it. + +A folder the person named themselves keeps its name, whatever `project.json` +says. The prompt tells the agent to leave the folder alone, so it doesn't +offer to rename it. From b534c3cc8ef28a680efdc389929077fa8edff331 Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Sun, 27 Sep 2026 03:06:39 -0400 Subject: [PATCH 2/6] Review fixes: old name as alias, move every tab, Windows-safe names - The old project name stays an alias: a message sent as the turn ends, a page's last save as it unloads and a stale home card all reach the new folder, and a GET on the old address redirects. - The moved event goes to every open page, so the tab moves when another project is in front, and the home list refreshes. - A name Windows can't hold, or a non-string name, leaves the folder alone; a throw while naming can no longer keep a run from ending. Co-Authored-By: Claude Opus 5.5 --- canvas/server/agent.ts | 23 +++++++++------ canvas/server/projects.test.ts | 7 +++++ canvas/server/projects.ts | 39 ++++++++++++++++++++++---- canvas/server/sp.ts | 6 ++-- canvas/src/AppShell.tsx | 1 + canvas/src/canvasIndex.ts | 6 ++-- docs/2026-09-27-project-folder-name.md | 25 +++++++++++++---- 7 files changed, 83 insertions(+), 24 deletions(-) diff --git a/canvas/server/agent.ts b/canvas/server/agent.ts index 11a017b0..9addffee 100644 --- a/canvas/server/agent.ts +++ b/canvas/server/agent.ts @@ -47,12 +47,14 @@ export function createAgentServer(options: { projects: () => Map; /** Moves a project the agent has just named into a folder of that name (projects.ts). */ named: (dir: string) => string | undefined; + /** A project by name, or by the name it had before `named` moved it. */ + project: (name: string) => string | undefined; /** This plugin's checkout, whose skills the sessions are given. */ repoRoot: string; /** `/.workspaces`: a folder per session, a record beside each, and the skills. */ workspaces: string; }) { - const { examplesDir, named, projects, repoRoot, workspaces } = options; + const { examplesDir, named, project: projectDir, projects, repoRoot, workspaces } = options; // One process per message — Claude Code or Codex, by the panel's choice, looked up in // agents.ts — its output kept here and streamed to the page. The runs are held in memory, the // newest twenty, for the panel to follow and for the history to say which session is running, @@ -360,7 +362,7 @@ export function createAgentServer(options: { // The project it was sent from, by the name its address carries. None from the home page // or an example, and then the agent has no project to write to. const dir = - project === undefined ? undefined : projects().get(project); + project === undefined ? undefined : projectDir(project); if (project !== undefined && dir === undefined) return send(404, "no such project"); // The session the panel is in, or a new one. Its id names a folder and a file, so it has @@ -525,7 +527,7 @@ export function createAgentServer(options: { // server's port. env: { ...process.env, - SP_PROJECT: project, + SP_PROJECT: dir && path.basename(dir), SP_CANVAS_PORT: String(req.socket.localPort), }, }), @@ -646,11 +648,16 @@ export function createAgentServer(options: { code === 0 && ![...runs.values()].some((r) => r !== run && !ended(r)) ) { - const moved = named(dir); - if (moved) - record.projects = record.projects.map((had) => - had === dir ? moved : had, - ); + // Never in the way of the run ending: a throw here would leave it running for good. + try { + const moved = named(dir); + if (moved) + record.projects = record.projects.map((had) => + had === dir ? moved : had, + ); + } catch (error) { + console.error(`[agent] ${dir} could not be named: ${error}`); + } } settle(); if (ended(run)) return; diff --git a/canvas/server/projects.test.ts b/canvas/server/projects.test.ts index eb78290a..de024208 100644 --- a/canvas/server/projects.test.ts +++ b/canvas/server/projects.test.ts @@ -270,4 +270,11 @@ it("names an Untitled project's folder from its project.json", () => { expect( namedFolder(project("Untitled 5", { name: ".hidden" })), ).toBeUndefined(); + for (const [folder, name] of [ + ["Untitled 6", "Acme Inc."], + ["Untitled 7", "a: b"], + ["Untitled 8", "CON"], + ["Untitled 9", 42], + ] as const) + expect(namedFolder(project(folder, { name }))).toBeUndefined(); }); diff --git a/canvas/server/projects.ts b/canvas/server/projects.ts index 85f5777e..3446a77e 100644 --- a/canvas/server/projects.ts +++ b/canvas/server/projects.ts @@ -116,12 +116,19 @@ export function loopbackHost(host: string | undefined) { * a name another project already has. */ export function namedFolder(dir: string) { - const name = readProjectJson(dir).name?.trim(); + const { name: given } = readProjectJson(dir); + const name = typeof given === "string" ? given.trim() : ""; + // Rejected on every platform, since a project is also opened on Windows once shared: there a + // trailing dot or space is dropped from the folder's name, and these characters and device + // names cannot be one. if ( !/^Untitled( \d+)?$/.test(path.basename(dir)) || !name || name.startsWith(".") || - path.basename(name) !== name + path.basename(name) !== name || + // oxlint-disable-next-line no-control-regex + /[<>:"|?*\x00-\x1f]|[. ]$/.test(name) || + /^(con|prn|aux|nul|com\d|lpt\d)(\..*)?$/i.test(name) ) return undefined; const to = path.join(path.dirname(dir), name); @@ -181,6 +188,13 @@ export function createProjectsServer(options: { // the folder takes the name, and the pages open on it follow (canvasIndex.ts). A name no folder // can have, or one another project has, leaves it where it is. A folder the person named keeps // its name whatever project.json says. Answers the new folder. + // A renamed project's old name, answered with its new folder: a page on the old address still + // saves its last edits there as it leaves, and a message sent as the turn ended names it. + const renamed = new Map(); + const project = (name: string) => { + const dir = projects().get(name) ?? renamed.get(name); + return dir !== undefined && fs.existsSync(dir) ? dir : undefined; + }; const named = (dir: string) => { const to = namedFolder(dir); if (to === undefined) return undefined; @@ -199,10 +213,14 @@ export function createProjectsServer(options: { sp?.close(); return undefined; } - sp?.close({ + renamed.set(path.basename(dir), to); + // Every open page, not only the project's: the window's tab for it moves whichever is in front. + const moved = { from: `/p/${encodeURIComponent(path.basename(dir))}/`, to: `/p/${encodeURIComponent(name)}/`, - }); + }; + for (const other of [root, sp, ...sps.values()]) other?.moved(moved); + sp?.close(); return to; }; @@ -211,6 +229,7 @@ export function createProjectsServer(options: { const agent = createAgentServer({ examplesDir, named, + project, projects, repoRoot, workspaces: path.join(projectsDir, ".workspaces"), @@ -491,12 +510,22 @@ export function createProjectsServer(options: { if (rest === undefined) return root.handle(req, res, next); let dir: string | undefined; try { - dir = projects().get(decodeURIComponent(name)); + dir = project(decodeURIComponent(name)); } catch {} // a broken escape is no project's name if (dir === undefined) { res.statusCode = 404; return res.end("no such project"); } + // A page asked for by the name the project had, from a card or a link made before it moved: + // sent to its address now, so it opens once, under one tab. + if (!projects().has(decodeURIComponent(name)) && req.method === "GET") { + res.statusCode = 302; + res.setHeader( + "location", + `/p/${encodeURIComponent(path.basename(dir))}${rest}`, + ); + return res.end(); + } // Made by a newer app, which may keep it in a way this one would misread, and then write back. const format = ( readJson(path.join(dir, "project.json")) as { format?: unknown } diff --git a/canvas/server/sp.ts b/canvas/server/sp.ts index d78c9b92..5ef53260 100644 --- a/canvas/server/sp.ts +++ b/canvas/server/sp.ts @@ -1306,10 +1306,10 @@ export function createSpServer(options: { return { handle, unwatch, - /** Ends the pages' streams, telling them first when the project has moved (projects.ts). */ - close(moved?: { from: string; to: string }) { + /** Tells the pages a project's address changed with its folder's name (projects.ts). */ + moved: (moved: { from: string; to: string }) => broadcast("moved", moved), + close() { unwatch(); - if (moved) broadcast("moved", moved); for (const page of pages) page.end(); pages.clear(); }, diff --git a/canvas/src/AppShell.tsx b/canvas/src/AppShell.tsx index 7d5ca23a..8eeb524d 100644 --- a/canvas/src/AppShell.tsx +++ b/canvas/src/AppShell.tsx @@ -154,6 +154,7 @@ export function AppShell() { setTabs((tabs) => withTab(tabs, tab)); }, moved(from, to) { + listProjects(); setTabs((tabs) => tabs.map((tab) => tab.kind === "project" && tab.url === from diff --git a/canvas/src/canvasIndex.ts b/canvas/src/canvasIndex.ts index 01495ba6..0608da94 100644 --- a/canvas/src/canvasIndex.ts +++ b/canvas/src/canvasIndex.ts @@ -169,12 +169,14 @@ export async function loadCanvasIndex(live = true, bridge = false) { `${import.meta.env.BASE_URL}__sp/events${bridge ? "?bridge=1" : ""}`, ); events.addEventListener("reload", () => window.location.reload()); - // The project's folder took the name its agent gave it (server/projects.ts, `named`): the same - // view at the new address, in the same tab, which the window moves first. + // A project's folder took the name its agent gave it (server/projects.ts, `named`), told to every + // open page: the window moves its tab, and a page of that project reloads the same view at the + // new address. events.addEventListener("moved", (event) => { const { from, to } = JSON.parse(event.data) as { from: string; to: string }; window.parent.spShell?.moved(from, to); const url = new URL(window.location.href); + if (!url.pathname.startsWith(from)) return; url.pathname = to + url.pathname.slice(from.length); window.location.replace(url.href); }); diff --git a/docs/2026-09-27-project-folder-name.md b/docs/2026-09-27-project-folder-name.md index 4b81c01f..fed07cf6 100644 --- a/docs/2026-09-27-project-folder-name.md +++ b/docs/2026-09-27-project-folder-name.md @@ -17,12 +17,25 @@ these, and it is still running inside the folder it would be moving. When a turn ends cleanly and no other turn is running, `named` in `canvas/server/projects.ts` renames an `Untitled( N)?` folder to the -`project.json` name. It skips a name that is empty, starts with a dot, -contains a slash, or is already another folder's. It closes the project's -file watchers first, because Windows won't rename a watched folder. Then it -sends the open pages a `moved` event. The page reloads at the new address, -and the window moves its tab first (`spShell.moved`). The session's -`projects[]` switches to the new path, so the next message runs in it. +`project.json` name. It skips a name that is empty, starts with a dot, or is +already another folder's. It also skips a name that no folder can have on +Windows (checked on every platform, since shared projects open there too). +It closes the project's file watchers first, because Windows won't rename a +watched folder (checked: `EPERM` while watched, fine after). Then it sends +every open page a `moved` event. The window moves its tab +(`spShell.moved`) whichever project is in front, and a page of the moved +project reloads at the new address. The session's `projects[]` switches to +the new path, so the next message runs in it. + +The old name stays an alias for the rest of the server's life. Several +requests can arrive after the rename and still carry the old name: + +- a page's last canvas save as it unloads; +- a message sent just as the turn ended; +- a click on a home card drawn before the move. + +The alias catches these. A GET on the old name redirects to the new one, so +the project opens in only one tab. A folder the person named themselves keeps its name, whatever `project.json` says. The prompt tells the agent to leave the folder alone, so it doesn't From 3b4ff979580d9923c57823fd7a75ab4473bec04d Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Sun, 27 Sep 2026 03:06:58 -0400 Subject: [PATCH 3/6] listProjects hoisted, so spShell.moved can call it Co-Authored-By: Claude Opus 5.5 --- canvas/src/AppShell.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/canvas/src/AppShell.tsx b/canvas/src/AppShell.tsx index 8eeb524d..be943a08 100644 --- a/canvas/src/AppShell.tsx +++ b/canvas/src/AppShell.tsx @@ -193,7 +193,7 @@ export function AppShell() { // The projects, fetched again each time home opens or closes, since that is where one was // made, renamed or edited since, and when home deletes one; and with them the one thing about // another project this window can learn, that it has gone since its tab was left open. - const listProjects = () => { + function listProjects() { if (!local()) return; void fetch("/__sp/projects.json") .then((response) => response.json()) @@ -209,7 +209,7 @@ export function AppShell() { ), ); }); - }; + } useEffect(listProjects, [home]); /** Loads a project's canvas at an address of the window's into the frame. */ From cbc46f5aa507874909e459a59f02ef326b54bd83 Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Sun, 27 Sep 2026 03:17:41 -0400 Subject: [PATCH 4/6] A backslash can't name the folder; a trailing dot or space is trimmed Windows drops a trailing dot or space from a folder's name, so trim them rather than leave "Acme Inc." an Untitled folder for good. Co-Authored-By: Claude Opus 5.5 --- canvas/server/projects.test.ts | 5 ++++- canvas/server/projects.ts | 11 ++++++----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/canvas/server/projects.test.ts b/canvas/server/projects.test.ts index de024208..25dfa883 100644 --- a/canvas/server/projects.test.ts +++ b/canvas/server/projects.test.ts @@ -260,6 +260,9 @@ it("names an Untitled project's folder from its project.json", () => { ); return path.join(tmp, folder); }; + expect(namedFolder(project("Untitled 10", { name: "Acme Inc. " }))).toBe( + path.join(tmp, "Acme Inc"), + ); expect(namedFolder(project("Untitled 3", { name: " Kasra " }))).toBe( path.join(tmp, "Kasra"), ); @@ -271,7 +274,7 @@ it("names an Untitled project's folder from its project.json", () => { namedFolder(project("Untitled 5", { name: ".hidden" })), ).toBeUndefined(); for (const [folder, name] of [ - ["Untitled 6", "Acme Inc."], + ["Untitled 6", "a\\b"], ["Untitled 7", "a: b"], ["Untitled 8", "CON"], ["Untitled 9", 42], diff --git a/canvas/server/projects.ts b/canvas/server/projects.ts index 3446a77e..730b444f 100644 --- a/canvas/server/projects.ts +++ b/canvas/server/projects.ts @@ -117,17 +117,18 @@ export function loopbackHost(host: string | undefined) { */ export function namedFolder(dir: string) { const { name: given } = readProjectJson(dir); - const name = typeof given === "string" ? given.trim() : ""; - // Rejected on every platform, since a project is also opened on Windows once shared: there a - // trailing dot or space is dropped from the folder's name, and these characters and device - // names cannot be one. + // Held to what Windows allows on every platform, since a project is also opened there once + // shared: it drops a trailing dot or space, so they go here too, and these characters and device + // names cannot be a folder's. + const name = + typeof given === "string" ? given.trim().replace(/[. ]+$/, "") : ""; if ( !/^Untitled( \d+)?$/.test(path.basename(dir)) || !name || name.startsWith(".") || path.basename(name) !== name || // oxlint-disable-next-line no-control-regex - /[<>:"|?*\x00-\x1f]|[. ]$/.test(name) || + /[<>:"|?*\\\x00-\x1f]/.test(name) || /^(con|prn|aux|nul|com\d|lpt\d)(\..*)?$/i.test(name) ) return undefined; From ef34d1b6891ee6a47152a44d7116d03da5cb3299 Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Sun, 27 Sep 2026 03:18:58 -0400 Subject: [PATCH 5/6] A rename waits only on turns in the same project; new projects skip old names A turn started in another project no longer defers the rename for good, and a new Untitled project never takes a name a renamed one still answers to. Co-Authored-By: Claude Opus 5.5 --- canvas/server/agent.ts | 7 +++++-- canvas/server/projects.ts | 7 ++++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/canvas/server/agent.ts b/canvas/server/agent.ts index 9addffee..3762f7ba 100644 --- a/canvas/server/agent.ts +++ b/canvas/server/agent.ts @@ -72,6 +72,8 @@ export function createAgentServer(options: { child: ChildProcess; /** The Stop button was pressed. Windows ends a run by exit code 1, which says nothing. */ stopped: boolean; + /** The project it works on, if any. */ + dir: string | undefined; } >(); @@ -532,6 +534,7 @@ export function createAgentServer(options: { }, }), stopped: false, + dir, }); runs.set(run.id, run); record.runs.push(run.id); @@ -642,11 +645,11 @@ export function createAgentServer(options: { }); run.child.on("close", (code, signal) => { // The turn that named an unnamed project is over, so its folder can take the name. - // Not when another turn has already started on it: the next one to end moves it. + // Not when another turn has already started on it: that one moves it when it ends. if ( dir !== undefined && code === 0 && - ![...runs.values()].some((r) => r !== run && !ended(r)) + ![...runs.values()].some((r) => r !== run && r.dir === dir && !ended(r)) ) { // Never in the way of the run ending: a throw here would leave it running for good. try { diff --git a/canvas/server/projects.ts b/canvas/server/projects.ts index 730b444f..93e09580 100644 --- a/canvas/server/projects.ts +++ b/canvas/server/projects.ts @@ -384,7 +384,12 @@ export function createProjectsServer(options: { let name = typeof parsed.name === "string" ? parsed.name.trim() : ""; if (name === "") { name = "Untitled"; - for (let n = 2; fs.existsSync(path.join(projectsDir, name)); n++) + // Nor a name a renamed project had, which still answers for it (`renamed`). + for ( + let n = 2; + fs.existsSync(path.join(projectsDir, name)) || renamed.has(name); + n++ + ) name = `Untitled ${n}`; } if (name.startsWith(".") || path.basename(name) !== name) From 22ce6d143ea61cd4dc43174c6dbc3ca603189480 Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Tue, 29 Sep 2026 04:44:31 -0400 Subject: [PATCH 6/6] A folder is renamed only if the app named it Untitled, and never takes an old name A project made without a name now carries "unnamed": true in project.json, and only such a folder follows its agent's name. One the person typed "Untitled" keeps it. A typed name that a renamed project still answers to is refused, as the generated ones already skip it. Co-Authored-By: Claude Opus 5.5 --- canvas/server/projects.test.ts | 18 +++++++++++++++++- canvas/server/projects.ts | 16 ++++++++++------ canvas/server/sp.ts | 1 + skills/sp-canvas/references/layout.md | 3 +++ 4 files changed, 31 insertions(+), 7 deletions(-) diff --git a/canvas/server/projects.test.ts b/canvas/server/projects.test.ts index 25dfa883..fe81fc38 100644 --- a/canvas/server/projects.test.ts +++ b/canvas/server/projects.test.ts @@ -202,6 +202,18 @@ it("serves every project at its own address and makes new ones", async () => { expect( JSON.parse((await ask("/__sp/projects", { name: " " })).text).url, ).toBe(url); + expect( + JSON.parse( + fs.readFileSync(path.join(tmp, "projects/Untitled/project.json"), "utf8"), + ).unnamed, + ).toBe(true); + // Typed, "Untitled" is the person's name for it, so its folder is never renamed. + await ask("/__sp/projects", { name: "Untitled 9" }); + expect( + JSON.parse( + fs.readFileSync(path.join(tmp, "projects/Untitled 9/project.json"), "utf8"), + ).unnamed, + ).toBeUndefined(); write("projects/Untitled/project.json", JSON.stringify({ name: "Gamma" })); const titled = JSON.parse((await ask("/__sp/projects.json")).text); expect(titled.find((p: any) => p.name === "Untitled").title).toBe("Gamma"); @@ -256,7 +268,7 @@ it("names an Untitled project's folder from its project.json", () => { fs.mkdirSync(path.join(tmp, folder)); fs.writeFileSync( path.join(tmp, folder, "project.json"), - JSON.stringify(json), + JSON.stringify({ unnamed: true, ...json }), ); return path.join(tmp, folder); }; @@ -268,6 +280,10 @@ it("names an Untitled project's folder from its project.json", () => { ); expect(namedFolder(project("Untitled", { format: 1 }))).toBeUndefined(); expect(namedFolder(project("Mine", { name: "Other" }))).toBeUndefined(); + // An "Untitled" the person typed has no `unnamed`. + expect( + namedFolder(project("Untitled 11", { name: "Other", unnamed: undefined })), + ).toBeUndefined(); expect(namedFolder(project("Untitled 2", { name: "Mine" }))).toBeUndefined(); expect(namedFolder(project("Untitled 4", { name: "a/b" }))).toBeUndefined(); expect( diff --git a/canvas/server/projects.ts b/canvas/server/projects.ts index 93e09580..4a959fbd 100644 --- a/canvas/server/projects.ts +++ b/canvas/server/projects.ts @@ -112,17 +112,18 @@ export function loopbackHost(host: string | undefined) { /** * Where an "Untitled" project's folder goes once its agent has named it in project.json: a folder - * of that name beside it. Undefined for a folder the person named, a name no folder can have, and - * a name another project already has. + * of that name beside it. Undefined for a folder the person named, "Untitled" included, a name no + * folder can have, and a name another project already has. */ export function namedFolder(dir: string) { - const { name: given } = readProjectJson(dir); + const { name: given, unnamed } = readProjectJson(dir); // Held to what Windows allows on every platform, since a project is also opened there once // shared: it drops a trailing dot or space, so they go here too, and these characters and device // names cannot be a folder's. const name = typeof given === "string" ? given.trim().replace(/[. ]+$/, "") : ""; if ( + unnamed !== true || !/^Untitled( \d+)?$/.test(path.basename(dir)) || !name || name.startsWith(".") || @@ -382,7 +383,8 @@ export function createProjectsServer(options: { // "Untitled", and its agent names it (sp.ts, PROJECT_JSON). It goes under the projects // folder, so there is no place to pick. The checks are the ones the dialog cannot make. let name = typeof parsed.name === "string" ? parsed.name.trim() : ""; - if (name === "") { + const unnamed = name === ""; + if (unnamed) { name = "Untitled"; // Nor a name a renamed project had, which still answers for it (`renamed`). for ( @@ -398,7 +400,8 @@ export function createProjectsServer(options: { "A name cannot start with a dot or have a slash in it.", ); const dir = path.join(projectsDir, name); - if (fs.existsSync(dir)) + // A renamed project's old name is still its own (`renamed`). + if (fs.existsSync(dir) || renamed.has(name)) return send( 409, `You already have a project called “${name}”. Try another name.`, @@ -409,9 +412,10 @@ export function createProjectsServer(options: { // nothing to copy in. fs.mkdirSync(path.join(dir, CANVASES), { recursive: true }); // Its id is what a package of it is known by, whatever the folder is renamed to. + // `unnamed` marks the folder as one the app named, so its agent's name can replace it. fs.writeFileSync( path.join(dir, "project.json"), - `${JSON.stringify({ format: PROJECT_FORMAT, id: crypto.randomUUID() }, null, 2)}\n`, + `${JSON.stringify({ format: PROJECT_FORMAT, id: crypto.randomUUID(), ...(unnamed && { unnamed }) }, null, 2)}\n`, ); } catch (e) { // A new name does not fix an unwritable Documents, so say what failed. diff --git a/canvas/server/sp.ts b/canvas/server/sp.ts index 26b0dce4..aa2661bd 100644 --- a/canvas/server/sp.ts +++ b/canvas/server/sp.ts @@ -281,6 +281,7 @@ export const readProjectJson = (dir: string) => (readJson(path.join(dir, PROJECT_JSON)) ?? {}) as { cover?: ChosenCover; name?: string; + unnamed?: boolean; }; export function createSpServer(options: { diff --git a/skills/sp-canvas/references/layout.md b/skills/sp-canvas/references/layout.md index 93e7a851..441b765e 100644 --- a/skills/sp-canvas/references/layout.md +++ b/skills/sp-canvas/references/layout.md @@ -163,6 +163,9 @@ so a new kind of content needs no change to the package. knows the project by. Never change it or copy it into another project. - `name` is the title shown for the project, which the agent sets; the folder name when there is none. +- `unnamed` is `true` on a project made without a name. Its `Untitled` + folder is renamed to `name` once the agent sets it; a folder without it + keeps its name. - `author` is the GitHub login of whoever made the project, and `contributors` the logins of anyone who has changed it since. The first `sp pack -o` sets `author` to the login `gh` is signed in as. The community