Compare commits

..
Author SHA1 Message Date
Codeman maintainer fa8ebe0068 chore: version packages 2026-08-16 23:07:43 +02:00
Ark0N b0e493d462 Merge pull request #311 from Ark0N/fix/workspace-hooks-followups
Workspace hooks follow-ups: one decision core for every claude create path, docker shell gate, boot-sweep and statusLine guards
2026-08-16 20:44:49 +02:00
Ark0N 631913f04c Merge pull request #310 from Ark0N/fix/files-sidebar-followups
fix: file-link and session-sidebar review follow-ups from 1.19.0
2026-08-16 20:44:14 +02:00
Ark0N bb959c4aac Merge pull request #309 from Ark0N/fix/home-order-followups
Home-screen ordering follow-ups: live stamps, one numbering, restart-proof recency
2026-08-16 20:43:21 +02:00
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 f44d597450 review fixes: every claude create path routes through the workspace-hooks decision
Post-#304 follow-ups. The install-vs-refresh decision (workspaceHooksEnabled,
default ON) moved from a session-routes-local helper into hooks-config.ts as
applyWorkspaceHooks(workspace, install?), and the claude session-create sites
that bypassed it now go through it: cron job fires (cron-service), legacy
scheduled-run iterations (runScheduledLoop), and the plan-orchestrator research
and planner one-shots. A cron or scheduled run firing in a linked case that
never had an interactive session ran hook-blind (no stop for completion
detection, no tab alert on a blocking dialog).

The shared core also carries the two guards every caller needs: a workspace
that no longer exists is skipped (ensureCodemanHooks mkdir -p's, so the boot
recovery sweep used to resurrect a deleted repo as an empty tree holding only
.claude/settings.local.json), and all errors are swallowed since a create must
never fail on hooks. Route handlers keep resolving the setting through their
ConfigPort and pass it in; non-route callers omit it and the core reads
settings.json itself (absent key or unreadable file = ON).

Two adjacent gates tightened in session-routes:
- the docker quick-start hooks branch excluded the five external CLIs but let
  `shell` through, contradicting its own rule that only claude reads .claude
  hooks; it is now gated on mode === 'claude'
- the statusLine exporter call in POST /api/sessions got the same
  !remote && body.workingDir guard the hooks call got in 499d355 (it mkdirs the
  same way, so a remote attach created a junk user@host:session dir locally and
  a cwd-fallback create wrote into $HOME)

plan-routes' one-shot deliberately stays out: its workingDir is process.cwd(),
exactly the target 499d355 forbids writing into. restoreMuxSessions stays out
too: the boot sweep already covers recovered workspaces.

