diff --git a/CLAUDE.md b/CLAUDE.md index ecc6b1d2..83f77c69 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -181,7 +181,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **DeepSeek web UI** (`POST`/`GET`/`DELETE /api/deepseek/web`, `deepseek-web-server.ts`): the Run menu's "DeepSeek web UI..." entry supervises ONE background `dsh web` child process, deliberately **NOT a shell session**. ⚠️ Every piece is load-bearing: exactly one server (a second click REUSES it), restarted when the browser authority changes (`--trusted-host`, last asker wins), killed on shutdown via `stopDeepSeekWeb()` (the detached child would otherwise outlive Codeman and hold its port), and failures returned to the caller. ⚠️ Never hardcode the port: search from 3080 across 40, detect free ports by BINDING, then wait for the server to really answer. ⚠️ Both `POST` and `DELETE` must stay behind `canUsernameRunPrivilegedCommands` (booting a profile runs its plugin code; the server is shared). → [architecture-invariants#deepseek-web-ui](docs/architecture-invariants.md#deepseek-web-ui) -**Case rename** (`POST /api/cases/:name/rename`, `src/case-rename.ts`; `docs/wiki/Core-Concepts.md#case`): a case under the cases dir is its folder name and always moves; a linked case renames its alias and moves its real folder only with `renameFolder`; remote and Docker cases are refused. ⚠️ A folder that sessions have open is the hazard: live sessions at or under it make the route answer `409 requiresStop` with their names, and only `stopSessions: true` ends them (via `ctx.cleanupSession`, nothing is restarted). `withCaseLock` holds the old and new paths for the whole operation (`isCasePathLocked`; the `Session` constructor throws for a working directory inside a held path, so nothing starts mid-move), `moveCaseFolder` refuses an existing target itself (POSIX `rename` would silently replace an EMPTY directory) and retries `EBUSY`/`EPERM`/`EACCES` before answering `423 LOCKED`. If the registries (`linked-cases.json`, `caseOrder`, `lastUsedCase`) fail after the move, the folder is moved back. Claude Code keys history by path (`claudeProjectDirName`: every non-alphanumeric becomes `-`), so `/projects/` is renamed too; other CLIs' history, Docker bind mounts, `git worktree` links, cron jobs' `workingDir` and Claude history of subfolder sessions are NOT migrated. The history scanner prefers the decoded project key over a transcript's `cwd` that no longer exists, so Resume follows a rename. Only an admin's rename touches the server-wide `settings.json` (`caseOrder`, `lastUsedCase`); the owner's default session group is carried across with `TabLayoutService`. `settings.json` is written before `linked-cases.json`, and the registry is restored if its write fails. +**Case rename** (`POST /api/cases/:name/rename`, `src/case-rename.ts`; `docs/wiki/Core-Concepts.md#case`): a case under the cases dir is its folder name and always moves; a linked case renames its alias and moves its real folder only with `renameFolder`; remote and Docker cases are refused. ⚠️ A folder that sessions have open is the hazard: live sessions at or under it make the route answer `409 requiresStop` with their names, and only `stopSessions: true` ends them (via `ctx.cleanupSession`, nothing is restarted). `withCaseLock` holds the old and new paths for the whole operation (`isCasePathLocked`; the `Session` constructor throws for a working directory inside a held path, so nothing starts mid-move), `moveCaseFolder` refuses an existing target itself (POSIX `rename` would silently replace an EMPTY directory) and retries `EBUSY`/`EPERM`/`EACCES` before answering `423 LOCKED`. If the registries (`linked-cases.json`, `caseOrder`, `lastUsedCase`) fail after the move, `settings.json` and the folder are put back (a failed move-back says the folder is now at the new path), and an error after `stopSessions` carries `stoppedSessions`. Claude Code keys history by path (`claudeProjectDirName`: every non-alphanumeric becomes `-`), so `/projects/` is renamed too; other CLIs' history, Docker bind mounts, `git worktree` links, cron jobs' `workingDir`, Claude history of subfolder sessions and Claude Code's per-project entry in `~/.claude.json` (local-scope `mcpServers`, `allowedTools`, trust; writing another CLI's config is the opt-in MCP sync's job) are NOT migrated. The history scanner prefers the decoded project key over a transcript's `cwd` that no longer exists, so Resume follows a rename. Only an admin's rename touches the server-wide `settings.json` (`caseOrder`, `lastUsedCase`); the owner's default session group is carried across with `TabLayoutService`. `settings.json` is written before `linked-cases.json`, and both are restored if the registry write fails. **Custom Model Endpoint Profiles** (opt-in, `customModelEndpointsEnabled`, SYNCED, default OFF; `docs/custom-model-endpoints.md`): points a session at a user-configured OpenAI-compatible endpoint (store `custom-model-hosts.ts`, `~/.codeman/custom-model-hosts.json`, 0600). `authStyle` is `bearer` or `api-key`, never both headers (hangs the server). The per-CLI redirect is registry data, `capabilities.customModelInjection` (`env` / `configContentEnv` / `configDir` / `unsupported`), computed by the pure `custom-model-injection.ts`; ⚠️ `configDir` writes an isolated per-session config, NEVER the user's real `~/.codex`/`~/.pi`/`~/.omp`/grok config. ⚠️ Claude applies via `Session.restartCli()` (a relaunch in the existing pane, so the conversation is pinned as `resumeSessionId`), the rest one-shot at launch; retired env keys must also be `setenv -u`'d (`_pendingEnvUnsets`), since tmux env survives `respawn-pane`. ⚠️ Remote/Docker sessions are refused (400). ⚠️ Persist only KEYS (`__customModel`), never values (they carry the API key). ⚠️ Every redirectable var must be in that CLI's `privilegedEnvKeys`, and `ANTHROPIC_*` stays out of claude's `allowedPrefixes`. ⚠️ The Run-menu picker builds entries from `window.__codemanCustomModelClis` (escaped via `escapeScriptJson()`), never a hardcoded CLI id list, and launches through `run()` via a temporary `_runMode` swap, never `setRunMode()`. → [architecture-invariants#custom-model-endpoint-profiles](docs/architecture-invariants.md#custom-model-endpoint-profiles) diff --git a/docs/api-reference.md b/docs/api-reference.md index 09b58a86..01f3a3a2 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1135,7 +1135,7 @@ A local case whose folder is the top of a git repository can keep **worktrees** A non-admin's `POST /api/quick-start` and `POST /api/ralph-loop/start` work as before but do not record their case as `lastUsedCase` (a server-wide key). -`POST /api/cases/:name/rename` renames a case. A case under the cases directory is its folder, so it always moves; a linked case renames its alias in `linked-cases.json` and moves its real folder (next to the old one, named `newName`) only with `renameFolder: true`. Remote and Docker cases are `400`. The new name must be free in every namespace the list merges (`409`). Live sessions whose working directory is at or inside the folder that would move make it `409` with `requiresStop: true` and `sessions: [{ id, name }]`; retry with `stopSessions: true` to end them first (nothing is restarted); another user's session is `403`. The folder is held by an in-process lock for the whole operation (an overlapping rename is `409 BUSY`, and `new Session` refuses a working directory inside it), the move is retried for a transient `EBUSY`/`EPERM`/`EACCES` and then answers `423` with `renameCode: "LOCKED"`, a cross-device move is `400`, and if the registries cannot be written afterwards the folder is moved back. On success the response is `{ oldName, newName, oldPath, newPath, folderMoved, stoppedSessions }`; `caseOrder`, `lastUsedCase`, `linked-cases.json` and Claude Code's `projects/` history are updated, and `case:renamed` is broadcast. A linked case is admin only in multi-user mode. +`POST /api/cases/:name/rename` renames a case. A case under the cases directory is its folder, so it always moves; a linked case renames its alias in `linked-cases.json` and moves its real folder (next to the old one, named `newName`) only with `renameFolder: true`. Remote and Docker cases are `400`. The new name must be free in every namespace the list merges (`409`). Live sessions whose working directory is at or inside the folder that would move make it `409` with `requiresStop: true` and `sessions: [{ id, name }]`; retry with `stopSessions: true` to end them first (nothing is restarted); another user's session is `403`. The folder is held by an in-process lock for the whole operation (an overlapping rename is `409 BUSY`, and `new Session` refuses a working directory inside it), the move is retried for a transient `EBUSY`/`EPERM`/`EACCES` and then answers `423` with `renameCode: "LOCKED"`, a cross-device move is `400`, and if the registries cannot be written afterwards `settings.json` and the folder are put back (when the folder cannot be moved back, the `500` says it is now at the new path). An error from the rename itself carries `stoppedSessions`: how many sessions it ended before it failed. On success the response is `{ oldName, newName, oldPath, newPath, folderMoved, stoppedSessions }`; `caseOrder`, `lastUsedCase`, `linked-cases.json` and Claude Code's `projects/` history are updated, and `case:renamed` is broadcast. A linked case is admin only in multi-user mode. ### System operations diff --git a/docs/wiki/Core-Concepts.md b/docs/wiki/Core-Concepts.md index b2197382..f71f9758 100644 --- a/docs/wiki/Core-Concepts.md +++ b/docs/wiki/Core-Concepts.md @@ -33,9 +33,9 @@ registration, and for a linked case that is all it removes. **Renaming a case.** In the case manager (Settings, or the case picker's manage view) the pencil button renames a case and its folder. A case under `~/codeman-cases` _is_ its folder name, so it -always moves. A linked case renames its name in the list only; Codeman then asks whether to rename -the real folder as well, next to where it is (Cancel leaves the folder alone). Remote and Docker -cases cannot be renamed. Renaming does the following for you: +always moves. A linked case renames its name in the list only, unless you tick **Also rename the +folder on disk** in the rename dialog: then its real folder is renamed too, next to where it is. +Remote and Docker cases cannot be renamed. Renaming does the following for you: - **Sessions are never moved from under you.** If live sessions have the folder open, the rename is refused and lists them; you can then choose to stop them (nothing is restarted). While a rename runs, @@ -49,8 +49,10 @@ cases cannot be renamed. Renaming does the following for you: Resume list uses the renamed folder too, and the case's default session group follows the new name. - **Not migrated.** Scheduled (cron) jobs keep the old `workingDir` and fail at their next run until you edit them; Claude history of sessions started in a subfolder (including `.claude/worktrees/`) - stays under the old path. In multi-user mode only an admin's rename updates the shared case order and - last used case. + stays under the old path. Claude Code's own per-project settings in `~/.claude.json` (local-scope MCP + servers, allowed tools, the folder's trust) are keyed by the old path too, so the renamed case starts + without them: Codeman does not write another CLI's configuration here. In multi-user mode only an + admin's rename updates the shared case order and last used case. - **The case order, the last used case and this browser's per-case options follow the new name.** **Clone Repo never asks for credentials.** It uses whatever the server's own git already has: diff --git a/src/web/routes/case-routes.ts b/src/web/routes/case-routes.ts index b0f05cc4..9f1c1a53 100644 --- a/src/web/routes/case-routes.ts +++ b/src/web/routes/case-routes.ts @@ -1845,11 +1845,19 @@ export function registerCaseRoutes( ); } + // Sessions really ended so far: an error after `stopSessions` still says how many are gone. + let stopped = 0; try { await withCaseLock(oldPath, newPath, async () => { - for (const s of open) await ctx.cleanupSession(s.id, true, 'case_rename'); + for (const s of open) { + await ctx.cleanupSession(s.id, true, 'case_rename'); + stopped++; + } if (moveFolder) await moveCaseFolder(oldPath, newPath); const originalLinks = isLinked ? await readLinkedCases() : undefined; + // The settings.json content before this rename wrote it, set only once a write is attempted (an + // absent file reads as {}, which must not be written back as a file that never existed). + let originalSettings: Record | undefined; try { // settings.json is server-wide and caseOrder/lastUsedCase name the admin's cases, so only the // admin's rename touches it (a non-admin's own rename must not rewrite someone else's list). @@ -1859,6 +1867,7 @@ export function registerCaseRoutes( const order = renameInOrder(existing.caseOrder, name, newName); const last = existing.lastUsedCase === name ? newName : undefined; if (order || last) { + originalSettings = existing; await fs.writeFile( SETTINGS_PATH, JSON.stringify( @@ -1881,9 +1890,20 @@ export function registerCaseRoutes( } } } catch (err) { - // The folder moved but the registries did not follow: put it back rather than leave a case - // whose name and folder disagree. - if (moveFolder) await fs.rename(newPath, oldPath).catch(() => {}); + // The folder moved but the registries did not follow: put settings.json and the folder back + // rather than leave a case whose name and folder disagree. + if (originalSettings) { + await fs.writeFile(SETTINGS_PATH, JSON.stringify(originalSettings, null, 2)).catch(() => {}); + } + if (moveFolder) { + const movedBack = await fs.rename(newPath, oldPath).then( + () => true, + () => false + ); + if (!movedBack) { + throw new Error(`${getErrorMessage(err)}. The folder could not be moved back and is now at ${newPath}.`); + } + } throw err; } if (moveFolder) await moveClaudeHistory(oldPath, newPath); @@ -1911,9 +1931,9 @@ export function registerCaseRoutes( : err.code === 'BUSY' ? ApiErrorCode.CONFLICT : ApiErrorCode.OPERATION_FAILED; - return fail(status, code, err.message, { renameCode: err.code }); + return fail(status, code, err.message, { renameCode: err.code, stoppedSessions: stopped }); } - return fail(500, ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); + return fail(500, ApiErrorCode.OPERATION_FAILED, getErrorMessage(err), { stoppedSessions: stopped }); } }); diff --git a/src/web/sse-events.ts b/src/web/sse-events.ts index 9e6f0a30..434bfad9 100644 --- a/src/web/sse-events.ts +++ b/src/web/sse-events.ts @@ -32,7 +32,7 @@ * - **Custom Model Endpoint Profiles** (1): swapped-out (a session's model got evicted by another session on the same llama-swap endpoint) * - **Orchestrator** (12): stateChanged, planProgress, planReady, phase*, verification, task*, completed, error * - **Clipboard** (1): write - * - **Cases** (4): created, linked, deleted, order-changed + * - **Cases** (5): created, linked, deleted, renamed, order-changed * - **Docker cases** (8): exportComplete/Failed, importComplete, imageBuild*, containerRecreated * - **Multi-user** (3): admin:usersChanged, auth:passwordChangeRequired, session:orderChanged * - **Web tabs** (2): webview:changed, tab:layoutChanged diff --git a/test/routes/case-rename-routes.test.ts b/test/routes/case-rename-routes.test.ts index cb1a2a10..6816a999 100644 --- a/test/routes/case-rename-routes.test.ts +++ b/test/routes/case-rename-routes.test.ts @@ -6,6 +6,7 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'; +import fs from 'node:fs/promises'; import { homedir } from 'node:os'; import { basename, dirname, join } from 'node:path'; import { createRouteTestHarness } from './_route-test-utils.js'; @@ -182,11 +183,12 @@ describe('POST /api/cases/:name/rename (a linked case)', () => { expect(linked()).toEqual({ renamed: join(work, 'renamed') }); }); - it('puts the folder back when the registry cannot be written', async () => { + it('puts the folder and settings.json back when the registry cannot be written', async () => { const { app } = await createRouteTestHarness(registerCaseRoutes); const real = join(work, 'my-project'); mkdirSync(real); writeFileSync(LINKED(), JSON.stringify({ proj: real })); + writeFileSync(SETTINGS_PATH, JSON.stringify({ caseOrder: ['a', 'proj', 'b'], lastUsedCase: 'proj' })); // the registry reads fine but cannot be written: the rename must undo the folder move chmodSync(LINKED(), 0o444); const res = await rename(app, 'proj', { newName: 'renamed', renameFolder: true }); @@ -194,6 +196,44 @@ describe('POST /api/cases/:name/rename (a linked case)', () => { expect(existsSync(real)).toBe(true); expect(linked()).toEqual({ proj: real }); expect(existsSync(join(work, 'renamed'))).toBe(false); + // settings.json is written first, so it must be put back too + expect(settings()).toEqual({ caseOrder: ['a', 'proj', 'b'], lastUsedCase: 'proj' }); + }); + + it('says where the folder is when it cannot be moved back', async () => { + const { app } = await createRouteTestHarness(registerCaseRoutes); + const real = join(work, 'my-project'); + mkdirSync(real); + writeFileSync(LINKED(), JSON.stringify({ proj: real })); + chmodSync(LINKED(), 0o444); + const moved = join(work, 'renamed'); + const realRename = fs.rename; + const spy = vi.spyOn(fs, 'rename').mockImplementation(async (from, to) => { + if (from === moved) throw Object.assign(new Error('resource busy'), { code: 'EBUSY' }); + return realRename(from, to); + }); + try { + const res = await rename(app, 'proj', { newName: 'renamed', renameFolder: true }); + expect(res.statusCode).toBe(500); + expect(res.json().error).toContain(`is now at ${moved}`); + expect(existsSync(moved)).toBe(true); + } finally { + spy.mockRestore(); + } + }); + + it('reports the sessions it stopped when the rename fails after stopping them', async () => { + const { app, ctx } = await createRouteTestHarness(registerCaseRoutes); + const real = join(work, 'my-project'); + mkdirSync(real); + writeFileSync(LINKED(), JSON.stringify({ proj: real })); + chmodSync(LINKED(), 0o444); + (ctx.sessions as Map).set('s1', { id: 's1', name: 'w1', workingDir: real }); + const res = await rename(app, 'proj', { newName: 'renamed', renameFolder: true, stopSessions: true }); + expect(res.statusCode).toBe(500); + expect(res.json().stoppedSessions).toBe(1); + expect(ctx.cleanupSession).toHaveBeenCalledWith('s1', true, 'case_rename'); + expect(existsSync(real)).toBe(true); }); it('puts the folder and the registry back when settings.json cannot be written', async () => {