From ba9d311cb778cabff52eded69e70e0c834de7ddd Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 8 Nov 2026 07:35:03 +0100 Subject: [PATCH 01/14] feat(agent-skill): make the injected skill copy ignore itself in git Every Codeman-managed copy of the agent skill (`.claude/skills/codeman/`, `.agents/skills/codeman/`, and the user-level copy) now carries a `.gitignore` of `*`, written once with O_EXCL before any skill file, the way the prompt uploads folder does. A user's repository no longer lists the copy as untracked, and Codeman never touches the repository's own ignore files. A file already at that name is never overwritten, a copy injected before this gains the file on its next install (reported as refreshed), and uninstall removes it only when it is the last thing left and holds exactly `*`, so a user's extra files in the folder stay ignored. Claude Code 2.1.319 runs `git check-ignore` only on directories at or above `.claude/skills`, never on a skill folder, so the skill still loads. Co-Authored-By: Claude Opus 5.5 --- src/hooks-config.ts | 69 +++++++++++++++++- test/agent-skill-targets.test.ts | 2 + test/agent-skill.test.ts | 117 ++++++++++++++++++++++++++++++- 3 files changed, 185 insertions(+), 3 deletions(-) diff --git a/src/hooks-config.ts b/src/hooks-config.ts index e1eef5a3..26fc6986 100644 --- a/src/hooks-config.ts +++ b/src/hooks-config.ts @@ -1358,6 +1358,62 @@ async function isSymlink(path: string): Promise { } } +/** + * The ignore file every Codeman-managed skill copy carries, and what it holds: `*` + * matches everything in the copy's own folder (the ignore file included), so a user's + * repository never lists `.claude/skills/codeman/` (or `.agents/skills/codeman/`) as + * untracked, without Codeman touching the repository's own `.gitignore` or + * `info/exclude`. Same idea as the prompt-uploads folder (`ensureUploadDir`). + * + * Deliberately NOT part of `readAgentSkillSource()`: it is written once with O_EXCL and + * never compared or rewritten, so a file the user put there (or edited) is theirs. + * + * Measured against Claude Code 2.1.319, whose skill discovery runs `git check-ignore` + * only on directories at or above `.claude/skills` (the nested-skills walk), never on a + * skill folder or its SKILL.md: a `*` inside `codeman/` ignores the folder's CONTENTS, + * not the folder, so the skill still loads. Codex 0.162 does no gitignore check on skills. + */ +const AGENT_SKILL_GITIGNORE = '.gitignore'; +const AGENT_SKILL_GITIGNORE_CONTENT = '*\n'; + +/** + * Write the copy's `.gitignore` if none is there. Returns whether it wrote one. + * 'wx' (O_CREAT|O_EXCL) never replaces an existing file and refuses a symlink at the + * name. A create that succeeded before the write failed (ENOSPC) would leave an empty + * file that reads as the user's next time, so it is taken back; that cleanup's own + * failure must not replace the cause. + */ +async function ensureAgentSkillGitignore(skillDir: string): Promise { + const ignoreFile = join(skillDir, AGENT_SKILL_GITIGNORE); + try { + await writeFile(ignoreFile, AGENT_SKILL_GITIGNORE_CONTENT, { flag: 'wx' }); + return true; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'EEXIST') return false; + await unlink(ignoreFile).catch(() => {}); + throw err; + } +} + +/** + * Remove the copy's `.gitignore` only when it is the LAST thing in the folder and holds + * exactly what Codeman writes. Anything else in the folder (a user's own notes) keeps + * the ignore file, so those files stay out of `git status` as they were, and a + * `.gitignore` with other content is the user's. + */ +async function removeAgentSkillGitignoreIfLast(skillDir: string): Promise { + const ignoreFile = join(skillDir, AGENT_SKILL_GITIGNORE); + try { + const entries = await readdir(skillDir); + if (entries.length !== 1 || entries[0] !== AGENT_SKILL_GITIGNORE) return; + if (!(await lstat(ignoreFile)).isFile()) return; + if ((await readFile(ignoreFile, 'utf-8')) !== AGENT_SKILL_GITIGNORE_CONTENT) return; + await unlink(ignoreFile); + } catch { + // best-effort: a folder that stays behind holds only an ignore file + } +} + /** What an install/remove actually did, so callers (CLI, logs) can say so. */ export type AgentSkillApplyResult = | 'installed' // fresh copy written @@ -1383,6 +1439,12 @@ export type AgentSkillApplyResult = * Idempotent and cheap: unchanged files are not rewritten, so calling on every * session create causes no mtime churn. * + * The copy ignores itself in git: a `.gitignore` of `*` is written FIRST, before any + * skill file, so a `git status` that runs mid-install never lists the folder either + * (`ensureAgentSkillGitignore`). It is written once and never rewritten; a copy made + * before this existed gains it on its next install or refresh, which then reports + * 'refreshed'. + * * Serialized on the skill dir through the same lock the settings writers use: two * sessions created at once in one repo both inject this skill, and interleaving their * ownership read with the other's write reports a bogus result (an 'unchanged' for a @@ -1402,7 +1464,8 @@ export async function installAgentSkillInto(skillDir: string): Promise {}); } await rmdir(join(skillDir, 'reference')).catch(() => {}); // fails when non-empty, fine + await removeAgentSkillGitignoreIfLast(skillDir); await rmdir(skillDir).catch(() => {}); await rmdir(dirname(skillDir)).catch(() => {}); // prune `.claude/skills` (or the CLI's own dir) if now empty return 'removed'; diff --git a/test/agent-skill-targets.test.ts b/test/agent-skill-targets.test.ts index 49d414c8..d0771313 100644 --- a/test/agent-skill-targets.test.ts +++ b/test/agent-skill-targets.test.ts @@ -81,6 +81,8 @@ describe('applyAgentSkill with a directory', () => { expect(skillMd.startsWith('---\nname: codeman')).toBe(true); expect(skillMd).toContain(MARKER_PREFIX); expect(existsSync(join(skillDir, 'reference', 'endpoints.md'))).toBe(true); + // It ignores itself in git there too, like the Claude Code copy. + expect(await readFile(join(skillDir, '.gitignore'), 'utf-8')).toBe('*\n'); expect(existsSync(join(casePath, '.claude'))).toBe(false); expect(await applyAgentSkill(casePath, true, AGENTS_DIR)).toBe('unchanged'); }); diff --git a/test/agent-skill.test.ts b/test/agent-skill.test.ts index f3c9340f..a5d01b56 100644 --- a/test/agent-skill.test.ts +++ b/test/agent-skill.test.ts @@ -7,12 +7,14 @@ * publish without them would be caught here before the `files` entry silently * ignores the missing directory. * - * Pure filesystem tests in a per-test temp dir. Port: N/A. + * Filesystem tests in a per-test temp dir; the self-ignoring block also runs real + * `git` there (no global or system config). Port: N/A. */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { mkdtemp, rm, mkdir, writeFile, readFile, symlink, readdir, stat, utimes } from 'node:fs/promises'; import { existsSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; import { join } from 'node:path'; import { tmpdir, homedir } from 'node:os'; import { @@ -92,6 +94,8 @@ describe('installAgentSkillInto / applyAgentSkill(enabled)', () => { expect(await applyAgentSkill(casePath, true)).toBe('foreign'); expect(await readFile(join(skillDir(), 'SKILL.md'), 'utf-8')).toContain('my own skill'); expect(existsSync(join(skillDir(), 'reference'))).toBe(false); + // Not even the ignore file: a foreign copy is the user's to track or not. + expect(existsSync(join(skillDir(), '.gitignore'))).toBe(false); }); it('refuses to write through a symlinked skill dir (dogfooding layout)', async () => { @@ -139,6 +143,117 @@ describe('removeAgentSkillFrom / applyAgentSkill(disabled)', () => { expect(existsSync(join(skillDir(), 'reference', 'endpoints.md'))).toBe(false); // The user's file and the directories holding it survive. expect(await readFile(join(skillDir(), 'reference', 'my-notes.md'), 'utf-8')).toBe('mine\n'); + // And so does the ignore file, so their notes stay out of `git status` as before. + expect(await readFile(join(skillDir(), '.gitignore'), 'utf-8')).toBe('*\n'); + }); +}); + +describe('the injected copy ignores itself in git', () => { + const ENV = { + ...process.env, + LC_ALL: 'C', + GIT_AUTHOR_NAME: 'T', + GIT_AUTHOR_EMAIL: 't@example.com', + GIT_COMMITTER_NAME: 'T', + GIT_COMMITTER_EMAIL: 't@example.com', + GIT_CONFIG_GLOBAL: '/dev/null', + GIT_CONFIG_NOSYSTEM: '1', + }; + const git = (...args: string[]) => + execFileSync('git', args, { cwd: casePath, env: ENV, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] }); + /** Exit status of `git check-ignore -q -- `: 0 ignored, 1 not ignored. */ + const checkIgnore = (path: string): number => { + try { + git('check-ignore', '-q', '--', path); + return 0; + } catch (err) { + return (err as { status: number }).status; + } + }; + /** A repository with one commit and a tracked `.gitignore` of its own. */ + const initRepo = async () => { + git('init', '-q', '-b', 'main'); + await writeFile(join(casePath, 'README.md'), 'hello\n'); + await writeFile(join(casePath, '.gitignore'), 'node_modules/\n'); + git('add', 'README.md', '.gitignore'); + git('-c', 'commit.gpgsign=false', 'commit', '-q', '-m', 'init'); + }; + + it('writes a .gitignore of `*` into the copy on install', async () => { + expect(await applyAgentSkill(casePath, true)).toBe('installed'); + expect(await readFile(join(skillDir(), '.gitignore'), 'utf-8')).toBe('*\n'); + // Never part of the skill content: a second run has nothing to do. + expect(await applyAgentSkill(casePath, true)).toBe('unchanged'); + }); + + it('keeps `git status` of the user repository clean, and leaves its own ignore file alone', async () => { + await initRepo(); + await applyAgentSkill(casePath, true); + await applyAgentSkill(casePath, true, '.agents/skills'); + + expect(git('status', '--porcelain', '--untracked-files=all')).toBe(''); + expect(await readFile(join(casePath, '.gitignore'), 'utf-8')).toBe('node_modules/\n'); + // Nor the repository's local exclude file (git's template may or may not create one). + const exclude = join(casePath, '.git', 'info', 'exclude'); + if (existsSync(exclude)) expect(await readFile(exclude, 'utf-8')).not.toContain('codeman'); + }); + + it('ignores the copy contents, never the folder above it (Claude Code checks only those)', async () => { + // Claude Code 2.1.319 skips a nested skills folder when `git check-ignore` says its + // project directory is ignored; it never checks a skill folder or its SKILL.md. + await initRepo(); + await applyAgentSkill(casePath, true); + expect(checkIgnore('.claude/skills/codeman/SKILL.md')).toBe(0); + expect(checkIgnore('.claude/skills/codeman')).toBe(1); + expect(checkIgnore('.claude/skills')).toBe(1); + expect(checkIgnore('.claude')).toBe(1); + }); + + it('never overwrites a .gitignore that is already in the folder', async () => { + await applyAgentSkill(casePath, true); + await writeFile(join(skillDir(), '.gitignore'), '# mine\nSKILL.md\n'); + // A stale copy: the refresh rewrites the skill but not the ignore file. + await writeFile(join(skillDir(), 'SKILL.md'), `stale\n${MARKER_PREFIX}: old -->\n`); + expect(await applyAgentSkill(casePath, true)).toBe('refreshed'); + expect(await readFile(join(skillDir(), '.gitignore'), 'utf-8')).toBe('# mine\nSKILL.md\n'); + }); + + it('a copy injected before the ignore file existed gains it on the next install', async () => { + await applyAgentSkill(casePath, true); + await rm(join(skillDir(), '.gitignore')); + // Every skill file is current, so only the ignore file is new: still a refresh. + expect(await applyAgentSkill(casePath, true)).toBe('refreshed'); + expect(await readFile(join(skillDir(), '.gitignore'), 'utf-8')).toBe('*\n'); + }); + + it('does not write through a symlink planted at the ignore file name', async () => { + await mkdir(skillDir(), { recursive: true }); + const outside = join(casePath, 'outside.txt'); + await writeFile(outside, 'untouched\n'); + await symlink(outside, join(skillDir(), '.gitignore')); + expect(await applyAgentSkill(casePath, true)).toBe('installed'); + expect(await readFile(outside, 'utf-8')).toBe('untouched\n'); + }); + + it('uninstall removes the ignore file with the copy, so no folder is left behind', async () => { + await initRepo(); + await applyAgentSkill(casePath, true); + expect(await applyAgentSkill(casePath, false)).toBe('removed'); + expect(existsSync(skillDir())).toBe(false); + expect(existsSync(join(casePath, '.claude', 'skills'))).toBe(false); + expect(git('status', '--porcelain', '--untracked-files=all')).toBe(''); + }); + + it("uninstall keeps an ignore file that is not Codeman's", async () => { + await applyAgentSkill(casePath, true); + await writeFile(join(skillDir(), '.gitignore'), 'SKILL.md\n'); + expect(await applyAgentSkill(casePath, false)).toBe('removed'); + expect(await readFile(join(skillDir(), '.gitignore'), 'utf-8')).toBe('SKILL.md\n'); + }); + + it('the packaged source carries no ignore file (the plugin mirror pins its file set)', () => { + expect(existsSync(join(process.cwd(), 'skills', 'codeman', '.gitignore'))).toBe(false); + expect(existsSync(join(process.cwd(), 'plugins', 'codeman', 'skills', 'codeman', '.gitignore'))).toBe(false); }); }); From b3f0ba9e296684a5ed6cffa41e2ec1cd479c82e1 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 8 Nov 2026 07:42:56 +0100 Subject: [PATCH 02/14] feat(agent-skill): turn the Codeman agent skill on by default An absent `agentSkillEnabled` now means on, on the server (`getAgentSkillEnabled()` reads `!== false`, like `workspaceHooksEnabled`) and on the page (the App Settings switch loads `!== false`, so a Save of any other setting no longer writes the old default back). Only an explicit false turns it off. A partial settings PUT that omits the key leaves it as stored, since the route merges the request into the file. The switch's help text says it is on by default, that the copy stays out of git, and that turning it off removes no copy already written. The shared route-test mock now defaults to on too, so route tests see what a user sees. Tests: quick-start against a real server (absent key injects, a partial PUT keeps it on, an explicit false stays off through a later partial PUT), the real openAppSettings/saveAppSettings in JSDOM (absent shows on, explicit false shows off, a Save on a never-touched install sends true and passes the schema), and the plain create route at the mock's default. Co-Authored-By: Claude Opus 5.5 --- .changeset/agent-skill-default-on.md | 5 + src/hooks-config.ts | 5 +- src/web/ports/config-port.ts | 2 +- src/web/public/index.html | 2 +- src/web/public/settings-ui.js | 4 +- src/web/routes/session-routes.ts | 4 +- src/web/server.ts | 10 +- test/agent-skill-setting-ui.test.ts | 191 ++++++++++++++++++ test/mocks/mock-route-context.ts | 8 +- test/quick-start.test.ts | 85 +++++--- .../routes/session-routes-agent-skill.test.ts | 26 ++- 11 files changed, 297 insertions(+), 45 deletions(-) create mode 100644 .changeset/agent-skill-default-on.md create mode 100644 test/agent-skill-setting-ui.test.ts diff --git a/.changeset/agent-skill-default-on.md b/.changeset/agent-skill-default-on.md new file mode 100644 index 00000000..1ca442b2 --- /dev/null +++ b/.changeset/agent-skill-default-on.md @@ -0,0 +1,5 @@ +--- +'aicodeman': minor +--- + +**The Codeman agent skill is on by default.** New sessions get the skill in their folder, where their CLI looks for skills (`.claude/skills/codeman/` for Claude Code, `.agents/skills/codeman/` for Codex, Gemini CLI, OpenCode, Antigravity, Grok, DeepSeek and OMP; still none for Pi or a plain shell), so an agent can start and drive workers without an install step. The copy now ignores itself in git (a `.gitignore` of `*` inside the copy, written once and never overwritten), so it never shows up as a new file in your repository, and `codeman skill uninstall` removes it with the copy. A `settings.json` without the `agentSkillEnabled` key now means on; an explicit `false` stays off, which includes every install whose App Settings were saved on an earlier version, since that save wrote the old default. Turn it off under App Settings, Agents & CLIs, All agent CLIs, **Codeman Agent Skill**. Turning it off still removes no copy already written: `codeman skill uninstall --case ` does that per case. diff --git a/src/hooks-config.ts b/src/hooks-config.ts index 26fc6986..38eeeb01 100644 --- a/src/hooks-config.ts +++ b/src/hooks-config.ts @@ -1666,7 +1666,10 @@ export function agentSkillDirFor(casePath: string, dir: AgentSkillDir): string { * Add or remove the Codeman agent skill in `//codeman`, where `dir` is the * skills directory the session's CLI scans (default: Claude Code's `.claude/skills`; * callers resolve it with `agentSkillDir()`), mirroring `applyStatusLineConfig`'s - * shape. Gated by the synced `agentSkillEnabled` app setting (default OFF). + * shape. Gated by the synced `agentSkillEnabled` app setting: default ON since 1.50.0 + * (an absent key is on, only an explicit false is off; `getAgentSkillEnabled()` in + * server.ts), which is safe for a user's repository because the copy ignores itself + * in git (`ensureAgentSkillGitignore`). * * Call-site policy is ADD-ONLY on session create (callers pass `enabled: true` or * skip the call), for the statusLine reason: sessions in a repo share one workspace, diff --git a/src/web/ports/config-port.ts b/src/web/ports/config-port.ts index 024b966f..8dcc7aee 100644 --- a/src/web/ports/config-port.ts +++ b/src/web/ports/config-port.ts @@ -17,7 +17,7 @@ export interface ConfigPort { getModelConfig(): Promise<{ defaultModel?: string; agentTypeOverrides?: Record } | null>; getClaudeModeConfig(): Promise<{ claudeMode?: ClaudeMode; allowedTools?: string }>; getTerminalHistoryConfig(): Promise; - /** Synced `agentSkillEnabled` app setting (default OFF); gates per-case agent-skill injection. */ + /** Synced `agentSkillEnabled` app setting (default ON, only an explicit false is off); gates per-case agent-skill injection. */ getAgentSkillEnabled(): Promise; /** Synced `workspaceHooksEnabled` app setting (default ON); gates INSTALLING hooks into a session's workspace. */ getWorkspaceHooksEnabled(): Promise; diff --git a/src/web/public/index.html b/src/web/public/index.html index c55ad232..604039d7 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -2941,7 +2941,7 @@
Codeman Agent Skill - Write Codeman's agent skill into each new session's folder, where that CLI looks for skills, so the agent in it can start workers in any installed CLI, send them prompts and wait for results. Every CLI except Pi and a plain shell. + Write Codeman's agent skill into each new session's folder, where that CLI looks for skills, so the agent in it can start workers in any installed CLI, send them prompts and wait for results. Every CLI except Pi and a plain shell. On by default. The copy ignores itself in git, so your repository never lists it as a new file. Off stops new copies but removes none: codeman skill uninstall --case <name> does that.
diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 8b6270b2..214244f8 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -707,7 +707,9 @@ Object.assign(CodemanApp.prototype, { this._applyCodexSettingsVisibility(); // Claude Permissions settings document.getElementById('appSettingsAgentTeams').checked = settings.agentTeamsEnabled ?? false; - document.getElementById('appSettingsAgentSkill').checked = settings.agentSkillEnabled ?? false; + // Default ON (since 1.50.0), like the workspace hooks below: the server reads an + // absent key as on, so the switch must too, or the next Save would write false. + document.getElementById('appSettingsAgentSkill').checked = settings.agentSkillEnabled !== false; // Default ON: an absent key is a user who has never seen this setting, and OFF // for them means no tab alerts in any workspace Codeman did not scaffold. document.getElementById('appSettingsWorkspaceHooks').checked = settings.workspaceHooksEnabled !== false; diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index b7ba3f41..a9158eaa 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -978,8 +978,8 @@ export function _paneDeathWatcherCount(): number { * * `applyAgentSkill` declines two shapes rather than writing through them ('foreign': * an unmarked skills/codeman the user authored; 'symlink': the skill dir or its - * parent is a link). Both were silent: the user flips `agentSkillEnabled` on, nothing - * appears in the case, and there is nowhere to look for why. The ordinary outcomes + * parent is a link). Both were silent: `agentSkillEnabled` is on (the default since + * 1.50.0), nothing appears in the case, and there is nowhere to look for why. The ordinary outcomes * ('installed'/'refreshed'/'unchanged') stay unlogged since they would print on every * single session create. * diff --git a/src/web/server.ts b/src/web/server.ts index f9202601..d75a4385 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -2450,11 +2450,15 @@ export class WebServer extends EventEmitter { return resolveTerminalHistoryConfig(settings); } - // Whether the Codeman agent skill is injected into cases on Claude session create - // (synced `agentSkillEnabled` setting, default OFF; docs/agent-control-plan.md §2). + // Whether the Codeman agent skill is injected into a session's workspace on create + // (synced `agentSkillEnabled` setting; docs/agent-control-plan.md §2). Default ON + // since 1.50.0, like `workspaceHooksEnabled` below: an absent key means a user who + // has never touched the switch, and only an explicit false turns it off. The copy + // ignores itself in git (`.gitignore` of `*`, see installAgentSkillInto), so the + // default leaves a user's `git status` clean. private async getAgentSkillEnabled(): Promise { const settings = await this.readSettings(); - return settings.agentSkillEnabled === true; + return settings.agentSkillEnabled !== false; } // Whether pending agent-inbox mail is snapshotted to disk (synced diff --git a/test/agent-skill-setting-ui.test.ts b/test/agent-skill-setting-ui.test.ts new file mode 100644 index 00000000..a62869dc --- /dev/null +++ b/test/agent-skill-setting-ui.test.ts @@ -0,0 +1,191 @@ +/** + * @fileoverview The Codeman Agent Skill switch (`agentSkillEnabled`) in App Settings, + * on by default since 1.50.0. + * + * What is pinned, and why it matters: + * - The REAL openAppSettings() reads an ABSENT key as on, like the server's + * getAgentSkillEnabled() (`!== false`). The page and the server must agree: a + * page that showed an absent key as off would write `false` on the next Save of + * any other setting, and the default would be gone without the user touching it. + * - Only an explicit false shows the switch off, wherever it came from (this + * device's blob or the server's settings.json, merged as a synced key). + * - saveAppSettings() sends the switch in the settings PUT, and the body passes the + * .strict() SettingsUpdateSchema (an undeclared key 400s every save). + * - The row says it is on by default, that the copy stays out of git, and how to + * remove copies already written (turning it off sweeps nothing). + * + * The real modules run INSIDE a JSDOM window (runScripts: 'outside-only'), with the + * harness of test/tab-cli-logos-setting.test.ts. + * + * Port: none. + */ + +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { JSDOM } from 'jsdom'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { SettingsUpdateSchema } from '../src/web/schemas.js'; + +const PUBLIC = join(process.cwd(), 'src/web/public'); +const read = (name: string) => readFileSync(join(PUBLIC, name), 'utf8'); +const INDEX = read('index.html'); +const SCRIPTS = ['constants.js', 'app.js', 'settings-ui.js'].map(read); + +const DESKTOP_KEY = 'codeman-app-settings'; + +type Settings = Record; +interface SettingsApp { + openAppSettings(): void; + saveAppSettings(): Promise; + loadAppSettingsFromServer(p: Promise): Promise; + _apiPut: ReturnType; +} + +/** Methods that run for real; every other method of the app is a no-op here. */ +const REAL = new Set([ + 'openAppSettings', + 'saveAppSettings', + 'loadAppSettingsFromServer', + 'loadAppSettingsFromStorage', + 'saveAppSettingsToStorage', + 'getSettingsStorageKey', + 'getDefaultSettings', + 'resolveTabArrangement', + 'resolveTabStateOrder', + // Real so the save builds the body a browser would send (the schema check). + 'resolveHeaderStatsStyle', + 'resolveSessionSidebarFontSize', + '_displaySettingKeySet', +]); + +/** Methods the load/save paths call that other modules add to the prototype. */ +const ELSEWHERE = [ + 'showToast', + '_updateLocalEchoState', + 'renderProjectInsightsPanel', + 'updateSubagentWindowVisibility', + 'showWelcome', + '_applyRunMode', +]; + +const windows: { close(): void }[] = []; +afterEach(() => { + for (const win of windows.splice(0)) win.close(); +}); + +/** The whole index.html with constants.js, app.js and settings-ui.js loaded into it. */ +async function boot(stored?: Settings) { + const dom = new JSDOM(INDEX, { url: 'http://localhost/', runScripts: 'outside-only' }); + const win = dom.window as unknown as Window & typeof globalThis & { eval(src: string): void; __App: any }; + windows.push(win); + // After load, so app.js's DOMContentLoaded boot never runs; only the prototype is under test. + if (win.document.readyState !== 'complete') await new Promise((resolve) => win.addEventListener('load', resolve)); + win.setInterval = (() => 0) as unknown as typeof win.setInterval; + if (stored) win.localStorage.setItem(DESKTOP_KEY, JSON.stringify(stored)); + win.eval( + [ + `var MobileDetection = { + getDeviceType: () => 'desktop', + isHandheldDevice: () => false, + isMobile: () => false, + isTouchDevice: () => false, + }; + var KeyboardHandler = {}, SwipeHandler = {}, DeepgramProvider = {}, NotificationManager = function () {}; + var KeyboardAccessoryBar = { setMode() {} }; + var VoiceInput = { _getDeepgramConfig: () => ({}), _saveDeepgramConfig() {}, refreshClaudeStatus: () => Promise.resolve() }; + var FocusTrap = function () { this.activate = () => {}; this.deactivate = () => {}; }; + var DEFAULT_VOICE_KEYTERMS = '';`, + ...SCRIPTS, + 'window.__App = CodemanApp;', + ].join('\n') + ); + const target = Object.create(win.__App.prototype); + target.sessions = new Map(); + target.sessionOrder = []; + target._apiPut = vi.fn(async () => ({ ok: true })); + const noop = () => undefined; + for (const name of ELSEWHERE) target[name] = noop; + const app = new Proxy(target, { + get(t, key, receiver) { + const value = Reflect.get(t, key, receiver); + const stubbed = + typeof key === 'string' && typeof value === 'function' && !Object.hasOwn(t, key) && !REAL.has(key); + return stubbed ? noop : value; + }, + }) as SettingsApp; + const checkbox = () => win.document.getElementById('appSettingsAgentSkill') as HTMLInputElement; + const putBody = () => { + const calls = app._apiPut.mock.calls as [string, Settings][]; + const call = calls.find(([path]) => path === '/api/settings'); + expect(call, 'a settings PUT').toBeDefined(); + return call![1]; + }; + return { app, checkbox, putBody }; +} + +describe('the switch loads on by default', () => { + it('an absent key shows on, an explicit false off, an explicit true on', async () => { + for (const [stored, expected] of [ + [{}, true], + [{ agentSkillEnabled: false }, false], + [{ agentSkillEnabled: true }, true], + ] as const) { + const { app, checkbox } = await boot(stored); + checkbox().checked = !expected; + app.openAppSettings(); + expect(checkbox().checked, JSON.stringify(stored)).toBe(expected); + } + }); + + it('a fresh device on a server with no stored value shows on', async () => { + const { app, checkbox } = await boot(); + await app.loadAppSettingsFromServer(Promise.resolve({ lastUsedCase: 'demo' })); + checkbox().checked = false; + app.openAppSettings(); + expect(checkbox().checked).toBe(true); + }); + + it("the server's explicit false wins over this device's blob (a synced key)", async () => { + const { app, checkbox } = await boot({ agentSkillEnabled: true }); + await app.loadAppSettingsFromServer(Promise.resolve({ agentSkillEnabled: false })); + app.openAppSettings(); + expect(checkbox().checked).toBe(false); + }); +}); + +describe('Save', () => { + it('saving other settings on a never-touched install keeps it on, and the body passes the schema', async () => { + const { app, putBody } = await boot({}); + app.openAppSettings(); + await app.saveAppSettings(); + const body = putBody(); + expect(body.agentSkillEnabled).toBe(true); + // The whole body, not just this key: an undeclared key 400s every save. + expect(SettingsUpdateSchema.safeParse(body).error?.issues ?? []).toEqual([]); + }); + + it('switching it off sends an explicit false', async () => { + const { app, checkbox, putBody } = await boot({}); + app.openAppSettings(); + checkbox().checked = false; + await app.saveAppSettings(); + expect(putBody().agentSkillEnabled).toBe(false); + }); +}); + +describe('the App Settings row', () => { + const doc = new JSDOM(INDEX).window.document; + const row = doc.getElementById('appSettingsAgentSkill')!.closest('.set-row') as HTMLElement; + const desc = row.querySelector('.set-row-desc')!.textContent!; + + it('is a synced switch', () => { + expect((doc.getElementById('appSettingsAgentSkill') as HTMLInputElement).type).toBe('checkbox'); + expect(row.closest('.set-group')!.querySelector('.set-scope')!.textContent).toBe('synced'); + }); + + it('says it is on by default, that git does not list the copy, and how to remove copies', () => { + expect(desc).toContain('On by default'); + expect(desc).toContain('ignores itself in git'); + expect(desc).toContain('codeman skill uninstall --case '); + }); +}); diff --git a/test/mocks/mock-route-context.ts b/test/mocks/mock-route-context.ts index 70a8133f..641db7e9 100644 --- a/test/mocks/mock-route-context.ts +++ b/test/mocks/mock-route-context.ts @@ -128,10 +128,10 @@ export function createMockRouteContext(options?: { getModelConfig: vi.fn(async () => null), getClaudeModeConfig: vi.fn(async () => ({})), getTerminalHistoryConfig: vi.fn(async () => resolveTerminalHistoryConfig({})), - // Default OFF mirrors the shipped setting, so existing tests never touch a - // case's .claude/skills. Overridable per test because the create-time - // injection call sites are otherwise unreachable from a route test. - getAgentSkillEnabled: vi.fn(async () => options?.agentSkillEnabled ?? false), + // Default ON mirrors the shipped setting (since 1.50.0), so a route test sees + // what a user sees. Writes land in the test's temp working dir, never in a real + // repo. Overridable per test, so the OFF branch stays reachable. + getAgentSkillEnabled: vi.fn(async () => options?.agentSkillEnabled ?? true), // Default OFF mirrors the shipped setting: no test opens a voice relay by accident. getClaudeVoiceEnabled: vi.fn(async () => options?.claudeVoiceEnabled ?? false), // Default ON mirrors the shipped setting, so a route test sees what a user sees. diff --git a/test/quick-start.test.ts b/test/quick-start.test.ts index 8daeff46..20e1fc56 100644 --- a/test/quick-start.test.ts +++ b/test/quick-start.test.ts @@ -353,49 +353,63 @@ describe('Agent skill injection (agentSkillEnabled)', () => { } }); - it('does not inject by default, accepts the setting via PUT, then injects on quick-start', async () => { - // 1. Default OFF: a claude quick-start creates the case without the skill. - const offCase = 'test-skill-off-' + Date.now(); - createdCases.push(offCase); - const offResponse = await fetch(`${baseUrl}/api/quick-start`, { + // The tests below run in order against one server and one settings.json: absent key + // (on), a partial PUT that omits it (still on), then an explicit false (off). + const skillDirOf = (caseName: string) => join(CASES_DIR, caseName, '.claude', 'skills', 'codeman'); + + async function quickStart(prefix: string, mode?: string): Promise { + const caseName = `${prefix}-${Date.now()}`; + createdCases.push(caseName); + const response = await fetch(`${baseUrl}/api/quick-start`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ caseName: offCase }), + body: JSON.stringify(mode ? { caseName, mode } : { caseName }), }); - const offData = await offResponse.json(); - expect(offData.success).toBe(true); - expect(existsSync(join(CASES_DIR, offCase, '.claude', 'skills', 'codeman'))).toBe(false); + expect((await response.json()).success).toBe(true); + return caseName; + } - // 2. The `.strict()` settings schema accepts the new synced key. - const putResponse = await fetch(`${baseUrl}/api/settings`, { + async function putSettings(body: Record): Promise { + const response = await fetch(`${baseUrl}/api/settings`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ agentSkillEnabled: true }), + body: JSON.stringify(body), }); - const putData = await putResponse.json(); - expect(putData.success).toBe(true); - - // 3. The server's settings read is cached ~2s; outwait it so the create sees the toggle. + expect((await response.json()).success).toBe(true); + // The server's settings read is cached ~2s; outwait it so the next create sees the change. await new Promise((resolve) => setTimeout(resolve, 2100)); + } - // 4. Quick-start now injects the marker-carrying skill into the new case. - const onCase = 'test-skill-on-' + Date.now(); - createdCases.push(onCase); - const onResponse = await fetch(`${baseUrl}/api/quick-start`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ caseName: onCase }), - }); - const onData = await onResponse.json(); - expect(onData.success).toBe(true); + /** What settings.json holds, read back through GET /api/settings (enveloped or not). */ + async function storedSettings(): Promise> { + const body = (await (await fetch(`${baseUrl}/api/settings`)).json()) as Record; + const settings = (body.success === true ? body.data : body) as Record; + // Not vacuous: every quick-start above records the case it used. + expect(settings).toHaveProperty('lastUsedCase'); + return settings; + } + + it('injects by default: a settings.json without the key means on', async () => { + const caseName = await quickStart('test-skill-default'); + // A quick-start writes lastUsedCase, never this key. + expect(await storedSettings()).not.toHaveProperty('agentSkillEnabled'); - const skillDir = join(CASES_DIR, onCase, '.claude', 'skills', 'codeman'); const { readFileSync } = await import('node:fs'); - const skillMd = readFileSync(join(skillDir, 'SKILL.md'), 'utf-8'); + const skillMd = readFileSync(join(skillDirOf(caseName), 'SKILL.md'), 'utf-8'); expect(skillMd.startsWith('---\nname: codeman')).toBe(true); expect(skillMd).toContain('\n`); + + expect(await applyAgentSkill(casePath, true)).toBe('symlink'); + expect(await readdir(outside)).toEqual(['recipes.md']); + expect(await readFile(join(outside, 'recipes.md'), 'utf-8')).toBe('my recipes\n'); + // The stale copy is left as found, not refreshed through the link. + expect(await readFile(join(skillDir(), 'SKILL.md'), 'utf-8')).not.toBe(skillMd); + }); }); describe('removeAgentSkillFrom / applyAgentSkill(disabled)', () => { + it('refuses to delete through a symlinked reference/ folder inside the copy', async () => { + await applyAgentSkill(casePath, true); + const outside = join(casePath, 'outside'); + await mkdir(outside, { recursive: true }); + // Files named like the packaged reference files: an unlink through the link would take them. + for (const name of await readdir(join(skillDir(), 'reference'))) { + await writeFile(join(outside, name), 'not yours\n'); + } + const names = (await readdir(outside)).sort(); + expect(names.length).toBeGreaterThan(0); + await rm(join(skillDir(), 'reference'), { recursive: true }); + await symlink(outside, join(skillDir(), 'reference')); + + expect(await applyAgentSkill(casePath, false)).toBe('symlink'); + expect((await readdir(outside)).sort()).toEqual(names); + for (const name of names) expect(await readFile(join(outside, name), 'utf-8')).toBe('not yours\n'); + expect(existsSync(join(skillDir(), 'SKILL.md'))).toBe(true); + }); + it('removes our copy and prunes the emptied directories', async () => { await applyAgentSkill(casePath, true); expect(await applyAgentSkill(casePath, false)).toBe('removed'); From df98912596d1e999b05957c8d1407e7006d84507 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 8 Nov 2026 08:35:04 +0100 Subject: [PATCH 05/14] test: keep session creates out of the machine's real /tmp Five gate files created sessions with `workingDir: '/tmp'` against a real server. With the agent skill on by default, every `npm test` then wrote a skill copy into `/tmp/.claude/skills/codeman/`, outside the per-file temp HOME, where a Claude Code started in `/tmp` would load it (the workspace hooks already landed in `/tmp/.claude/settings.local.json` the same way). Each file now uses a temp folder of its own and removes it afterwards. Co-Authored-By: Claude Opus 5.5 --- test/edge-cases.test.ts | 12 +++++++++--- test/operation-lightspeed.test.ts | 13 ++++++++++--- test/quick-start.test.ts | 7 +++++-- test/sse-events.test.ts | 15 +++++++++++---- test/sse-subscription-filter.test.ts | 21 ++++++++++++++------- 5 files changed, 49 insertions(+), 19 deletions(-) diff --git a/test/edge-cases.test.ts b/test/edge-cases.test.ts index a3724705..6e530dbb 100644 --- a/test/edge-cases.test.ts +++ b/test/edge-cases.test.ts @@ -1,10 +1,16 @@ import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'; import { WebServer } from '../src/web/server.js'; +import { mkdtempSync, rmSync } from 'node:fs'; import { join } from 'node:path'; -import { homedir } from 'node:os'; +import { homedir, tmpdir } from 'node:os'; import { safeRmHomeTree } from './mocks/index.js'; const CASES_DIR = join(homedir(), 'codeman-cases'); +// A folder of this file's own for the sessions below, never the machine's real `/tmp`: +// a create writes the workspace hooks and the agent skill (on by default) into its +// working dir, and those would outlive the run there. +const WORK_DIR = mkdtempSync(join(tmpdir(), 'codeman-edge-cases-')); +afterAll(() => rmSync(WORK_DIR, { recursive: true, force: true })); describe('Edge Cases and Error Handling', () => { let server: WebServer; @@ -279,7 +285,7 @@ describe('Concurrent Session Handling', () => { fetch(`${baseUrl}/api/sessions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ workingDir: '/tmp' }), + body: JSON.stringify({ workingDir: WORK_DIR }), }).then((r) => r.json()) ); @@ -312,7 +318,7 @@ describe('Concurrent Session Handling', () => { const createRes = await fetch(`${baseUrl}/api/sessions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ workingDir: '/tmp' }), + body: JSON.stringify({ workingDir: WORK_DIR }), }); const createData = await createRes.json(); expect(createData.success).toBe(true); diff --git a/test/operation-lightspeed.test.ts b/test/operation-lightspeed.test.ts index 0e93da5c..f954a5ca 100644 --- a/test/operation-lightspeed.test.ts +++ b/test/operation-lightspeed.test.ts @@ -13,9 +13,16 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; import { WebServer } from '../src/web/server.js'; import { safeRmHomeTree } from './mocks/index.js'; -import { homedir } from 'node:os'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { homedir, tmpdir } from 'node:os'; import { join } from 'node:path'; +// A folder of this file's own for the sessions below, never the machine's real `/tmp`: +// a create writes the workspace hooks and the agent skill (on by default) into its +// working dir, and those would outlive the run there. +const WORK_DIR = mkdtempSync(join(tmpdir(), 'codeman-lightspeed-')); +afterAll(() => rmSync(WORK_DIR, { recursive: true, force: true })); + // Helper to parse SSE events from raw text function parseSSEEvents(text: string): Array<{ event: string; data: unknown }> { const events: Array<{ event: string; data: unknown }> = []; @@ -86,7 +93,7 @@ async function createSession(baseUrl: string): Promise { const res = await fetch(`${baseUrl}/api/sessions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ workingDir: '/tmp' }), + body: JSON.stringify({ workingDir: WORK_DIR }), }); const data = await res.json(); if (!data.data?.session?.id) { @@ -1226,7 +1233,7 @@ describe('Operation Lightspeed', () => { fetch(`${baseUrl}/api/sessions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ workingDir: '/tmp' }), + body: JSON.stringify({ workingDir: WORK_DIR }), }).then((r) => r.json()) ) ); diff --git a/test/quick-start.test.ts b/test/quick-start.test.ts index 20e1fc56..0166965a 100644 --- a/test/quick-start.test.ts +++ b/test/quick-start.test.ts @@ -174,10 +174,13 @@ describe('Session Management', () => { describe('POST /api/sessions', () => { it('should create a new session', async () => { + // Under the test HOME, never the machine's real `/tmp`: a create writes the workspace + // hooks and the agent skill (on by default) into its working dir. + const workingDir = mkdtempSync(join(TEST_HOME, 'work-')); const response = await fetch(`${baseUrl}/api/sessions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ workingDir: '/tmp' }), + body: JSON.stringify({ workingDir }), }); const data = await response.json(); @@ -185,7 +188,7 @@ describe('Session Management', () => { expect(data.success).toBe(true); expect(data.data.session).toBeDefined(); expect(data.data.session.id).toBeDefined(); - expect(data.data.session.workingDir).toBe('/tmp'); + expect(data.data.session.workingDir).toBe(workingDir); expect(data.data.session.status).toBe('idle'); }); }); diff --git a/test/sse-events.test.ts b/test/sse-events.test.ts index 71fecf37..1addcd67 100644 --- a/test/sse-events.test.ts +++ b/test/sse-events.test.ts @@ -2,9 +2,16 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { WebServer } from '../src/web/server.js'; import { EventEmitter } from 'node:events'; import { safeRmHomeTree } from './mocks/index.js'; -import { homedir } from 'node:os'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { homedir, tmpdir } from 'node:os'; import { join } from 'node:path'; +// A folder of this file's own for the sessions below, never the machine's real `/tmp`: +// a create writes the workspace hooks and the agent skill (on by default) into its +// working dir, and those would outlive the run there. +const WORK_DIR = mkdtempSync(join(tmpdir(), 'codeman-sse-events-')); +afterAll(() => rmSync(WORK_DIR, { recursive: true, force: true })); + // Helper to parse SSE events function parseSSEEvents(text: string): Array<{ event: string; data: unknown }> { const events: Array<{ event: string; data: unknown }> = []; @@ -130,7 +137,7 @@ describe('SSE Events', () => { await fetch(`${baseUrl}/api/sessions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ workingDir: '/tmp' }), + body: JSON.stringify({ workingDir: WORK_DIR }), }); // Wait a bit for the event @@ -168,7 +175,7 @@ describe('SSE Events', () => { await fetch(`${baseUrl}/api/sessions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ workingDir: '/tmp' }), + body: JSON.stringify({ workingDir: WORK_DIR }), }); const response = await fetch(`${baseUrl}/api/status`); @@ -221,7 +228,7 @@ describe('SSE Event Types', () => { const createRes = await fetch(`${baseUrl}/api/sessions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ workingDir: '/tmp' }), + body: JSON.stringify({ workingDir: WORK_DIR }), }); const createData = await createRes.json(); diff --git a/test/sse-subscription-filter.test.ts b/test/sse-subscription-filter.test.ts index e809c91b..f2a011c6 100644 --- a/test/sse-subscription-filter.test.ts +++ b/test/sse-subscription-filter.test.ts @@ -1,9 +1,16 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { WebServer } from '../src/web/server.js'; import { safeRmHomeTree } from './mocks/index.js'; -import { homedir } from 'node:os'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { homedir, tmpdir } from 'node:os'; import { join } from 'node:path'; +// A folder of this file's own for the sessions below, never the machine's real `/tmp`: +// a create writes the workspace hooks and the agent skill (on by default) into its +// working dir, and those would outlive the run there. +const WORK_DIR = mkdtempSync(join(tmpdir(), 'codeman-sse-filter-')); +afterAll(() => rmSync(WORK_DIR, { recursive: true, force: true })); + // Helper to parse SSE events from raw text function parseSSEEvents(text: string): Array<{ event: string; data: unknown }> { const events: Array<{ event: string; data: unknown }> = []; @@ -157,7 +164,7 @@ describe('SSE Subscription Filtering', () => { const createRes = await fetch(`${baseUrl}/api/sessions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ workingDir: '/tmp' }), + body: JSON.stringify({ workingDir: WORK_DIR }), }); const createData = await createRes.json(); const sessionId = createData.data.session.id; @@ -208,7 +215,7 @@ describe('SSE Subscription Filtering', () => { const createRes = await fetch(`${baseUrl}/api/sessions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ workingDir: '/tmp' }), + body: JSON.stringify({ workingDir: WORK_DIR }), }); const createData = await createRes.json(); const sessionId = createData.data.session.id; @@ -263,14 +270,14 @@ describe('SSE Subscription Filtering', () => { const createRes1 = await fetch(`${baseUrl}/api/sessions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ workingDir: '/tmp' }), + body: JSON.stringify({ workingDir: WORK_DIR }), }); const session1 = (await createRes1.json()).data.session; const createRes2 = await fetch(`${baseUrl}/api/sessions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ workingDir: '/tmp' }), + body: JSON.stringify({ workingDir: WORK_DIR }), }); const session2 = (await createRes2.json()).data.session; @@ -330,14 +337,14 @@ describe('SSE Subscription Filtering', () => { const createRes1 = await fetch(`${baseUrl}/api/sessions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ workingDir: '/tmp' }), + body: JSON.stringify({ workingDir: WORK_DIR }), }); const session1 = (await createRes1.json()).data.session; const createRes2 = await fetch(`${baseUrl}/api/sessions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ workingDir: '/tmp' }), + body: JSON.stringify({ workingDir: WORK_DIR }), }); const session2 = (await createRes2.json()).data.session; From cd2265f8d030d80f5d6c6e294815f651b7824114 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 8 Nov 2026 08:35:38 +0100 Subject: [PATCH 06/14] docs(agent-skill): name GitHub Copilot among the CLIs that get no skill copy Copilot's registry entry takes no agent skill, like Pi and a plain shell, but the changeset, the App Settings help text, the Settings Reference and CLAUDE.md named only Pi (and the shell). The Agent CLIs wiki page still said no CLI but Claude gets the bundled skill; every CLI except Pi and Copilot gets it, in `.agents/skills/`. Co-Authored-By: Claude Opus 5.5 --- .changeset/agent-skill-default-on.md | 2 +- CLAUDE.md | 2 +- docs/wiki/Agent-CLIs.md | 2 +- docs/wiki/Settings-Reference.md | 2 +- src/web/public/index.html | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.changeset/agent-skill-default-on.md b/.changeset/agent-skill-default-on.md index 1ca442b2..2b84b903 100644 --- a/.changeset/agent-skill-default-on.md +++ b/.changeset/agent-skill-default-on.md @@ -2,4 +2,4 @@ 'aicodeman': minor --- -**The Codeman agent skill is on by default.** New sessions get the skill in their folder, where their CLI looks for skills (`.claude/skills/codeman/` for Claude Code, `.agents/skills/codeman/` for Codex, Gemini CLI, OpenCode, Antigravity, Grok, DeepSeek and OMP; still none for Pi or a plain shell), so an agent can start and drive workers without an install step. The copy now ignores itself in git (a `.gitignore` of `*` inside the copy, written once and never overwritten), so it never shows up as a new file in your repository, and `codeman skill uninstall` removes it with the copy. A `settings.json` without the `agentSkillEnabled` key now means on; an explicit `false` stays off, which includes every install whose App Settings were saved on an earlier version, since that save wrote the old default. Turn it off under App Settings, Agents & CLIs, All agent CLIs, **Codeman Agent Skill**. Turning it off still removes no copy already written: `codeman skill uninstall --case ` does that per case. +**The Codeman agent skill is on by default.** New sessions get the skill in their folder, where their CLI looks for skills (`.claude/skills/codeman/` for Claude Code, `.agents/skills/codeman/` for Codex, Gemini CLI, OpenCode, Antigravity, Grok, DeepSeek and OMP; still none for Pi, GitHub Copilot or a plain shell), so an agent can start and drive workers without an install step. The copy now ignores itself in git (a `.gitignore` of `*` inside the copy, written once and never overwritten), so it never shows up as a new file in your repository, and `codeman skill uninstall` removes it with the copy. A `settings.json` without the `agentSkillEnabled` key now means on; an explicit `false` stays off, which includes every install whose App Settings were saved on an earlier version, since that save wrote the old default. Turn it off under App Settings, Agents & CLIs, All agent CLIs, **Codeman Agent Skill**. Turning it off still removes no copy already written: `codeman skill uninstall --case ` does that per case. diff --git a/CLAUDE.md b/CLAUDE.md index 35cd581d..14784e95 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -127,7 +127,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Input**: `session.writeViaMux()` for programmatic/curl input via one tmux bracketed paste (`paste-buffer -p`) + `send-keys Enter`; a multi-line prompt keeps its line breaks inside that paste. A single character is typed with `send-keys -l` instead, because selection dialogs ignore a pasted digit. Interactive **browser** input goes through a durable **exactly-once** layer: a stable `clientId` + monotonic per-session `seq` persisted to localStorage until the server ACKs, so a dropped link cannot lose or double-deliver a prompt. `ws-connection-registry.ts` supersedes only same-TAB reconnects, so two tabs on one session coexist. → [architecture-invariants#input-delivery-and-ws-resilience](docs/architecture-invariants.md#input-delivery-and-ws-resilience) -**Agent wait primitives**: bounded long-polls: `GET /api/sessions/:id/wait`, `GET .../wait-output` (literal substring, **never** regex) and `wait`/`waitTimeout` on `POST .../input`. Registry `session-wait-registry.ts` (pure), bounds `config/agent-wait.ts`. ⚠️ A timeout is a 200 (`wait.timedOut`). ⚠️ `stop` exists for `claude` and `deepseek` ONLY, and never for an adopted session (rule lives in `hooksAvailableForMode()`), `blocked` also for a CLI declaring `capabilities.workDetect.blockedLine` (codex, read off the screen; `blockedSignalAvailable()`): explicit request elsewhere is a 400. ⚠️ Send-and-wait registers the waiter BEFORE the write; teardown must `notifySignal('exit')` BEFORE `cancelAll()`; hangup abort listens on `reply.raw` (guarded by `writableFinished`), never `req.raw`; liveness comes from `isPaneDead`, never `session.pid`. ⚠️ Signals are edge-triggered with no history: gather fan-outs via send-and-wait or `wait-output` markers. Packaged as the `skills/codeman` skill (`codeman skill install`, plugin marketplace, or injection behind `agentSkillEnabled`, SYNCED, **default ON since 1.50.0**, into the directory each CLI scans: `capabilities.agentSkillDir`, `.claude/skills` for claude, `.agents/skills` for the others, none for pi): injection is add-only, marker-owned (`applyAgentSkill`), refuses symlinks, and refreshes a marker-owned user-level copy (`refreshUserAgentSkill`). ⚠️ An ABSENT key means ON on both sides (server `!== false`, the page's switch `!== false`, or a Save writes the old default back); only an explicit false is off, and turning it off still sweeps nothing (`codeman skill uninstall --case ` removes copies). ⚠️ Every managed copy ignores itself in git: a `.gitignore` of `*` written FIRST with `wx` (never overwritten, never part of `readAgentSkillSource()`, so never in `skills/codeman/` or the plugin mirror), removed by uninstall only when it is the last file left; never touch the user's own ignore files for it. → [architecture-invariants#agent-wait-primitives](docs/architecture-invariants.md#agent-wait-primitives), `docs/api-reference.md` +**Agent wait primitives**: bounded long-polls: `GET /api/sessions/:id/wait`, `GET .../wait-output` (literal substring, **never** regex) and `wait`/`waitTimeout` on `POST .../input`. Registry `session-wait-registry.ts` (pure), bounds `config/agent-wait.ts`. ⚠️ A timeout is a 200 (`wait.timedOut`). ⚠️ `stop` exists for `claude` and `deepseek` ONLY, and never for an adopted session (rule lives in `hooksAvailableForMode()`), `blocked` also for a CLI declaring `capabilities.workDetect.blockedLine` (codex, read off the screen; `blockedSignalAvailable()`): explicit request elsewhere is a 400. ⚠️ Send-and-wait registers the waiter BEFORE the write; teardown must `notifySignal('exit')` BEFORE `cancelAll()`; hangup abort listens on `reply.raw` (guarded by `writableFinished`), never `req.raw`; liveness comes from `isPaneDead`, never `session.pid`. ⚠️ Signals are edge-triggered with no history: gather fan-outs via send-and-wait or `wait-output` markers. Packaged as the `skills/codeman` skill (`codeman skill install`, plugin marketplace, or injection behind `agentSkillEnabled`, SYNCED, **default ON since 1.50.0**, into the directory each CLI scans: `capabilities.agentSkillDir`, `.claude/skills` for claude, `.agents/skills` for the others, none for pi or copilot): injection is add-only, marker-owned (`applyAgentSkill`), refuses symlinks, and refreshes a marker-owned user-level copy (`refreshUserAgentSkill`). ⚠️ An ABSENT key means ON on both sides (server `!== false`, the page's switch `!== false`, or a Save writes the old default back); only an explicit false is off, and turning it off still sweeps nothing (`codeman skill uninstall --case ` removes copies). ⚠️ Every managed copy ignores itself in git: a `.gitignore` of `*` written FIRST with `wx` (never overwritten, never part of `readAgentSkillSource()`, so never in `skills/codeman/` or the plugin mirror), removed by uninstall only when it is the last file left; never touch the user's own ignore files for it. → [architecture-invariants#agent-wait-primitives](docs/architecture-invariants.md#agent-wait-primitives), `docs/api-reference.md` **Agent CLI (`codeman agent …`, `src/cli-agent.ts`)**: the command-line client for the wait primitives and the session verbs, for agents the skill does not reach (pi, shell, no `jq`; `seedAgentSessionPreamble` covers every local session whose CLI takes the skill, `capabilities.agentSkillDir`; every pane still exports `CODEMAN_MUX`/`CODEMAN_SESSION_ID`/`CODEMAN_API_URL`). It is an HTTP CLIENT: inspection and guarded stage/submit use the ownership-checked `agent-session-routes.ts` module (experimental, behind `agentCoordinationEnabled`, SYNCED, default OFF: all three routes answer `403 FORBIDDEN` naming it while off, and the CLI shows that verbatim); the other verbs reuse existing routes, with no second transport (the plan's "Socket API" non-goal stands), every call carries `X-Codeman-Parent-Session`, only `spawn`'s quick-start carries `X-Codeman-Agent-Origin` (pinned in test/cli-agent.test.ts), while `spawn` without a case (cwd or `--working-dir`) is `POST /api/v1/sessions` plus `/interactive` or `/shell` without that header, so no agent-case marker, and refuses (exit 4, before any request) an empty `--working-dir` and a bare spawn from a HOME cwd, since it always sends an explicit `workingDir` and the server's no-`workingDir` hooks skip cannot apply (test/cli-agent-project-spawn.test.ts), auth (`src/codeman-credentials.ts`, shared with `codeman attach` and the TUI) from `CODEMAN_PASSWORD` or the data dir's `.env` (the same reader `codeman attach` uses — `readCodemanEnvFile`). Verbs return exit codes (`0` ok / `1` error / `2` timeout / `3` exited / `4` refused) instead of calling `process.exit`, so `test/cli-agent.test.ts` drives every verb against a recording fake transport plus the real `httpRequest` against a local server. ⚠️ Three guards live in code, not prose, and the tests pin them: refuse without `CODEMAN_MUX=1`+`CODEMAN_API_URL` (never guess a URL); `send` transmits printable text + `\r` only (`inputRefusal`: any byte < 0x20 or DEL refuses BEFORE the transport — opencode's `Ctrl+C` is `app_exit`), ESC exists solely as `interrupt` with no `\r`; `rm` fails closed (`deleteRefusal`: empty id, self shorter than 8, prefix match in EITHER direction, re-checked on the resolved id). ⚠️ The routes accept FULL ids only — an 8-char prefix is a 404 (measured live) — while `ls` prints prefixes, so `resolveSessionId` maps a short id through `GET /api/v1/sessions` and refuses an ambiguous one. It also refuses any id under 8 characters (exit 4, on every verb, before any request), the same floor as the server's `PARENT_SESSION_ID_MIN_PREFIX`, so `rm 9` can never pick an arbitrary session. ⚠️ A 400 for `--until stop` on a hook-less mode is passed through verbatim, never papered over: the marker path is the answer there. `spawn` on a readiness timeout exits 2 and LEAVES the session for inspection (the skill's `spawn_worker` deletes it; the CLI's caller is often a human); its stdout is the id ALONE (prose on stderr) so `SID=$(…)` works. ⚠️ Two server answers that look like success and are not: `delivered:false` without `duplicate` (the bytes went into a dead pane — the field exists so a client says "restart" instead of "wait longer") and `wait.ended:true, signal:null` (the worker died during a `stop`/`idle` wait: the registry satisfies only waiters that listed `exit`, then cancels the rest) — both exit 3, checked BEFORE the happy paths in `waitExitCode`. → README "`codeman agent`", `docs/api-reference.md` diff --git a/docs/wiki/Agent-CLIs.md b/docs/wiki/Agent-CLIs.md index fcf42948..5b2e66e5 100644 --- a/docs/wiki/Agent-CLIs.md +++ b/docs/wiki/Agent-CLIs.md @@ -79,7 +79,7 @@ output. The other CLIs expose no equivalent. | Subagent and team windows | Yes | No | | Model, effort, advisor, and ultracode controls | Yes | No | | `stop` and `blocked` wait signals | Yes | DeepSeek yes; Codex `blocked` only; elsewhere 400 if you ask for them explicitly | -| The bundled agent skill | Yes | No | +| The bundled agent skill | Yes | Every CLI except Pi and Copilot, in `.agents/skills/` | Everything that makes a session a session works everywhere. What is Claude-only is mostly the machinery that needs to know *what* the agent is doing rather than *that* it is doing diff --git a/docs/wiki/Settings-Reference.md b/docs/wiki/Settings-Reference.md index 37f7451b..361dc66f 100644 --- a/docs/wiki/Settings-Reference.md +++ b/docs/wiki/Settings-Reference.md @@ -202,7 +202,7 @@ instead of its native cloud backend. See [Custom Model Endpoints](Custom-Model-E | Ralph / Todo Tracker | Enables the Ralph loop surfaces. | | Agent Teams | Experimental teams. Also needs the CLI's own environment flag. | | Pin tab names on the CLI | Launches every Claude session with `--name` set to its tab name, so Claude Code's prompt label and `/resume` list match your tabs. Claude then stops generating its own titles for those sessions, and a rename in Codeman holds only until the next turn. Synced, off by default; applies from the next session start or respawn. | -| Codeman Agent Skill | Under **All agent CLIs**. Injects the agent skill into each new session's case, where that session's CLI looks for skills (every CLI except Pi and a plain shell). On by default since 1.50.0; the copy ignores itself in git, so your repository never lists it. Off stops new copies but removes none (`codeman skill uninstall --case ` does). See [The Codeman Skill](Codeman-Skill). | +| Codeman Agent Skill | Under **All agent CLIs**. Injects the agent skill into each new session's case, where that session's CLI looks for skills (every CLI except Pi, GitHub Copilot and a plain shell). On by default since 1.50.0; the copy ignores itself in git, so your repository never lists it. Off stops new copies but removes none (`codeman skill uninstall --case ` does). See [The Codeman Skill](Codeman-Skill). | | Remote auto-reconnect | Reattaches dropped remote SSH sessions. On by default. | | Nice priority / value | Runs agent processes at a lower CPU priority. | | Default Codex model | Model for new local Codex sessions; empty uses Codex's own config. Letters, digits, `.` `_` `-` `/` only. | diff --git a/src/web/public/index.html b/src/web/public/index.html index 604039d7..b80c85a8 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -2941,7 +2941,7 @@
Codeman Agent Skill - Write Codeman's agent skill into each new session's folder, where that CLI looks for skills, so the agent in it can start workers in any installed CLI, send them prompts and wait for results. Every CLI except Pi and a plain shell. On by default. The copy ignores itself in git, so your repository never lists it as a new file. Off stops new copies but removes none: codeman skill uninstall --case <name> does that. + Write Codeman's agent skill into each new session's folder, where that CLI looks for skills, so the agent in it can start workers in any installed CLI, send them prompts and wait for results. Every CLI except Pi, GitHub Copilot and a plain shell. On by default. The copy ignores itself in git, so your repository never lists it as a new file. Off stops new copies but removes none: codeman skill uninstall --case <name> does that.
From 3d61402250ce1274f74feb59530c600b1b3cc763 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 8 Nov 2026 08:37:00 +0100 Subject: [PATCH 07/14] docs(agent-skill): record the reference-folder symlink refusal and the live CLI checks The architecture note now says that install and uninstall refuse a linked folder inside the copy, why (a cloned repository or a Docker case's container can plant one), and that the check is not race-proof. It also records which CLIs were checked live with the copy's ignore file in place: Claude Code 2.1.319, Codex 0.162, OpenCode 1.3.0 and Grok 1.0.52 all list the skill; agy, dsh, gemini and omp were not measured. Co-Authored-By: Claude Opus 5.5 --- docs/architecture-invariants.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 9f2edebe..eae0021d 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -167,7 +167,7 @@ Spawn commands are pinned as literal strings in `test/cli-registry-spawn-golden. Further detail: `stop`/`blocked` now fire for **`claude` and `deepseek`** (`shell` installs no hooks either); asking for one explicitly on any other mode is a 400 and the default set silently drops them. `deepseek` qualifies because the DeepSeek Harness TUI REPORTS idle/working/blocked to its supervisor and Codeman is that supervisor (`deepseek-status-shim.ts`), so its signals are definitive rather than inferred, and `hooksAvailableForMode()` in `session-wait-registry.ts` is the one place that rule lives (see the External CLI modes section for the per-session `statusReporting` wrinkle and the two dsh traps baked into the skill preamble: boot `idle` landing ~300 ms before the composer paints, and `sendwait` asking for `wait:"stop,exit"`). That is also why `deepseek` is the one non-claude mode the skill drives like claude: `spawn_workers alpha beta:deepseek` is a mixed fleet in one call, and `sendwait`/`last_text` need no variant. ⚠️ **`blocked` alone has a second source**: a CLI with no hook for it can declare the dialogs it blocks on (`capabilities.workDetect.blockedLine`, codex), and `blockedSignalAvailable()` (hooks OR that field) is the rule for that one signal; `stop` stays hooks-only, since nothing on a screen says a turn ended. A screen-read `blocked` is a LEVEL the server keeps reading (`Session.screenBlocked`), so the wait route answers it immediately (`currentSignalForWait()`) when the caller asked for `blocked`, and only then: such a pane is also `idle`, and `until=idle` must keep answering `idle`. See [Screen-read blocked dialogs](#screen-read-blocked-dialogs-codex). -**Packaging as the `skills/codeman` agent skill.** The primitives are packaged as the `skills/codeman` agent skill, installable three ways: via `codeman skill install [--case ]` / `skill uninstall`; as a Claude Code plugin from the repo's own marketplace (`/plugin marketplace add Ark0N/Codeman` + `/plugin install codeman@codeman`, see the root-files note in CLAUDE.md); or auto-injected into a case on session create behind `agentSkillEnabled` (SYNCED, default ON since 1.50.0, default OFF before). ⚠️ An ABSENT key means on in BOTH readers, the server's `getAgentSkillEnabled()` and the App Settings switch (both `!== false`): a page that showed an absent key as off would write `false` on the next Save of any other setting. Only an explicit false is off, so an install whose App Settings were saved on 1.49.0 or earlier (which wrote the old default) stays off until the user turns it on. Turning it off sweeps nothing. ⚠️ Every managed copy ignores itself in git, which is what makes the default safe in a user's repository: `installAgentSkillInto()` writes `/.gitignore` holding `*` FIRST (so a `git status` mid-install never lists it), with `wx` (a file already there is the user's and is never overwritten; a symlink at the name is refused), and outside the content list `readAgentSkillSource()` compares, so it never appears in `skills/codeman/` or the plugin mirror. A copy injected before 1.50.0 gains it on its next install (`refreshed`). `removeAgentSkillFrom()` deletes it only when it is the last entry left and holds exactly `*`, so a user's extra files there stay ignored. The repository's own `.gitignore` and `info/exclude` are never touched for it (the managed-worktree exclude in git-worktrees.ts is a separate, older line and still added). Measured against Claude Code 2.1.319: its skill discovery runs `git check-ignore` only on directories at or above `.claude/skills` (the nested-skills walk), never on a skill folder or its SKILL.md, and a `*` inside `codeman/` ignores the folder's contents, not the folder, so the skill still loads; codex 0.162 has no gitignore check on skills. ⚠️ WHERE it lands is registry data, `capabilities.agentSkillDir` (absent with `agentSkillInjection: true` = `.claude/skills`): `.claude/skills/codeman` for claude, `.agents/skills/codeman` for every other CLI that takes it (pi does not: a project skill triggers its trust prompt). `agentSkillDir` is a CLOSED enum (`AGENT_SKILL_DIRS`, schema.ts) because `clis.json` can set it and it decides where the server writes. The per-session preamble is seeded for every local session whose CLI takes the skill, and `codeman skill uninstall --case` removes the copy from every distinct registry directory (`registryAgentSkillDirs()`, cli.ts). ⚠️ Plugin skills are namespaced, so a Claude Code holding BOTH the plugin and a user-level or per-case copy lists the skill twice, `codeman` and `codeman:codeman` (measured 2026-09-14: neither shadows the other, both work); the docs tell users to pick one route, and since the per-case injection is on by default, a plugin user turns that setting off. +**Packaging as the `skills/codeman` agent skill.** The primitives are packaged as the `skills/codeman` agent skill, installable three ways: via `codeman skill install [--case ]` / `skill uninstall`; as a Claude Code plugin from the repo's own marketplace (`/plugin marketplace add Ark0N/Codeman` + `/plugin install codeman@codeman`, see the root-files note in CLAUDE.md); or auto-injected into a case on session create behind `agentSkillEnabled` (SYNCED, default ON since 1.50.0, default OFF before). ⚠️ An ABSENT key means on in BOTH readers, the server's `getAgentSkillEnabled()` and the App Settings switch (both `!== false`): a page that showed an absent key as off would write `false` on the next Save of any other setting. Only an explicit false is off, so an install whose App Settings were saved on 1.49.0 or earlier (which wrote the old default) stays off until the user turns it on. Turning it off sweeps nothing. ⚠️ Every managed copy ignores itself in git, which is what makes the default safe in a user's repository: `installAgentSkillInto()` writes `/.gitignore` holding `*` FIRST (so a `git status` mid-install never lists it), with `wx` (a file already there is the user's and is never overwritten; a symlink at the name is refused), and outside the content list `readAgentSkillSource()` compares, so it never appears in `skills/codeman/` or the plugin mirror. A copy injected before 1.50.0 gains it on its next install (`refreshed`). `removeAgentSkillFrom()` deletes it only when it is the last entry left and holds exactly `*`, so a user's extra files there stay ignored. The repository's own `.gitignore` and `info/exclude` are never touched for it (the managed-worktree exclude in git-worktrees.ts is a separate, older line and still added). Measured against Claude Code 2.1.319: its skill discovery runs `git check-ignore` only on directories at or above `.claude/skills` (the nested-skills walk), never on a skill folder or its SKILL.md, and a `*` inside `codeman/` ignores the folder's contents, not the folder, so the skill still loads; codex 0.162 has no gitignore check on skills. Checked live on 2026-11-08 with the ignore file in place: Claude Code 2.1.319 (`--debug` reports `project: 1`), Codex 0.162 (app-server `skills/list`), OpenCode 1.3.0 (`opencode debug skill`) and Grok 1.0.52 (`grok --trust inspect`) all list `codeman`; agy, dsh, gemini and omp were not measured. ⚠️ Install and uninstall refuse (`symlink`) a link at the copy, at its `skills/` parent, and at a folder INSIDE the copy (`reference/`, `agentSkillSubdirIsSymlink()`): a cloned repository can commit one and a Docker case's container can plant one in the bind-mounted workspace, and a write or unlink of `reference/.md` through it lands wherever it points (measured: install overwrote a file there, uninstall deleted it). The check runs once before the first write; a link swapped in after it is not covered. ⚠️ WHERE it lands is registry data, `capabilities.agentSkillDir` (absent with `agentSkillInjection: true` = `.claude/skills`): `.claude/skills/codeman` for claude, `.agents/skills/codeman` for every other CLI that takes it (pi does not: a project skill triggers its trust prompt). `agentSkillDir` is a CLOSED enum (`AGENT_SKILL_DIRS`, schema.ts) because `clis.json` can set it and it decides where the server writes. The per-session preamble is seeded for every local session whose CLI takes the skill, and `codeman skill uninstall --case` removes the copy from every distinct registry directory (`registryAgentSkillDirs()`, cli.ts). ⚠️ Plugin skills are namespaced, so a Claude Code holding BOTH the plugin and a user-level or per-case copy lists the skill twice, `codeman` and `codeman:codeman` (measured 2026-09-14: neither shadows the other, both work); the docs tell users to pick one route, and since the per-case injection is on by default, a plugin user turns that setting off. Injection is ADD-ONLY at create, marker-owned (`applyAgentSkill` in `hooks-config.ts` never touches an unmarked user copy) and refuses symlinks (this repo's own `.claude/skills/codeman` is a symlink to the source, which the injector must never write through). ⚠️ Claude Code loads a same-named USER-LEVEL skill (`~/.claude/skills/codeman`, written once by `codeman skill install` with no `--case`) over the per-case copy, and nothing used to refresh it: a stale Aug-9 user copy shadowed every fresh injection (2026-08-14: agents ran the old recipes, spawned workers serially and lost their lineage arcs), so session create now also refreshes a marker-owned user copy (`refreshUserAgentSkill`; refresh-only, never installs, foreign/symlink refused). From 14cf95e6eebc99ddab2a67c4b0eeff325ed5a370 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 8 Nov 2026 08:57:28 +0100 Subject: [PATCH 08/14] test: create the custom-working-dir session in a temp folder, not /tmp/test session.test.ts created a session in the fixed `/tmp/test`, so with the agent skill on by default every `npm test` wrote a skill copy into `/tmp/test/.claude/skills/codeman/` and left it there. It now uses a temp folder of its own and removes it afterwards. Co-Authored-By: Claude Opus 5.5 --- test/session.test.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/test/session.test.ts b/test/session.test.ts index 1eeaf591..b5addb7e 100644 --- a/test/session.test.ts +++ b/test/session.test.ts @@ -1,15 +1,20 @@ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; -import { mkdirSync } from 'node:fs'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; import { WebServer } from '../src/web/server.js'; describe('Interactive Session Lifecycle', () => { let server: WebServer; let baseUrl: string; + let customDir: string; beforeAll(async () => { - // The 'custom working directory' test creates a session in /tmp/test; workingDir - // validation requires the dir to exist, so ensure it does (idempotent, CI-safe). - mkdirSync('/tmp/test', { recursive: true }); + // The 'custom working directory' test creates a session in a folder of its own + // (workingDir validation requires it to exist), never a fixed path in the machine's + // real `/tmp`: a create writes the workspace hooks and the agent skill (on by + // default) into its working dir, and those would outlive the run there. + customDir = mkdtempSync(join(tmpdir(), 'codeman-session-test-')); server = new WebServer(0, false, true); await server.start(); baseUrl = `http://localhost:${server.boundPort}`; @@ -17,6 +22,7 @@ describe('Interactive Session Lifecycle', () => { afterAll(async () => { await server.stop(); + rmSync(customDir, { recursive: true, force: true }); }, 60000); // Extended timeout for cleanup describe('Session Creation', () => { @@ -39,13 +45,13 @@ describe('Interactive Session Lifecycle', () => { const response = await fetch(`${baseUrl}/api/sessions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ workingDir: '/tmp/test' }), + body: JSON.stringify({ workingDir: customDir }), }); const data = await response.json(); expect(data.success).toBe(true); - expect(data.data.session.workingDir).toBe('/tmp/test'); + expect(data.data.session.workingDir).toBe(customDir); }); }); From 55e0f0e7e40195881d3c5b400ff91c6e0ccefcee Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 8 Nov 2026 09:15:53 +0100 Subject: [PATCH 09/14] feat(agent-skill): switch a stored off from before 1.50.0 on, once per data dir Every App Settings Save up to 1.49.0 wrote `agentSkillEnabled: false`, chosen or not (the switch loaded an absent key as off and Save sends the whole form), so the new default never reached an install that had ever saved. On its first start the server now replaces a stored false with true, once per data dir, and logs one line when it does. It cannot tell a deliberate off from the old default; that is accepted and stated in the release notes. An off set after it ran stays off. It stores true rather than deleting the key: the page's merge overwrites only the synced keys the server has, so every device that saved on 1.49 kept its own false, showed the switch off and wrote false back on its next Save (measured in the JSDOM harness). True wins on every device, including a browser still on a cached settings-ui.js. For a server with no key the page now drops the device's copy as well, so the switch shows the default. The "done" marker is its own file, `agent-skill-default-migrated.json` in the data dir: it is not a setting, so GET /api/settings never returns it, no Save can send it back (the strict schema would refuse it) or drop it (which would re-arm the migration), and a config-backup restore leaves it alone. It is checked before anything is read and written only after the settings write landed. The migration runs in WebServer.start() before the first settings read and before listen (skipped in test mode, like the other boot-time writes), writes only through mutateSettingsFile(), drops the 2 s settings cache, and leaves a settings.json that does not parse alone with no marker, so the next start after the fix runs it. Co-Authored-By: Claude Opus 5.5 --- src/web/agent-skill-default-migration.ts | 106 +++++++++++ src/web/public/settings-ui.js | 7 + src/web/server.ts | 9 + test/agent-skill-default-migration.test.ts | 211 +++++++++++++++++++++ test/agent-skill-setting-ui.test.ts | 52 +++++ 5 files changed, 385 insertions(+) create mode 100644 src/web/agent-skill-default-migration.ts create mode 100644 test/agent-skill-default-migration.test.ts diff --git a/src/web/agent-skill-default-migration.ts b/src/web/agent-skill-default-migration.ts new file mode 100644 index 00000000..0cd25c1d --- /dev/null +++ b/src/web/agent-skill-default-migration.ts @@ -0,0 +1,106 @@ +/** + * @fileoverview One-time boot migration that lets the 1.50.0 agent skill default reach installs + * that saved App Settings before it. + * + * `agentSkillEnabled` is ON by default since 1.50.0 (an absent key is on, only an explicit false + * is off). Up to 1.49.0 the App Settings switch loaded an absent key as off (`?? false`) and Save + * sends the whole form, so every install whose App Settings were ever saved holds an explicit + * `false` the user may never have chosen, and the new default would never reach it. On the first + * start of a server that has this module, a stored `false` is switched on, exactly once per data + * dir. It cannot tell a deliberate off from the old default; that is accepted and stated as a + * behaviour change in the release notes. An off set after this ran is never touched again. + * + * Why a stored `false` becomes `true` rather than being deleted: deleting it would not reach the + * page. Every browser that saved App Settings keeps the form in its localStorage blob, and + * `loadAppSettingsFromServer()` only overwrites a synced key the server HAS: a key the server + * lacks keeps the device's `false`, the switch loads off, and the next Save of any setting writes + * `false` back (test/agent-skill-setting-ui.test.ts proves both shapes). A stored `true` wins on + * every device, including a browser still running a cached settings-ui.js from before the upgrade. + * Every reader treats `true` and an absent key alike (`!== false`). + * + * Why the "done" marker is its own file (`agent-skill-default-migrated.json` in the data dir) + * and not a key in settings.json: + * - it is not a setting, so `GET /api/settings` never returns it, the page never holds it, and + * the page's Save (rebuilt from the form) can neither send it back nor drop it; the `.strict()` + * `SettingsUpdateSchema` never sees it; + * - a PUT or any other settings.json writer cannot erase it, which would re-arm the migration and + * switch a deliberate later off back on; + * - it is not in `CONFIG_BACKUP_FILES` (like `config-backup-id`), so a restore leaves it alone: a + * restored settings.json, even one from before 1.50.0 holding `false`, is what the user asked + * for and stays as restored. + * A data dir is one instance, and settings.json is one file for the whole server, so in + * multi-user mode this runs once for the server, never per user. + * + * Order: the marker is checked first (present: nothing is read), the settings change goes through + * `mutateSettingsFile()` (serialized, atomic, refuses a file that does not parse), and the marker + * is written only after that resolves. A refused or failed settings write writes no marker, so the + * migration runs again at the next start (after the user fixed the file). A crash between the two + * writes is harmless: the next start finds `true` (nothing to change) and writes the marker. + * + * Called once from `WebServer.start()`, before the first settings read and before the server + * listens, so no request path can see the old value. + */ + +import fs from 'node:fs/promises'; +import { dirname } from 'node:path'; +import { dataPath } from '../config/instance.js'; +import { getErrorMessage } from '../types.js'; +import { mutateSettingsFile, SettingsWriteRefusedError } from './settings-writer.js'; + +/** The marker's file name in the data dir. Never in CONFIG_BACKUP_FILES (see the file comment). */ +export const AGENT_SKILL_DEFAULT_MARKER = 'agent-skill-default-migrated.json'; + +/** + * - `switched-on`: settings.json held `agentSkillEnabled: false`; it now holds `true`. + * - `nothing-to-change`: the key was absent or not `false` (or there is no settings.json); nothing was written to it. + * - `already-done`: the marker exists; settings.json was not read. + * - `refused`: settings.json could not be read, parsed or written, or the marker could not be checked. + * Nothing changed and no marker was written, so the next start tries again. + */ +export type AgentSkillDefaultMigration = 'switched-on' | 'nothing-to-change' | 'already-done' | 'refused'; + +/** The log line of a migration that switched the skill on. */ +export const AGENT_SKILL_DEFAULT_MIGRATED_LOG = + '[agent-skill] cleared the stored off from before 1.50.0 so the new default applies (turn it off again under App Settings, Agents & CLIs, Codeman Agent Skill; that choice is kept)'; + +export async function migrateAgentSkillDefault( + markerPath: string = dataPath(AGENT_SKILL_DEFAULT_MARKER) +): Promise { + try { + await fs.lstat(markerPath); + return 'already-done'; + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + // Not knowing whether it ran must not switch a deliberate off on a second time. + console.warn(`[agent-skill] default migration skipped: cannot check ${markerPath} (${getErrorMessage(err)})`); + return 'refused'; + } + } + + let switched = false; + try { + await mutateSettingsFile((current) => { + if (current.agentSkillEnabled !== false) return undefined; + switched = true; + return { ...current, agentSkillEnabled: true }; + }); + } catch (err) { + // A refusal is already logged by the writer (`[settings] write refused: ...`), with the fix. + const why = err instanceof SettingsWriteRefusedError ? 'settings.json was left as it is' : getErrorMessage(err); + console.warn(`[agent-skill] default migration skipped (${why}); it runs again at the next start`); + return 'refused'; + } + + try { + await fs.mkdir(dirname(markerPath), { recursive: true }); + const marker = { migratedAt: new Date().toISOString(), switchedOn: switched }; + await fs.writeFile(markerPath, `${JSON.stringify(marker, null, 2)}\n`, { mode: 0o600 }); + } catch (err) { + // settings.json is already right; without the marker the next start runs again, which + // changes nothing unless the user has switched it off by then. + console.warn(`[agent-skill] could not record the default migration at ${markerPath}: ${getErrorMessage(err)}`); + } + + if (switched) console.log(AGENT_SKILL_DEFAULT_MIGRATED_LOG); + return switched ? 'switched-on' : 'nothing-to-change'; +} diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 214244f8..21ee5037 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -5268,6 +5268,13 @@ Object.assign(CodemanApp.prototype, { merged[key] = value; } } + // The agent skill is a synced key that is ON when absent (since 1.50.0). A server + // without the key means that default, so this device's own copy goes rather than + // standing in for it: up to 1.49.0 the switch loaded an absent key as off, so a + // device whose save never reached the server still holds `false`, and its next + // Save would write it back. The server's one-time boot migration stores `true` for + // installs that had saved `false` (agent-skill-default-migration.ts). + if (!('agentSkillEnabled' in appSettings)) delete merged.agentSkillEnabled; this.saveAppSettingsToStorage(merged); // Apply notification prefs from server if present (only if localStorage has none) diff --git a/src/web/server.ts b/src/web/server.ts index d75a4385..d56ba7d4 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -195,6 +195,7 @@ import { resolveTerminalHistoryConfig } from '../config/terminal-history.js'; import { SseEvent } from './sse-events.js'; import { MAX_ROUTE_PARAM_LENGTH } from './route-helpers.js'; import { mutateSettingsFile } from './settings-writer.js'; +import { migrateAgentSkillDefault } from './agent-skill-default-migration.js'; import { getLatestPlanUsage, setLatestCodexPlanUsage, setLatestCopilotPlanUsage } from './plan-usage-latest.js'; import { telemetrySignature } from '../usage-telemetry.js'; import { readCodexPlanUsage, resolveCodexBinaryPath } from '../utils/codex-cli-resolver.js'; @@ -3401,6 +3402,14 @@ export class WebServer extends EventEmitter { reconcileUpdateOnBoot(); } + // One-time, once per data dir: a stored `agentSkillEnabled: false` written by the switch's + // old default (up to 1.49.0) becomes true, so the 1.50.0 default reaches installs that ever + // saved App Settings (agent-skill-default-migration.ts). Before the restore below reads + // settings (2 s cache) and before listen, so no session create can see the old value. + if (!this.testMode && (await migrateAgentSkillDefault()) === 'switched-on') { + this._settingsCache = null; + } + // Restore mux sessions BEFORE accepting connections // This prevents race conditions where clients connect before state is ready // CRITICAL: Skip in test mode to prevent tests from picking up user sessions diff --git a/test/agent-skill-default-migration.test.ts b/test/agent-skill-default-migration.test.ts new file mode 100644 index 00000000..aabfabf3 --- /dev/null +++ b/test/agent-skill-default-migration.test.ts @@ -0,0 +1,211 @@ +/** + * @fileoverview The one-time boot migration that lets the 1.50.0 agent skill default reach + * installs that saved App Settings before it (src/web/agent-skill-default-migration.ts). + * + * What is pinned, and why it matters: + * - An absent key, a stored `true` or no settings.json at all: settings.json is not written + * (byte-identical, odd formatting kept) and the marker is set. + * - A stored `false` (what every 1.49 save wrote, chosen or not) becomes `true`, every other key + * kept, exactly once: the log line prints once, and a second start reports `already-done` + * without reading the file. + * - An off set AFTER the migration stays off: through the real `PUT /api/settings` (what the + * page's Save sends), then a second start. The PUT neither writes nor drops the marker, and + * `GET /api/settings` never returns it, so the page cannot hold it to send it back. + * - A settings.json that does not parse is left byte-identical, no marker is written, nothing + * throws, and the next start (after the fix) does the migration. + * - A marker that cannot be checked switches nothing on (a deliberate off must not be cleared + * twice). + * - The marker is not a config-backup file, and `WebServer.start()` runs the migration before + * its first settings read and before it listens (source wiring guard: the boot call is skipped + * in test mode, like the other boot-time writes). + * + * The page's side (a device blob holding the old `false`) is pinned in + * test/agent-skill-setting-ui.test.ts. + * + * Port: 0 (one WebServer for the PUT/GET cases). + */ + +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { WebServer } from '../src/web/server.js'; +import { TmuxManager } from '../src/tmux-manager.js'; +import { SETTINGS_PATH } from '../src/web/route-helpers.js'; +import { dataPath } from '../src/config/instance.js'; +import { CONFIG_BACKUP_FILES } from '../src/config-backup.js'; +import { + AGENT_SKILL_DEFAULT_MARKER, + AGENT_SKILL_DEFAULT_MIGRATED_LOG, + migrateAgentSkillDefault, +} from '../src/web/agent-skill-default-migration.js'; + +vi.spyOn(TmuxManager, 'isTmuxAvailable').mockReturnValue(true); + +const MARKER = dataPath(AGENT_SKILL_DEFAULT_MARKER); + +/** Written with a formatting no writer of ours produces, so any rewrite shows in the bytes. */ +const seedRaw = (text: string) => writeFileSync(SETTINGS_PATH, text); +const seed = (value: unknown) => seedRaw(JSON.stringify(value)); +const raw = () => readFileSync(SETTINGS_PATH, 'utf-8'); +const stored = () => JSON.parse(raw()) as Record; +const marker = () => JSON.parse(readFileSync(MARKER, 'utf-8')) as { migratedAt: string; switchedOn: boolean }; + +/** The bytes of a torn settings.json (test/settings-json-write-serialization.test.ts). */ +const TORN = '{\n "agentSkillEnabled": false\n}' + 'rt-1761000000000"\n}'; + +let logSpy: ReturnType; +let warnSpy: ReturnType; +const migratedLogs = () => logSpy.mock.calls.filter(([line]) => line === AGENT_SKILL_DEFAULT_MIGRATED_LOG).length; + +beforeEach(() => { + mkdirSync(dirname(SETTINGS_PATH), { recursive: true }); + rmSync(SETTINGS_PATH, { force: true }); + rmSync(MARKER, { force: true }); + logSpy = vi.spyOn(console, 'log'); + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); +}); + +afterEach(() => { + logSpy.mockRestore(); + warnSpy.mockRestore(); +}); + +describe('nothing to switch on', () => { + it('an absent key: settings.json is not written, the marker is set', async () => { + seed({ lastUsedCase: 'demo' }); + const before = raw(); + expect(await migrateAgentSkillDefault()).toBe('nothing-to-change'); + expect(raw()).toBe(before); + expect(marker()).toMatchObject({ switchedOn: false }); + expect(migratedLogs()).toBe(0); + }); + + it('no settings.json at all: none is created, the marker is set', async () => { + expect(await migrateAgentSkillDefault()).toBe('nothing-to-change'); + expect(existsSync(SETTINGS_PATH)).toBe(false); + expect(marker()).toMatchObject({ switchedOn: false }); + }); + + it('a stored true is left untouched', async () => { + seed({ agentSkillEnabled: true, caseOrder: ['a'] }); + const before = raw(); + expect(await migrateAgentSkillDefault()).toBe('nothing-to-change'); + expect(raw()).toBe(before); + expect(marker()).toMatchObject({ switchedOn: false }); + }); +}); + +describe('a stored false from before 1.50.0', () => { + it('is switched on once, every other key kept, with one log line', async () => { + seed({ agentSkillEnabled: false, lastUsedCase: 'demo', nice: { enabled: true, niceValue: 5 } }); + expect(await migrateAgentSkillDefault()).toBe('switched-on'); + expect(stored()).toEqual({ agentSkillEnabled: true, lastUsedCase: 'demo', nice: { enabled: true, niceValue: 5 } }); + expect(marker()).toMatchObject({ switchedOn: true }); + expect(Number.isNaN(Date.parse(marker().migratedAt))).toBe(false); + expect(migratedLogs()).toBe(1); + + // A second start finds the marker and reads nothing. + const after = raw(); + expect(await migrateAgentSkillDefault()).toBe('already-done'); + expect(raw()).toBe(after); + expect(migratedLogs()).toBe(1); + }); + + it('a false written by hand after the migration is never switched on again', async () => { + seed({ agentSkillEnabled: false }); + expect(await migrateAgentSkillDefault()).toBe('switched-on'); + seed({ agentSkillEnabled: false }); + expect(await migrateAgentSkillDefault()).toBe('already-done'); + expect(stored()).toEqual({ agentSkillEnabled: false }); + }); +}); + +describe('a settings.json that does not parse', () => { + it('is left alone, writes no marker, does not throw, and the next start after the fix migrates', async () => { + seedRaw(TORN); + expect(await migrateAgentSkillDefault()).toBe('refused'); + expect(raw()).toBe(TORN); + expect(existsSync(MARKER)).toBe(false); + expect( + warnSpy.mock.calls.some(([line]) => String(line).startsWith('[agent-skill] default migration skipped')) + ).toBe(true); + + // The user repairs the file (or restores a backup): the migration has not run yet, so it runs now. + seed({ agentSkillEnabled: false }); + expect(await migrateAgentSkillDefault()).toBe('switched-on'); + expect(stored()).toEqual({ agentSkillEnabled: true }); + }); +}); + +describe('a marker that cannot be checked', () => { + it('switches nothing on and writes no marker', async () => { + seed({ agentSkillEnabled: false }); + // A path below a regular file: lstat answers ENOTDIR, not ENOENT. + expect(await migrateAgentSkillDefault(join(SETTINGS_PATH, 'marker.json'))).toBe('refused'); + expect(stored()).toEqual({ agentSkillEnabled: false }); + expect(existsSync(MARKER)).toBe(false); + }); +}); + +describe('with the page and the server (port 0)', () => { + let server: WebServer; + const url = (p: string) => `http://localhost:${server.boundPort}${p}`; + + beforeAll(async () => { + server = new WebServer(0, false, true); + await server.start(); + }, 30000); + + afterAll(async () => { + await server?.stop(); + }, 30000); + + it('an off saved through PUT /api/settings after the migration stays off at the next start, and the PUT leaves the marker alone', async () => { + seed({ agentSkillEnabled: false, lastUsedCase: 'demo' }); + expect(await migrateAgentSkillDefault()).toBe('switched-on'); + const markerBytes = readFileSync(MARKER, 'utf-8'); + + // What the page sends when the user turns the switch off (plus another synced key). + const res = await fetch(url('/api/settings'), { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ agentSkillEnabled: false, autoNameSessions: true }), + }); + expect(res.status).toBe(200); + expect(stored()).toEqual({ agentSkillEnabled: false, lastUsedCase: 'demo', autoNameSessions: true }); + expect(readFileSync(MARKER, 'utf-8')).toBe(markerBytes); + + expect(await migrateAgentSkillDefault()).toBe('already-done'); + expect(stored().agentSkillEnabled).toBe(false); + }); + + it('GET /api/settings never carries the marker, so the page cannot hold or send it', async () => { + seed({ agentSkillEnabled: false }); + await migrateAgentSkillDefault(); + const res = await fetch(url('/api/settings')); + const body = (await res.json()) as Record; + const data = (body.success === true ? body.data : body) as Record; + expect(data).toEqual({ agentSkillEnabled: true }); + }); +}); + +describe('placement', () => { + it('the marker is not a config-backup file, so a restore never re-arms or drops it', () => { + expect(CONFIG_BACKUP_FILES as readonly string[]).not.toContain(AGENT_SKILL_DEFAULT_MARKER); + }); + + it('WebServer.start() runs it before its first settings read, the restore and listen, and drops the settings cache', () => { + const source = readFileSync(resolve(import.meta.dirname, '../src/web/server.ts'), 'utf8'); + const start = source.slice(source.indexOf(' async start(): Promise {')); + const call = start.indexOf('await migrateAgentSkillDefault()'); + expect(call).toBeGreaterThan(0); + for (const later of [ + 'await this.getAgentInboxPersistEnabled()', + 'await this.restoreMuxSessions()', + 'await this.app.listen(', + ]) { + expect(start.indexOf(later), later).toBeGreaterThan(call); + } + expect(start.slice(call, call + 200)).toContain('this._settingsCache = null;'); + }); +}); diff --git a/test/agent-skill-setting-ui.test.ts b/test/agent-skill-setting-ui.test.ts index a62869dc..05ff2563 100644 --- a/test/agent-skill-setting-ui.test.ts +++ b/test/agent-skill-setting-ui.test.ts @@ -11,6 +11,10 @@ * device's blob or the server's settings.json, merged as a synced key). * - saveAppSettings() sends the switch in the settings PUT, and the body passes the * .strict() SettingsUpdateSchema (an undeclared key 400s every save). + * - A device that saved on 1.49.0 or earlier keeps `false` in its own blob. After the + * server's one-time boot migration (stored true) its switch shows on and a Save keeps it + * on; on a server without the key the device's copy is dropped for the default; a later + * explicit off on the server stays off. The Save body never carries the migration marker. * - The row says it is on by default, that the copy stays out of git, and how to * remove copies already written (turning it off sweeps nothing). * @@ -173,6 +177,54 @@ describe('Save', () => { }); }); +// Every device that saved App Settings on 1.49.0 or earlier holds `agentSkillEnabled: false` in +// its own blob (the switch loaded an absent key as off). The server's boot migration +// (src/web/agent-skill-default-migration.ts) switches a stored false on by storing TRUE, because +// the merge only overwrites a synced key the server has: a key it deleted would leave the +// device's false in place, and the next Save would write it back. +describe('a device that saved before 1.50.0', () => { + const OLD_BLOB = { agentSkillEnabled: false, autoNameSessions: false }; + + it("after the server's migration (stored true) the switch shows on, and a Save keeps it on and passes the schema", async () => { + const { app, checkbox, putBody } = await boot(OLD_BLOB); + await app.loadAppSettingsFromServer(Promise.resolve({ agentSkillEnabled: true, lastUsedCase: 'demo' })); + app.openAppSettings(); + expect(checkbox().checked).toBe(true); + await app.saveAppSettings(); + const body = putBody(); + expect(body.agentSkillEnabled).toBe(true); + // The migration's marker is a file of its own, never a setting: the Save can neither send it + // back nor be refused for it by the .strict() schema. + expect(Object.keys(body).filter((key) => /migrat/i.test(key))).toEqual([]); + expect(SettingsUpdateSchema.safeParse(body).error?.issues ?? []).toEqual([]); + }); + + it('on a server without the key (a save that never reached it) the switch shows the default, on', async () => { + const { app, checkbox, putBody } = await boot(OLD_BLOB); + await app.loadAppSettingsFromServer(Promise.resolve({ lastUsedCase: 'demo' })); + app.openAppSettings(); + expect(checkbox().checked).toBe(true); + await app.saveAppSettings(); + expect(putBody().agentSkillEnabled).toBe(true); + }); + + it('an off the user saved after the migration stays off on every device', async () => { + const { app, checkbox, putBody } = await boot({ agentSkillEnabled: true }); + await app.loadAppSettingsFromServer(Promise.resolve({ agentSkillEnabled: false })); + app.openAppSettings(); + expect(checkbox().checked).toBe(false); + await app.saveAppSettings(); + expect(putBody().agentSkillEnabled).toBe(false); + }); + + it('without a server answer the device keeps what it has', async () => { + const { app, checkbox } = await boot(OLD_BLOB); + await app.loadAppSettingsFromServer(Promise.resolve(null as unknown as Settings)); + app.openAppSettings(); + expect(checkbox().checked).toBe(false); + }); +}); + describe('the App Settings row', () => { const doc = new JSDOM(INDEX).window.document; const row = doc.getElementById('appSettingsAgentSkill')!.closest('.set-row') as HTMLElement; From 0c0ada7cdb89bdc14c703c511e876c390a921771 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 8 Nov 2026 09:15:58 +0100 Subject: [PATCH 10/14] docs(agent-skill): existing installs that stored off are switched on once The changeset, CLAUDE.md, the invariants, the agent control plan, both READMEs and the wiki's Codeman Skill and Settings Reference pages said an install whose App Settings were saved on an earlier version stays off until switched on. They now say the first 1.50.0 start switches a stored off on once, and that turning it off afterwards sticks. The changeset marks it "Behaviour change:", says the migration cannot tell a chosen off from the old default, and tells users to reload a tab left open from before the upgrade before saving App Settings in it. CLAUDE.md lists the marker file among the data dir's state files, and the invariants record why it stores true, why the marker is its own file, and where it runs. Co-Authored-By: Claude Opus 5.5 --- .changeset/agent-skill-default-on.md | 2 +- CLAUDE.md | 4 ++-- README.md | 2 +- README.zh-CN.md | 2 +- docs/agent-control-plan.md | 9 ++++++--- docs/architecture-invariants.md | 2 +- docs/wiki/Codeman-Skill.md | 5 +++-- docs/wiki/Settings-Reference.md | 2 +- 8 files changed, 16 insertions(+), 12 deletions(-) diff --git a/.changeset/agent-skill-default-on.md b/.changeset/agent-skill-default-on.md index 2b84b903..643cdf2a 100644 --- a/.changeset/agent-skill-default-on.md +++ b/.changeset/agent-skill-default-on.md @@ -2,4 +2,4 @@ 'aicodeman': minor --- -**The Codeman agent skill is on by default.** New sessions get the skill in their folder, where their CLI looks for skills (`.claude/skills/codeman/` for Claude Code, `.agents/skills/codeman/` for Codex, Gemini CLI, OpenCode, Antigravity, Grok, DeepSeek and OMP; still none for Pi, GitHub Copilot or a plain shell), so an agent can start and drive workers without an install step. The copy now ignores itself in git (a `.gitignore` of `*` inside the copy, written once and never overwritten), so it never shows up as a new file in your repository, and `codeman skill uninstall` removes it with the copy. A `settings.json` without the `agentSkillEnabled` key now means on; an explicit `false` stays off, which includes every install whose App Settings were saved on an earlier version, since that save wrote the old default. Turn it off under App Settings, Agents & CLIs, All agent CLIs, **Codeman Agent Skill**. Turning it off still removes no copy already written: `codeman skill uninstall --case ` does that per case. +**The Codeman agent skill is on by default.** New sessions get the skill in their folder, where their CLI looks for skills (`.claude/skills/codeman/` for Claude Code, `.agents/skills/codeman/` for Codex, Gemini CLI, OpenCode, Antigravity, Grok, DeepSeek and OMP; still none for Pi, GitHub Copilot or a plain shell), so an agent can start and drive workers without an install step. The copy now ignores itself in git (a `.gitignore` of `*` inside the copy, written once and never overwritten), so it never shows up as a new file in your repository, and `codeman skill uninstall` removes it with the copy. A `settings.json` without the `agentSkillEnabled` key now means on. **Behaviour change:** every Save of App Settings on 1.49.0 or earlier wrote the old default (`false`) whether or not you touched this switch, so on its first start 1.50.0 switches a stored off on, once per data directory (it cannot tell an off you chose from the old default; the startup log says when it did). Turn it off under App Settings, Agents & CLIs, All agent CLIs, **Codeman Agent Skill**: an off set after that first start stays off. A browser tab left open from before the upgrade still holds the old value until you reload it, so reload before saving App Settings there. Turning it off still removes no copy already written: `codeman skill uninstall --case ` does that per case. diff --git a/CLAUDE.md b/CLAUDE.md index 14784e95..9d5b7b94 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -127,7 +127,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Input**: `session.writeViaMux()` for programmatic/curl input via one tmux bracketed paste (`paste-buffer -p`) + `send-keys Enter`; a multi-line prompt keeps its line breaks inside that paste. A single character is typed with `send-keys -l` instead, because selection dialogs ignore a pasted digit. Interactive **browser** input goes through a durable **exactly-once** layer: a stable `clientId` + monotonic per-session `seq` persisted to localStorage until the server ACKs, so a dropped link cannot lose or double-deliver a prompt. `ws-connection-registry.ts` supersedes only same-TAB reconnects, so two tabs on one session coexist. → [architecture-invariants#input-delivery-and-ws-resilience](docs/architecture-invariants.md#input-delivery-and-ws-resilience) -**Agent wait primitives**: bounded long-polls: `GET /api/sessions/:id/wait`, `GET .../wait-output` (literal substring, **never** regex) and `wait`/`waitTimeout` on `POST .../input`. Registry `session-wait-registry.ts` (pure), bounds `config/agent-wait.ts`. ⚠️ A timeout is a 200 (`wait.timedOut`). ⚠️ `stop` exists for `claude` and `deepseek` ONLY, and never for an adopted session (rule lives in `hooksAvailableForMode()`), `blocked` also for a CLI declaring `capabilities.workDetect.blockedLine` (codex, read off the screen; `blockedSignalAvailable()`): explicit request elsewhere is a 400. ⚠️ Send-and-wait registers the waiter BEFORE the write; teardown must `notifySignal('exit')` BEFORE `cancelAll()`; hangup abort listens on `reply.raw` (guarded by `writableFinished`), never `req.raw`; liveness comes from `isPaneDead`, never `session.pid`. ⚠️ Signals are edge-triggered with no history: gather fan-outs via send-and-wait or `wait-output` markers. Packaged as the `skills/codeman` skill (`codeman skill install`, plugin marketplace, or injection behind `agentSkillEnabled`, SYNCED, **default ON since 1.50.0**, into the directory each CLI scans: `capabilities.agentSkillDir`, `.claude/skills` for claude, `.agents/skills` for the others, none for pi or copilot): injection is add-only, marker-owned (`applyAgentSkill`), refuses symlinks, and refreshes a marker-owned user-level copy (`refreshUserAgentSkill`). ⚠️ An ABSENT key means ON on both sides (server `!== false`, the page's switch `!== false`, or a Save writes the old default back); only an explicit false is off, and turning it off still sweeps nothing (`codeman skill uninstall --case ` removes copies). ⚠️ Every managed copy ignores itself in git: a `.gitignore` of `*` written FIRST with `wx` (never overwritten, never part of `readAgentSkillSource()`, so never in `skills/codeman/` or the plugin mirror), removed by uninstall only when it is the last file left; never touch the user's own ignore files for it. → [architecture-invariants#agent-wait-primitives](docs/architecture-invariants.md#agent-wait-primitives), `docs/api-reference.md` +**Agent wait primitives**: bounded long-polls: `GET /api/sessions/:id/wait`, `GET .../wait-output` (literal substring, **never** regex) and `wait`/`waitTimeout` on `POST .../input`. Registry `session-wait-registry.ts` (pure), bounds `config/agent-wait.ts`. ⚠️ A timeout is a 200 (`wait.timedOut`). ⚠️ `stop` exists for `claude` and `deepseek` ONLY, and never for an adopted session (rule lives in `hooksAvailableForMode()`), `blocked` also for a CLI declaring `capabilities.workDetect.blockedLine` (codex, read off the screen; `blockedSignalAvailable()`): explicit request elsewhere is a 400. ⚠️ Send-and-wait registers the waiter BEFORE the write; teardown must `notifySignal('exit')` BEFORE `cancelAll()`; hangup abort listens on `reply.raw` (guarded by `writableFinished`), never `req.raw`; liveness comes from `isPaneDead`, never `session.pid`. ⚠️ Signals are edge-triggered with no history: gather fan-outs via send-and-wait or `wait-output` markers. Packaged as the `skills/codeman` skill (`codeman skill install`, plugin marketplace, or injection behind `agentSkillEnabled`, SYNCED, **default ON since 1.50.0**, into the directory each CLI scans: `capabilities.agentSkillDir`, `.claude/skills` for claude, `.agents/skills` for the others, none for pi or copilot): injection is add-only, marker-owned (`applyAgentSkill`), refuses symlinks, and refreshes a marker-owned user-level copy (`refreshUserAgentSkill`). ⚠️ An ABSENT key means ON on both sides (server `!== false`, the page's switch `!== false`, or a Save writes the old default back); only an explicit false is off, and turning it off still sweeps nothing (`codeman skill uninstall --case ` removes copies). ⚠️ Existing installs that stored off are switched on once at the first 1.50.0 start (every Save up to 1.49.0 wrote `false`); turning it off afterwards sticks. That is `migrateAgentSkillDefault()` (`web/agent-skill-default-migration.ts`, from `WebServer.start()` before the first settings read and before listen, skipped in test mode, through `mutateSettingsFile()` only): it stores `true`, never deletes the key (the page's merge keeps a device's own `false` for a key the server lacks, and the next Save would write it back; the page also drops its copy when the server has no key), and its "done" marker is its own file, `agent-skill-default-migrated.json` in the data dir, never a settings key (so no Save sends or drops it, the `.strict()` schema never sees it, and a config-backup restore leaves it alone). ⚠️ Every managed copy ignores itself in git: a `.gitignore` of `*` written FIRST with `wx` (never overwritten, never part of `readAgentSkillSource()`, so never in `skills/codeman/` or the plugin mirror), removed by uninstall only when it is the last file left; never touch the user's own ignore files for it. → [architecture-invariants#agent-wait-primitives](docs/architecture-invariants.md#agent-wait-primitives), `docs/api-reference.md` **Agent CLI (`codeman agent …`, `src/cli-agent.ts`)**: the command-line client for the wait primitives and the session verbs, for agents the skill does not reach (pi, shell, no `jq`; `seedAgentSessionPreamble` covers every local session whose CLI takes the skill, `capabilities.agentSkillDir`; every pane still exports `CODEMAN_MUX`/`CODEMAN_SESSION_ID`/`CODEMAN_API_URL`). It is an HTTP CLIENT: inspection and guarded stage/submit use the ownership-checked `agent-session-routes.ts` module (experimental, behind `agentCoordinationEnabled`, SYNCED, default OFF: all three routes answer `403 FORBIDDEN` naming it while off, and the CLI shows that verbatim); the other verbs reuse existing routes, with no second transport (the plan's "Socket API" non-goal stands), every call carries `X-Codeman-Parent-Session`, only `spawn`'s quick-start carries `X-Codeman-Agent-Origin` (pinned in test/cli-agent.test.ts), while `spawn` without a case (cwd or `--working-dir`) is `POST /api/v1/sessions` plus `/interactive` or `/shell` without that header, so no agent-case marker, and refuses (exit 4, before any request) an empty `--working-dir` and a bare spawn from a HOME cwd, since it always sends an explicit `workingDir` and the server's no-`workingDir` hooks skip cannot apply (test/cli-agent-project-spawn.test.ts), auth (`src/codeman-credentials.ts`, shared with `codeman attach` and the TUI) from `CODEMAN_PASSWORD` or the data dir's `.env` (the same reader `codeman attach` uses — `readCodemanEnvFile`). Verbs return exit codes (`0` ok / `1` error / `2` timeout / `3` exited / `4` refused) instead of calling `process.exit`, so `test/cli-agent.test.ts` drives every verb against a recording fake transport plus the real `httpRequest` against a local server. ⚠️ Three guards live in code, not prose, and the tests pin them: refuse without `CODEMAN_MUX=1`+`CODEMAN_API_URL` (never guess a URL); `send` transmits printable text + `\r` only (`inputRefusal`: any byte < 0x20 or DEL refuses BEFORE the transport — opencode's `Ctrl+C` is `app_exit`), ESC exists solely as `interrupt` with no `\r`; `rm` fails closed (`deleteRefusal`: empty id, self shorter than 8, prefix match in EITHER direction, re-checked on the resolved id). ⚠️ The routes accept FULL ids only — an 8-char prefix is a 404 (measured live) — while `ls` prints prefixes, so `resolveSessionId` maps a short id through `GET /api/v1/sessions` and refuses an ambiguous one. It also refuses any id under 8 characters (exit 4, on every verb, before any request), the same floor as the server's `PARENT_SESSION_ID_MIN_PREFIX`, so `rm 9` can never pick an arbitrary session. ⚠️ A 400 for `--until stop` on a hook-less mode is passed through verbatim, never papered over: the marker path is the answer there. `spawn` on a readiness timeout exits 2 and LEAVES the session for inspection (the skill's `spawn_worker` deletes it; the CLI's caller is often a human); its stdout is the id ALONE (prose on stderr) so `SID=$(…)` works. ⚠️ Two server answers that look like success and are not: `delivered:false` without `duplicate` (the bytes went into a dead pane — the field exists so a client says "restart" instead of "wait longer") and `wait.ended:true, signal:null` (the worker died during a `stop`/`idle` wait: the registry satisfies only waiters that listed `exit`, then cancels the rest) — both exit 3, checked BEFORE the happy paths in `waitExitCode`. → README "`codeman agent`", `docs/api-reference.md` @@ -323,7 +323,7 @@ One module per domain in `src/web/routes/` (plus a barrel; `ls src/web/routes/` ## State Files -All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, cron jobs/runs, owner tab layouts, case default groups), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` + `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `docker-env-applied.json` (Compose deployment only: sha256 of the Dockerfile + compose file the running container was built from, written by `Start-Codeman.sh`, read by the self-updater's environment gate), `docker-build-source.json` (Compose deployment only: the checkout's HEAD commit and `package-lock.json` hash the `codeman-node-modules`/`codeman-dist` volumes currently reflect, written by both `Start-Codeman.sh` and a successful in-place self-update, compared to detect and refresh a volume left stale by an externally-triggered rebuild), `linked-cases.json`, `webviews.json` (saved web-tab dashboard URLs), `remote-hosts.json` + `remote-cases.json`, `docker-hosts.json` + `docker-cases.json` + `docker-exports/`, `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance), `users.json` (multi-user, mode 0600) + `admin-audit.jsonl`, `intents.json` (Read My Mind intent profiles, mode 0600), `agent-inbox.json` (agent inbox snapshot, only while `agentInboxPersistEnabled` is on, mode 0600), `edit-history/.json` (Edit history panel, one store per session, every diff an agent wrote, mode 0600), `webhook.json` (webhook notifications: enabled/service/scope plus the ntfy/Slack/Discord URL, a bearer secret, so mode 0600, kept out of `settings.json` and never returned by `/api/webhook`, which is admin-only in multi-user mode), `discord.json` (Discord bridge: bot token, a bearer secret, so mode 0600, never returned by `/api/discord`, admin-only in multi-user mode) + `discord-bridge.json` (sessionId to Discord thread id), `config-backup-id` (the random id config backups record as each manifest's `origin`, so instances sharing a backup folder keep to their own snapshots; mode 0600, never tracked or restored, and deleting it unlists this instance's earlier snapshots), `backups/config/` (the default config-backup folder, moved by `configBackupDir`: `cfg-YYYYMMDD-HHMMSS/` snapshots, 0700 with 0600 files, each with a `manifest.json` of SHA-256s, the origin and the retention in force (`keepCount`/`keepDays`), which a pass falls back to when `settings.json` lacks a retention key), `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI), `install.log` (installer step output, written by `install.sh`'s `run_step`) `tailscale-rename` (the node name before `install.sh` renamed it, so uninstall can offer it back) and `runtime/` (the Node.js and tmux `install.sh` downloaded on its no-admin route, linked from `~/.local/bin`; all three installer-route only). Transient: `self-update-runner.sh`. Multi-user case spaces live OUTSIDE the data dir at `~/codeman-users//cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`). +All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, cron jobs/runs, owner tab layouts, case default groups), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` + `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `docker-env-applied.json` (Compose deployment only: sha256 of the Dockerfile + compose file the running container was built from, written by `Start-Codeman.sh`, read by the self-updater's environment gate), `docker-build-source.json` (Compose deployment only: the checkout's HEAD commit and `package-lock.json` hash the `codeman-node-modules`/`codeman-dist` volumes currently reflect, written by both `Start-Codeman.sh` and a successful in-place self-update, compared to detect and refresh a volume left stale by an externally-triggered rebuild), `linked-cases.json`, `webviews.json` (saved web-tab dashboard URLs), `remote-hosts.json` + `remote-cases.json`, `docker-hosts.json` + `docker-cases.json` + `docker-exports/`, `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance), `users.json` (multi-user, mode 0600) + `admin-audit.jsonl`, `intents.json` (Read My Mind intent profiles, mode 0600), `agent-inbox.json` (agent inbox snapshot, only while `agentInboxPersistEnabled` is on, mode 0600), `edit-history/.json` (Edit history panel, one store per session, every diff an agent wrote, mode 0600), `webhook.json` (webhook notifications: enabled/service/scope plus the ntfy/Slack/Discord URL, a bearer secret, so mode 0600, kept out of `settings.json` and never returned by `/api/webhook`, which is admin-only in multi-user mode), `discord.json` (Discord bridge: bot token, a bearer secret, so mode 0600, never returned by `/api/discord`, admin-only in multi-user mode) + `discord-bridge.json` (sessionId to Discord thread id), `agent-skill-default-migrated.json` (written once by the 1.50.0 boot migration of `agentSkillEnabled`; while it exists the migration never runs again, so deleting it would switch a stored off on at the next start; never tracked or restored), `config-backup-id` (the random id config backups record as each manifest's `origin`, so instances sharing a backup folder keep to their own snapshots; mode 0600, never tracked or restored, and deleting it unlists this instance's earlier snapshots), `backups/config/` (the default config-backup folder, moved by `configBackupDir`: `cfg-YYYYMMDD-HHMMSS/` snapshots, 0700 with 0600 files, each with a `manifest.json` of SHA-256s, the origin and the retention in force (`keepCount`/`keepDays`), which a pass falls back to when `settings.json` lacks a retention key), `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI), `install.log` (installer step output, written by `install.sh`'s `run_step`) `tailscale-rename` (the node name before `install.sh` renamed it, so uninstall can offer it back) and `runtime/` (the Node.js and tmux `install.sh` downloaded on its no-admin route, linked from `~/.local/bin`; all three installer-route only). Transient: `self-update-runner.sh`. Multi-user case spaces live OUTSIDE the data dir at `~/codeman-users//cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`). **Generated top-level dirs** (all gitignored — don't edit or commit): `dist/` (esbuild output), `out/`, `coverage/`, `test-results/`, `tmp/`, `screenshots-echo-diag/`. The committed gesture bundle (`src/web/public/gesture/gesture-codeman.js`) IS tracked, but its runtime wasm/model assets (`src/web/public/gesture/wasm/`, `*.task`) are fetched and gitignored. diff --git a/README.md b/README.md index b6c553c4..4d2342fe 100644 --- a/README.md +++ b/README.md @@ -850,7 +850,7 @@ Every recipe in there was verified against a live server, and the comments recor - **It self-gates.** It acts inside a Codeman-managed session. Outside one (`CODEMAN_MUX` unset) it only reads saved Codex conversations offline (`codeman history read`) and refuses everything else, and it never guesses an API URL, so a global install costs an unrelated Claude Code session nothing. - **It is deliberately conservative.** Unprompted, it may only spawn sessions, prompt them, and delete ones **it created in that same conversation, by exact id**, through a fail-closed guard that refuses to delete the agent's own session. Deleting a case (which erases a real directory of your code), bulk kills, respawn/ralph/cron/orchestrator changes and settings writes all require you to ask, naming the target. -⚠️ The web UI injection is **on by default** since 1.50.0. A `settings.json` without the `agentSkillEnabled` key means on; an install whose App Settings were saved on an earlier version stored the old default (`false`) and stays off until you switch it on. Turning it off **does not remove already-injected copies** (a create-time sweep would yank the skill out from under other live sessions sharing that workspace). Remove them per case with `codeman skill uninstall --case `. +⚠️ The web UI injection is **on by default** since 1.50.0. A `settings.json` without the `agentSkillEnabled` key means on. Existing installs that stored off are switched on once at the first 1.50.0 start (every App Settings save on 1.49.0 or earlier wrote the old default, `false`, whether or not you touched this switch); turning it off afterwards sticks. Turning it off **does not remove already-injected copies** (a create-time sweep would yank the skill out from under other live sessions sharing that workspace). Remove them per case with `codeman skill uninstall --case `. --- diff --git a/README.zh-CN.md b/README.zh-CN.md index e2e9967f..d08bcf79 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -850,7 +850,7 @@ deleted 9f2d8e5f, aff9c691, be9f1f15 标签消失 - **它会自我门禁。** 它在 Codeman 管理的会话里工作。不在这样的会话里(`CODEMAN_MUX` 未设置)时,它只会离线读取已保存的 Codex 对话(`codeman history read`),其他一律拒绝,也从不去猜 API 地址,所以全局安装对无关的 Claude Code 会话没有任何代价。 - **它刻意保守。** 未经提示,它只会拉起会话、给它们发提示,并删除**它在同一段对话里自己创建的**会话(按精确 id,经由一个拒绝删除智能体自身会话的失败即关闭守卫)。删除 case(会抹掉一个真实的代码目录)、批量杀会话、改动 respawn/ralph/cron/orchestrator 以及写设置,都需要你开口并指名目标。 -⚠️ 从 1.50.0 起,Web UI 注入**默认开启**。`settings.json` 里没有 `agentSkillEnabled` 这个键就表示开启;如果你在更早的版本里保存过 App Settings,当时存下的是旧默认值(`false`),它会保持关闭,直到你手动打开。把它关掉**不会删掉已经注入的副本**(在创建时做清扫,会把技能从共用同一个工作区的其他活动会话脚下抽走)。要删就按 case 删:`codeman skill uninstall --case `。 +⚠️ 从 1.50.0 起,Web UI 注入**默认开启**。`settings.json` 里没有 `agentSkillEnabled` 这个键就表示开启。已经存了关闭的旧安装,会在第一次启动 1.50.0 时被打开一次(1.49.0 及更早版本每次保存 App Settings 都会写入旧默认值 `false`,不管你有没有动过这个开关);之后你再把它关掉,就会一直保持关闭。把它关掉**不会删掉已经注入的副本**(在创建时做清扫,会把技能从共用同一个工作区的其他活动会话脚下抽走)。要删就按 case 删:`codeman skill uninstall --case `。 --- diff --git a/docs/agent-control-plan.md b/docs/agent-control-plan.md index 6fd918f2..63cfaa29 100644 --- a/docs/agent-control-plan.md +++ b/docs/agent-control-plan.md @@ -11,7 +11,10 @@ guard and the Part 3 deferrals). **Update 2026-11-08 (1.50.0)**: `agentSkillEnabled` is now **on by default** (owner decision: "enable the codeman skill by default"), the flip §2.2 planned for after dogfooding. An absent key means on in both readers (server `getAgentSkillEnabled()` and the App Settings switch, both -`!== false`); an explicit false stays off. To keep users' repositories clean, every managed copy +`!== false`); an explicit false is off. Existing installs that stored off are switched on once at +the first 1.50.0 start (every App Settings Save up to 1.49.0 wrote `false`); turning it off +afterwards sticks (`src/web/agent-skill-default-migration.ts`, a "Behaviour change" in the +release notes). To keep users' repositories clean, every managed copy now carries a `.gitignore` of `*` (written once with `wx`, removed by uninstall only when it is the last file left). Turning the setting off still sweeps nothing. The sections below record the original default-off decision as it was made. @@ -690,9 +693,9 @@ Built to the §2.6 file list, mirroring the statusLine mechanism throughout: | ----- | ----- | | `applyAgentSkill(casePath, enabled)` + `installAgentSkillInto` / `removeAgentSkillFrom` | `src/hooks-config.ts` | | `codeman skill install` / `skill uninstall` (`--global` default, `--case `) | `src/cli.ts` | -| `agentSkillEnabled` (SYNCED, default OFF; ON since 1.50.0) | `schemas.ts` (`SettingsUpdateSchema`), `getAgentSkillEnabled()` on `ConfigPort`/`server.ts`, checkbox in `index.html` + `settings-ui.js` | +| `agentSkillEnabled` (SYNCED, default OFF; ON since 1.50.0) | `schemas.ts` (`SettingsUpdateSchema`), `getAgentSkillEnabled()` on `ConfigPort`/`server.ts`, checkbox in `index.html` + `settings-ui.js`; the one-time 1.50.0 boot migration of a stored false in `src/web/agent-skill-default-migration.ts` | | Injection call sites (Claude mode only) | `POST /api/sessions` next to `refreshStaleCodemanHooks`; `POST /api/quick-start` after the case-create/self-heal blocks (local + docker cases; remote skipped, its path lives on another host) | -| Tests | `test/agent-skill.test.ts` (unit, incl. the self-ignoring `.gitignore` against real git since 1.50.0), `test/quick-start.test.ts` (real server: default-off at first, default-on since 1.50.0 incl. a partial PUT and an explicit false, injection on create, shell-mode skipped), `test/agent-skill-setting-ui.test.ts` (the App Settings switch reads an absent key as on) | +| Tests | `test/agent-skill.test.ts` (unit, incl. the self-ignoring `.gitignore` against real git since 1.50.0), `test/quick-start.test.ts` (real server: default-off at first, default-on since 1.50.0 incl. a partial PUT and an explicit false, injection on create, shell-mode skipped), `test/agent-skill-setting-ui.test.ts` (the App Settings switch reads an absent key as on, and a device blob holding the old false follows the server), `test/agent-skill-default-migration.test.ts` (the boot migration: once per data dir, marker outside settings.json) | Decisions worth keeping: diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index eae0021d..52b39141 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -167,7 +167,7 @@ Spawn commands are pinned as literal strings in `test/cli-registry-spawn-golden. Further detail: `stop`/`blocked` now fire for **`claude` and `deepseek`** (`shell` installs no hooks either); asking for one explicitly on any other mode is a 400 and the default set silently drops them. `deepseek` qualifies because the DeepSeek Harness TUI REPORTS idle/working/blocked to its supervisor and Codeman is that supervisor (`deepseek-status-shim.ts`), so its signals are definitive rather than inferred, and `hooksAvailableForMode()` in `session-wait-registry.ts` is the one place that rule lives (see the External CLI modes section for the per-session `statusReporting` wrinkle and the two dsh traps baked into the skill preamble: boot `idle` landing ~300 ms before the composer paints, and `sendwait` asking for `wait:"stop,exit"`). That is also why `deepseek` is the one non-claude mode the skill drives like claude: `spawn_workers alpha beta:deepseek` is a mixed fleet in one call, and `sendwait`/`last_text` need no variant. ⚠️ **`blocked` alone has a second source**: a CLI with no hook for it can declare the dialogs it blocks on (`capabilities.workDetect.blockedLine`, codex), and `blockedSignalAvailable()` (hooks OR that field) is the rule for that one signal; `stop` stays hooks-only, since nothing on a screen says a turn ended. A screen-read `blocked` is a LEVEL the server keeps reading (`Session.screenBlocked`), so the wait route answers it immediately (`currentSignalForWait()`) when the caller asked for `blocked`, and only then: such a pane is also `idle`, and `until=idle` must keep answering `idle`. See [Screen-read blocked dialogs](#screen-read-blocked-dialogs-codex). -**Packaging as the `skills/codeman` agent skill.** The primitives are packaged as the `skills/codeman` agent skill, installable three ways: via `codeman skill install [--case ]` / `skill uninstall`; as a Claude Code plugin from the repo's own marketplace (`/plugin marketplace add Ark0N/Codeman` + `/plugin install codeman@codeman`, see the root-files note in CLAUDE.md); or auto-injected into a case on session create behind `agentSkillEnabled` (SYNCED, default ON since 1.50.0, default OFF before). ⚠️ An ABSENT key means on in BOTH readers, the server's `getAgentSkillEnabled()` and the App Settings switch (both `!== false`): a page that showed an absent key as off would write `false` on the next Save of any other setting. Only an explicit false is off, so an install whose App Settings were saved on 1.49.0 or earlier (which wrote the old default) stays off until the user turns it on. Turning it off sweeps nothing. ⚠️ Every managed copy ignores itself in git, which is what makes the default safe in a user's repository: `installAgentSkillInto()` writes `/.gitignore` holding `*` FIRST (so a `git status` mid-install never lists it), with `wx` (a file already there is the user's and is never overwritten; a symlink at the name is refused), and outside the content list `readAgentSkillSource()` compares, so it never appears in `skills/codeman/` or the plugin mirror. A copy injected before 1.50.0 gains it on its next install (`refreshed`). `removeAgentSkillFrom()` deletes it only when it is the last entry left and holds exactly `*`, so a user's extra files there stay ignored. The repository's own `.gitignore` and `info/exclude` are never touched for it (the managed-worktree exclude in git-worktrees.ts is a separate, older line and still added). Measured against Claude Code 2.1.319: its skill discovery runs `git check-ignore` only on directories at or above `.claude/skills` (the nested-skills walk), never on a skill folder or its SKILL.md, and a `*` inside `codeman/` ignores the folder's contents, not the folder, so the skill still loads; codex 0.162 has no gitignore check on skills. Checked live on 2026-11-08 with the ignore file in place: Claude Code 2.1.319 (`--debug` reports `project: 1`), Codex 0.162 (app-server `skills/list`), OpenCode 1.3.0 (`opencode debug skill`) and Grok 1.0.52 (`grok --trust inspect`) all list `codeman`; agy, dsh, gemini and omp were not measured. ⚠️ Install and uninstall refuse (`symlink`) a link at the copy, at its `skills/` parent, and at a folder INSIDE the copy (`reference/`, `agentSkillSubdirIsSymlink()`): a cloned repository can commit one and a Docker case's container can plant one in the bind-mounted workspace, and a write or unlink of `reference/.md` through it lands wherever it points (measured: install overwrote a file there, uninstall deleted it). The check runs once before the first write; a link swapped in after it is not covered. ⚠️ WHERE it lands is registry data, `capabilities.agentSkillDir` (absent with `agentSkillInjection: true` = `.claude/skills`): `.claude/skills/codeman` for claude, `.agents/skills/codeman` for every other CLI that takes it (pi does not: a project skill triggers its trust prompt). `agentSkillDir` is a CLOSED enum (`AGENT_SKILL_DIRS`, schema.ts) because `clis.json` can set it and it decides where the server writes. The per-session preamble is seeded for every local session whose CLI takes the skill, and `codeman skill uninstall --case` removes the copy from every distinct registry directory (`registryAgentSkillDirs()`, cli.ts). ⚠️ Plugin skills are namespaced, so a Claude Code holding BOTH the plugin and a user-level or per-case copy lists the skill twice, `codeman` and `codeman:codeman` (measured 2026-09-14: neither shadows the other, both work); the docs tell users to pick one route, and since the per-case injection is on by default, a plugin user turns that setting off. +**Packaging as the `skills/codeman` agent skill.** The primitives are packaged as the `skills/codeman` agent skill, installable three ways: via `codeman skill install [--case ]` / `skill uninstall`; as a Claude Code plugin from the repo's own marketplace (`/plugin marketplace add Ark0N/Codeman` + `/plugin install codeman@codeman`, see the root-files note in CLAUDE.md); or auto-injected into a case on session create behind `agentSkillEnabled` (SYNCED, default ON since 1.50.0, default OFF before). ⚠️ An ABSENT key means on in BOTH readers, the server's `getAgentSkillEnabled()` and the App Settings switch (both `!== false`): a page that showed an absent key as off would write `false` on the next Save of any other setting. Only an explicit false is off. Existing installs that stored off are switched on once at the first 1.50.0 start; turning it off afterwards sticks. Every App Settings Save up to 1.49.0 wrote `false` (the switch loaded an absent key as off and Save sends the whole form), so without that the default would never reach anyone who had ever saved. ⚠️ The migration (`migrateAgentSkillDefault()`, `web/agent-skill-default-migration.ts`) runs from `WebServer.start()` before the first settings read (and the 2 s `readSettings()` cache, which it drops after a write) and before listen, never in test mode, and writes only through `mutateSettingsFile()`; a `settings.json` that does not parse is left alone with one log line and no marker, so the next start after the fix tries again. It cannot tell a deliberate off from the old default (accepted, a "Behaviour change" in the release notes). ⚠️ It stores `true` rather than deleting the key: `loadAppSettingsFromServer()` overwrites only the synced keys the server HAS, so every device that saved on 1.49 would keep its own `false`, show the switch off and write `false` back on its next Save (`true` also wins in a browser still running a cached settings-ui.js); for a server with no key the page drops the device's copy too. ⚠️ Its "done" marker is its own file, `agent-skill-default-migrated.json` in the data dir via `dataPath()`, checked before anything is read and written only after the settings write landed (a crash between the two is harmless: the next start finds `true`). Never a settings key: `GET /api/settings` never returns it, so no Save can send it back (the `.strict()` `SettingsUpdateSchema` would 400) or drop it (which would re-arm the migration and switch a later off back on), and it is outside `CONFIG_BACKUP_FILES`, so a restored settings.json, even a pre-1.50 one holding `false`, stays as restored. One data dir is one server, so in multi-user mode it runs once for the server. A device whose pages were all loaded before the upgrade still holds the old `false` in its localStorage blob until a page load refreshes it, and an App Settings Save from such a page writes that back (accepted; the release note says to reload). Tests: `test/agent-skill-default-migration.test.ts`, `test/agent-skill-setting-ui.test.ts`. Turning it off sweeps nothing. ⚠️ Every managed copy ignores itself in git, which is what makes the default safe in a user's repository: `installAgentSkillInto()` writes `/.gitignore` holding `*` FIRST (so a `git status` mid-install never lists it), with `wx` (a file already there is the user's and is never overwritten; a symlink at the name is refused), and outside the content list `readAgentSkillSource()` compares, so it never appears in `skills/codeman/` or the plugin mirror. A copy injected before 1.50.0 gains it on its next install (`refreshed`). `removeAgentSkillFrom()` deletes it only when it is the last entry left and holds exactly `*`, so a user's extra files there stay ignored. The repository's own `.gitignore` and `info/exclude` are never touched for it (the managed-worktree exclude in git-worktrees.ts is a separate, older line and still added). Measured against Claude Code 2.1.319: its skill discovery runs `git check-ignore` only on directories at or above `.claude/skills` (the nested-skills walk), never on a skill folder or its SKILL.md, and a `*` inside `codeman/` ignores the folder's contents, not the folder, so the skill still loads; codex 0.162 has no gitignore check on skills. Checked live on 2026-11-08 with the ignore file in place: Claude Code 2.1.319 (`--debug` reports `project: 1`), Codex 0.162 (app-server `skills/list`), OpenCode 1.3.0 (`opencode debug skill`) and Grok 1.0.52 (`grok --trust inspect`) all list `codeman`; agy, dsh, gemini and omp were not measured. ⚠️ Install and uninstall refuse (`symlink`) a link at the copy, at its `skills/` parent, and at a folder INSIDE the copy (`reference/`, `agentSkillSubdirIsSymlink()`): a cloned repository can commit one and a Docker case's container can plant one in the bind-mounted workspace, and a write or unlink of `reference/.md` through it lands wherever it points (measured: install overwrote a file there, uninstall deleted it). The check runs once before the first write; a link swapped in after it is not covered. ⚠️ WHERE it lands is registry data, `capabilities.agentSkillDir` (absent with `agentSkillInjection: true` = `.claude/skills`): `.claude/skills/codeman` for claude, `.agents/skills/codeman` for every other CLI that takes it (pi does not: a project skill triggers its trust prompt). `agentSkillDir` is a CLOSED enum (`AGENT_SKILL_DIRS`, schema.ts) because `clis.json` can set it and it decides where the server writes. The per-session preamble is seeded for every local session whose CLI takes the skill, and `codeman skill uninstall --case` removes the copy from every distinct registry directory (`registryAgentSkillDirs()`, cli.ts). ⚠️ Plugin skills are namespaced, so a Claude Code holding BOTH the plugin and a user-level or per-case copy lists the skill twice, `codeman` and `codeman:codeman` (measured 2026-09-14: neither shadows the other, both work); the docs tell users to pick one route, and since the per-case injection is on by default, a plugin user turns that setting off. Injection is ADD-ONLY at create, marker-owned (`applyAgentSkill` in `hooks-config.ts` never touches an unmarked user copy) and refuses symlinks (this repo's own `.claude/skills/codeman` is a symlink to the source, which the injector must never write through). ⚠️ Claude Code loads a same-named USER-LEVEL skill (`~/.claude/skills/codeman`, written once by `codeman skill install` with no `--case`) over the per-case copy, and nothing used to refresh it: a stale Aug-9 user copy shadowed every fresh injection (2026-08-14: agents ran the old recipes, spawned workers serially and lost their lineage arcs), so session create now also refreshes a marker-owned user copy (`refreshUserAgentSkill`; refresh-only, never installs, foreign/symlink refused). diff --git a/docs/wiki/Codeman-Skill.md b/docs/wiki/Codeman-Skill.md index 7fdd33b4..642087fe 100644 --- a/docs/wiki/Codeman-Skill.md +++ b/docs/wiki/Codeman-Skill.md @@ -76,8 +76,9 @@ Codeman-managed copy in that case, whichever CLI's directory it sits in). It nev To stop the Web UI copy, turn **App Settings → Agents & CLIs → All agent CLIs → Codeman Agent Skill** off. It is on by default since 1.50.0: a settings file without the setting means on, -and only switching it off turns it off. An install whose App Settings were saved on an -earlier version stored the old default (off), so there it stays off until you switch it on. +and only switching it off turns it off. Existing installs that stored off are switched on +once at the first 1.50.0 start (every App Settings save on an earlier version stored the old +default, off, whether or not you touched this switch); turning it off afterwards sticks. Turning the web UI setting off **does not remove copies it already injected**, because a sweep at session start would pull the skill out from under other live sessions sharing diff --git a/docs/wiki/Settings-Reference.md b/docs/wiki/Settings-Reference.md index 361dc66f..c30e8f0e 100644 --- a/docs/wiki/Settings-Reference.md +++ b/docs/wiki/Settings-Reference.md @@ -202,7 +202,7 @@ instead of its native cloud backend. See [Custom Model Endpoints](Custom-Model-E | Ralph / Todo Tracker | Enables the Ralph loop surfaces. | | Agent Teams | Experimental teams. Also needs the CLI's own environment flag. | | Pin tab names on the CLI | Launches every Claude session with `--name` set to its tab name, so Claude Code's prompt label and `/resume` list match your tabs. Claude then stops generating its own titles for those sessions, and a rename in Codeman holds only until the next turn. Synced, off by default; applies from the next session start or respawn. | -| Codeman Agent Skill | Under **All agent CLIs**. Injects the agent skill into each new session's case, where that session's CLI looks for skills (every CLI except Pi, GitHub Copilot and a plain shell). On by default since 1.50.0; the copy ignores itself in git, so your repository never lists it. Off stops new copies but removes none (`codeman skill uninstall --case ` does). See [The Codeman Skill](Codeman-Skill). | +| Codeman Agent Skill | Under **All agent CLIs**. Injects the agent skill into each new session's case, where that session's CLI looks for skills (every CLI except Pi, GitHub Copilot and a plain shell). On by default since 1.50.0; existing installs that stored off are switched on once at the first 1.50.0 start, and turning it off afterwards sticks. The copy ignores itself in git, so your repository never lists it. Off stops new copies but removes none (`codeman skill uninstall --case ` does). See [The Codeman Skill](Codeman-Skill). | | Remote auto-reconnect | Reattaches dropped remote SSH sessions. On by default. | | Nice priority / value | Runs agent processes at a lower CPU priority. | | Default Codex model | Model for new local Codex sessions; empty uses Codex's own config. Letters, digits, `.` `_` `-` `/` only. | From edfc2ed66747a813ce77df6c030509074381eff7 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 8 Nov 2026 09:38:15 +0100 Subject: [PATCH 11/14] docs(agent-skill): say what the setting UI test pins, not that it proves the deleted-key shape The migration's file comment said test/agent-skill-setting-ui.test.ts proves both shapes. It pins the shipped one (a stored true wins on a device holding the old false); the deleted-key shape was measured with the same harness and is not kept as a test. Co-Authored-By: Claude Opus 5.5 --- src/web/agent-skill-default-migration.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/web/agent-skill-default-migration.ts b/src/web/agent-skill-default-migration.ts index 0cd25c1d..3694bb92 100644 --- a/src/web/agent-skill-default-migration.ts +++ b/src/web/agent-skill-default-migration.ts @@ -14,7 +14,8 @@ * page. Every browser that saved App Settings keeps the form in its localStorage blob, and * `loadAppSettingsFromServer()` only overwrites a synced key the server HAS: a key the server * lacks keeps the device's `false`, the switch loads off, and the next Save of any setting writes - * `false` back (test/agent-skill-setting-ui.test.ts proves both shapes). A stored `true` wins on + * `false` back (measured with the real page code in the JSDOM harness of + * test/agent-skill-setting-ui.test.ts, which pins the shipped shape). A stored `true` wins on * every device, including a browser still running a cached settings-ui.js from before the upgrade. * Every reader treats `true` and an absent key alike (`!== false`). * From f1cfe61950bdead6948c36cace9b393f9b32de67 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 8 Nov 2026 10:09:30 +0100 Subject: [PATCH 12/14] docs(agent-skill): the settings schema comment still said default off The JSDoc on `agentSkillEnabled` in SettingsUpdateSchema said the skill is injected into `/.claude/skills/` on Claude session create and is "SYNCED, default OFF ... so it is opt-in". Since this branch it is on when absent, goes into whichever directory the session's CLI scans for skills, and a false stored by 1.49.0 or earlier is switched on once at the first 1.50.0 start. Comment only. Co-Authored-By: Claude Opus 5.5 --- src/web/schemas.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/web/schemas.ts b/src/web/schemas.ts index 652a81c4..9aad57a1 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -1471,11 +1471,13 @@ export const SettingsUpdateSchema = z ultracodeFloatingWindows: z.boolean().optional(), imageWatcherEnabled: z.boolean().optional(), /** - * Inject the Codeman agent skill (`skills/codeman`) into `/.claude/skills/` - * on Claude session create, so an agent inside the session can drive the API - * (see docs/agent-control-plan.md §2). SYNCED, default OFF: every skill's - * name+description costs context on every turn, so it is opt-in. Injection is - * add-only at create; a marker keeps user-authored copies untouched. + * Inject the Codeman agent skill (`skills/codeman`) into each new session's case, in + * the directory its CLI scans for skills (`capabilities.agentSkillDir`), so an agent + * inside the session can drive the API (see docs/agent-control-plan.md §2). SYNCED, + * default ON since 1.50.0: an absent key is on, only an explicit false is off, and a + * false stored by 1.49.0 or earlier is switched on once at the first 1.50.0 start + * (agent-skill-default-migration.ts). Injection is add-only at create; a marker keeps + * user-authored copies untouched. */ agentSkillEnabled: z.boolean().optional(), /** From cda4184e4207a459559747e22f1709a2e81f46ca Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 8 Nov 2026 10:09:34 +0100 Subject: [PATCH 13/14] docs(agent-skill): say what the migration does after a refused first start The migration's file comment and the invariants said a restored settings.json stays as restored and that a refused start leaves the migration to the next start. Both hold, but the first only once the migration has run: after a first 1.50.0 start that found settings.json unparseable, no marker exists, so an off saved between the fix and the next start is switched on with the rest. That is the same "cannot tell a chosen off from the old default" trade the release note already states, in a narrower window. Both texts now say so. Comment and docs only. Co-Authored-By: Claude Opus 5.5 --- docs/architecture-invariants.md | 2 +- src/web/agent-skill-default-migration.ts | 13 ++++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 52b39141..fd21a13d 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -167,7 +167,7 @@ Spawn commands are pinned as literal strings in `test/cli-registry-spawn-golden. Further detail: `stop`/`blocked` now fire for **`claude` and `deepseek`** (`shell` installs no hooks either); asking for one explicitly on any other mode is a 400 and the default set silently drops them. `deepseek` qualifies because the DeepSeek Harness TUI REPORTS idle/working/blocked to its supervisor and Codeman is that supervisor (`deepseek-status-shim.ts`), so its signals are definitive rather than inferred, and `hooksAvailableForMode()` in `session-wait-registry.ts` is the one place that rule lives (see the External CLI modes section for the per-session `statusReporting` wrinkle and the two dsh traps baked into the skill preamble: boot `idle` landing ~300 ms before the composer paints, and `sendwait` asking for `wait:"stop,exit"`). That is also why `deepseek` is the one non-claude mode the skill drives like claude: `spawn_workers alpha beta:deepseek` is a mixed fleet in one call, and `sendwait`/`last_text` need no variant. ⚠️ **`blocked` alone has a second source**: a CLI with no hook for it can declare the dialogs it blocks on (`capabilities.workDetect.blockedLine`, codex), and `blockedSignalAvailable()` (hooks OR that field) is the rule for that one signal; `stop` stays hooks-only, since nothing on a screen says a turn ended. A screen-read `blocked` is a LEVEL the server keeps reading (`Session.screenBlocked`), so the wait route answers it immediately (`currentSignalForWait()`) when the caller asked for `blocked`, and only then: such a pane is also `idle`, and `until=idle` must keep answering `idle`. See [Screen-read blocked dialogs](#screen-read-blocked-dialogs-codex). -**Packaging as the `skills/codeman` agent skill.** The primitives are packaged as the `skills/codeman` agent skill, installable three ways: via `codeman skill install [--case ]` / `skill uninstall`; as a Claude Code plugin from the repo's own marketplace (`/plugin marketplace add Ark0N/Codeman` + `/plugin install codeman@codeman`, see the root-files note in CLAUDE.md); or auto-injected into a case on session create behind `agentSkillEnabled` (SYNCED, default ON since 1.50.0, default OFF before). ⚠️ An ABSENT key means on in BOTH readers, the server's `getAgentSkillEnabled()` and the App Settings switch (both `!== false`): a page that showed an absent key as off would write `false` on the next Save of any other setting. Only an explicit false is off. Existing installs that stored off are switched on once at the first 1.50.0 start; turning it off afterwards sticks. Every App Settings Save up to 1.49.0 wrote `false` (the switch loaded an absent key as off and Save sends the whole form), so without that the default would never reach anyone who had ever saved. ⚠️ The migration (`migrateAgentSkillDefault()`, `web/agent-skill-default-migration.ts`) runs from `WebServer.start()` before the first settings read (and the 2 s `readSettings()` cache, which it drops after a write) and before listen, never in test mode, and writes only through `mutateSettingsFile()`; a `settings.json` that does not parse is left alone with one log line and no marker, so the next start after the fix tries again. It cannot tell a deliberate off from the old default (accepted, a "Behaviour change" in the release notes). ⚠️ It stores `true` rather than deleting the key: `loadAppSettingsFromServer()` overwrites only the synced keys the server HAS, so every device that saved on 1.49 would keep its own `false`, show the switch off and write `false` back on its next Save (`true` also wins in a browser still running a cached settings-ui.js); for a server with no key the page drops the device's copy too. ⚠️ Its "done" marker is its own file, `agent-skill-default-migrated.json` in the data dir via `dataPath()`, checked before anything is read and written only after the settings write landed (a crash between the two is harmless: the next start finds `true`). Never a settings key: `GET /api/settings` never returns it, so no Save can send it back (the `.strict()` `SettingsUpdateSchema` would 400) or drop it (which would re-arm the migration and switch a later off back on), and it is outside `CONFIG_BACKUP_FILES`, so a restored settings.json, even a pre-1.50 one holding `false`, stays as restored. One data dir is one server, so in multi-user mode it runs once for the server. A device whose pages were all loaded before the upgrade still holds the old `false` in its localStorage blob until a page load refreshes it, and an App Settings Save from such a page writes that back (accepted; the release note says to reload). Tests: `test/agent-skill-default-migration.test.ts`, `test/agent-skill-setting-ui.test.ts`. Turning it off sweeps nothing. ⚠️ Every managed copy ignores itself in git, which is what makes the default safe in a user's repository: `installAgentSkillInto()` writes `/.gitignore` holding `*` FIRST (so a `git status` mid-install never lists it), with `wx` (a file already there is the user's and is never overwritten; a symlink at the name is refused), and outside the content list `readAgentSkillSource()` compares, so it never appears in `skills/codeman/` or the plugin mirror. A copy injected before 1.50.0 gains it on its next install (`refreshed`). `removeAgentSkillFrom()` deletes it only when it is the last entry left and holds exactly `*`, so a user's extra files there stay ignored. The repository's own `.gitignore` and `info/exclude` are never touched for it (the managed-worktree exclude in git-worktrees.ts is a separate, older line and still added). Measured against Claude Code 2.1.319: its skill discovery runs `git check-ignore` only on directories at or above `.claude/skills` (the nested-skills walk), never on a skill folder or its SKILL.md, and a `*` inside `codeman/` ignores the folder's contents, not the folder, so the skill still loads; codex 0.162 has no gitignore check on skills. Checked live on 2026-11-08 with the ignore file in place: Claude Code 2.1.319 (`--debug` reports `project: 1`), Codex 0.162 (app-server `skills/list`), OpenCode 1.3.0 (`opencode debug skill`) and Grok 1.0.52 (`grok --trust inspect`) all list `codeman`; agy, dsh, gemini and omp were not measured. ⚠️ Install and uninstall refuse (`symlink`) a link at the copy, at its `skills/` parent, and at a folder INSIDE the copy (`reference/`, `agentSkillSubdirIsSymlink()`): a cloned repository can commit one and a Docker case's container can plant one in the bind-mounted workspace, and a write or unlink of `reference/.md` through it lands wherever it points (measured: install overwrote a file there, uninstall deleted it). The check runs once before the first write; a link swapped in after it is not covered. ⚠️ WHERE it lands is registry data, `capabilities.agentSkillDir` (absent with `agentSkillInjection: true` = `.claude/skills`): `.claude/skills/codeman` for claude, `.agents/skills/codeman` for every other CLI that takes it (pi does not: a project skill triggers its trust prompt). `agentSkillDir` is a CLOSED enum (`AGENT_SKILL_DIRS`, schema.ts) because `clis.json` can set it and it decides where the server writes. The per-session preamble is seeded for every local session whose CLI takes the skill, and `codeman skill uninstall --case` removes the copy from every distinct registry directory (`registryAgentSkillDirs()`, cli.ts). ⚠️ Plugin skills are namespaced, so a Claude Code holding BOTH the plugin and a user-level or per-case copy lists the skill twice, `codeman` and `codeman:codeman` (measured 2026-09-14: neither shadows the other, both work); the docs tell users to pick one route, and since the per-case injection is on by default, a plugin user turns that setting off. +**Packaging as the `skills/codeman` agent skill.** The primitives are packaged as the `skills/codeman` agent skill, installable three ways: via `codeman skill install [--case ]` / `skill uninstall`; as a Claude Code plugin from the repo's own marketplace (`/plugin marketplace add Ark0N/Codeman` + `/plugin install codeman@codeman`, see the root-files note in CLAUDE.md); or auto-injected into a case on session create behind `agentSkillEnabled` (SYNCED, default ON since 1.50.0, default OFF before). ⚠️ An ABSENT key means on in BOTH readers, the server's `getAgentSkillEnabled()` and the App Settings switch (both `!== false`): a page that showed an absent key as off would write `false` on the next Save of any other setting. Only an explicit false is off. Existing installs that stored off are switched on once at the first 1.50.0 start; turning it off afterwards sticks. Every App Settings Save up to 1.49.0 wrote `false` (the switch loaded an absent key as off and Save sends the whole form), so without that the default would never reach anyone who had ever saved. ⚠️ The migration (`migrateAgentSkillDefault()`, `web/agent-skill-default-migration.ts`) runs from `WebServer.start()` before the first settings read (and the 2 s `readSettings()` cache, which it drops after a write) and before listen, never in test mode, and writes only through `mutateSettingsFile()`; a `settings.json` that does not parse is left alone with one log line and no marker, so the next start after the fix tries again (and switches on an off saved between the fix and that start, since the migration has not run yet). It cannot tell a deliberate off from the old default (accepted, a "Behaviour change" in the release notes). ⚠️ It stores `true` rather than deleting the key: `loadAppSettingsFromServer()` overwrites only the synced keys the server HAS, so every device that saved on 1.49 would keep its own `false`, show the switch off and write `false` back on its next Save (`true` also wins in a browser still running a cached settings-ui.js); for a server with no key the page drops the device's copy too. ⚠️ Its "done" marker is its own file, `agent-skill-default-migrated.json` in the data dir via `dataPath()`, checked before anything is read and written only after the settings write landed (a crash between the two is harmless: the next start finds `true`). Never a settings key: `GET /api/settings` never returns it, so no Save can send it back (the `.strict()` `SettingsUpdateSchema` would 400) or drop it (which would re-arm the migration and switch a later off back on), and it is outside `CONFIG_BACKUP_FILES`, so once the migration has run, a restored settings.json, even a pre-1.50 one holding `false`, stays as restored. One data dir is one server, so in multi-user mode it runs once for the server. A device whose pages were all loaded before the upgrade still holds the old `false` in its localStorage blob until a page load refreshes it, and an App Settings Save from such a page writes that back (accepted; the release note says to reload). Tests: `test/agent-skill-default-migration.test.ts`, `test/agent-skill-setting-ui.test.ts`. Turning it off sweeps nothing. ⚠️ Every managed copy ignores itself in git, which is what makes the default safe in a user's repository: `installAgentSkillInto()` writes `/.gitignore` holding `*` FIRST (so a `git status` mid-install never lists it), with `wx` (a file already there is the user's and is never overwritten; a symlink at the name is refused), and outside the content list `readAgentSkillSource()` compares, so it never appears in `skills/codeman/` or the plugin mirror. A copy injected before 1.50.0 gains it on its next install (`refreshed`). `removeAgentSkillFrom()` deletes it only when it is the last entry left and holds exactly `*`, so a user's extra files there stay ignored. The repository's own `.gitignore` and `info/exclude` are never touched for it (the managed-worktree exclude in git-worktrees.ts is a separate, older line and still added). Measured against Claude Code 2.1.319: its skill discovery runs `git check-ignore` only on directories at or above `.claude/skills` (the nested-skills walk), never on a skill folder or its SKILL.md, and a `*` inside `codeman/` ignores the folder's contents, not the folder, so the skill still loads; codex 0.162 has no gitignore check on skills. Checked live on 2026-11-08 with the ignore file in place: Claude Code 2.1.319 (`--debug` reports `project: 1`), Codex 0.162 (app-server `skills/list`), OpenCode 1.3.0 (`opencode debug skill`) and Grok 1.0.52 (`grok --trust inspect`) all list `codeman`; agy, dsh, gemini and omp were not measured. ⚠️ Install and uninstall refuse (`symlink`) a link at the copy, at its `skills/` parent, and at a folder INSIDE the copy (`reference/`, `agentSkillSubdirIsSymlink()`): a cloned repository can commit one and a Docker case's container can plant one in the bind-mounted workspace, and a write or unlink of `reference/.md` through it lands wherever it points (measured: install overwrote a file there, uninstall deleted it). The check runs once before the first write; a link swapped in after it is not covered. ⚠️ WHERE it lands is registry data, `capabilities.agentSkillDir` (absent with `agentSkillInjection: true` = `.claude/skills`): `.claude/skills/codeman` for claude, `.agents/skills/codeman` for every other CLI that takes it (pi does not: a project skill triggers its trust prompt). `agentSkillDir` is a CLOSED enum (`AGENT_SKILL_DIRS`, schema.ts) because `clis.json` can set it and it decides where the server writes. The per-session preamble is seeded for every local session whose CLI takes the skill, and `codeman skill uninstall --case` removes the copy from every distinct registry directory (`registryAgentSkillDirs()`, cli.ts). ⚠️ Plugin skills are namespaced, so a Claude Code holding BOTH the plugin and a user-level or per-case copy lists the skill twice, `codeman` and `codeman:codeman` (measured 2026-09-14: neither shadows the other, both work); the docs tell users to pick one route, and since the per-case injection is on by default, a plugin user turns that setting off. Injection is ADD-ONLY at create, marker-owned (`applyAgentSkill` in `hooks-config.ts` never touches an unmarked user copy) and refuses symlinks (this repo's own `.claude/skills/codeman` is a symlink to the source, which the injector must never write through). ⚠️ Claude Code loads a same-named USER-LEVEL skill (`~/.claude/skills/codeman`, written once by `codeman skill install` with no `--case`) over the per-case copy, and nothing used to refresh it: a stale Aug-9 user copy shadowed every fresh injection (2026-08-14: agents ran the old recipes, spawned workers serially and lost their lineage arcs), so session create now also refreshes a marker-owned user copy (`refreshUserAgentSkill`; refresh-only, never installs, foreign/symlink refused). diff --git a/src/web/agent-skill-default-migration.ts b/src/web/agent-skill-default-migration.ts index 3694bb92..2a1f2e66 100644 --- a/src/web/agent-skill-default-migration.ts +++ b/src/web/agent-skill-default-migration.ts @@ -26,17 +26,20 @@ * `SettingsUpdateSchema` never sees it; * - a PUT or any other settings.json writer cannot erase it, which would re-arm the migration and * switch a deliberate later off back on; - * - it is not in `CONFIG_BACKUP_FILES` (like `config-backup-id`), so a restore leaves it alone: a - * restored settings.json, even one from before 1.50.0 holding `false`, is what the user asked - * for and stays as restored. + * - it is not in `CONFIG_BACKUP_FILES` (like `config-backup-id`), so a restore leaves it alone: + * once the migration has run, a restored settings.json, even one from before 1.50.0 holding + * `false`, is what the user asked for and stays as restored. * A data dir is one instance, and settings.json is one file for the whole server, so in * multi-user mode this runs once for the server, never per user. * * Order: the marker is checked first (present: nothing is read), the settings change goes through * `mutateSettingsFile()` (serialized, atomic, refuses a file that does not parse), and the marker * is written only after that resolves. A refused or failed settings write writes no marker, so the - * migration runs again at the next start (after the user fixed the file). A crash between the two - * writes is harmless: the next start finds `true` (nothing to change) and writes the marker. + * migration runs again at the next start (after the user fixed the file). Until then it has not + * run, so an off saved after the fix and before that start is switched on with the rest: the same + * "cannot tell" trade as above, in a window that needs an unparseable settings.json at the first + * 1.50.0 start. A crash between the two writes is harmless: the next start finds `true` (nothing + * to change) and writes the marker. * * Called once from `WebServer.start()`, before the first settings read and before the server * listens, so no request path can see the old value. From 235db69c1e8abcb6d4cf87f63e42b155f4f19e6c Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 8 Nov 2026 10:11:36 +0100 Subject: [PATCH 14/14] fix(agent-skill): the migration's log line says it switched the setting on It stores true rather than clearing the key, so the line now says what happened. Co-Authored-By: Claude Opus 5.5 --- src/web/agent-skill-default-migration.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/web/agent-skill-default-migration.ts b/src/web/agent-skill-default-migration.ts index 2a1f2e66..d176afaf 100644 --- a/src/web/agent-skill-default-migration.ts +++ b/src/web/agent-skill-default-migration.ts @@ -65,7 +65,7 @@ export type AgentSkillDefaultMigration = 'switched-on' | 'nothing-to-change' | ' /** The log line of a migration that switched the skill on. */ export const AGENT_SKILL_DEFAULT_MIGRATED_LOG = - '[agent-skill] cleared the stored off from before 1.50.0 so the new default applies (turn it off again under App Settings, Agents & CLIs, Codeman Agent Skill; that choice is kept)'; + '[agent-skill] switched on the off stored before 1.50.0, so the new default applies (turn it off again under App Settings, Agents & CLIs, Codeman Agent Skill; that choice is kept)'; export async function migrateAgentSkillDefault( markerPath: string = dataPath(AGENT_SKILL_DEFAULT_MARKER)