diff --git a/canvas/server/agent.ts b/canvas/server/agent.ts index b643562b..9aa1dee3 100644 --- a/canvas/server/agent.ts +++ b/canvas/server/agent.ts @@ -45,12 +45,16 @@ 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; + /** 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, 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, @@ -68,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; } >(); @@ -368,7 +374,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 @@ -562,11 +568,12 @@ 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), }, }), stopped: false, + dir, }); runs.set(run.id, run); record.runs.push(run.id); @@ -676,6 +683,24 @@ 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: that one moves it when it ends. + if ( + dir !== undefined && + code === 0 && + ![...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 { + 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; 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..fe81fc38 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 @@ -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"); @@ -248,3 +260,40 @@ 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({ unnamed: true, ...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"), + ); + 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( + namedFolder(project("Untitled 5", { name: ".hidden" })), + ).toBeUndefined(); + for (const [folder, name] of [ + ["Untitled 6", "a\\b"], + ["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 5e8aacd8..4a959fbd 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,33 @@ 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, "Untitled" included, a name no + * folder can have, and a name another project already has. + */ +export function namedFolder(dir: string) { + 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(".") || + 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); + 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 +185,53 @@ 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. + // 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; + 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; + } + 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; + }; + // 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, + project, projects, repoRoot, workspaces: path.join(projectsDir, ".workspaces"), @@ -312,9 +383,15 @@ 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"; - 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) @@ -323,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.`, @@ -334,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. @@ -441,12 +520,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 b1bc73af..aa2661bd 100644 --- a/canvas/server/sp.ts +++ b/canvas/server/sp.ts @@ -97,8 +97,8 @@ export function canvasFile(canvasesDir: string, examplesDir: string, name: strin /** * 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"; @@ -277,10 +277,11 @@ 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; + unnamed?: boolean; }; export function createSpServer(options: { @@ -344,7 +345,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`; @@ -1312,12 +1313,18 @@ export function createSpServer(options: { docWatcher.unref(); } + const unwatch = () => { + watcher?.close(); + docWatcher?.close(); + clearTimeout(docBatch); + }; return { handle, + unwatch, + /** 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() { - watcher?.close(); - docWatcher?.close(); - clearTimeout(docBatch); + unwatch(); for (const page of pages) page.end(); pages.clear(); }, diff --git a/canvas/src/AppShell.tsx b/canvas/src/AppShell.tsx index 2c16aadd..be943a08 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,21 @@ export function AppShell() { setShown({ tab, href }); setTabs((tabs) => withTab(tabs, tab)); }, + moved(from, to) { + listProjects(); + 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: [] }, }; }, []); @@ -176,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()) @@ -192,7 +209,7 @@ export function AppShell() { ), ); }); - }; + } useEffect(listProjects, [home]); /** Loads a project's canvas at an address of the window's into the frame. */ @@ -304,12 +321,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..0608da94 100644 --- a/canvas/src/canvasIndex.ts +++ b/canvas/src/canvasIndex.ts @@ -169,6 +169,17 @@ export async function loadCanvasIndex(live = true, bridge = false) { `${import.meta.env.BASE_URL}__sp/events${bridge ? "?bridge=1" : ""}`, ); events.addEventListener("reload", () => window.location.reload()); + // 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); + }); 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..fed07cf6 --- /dev/null +++ b/docs/2026-09-27-project-folder-name.md @@ -0,0 +1,42 @@ +# 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, 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 +offer to rename it. 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