mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 22:19:42 +02:00
Merge pull request #304 from Ark0N/fix/workspace-hooks-install
fix: install Codeman hooks into every claude workspace, not just cases Codeman created
This commit is contained in:
+20
-14
@@ -646,22 +646,28 @@ export async function writeHooksConfig(casePath: string): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures an explicitly managed case has the current Codeman hooks.
|
||||
* Ensures a workspace Codeman is about to run Claude in has the current Codeman hooks.
|
||||
*
|
||||
* Unlike `refreshStaleCodemanHooks`, this may add Codeman handlers to a valid
|
||||
* user-owned settings file. It is therefore reserved for case quick-starts,
|
||||
* where the user has explicitly asked Codeman to manage that workspace. A
|
||||
* malformed existing file is left untouched rather than replaced.
|
||||
* Unlike `refreshStaleCodemanHooks`, this may ADD Codeman handlers to a settings
|
||||
* file that has none (a linked case, a cloned repo, any directory Codeman did not
|
||||
* scaffold). It merges rather than replaces, so a user's own hook entries survive,
|
||||
* and a malformed existing file is left untouched rather than replaced.
|
||||
*
|
||||
* ⚠️ It has NO production call site: PR #233 landed it with the hook scripts and never
|
||||
* wired it up, and knip can't flag it (`test/**` are entry points, so its tests count as
|
||||
* a use). Kept anyway, because it is redundant with neither sibling: `writeHooksConfig`
|
||||
* REPLACES a malformed settings file and rewrites unconditionally, and
|
||||
* `refreshStaleCodemanHooks` deliberately never adds hooks to a case that has none. The
|
||||
* one place it fits is quick-start's existing-case branch in session-routes.ts, and
|
||||
* moving that branch onto this function is a POLICY change (hooks would come back for a
|
||||
* user who deleted them from their case, and linked cases would start getting a hooks
|
||||
* block they have never had), so that call is left to the owner rather than made here.
|
||||
* ⚠️ That "may add" is a deliberate POLICY, adopted 2026-08-15 after the symptom it
|
||||
* causes was reported: hooks were only ever written when Codeman CREATED a case
|
||||
* directory, so every session in a linked case ran with no hooks at all and each
|
||||
* hook-driven surface was silently dead there — an AskUserQuestion dialog blocking
|
||||
* the pane while the tab and the phone overview both read a calm `idle`, no
|
||||
* Approvals Inbox item, no push, no definitive `stop`/`idle_prompt` for respawn, and
|
||||
* no `stop`/`blocked` for the agent wait endpoints. The cost of the policy is the
|
||||
* other direction: a user who DELETES Codeman's hooks from a workspace gets them
|
||||
* back on the next session create there, because nothing on disk distinguishes
|
||||
* "removed on purpose" from "never had any".
|
||||
*
|
||||
* Called from both session-create paths (`POST /api/sessions`, `POST /api/quick-start`)
|
||||
* for claude mode, and from `restoreMuxSessions()` so sessions that predate this heal
|
||||
* on the next server start. Claude Code re-reads the file, so a session ALREADY running
|
||||
* in the workspace picks the hooks up without a restart (verified live, 2026-08-15).
|
||||
*/
|
||||
export async function ensureCodemanHooks(casePath: string): Promise<void> {
|
||||
await withSafeSettingsWrite(casePath, 'hooks (ensure)', async (claudeDir, settingsPath) => {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -2061,6 +2061,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>
|
||||
|
||||
@@ -411,6 +411,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;
|
||||
@@ -2020,6 +2023,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,
|
||||
|
||||
@@ -84,6 +84,7 @@ import {
|
||||
applyAgentSkill,
|
||||
refreshUserAgentSkill,
|
||||
seedAgentSessionPreamble,
|
||||
ensureCodemanHooks,
|
||||
refreshStaleCodemanHooks,
|
||||
} from '../../hooks-config.js';
|
||||
import { generateClaudeMd } from '../../templates/claude-md.js';
|
||||
@@ -625,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
|
||||
@@ -765,11 +792,14 @@ 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.
|
||||
if ((body.mode ?? 'claude') === 'claude') {
|
||||
await refreshStaleCodemanHooks(workingDir).catch(() => {});
|
||||
// Hooks for the workspace this session runs in (install vs refresh-only is the
|
||||
// `workspaceHooksEnabled` setting; see applyWorkspaceHooks). Never for a remote
|
||||
// attach (workingDir is a user@host:session pseudo-path — mkdir would create it
|
||||
// as a junk local dir), and only when the caller named a workingDir: the
|
||||
// process-cwd fallback is $HOME under installer-created services, and hooks
|
||||
// materializing in ~/.claude/settings.local.json was never asked for.
|
||||
if (!remote && body.workingDir && (body.mode ?? 'claude') === 'claude') {
|
||||
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
|
||||
@@ -2899,11 +2929,17 @@ 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): 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 applyWorkspaceHooks(ctx, resolvedCasePath);
|
||||
} else {
|
||||
await refreshStaleCodemanHooks(resolvedCasePath).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// Agent skill injection (docs/agent-control-plan.md §2): ADD-ONLY on create,
|
||||
@@ -2936,7 +2972,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`, and
|
||||
// applyWorkspaceHooks adds the user-level gate on top.
|
||||
await applyWorkspaceHooks(ctx, resolvedCasePath);
|
||||
}
|
||||
} catch {
|
||||
/* non-fatal — the session still runs, hooks may be degraded */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
@@ -636,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),
|
||||
@@ -1710,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
|
||||
@@ -2819,6 +2831,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 +2864,37 @@ 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.
|
||||
*
|
||||
* 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;
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user