Compare commits

..
Author SHA1 Message Date
Codeman maintainer cb9149879d restore the activity stamp across restarts: the quiet ordering no longer flattens on deploy
Root cause of the reviewer's mass-bump measurement (17 of 17 sessions with an
identical lastActivityAt): every restart restamps all sessions in the
constructor loop, and the boot auto-attach's repaint re-bumps the rest within
the same second. A 12-minute steady-state sample shows NO ambient mass bump,
so restarts are the whole story, and Codeman restarts on every deploy.

The stamp now has a display twin: recovery threads the previous run's
lastActivityAt from state.json into the wire-visible stamp (getter + toState),
and a 15s settle window keeps the attach repaint from overwriting it. Real
actions (input, task assignment, respawn) always write through. The private
stamp keeps its boot-anchored semantics untouched, because the idle
confirmation reads it as how long the pane has been quiet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 20:32:41 +02:00
Codeman maintainer cdbde9f36f home-order follow-ups: fresh stamps for the blocked group, sort/display agreement, live Alt+N projection
Three follow-ups from the 1.19.0 review of the activity-ordered home screens:

- Hook events now ride the same debounced session state broadcast the
  working/idle handlers use. The blocked group ranks on lastActivityAt, and
  without this a permission prompt raised after page load kept ranking by
  whatever stamp the browser loaded with.

- A working row with no submit stamp now shows the lastActivityAt fallback
  its sort anchor already uses: a row must never be ranked by a number it
  does not display.

- Alt+digit resolves through the live-session projection the render paints
  (sessionOrder minus dead ids), so a stale id cannot shift every painted
  number off its target, web tabs included. New tests pin both surfaces to
  one shared order and the numbering to the live projection.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 20:21:54 +02:00
