chore: version packages

Red tab alerts track the dialog, not the keyboard: typing no longer clears them, and a dialog answered in the terminal resolves itself on the next listing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-08-17 01:46:48 +02:00
parent f4ba4d2cb1
commit f60bf93c99
10 changed files with 92 additions and 11 deletions
+10
View File
@@ -1,5 +1,15 @@
# aicodeman
## 1.19.3
### Patch Changes
- Red "needs you" tab alerts now follow the dialog instead of the keyboard.
Typing in the terminal no longer clears a red alert. It used to clear every pending alert on the device you typed on, but a permission or question dialog ignores keystrokes that are not one of its options, so the dialog was still open and still blocking: the other devices stayed red and a reload brought the red back on the first one. Input now spends the yellow idle alert only, and it does that through the server-side acknowledgement added in 1.19.2, so the clear is durable and reaches every device.
A dialog answered in the terminal now clears by itself. Claude Code fires no "permission answered" hook, so the item stayed pending until the whole turn ended, and any page load in between re-armed a red alert for a dialog that was long gone. Listing approvals now re-captures the pane and resolves items whose dialog is no longer on screen, using the same conservative check the answer path already uses: only an item whose original frame parsed numbered options can be dropped this way, so an unreadable capture keeps the alert rather than losing a live one. Measured against a real AskUserQuestion dialog: the stale item cleared 5 seconds ahead of the stop hook that used to be the only signal, while a dialog still on screen survived 11 consecutive listings over 55 seconds untouched.
## 1.19.2
### Patch 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.2 (must match `package.json`)
**Version**: 1.19.3 (must match `package.json`)
## Project Overview
@@ -210,7 +210,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**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). ⚠️ **Viewing a session ACKNOWLEDGES its idle item, it does not resolve it** (`POST /api/approvals/session/:sessionId/viewed` → `acknowledgedAt` → `approval:updated`): the item stays pending (still answerable, still Read My Mind context) and only stops arming the yellow tab alert. That flag is what makes the clear durable, since the view-clears-idle rule used to live in one browser's memory and `seedApprovals()` re-armed the alert on the next reload while other devices never heard about it at all; the local half is `markIdleAlertSeen()` (app.js), called from BOTH `selectSession` paths, including the already-active early return, where a click could otherwise never clear the alert. Idle-only by construction (`acknowledge()` defaults to `['idle']`): looking at a permission/question dialog does not answer it. 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). ⚠️ **Viewing a session ACKNOWLEDGES its idle item, it does not resolve it** (`POST /api/approvals/session/:sessionId/viewed` → `acknowledgedAt` → `approval:updated`): the item stays pending (still answerable, still Read My Mind context) and only stops arming the yellow tab alert. That flag is what makes the clear durable, since the view-clears-idle rule used to live in one browser's memory and `seedApprovals()` re-armed the alert on the next reload while other devices never heard about it at all; the local half is `markIdleAlertSeen()` (app.js), called from BOTH `selectSession` paths, including the already-active early return, where a click could otherwise never clear the alert. Idle-only by construction (`acknowledge()` defaults to `['idle']`): looking at a permission/question dialog does not answer it. ⚠️ Same rule on the input path: `_ackDelivery` (app.js) spends the IDLE alert only, via that same `markIdleAlertSeen()`. It used to `clearPendingHooks(sessionId)` with no kind, so one keystroke wiped a RED alert on that device while the dialog was still up, the other devices stayed red, and a reload re-seeded it. ⚠️ Claude Code fires no "permission answered" hook (only `elicitation_complete`/`elicitation_response`, i.e. the question flavor), so an answered-in-the-terminal dialog would otherwise sit pending until `stop`: `GET /api/approvals` therefore runs a **staleness sweep** over the caller's own items via `verifyStillAnswerable()`, which is deliberately the conservative check the answer path uses (only an item whose ORIGINAL frame parsed options can be dropped, so an unreadable capture keeps the alert rather than losing a live one). 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`.
**Read My Mind intent profiles** (phase 1 of `docs/readmymind-plan.md`; `readMyMindEnabled`, SYNCED, default OFF): per-CASE profiles (user-stated `goals` + the user's recent real prompts), keyed by owner + realpath(workingDir) so they survive `/clear`/respawns and multi-user scoping is structural. Capture rides the transcript (`transcript:user_prompt` from `transcript-watcher.ts`), NOT the input paths: `POST /input` sees only programmatic prompts and the WS channel is raw keystrokes. The listener lives inside `startTranscriptWatcher()`'s `if (!watcher)` block (outside it would duplicate per hook event) and is claude-only + gated on the setting per event. Store: `src/intent-store.ts` singleton, `intents.json` written 0600 tmp+rename (prompts can contain secrets; never fed to `/api/search`). Endpoints: GET/PUT/DELETE `/api/sessions/:id/intent` + POST `/api/sessions/:id/readmymind` (`readmymind-routes.ts`, ownership via `findSessionOrFail` WITH `req`; registrations stay the bare `app.<method>('path')` shape, the endpoints.md drift scanner cannot see generics). **Phase 2 (predictor + 🧠 button)**: `readmymind-context.ts` is the PURE budgeted assembler (9 ranked sources, drop order siblings→away→workspace→tools, sections 1-4 truncate only); IO lives in `readmymind-collectors.ts` (transcript TAIL read — the live watcher keeps only a 500-char snippet — + git signals, skipped for remote-SSH cases) and the route; `readmymind-predictor.ts` reuses the AiCheckerBase spawn mechanics standalone (verdict-shaped base vs freeform JSON) as a mutable singleton routes call and tests stub. Claude-mode only (400), one in flight per session (409 CONFLICT), model = `readMyMindModel` setting defaulting to `AI_CHECK_MODEL` (opus, decided). Frontend `readmymind-ui.js`: header 🧠 marker-hidden (`btn-readmymind--hidden`) until the setting is ON; phones hide it in mobile.css and get a keyboard-accessory 🧠 key instead (ships in BOTH bar templates, revealed by the `rmm-enabled` class on the BAR element — setMode() rebuilds button innerHTML, so per-key state would be wiped; synced at init + every `applyHeaderVisibilitySettings()`). Alternate suggestions render as tappable rows that swap into the editable field without losing edits; Rethink rejects the whole shown set and carries the optional steer note (`#readMyMindSteer`, sent as `steer`, shown in ready + empty-result phases, cleared on each open). Suggestions render via value/`textContent` ONLY and Send/Insert go through `POST /input` (server-side, so the sendEnterKey/local-echo trap does not apply) — nothing auto-sends, ever. User guide: `docs/readmymind.md`.
+6 -1
View File
@@ -446,7 +446,12 @@ Design: [`approvals-inbox-plan.md`](approvals-inbox-plan.md).
acknowledgedAt? }`. `context` is the ANSI-stripped visible pane frame;
`options` is present only when the dialog's numbered choices parsed
confidently; `acknowledgedAt` marks an item a human has already looked at
(see `/viewed` below) and tells clients not to re-arm its tab alert.
(see `/viewed` below) and tells clients not to re-arm its tab alert. Listing
also runs a staleness sweep over the caller's own items: the pane is
re-captured, and an item whose dialog no longer parses is resolved as
`resolved_in_terminal` instead of being returned (only items whose original
frame parsed `options` can be dropped this way, so an unreadable capture
keeps the item).
- `POST /api/v1/approvals/:id/answer` with `{ action: 'approve' }` (sends the
digit `1`), `{ action: 'deny' }` (sends Esc), `{ action: 'option', option: n }`
(sends the digit; accepted only when `n` is among the item's parsed
+1 -1
View File
@@ -60,7 +60,7 @@ Module-level singleton in the style of `session-wait-registry.ts` (pure, no `Ses
Normal authed API (NOT the hook-secret bypass), `ApiResponse` envelope, Zod schemas in `schemas.ts`:
- `GET /api/approvals` → pending items, multi-user filtered by `canAccessOwned` (same policy as session lists).
- `GET /api/approvals` → pending items, multi-user filtered by `canAccessOwned` (same policy as session lists). Also sweeps the caller's own items for staleness through `verifyStillAnswerable()`: Claude Code fires no "permission answered" hook, so a dialog answered in the terminal used to sit pending until `stop` and re-arm a red tab alert on the next page load. Only items whose original frame parsed options can be dropped this way, so an unreadable capture keeps the alert.
- `POST /api/approvals/:id/answer` body `{ action: 'approve' | 'deny' | 'option' | 'text', option?, text? }`:
- `approve` → `writeViaMux('1')` (option 1 is always plain Yes; no Enter, menus react to the digit).
- `deny` → `writeViaMux('\x1b')` (Esc is the official No/cancel; precedent: auto-resume sends Esc the same way).
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "aicodeman",
"version": "1.19.2",
"version": "1.19.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
"version": "1.19.2",
"version": "1.19.3",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "1.19.2",
"version": "1.19.3",
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module",
"main": "dist/index.js",
+11 -2
View File
@@ -858,7 +858,9 @@ class CodemanApp {
* is on screen, and looking at one does not answer it.
*/
markIdleAlertSeen(sessionId) {
if (!this.pendingHooks.get(sessionId)?.has('idle_prompt')) return;
// `pendingHooks?` because _ackDelivery calls this from the input hot path,
// which partial app instances (the vm-loaded delivery tests) also drive.
if (!this.pendingHooks?.get(sessionId)?.has('idle_prompt')) return;
this.clearPendingHooks(sessionId, 'idle_prompt');
this.acknowledgeIdleApprovalOnView?.(sessionId);
}
@@ -2957,7 +2959,14 @@ class CodemanApp {
this._updateConnectionIndicator();
}
}
this.clearPendingHooks?.(sessionId);
// ⚠️ IDLE ONLY, and acknowledged server-side rather than cleared in memory.
// Delivering input answers "Claude is waiting for a prompt" by definition,
// so this is the same "I am on it" signal as opening the tab. It does NOT
// answer a permission/question dialog: those ignore any keystroke that is
// not one of their options, so the dialog is still up and still needs you.
// Clearing action alerts here hid a LIVE alert on this device alone (the
// other devices stayed red and a reload re-seeded it straight back).
this.markIdleAlertSeen?.(sessionId);
}
/** Server input-ACK frame ({t:'ia',seq}) over the WebSocket. */
+14 -2
View File
@@ -3,7 +3,9 @@
*
* The cross-session queue of prompts waiting on a human (see
* web/approval-inbox.ts, docs/approvals-inbox-plan.md):
* - `GET /api/approvals`: pending items, ownership-scoped in multi-user mode
* - `GET /api/approvals`: pending items, ownership-scoped in multi-user mode,
* with a pane-capture staleness sweep (a dialog answered in the terminal is
* resolved here rather than re-arming a tab alert on the next page load)
* - `POST /api/approvals/:id/answer`: answer in place by sending the
* corresponding keystrokes to the session (digit / Esc / idle-prompt text)
* - `POST /api/approvals/:id/dismiss`: drop the item without keystrokes
@@ -72,7 +74,17 @@ export function registerApprovalRoutes(app: FastifyInstance, ctx: SessionPort):
approvalInbox.resolveForSession(item.sessionId, 'session_ended');
return false;
}
return canAccessOwned(user, session.owner);
if (!canAccessOwned(user, session.owner)) return false;
// Staleness sweep, on the caller's own items only. Claude Code fires no
// "permission answered" hook, so a dialog answered IN the terminal leaves
// its item pending until `stop`, and this list is what re-arms tab alerts
// on every page load: a red "needs you" would come back for a dialog that
// is long gone. The pane is the truth, so ask it, using the SAME
// conservative rule the answer path uses (`verifyStillAnswerable`): only
// an item whose original frame parsed options can be resolved this way, so
// an unreadable capture keeps the alert rather than dropping it. Resolving
// here broadcasts `approval:resolved`, so the other devices clear too.
return approvalInbox.verifyStillAnswerable(item.id);
});
return { success: true, data: { approvals } };
});
+3
View File
@@ -78,6 +78,9 @@ function makeApp(): App {
app._persistReliableNow = vi.fn();
app._updateConnectionIndicator = vi.fn();
app.clearPendingHooks = vi.fn();
// _ackDelivery spends a pending IDLE alert through markIdleAlertSeen, which
// reads this map; without it the real prototype method throws on every ACK.
app.pendingHooks = new Map();
app.activeSessionId = 'session-1';
app.isOnline = true;
app._connectionStatus = 'connected';
+42
View File
@@ -283,6 +283,48 @@ describe('approval routes', () => {
expect(await listApprovals(harness)).toHaveLength(0);
});
describe('staleness sweep on GET /api/approvals', () => {
it('resolves an item whose dialog left the pane, and tells the other clients', async () => {
const resolved: Array<Record<string, unknown>> = [];
await postHook(harness, 'permission_prompt', { tool_name: 'Bash' });
expect((await listApprovals(harness))[0].options).toHaveLength(3);
// Answered in the terminal: Claude Code fires no hook for that, so only
// the pane knows. The dialog is gone from the frame the next capture sees.
approvalInbox.onResolved = (info) => resolved.push({ ...info });
session.terminalBuffer = 'claude> back at the composer';
expect(await listApprovals(harness)).toHaveLength(0);
expect(resolved).toEqual([expect.objectContaining({ resolution: 'resolved_in_terminal' })]);
});
it('keeps an item whose dialog is still on screen', async () => {
await postHook(harness, 'permission_prompt', { tool_name: 'Bash' });
// Pane unchanged (PERMISSION_DIALOG): the human has not answered yet.
expect(await listApprovals(harness)).toHaveLength(1);
expect(await listApprovals(harness)).toHaveLength(1);
});
it('never drops an item that could not be read in the first place', async () => {
// No parseable dialog at capture time, so a later "it does not parse" says
// nothing new. Conservative by design: an unreadable pane keeps the alert.
session.terminalBuffer = 'some output with no dialog in it';
await postHook(harness, 'permission_prompt', { tool_name: 'Bash' });
const [item] = await listApprovals(harness);
expect(item.options).toBeUndefined();
session.terminalBuffer = 'still nothing that parses';
expect(await listApprovals(harness)).toHaveLength(1);
});
it('leaves idle prompts alone (they are not dialogs)', async () => {
session.terminalBuffer = 'claude> waiting at the composer';
await postHook(harness, 'idle_prompt', {});
session.terminalBuffer = 'claude> still waiting, different frame';
const [item] = await listApprovals(harness);
expect(item.kind).toBe('idle');
});
});
it('viewing a session acknowledges its idle prompt (item stays pending) and broadcasts it', async () => {
session.terminalBuffer = 'claude> waiting at the composer';
await postHook(harness, 'idle_prompt', {});