mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 13:39:41 +02:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa8ebe0068 | ||
|
|
b0e493d462 | ||
|
|
631913f04c | ||
|
|
bb959c4aac | ||
|
|
cb9149879d | ||
|
|
f44d597450 | ||
|
|
cdbde9f36f |
@@ -1,5 +1,17 @@
|
|||||||
# aicodeman
|
# 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
|
## 1.19.0
|
||||||
|
|
||||||
### Minor Changes
|
### Minor Changes
|
||||||
|
|||||||
@@ -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.
|
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
|
## 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)
|
**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`.
|
**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`.
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "1.19.0",
|
"version": "1.19.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "1.19.0",
|
"version": "1.19.1",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aicodeman",
|
"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",
|
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { v4 as uuidv4 } from 'uuid';
|
|||||||
import { readFile } from 'node:fs/promises';
|
import { readFile } from 'node:fs/promises';
|
||||||
import { statSync, realpathSync } from 'node:fs';
|
import { statSync, realpathSync } from 'node:fs';
|
||||||
import { Session } from '../session.js';
|
import { Session } from '../session.js';
|
||||||
|
import { applyWorkspaceHooks } from '../hooks-config.js';
|
||||||
import { SseEvent } from '../web/sse-events.js';
|
import { SseEvent } from '../web/sse-events.js';
|
||||||
import { CronJobSchema } from '../web/schemas.js';
|
import { CronJobSchema } from '../web/schemas.js';
|
||||||
import { getErrorMessage, createErrorResponse, ApiErrorCode } from '../types/api.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
|
// clampCronExternalCliConfigs — cron sends no per-CLI config, so the CLI's own
|
||||||
// spawn default is what would otherwise apply).
|
// spawn default is what would otherwise apply).
|
||||||
const { geminiConfig, piConfig } = clampCronExternalCliConfigs(mode, ownerGranted);
|
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({
|
session = new Session({
|
||||||
workingDir: job.workingDir,
|
workingDir: job.workingDir,
|
||||||
mode,
|
mode,
|
||||||
|
|||||||
+61
-1
@@ -10,8 +10,9 @@
|
|||||||
* Key exports:
|
* Key exports:
|
||||||
* - `generateHooksConfig()` — returns hooks object for settings.local.json
|
* - `generateHooksConfig()` — returns hooks object for settings.local.json
|
||||||
* - `writeHooksConfig(casePath)` — writes hooks + env config to disk
|
* - `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
|
* - `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
|
* - `updateCaseEnvVars(casePath, envVars)` — merges env vars into settings
|
||||||
*
|
*
|
||||||
* Hook events generated: `idle_prompt`, `permission_prompt`, `elicitation_dialog`,
|
* 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 type { HookEventType } from './types.js';
|
||||||
import { HOOK_TIMEOUT_SECONDS } from './config/auth-config.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
|
* 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). */
|
/** Unique marker identifying Codeman's own statusLine command (vs a user's). */
|
||||||
const STATUSLINE_MARKER = '/api/status-telemetry';
|
const STATUSLINE_MARKER = '/api/status-telemetry';
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import type { TerminalMultiplexer } from './mux-interface.js';
|
|||||||
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { RESEARCH_AGENT_PROMPT, PLANNER_PROMPT } from './prompts/index.js';
|
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';
|
import { getErrorMessage, type PlanItem, type ClaudeMode } from './types.js';
|
||||||
|
|
||||||
// Re-export for backward compatibility
|
// Re-export for backward compatibility
|
||||||
@@ -429,6 +430,11 @@ export class PlanOrchestrator {
|
|||||||
detail: 'Researching...',
|
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({
|
const session = new Session({
|
||||||
workingDir: this.workingDir,
|
workingDir: this.workingDir,
|
||||||
mux: this.mux,
|
mux: this.mux,
|
||||||
@@ -591,6 +597,10 @@ export class PlanOrchestrator {
|
|||||||
detail: 'Generating plan...',
|
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({
|
const session = new Session({
|
||||||
workingDir: this.workingDir,
|
workingDir: this.workingDir,
|
||||||
mux: this.mux,
|
mux: this.mux,
|
||||||
|
|||||||
+51
-10
@@ -135,6 +135,14 @@ const MUX_STARTUP_DELAY_MS = 300;
|
|||||||
/** Delay before declaring session idle after last output (2 seconds) */
|
/** Delay before declaring session idle after last output (2 seconds) */
|
||||||
const IDLE_DETECTION_DELAY_MS = 2000;
|
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
|
// Note: Auto-compact/clear timing constants moved to session-auto-ops.ts
|
||||||
|
|
||||||
/** Graceful shutdown delay when stopping session (100ms) */
|
/** 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 _textOutput = new BufferAccumulator(MAX_TEXT_OUTPUT_SIZE, TEXT_OUTPUT_TRIM_SIZE);
|
||||||
private _errorBuffer: string = '';
|
private _errorBuffer: string = '';
|
||||||
private _lastActivityAt: number;
|
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 _claudeSessionId: string | null = null;
|
||||||
private _totalCost: number = 0;
|
private _totalCost: number = 0;
|
||||||
private _messages: ClaudeMessage[] = [];
|
private _messages: ClaudeMessage[] = [];
|
||||||
@@ -592,6 +606,8 @@ export class Session extends EventEmitter {
|
|||||||
attachmentHistory?: SessionAttachmentHistoryItem[];
|
attachmentHistory?: SessionAttachmentHistoryItem[];
|
||||||
/** Restored wall-clock ms of the pane's last Enter (see `lastSubmitAt`). */
|
/** Restored wall-clock ms of the pane's last Enter (see `lastSubmitAt`). */
|
||||||
lastSubmitAt?: number;
|
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 execution metadata for sessions launched through SSH inside local tmux. */
|
||||||
remote?: SessionRemote;
|
remote?: SessionRemote;
|
||||||
/** Docker execution metadata for sessions launched inside a container via local tmux. */
|
/** 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
|
// NOW, not `createdAt`: recovery passes the ORIGINAL creation time of a
|
||||||
// days-old tmux session, and seeding last-activity from it would report 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
|
// 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
|
// confirmation reads as "already quiet". For a genuinely new session the
|
||||||
// idle duration. For a genuinely new session the two are the same instant.
|
// two are the same instant.
|
||||||
this._lastActivityAt = Date.now();
|
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.
|
// Set claudeSessionId — when resuming, the Claude conversation ID is the resumed one.
|
||||||
this._claudeSessionId = config.resumeSessionId || this.id;
|
this._claudeSessionId = config.resumeSessionId || this.id;
|
||||||
// Restored from state.json on boot recovery. start() resets _claudeSessionId
|
// Restored from state.json on boot recovery. start() resets _claudeSessionId
|
||||||
@@ -794,7 +819,21 @@ export class Session extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get lastActivityAt(): number {
|
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 {
|
get claudeSessionId(): string | null {
|
||||||
@@ -1219,7 +1258,9 @@ export class Session extends EventEmitter {
|
|||||||
parentSessionId: this._parentSessionId,
|
parentSessionId: this._parentSessionId,
|
||||||
currentTaskId: this._currentTaskId,
|
currentTaskId: this._currentTaskId,
|
||||||
createdAt: this.createdAt,
|
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,
|
name: this._name,
|
||||||
mode: this.mode,
|
mode: this.mode,
|
||||||
autoClearEnabled: this._autoOps.autoClearEnabled,
|
autoClearEnabled: this._autoOps.autoClearEnabled,
|
||||||
@@ -1585,7 +1626,7 @@ export class Session extends EventEmitter {
|
|||||||
|
|
||||||
// BufferAccumulator handles auto-trimming when max size exceeded
|
// BufferAccumulator handles auto-trimming when max size exceeded
|
||||||
this._terminalBuffer.append(data);
|
this._terminalBuffer.append(data);
|
||||||
this._lastActivityAt = Date.now();
|
this._markActivity();
|
||||||
this.emit('terminal', data);
|
this.emit('terminal', data);
|
||||||
this.emit('output', data);
|
this.emit('output', data);
|
||||||
}
|
}
|
||||||
@@ -2484,7 +2525,7 @@ export class Session extends EventEmitter {
|
|||||||
this._messages = [];
|
this._messages = [];
|
||||||
this._lineBuffer = '';
|
this._lineBuffer = '';
|
||||||
this._altScreenSeqCarry = '';
|
this._altScreenSeqCarry = '';
|
||||||
this._lastActivityAt = Date.now();
|
this._markActivity(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
private _clearAllTimers(): void {
|
private _clearAllTimers(): void {
|
||||||
@@ -3083,7 +3124,7 @@ export class Session extends EventEmitter {
|
|||||||
// Legacy method for sending input - wraps runPrompt
|
// Legacy method for sending input - wraps runPrompt
|
||||||
async sendInput(input: string): Promise<void> {
|
async sendInput(input: string): Promise<void> {
|
||||||
this._status = 'busy';
|
this._status = 'busy';
|
||||||
this._lastActivityAt = Date.now();
|
this._markActivity(true);
|
||||||
this.runPrompt(input).catch((err) => {
|
this.runPrompt(input).catch((err) => {
|
||||||
const errorMsg = getErrorMessage(err);
|
const errorMsg = getErrorMessage(err);
|
||||||
// Clean up task state so the task queue doesn't get stuck
|
// 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;
|
const taskId = this._currentTaskId;
|
||||||
this._currentTaskId = null;
|
this._currentTaskId = null;
|
||||||
this._status = 'idle';
|
this._status = 'idle';
|
||||||
this._lastActivityAt = Date.now();
|
this._markActivity(true);
|
||||||
this.emit('taskError', taskId, errorMsg);
|
this.emit('taskError', taskId, errorMsg);
|
||||||
} else {
|
} else {
|
||||||
this._status = 'idle';
|
this._status = 'idle';
|
||||||
@@ -3252,13 +3293,13 @@ export class Session extends EventEmitter {
|
|||||||
this._textOutput.clear();
|
this._textOutput.clear();
|
||||||
this._errorBuffer = '';
|
this._errorBuffer = '';
|
||||||
this._messages = [];
|
this._messages = [];
|
||||||
this._lastActivityAt = Date.now();
|
this._markActivity(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
clearTask(): void {
|
clearTask(): void {
|
||||||
this._currentTaskId = null;
|
this._currentTaskId = null;
|
||||||
this._status = 'idle';
|
this._status = 'idle';
|
||||||
this._lastActivityAt = Date.now();
|
this._markActivity(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
getOutput(): string {
|
getOutput(): string {
|
||||||
|
|||||||
@@ -1116,12 +1116,17 @@ class CodemanApp {
|
|||||||
if (digitMatch) {
|
if (digitMatch) {
|
||||||
const idx = parseInt(digitMatch[1], 10) - 1;
|
const idx = parseInt(digitMatch[1], 10) - 1;
|
||||||
// Sessions occupy 1..N and web tabs continue from N+1, matching the
|
// Sessions occupy 1..N and web tabs continue from N+1, matching the
|
||||||
// numbers actually painted on the tabs.
|
// numbers actually painted on the tabs. Resolve through the same
|
||||||
if (idx < this.sessionOrder.length) {
|
// 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();
|
e.preventDefault();
|
||||||
this.selectSession(this.sessionOrder[idx]);
|
this.selectSession(live[idx]);
|
||||||
} else {
|
} else {
|
||||||
const webIdx = idx - this.sessionOrder.length;
|
const webIdx = idx - live.length;
|
||||||
const webId = (this.webviewOrder || [])[webIdx];
|
const webId = (this.webviewOrder || [])[webIdx];
|
||||||
if (webId) {
|
if (webId) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@@ -118,14 +118,16 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
* A WORKING pane is the opposite: it repaints about once a second, so its
|
* 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
|
* 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
|
* 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
|
* persisted server-side and therefore survives a Codeman restart. A working
|
||||||
* that has never submitted has no anchor at all, and gets no stamp rather than
|
* session with NO submit stamp falls back to `lastActivityAt`, because that is
|
||||||
* a made-up one.
|
* 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}
|
* @returns {{key: string, at: number}|null}
|
||||||
*/
|
*/
|
||||||
_mobileOverviewSince(state, session) {
|
_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;
|
if (!at) return null;
|
||||||
return { key: MOBILE_OVERVIEW_SINCE_LABEL[state] || state, at };
|
return { key: MOBILE_OVERVIEW_SINCE_LABEL[state] || state, at };
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -148,6 +148,11 @@ export function registerHookEventRoutes(
|
|||||||
...safeData,
|
...safeData,
|
||||||
...(approvalId && { approvalId }),
|
...(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
|
// Send push notifications for hook events
|
||||||
ctx.sendPushNotifications(`hook:${event}`, {
|
ctx.sendPushNotifications(`hook:${event}`, {
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ import {
|
|||||||
applyAgentSkill,
|
applyAgentSkill,
|
||||||
refreshUserAgentSkill,
|
refreshUserAgentSkill,
|
||||||
seedAgentSessionPreamble,
|
seedAgentSessionPreamble,
|
||||||
ensureCodemanHooks,
|
applyWorkspaceHooks,
|
||||||
refreshStaleCodemanHooks,
|
refreshStaleCodemanHooks,
|
||||||
} from '../../hooks-config.js';
|
} from '../../hooks-config.js';
|
||||||
import { generateClaudeMd } from '../../templates/claude-md.js';
|
import { generateClaudeMd } from '../../templates/claude-md.js';
|
||||||
@@ -626,31 +626,12 @@ async function injectAgentSkill(casePath: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Workspace hooks: the install-vs-refresh decision core moved to
|
||||||
* Hooks for the workspace a Claude session is about to run in. ONE decision point,
|
// `applyWorkspaceHooks` in hooks-config.ts (imported above) so the non-route
|
||||||
* shared by every create path, so the setting cannot apply to some of them only.
|
// claude create paths — cron fires, legacy scheduled runs, the plan-orchestrator
|
||||||
*
|
// one-shots, the boot recovery sweep — share the SAME decision instead of
|
||||||
* ON (`workspaceHooksEnabled`, the default): INSTALL Codeman's hooks block, merging
|
// bypassing the `workspaceHooksEnabled` setting. Route handlers here resolve the
|
||||||
* so a user's own hook entries and every other settings key survive. Hooks were
|
// setting through the ConfigPort (tests stub it) and pass it as the second arg.
|
||||||
* 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(
|
export function registerSessionRoutes(
|
||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
@@ -788,7 +769,14 @@ export function registerSessionRoutes(
|
|||||||
// chip's data feed for everyone. The exporter is benign when the chip is off
|
// 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
|
// (the footer just shows session status). isOurs-guarded so a user's own
|
||||||
// statusLine is never touched.
|
// 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);
|
await applyStatusLineConfig(workingDir, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -799,7 +787,7 @@ export function registerSessionRoutes(
|
|||||||
// process-cwd fallback is $HOME under installer-created services, and hooks
|
// process-cwd fallback is $HOME under installer-created services, and hooks
|
||||||
// materializing in ~/.claude/settings.local.json was never asked for.
|
// materializing in ~/.claude/settings.local.json was never asked for.
|
||||||
if (!remote && body.workingDir && (body.mode ?? 'claude') === 'claude') {
|
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-
|
// 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
|
// .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
|
// 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
|
// of its own. Skipped for remote cases — resolvedCasePath is a REMOTE path that
|
||||||
// doesn't exist on the local filesystem.
|
// doesn't exist on the local filesystem.
|
||||||
if (mode === 'claude') {
|
if (mode === 'claude') {
|
||||||
await applyWorkspaceHooks(ctx, resolvedCasePath);
|
await applyWorkspaceHooks(resolvedCasePath, await ctx.getWorkspaceHooksEnabled());
|
||||||
} else {
|
} else {
|
||||||
await refreshStaleCodemanHooks(resolvedCasePath).catch(() => {});
|
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.
|
// 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
|
// 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
|
// hook-idle detection fire (decision: wire hooks now). Never clobbers an existing
|
||||||
// configured project. Skipped for external CLIs (they use their own systems).
|
// configured project. Claude mode ONLY — only claude reads `.claude` hooks, so a
|
||||||
if (
|
// shell or external-CLI quick-start must not author a block of its own (the same
|
||||||
docker &&
|
// rule the existing-case branch above states; this branch used to exclude just
|
||||||
docker.hooksEnabled &&
|
// the five external CLIs and let `shell` through).
|
||||||
mode !== 'opencode' &&
|
if (docker && docker.hooksEnabled && mode === 'claude') {
|
||||||
mode !== 'codex' &&
|
|
||||||
mode !== 'gemini' &&
|
|
||||||
mode !== 'antigravity' &&
|
|
||||||
mode !== 'pi'
|
|
||||||
) {
|
|
||||||
try {
|
try {
|
||||||
if (!existsSync(join(resolvedCasePath, 'CLAUDE.md'))) {
|
if (!existsSync(join(resolvedCasePath, 'CLAUDE.md'))) {
|
||||||
const templatePath = await ctx.getDefaultClaudeMdPath();
|
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
|
// 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
|
// linked case. This branch is already gated on `docker.hooksEnabled`, and
|
||||||
// applyWorkspaceHooks adds the user-level gate on top.
|
// applyWorkspaceHooks adds the user-level gate on top.
|
||||||
await applyWorkspaceHooks(ctx, resolvedCasePath);
|
await applyWorkspaceHooks(resolvedCasePath, await ctx.getWorkspaceHooksEnabled());
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* non-fatal — the session still runs, hooks may be degraded */
|
/* non-fatal — the session still runs, hooks may be degraded */
|
||||||
|
|||||||
+22
-2
@@ -76,7 +76,7 @@ import { RunSummaryTracker } from '../run-summary.js';
|
|||||||
import { PlanOrchestrator } from '../plan-orchestrator.js';
|
import { PlanOrchestrator } from '../plan-orchestrator.js';
|
||||||
import { OrchestratorLoop } from '../orchestrator-loop.js';
|
import { OrchestratorLoop } from '../orchestrator-loop.js';
|
||||||
import { getLifecycleLog } from '../session-lifecycle-log.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 { PushSubscriptionStore } from '../push-store.js';
|
||||||
import webpush from 'web-push';
|
import webpush from 'web-push';
|
||||||
import { SseStreamManager } from './sse-stream-manager.js';
|
import { SseStreamManager } from './sse-stream-manager.js';
|
||||||
@@ -1819,6 +1819,14 @@ export class WebServer extends EventEmitter {
|
|||||||
|
|
||||||
let session: Session | null = null;
|
let session: Session | null = null;
|
||||||
try {
|
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.
|
// Create a session for this iteration.
|
||||||
if (isMultiUserMode()) {
|
if (isMultiUserMode()) {
|
||||||
// §6.3: resolve the permission mode with the RUN OWNER (a non-granted user
|
// §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 launch conversation until the user types again, even though
|
||||||
// the re-attached CLI is on a post-`/clear` one.
|
// the re-attached CLI is on a post-`/clear` one.
|
||||||
lastSubmitAt: savedState?.lastSubmitAt,
|
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
|
// Remote SSH metadata must round-trip on recovery: without it the
|
||||||
// attach cwd falls back to the (nonexistent-locally) remote path and
|
// attach cwd falls back to the (nonexistent-locally) remote path and
|
||||||
// respawn rebuilds a LOCAL command, breaking the pane and silently
|
// 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
|
* 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
|
* user can keep Codeman out of their repos, and a boot-time sweep is the last
|
||||||
* place that should ignore it.
|
* 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> {
|
private async ensureHooksForRecoveredWorkspaces(): Promise<void> {
|
||||||
if (!(await this.getWorkspaceHooksEnabled())) return;
|
if (!(await this.getWorkspaceHooksEnabled())) return;
|
||||||
@@ -2891,7 +2909,9 @@ export class WebServer extends EventEmitter {
|
|||||||
if (session.workingDir) workspaces.add(session.workingDir);
|
if (session.workingDir) workspaces.add(session.workingDir);
|
||||||
}
|
}
|
||||||
for (const workspace of workspaces) {
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -270,3 +270,75 @@ describe('home sessions column: wiring', () => {
|
|||||||
expect(html.indexOf('src="mobile-overview.js"')).toBeGreaterThan(html.indexOf('src="constants.js"'));
|
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]');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -277,15 +277,28 @@ describe('mobile overview model', () => {
|
|||||||
expect(rows.i.createdAt).toBe(now - 7200_000);
|
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 app = loadOverviewApp();
|
||||||
const model = app.buildMobileOverviewModel({
|
const model = app.buildMobileOverviewModel({
|
||||||
// A session that has never submitted has no turn start to measure from.
|
sessions: [session({ id: 'w', status: 'busy', lastActivityAt: now })],
|
||||||
sessions: [session({ id: 'w', status: 'busy', lastActivityAt: Date.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,
|
cases: CASES,
|
||||||
});
|
});
|
||||||
expect(model.current[0].since).toBeNull();
|
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', () => {
|
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.
|
* Asserts bytes on disk (the real `ensureCodemanHooks`), not a spy call.
|
||||||
* Uses app.inject(), so no real HTTP port is needed.
|
* 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';
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
@@ -22,8 +29,9 @@ import { tmpdir } from 'node:os';
|
|||||||
import { createMockRouteContext } from '../mocks/index.js';
|
import { createMockRouteContext } from '../mocks/index.js';
|
||||||
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
||||||
import { registerSessionRoutes } from '../../src/web/routes/session-routes.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 { getDataDir } from '../../src/config/instance.js';
|
||||||
|
import { CASES_DIR } from '../../src/web/route-helpers.js';
|
||||||
|
|
||||||
interface HooksFile {
|
interface HooksFile {
|
||||||
hooks?: Record<string, Array<{ matcher?: string; hooks?: Array<{ command?: string }> }>>;
|
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 () => {
|
it('leaves the server cwd alone when workingDir is omitted', async () => {
|
||||||
// workingDir falls back to process.cwd(), which is $HOME under installer-created
|
// 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 cwdSettings = join(process.cwd(), '.claude', 'settings.local.json');
|
||||||
const before = existsSync(cwdSettings) ? await readFile(cwdSettings, 'utf-8') : null;
|
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;
|
const after = existsSync(cwdSettings) ? await readFile(cwdSettings, 'utf-8') : null;
|
||||||
expect(after).toBe(before);
|
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 () => {
|
it('never writes hooks for a remote attach (workingDir is a user@host pseudo-path)', async () => {
|
||||||
// A claude-mode attachRemoteSession create overwrites workingDir with
|
// A claude-mode attachRemoteSession create overwrites workingDir with
|
||||||
// `user@host:session` — locally a RELATIVE path, so a mkdir would create it
|
// `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 mkdir(getDataDir(), { recursive: true });
|
||||||
await writeFile(
|
await writeFile(
|
||||||
join(getDataDir(), 'remote-hosts.json'),
|
join(getDataDir(), 'remote-hosts.json'),
|
||||||
@@ -164,6 +176,7 @@ describe('POST /api/sessions workspace hooks', () => {
|
|||||||
const res = await createSession({
|
const res = await createSession({
|
||||||
name: 'hooks-remote',
|
name: 'hooks-remote',
|
||||||
mode: 'claude',
|
mode: 'claude',
|
||||||
|
statusLineTelemetry: true,
|
||||||
attachRemoteSession: { hostId: 'h1', remoteSessionName: 'codeman-ssh-abc123' },
|
attachRemoteSession: { hostId: 'h1', remoteSessionName: 'codeman-ssh-abc123' },
|
||||||
});
|
});
|
||||||
expect(res.statusCode).toBe(200);
|
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());
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -246,3 +246,45 @@ describe('Session interactive idle detection', () => {
|
|||||||
expect(events).toEqual([]);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user