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
+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 */