feat: workspaceHooksEnabled setting as the opt-out for workspace hook installs

Installing hooks into any workspace a Claude session runs in is the right
default, but it takes a decision away from a user who deliberately removed
them: nothing on disk distinguishes "removed on purpose" from "never had any",
so they would come back on the next session create.

Adds the synced workspaceHooksEnabled setting (App Settings -> Agents & CLIs ->
Claude), default ON. OFF restores the older behavior exactly: a Codeman hooks
block that is already present is still refreshed when stale (COD-91), but one
is never added.

Every create path routes through one applyWorkspaceHooks() helper so the gate
cannot apply to some paths only, and the boot-time recovery sweep honours it too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-08-16 07:05:10 +02:00
parent 98fa8c00d1
commit f485085174
9 changed files with 149 additions and 26 deletions
+1 -1
View File
@@ -208,7 +208,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Unified session list**: `GET /api/sessions/unified` merges live sessions, persisted state, lifecycle-log history, and Claude transcript files into one deduped list (pure core in `src/services/unified-session-service.ts`). Transcript rows fold into their owning session via a `claudeSessionId → Codeman id` alias map, so resumed and `/clear`-respawned sessions do not appear twice. No terminal buffers in the response, unlike `/api/sessions`. Backs the Cmd+K Session Manager, plus pinning and cross-device tab order (`PUT /api/session-order`; pure merge helpers in `src/session-order.ts`, pushing device wins and server-only ids are never dropped). → [architecture-invariants#unified-session-list-and-session-manager](docs/architecture-invariants.md#unified-session-list-and-session-manager)
**Hook events**: Claude Code hooks trigger via `/api/hook-event`. Key events: `permission_prompt`, `elicitation_dialog`, `elicitation_complete`, `elicitation_response`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`. See `src/hooks-config.ts`; upstream hook semantics mirrored in `docs/claude-code-hooks-reference.md`. ⚠️ **Every claude session INSTALLS the hooks block into its workspace** (`ensureCodemanHooks`, add-only merge that keeps a user's own handlers), from both create paths and from `restoreMuxSessions()` for sessions recovered on server start. Before 2026-08-15 hooks were written ONLY when Codeman created the case DIRECTORY, so a linked case / cloned repo — where most sessions actually run — had no hooks at all and every hook-driven surface was silently dead there: an AskUserQuestion dialog blocked the pane while the tab and the phone overview both read a calm `idle`, with no Approvals Inbox item, no push, no definitive `stop`/`idle_prompt` for respawn and no `stop`/`blocked` for the wait endpoints. Do not restore the old "never add" policy. ⚠️ Claude Code RE-READS `settings.local.json`, so an already-running session starts firing hooks without a restart (measured 2026-08-15) — and the notification for a blocking dialog is delayed by Claude Code (~30s), so the alert trails the dialog. ⚠️ An AskUserQuestion / plan-selection dialog arrives as **`permission_prompt`**, not `elicitation_dialog` (that one is MCP elicitation), so it renders as the RED "needs you" alert, not the yellow idle one.
**Hook events**: Claude Code hooks trigger via `/api/hook-event`. Key events: `permission_prompt`, `elicitation_dialog`, `elicitation_complete`, `elicitation_response`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`. See `src/hooks-config.ts`; upstream hook semantics mirrored in `docs/claude-code-hooks-reference.md`. ⚠️ **Every claude session INSTALLS the hooks block into its workspace** (`applyWorkspaceHooks` in session-routes.ts → `ensureCodemanHooks`, an add-only merge that keeps a user's own handlers), from both create paths and from `restoreMuxSessions()` for sessions recovered on server start. Before 2026-08-15 hooks were written ONLY when Codeman created the case DIRECTORY, so a linked case / cloned repo — where most sessions actually run — had no hooks at all and every hook-driven surface was silently dead there: an AskUserQuestion dialog blocked the pane while the tab and the phone overview both read a calm `idle`, with no Approvals Inbox item, no push, no definitive `stop`/`idle_prompt` for respawn and no `stop`/`blocked` for the wait endpoints. The escape hatch is the synced `workspaceHooksEnabled` setting (App Settings → Agents & CLIs → Claude, **default ON**); OFF restores the old behavior, where a Codeman block that is already there is still refreshed when stale (COD-91) but one is never added. ⚠️ Route the decision through `applyWorkspaceHooks` rather than calling `ensureCodemanHooks` at a new site, or the setting silently stops applying to that path. ⚠️ Claude Code RE-READS `settings.local.json`, so an already-running session starts firing hooks without a restart (measured 2026-08-15) — and the notification for a blocking dialog is delayed by Claude Code (~30s), so the alert trails the dialog. ⚠️ An AskUserQuestion / plan-selection dialog arrives as **`permission_prompt`**, not `elicitation_dialog` (that one is MCP elicitation), so it renders as the RED "needs you" alert, not the yellow idle one.
**Approvals Inbox** (cross-session queue of prompts waiting on a human; `approvalsInboxEnabled`, SYNCED, default OFF: every surface is opt-in; only the store and answer endpoints run regardless, so flipping it ON shows anything already pending): `web/approval-inbox.ts` is a `sessionWaits`-style singleton fed by `/api/hook-event`, holding at most ONE item per session (a new prompt supersedes), claude-mode only, in-memory. Cards are answered via `POST /api/approvals/:id/answer`, which sends a digit / Esc / idle-prompt text through `writeViaMux` (menu answers never carry `\r`). ⚠️ `option` digits are accepted ONLY when they match options parsed from the captured pane frame, and the answer path RE-CAPTURES the pane first (a dialog that no longer parses on screen means the keystroke would land in the composer, so refuse with 409). ⚠️ Resolution on the heuristic `working` signal is restricted to `idle` items; permission/question items clear only on definitive signals (`stop`, `elicitation_complete`/`elicitation_response`, exit/delete, answer, supersede, 12h TTL). The frontend seeds from `GET /api/approvals` in `handleInit` **regardless of the setting**: the seed re-arms the tab-alert state machine (`setPendingHook`) unconditionally, and only populating `this.approvals` (the inbox surfaces) is gated — seeding used to be gated wholesale, which left a reloaded page with NO red tab while a permission dialog sat blocking a session (2026-08-15); `_onApprovalResolved` clears the pending-hook alert unconditionally for the same reason. ⚠️ The red/yellow tab alert itself is a STEADY border/background/dot with a pulse on top: the original keyframes swung to transparent at 0%/100%, so half of every cycle looked like a normal tab. Push Approve/Deny buttons stay gated on the setting (`sendPushNotifications` strips `actions`/`approvalId` when OFF) and are answered from `sw.js` directly so they work with no tab open. Surfaces (all gated on the setting): header bell (marker-hidden until count > 0, phones never show it) + drawer (`approvals-ui.js`), phone overview NEEDS YOU answer strips (`mobile-overview.js`). Design: `docs/approvals-inbox-plan.md`.
+2
View File
@@ -19,6 +19,8 @@ export interface ConfigPort {
getTerminalHistoryConfig(): Promise<TerminalHistoryConfig>;
/** Synced `agentSkillEnabled` app setting (default OFF); gates per-case agent-skill injection. */
getAgentSkillEnabled(): Promise<boolean>;
/** Synced `workspaceHooksEnabled` app setting (default ON); gates INSTALLING hooks into a session's workspace. */
getWorkspaceHooksEnabled(): Promise<boolean>;
/** Synced `claudeVoiceEnabled` app setting (default OFF); gates the Claude voice dictation relay. */
getClaudeVoiceEnabled(): Promise<boolean>;
getDefaultClaudeMdPath(): Promise<string | undefined>;
+7
View File
@@ -1984,6 +1984,13 @@
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsAgentSkill"><span class="slider"></span></label>
</div>
<div class="set-row" data-search="workspace hooks alerts approvals notifications settings.local.json">
<div class="set-row-text">
<span class="set-row-label">Workspace Hooks</span>
<span class="set-row-desc">Install Codeman's hooks in each Claude workspace, so tab alerts, the Approvals Inbox and idle detection also work in linked cases and existing repos. Off leaves your repos untouched.</span>
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsWorkspaceHooks"><span class="slider"></span></label>
</div>
<div class="set-row" data-search="remote auto reconnect ssh">
<div class="set-row-text">
<span class="set-row-label">Remote auto-reconnect</span>
+4
View File
@@ -409,6 +409,9 @@ Object.assign(CodemanApp.prototype, {
// Claude Permissions settings
document.getElementById('appSettingsAgentTeams').checked = settings.agentTeamsEnabled ?? 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;
document.getElementById('appSettingsClaudeModel').value = settings.claudeModel ?? '';
document.getElementById('appSettingsOpusContext1m').checked = settings.opusContext1mEnabled ?? false;
document.getElementById('appSettingsRemoteAutoReconnect').checked = settings.remoteAutoReconnect ?? true;
@@ -2017,6 +2020,7 @@ Object.assign(CodemanApp.prototype, {
// Claude Permissions settings
agentTeamsEnabled: document.getElementById('appSettingsAgentTeams').checked,
agentSkillEnabled: document.getElementById('appSettingsAgentSkill').checked,
workspaceHooksEnabled: document.getElementById('appSettingsWorkspaceHooks').checked,
claudeVoiceEnabled: document.getElementById('appSettingsClaudeVoice').checked,
claudeModel: document.getElementById('appSettingsClaudeModel').value,
opusContext1mEnabled: document.getElementById('appSettingsOpusContext1m').checked,
+38 -25
View File
@@ -626,6 +626,32 @@ async function injectAgentSkill(casePath: string): Promise<void> {
}
}
/**
* Hooks for the workspace a Claude session is about to run in. ONE decision point,
* shared by every create path, so the setting cannot apply to some of them only.
*
* ON (`workspaceHooksEnabled`, the default): INSTALL Codeman's hooks block, merging
* so a user's own hook entries and every other settings key survive. Hooks were
* previously written only when Codeman CREATED the case DIRECTORY, so a linked case
* or any pre-existing repo — where most sessions actually run — had none, and every
* hook-driven surface was silently dead there: no tab alert or phone-overview row
* when a dialog blocks the pane, no Approvals Inbox item, no push, no definitive
* `stop`/`idle_prompt` for respawn, and no `stop`/`blocked` for the wait endpoints.
* Measured 2026-08-15 in a linked case: an AskUserQuestion dialog on screen with the
* tab reporting a calm `idle`. Claude Code re-reads the file, so a session already
* running in that workspace starts firing hooks without a restart (verified live).
*
* OFF: the older, narrower behavior. A Codeman block that is already there is still
* refreshed when stale (COD-91: a pre-secret block 401s once the hook-secret gate
* went unconditional), but one is never added, so Codeman leaves the repo alone.
*
* Best-effort either way: a refusal or a thrown error must never fail the create.
*/
async function applyWorkspaceHooks(ctx: ConfigPort, workspace: string): Promise<void> {
const install = await ctx.getWorkspaceHooksEnabled();
await (install ? ensureCodemanHooks(workspace) : refreshStaleCodemanHooks(workspace)).catch(() => {});
}
export function registerSessionRoutes(
app: FastifyInstance,
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort
@@ -766,21 +792,10 @@ export function registerSessionRoutes(
await applyStatusLineConfig(workingDir, true);
}
// Hooks for the workspace this session runs in. ADD-ONLY and merge-based:
// Codeman's own handlers are (re)written, a user's own hook entries are kept.
//
// This used to be `refreshStaleCodemanHooks`, which deliberately never ADDS —
// and `writeHooksConfig` only runs when Codeman CREATES a case directory. So a
// session in a LINKED case or any pre-existing repo (where most sessions live)
// got no hooks block at all, and every hook-driven surface was silently dead
// there: no permission/question tab alert, no Approvals Inbox item, no push,
// no definitive `stop`/`idle_prompt` for respawn, and no `stop`/`blocked` for
// the agent wait endpoints. Measured 2026-08-15 on a linked case: an
// AskUserQuestion dialog sat on screen with the tab showing plain `idle`.
// Claude Code re-reads the file, so a session already running in that
// workspace starts firing hooks too (verified live, same day).
// Hooks for the workspace this session runs in (install vs refresh-only is the
// `workspaceHooksEnabled` setting; see applyWorkspaceHooks).
if ((body.mode ?? 'claude') === 'claude') {
await ensureCodemanHooks(workingDir).catch(() => {});
await applyWorkspaceHooks(ctx, workingDir);
// Agent skill (docs/agent-control-plan.md §2): ADD-ONLY on create, same shared-
// .claude rationale as the statusLine above: a create must never remove the
// skill from under other live sessions in the repo. Marker-guarded, so a
@@ -2911,15 +2926,13 @@ export function registerSessionRoutes(
}
} else if (!remote && !docker && mode !== 'opencode') {
// EXISTING case directory (a linked case, a cloned repo, anything Codeman did
// not scaffold). Claude mode INSTALLS the hooks block when it is missing and
// refreshes ours when it is stale — a linked case never got one otherwise, which
// left every hook-driven surface dead there (see POST /api/sessions above).
// Other modes keep the narrower COD-91 self-heal: only claude reads `.claude`
// hooks, so a shell/codex quick-start should not author a block of its own.
// Skipped for remote cases — resolvedCasePath is a REMOTE path that doesn't
// exist on the local filesystem.
// not scaffold): install-or-refresh per the setting (see applyWorkspaceHooks).
// Other modes keep the narrower COD-91 self-heal unconditionally: only claude
// reads `.claude` hooks, so a shell/codex quick-start should not author a block
// of its own. Skipped for remote cases — resolvedCasePath is a REMOTE path that
// doesn't exist on the local filesystem.
if (mode === 'claude') {
await ensureCodemanHooks(resolvedCasePath).catch(() => {});
await applyWorkspaceHooks(ctx, resolvedCasePath);
} else {
await refreshStaleCodemanHooks(resolvedCasePath).catch(() => {});
}
@@ -2956,9 +2969,9 @@ export function registerSessionRoutes(
await writeHooksConfig(resolvedCasePath);
} else {
// A settings file with no hooks in it is the same dead-surface case as a
// linked case: this branch is already gated on `docker.hooksEnabled`, so
// install ours rather than only refreshing an existing block.
await ensureCodemanHooks(resolvedCasePath).catch(() => {});
// linked case. This branch is already gated on `docker.hooksEnabled`, and
// applyWorkspaceHooks adds the user-level gate on top.
await applyWorkspaceHooks(ctx, resolvedCasePath);
}
} catch {
/* non-fatal — the session still runs, hooks may be degraded */
+11
View File
@@ -906,6 +906,17 @@ export const SettingsUpdateSchema = z
* add-only at create; a marker keeps user-authored copies untouched.
*/
agentSkillEnabled: z.boolean().optional(),
/**
* Install Codeman's hooks block into the workspace of every Claude session,
* not only into cases Codeman scaffolded itself. SYNCED, default ON: without
* it a linked case or an existing repo runs with no hooks at all, and each
* hook-driven surface is silently dead there (tab alert, Approvals Inbox,
* push, respawn's definitive idle signals, the wait endpoints' stop/blocked).
* Turning it OFF restores the older, narrower behavior — a Codeman hooks
* block that is already present is still refreshed when stale, but one is
* never added — for a user who wants Codeman to leave their repos alone.
*/
workspaceHooksEnabled: z.boolean().optional(),
/**
* Let browser dictation transcribe through this machine's Claude Code login,
* the same speech-to-text service the CLI's own `/voice` mode uses
+16
View File
@@ -637,6 +637,7 @@ export class WebServer extends EventEmitter {
getClaudeModeConfig: this.getClaudeModeConfig.bind(this),
getTerminalHistoryConfig: this.getTerminalHistoryConfig.bind(this),
getAgentSkillEnabled: this.getAgentSkillEnabled.bind(this),
getWorkspaceHooksEnabled: this.getWorkspaceHooksEnabled.bind(this),
getClaudeVoiceEnabled: this.getClaudeVoiceEnabled.bind(this),
getDefaultClaudeMdPath: this.getDefaultClaudeMdPath.bind(this),
getLightState: this.getLightState.bind(this),
@@ -1711,6 +1712,16 @@ export class WebServer extends EventEmitter {
return settings.agentSkillEnabled === true;
}
// Whether a Claude session installs Codeman's hooks block into its workspace
// (synced `workspaceHooksEnabled` setting). Default ON — an absent key means a
// user who has never seen this setting, and OFF for them would mean no tab
// alerts, no Approvals Inbox and no respawn idle signals in every workspace
// Codeman did not scaffold itself.
private async getWorkspaceHooksEnabled(): Promise<boolean> {
const settings = await this.readSettings();
return settings.workspaceHooksEnabled !== false;
}
// Whether browser dictation may use this machine's Claude Code credentials
// (synced `claudeVoiceEnabled` setting, default OFF; docs/claude-voice-plan.md).
// OFF by default because turning it on spends the operator's Claude subscription
@@ -2866,8 +2877,13 @@ export class WebServer extends EventEmitter {
* Failures are swallowed per workspace: `ensureCodemanHooks` already refuses
* unsafe targets with a warning, and a workspace we cannot write to must not
* stop the rest of recovery.
*
* Skipped entirely when `workspaceHooksEnabled` is OFF: that setting exists so a
* user can keep Codeman out of their repos, and a boot-time sweep is the last
* place that should ignore it.
*/
private async ensureHooksForRecoveredWorkspaces(): Promise<void> {
if (!(await this.getWorkspaceHooksEnabled())) return;
const workspaces = new Set<string>();
for (const session of this.sessions.values()) {
if (session.mode !== 'claude' || session.remote) continue;
+4
View File
@@ -19,6 +19,7 @@ export function createMockRouteContext(options?: {
sessionId?: string;
agentSkillEnabled?: boolean;
claudeVoiceEnabled?: boolean;
workspaceHooksEnabled?: boolean;
}) {
const sessionId = options?.sessionId ?? 'test-session-1';
const session = createMockSession(sessionId);
@@ -96,6 +97,9 @@ export function createMockRouteContext(options?: {
getAgentSkillEnabled: vi.fn(async () => options?.agentSkillEnabled ?? false),
// 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.
// Writes land in the test's temp working dir, never in a real repo.
getWorkspaceHooksEnabled: vi.fn(async () => options?.workspaceHooksEnabled ?? true),
getDefaultClaudeMdPath: vi.fn(async () => undefined),
getLightState: vi.fn(() => ({ sessions: [], status: 'ok' })),
getLightSessionsState: vi.fn(() => {
@@ -22,6 +22,7 @@ import { tmpdir } from 'node:os';
import { createMockRouteContext } from '../mocks/index.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
import { generateHooksConfig } from '../../src/hooks-config.js';
interface HooksFile {
hooks?: Record<string, Array<{ matcher?: string; hooks?: Array<{ command?: string }> }>>;
@@ -29,6 +30,32 @@ interface HooksFile {
model?: unknown;
}
/**
* A faithful PRE-SECRET Codeman hooks block (what a case created before COD-54
* contains): it targets /api/hook-event, so it is recognisably ours, but carries
* no X-Codeman-Hook-Secret header and no -k. Used to prove the self-heal still
* runs with the setting OFF.
*/
function staleCodemanHooks() {
return {
Stop: [
{
matcher: '',
hooks: [
{
type: 'command',
command:
"HOOK_DATA=$(cat 2>/dev/null || echo '{}'); " +
'printf \'{"event":"stop","sessionId":"%s","data":%s}\' "$CODEMAN_SESSION_ID" "$HOOK_DATA" | ' +
'curl -s -X POST "$CODEMAN_API_URL/api/hook-event" -H \'Content-Type: application/json\' --data @- 2>/dev/null || true',
timeout: 5,
},
],
},
],
};
}
describe('POST /api/sessions workspace hooks', () => {
let app: FastifyInstance;
let workingDir: string;
@@ -39,6 +66,16 @@ describe('POST /api/sessions workspace hooks', () => {
const createSession = (payload: Record<string, unknown>) =>
app.inject({ method: 'POST', url: '/api/sessions', payload });
/** Rebuild the app with the `workspaceHooksEnabled` gate in a given position. */
const useApp = async (workspaceHooksEnabled: boolean) => {
await app?.close();
app = Fastify({ logger: false });
await app.register(fastifyCookie);
registerSessionRoutes(app, createMockRouteContext({ workspaceHooksEnabled }));
installRouteErrorHandler(app);
await app.ready();
};
beforeEach(async () => {
workingDir = await mkdtemp(join(tmpdir(), 'codeman-workspace-hooks-'));
app = Fastify({ logger: false });
@@ -108,4 +145,33 @@ describe('POST /api/sessions workspace hooks', () => {
expect((await createSession({ name: 'hooks-malformed', mode: 'claude', workingDir })).statusCode).toBe(200);
expect(await readFile(settingsPath(), 'utf-8')).toBe('{ not json');
});
it('adds nothing when workspaceHooksEnabled is OFF', async () => {
await useApp(false);
expect((await createSession({ name: 'hooks-off', mode: 'claude', workingDir })).statusCode).toBe(200);
expect(existsSync(settingsPath())).toBe(false);
});
it('still heals a stale Codeman block when workspaceHooksEnabled is OFF', async () => {
// The setting turns off ADDING hooks, not the COD-91 self-heal: a pre-secret
// block 401s against the now-unconditional hook-secret gate, so a workspace that
// already opted in must not be left with hooks that silently fail.
await useApp(false);
await mkdir(join(workingDir, '.claude'), { recursive: true });
await writeFile(settingsPath(), JSON.stringify({ model: 'opus', hooks: staleCodemanHooks() }));
expect((await createSession({ name: 'hooks-off-stale', mode: 'claude', workingDir })).statusCode).toBe(200);
const settings = await readSettings();
expect(settings.model).toBe('opus');
expect(JSON.stringify(settings.hooks)).toContain('X-Codeman-Hook-Secret');
});
it('writes the hooks the generator produces, so the two cannot drift', async () => {
expect((await createSession({ name: 'hooks-parity', mode: 'claude', workingDir })).statusCode).toBe(200);
const written = (await readSettings()).hooks ?? {};
expect(Object.keys(written).sort()).toEqual(Object.keys(generateHooksConfig().hooks).sort());
});
});