Tests: quick-start existing-case install, docker claude-installs/shell-does-not,
and the core directly (default-ON install, OFF add-nothing, OFF still heals a
stale block, malformed file untouched, vanished workspace skipped); the remote
and cwd-fallback regressions now also send statusLineTelemetry:true to pin the
statusLine guard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 20:27:38 +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
17 changed files with 516 additions and 74 deletions
+12
View File
@@ -1,5 +1,17 @@
# aicodeman
## 1.19.1
### Patch Changes
- Follow-up hardening from the 1.19.0 reviews, across all three of that release's areas (#309, #310, #311).
Home screens: the activity ordering introduced in 1.19.0 now stays truthful. Hook events push a session state broadcast, so a blocked session ranks by a fresh stamp instead of whatever the page loaded with; a working row with no recorded submit shows the same stamp it sorts by; Alt+1..9 resolves through the live sessions the tabs actually paint, so a stale id in the saved order can no longer shift every number off its target; and the "most recently quiet" ordering survives restarts, since recovery now restores each session's previous activity stamp from state.json instead of restamping everything at boot (previously every deploy flattened the ordering to tab order).
Files and sidebar: playable media extensions are pinned to the attachment registry by a parity test, so an in-workspace .m4a/.flac/.opus opens the preview player instead of the log viewer; /etc paths no longer render as links that can only 403; the sidebar session count counts the rows actually on screen (web tabs included, filtered rows excluded) and follows the filter box; connectors re-anchor on incremental renders in sidebar layout; and ~/.claude.json plus ~/.claude/settings(.local).json are blocked from file serving, home-anchored only, so case-level .claude files stay viewable.
Workspace hooks: the install-vs-refresh decision is one shared core that every claude create path routes through, so the workspaceHooksEnabled setting now also applies to cron jobs, legacy scheduled runs, and plan-orchestrator one-shots; a shell session in a docker case no longer authors a hooks block; the boot sweep no longer resurrects a deleted workspace as an empty directory; and the statusLine exporter got the same remote-attach and cwd-fallback guards as the hooks install.
## 1.19.0
### Minor Changes
+2 -2
View File
@@ -74,7 +74,7 @@ When user says "COM":
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
**Version**: 1.19.0 (must match `package.json`)
**Version**: 1.19.1 (must match `package.json`)
## Project Overview
@@ -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 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.
**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.
**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 -2
View File
@@ -1,12 +1,12 @@
{
"name": "aicodeman",
"version": "1.19.0",
"version": "1.19.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
"version": "1.19.0",
"version": "1.19.1",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "1.19.0",
"version": "1.19.1",
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module",
"main": "dist/index.js",
+10
View File
@@ -11,6 +11,7 @@ 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';
@@ -401,6 +402,15 @@ 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,
+61 -1
View File
@@ -10,8 +10,9 @@
* 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`,
@@ -37,6 +38,7 @@ 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
@@ -747,6 +749,64 @@ 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,6 +20,7 @@ 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
@@ -429,6 +430,11 @@ 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,
@@ -591,6 +597,10 @@ 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}`, {
+23 -40
View File
@@ -84,7 +84,7 @@ import {
applyAgentSkill,
refreshUserAgentSkill,
seedAgentSessionPreamble,
ensureCodemanHooks,
applyWorkspaceHooks,
refreshStaleCodemanHooks,
} from '../../hooks-config.js';
import { generateClaudeMd } from '../../templates/claude-md.js';
@@ -626,31 +626,12 @@ 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(() => {});
}
// 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.
export function registerSessionRoutes(
app: FastifyInstance,
@@ -788,7 +769,14 @@ 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.
if ((body.mode ?? 'claude') === 'claude' && body.statusLineTelemetry === true) {
//
// 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) {
await applyStatusLineConfig(workingDir, true);
}
@@ -799,7 +787,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(ctx, workingDir);
await applyWorkspaceHooks(workingDir, await ctx.getWorkspaceHooksEnabled());
// 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
@@ -2936,7 +2924,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(ctx, resolvedCasePath);
await applyWorkspaceHooks(resolvedCasePath, await ctx.getWorkspaceHooksEnabled());
} else {
await refreshStaleCodemanHooks(resolvedCasePath).catch(() => {});
}
@@ -2954,16 +2942,11 @@ 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. Skipped for external CLIs (they use their own systems).
if (
docker &&
docker.hooksEnabled &&
mode !== 'opencode' &&
mode !== 'codex' &&
mode !== 'gemini' &&
mode !== 'antigravity' &&
mode !== 'pi'
) {
// 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') {
try {
if (!existsSync(join(resolvedCasePath, 'CLAUDE.md'))) {
const templatePath = await ctx.getDefaultClaudeMdPath();
@@ -2975,7 +2958,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(ctx, resolvedCasePath);
await applyWorkspaceHooks(resolvedCasePath, await ctx.getWorkspaceHooksEnabled());
}
} catch {
/* non-fatal — the session still runs, hooks may be degraded */
+22 -2
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 { ensureCodemanHooks } from '../hooks-config.js';
import { applyWorkspaceHooks } from '../hooks-config.js';
import { PushSubscriptionStore } from '../push-store.js';
import webpush from 'web-push';
import { SseStreamManager } from './sse-stream-manager.js';
@@ -1819,6 +1819,14 @@ 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
@@ -2657,6 +2665,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
@@ -2881,6 +2894,11 @@ 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;
@@ -2891,7 +2909,9 @@ export class WebServer extends EventEmitter {
if (session.workingDir) workspaces.add(session.workingDir);
}
for (const workspace of workspaces) {
await ensureCodemanHooks(workspace).catch(() => {});
// 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);
}
}
+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,6 +10,13 @@
*
* 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';
@@ -22,8 +29,9 @@ 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';
import { generateHooksConfig, applyWorkspaceHooks } 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 }> }>>;
@@ -141,11 +149,14 @@ 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 — hooks must not materialize in ~/.claude/settings.local.json.
// 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.
const cwdSettings = join(process.cwd(), '.claude', 'settings.local.json');
const before = existsSync(cwdSettings) ? await readFile(cwdSettings, 'utf-8') : null;
expect((await createSession({ name: 'hooks-no-dir', mode: 'claude' })).statusCode).toBe(200);
const res = await createSession({ name: 'hooks-no-dir', mode: 'claude', statusLineTelemetry: true });
expect(res.statusCode).toBe(200);
const after = existsSync(cwdSettings) ? await readFile(cwdSettings, 'utf-8') : null;
expect(after).toBe(before);
@@ -154,7 +165,8 @@ 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.
// as a junk directory under the server cwd. statusLineTelemetry rides along:
// applyStatusLineConfig mkdirs the same way and used to run for remote attaches.
await mkdir(getDataDir(), { recursive: true });
await writeFile(
join(getDataDir(), 'remote-hosts.json'),
@@ -164,6 +176,7 @@ 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);
@@ -207,3 +220,157 @@ 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);
});
});