14 changed files with 255 additions and 315 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** (`applyWorkspaceHooks` in hooks-config.ts → `ensureCodemanHooks`, an add-only merge that keeps a user's own handlers), from EVERY claude create path — both interactive routes, cron fires, legacy scheduled runs, the plan-orchestrator one-shots — and from `restoreMuxSessions()` for sessions recovered on server start (that boot sweep skips a workspace that no longer exists, so a deleted repo with a surviving tmux session is never resurrected as an empty dir). 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.
**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`.
-10
View File
@@ -11,7 +11,6 @@ import { v4 as uuidv4 } from 'uuid';
import { readFile } from 'node:fs/promises';
import { statSync, realpathSync } from 'node:fs';
import { Session } from '../session.js';
import { applyWorkspaceHooks } from '../hooks-config.js';
import { SseEvent } from '../web/sse-events.js';
import { CronJobSchema } from '../web/schemas.js';
import { getErrorMessage, createErrorResponse, ApiErrorCode } from '../types/api.js';
@@ -402,15 +401,6 @@ export class CronService {
// clampCronExternalCliConfigs — cron sends no per-CLI config, so the CLI's own
// spawn default is what would otherwise apply).
const { geminiConfig, piConfig } = clampCronExternalCliConfigs(mode, ownerGranted);
// Workspace hooks (see applyWorkspaceHooks in hooks-config): cron jobs are
// always local (workingDir was stat-validated above) but used to bypass the
// shared install-vs-refresh decision, so a job firing in a linked case that
// never had an interactive session ran hook-blind — no `stop` for the
// completion detection, no tab alert on a blocking dialog. Claude mode only
// (nothing else reads `.claude` hooks); best-effort inside the helper.
if (mode === 'claude') {
await applyWorkspaceHooks(job.workingDir);
}
session = new Session({
workingDir: job.workingDir,
mode,
+1 -61
View File
@@ -10,9 +10,8 @@
* Key exports:
* - `generateHooksConfig()` — returns hooks object for settings.local.json
* - `writeHooksConfig(casePath)` — writes hooks + env config to disk
* - `applyWorkspaceHooks(workspace, install?)` — the ONE install-vs-refresh decision
* point every claude-session create path routes through (see its doc comment)
* - `ensureCodemanHooks(casePath)` — safely installs/updates hooks for a managed case
* (no production call site yet; see its doc comment before wiring one)
* - `updateCaseEnvVars(casePath, envVars)` — merges env vars into settings
*
* Hook events generated: `idle_prompt`, `permission_prompt`, `elicitation_dialog`,
@@ -38,7 +37,6 @@ import { fileURLToPath } from 'node:url';
import type { HookEventType } from './types.js';
import { HOOK_TIMEOUT_SECONDS } from './config/auth-config.js';
import { dataPath } from './config/instance.js';
/**
* Serializes read-modify-write access to a `settings.local.json` path. Every
@@ -749,64 +747,6 @@ export async function refreshStaleCodemanHooks(casePath: string): Promise<void>
});
}
/**
* Hooks for the workspace a Claude session is about to run in. ONE decision point,
* shared by every claude-session create path — the interactive routes, quick-start,
* cron fires, legacy scheduled runs, the plan-orchestrator one-shots, and the boot
* recovery sweep — so the `workspaceHooksEnabled` setting cannot apply to some of
* them only.
*
* ON (the default): INSTALL Codeman's hooks block (`ensureCodemanHooks`), merging so
* a user's own hook entries and every other settings key survive. Hooks used to be
* 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 (full history on `ensureCodemanHooks`).
*
* 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.
*
* `install` overrides the setting read: route handlers resolve it through their
* ConfigPort (`ctx.getWorkspaceHooksEnabled()`, which tests stub), and the boot sweep
* passes `true` after checking the setting once for its whole batch. Every other
* caller omits it and the synced setting is read from settings.json here — default ON
* when the key is absent or the file unreadable, matching the server's resolver.
*
* Callers gate on their own context (claude mode only; local — never a remote
* workingDir, which is a path on ANOTHER host, and never a docker case that opted
* out of hooks). The guards EVERY caller needs live here instead:
* - a workspace that does not exist is skipped — `ensureCodemanHooks` mkdir -p's,
* so a deleted repo whose tmux session survived would otherwise be resurrected
* as an empty directory tree holding only `.claude/settings.local.json`;
* - errors are swallowed — a session create must never fail on hooks.
*/
export async function applyWorkspaceHooks(workspace: string, install?: boolean): Promise<void> {
try {
if (!existsSync(workspace)) return;
const shouldInstall = install ?? (await readWorkspaceHooksEnabled());
await (shouldInstall ? ensureCodemanHooks(workspace) : refreshStaleCodemanHooks(workspace));
} catch {
// Best-effort by contract (see doc comment): hooks degrade to output-based
// idle detection; the create goes ahead.
}
}
/**
* The synced `workspaceHooksEnabled` app setting, read straight from settings.json
* for callers that live outside the web layer (cron, scheduled runs, the plan
* orchestrator). Default ON: an absent key means a user who has never seen the
* 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.
*/
async function readWorkspaceHooksEnabled(): Promise<boolean> {
try {
const parsed = JSON.parse(await readFile(dataPath('settings.json'), 'utf-8')) as Record<string, unknown>;
return parsed.workspaceHooksEnabled !== false;
} catch {
return true;
}
}
/** Unique marker identifying Codeman's own statusLine command (vs a user's). */
const STATUSLINE_MARKER = '/api/status-telemetry';
-10
View File
@@ -20,7 +20,6 @@ import type { TerminalMultiplexer } from './mux-interface.js';
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { RESEARCH_AGENT_PROMPT, PLANNER_PROMPT } from './prompts/index.js';
import { applyWorkspaceHooks } from './hooks-config.js';
import { getErrorMessage, type PlanItem, type ClaudeMode } from './types.js';
// Re-export for backward compatibility
@@ -430,11 +429,6 @@ export class PlanOrchestrator {
detail: 'Researching...',
});
// Workspace hooks for the case this plan targets (see applyWorkspaceHooks in
// hooks-config): claude-mode, local workingDir, and the helper itself skips a
// vanished dir + swallows failures — the plan run must never fail on hooks.
await applyWorkspaceHooks(this.workingDir);
const session = new Session({
workingDir: this.workingDir,
mux: this.mux,
@@ -597,10 +591,6 @@ export class PlanOrchestrator {
detail: 'Generating plan...',
});
// Workspace hooks: same rationale as the research one-shot above (idempotent —
// the helper short-circuits when the hooks block is already current).
await applyWorkspaceHooks(this.workingDir);
const session = new Session({
workingDir: this.workingDir,
mux: this.mux,
+51 -10
View File
@@ -135,6 +135,14 @@ const MUX_STARTUP_DELAY_MS = 300;
/** Delay before declaring session idle after last output (2 seconds) */
const IDLE_DETECTION_DELAY_MS = 2000;
// How long after construction a RECOVERED session's wire activity stamp keeps
// its restored previous-run value. Recovery attaches every pane at boot and the
// attach repaint arrives as ordinary PTY output; without this window that
// repaint would overwrite every restored stamp within the same second, which is
// exactly the restart flattening the restore exists to prevent. Real actions
// (input, task assignment, respawn) always stamp through it.
const WIRE_ACTIVITY_SETTLE_MS = 15_000;
// Note: Auto-compact/clear timing constants moved to session-auto-ops.ts
/** Graceful shutdown delay when stopping session (100ms) */
@@ -392,6 +400,12 @@ export class Session extends EventEmitter {
private _textOutput = new BufferAccumulator(MAX_TEXT_OUTPUT_SIZE, TEXT_OUTPUT_TRIM_SIZE);
private _errorBuffer: string = '';
private _lastActivityAt: number;
// Display twin of _lastActivityAt, reported by toState()/the getter. It can
// lag behind on recovery: the restored previous-run stamp survives the attach
// repaint (see _markActivity), so a restart does not flatten the home
// screens' quiet ordering. Idle detection never reads it.
private _wireActivityAt: number;
private _wireActivitySettleUntil: number;
private _claudeSessionId: string | null = null;
private _totalCost: number = 0;
private _messages: ClaudeMessage[] = [];
@@ -592,6 +606,8 @@ export class Session extends EventEmitter {
attachmentHistory?: SessionAttachmentHistoryItem[];
/** Restored wall-clock ms of the pane's last Enter (see `lastSubmitAt`). */
lastSubmitAt?: number;
/** Restored wall-clock ms of the pane's last output (recovery only; see `_wireActivityAt`). */
lastActivityAt?: number;
/** Remote execution metadata for sessions launched through SSH inside local tmux. */
remote?: SessionRemote;
/** Docker execution metadata for sessions launched inside a container via local tmux. */
@@ -620,9 +636,18 @@ export class Session extends EventEmitter {
// NOW, not `createdAt`: recovery passes the ORIGINAL creation time of a
// days-old tmux session, and seeding last-activity from it would report a
// freshly re-attached pane as having been silent for days, which the idle
// confirmation reads as "already quiet" and the home screens print as its
// idle duration. For a genuinely new session the two are the same instant.
// confirmation reads as "already quiet". For a genuinely new session the
// two are the same instant.
this._lastActivityAt = Date.now();
// The WIRE copy of the stamp is allowed to be older: recovery threads the
// previous run's value so a restart does not flatten the home screens'
// most-recently-quiet ordering (every stamp otherwise resets to boot time,
// and the attach repaint re-bumps the rest within the same second). The
// settle window in _markActivity() carries the restored value through that
// repaint; the private stamp above stays boot-anchored because the idle
// confirmation reads it as "how long has the pane been quiet".
this._wireActivityAt = config.lastActivityAt || Date.now();
this._wireActivitySettleUntil = config.lastActivityAt ? Date.now() + WIRE_ACTIVITY_SETTLE_MS : 0;
// Set claudeSessionId — when resuming, the Claude conversation ID is the resumed one.
this._claudeSessionId = config.resumeSessionId || this.id;
// Restored from state.json on boot recovery. start() resets _claudeSessionId
@@ -794,7 +819,21 @@ export class Session extends EventEmitter {
}
get lastActivityAt(): number {
return this._lastActivityAt;
return this._wireActivityAt;
}
/**
* Stamp activity NOW. The private stamp (idle detection's "how long has the
* pane been quiet") always moves; the wire stamp holds its restored value
* through the post-recovery attach-repaint window unless the activity is a
* real action (input, task assignment, respawn), which always writes through.
*/
private _markActivity(realAction = false): void {
this._lastActivityAt = Date.now();
if (realAction || Date.now() >= this._wireActivitySettleUntil) {
this._wireActivityAt = this._lastActivityAt;
this._wireActivitySettleUntil = 0;
}
}
get claudeSessionId(): string | null {
@@ -1219,7 +1258,9 @@ export class Session extends EventEmitter {
parentSessionId: this._parentSessionId,
currentTaskId: this._currentTaskId,
createdAt: this.createdAt,
lastActivityAt: this._lastActivityAt,
// The wire twin, not the private stamp: it survives the post-recovery
// attach repaint, so the home screens' quiet ordering survives a restart.
lastActivityAt: this._wireActivityAt,
name: this._name,
mode: this.mode,
autoClearEnabled: this._autoOps.autoClearEnabled,
@@ -1585,7 +1626,7 @@ export class Session extends EventEmitter {
// BufferAccumulator handles auto-trimming when max size exceeded
this._terminalBuffer.append(data);
this._lastActivityAt = Date.now();
this._markActivity();
this.emit('terminal', data);
this.emit('output', data);
}
@@ -2484,7 +2525,7 @@ export class Session extends EventEmitter {
this._messages = [];
this._lineBuffer = '';
this._altScreenSeqCarry = '';
this._lastActivityAt = Date.now();
this._markActivity(true);
}
private _clearAllTimers(): void {
@@ -3083,7 +3124,7 @@ export class Session extends EventEmitter {
// Legacy method for sending input - wraps runPrompt
async sendInput(input: string): Promise<void> {
this._status = 'busy';
this._lastActivityAt = Date.now();
this._markActivity(true);
this.runPrompt(input).catch((err) => {
const errorMsg = getErrorMessage(err);
// Clean up task state so the task queue doesn't get stuck
@@ -3091,7 +3132,7 @@ export class Session extends EventEmitter {
const taskId = this._currentTaskId;
this._currentTaskId = null;
this._status = 'idle';
this._lastActivityAt = Date.now();
this._markActivity(true);
this.emit('taskError', taskId, errorMsg);
} else {
this._status = 'idle';
@@ -3252,13 +3293,13 @@ export class Session extends EventEmitter {
this._textOutput.clear();
this._errorBuffer = '';
this._messages = [];
this._lastActivityAt = Date.now();
this._markActivity(true);
}
clearTask(): void {
this._currentTaskId = null;
this._status = 'idle';
this._lastActivityAt = Date.now();
this._markActivity(true);
}
getOutput(): string {
+9 -4
View File
@@ -1116,12 +1116,17 @@ class CodemanApp {
if (digitMatch) {
const idx = parseInt(digitMatch[1], 10) - 1;
// Sessions occupy 1..N and web tabs continue from N+1, matching the
// numbers actually painted on the tabs.
if (idx < this.sessionOrder.length) {
// numbers actually painted on the tabs. Resolve through the same
// live-session projection the render paints: sessionOrder can
// transiently hold a dead id (delete raced against the order sync),
// and raw indexing then names the wrong tab for every key to its
// right, web tabs included.
const live = this.sessionOrder.filter((id) => this.sessions.has(id));
if (idx < live.length) {
e.preventDefault();
this.selectSession(this.sessionOrder[idx]);
this.selectSession(live[idx]);
} else {
const webIdx = idx - this.sessionOrder.length;
const webIdx = idx - live.length;
const webId = (this.webviewOrder || [])[webIdx];
if (webId) {
e.preventDefault();
+6 -4
View File
@@ -118,14 +118,16 @@ Object.assign(CodemanApp.prototype, {
* A WORKING pane is the opposite: it repaints about once a second, so its
* last-activity stamp is always "now" and would report every running turn as
* 0m. The turn's own start is the pane's last Enter (`lastSubmitAt`), which is
* persisted server-side and therefore survives a Codeman restart. A session
* that has never submitted has no anchor at all, and gets no stamp rather than
* a made-up one.
* persisted server-side and therefore survives a Codeman restart. A working
* session with NO submit stamp falls back to `lastActivityAt`, because that is
* exactly what `sessionActivityAnchor` (constants.js) sorts it by: a row must
* never be ranked by a number it does not show.
*
* @returns {{key: string, at: number}|null}
*/
_mobileOverviewSince(state, session) {
const at = state === 'working' ? Number(session.lastSubmitAt) || 0 : Number(session.lastActivityAt) || 0;
const activeAt = Number(session.lastActivityAt) || 0;
const at = state === 'working' ? Number(session.lastSubmitAt) || activeAt : activeAt;
if (!at) return null;
return { key: MOBILE_OVERVIEW_SINCE_LABEL[state] || state, at };
},
+5
View File
@@ -148,6 +148,11 @@ export function registerHookEventRoutes(
...safeData,
...(approvalId && { approvalId }),
});
// Full state ride-along, same shape as the working/idle handlers: the home
// screens rank the blocked group on lastActivityAt, and without this a
// permission prompt raised after page load kept ranking by whatever stamp
// the browser loaded with. Debounced, so a hook burst costs one broadcast.
ctx.broadcastSessionStateDebounced(sessionId);
// Send push notifications for hook events
ctx.sendPushNotifications(`hook:${event}`, {
+40 -23
View File
@@ -84,7 +84,7 @@ import {
applyAgentSkill,
refreshUserAgentSkill,
seedAgentSessionPreamble,
applyWorkspaceHooks,
ensureCodemanHooks,
refreshStaleCodemanHooks,
} from '../../hooks-config.js';
import { generateClaudeMd } from '../../templates/claude-md.js';
@@ -626,12 +626,31 @@ async function injectAgentSkill(casePath: string): Promise<void> {
}
}
// Workspace hooks: the install-vs-refresh decision core moved to
// `applyWorkspaceHooks` in hooks-config.ts (imported above) so the non-route
// claude create paths — cron fires, legacy scheduled runs, the plan-orchestrator
// one-shots, the boot recovery sweep — share the SAME decision instead of
// bypassing the `workspaceHooksEnabled` setting. Route handlers here resolve the
// setting through the ConfigPort (tests stub it) and pass it as the second arg.
/**
* 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,
@@ -769,14 +788,7 @@ export function registerSessionRoutes(
// chip's data feed for everyone. The exporter is benign when the chip is off
// (the footer just shows session status). isOurs-guarded so a user's own
// statusLine is never touched.
//
// Same guard as the hooks call below (499d355): never for a remote attach
// (workingDir is a user@host:session pseudo-path — the mkdir inside
// applyStatusLineConfig 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 a statusLine materializing in
// ~/.claude/settings.local.json was never asked for.
if (!remote && body.workingDir && (body.mode ?? 'claude') === 'claude' && body.statusLineTelemetry === true) {
if ((body.mode ?? 'claude') === 'claude' && body.statusLineTelemetry === true) {
await applyStatusLineConfig(workingDir, true);
}
@@ -787,7 +799,7 @@ export function registerSessionRoutes(
// 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(workingDir, await ctx.getWorkspaceHooksEnabled());
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
@@ -2924,7 +2936,7 @@ export function registerSessionRoutes(
// 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(resolvedCasePath, await ctx.getWorkspaceHooksEnabled());
await applyWorkspaceHooks(ctx, resolvedCasePath);
} else {
await refreshStaleCodemanHooks(resolvedCasePath).catch(() => {});
}
@@ -2942,11 +2954,16 @@ export function registerSessionRoutes(
// Docker cases: the workspace is a REAL host dir bind-mounted into the container.
// Scaffold hooks (+ a CLAUDE.md) if MISSING so in-container permission prompts and
// hook-idle detection fire (decision: wire hooks now). Never clobbers an existing
// configured project. Claude mode ONLY — only claude reads `.claude` hooks, so a
// shell or external-CLI quick-start must not author a block of its own (the same
// rule the existing-case branch above states; this branch used to exclude just
// the five external CLIs and let `shell` through).
if (docker && docker.hooksEnabled && mode === 'claude') {
// configured project. Skipped for external CLIs (they use their own systems).
if (
docker &&
docker.hooksEnabled &&
mode !== 'opencode' &&
mode !== 'codex' &&
mode !== 'gemini' &&
mode !== 'antigravity' &&
mode !== 'pi'
) {
try {
if (!existsSync(join(resolvedCasePath, 'CLAUDE.md'))) {
const templatePath = await ctx.getDefaultClaudeMdPath();
@@ -2958,7 +2975,7 @@ export function registerSessionRoutes(
// 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(resolvedCasePath, await ctx.getWorkspaceHooksEnabled());
await applyWorkspaceHooks(ctx, resolvedCasePath);
}
} catch {
/* non-fatal — the session still runs, hooks may be degraded */
+7 -17
View File
@@ -76,7 +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 { applyWorkspaceHooks } from '../hooks-config.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';
@@ -1819,14 +1819,6 @@ export class WebServer extends EventEmitter {
let session: Session | null = null;
try {
// Workspace hooks for this iteration's session — legacy scheduled runs are
// always claude-mode and always local, and used to bypass the shared decision
// entirely: a scheduled run firing in a linked case that never had an
// interactive session ran hook-blind (see applyWorkspaceHooks in hooks-config;
// it reads the `workspaceHooksEnabled` setting itself, skips a vanished
// workingDir, and swallows failures — a run must never fail on hooks).
await applyWorkspaceHooks(run.workingDir);
// Create a session for this iteration.
if (isMultiUserMode()) {
// §6.3: resolve the permission mode with the RUN OWNER (a non-granted user
@@ -2665,6 +2657,11 @@ export class WebServer extends EventEmitter {
// the launch conversation until the user types again, even though
// the re-attached CLI is on a post-`/clear` one.
lastSubmitAt: savedState?.lastSubmitAt,
// The pane's last output, previous run's value. Without it every
// restart restamped all sessions "now" (constructor + the attach
// repaint within the same second), flattening the home screens'
// most-recently-quiet ordering to tab order after each deploy.
lastActivityAt: savedState?.lastActivityAt,
// Remote SSH metadata must round-trip on recovery: without it the
// attach cwd falls back to the (nonexistent-locally) remote path and
// respawn rebuilds a LOCAL command, breaking the pane and silently
@@ -2889,11 +2886,6 @@ export class WebServer extends EventEmitter {
* 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.
*
* A workspace that no longer EXISTS is skipped by applyWorkspaceHooks: a tmux
* session can outlive its deleted repo, and `ensureCodemanHooks` mkdir -p's, so
* the sweep used to resurrect the directory as an empty tree holding only
* `.claude/settings.local.json`.
*/
private async ensureHooksForRecoveredWorkspaces(): Promise<void> {
if (!(await this.getWorkspaceHooksEnabled())) return;
@@ -2904,9 +2896,7 @@ export class WebServer extends EventEmitter {
if (session.workingDir) workspaces.add(session.workingDir);
}
for (const workspace of workspaces) {
// install=true: the setting was already resolved ON above for the whole batch
// (OFF skips the sweep wholesale, keeping its documented semantics).
await applyWorkspaceHooks(workspace, true);
await ensureCodemanHooks(workspace).catch(() => {});
}
}
+72
View File
@@ -270,3 +270,75 @@ describe('home sessions column: wiring', () => {
expect(html.indexOf('src="mobile-overview.js"')).toBeGreaterThan(html.indexOf('src="constants.js"'));
});
});
describe('home screens: one order, one numbering', () => {
it('produces the same order on the rail and the phone overview for one input', () => {
// Both surfaces claim to share CodemanSessionOrder. Nothing used to assert
// they actually produce one order for one input, so a future local sort in
// either builder would silently split them. The rail is one list; the phone
// splits NEEDS YOU / CURRENT, so rail order must equal the concatenation.
const fixture = [
{ id: 'blocked-new', lastActivityAt: 5_000 },
{ id: 'idle-old', lastActivityAt: 3_000 },
{ id: 'run-new', status: 'busy', lastSubmitAt: 8_000, lastActivityAt: 9_500 },
{ id: 'blocked-old', lastActivityAt: 1_000 },
{ id: 'run-old', status: 'busy', lastSubmitAt: 2_000, lastActivityAt: 9_600 },
{ id: 'idle-new', lastActivityAt: 9_000 },
];
const pendingHooks = new Map([
['blocked-new', new Set(['permission_prompt'])],
['blocked-old', new Set(['permission_prompt'])],
]);
const sessionOrder = fixture.map((s) => s.id);
const app = loadHomeSessionsApp({
sessions: sessionMap(fixture),
sessionOrder,
cases: CASES,
pendingHooks,
});
const railIds = app.buildHomeSessionRows().map((r: any) => r.id);
const model = app.buildMobileOverviewModel({
sessions: app.sessions,
cases: CASES,
sessionOrder,
pendingHooks,
});
const phoneIds = [...model.needsYou, ...model.current].map((r: any) => r.id);
expect(railIds).toEqual(phoneIds);
// And the shared order is the documented one: blocked longest-first, then
// running longest-first, then quiet newest-first.
expect(railIds).toEqual(['blocked-old', 'blocked-new', 'run-old', 'run-new', 'idle-new', 'idle-old']);
});
it('numbers rows over the LIVE projection when sessionOrder holds a dead id', () => {
// sessionOrder can transiently contain a deleted session (delete raced the
// order sync). The strip paints numbers over live sessions only, and the
// Alt+digit handler resolves through the same projection, so the rail must
// number alpha=1, beta=2 with no hole where the ghost sits.
const app = loadHomeSessionsApp({
sessions: sessionMap([{ id: 'alpha' }, { id: 'beta' }]),
sessionOrder: ['ghost', 'alpha', 'beta'],
cases: CASES,
});
expect(app.buildHomeSessionRows().map((r: any) => [r.id, r.orderIndex])).toEqual([
['alpha', 0],
['beta', 1],
]);
});
it('Alt+digit resolves through the live-session projection in app.js', () => {
// Static guard for the handler half of the invariant above: the digit
// branch must filter sessionOrder against live sessions before indexing,
// for sessions AND for the web-tab continuation.
const appJs = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
const start = appJs.indexOf('^Digit([1-9])$');
expect(start).toBeGreaterThan(-1);
const branch = appJs.slice(start, start + 1200);
expect(branch).toContain('this.sessionOrder.filter((id) => this.sessions.has(id))');
expect(branch).toContain('idx < live.length');
expect(branch).toContain('idx - live.length');
expect(branch).not.toContain('this.sessionOrder[idx]');
});
});
+17 -4
View File
@@ -277,15 +277,28 @@ describe('mobile overview model', () => {
expect(rows.i.createdAt).toBe(now - 7200_000);
});
it('leaves the stamp off rather than inventing an anchor', () => {
it('falls back to the sort anchor for a working row with no submit stamp', () => {
// A session that has never submitted has no turn start to measure from, but
// `sessionActivityAnchor` still RANKS it by lastActivityAt. The stamp must
// show that same number rather than nothing: a row sorted by a value it
// does not display reads as randomly placed.
const now = Date.now();
const app = loadOverviewApp();
const model = app.buildMobileOverviewModel({
// A session that has never submitted has no turn start to measure from.
sessions: [session({ id: 'w', status: 'busy', lastActivityAt: Date.now() })],
sessions: [session({ id: 'w', status: 'busy', lastActivityAt: now })],
cases: CASES,
});
expect(model.current[0].since).toEqual({ key: 'working', at: now });
expect(model.current[0].createdAt).toBe(0);
});
it('still leaves the stamp off when there is no anchor at all', () => {
const app = loadOverviewApp();
const model = app.buildMobileOverviewModel({
sessions: [session({ id: 'w', status: 'busy' })],
cases: CASES,
});
expect(model.current[0].since).toBeNull();
expect(model.current[0].createdAt).toBe(0);
});
it('formats a moment as "ago" and a span as a bare duration', () => {
@@ -10,13 +10,6 @@
*
* Asserts bytes on disk (the real `ensureCodemanHooks`), not a spy call.
* Uses app.inject(), so no real HTTP port is needed.
*
* Also covers the post-#304 follow-ups: the quick-start existing-case branch, the
* docker branch's claude-only gate (a shell quick-start used to author a hooks
* block of its own), and the shared decision core `applyWorkspaceHooks` in
* hooks-config.ts — the function the non-route create paths (cron, scheduled runs,
* plan one-shots, the boot recovery sweep) go through, tested directly here
* including the sweep's deleted-workspace guard.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
@@ -29,9 +22,8 @@ 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, applyWorkspaceHooks } from '../../src/hooks-config.js';
import { generateHooksConfig } from '../../src/hooks-config.js';
import { getDataDir } from '../../src/config/instance.js';
import { CASES_DIR } from '../../src/web/route-helpers.js';
interface HooksFile {
hooks?: Record<string, Array<{ matcher?: string; hooks?: Array<{ command?: string }> }>>;
@@ -149,14 +141,11 @@ describe('POST /api/sessions workspace hooks', () => {
it('leaves the server cwd alone when workingDir is omitted', async () => {
// workingDir falls back to process.cwd(), which is $HOME under installer-created
// services — neither hooks NOR the statusLine exporter (same mkdir-into-cwd
// exposure, closed in the #304 follow-ups) may materialize in
// ~/.claude/settings.local.json.
// services — hooks must not materialize in ~/.claude/settings.local.json.
const cwdSettings = join(process.cwd(), '.claude', 'settings.local.json');
const before = existsSync(cwdSettings) ? await readFile(cwdSettings, 'utf-8') : null;
const res = await createSession({ name: 'hooks-no-dir', mode: 'claude', statusLineTelemetry: true });
expect(res.statusCode).toBe(200);
expect((await createSession({ name: 'hooks-no-dir', mode: 'claude' })).statusCode).toBe(200);
const after = existsSync(cwdSettings) ? await readFile(cwdSettings, 'utf-8') : null;
expect(after).toBe(before);
@@ -165,8 +154,7 @@ describe('POST /api/sessions workspace hooks', () => {
it('never writes hooks for a remote attach (workingDir is a user@host pseudo-path)', async () => {
// A claude-mode attachRemoteSession create overwrites workingDir with
// `user@host:session` — locally a RELATIVE path, so a mkdir would create it
// as a junk directory under the server cwd. statusLineTelemetry rides along:
// applyStatusLineConfig mkdirs the same way and used to run for remote attaches.
// as a junk directory under the server cwd.
await mkdir(getDataDir(), { recursive: true });
await writeFile(
join(getDataDir(), 'remote-hosts.json'),
@@ -176,7 +164,6 @@ describe('POST /api/sessions workspace hooks', () => {
const res = await createSession({
name: 'hooks-remote',
mode: 'claude',
statusLineTelemetry: true,
attachRemoteSession: { hostId: 'h1', remoteSessionName: 'codeman-ssh-abc123' },
});
expect(res.statusCode).toBe(200);
@@ -220,157 +207,3 @@ describe('POST /api/sessions workspace hooks', () => {
expect(Object.keys(written).sort()).toEqual(Object.keys(generateHooksConfig().hooks).sort());
});
});
describe('POST /api/quick-start workspace hooks', () => {
let app: FastifyInstance;
const quickStart = (payload: Record<string, unknown>) =>
app.inject({ method: 'POST', url: '/api/quick-start', payload });
const hooksFileIn = (dir: string) => join(dir, '.claude', 'settings.local.json');
beforeEach(async () => {
app = Fastify({ logger: false });
await app.register(fastifyCookie);
registerSessionRoutes(app, createMockRouteContext());
installRouteErrorHandler(app);
await app.ready();
});
afterEach(async () => {
await app.close();
// Docker fixtures + case dirs must not leak into the next test.
await rm(join(getDataDir(), 'docker-hosts.json'), { force: true });
await rm(join(getDataDir(), 'docker-cases.json'), { force: true });
await rm(CASES_DIR, { recursive: true, force: true });
});
it('installs hooks into an EXISTING case directory (a linked case / cloned repo)', async () => {
// The scaffold branch (writeHooksConfig) only runs when quick-start CREATES the
// directory; a pre-existing case takes the applyWorkspaceHooks branch instead.
const casePath = join(CASES_DIR, 'existingcase');
await mkdir(casePath, { recursive: true });
const res = await quickStart({ caseName: 'existingcase', mode: 'claude' });
expect(res.statusCode).toBe(200);
const raw = await readFile(hooksFileIn(casePath), 'utf-8');
expect(raw).toContain('X-Codeman-Hook-Secret');
expect(raw).toContain('/api/hook-event');
});
/** Minimal docker host + case fixtures (docker IO is no-op'd under vitest). */
const writeDockerFixtures = async (caseName: string, hostWorkspacePath: string) => {
await mkdir(getDataDir(), { recursive: true });
await writeFile(
join(getDataDir(), 'docker-hosts.json'),
JSON.stringify([{ id: 'd1', label: 'box', image: 'codeman/agent:base' }])
);
await writeFile(
join(getDataDir(), 'docker-cases.json'),
JSON.stringify([{ name: caseName, type: 'docker', hostId: 'd1', hostWorkspacePath }])
);
};
it('docker branch scaffolds hooks for a claude session', async () => {
// Companion to the shell test below: proves the docker fixture path is live,
// so the shell assertion cannot pass vacuously.
const ws = await mkdtemp(join(tmpdir(), 'codeman-docker-claude-'));
try {
await writeDockerFixtures('dockclaude', ws);
expect((await quickStart({ caseName: 'dockclaude', mode: 'claude' })).statusCode).toBe(200);
expect(await readFile(hooksFileIn(ws), 'utf-8')).toContain('X-Codeman-Hook-Secret');
} finally {
await rm(ws, { recursive: true, force: true });
}
});
it('docker branch authors NO hooks block for a shell session', async () => {
// The branch used to exclude only the five external CLIs, so a shell
// quick-start into a docker case wrote a `.claude` block of its own —
// contradicting the existing-case branch's rule that only claude reads it.
const ws = await mkdtemp(join(tmpdir(), 'codeman-docker-shell-'));
try {
await writeDockerFixtures('dockshell', ws);
expect((await quickStart({ caseName: 'dockshell', mode: 'shell' })).statusCode).toBe(200);
expect(existsSync(join(ws, '.claude'))).toBe(false);
} finally {
await rm(ws, { recursive: true, force: true });
}
});
});
describe('applyWorkspaceHooks (the shared decision core in hooks-config)', () => {
// The non-route claude create paths — cron fires, legacy scheduled runs, the
// plan-orchestrator one-shots, the boot recovery sweep — call this function
// directly, so its contract is tested here rather than by spinning those up.
let workspace: string;
const appSettingsPath = () => join(getDataDir(), 'settings.json');
const wsSettingsPath = () => join(workspace, '.claude', 'settings.local.json');
const setWorkspaceHooksSetting = async (enabled: boolean) => {
await mkdir(getDataDir(), { recursive: true });
await writeFile(appSettingsPath(), JSON.stringify({ workspaceHooksEnabled: enabled }));
};
beforeEach(async () => {
workspace = await mkdtemp(join(tmpdir(), 'codeman-hooks-core-'));
});
afterEach(async () => {
await rm(workspace, { recursive: true, force: true });
await rm(appSettingsPath(), { force: true });
});
it('installs hooks with no settings.json at all (absent key = default ON)', async () => {
await applyWorkspaceHooks(workspace);
const raw = await readFile(wsSettingsPath(), 'utf-8');
expect(raw).toContain('X-Codeman-Hook-Secret');
expect(raw).toContain('curl -sk -X POST');
});
it('never ADDS a hooks block when workspaceHooksEnabled is OFF', async () => {
await setWorkspaceHooksSetting(false);
await applyWorkspaceHooks(workspace);
expect(existsSync(wsSettingsPath())).toBe(false);
});
it('still heals a stale Codeman block when the setting is OFF (COD-91 self-heal)', async () => {
await setWorkspaceHooksSetting(false);
await mkdir(join(workspace, '.claude'), { recursive: true });
await writeFile(wsSettingsPath(), JSON.stringify({ model: 'opus', hooks: staleCodemanHooks() }));
await applyWorkspaceHooks(workspace);
const settings: HooksFile = JSON.parse(await readFile(wsSettingsPath(), 'utf-8'));
expect(settings.model).toBe('opus');
expect(JSON.stringify(settings.hooks)).toContain('X-Codeman-Hook-Secret');
});
it('leaves a malformed settings file untouched rather than replacing it', async () => {
await mkdir(join(workspace, '.claude'), { recursive: true });
await writeFile(wsSettingsPath(), '{ not json');
await applyWorkspaceHooks(workspace);
expect(await readFile(wsSettingsPath(), 'utf-8')).toBe('{ not json');
});
it('skips a workspace that no longer exists (the boot-sweep resurrection bug)', async () => {
// ensureCodemanHooks mkdir -p's, so the sweep used to recreate a DELETED repo
// as an empty directory tree holding only .claude/settings.local.json.
const gone = join(workspace, 'deleted-repo');
// install=true mirrors the boot sweep's call shape (setting pre-resolved ON).
await applyWorkspaceHooks(gone, true);
expect(existsSync(gone)).toBe(false);
// The setting-driven shape must skip it too.
await applyWorkspaceHooks(gone);
expect(existsSync(gone)).toBe(false);
});
});
+42
View File
@@ -246,3 +246,45 @@ describe('Session interactive idle detection', () => {
expect(events).toEqual([]);
});
});
describe('wire activity stamp across recovery', () => {
// The stamp both home screens sort the quiet group on. Recovery restores the
// previous run's value, and the settle window keeps the boot attach repaint
// (ordinary PTY output, arriving within seconds of construction) from
// restamping every session "now": measured live, a restart left 17 of 17
// sessions with an identical lastActivityAt, which flattens the ordering to
// tab order after every deploy.
const OLD = 1_700_000_000_000;
const restored = () =>
new Session({ workingDir: '/tmp', mode: 'claude', lastActivityAt: OLD } as ConstructorParameters<
typeof Session
>[0]);
it('restores the previous-run stamp and holds it through attach-repaint output', () => {
const session = restored();
expect(session.lastActivityAt).toBe(OLD);
(session as unknown as SessionInternals)._handleTerminalOutput('attach repaint bytes');
expect(session.lastActivityAt).toBe(OLD);
expect(session.toState().lastActivityAt).toBe(OLD);
});
it('a real action writes through the settle window', () => {
const session = restored();
session.assignTask('t1');
expect(session.lastActivityAt).toBeGreaterThan(OLD);
});
it('output after the window moves the stamp normally', () => {
const session = restored();
(session as unknown as { _wireActivitySettleUntil: number })._wireActivitySettleUntil = Date.now() - 1;
(session as unknown as SessionInternals)._handleTerminalOutput('real output');
expect(session.lastActivityAt).toBeGreaterThan(OLD);
});
it('a fresh session has no window: first output stamps immediately', () => {
const before = Date.now();
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
(session as unknown as SessionInternals)._handleTerminalOutput('x');
expect(session.lastActivityAt).toBeGreaterThanOrEqual(before);
});
});