fix: install Codeman hooks into every claude workspace, not just cases Codeman created

A session in a linked case (or any pre-existing repo) ran with no hooks block
at all: writeHooksConfig only fires when Codeman CREATES the case directory,
and refreshStaleCodemanHooks deliberately never adds one. Every hook-driven
surface was therefore dead in exactly the place most sessions run: no tab
alert or phone-overview NEEDS YOU 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 agent wait endpoints.

Both session-create paths and restoreMuxSessions() now call
ensureCodemanHooks(), an add-only merge that keeps a user's own handlers and
leaves a malformed settings file untouched. Claude Code re-reads
settings.local.json, so a session already running in the workspace starts
firing hooks without a restart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-08-16 04:46:01 +02:00
parent 869a507482
commit 98fa8c00d1
5 changed files with 198 additions and 25 deletions
+32 -10
View File
@@ -84,6 +84,7 @@ import {
applyAgentSkill,
refreshUserAgentSkill,
seedAgentSessionPreamble,
ensureCodemanHooks,
refreshStaleCodemanHooks,
} from '../../hooks-config.js';
import { generateClaudeMd } from '../../templates/claude-md.js';
@@ -765,11 +766,21 @@ export function registerSessionRoutes(
await applyStatusLineConfig(workingDir, true);
}
// COD-91 self-heal: refresh a pre-secret hooks block in an existing case so the now
// unconditional hook-secret gate keeps accepting its hook events. No-op for fresh
// cases (writeHooksConfig already wrote the secret) and for non-Codeman/absent hooks.
// 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).
if ((body.mode ?? 'claude') === 'claude') {
await refreshStaleCodemanHooks(workingDir).catch(() => {});
await ensureCodemanHooks(workingDir).catch(() => {});
// 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
@@ -2899,11 +2910,19 @@ export function registerSessionRoutes(
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to create case: ${getErrorMessage(err)}`);
}
} else if (!remote && !docker && mode !== 'opencode') {
// COD-91 self-heal for an EXISTING case: refresh a pre-secret hooks block so the
// now-unconditional hook-secret gate keeps accepting its hook events. No-op when
// the hooks aren't ours or already carry the secret. Skipped for remote cases —
// resolvedCasePath is a REMOTE path that doesn't exist on the local filesystem.
await refreshStaleCodemanHooks(resolvedCasePath).catch(() => {});
// 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.
if (mode === 'claude') {
await ensureCodemanHooks(resolvedCasePath).catch(() => {});
} else {
await refreshStaleCodemanHooks(resolvedCasePath).catch(() => {});
}
}
// Agent skill injection (docs/agent-control-plan.md §2): ADD-ONLY on create,
@@ -2936,7 +2955,10 @@ export function registerSessionRoutes(
if (!existsSync(join(resolvedCasePath, '.claude', 'settings.local.json'))) {
await writeHooksConfig(resolvedCasePath);
} else {
await refreshStaleCodemanHooks(resolvedCasePath).catch(() => {});
// 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(() => {});
}
} catch {
/* non-fatal — the session still runs, hooks may be degraded */
+34
View File
@@ -76,6 +76,7 @@ import { RunSummaryTracker } from '../run-summary.js';
import { PlanOrchestrator } from '../plan-orchestrator.js';
import { OrchestratorLoop } from '../orchestrator-loop.js';
import { getLifecycleLog } from '../session-lifecycle-log.js';
import { ensureCodemanHooks } from '../hooks-config.js';
import { PushSubscriptionStore } from '../push-store.js';
import webpush from 'web-push';
import { SseStreamManager } from './sse-stream-manager.js';
@@ -2819,6 +2820,13 @@ export class WebServer extends EventEmitter {
}
}
// Sessions recovered from a previous run predate the create-path hook
// install, and these are long-lived: by the time a server restart comes
// round a session may be days old and has been running hook-blind the
// whole time. Claude Code re-reads settings.local.json, so writing the
// block now arms the RUNNING CLI, no session restart needed.
await this.ensureHooksForRecoveredWorkspaces();
// Start stats collection for mux sessions
this.mux.startStatsCollection(STATS_COLLECTION_INTERVAL_MS);
}
@@ -2845,6 +2853,32 @@ export class WebServer extends EventEmitter {
}
}
/**
* Install Codeman's hooks into the workspaces of the sessions just recovered.
*
* Deduped by workspace, because sessions in one repo share a single
* `.claude/settings.local.json` and the write is otherwise repeated per tab.
* Claude mode only (nothing else reads `.claude` hooks), never for remote
* sessions (their `workingDir` is a path on ANOTHER host, so writing it here
* would scaffold a stray directory locally), and never for a docker case that
* opted out of hooks.
*
* 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.
*/
private async ensureHooksForRecoveredWorkspaces(): Promise<void> {
const workspaces = new Set<string>();
for (const session of this.sessions.values()) {
if (session.mode !== 'claude' || session.remote) continue;
if (session.docker && !session.docker.hooksEnabled) continue;
if (session.workingDir) workspaces.add(session.workingDir);
}
for (const workspace of workspaces) {
await ensureCodemanHooks(workspace).catch(() => {});
}
}
/**
* COD-108 — handle a `remoteSessionDropped` emit from the watcher: reattach
* the dropped remote session and report the outcome back to the watcher so it