Merge remote-tracking branch 'origin/master' into pr251-review-fixes

# Conflicts:
#	CLAUDE.md
This commit is contained in:
Codeman maintainer
2026-08-10 00:29:19 +02:00
77 changed files with 7775 additions and 169 deletions
+21 -3
View File
@@ -15,9 +15,10 @@
* - `updateCaseEnvVars(casePath, envVars)` — merges env vars into settings
*
* Hook events generated: `idle_prompt`, `permission_prompt`, `elicitation_dialog`,
* `stop`, `teammate_idle`, `task_completed`
* `elicitation_complete`, `elicitation_response`, `stop`, `teammate_idle`,
* `task_completed`
*
* Hook categories: `Notification` (3 matchers), `Stop` (1), `SubagentStop` (1),
* Hook categories: `Notification` (5 matchers), `Stop` (1), `SubagentStop` (1),
* `TeammateIdle` (1), `TaskCompleted` (1), `PostToolUse` (1 self-contained
* background Bash rewake)
*
@@ -390,6 +391,16 @@ export function generateHooksConfig(): { hooks: Record<string, unknown[]> } {
matcher: 'elicitation_dialog',
hooks: [{ type: 'command', command: curlCmd('elicitation_dialog'), timeout: HOOK_TIMEOUT_SECONDS }],
},
// The two dialog-closed notifications resolve Approvals Inbox items the
// moment a question is answered IN the terminal (long before `stop`).
{
matcher: 'elicitation_complete',
hooks: [{ type: 'command', command: curlCmd('elicitation_complete'), timeout: HOOK_TIMEOUT_SECONDS }],
},
{
matcher: 'elicitation_response',
hooks: [{ type: 'command', command: curlCmd('elicitation_response'), timeout: HOOK_TIMEOUT_SECONDS }],
},
],
Stop: [
{
@@ -712,7 +723,14 @@ export async function refreshStaleCodemanHooks(casePath: string): Promise<void>
// on a self-signed HTTPS install.
const hasTlsFlaglessCurl = hooksJson.includes('curl -s -X POST');
const hasSubagentStopGuard = hooksJson.includes(SUBAGENT_STOP_GUARD_MARKER);
if (!isOurs || (hasSecret && hasBackgroundWake && hasSubagentStopGuard && !hasTlsFlaglessCurl)) return;
// Approvals Inbox needs the elicitation_complete/elicitation_response
// matchers; their absence marks a pre-inbox hooks block.
const hasElicitationComplete = hooksJson.includes('elicitation_complete');
if (
!isOurs ||
(hasSecret && hasBackgroundWake && hasSubagentStopGuard && hasElicitationComplete && !hasTlsFlaglessCurl)
)
return;
const generated = generateHooksConfig();
const merged = {
...existing,
+233
View File
@@ -0,0 +1,233 @@
/**
* @fileoverview Read My Mind intent store: per-case profiles of user intent.
*
* Feeds the Read My Mind predictor (`docs/readmymind-plan.md`). Each profile
* pairs user/agent-stated `goals` with the user's recently captured prompts,
* keyed by owner + realpath(workingDir) so the profile survives `/clear`,
* respawns, and session churn, and so multi-user scoping is structural (two
* owners of the same directory get distinct profiles).
*
* Capture rides the session transcript (`transcript:user_prompt`), not the
* input paths: `POST /input` sees only programmatic prompts and the WS channel
* delivers raw keystrokes, so neither yields clean submitted prompts.
*
* Prompts can contain secrets, so the state file is written 0600 (same posture
* as `users.json`) and the store is never fed into `/api/search`.
*
* Pure helpers (`deriveIntentKey`, `sanitizePromptText`, `isCapturablePrompt`,
* `appendPrompt`) are exported for unit tests; the `IntentStore` class adds the
* IO. Writes are atomic (tmp + rename) and synchronous: mutations arrive at
* human prompting pace, so there is nothing to debounce and no timer to leak.
*/
import { createHash } from 'node:crypto';
import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from 'node:fs';
import { dirname } from 'node:path';
import { dataPath } from './config/instance.js';
import type { IntentProfile, IntentPromptEntry } from './types/index.js';
// ========== Limits ==========
/** Max stored profiles; lowest `updatedAt` is evicted first. */
export const MAX_INTENT_PROFILES = 200;
/** Max captured prompts per profile (FIFO). */
export const MAX_RECENT_PROMPTS = 50;
/** Max characters kept per captured prompt. */
export const MAX_PROMPT_CHARS = 500;
/** Max characters for the `goals` field. */
export const MAX_GOALS_CHARS = 8192;
/** Prompts shorter than this are menu digits / Esc artifacts, not intent. */
const MIN_PROMPT_CHARS = 3;
// ========== Pure helpers ==========
/** Stable per-case key: owner + resolved workingDir, hashed. */
export function deriveIntentKey(owner: string | undefined, workingDir: string): string {
return createHash('sha256')
.update(`${owner ?? ''}:${workingDir}`)
.digest('hex')
.slice(0, 16);
}
/**
* Transcript user entries that are not typed intent: local slash-command echo,
* hook/system wrappers, and interrupt markers.
*/
export function isCapturablePrompt(text: string): boolean {
if (text.includes('<command-name>') || text.includes('<local-command-stdout>')) return false;
if (text.startsWith('<system-reminder>')) return false;
if (text.startsWith('Caveat: The messages below')) return false;
if (text.startsWith('[Request interrupted')) return false;
return true;
}
/**
* Collapse a transcript prompt to a bounded single line, or null when it is
* too short to mean anything (menu digits, Esc artifacts).
*/
export function sanitizePromptText(raw: string): string | null {
const text = raw
.replace(/[\r\n]+/g, ' ')
// eslint-disable-next-line no-control-regex
.replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '')
.trim();
if (text.length < MIN_PROMPT_CHARS) return null;
return text.length > MAX_PROMPT_CHARS ? text.slice(0, MAX_PROMPT_CHARS) : text;
}
/**
* Fold one prompt into a profile: consecutive duplicates collapse (auto-resume
* "continue" spam), FIFO cap applies. Returns a new profile object.
*/
export function appendPrompt(profile: IntentProfile, entry: IntentPromptEntry): IntentProfile {
const last = profile.recentPrompts[profile.recentPrompts.length - 1];
if (last && last.text === entry.text) {
return { ...profile, updatedAt: entry.ts };
}
const recentPrompts = [...profile.recentPrompts, entry].slice(-MAX_RECENT_PROMPTS);
return { ...profile, recentPrompts, updatedAt: entry.ts };
}
// ========== Store ==========
interface IntentStoreFile {
version: 1;
profiles: IntentProfile[];
}
export class IntentStore {
private profiles: Map<string, IntentProfile> | null = null;
private get filePath(): string {
return dataPath('intents.json');
}
// ----- Public API -----
/**
* The profile for a session's case. Never persists on read: an absent
* profile returns an empty transient one (`updatedAt: 0`).
*/
getProfile(owner: string | undefined, workingDir: string): IntentProfile {
const dir = this.resolveDir(workingDir);
const key = deriveIntentKey(owner, dir);
return this.load().get(key) ?? this.emptyProfile(key, dir);
}
/**
* Capture one submitted prompt. Returns true when it was recorded (passed
* the capturability filter and sanitization).
*/
recordPrompt(
owner: string | undefined,
workingDir: string,
sessionId: string,
rawText: string,
ts: number = Date.now()
): boolean {
if (!isCapturablePrompt(rawText)) return false;
const text = sanitizePromptText(rawText);
if (text === null) return false;
const dir = this.resolveDir(workingDir);
const key = deriveIntentKey(owner, dir);
const profiles = this.load();
const profile = profiles.get(key) ?? this.emptyProfile(key, dir);
profiles.set(key, appendPrompt(profile, { ts, sessionId, text }));
this.evictOverflow(profiles);
this.persist();
return true;
}
/** Replace the goals text (bounded). Returns the updated profile. */
setGoals(owner: string | undefined, workingDir: string, goals: string): IntentProfile {
const dir = this.resolveDir(workingDir);
const key = deriveIntentKey(owner, dir);
const profiles = this.load();
const profile = profiles.get(key) ?? this.emptyProfile(key, dir);
const updated: IntentProfile = { ...profile, goals: goals.slice(0, MAX_GOALS_CHARS), updatedAt: Date.now() };
profiles.set(key, updated);
this.evictOverflow(profiles);
this.persist();
return updated;
}
/** Forget everything for a case. Returns true when a profile existed. */
deleteProfile(owner: string | undefined, workingDir: string): boolean {
const dir = this.resolveDir(workingDir);
const key = deriveIntentKey(owner, dir);
const profiles = this.load();
const existed = profiles.delete(key);
if (existed) this.persist();
return existed;
}
// ----- Internals -----
private emptyProfile(key: string, workingDir: string): IntentProfile {
return { key, workingDir, updatedAt: 0, goals: '', recentPrompts: [] };
}
private resolveDir(workingDir: string): string {
try {
return realpathSync(workingDir);
} catch {
return workingDir;
}
}
private load(): Map<string, IntentProfile> {
if (this.profiles) return this.profiles;
this.profiles = new Map();
try {
if (existsSync(this.filePath)) {
const parsed = JSON.parse(readFileSync(this.filePath, 'utf-8')) as IntentStoreFile;
if (parsed && Array.isArray(parsed.profiles)) {
for (const profile of parsed.profiles) {
if (profile && typeof profile.key === 'string') this.profiles.set(profile.key, profile);
}
}
}
} catch (err) {
console.warn(`[IntentStore] Failed to load ${this.filePath}, starting empty:`, err);
}
return this.profiles;
}
private evictOverflow(profiles: Map<string, IntentProfile>): void {
while (profiles.size > MAX_INTENT_PROFILES) {
let oldestKey: string | null = null;
let oldestAt = Infinity;
for (const [key, profile] of profiles) {
if (profile.updatedAt < oldestAt) {
oldestAt = profile.updatedAt;
oldestKey = key;
}
}
if (oldestKey === null) return;
profiles.delete(oldestKey);
}
}
private persist(): void {
if (!this.profiles) return;
const file: IntentStoreFile = { version: 1, profiles: [...this.profiles.values()] };
const tmpPath = `${this.filePath}.tmp`;
try {
// dataPath()'s own mkdir is once-per-process; per-file test HOMEs need this.
mkdirSync(dirname(this.filePath), { recursive: true });
// 0600: captured prompts can contain secrets (same posture as users.json).
writeFileSync(tmpPath, JSON.stringify(file, null, 2), { mode: 0o600 });
renameSync(tmpPath, this.filePath);
} catch (err) {
console.warn(`[IntentStore] Failed to persist ${this.filePath}:`, err);
}
}
}
/** Module-level singleton, same pattern as `approvalInbox` (web/approval-inbox.ts). */
export const intentStore = new IntentStore();
+11
View File
@@ -97,6 +97,8 @@ export interface RespawnPaneOptions {
sessionId: string;
workingDir: string;
mode: SessionMode;
/** Session display name; a respawned claude keeps its `--name` peer name (version-gated, local only). */
name?: string;
niceConfig?: NiceConfig;
model?: string;
claudeMode?: ClaudeMode;
@@ -274,4 +276,13 @@ export interface TerminalMultiplexer extends EventEmitter {
* Pass `{ fullHistory: true }` to capture the entire scrollback (COD-47).
*/
captureActivePaneBuffer?(muxName: string, opts?: PaneCaptureOptions): string | null;
/**
* Plain text of the visible frame: no styles, no cursor query, no repaint
* reconstruction. Deliberately cheaper than `capturePaneBuffer` because idle
* detection calls it on a timer: it only needs to read what the CLI is
* currently rendering, never to replay it into an xterm. Returns null when the
* pane cannot be read.
*/
capturePaneText?(muxName: string, paneTarget?: string): string | null;
}
+7 -2
View File
@@ -8,7 +8,7 @@
* @module respawn-patterns
*/
import { TOKEN_PATTERN } from './utils/index.js';
import { TOKEN_PATTERN, CLAUDE_WORKING_LINE_PATTERN } from './utils/index.js';
// ========== Constants ==========
@@ -108,7 +108,12 @@ export function isCompletionMessage(data: string): boolean {
* @returns True if any working pattern is found in the window
*/
export function hasWorkingPattern(window: string): boolean {
return WORKING_PATTERNS.some((pattern) => window.includes(pattern));
// Current Claude randomizes the gerund ("Actualizing…", "Finagling…"), so the
// list above catches only a fraction of turns. The live status line's own shape
// (`… (13m 23s · ↓ 47.5k tokens)`) is what identifies the rest. Kept as an
// extra signal rather than a replacement: this window is RAW terminal data, and
// a partial repaint can split the line across chunks.
return CLAUDE_WORKING_LINE_PATTERN.test(window) || WORKING_PATTERNS.some((pattern) => window.includes(pattern));
}
/**
+93
View File
@@ -0,0 +1,93 @@
/**
* @fileoverview Pure working/idle heuristics for a Claude interactive pane.
*
* Split out of `session.ts` so the thresholds and the state math are unit
* testable without a PTY (same reasoning as `session-order.ts` /
* `usage-limit-patterns.ts`).
*
* **Why activity and not the status line.** Claude Code's working indicator is
* `✻ Actualizing… (13m 23s · ↓ 47.5k tokens)`, where the glyph animates through
* `· ✢ ✳ ∗ ✻ ✽` and the gerund is randomized per turn. Neither the braille
* spinner (`SPINNER_PATTERN`) nor the old keyword list (`Thinking|Writing|
* Reading|Running`) matches any of that, so the pane looked idle for a whole
* turn. Matching the new line does not rescue the stream either: tmux ships
* PARTIAL repaints, so measured on a live worker the complete line reached the
* PTY roughly once every 20 seconds, while the composer's `❯` (which is what
* ARMS idle detection) arrived every single second.
*
* What is left is the one thing measured to separate the two states cleanly: a
* working pane repaints, an idle pane emits nothing at all. Sampled once per
* second for 12s across six live sessions, the two working ones produced output
* in 12/12 windows and the four idle ones in 0/12.
*/
/**
* A gap longer than this ends a run of continuous output. Claude repaints at
* least once a second while working, so this leaves generous headroom.
*/
export const ACTIVITY_GAP_MS = 2000;
/**
* Continuous output for this long means the pane is working. Long enough that a
* one-off repaint (an update-check line, a rotating tip) cannot reach it.
*/
export const WORKING_STREAK_MS = 2000;
/**
* Silence for this long is what confirms the pane really went idle. Must stay
* above ACTIVITY_GAP_MS, or a pause between two repaints of one turn would
* read as the end of the turn.
*/
export const IDLE_SILENCE_MS = 2500;
/** How often a pending idle confirmation re-checks a pane that is still noisy. */
export const IDLE_RECHECK_MS = 500;
/**
* Floor between two pane probes for one session. The probe shells out to tmux,
* so this is what keeps a screenful of busy sessions from turning idle detection
* into a subprocess storm.
*/
export const PANE_PROBE_MIN_INTERVAL_MS = 1500;
/**
* How long to wait before looking again at a pane the probe just called working.
* Claude can sit silent for tens of seconds inside one tool call, so this is the
* cadence that carries a long quiet turn, so it is deliberately slow.
*/
export const PANE_PROBE_RECHECK_MS = 5000;
/** An unbroken run of PTY output. */
export interface ActivityStreak {
/** When this run began. */
startedAt: number;
/** The most recent chunk in it. */
lastAt: number;
}
/**
* Fold one output chunk into the current streak, starting a new one when the
* pane has been quiet longer than `gapMs`.
*/
export function trackActivityStreak(
streak: ActivityStreak | null,
now: number,
gapMs: number = ACTIVITY_GAP_MS
): ActivityStreak {
if (!streak || now - streak.lastAt > gapMs) return { startedAt: now, lastAt: now };
return { startedAt: streak.startedAt, lastAt: now };
}
/**
* True once a streak has been running long enough to mean work rather than a
* single repaint. Measured on the streak's own span (`lastAt - startedAt`), not
* against the caller's clock, so a stale streak cannot age into a true.
*/
export function isSustainedActivity(streak: ActivityStreak | null, streakMs: number = WORKING_STREAK_MS): boolean {
return !!streak && streak.lastAt - streak.startedAt >= streakMs;
}
/** True when the pane has produced nothing for long enough to call it idle. */
export function isPaneQuiet(lastActivityAt: number, now: number, silenceMs: number = IDLE_SILENCE_MS): boolean {
return now - lastActivityAt >= silenceMs;
}
+54 -1
View File
@@ -11,6 +11,7 @@
import type { ClaudeMode, EffortLevel } from './types.js';
import { isEffortLevel } from './types.js';
import { getAugmentedPath } from './utils/index.js';
import { compareVersions } from './utils/dependency-checker.js';
import { dataPath } from './config/instance.js';
/**
@@ -52,6 +53,53 @@ export function buildEffortCliArgs(effort?: EffortLevel): string[] {
return effort === 'ultracode' ? ['--settings', '{"ultracode":true}'] : ['--effort', effort];
}
/**
* Minimum Claude CLI version for passing `--name` at spawn. 2.1.224 is the release
* that ships cross-session messaging (the feature that makes the peer name matter),
* and the flag's presence at exactly this version was verified against the installed
* binary (`2.1.224 --help` lists `-n, --name`). The gate MUST stay fail-closed: an
* older or unknown CLI aborts startup on an unknown flag ("error: unknown option"),
* which would kill every session spawn: so no version means no flag, and the
* command line stays byte-identical to the pre-`--name` one.
*/
export const CLAUDE_NAME_FLAG_MIN_VERSION = '2.1.224';
/**
* Reduce a Codeman session name to a string safe to pass as the Claude CLI
* `--name` value. Allowlist, not escaping: keeps Unicode letters/digits (CJK
* session names survive) plus ` . _ : -`, which excludes every character that is
* special inside the double-quoted shell interpolation buildSpawnCommand uses
* (`"`, `$`, backslash, backtick) as well as newlines. Leading dashes/punctuation
* are stripped so the value can never be parsed as another CLI option, and the
* result is capped at 64 chars. Returns undefined when nothing safe remains;
* callers must then omit the flag entirely (never send `--name ""`).
*/
export function sanitizeCliSessionName(name?: string): string | undefined {
if (!name) return undefined;
const cleaned = name
.replace(/[^\p{L}\p{N} ._:-]/gu, '')
.replace(/\s+/g, ' ')
.replace(/^[\s._:-]+/, '')
.trim()
.slice(0, 64)
.trim();
return cleaned.length > 0 ? cleaned : undefined;
}
/**
* Build the `--name <session name>` args pair, version-gated and fail-closed.
* Returns [] unless the CLI version is KNOWN to support the flag (>= 2.1.224):
* a null/undefined version (probe failed, or running under vitest where
* getClaudeCliVersion() is hermetically null) yields [], keeping the spawn
* command identical to a Codeman without this feature. The name itself is a
* SOFT default, exactly like model and effort: `/rename` in-session still works.
*/
export function buildNameCliArgs(sessionName: string | undefined, cliVersion: string | null | undefined): string[] {
if (!cliVersion || compareVersions(cliVersion, CLAUDE_NAME_FLAG_MIN_VERSION) < 0) return [];
const name = sanitizeCliSessionName(sessionName);
return name ? ['--name', name] : [];
}
/**
* Build args for an interactive Claude CLI session (direct PTY, non-mux fallback).
*
@@ -60,6 +108,8 @@ export function buildEffortCliArgs(effort?: EffortLevel): string[] {
* @param model - Optional model override (e.g., 'opus', 'sonnet')
* @param allowedTools - Optional comma-separated allowed tools list
* @param effort - Optional effort level, injected via --settings (overridable in-session)
* @param sessionName - Optional Codeman session name, passed as `--name` (version-gated)
* @param cliVersion - Installed Claude CLI version for the `--name` gate (null = omit the flag)
* @returns Array of CLI arguments
*/
export function buildInteractiveArgs(
@@ -67,11 +117,14 @@ export function buildInteractiveArgs(
claudeMode: ClaudeMode,
model?: string,
allowedTools?: string,
effort?: EffortLevel
effort?: EffortLevel,
sessionName?: string,
cliVersion?: string | null
): string[] {
const args = [...buildPermissionArgs(claudeMode, allowedTools), '--session-id', sessionId];
if (model) args.push('--model', model);
args.push(...buildEffortCliArgs(effort));
args.push(...buildNameCliArgs(sessionName, cliVersion));
return args;
}
+92
View File
@@ -0,0 +1,92 @@
/**
* @fileoverview Recognizing Claude Code's workspace-trust dialog on screen.
*
* Claude asks once per directory before it will read or edit anything:
*
* Quick safety check: Is this a project you created or one you trust? ...
* ❯ 1. Yes, I trust this folder
* 2. No, exit
* Enter to confirm · Esc to cancel
*
* Codeman sessions run permission-skipping or classifier-guarded modes, so the
* answer is always yes, and a session parked on this dialog is simply stuck.
*
* **Why the text has to be compacted.** tmux repaints a row by writing each word
* and then a cursor-forward (`\x1b[C`) instead of a space, and Ink colours each
* word separately, so the wire carries `I\x1b[Ctrust\x1b[Cthis\x1b[Cfolder`.
* Stripping the escapes leaves `Itrustthisfolder`: the spaces are not there to
* strip, they were never sent. A plain `includes('trust this folder')` therefore
* never matched a single chunk, which is why the auto-accept had been silently
* dead. Removing ALL whitespace instead is what survives both that repaint style
* and the spaced full-screen redraw.
*
* **Why two markers are required.** Answering means pressing Enter, so a false
* positive types into a live session. One phrase is not enough: an agent's own
* transcript can quote it (this file does). Matching a trust phrase AND the
* dialog's confirm affordance is the cheap way to require the actual widget, and
* the caller adds the real guard by only looking during session startup.
*/
import { stripAnsi } from './utils/index.js';
/** Phrases from the question or the "yes" option, whitespace removed, lowercased. */
const TRUST_PHRASES = [
'trustthisfolder', // 2.x: "1. Yes, I trust this folder"
'trustthefiles', // older: "Do you trust the files in this folder?"
'oneyoutrust', // 2.x question: "a project you created or one you trust?"
];
/** The dialog's own affordances. Prose that quotes the question will not have these. */
const CONFIRM_PHRASES = ['entertoconfirm', 'esctocancel', '2.no,exit'];
/**
* Charset-select sequences (`ESC ( B`), which tmux emits around styled runs and
* `stripAnsi` does not cover. Left in, they would land inside a phrase as a
* literal `(B` and break the match.
*/
// eslint-disable-next-line no-control-regex
const CHARSET_SELECT = /\x1b[()][AB0]/g;
/**
* Normalize a screen or PTY chunk for phrase matching: escapes dropped, every
* whitespace run removed, lowercased.
*/
export function compactScreenText(text: string): string {
return stripAnsi(text).replace(CHARSET_SELECT, '').replace(/\s+/g, '').toLowerCase();
}
/**
* True when this text is the trust dialog rather than something merely talking
* about it. Feed the RENDERED SCREEN where possible: the session's terminal
* buffer is append-only, so the dialog stays in its tail long after it is gone.
*/
export function isTrustDialogScreen(text: string): boolean {
const compact = compactScreenText(text);
return TRUST_PHRASES.some((p) => compact.includes(p)) && CONFIRM_PHRASES.some((p) => compact.includes(p));
}
/**
* How long after the pane starts the dialog is still plausible. It renders
* before the main UI, so this only has to cover a slow first launch; leaving it
* open forever would let a transcript that quotes the dialog trigger an Enter.
*/
export const TRUST_DIALOG_WINDOW_MS = 90_000;
/** Minimum gap between two Enter presses, and between two screen reads. */
export const TRUST_DIALOG_RETRY_MS = 1500;
/**
* Attempts before giving up and leaving the dialog to the user. A keystroke can
* land while Ink is still mounting the widget and be dropped, which is the other
* half of why sessions got stuck here; retrying costs nothing, but retrying
* forever would hammer Enter into whatever came next.
*/
export const TRUST_DIALOG_MAX_ATTEMPTS = 3;
/**
* How much of the append-only terminal buffer to read on a direct-PTY session,
* which has no pane to capture. Small on purpose: the dialog scrolls out of a
* short tail as soon as Claude repaints its main UI, which is what keeps a
* fallback retry from firing at an already-answered dialog.
*/
export const TRUST_DIALOG_SCAN_BYTES = 4000;
+229 -58
View File
@@ -59,11 +59,28 @@ import type { TerminalMultiplexer, MuxSession } from './mux-interface.js';
import { TaskTracker, type BackgroundTask } from './task-tracker.js';
import { RalphTracker } from './ralph-tracker.js';
import { BashToolParser } from './bash-tool-parser.js';
import {
isTrustDialogScreen,
TRUST_DIALOG_WINDOW_MS,
TRUST_DIALOG_RETRY_MS,
TRUST_DIALOG_MAX_ATTEMPTS,
TRUST_DIALOG_SCAN_BYTES,
} from './session-trust-dialog.js';
import {
trackActivityStreak,
isSustainedActivity,
isPaneQuiet,
IDLE_RECHECK_MS,
PANE_PROBE_MIN_INTERVAL_MS,
PANE_PROBE_RECHECK_MS,
type ActivityStreak,
} from './session-activity.js';
import {
BufferAccumulator,
ANSI_ESCAPE_PATTERN_FULL,
TOKEN_PATTERN,
SPINNER_PATTERN,
CLAUDE_WORKING_LINE_PATTERN,
MAX_SESSION_TOKENS,
execPattern,
getClaudeCliVersion,
@@ -376,7 +393,13 @@ export class Session extends EventEmitter {
private _lastPromptTime: number = 0;
private activityTimeout: NodeJS.Timeout | null = null;
private _awaitingIdleConfirmation: boolean = false; // Prevents timeout reset during idle detection
private _trustDialogAccepted: boolean = false; // Prevents repeated trust dialog auto-accept
private _activityStreak: ActivityStreak | null = null; // Unbroken run of PTY repaints (working detection)
private _lastPaneProbeAt = 0; // Throttle for the tmux screen probe
private _lastPaneProbeWorking: boolean | null = null; // Its last verdict (null = could not read)
private _trustDialogAccepted: boolean = false; // Stops the trust-dialog scan (answered, or given up)
private _trustDialogAttempts = 0; // Enter presses sent at the trust dialog
private _lastTrustDialogScanAt = 0; // Throttle for the trust-dialog screen read
private _interactiveStartedAt = 0; // When the interactive pane launched (bounds that scan)
private _taskTracker: TaskTracker;
// Token tracking for auto-clear
@@ -1196,7 +1219,9 @@ export class Session extends EventEmitter {
/**
* Returns a subset of env overrides safe for disk persistence (state.json).
* Only non-sensitive `CLAUDE_CODE_*` keys are included. `OPENCODE_*` keys are
* Only non-sensitive `CLAUDE_CODE_*` keys plus CLAUDE_CONFIG_DIR (a path, not
* a secret — and losing it across a restart would silently move a session back
* to the default Claude account, #255) are included. `OPENCODE_*` keys are
* filtered out because the schema permits them and they can carry secrets
* (e.g., OPENCODE_API_KEY); secrets must not land in `~/.codeman/state.json`.
* Must NOT be included in any API-bound serializer — see toState() comment.
@@ -1205,7 +1230,7 @@ export class Session extends EventEmitter {
if (!this._envOverrides) return undefined;
const safe: Record<string, string> = {};
for (const [key, value] of Object.entries(this._envOverrides)) {
if (key.startsWith('CLAUDE_CODE_')) safe[key] = value;
if (key.startsWith('CLAUDE_CODE_') || key === 'CLAUDE_CONFIG_DIR') safe[key] = value;
}
return Object.keys(safe).length > 0 ? safe : undefined;
}
@@ -1406,6 +1431,7 @@ export class Session extends EventEmitter {
sessionId: this.id,
workingDir: this.workingDir,
mode: this.mode,
name: this._name,
niceConfig: this._niceConfig,
model: this._model,
claudeMode: this._claudeMode,
@@ -1514,6 +1540,12 @@ export class Session extends EventEmitter {
throw new Error('Session already has a running process');
}
// Bounds the workspace-trust scan (see _maybeAcceptTrustDialog). Stamped here
// rather than at PTY spawn so a slow mux attach still counts as startup.
this._interactiveStartedAt = Date.now();
this._trustDialogAttempts = 0;
this._lastTrustDialogScanAt = 0;
// COD-118: if the PTY exit breaker has tripped (repeated non-zero exits in a
// short window), refuse to respawn. This is the uniform choke point that stops
// automatic recovery/reconnect callers from re-creating a crash-looping PTY.
@@ -1710,7 +1742,15 @@ export class Session extends EventEmitter {
try {
// Pass --session-id to use the SAME ID as the Codeman session
// This ensures subagents can be directly matched to the correct tab
const args = buildInteractiveArgs(this.id, this._claudeMode, this._model, this._allowedTools, this._effort);
const args = buildInteractiveArgs(
this.id,
this._claudeMode,
this._model,
this._allowedTools,
this._effort,
this._name,
getClaudeCliVersion()
);
this.ptyProcess = spawnPtyWithHelperRepair(() =>
pty.spawn(getClaudeBinaryPath(), args, {
name: 'xterm-256color',
@@ -1743,54 +1783,10 @@ export class Session extends EventEmitter {
this._handleTerminalOutput(data);
// === Auto-accept workspace trust dialog ===
// Claude CLI 2.x shows "Yes, I trust this folder" prompt on first launch per directory.
// Codeman sessions run permission-skipping or classifier-guarded (auto) modes, so auto-accept.
if (!this._trustDialogAccepted && data.includes('trust this folder')) {
this._trustDialogAccepted = true;
console.log(`[Session] Auto-accepting workspace trust dialog for: ${this.id}`);
// Send Enter to accept the default selection ("Yes, I trust this folder")
this.writeViaMux('\r');
}
this._maybeAcceptTrustDialog();
// === Idle/working detection runs on every chunk (latency-sensitive) ===
// Detect if Claude is working or at prompt
// The prompt line contains "❯" when waiting for input
if (data.includes('❯') || data.includes('\u276f')) {
// Only start a new timeout if we're not already awaiting idle confirmation
// This prevents status bar redraws (which include ❯) from resetting the timer
if (!this._awaitingIdleConfirmation) {
if (this.activityTimeout) clearTimeout(this.activityTimeout);
this._awaitingIdleConfirmation = true;
this.activityTimeout = setTimeout(() => {
this._awaitingIdleConfirmation = false;
// Emit idle if either:
// 1. Claude was working and is now at prompt (normal case)
// 2. Session just started and is ready (status is 'busy' but _isWorking is false)
const wasWorking = this._isWorking;
const isInitialReady = this._status === 'busy' && !this._isWorking;
if (wasWorking || isInitialReady) {
this._isWorking = false;
this._status = 'idle';
this._lastPromptTime = Date.now();
this.emit('idle');
}
}, IDLE_DETECTION_DELAY_MS);
}
}
// Detect when Claude starts working (thinking, writing, etc)
// Fast path: check spinner characters on raw data (Unicode, never in ANSI sequences)
const hasSpinner = SPINNER_PATTERN.test(data);
if (hasSpinner) {
if (!this._isWorking) {
this._isWorking = true;
this._status = 'busy';
this.emit('working');
this._autoOps.notifyWorking();
}
this._awaitingIdleConfirmation = false;
if (this.activityTimeout) clearTimeout(this.activityTimeout);
}
this._detectInteractiveActivity(data);
// === Expensive processing (ANSI strip, Ralph, bash parser) is throttled ===
// Instead of running regex-heavy parsers on every PTY chunk, we accumulate
@@ -1839,6 +1835,7 @@ export class Session extends EventEmitter {
this._pid = null;
this._status = 'idle';
this._awaitingIdleConfirmation = false;
this._activityStreak = null;
// Clear all timers to prevent memory leaks
if (this.activityTimeout) {
clearTimeout(this.activityTimeout);
@@ -1894,6 +1891,180 @@ export class Session extends EventEmitter {
return this._respawnBlocked;
}
/**
* Answer Claude's workspace-trust dialog, which blocks a fresh case until
* someone presses Enter. Codeman sessions run permission-skipping or
* classifier-guarded modes, so the answer is always "yes, I trust this folder".
*
* Reads the RENDERED SCREEN rather than the chunk that just arrived. tmux
* repaints a row with cursor-forward escapes in place of spaces, so the wire
* carries `I\x1b[Ctrust\x1b[Cthis\x1b[Cfolder` and the old
* `data.includes('trust this folder')` could never match: the auto-accept had
* been dead for every session that hit the dialog. The screen is also what
* makes a retry safe, since the terminal buffer is append-only and keeps the
* dialog in its tail long after it has been answered.
*
* Three guards keep an Enter press off a live session: a startup-only window,
* a two-marker match (isTrustDialogScreen), and an attempt cap.
*/
private _maybeAcceptTrustDialog(): void {
if (this._trustDialogAccepted) return;
const now = Date.now();
if (now - this._interactiveStartedAt > TRUST_DIALOG_WINDOW_MS) {
this._trustDialogAccepted = true; // window closed; anything matching now is not the dialog
return;
}
if (now - this._lastTrustDialogScanAt < TRUST_DIALOG_RETRY_MS) return;
this._lastTrustDialogScanAt = now;
// Prefer the pane; fall back to the buffer tail on a direct-PTY session,
// where there is no screen to read.
const screen =
(this._mux && this._muxSession ? this._mux.capturePaneText?.(this._muxSession.muxName) : null) ??
this._terminalBuffer.value.slice(-TRUST_DIALOG_SCAN_BYTES);
if (!isTrustDialogScreen(screen)) return;
this._trustDialogAttempts++;
if (this._trustDialogAttempts > TRUST_DIALOG_MAX_ATTEMPTS) {
this._trustDialogAccepted = true; // leave it to the user rather than keep typing
console.warn(`[Session] Workspace trust dialog did not clear after retries: ${this.id}`);
return;
}
console.log(
`[Session] Auto-accepting workspace trust dialog for: ${this.id} (attempt ${this._trustDialogAttempts})`
);
// Enter confirms the highlighted default, "1. Yes, I trust this folder".
this.writeViaMux('\r');
}
/**
* Per-chunk working/idle detection for an interactive pane. Split out of the
* PTY `onData` handler so it can be unit tested without spawning one.
*
* @param data raw PTY chunk, ANSI included
*/
private _detectInteractiveActivity(data: string): void {
// The prompt line contains "❯" when Claude is waiting for input. It only ARMS
// the check and is NOT evidence the turn ended: Claude redraws the composer
// about once a second all the way through a turn, which is exactly how a
// working session used to flip to idle two seconds in. _confirmIdle() waits
// for the pane to actually go quiet before believing it.
if (data.includes('❯')) {
// Only start a new timeout if we're not already awaiting idle confirmation.
// This prevents status bar redraws (which include the prompt) from resetting it.
if (!this._awaitingIdleConfirmation) {
if (this.activityTimeout) clearTimeout(this.activityTimeout);
this._awaitingIdleConfirmation = true;
this.activityTimeout = setTimeout(() => this._confirmIdle(), IDLE_DETECTION_DELAY_MS);
}
}
// Detect when Claude starts working (thinking, writing, etc).
// Fast path: spinner characters on raw data (Unicode, never inside ANSI sequences).
if (SPINNER_PATTERN.test(data)) this._markWorking();
// Activity fallback: current Claude Code animates `✻ Actualizing…` instead of a
// braille spinner, so the fast path above misses entire turns, and matching the
// new status line does not rescue it either (tmux repaints partially, so the
// complete line reaches the PTY only every few tens of seconds). An unbroken run
// of repaints is the signal that survives. See session-activity.ts for the
// measurement. Claude only: an external CLI's TUI has no ❯, so nothing would
// ever arm the idle confirmation and such a session would latch busy forever.
if (!isExternalCliMode(this.mode)) {
this._activityStreak = trackActivityStreak(this._activityStreak, Date.now());
// A streak is the TRIGGER to look, not the verdict: typing into the composer
// also produces a steady stream of repaints. The screen settles it, and only
// an explicit "no working line" vetoes; a probe that cannot read the pane
// (null) leaves the streak in charge.
if (!this._isWorking && isSustainedActivity(this._activityStreak) && this._probePaneWorking() !== false) {
this._markWorking();
}
}
}
/**
* Ask the pane what it is rendering right now.
*
* The PTY stream cannot answer this on its own: measured on a live worker,
* Claude repaints roughly once a second for most of a turn but can then sit
* completely silent for tens of seconds inside a single tool call, while the
* `✻ Elucidating… (39s · ↓ 2.0k tokens)` line stays on screen the whole time.
* Silence therefore proves nothing, and the rendered frame is the only cheap
* source that is right in both directions.
*
* Costs one `capture-pane`, floored at PANE_PROBE_MIN_INTERVAL_MS per session
* and only ever called at a transition, never on the output hot path.
*
* @returns true/false when the screen could be read, null when it could not
* (no mux, capture failed, tests). Callers must treat null as "no evidence"
* and fall back to their stream heuristics.
*/
private _probePaneWorking(): boolean | null {
if (!this._mux || !this._muxSession) return null;
const now = Date.now();
if (now - this._lastPaneProbeAt < PANE_PROBE_MIN_INTERVAL_MS) return this._lastPaneProbeWorking;
this._lastPaneProbeAt = now;
const text = this._mux.capturePaneText?.(this._muxSession.muxName) ?? null;
this._lastPaneProbeWorking = text === null ? null : CLAUDE_WORKING_LINE_PATTERN.test(text);
return this._lastPaneProbeWorking;
}
/**
* Mark the pane as working. Idempotent: `working` is emitted on the transition
* only, so the per-chunk detectors can all call it freely.
*
* Deliberately does NOT cancel a pending idle confirmation. That confirmation
* is what eventually notices the turn ended, and it already refuses to fire
* while the pane is noisy, and cancelling it here would leave a session that
* finished during a lull with nothing armed to ever call it idle.
*/
private _markWorking(): void {
if (this._isWorking) return;
this._isWorking = true;
this._status = 'busy';
this.emit('working');
this._autoOps.notifyWorking();
}
/**
* Decide whether the armed idle confirmation is real.
*
* A ❯ sighting alone means nothing (Claude redraws the composer through the
* whole turn), so the pane must ALSO have gone quiet. While output is still
* flowing the check re-arms instead of concluding. That loop is a timestamp
* compare every IDLE_RECHECK_MS and ends the moment the pane falls silent.
*/
private _confirmIdle(): void {
if (this._isStopped) {
this._awaitingIdleConfirmation = false;
return;
}
if (!isPaneQuiet(this._lastActivityAt, Date.now())) {
this.activityTimeout = setTimeout(() => this._confirmIdle(), IDLE_RECHECK_MS);
return; // stays _awaitingIdleConfirmation, so ❯ redraws do not pile up timers
}
// Quiet is necessary but NOT sufficient: a turn can go silent mid-tool-call.
// Ask the screen before concluding, and keep asking on a slow cadence.
if (this._probePaneWorking() === true) {
this._markWorking();
this.activityTimeout = setTimeout(() => this._confirmIdle(), PANE_PROBE_RECHECK_MS);
return;
}
this._awaitingIdleConfirmation = false;
this.activityTimeout = null;
// Emit idle if either:
// 1. Claude was working and is now at prompt (normal case)
// 2. Session just started and is ready (status is 'busy' but _isWorking is false)
const wasWorking = this._isWorking;
const isInitialReady = this._status === 'busy' && !this._isWorking;
if (wasWorking || isInitialReady) {
this._isWorking = false;
this._status = 'idle';
this._lastPromptTime = Date.now();
this.emit('idle');
}
}
/**
* Process expensive parsers (ANSI strip, Ralph, bash tool, token, CLI info, task descriptions).
* Called on a throttled schedule (every EXPENSIVE_PROCESS_INTERVAL_MS) instead of on every
@@ -1944,22 +2115,22 @@ export class Session extends EventEmitter {
this.parseTaskDescriptionsFromTerminalData(getCleanData());
}
// Work keyword detection (text-based, needs clean data)
// Only check if spinner didn't already trigger working state
// Work detection (text-based, needs clean data: the status line is coloured,
// so raw data has escape sequences between the `…` and the elapsed timer).
// Only check if a faster path didn't already trigger working state.
if (!this._isWorking) {
const cleanData = getCleanData();
if (
CLAUDE_WORKING_LINE_PATTERN.test(cleanData) ||
// Legacy gerunds. Current Claude randomizes the word ("Actualizing…",
// "Finagling…"), so these catch only a fraction of turns; the pattern
// above and the activity streak carry the rest.
cleanData.includes('Thinking') ||
cleanData.includes('Writing') ||
cleanData.includes('Reading') ||
cleanData.includes('Running')
) {
this._isWorking = true;
this._status = 'busy';
this.emit('working');
this._autoOps.notifyWorking();
this._awaitingIdleConfirmation = false;
if (this.activityTimeout) clearTimeout(this.activityTimeout);
this._markWorking();
}
}
}
+59 -4
View File
@@ -49,7 +49,7 @@ import {
type SessionDocker,
type DockerCommandMode,
} from './types.js';
import { buildEffortCliArgs } from './session-cli-builder.js';
import { buildEffortCliArgs, buildNameCliArgs } from './session-cli-builder.js';
import {
buildSshConnectionArgs,
defaultRemoteCommandForMode,
@@ -73,6 +73,7 @@ import {
wrapWithNice,
SAFE_PATH_PATTERN,
findClaudeDir,
getClaudeCliVersion,
resolveOpenCodeDir,
resolveCodexDir,
resolveGeminiDir,
@@ -752,6 +753,20 @@ function buildEffortSettingsFlag(effort?: EffortLevel): string {
return flag && value ? ` ${flag} '${value}'` : '';
}
/**
* Build the ` --name "<session name>"` shell fragment, or '' when it must be
* omitted. Version-gated FAIL-CLOSED in buildNameCliArgs (an older/unknown CLI
* aborts startup on an unknown flag, which would kill every claude spawn), and
* the value is allowlist-sanitized there, so it contains none of the characters
* that are special inside this double-quoted interpolation. The peer name is a
* soft default (in-session /rename still wins), which is why this rides the
* spawn command rather than any persisted config.
*/
function buildClaudeNameFlag(sessionName: string | undefined, cliVersion: string | null): string {
const [flag, value] = buildNameCliArgs(sessionName, cliVersion);
return flag && value ? ` ${flag} "${value}"` : '';
}
export function buildSpawnCommand(options: {
mode: SessionMode;
sessionId: string;
@@ -764,12 +779,25 @@ export function buildSpawnCommand(options: {
antigravityConfig?: AntigravityConfig;
resumeSessionId?: string;
effort?: EffortLevel;
/** Codeman session name, passed to claude as `--name` (version-gated, sanitized; local spawns only). */
sessionName?: string;
/**
* Claude CLI version for the `--name` gate. Omitted = probe the local CLI
* (getClaudeCliVersion; null under vitest). Tests inject a value here; the
* docker/remote paths never see this builder's output, which is what keeps the
* gate measuring the RIGHT binary, the local one.
*/
claudeCliVersion?: string | null;
}): string {
if (options.mode === 'claude') {
// Validate model to prevent command injection
const safeModel = options.model && /^[a-zA-Z0-9._\-[\]]+$/.test(options.model) ? options.model : undefined;
const modelFlag = safeModel ? ` --model "${safeModel}"` : '';
const effortFlag = buildEffortSettingsFlag(options.effort);
const nameFlag = buildClaudeNameFlag(
options.sessionName,
options.claudeCliVersion !== undefined ? options.claudeCliVersion : getClaudeCliVersion()
);
// Use --resume to restore a previous conversation, otherwise --session-id for new sessions.
// Wrap --resume in a fallback: if it exits non-zero (session not found, corrupt, etc.),
// fall back to a new session with --session-id so the pane doesn't die.
@@ -777,11 +805,11 @@ export function buildSpawnCommand(options: {
options.resumeSessionId && /^[a-f0-9-]+$/.test(options.resumeSessionId) ? options.resumeSessionId : undefined;
const permFlags = buildClaudePermissionFlags(options.claudeMode, options.allowedTools);
if (safeResumeId) {
const resumeCmd = `claude${permFlags} --resume "${safeResumeId}"${modelFlag}${effortFlag}`;
const fallbackCmd = `claude${permFlags} --session-id "${options.sessionId}"${modelFlag}${effortFlag}`;
const resumeCmd = `claude${permFlags} --resume "${safeResumeId}"${modelFlag}${effortFlag}${nameFlag}`;
const fallbackCmd = `claude${permFlags} --session-id "${options.sessionId}"${modelFlag}${effortFlag}${nameFlag}`;
return `${resumeCmd} || ${fallbackCmd}`;
}
return `claude${permFlags} --session-id "${options.sessionId}"${modelFlag}${effortFlag}`;
return `claude${permFlags} --session-id "${options.sessionId}"${modelFlag}${effortFlag}${nameFlag}`;
}
if (options.mode === 'opencode') {
return buildOpenCodeCommand(options.openCodeConfig);
@@ -1789,6 +1817,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
antigravityConfig,
resumeSessionId,
effort,
sessionName: name,
});
const config = niceConfig || DEFAULT_NICE_CONFIG;
@@ -2016,6 +2045,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
historyLimit = DEFAULT_TMUX_HISTORY_LIMIT,
remote,
docker,
name,
} = options;
const session = this.sessions.get(sessionId);
if (!session) return null;
@@ -2050,6 +2080,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
antigravityConfig,
resumeSessionId,
effort,
sessionName: name,
});
const config = niceConfig || DEFAULT_NICE_CONFIG;
const cmd = wrapWithNice(baseCmd, config);
@@ -3144,6 +3175,30 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
* Used for full page reloads so the user gets back their scroll history.
* Caveat: lines tmux has already evicted past its history-limit are gone.
*/
/**
* Plain visible-frame text for the working/idle probe (see `session.ts`).
*
* One `capture-pane` and nothing else: no `-e` styles, no `display-message`
* cursor query, no repaint reconstruction: this feeds a regex, not a
* terminal. Returns null in tests (no tmux) so callers fall back to their
* stream heuristics rather than reading an empty screen as "not working".
*/
capturePaneText(muxName: string, paneTarget?: string): string | null {
if (IS_TEST_MODE) return null;
const target = resolveTmuxPaneTarget(muxName, paneTarget);
if (!target) return null;
try {
return execSync(`${this.tmux()} capture-pane -p -t ${shellescape(target)}`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
});
} catch {
// A dead/renamed pane is an ordinary outcome here, not an error worth logging
// on a timer; the caller treats null as "no evidence either way".
return null;
}
}
capturePaneBuffer(muxName: string, paneTarget?: string, opts?: PaneCaptureOptions): string | null {
if (IS_TEST_MODE) return '';
const target = resolveTmuxPaneTarget(muxName, paneTarget);
+12
View File
@@ -6,6 +6,7 @@
* - Tool execution state
* - Error conditions
* - Plan mode prompts
* - User-authored prompts (`transcript:user_prompt`, Read My Mind intent capture)
*
* The transcript path is provided by Claude Code hooks in the `transcript_path` field.
*/
@@ -372,12 +373,23 @@ export class TranscriptWatcher extends EventEmitter {
this.state.errorMessage = null;
const content = entry.message?.content;
if (typeof content === 'string') {
if (content.trim()) this.emit('transcript:user_prompt', content, entry.timestamp);
return;
}
if (!Array.isArray(content)) return;
let promptText = '';
for (const block of content) {
if (block.type === 'tool_result') {
this.handleToolResult(block);
} else if (block.type === 'text' && block.text) {
promptText += (promptText ? ' ' : '') + block.text;
}
}
// Text blocks mean a typed prompt; tool_result-only entries are Claude's own
// tool plumbing, not intent. Filtering of command echo / system wrappers is
// the intent store's job (`isCapturablePrompt`), not the watcher's.
if (promptText.trim()) this.emit('transcript:user_prompt', promptText, entry.timestamp);
}
private handleToolResult(block: TranscriptContentBlock): void {
+2
View File
@@ -105,6 +105,8 @@ export type HookEventType =
| 'idle_prompt'
| 'permission_prompt'
| 'elicitation_dialog'
| 'elicitation_complete'
| 'elicitation_response'
| 'stop'
| 'teammate_idle'
| 'task_completed';
+1
View File
@@ -71,3 +71,4 @@ export * from './workflow-run.js';
export * from './search.js';
export * from './user.js';
export * from './webview.js';
export * from './intent.js';
+31
View File
@@ -0,0 +1,31 @@
/**
* @fileoverview Read My Mind intent types.
*
* An intent profile is per CASE (owner + workingDir), not per session:
* intentions outlive `/clear`, respawn cycles, and individual sessions.
* See `docs/readmymind-plan.md`.
*/
/** One captured user prompt, as it appeared in the session transcript. */
export interface IntentPromptEntry {
/** Capture time (ms epoch). */
ts: number;
/** Codeman session the prompt was sent in. */
sessionId: string;
/** The prompt text, sanitized and bounded. */
text: string;
}
/** Per-case profile of what the user is trying to accomplish. */
export interface IntentProfile {
/** Stable key: sha256(owner + ':' + realpath(workingDir)), first 16 hex chars. */
key: string;
/** The case working directory the profile belongs to (realpath-resolved). */
workingDir: string;
/** Last mutation (ms epoch). 0 for a never-persisted empty profile. */
updatedAt: number;
/** User/agent-stated goals, freeform markdown, bounded. */
goals: string;
/** Most recent captured prompts, oldest first, FIFO-capped. */
recentPrompts: IntentPromptEntry[];
}
+1
View File
@@ -17,6 +17,7 @@ export {
ANSI_ESCAPE_PATTERN_SIMPLE,
TOKEN_PATTERN,
SPINNER_PATTERN,
CLAUDE_WORKING_LINE_PATTERN,
stripAnsi,
SAFE_PATH_PATTERN,
execPattern,
+18
View File
@@ -60,6 +60,24 @@ export function stripAnsi(text: string): string {
*/
export const SPINNER_PATTERN = /[⠋⠙⠹⠸⠼⠴⠦⠧]/;
/**
* Claude Code's live working status line, e.g.
* `✻ Actualizing… (13m 23s · ↓ 47.5k tokens)`
* `✽ Herding… (3s · esc to interrupt)`
*
* Matched on the ELLIPSIS + elapsed timer, never on the leading glyph: the
* animation cycles through `· ✢ ✳ ∗ ✻ ✽` (two of those are ordinary punctuation)
* and the gerund is randomized per turn, while the finished line (`✻ Cooked for
* 2m 49s`) carries the same glyph with no `…` and no parenthesis. Feed this
* ANSI-STRIPPED data: tmux colours the timer separately, so the raw stream has
* escape sequences sitting between the `…` and the `(`.
*
* A sighting is proof the pane is working; its ABSENCE proves nothing, because
* tmux repaints partially and the whole line reaches the PTY only occasionally
* (see `session-activity.ts` for what carries the idle decision instead).
*/
export const CLAUDE_WORKING_LINE_PATTERN = /…\s*\((?:\d+h\s+)?(?:\d+m\s+)?\d+s\b|esc to interrupt/;
export const SAFE_PATH_PATTERN = /^[\p{L}\p{N}_/\-. ~]+$/u;
/**
+377
View File
@@ -0,0 +1,377 @@
/**
* @fileoverview Approvals Inbox: server-side registry of prompts waiting on a human.
*
* One cross-session queue of pending Claude prompts (permission dialogs,
* AskUserQuestion/elicitation questions, idle prompts), fed by `/api/hook-event`
* and answered via `POST /api/approvals/:id/answer`. Before this store existed,
* pending prompts lived only in `app.js` memory (SSE-transient, lost on reload)
* and the push notification Approve/Deny buttons had nothing to act on.
* Design: `docs/approvals-inbox-plan.md`.
*
* Invariants:
* - At most ONE active item per session: the Claude TUI shows one dialog at a
* time, so a new prompt supersedes the session's previous item.
* - Module-level singleton in the style of `session-wait-registry.ts`: no
* `Session` import, no IO; the server injects emit callbacks (`onPending`/
* `onUpdated`/`onResolved`), which keeps this unit-testable and cycle-free.
* - Items are in-memory only. A server restart drops them; the next prompt
* re-fires the hook. Claude-mode sessions only (hooks fire for nothing else).
* - Answer flow is take-then-write: `take()` removes the item BEFORE keystrokes
* are sent so a double-tap cannot double-send; `restore()` re-inserts on a
* failed write unless a newer prompt arrived meanwhile.
*
* @dependencies utils (stripAnsi)
* @consumedby web/routes/hook-event-routes (notePrompt/resolve), web/routes/approval-routes,
* web/session-listener-wiring (working/exit resolution), web/server (emit callbacks + stop)
*
* @module web/approval-inbox
*/
import { stripAnsi } from '../utils/index.js';
// ─── Types ───────────────────────────────────────────────────────────────────
export type ApprovalKind = 'permission' | 'question' | 'idle';
export type ApprovalResolution =
| 'answered'
| 'resolved_in_terminal'
| 'superseded'
| 'session_ended'
| 'dismissed'
| 'expired';
/** A numbered choice parsed from the captured dialog frame. */
export interface ApprovalOption {
n: number;
label: string;
}
export interface ApprovalItem {
/** `${sessionId}:${seq}`, stable across re-captures, unique per prompt. */
id: string;
sessionId: string;
sessionName: string;
kind: ApprovalKind;
createdAt: number;
/** Sanitized hook fields (already bounded by sanitizeHookData). */
toolName?: string;
toolSummary?: string;
message?: string;
cwd?: string;
/** ANSI-stripped tail of the visible pane frame at capture time. */
context?: string;
/**
* Present only when the frame parsed confidently. Gates which digits the
* answer endpoint accepts; absent → only approve('1')/deny(Esc) are allowed.
*/
options?: ApprovalOption[];
}
export interface ApprovalResolvedInfo {
id: string;
sessionId: string;
kind: ApprovalKind;
resolution: ApprovalResolution;
}
interface NotePromptArgs {
sessionId: string;
sessionName: string;
kind: ApprovalKind;
toolName?: string;
toolSummary?: string;
message?: string;
cwd?: string;
/** Returns the raw (ANSI-bearing) pane frame, or null when unavailable. */
capture?: () => string | null;
}
// ─── Tunables ────────────────────────────────────────────────────────────────
/** Items older than this are dropped on read: a 12h-old dialog is stale by any measure. */
const ITEM_TTL_MS = 12 * 60 * 60 * 1000;
/**
* The Notification hook can fire before Ink finishes painting the dialog, so a
* single delayed re-capture picks up the frame the immediate capture missed.
*/
const RECAPTURE_DELAY_MS = 600;
/** Context kept per item: enough for a dialog plus a few lines above it. */
const MAX_CONTEXT_CHARS = 4000;
const MAX_CONTEXT_LINES = 30;
const MAX_OPTION_LABEL_CHARS = 120;
// ─── Pure helpers ────────────────────────────────────────────────────────────
/**
* The visible-frame tmux capture (`formatPaneSnapshot`) carries NO newlines: it
* repaints every row at its absolute position via `ESC[<row>;<col>H`. Verified
* against a live dialog: without this conversion the whole frame collapses to
* one line and no dialog ever parses. Column 1 (or omitted) means a fresh row →
* newline; a mid-row jump becomes a space so adjacent words don't merge.
*/
// eslint-disable-next-line no-control-regex
const CURSOR_POSITION_PATTERN = /\x1b\[(?:(\d+)(?:;(\d+))?)?[Hf]/g;
/**
* Normalize a raw pane capture into card context: convert row repaints to
* lines, strip ANSI, right-trim lines, drop trailing blanks, keep the last
* MAX_CONTEXT_LINES lines.
*/
export function normalizeCapturedFrame(raw: string | null | undefined): string | undefined {
if (!raw) return undefined;
const rowed = raw.replace(CURSOR_POSITION_PATTERN, (_m, _row, col) => (!col || col === '1' ? '\n' : ' '));
const lines = stripAnsi(rowed)
.split('\n')
.map((line) => line.replace(/\s+$/, ''));
while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
while (lines.length > 0 && lines[0] === '') lines.shift();
if (lines.length === 0) return undefined;
const text = lines.slice(-MAX_CONTEXT_LINES).join('\n');
return text.length > MAX_CONTEXT_CHARS ? text.slice(-MAX_CONTEXT_CHARS) : text;
}
/**
* Parse the numbered options of a Claude dialog out of a normalized frame.
*
* Matches the shapes Ink renders for permission prompts and AskUserQuestion:
*
* ❯ 1. Yes ❯ 1. Red
* 2. Yes, allow all edits (shift+tab) Prefer red
* 3. No, tell Claude what to do (esc) 2. Blue
* Prefer blue
*
* Options must be consecutively numbered from 1 (2..6 of them); description /
* wrap / separator lines between options are tolerated up to a small gap
* (AskUserQuestion puts a description under every option and a ─ separator
* before its "Chat about this" entry, measured against the live dialog). The
* LAST complete block in the frame wins (dialogs render at the bottom).
* Returns undefined when nothing parses; callers then fall back to
* approve/deny only, so a mis-parse can never route a digit at a dialog that
* does not have it.
*/
export function parseDialogOptions(context: string | undefined): ApprovalOption[] | undefined {
if (!context) return undefined;
const lines = context.split('\n');
let lastComplete: ApprovalOption[] | undefined;
let run: ApprovalOption[] = [];
let gap = 0;
const commit = () => {
if (run.length >= 2 && run.length <= 6) lastComplete = run;
run = [];
gap = 0;
};
for (const line of lines) {
const m = line.match(/^\s*(?:❯\s*)?(\d)[.)]\s+(.+)$/);
const n = m ? Number(m[1]) : NaN;
if (m && n === run.length + 1) {
run.push({ n, label: m[2].trim().slice(0, MAX_OPTION_LABEL_CHARS) });
gap = 0;
} else if (m && n === 1) {
commit();
run = [{ n: 1, label: m[2].trim().slice(0, MAX_OPTION_LABEL_CHARS) }];
} else if (run.length > 0 && ++gap > 3) {
// Too far past the last option for this to still be its description:
// the block is over.
commit();
}
}
commit();
return lastComplete;
}
// ─── Registry ────────────────────────────────────────────────────────────────
export class ApprovalInbox {
/** Keyed by sessionId; the one-active-item-per-session invariant lives here. */
private items = new Map<string, ApprovalItem>();
private recaptureTimers = new Map<string, ReturnType<typeof setTimeout>>();
/** Capture callbacks kept for answer-time re-verification; dropped on remove. */
private captures = new Map<string, () => string | null>();
private seq = 0;
private stopped = false;
/** Emit callbacks, injected by the server (SSE broadcast + push). */
onPending?: (item: ApprovalItem) => void;
onUpdated?: (item: ApprovalItem) => void;
onResolved?: (info: ApprovalResolvedInfo) => void;
/**
* Record a prompt for a session, superseding any previous item, and return
* the new item. Captures context immediately and once more after a short
* delay (see RECAPTURE_DELAY_MS).
*/
notePrompt(args: NotePromptArgs): ApprovalItem {
this.resolveForSession(args.sessionId, 'superseded');
const item: ApprovalItem = {
id: `${args.sessionId}:${++this.seq}`,
sessionId: args.sessionId,
sessionName: args.sessionName,
kind: args.kind,
createdAt: Date.now(),
toolName: args.toolName,
toolSummary: args.toolSummary,
message: args.message,
cwd: args.cwd,
};
this.applyCapture(item, args.capture);
this.items.set(args.sessionId, item);
if (args.capture) this.captures.set(args.sessionId, args.capture);
this.onPending?.(item);
if (args.capture && !this.stopped) {
const timer = setTimeout(() => {
this.recaptureTimers.delete(item.id);
// Only update the item if it is still the live one for the session.
if (this.items.get(args.sessionId)?.id !== item.id) return;
this.applyCapture(item, args.capture);
this.onUpdated?.(item);
}, RECAPTURE_DELAY_MS);
this.recaptureTimers.set(item.id, timer);
}
return item;
}
/**
* Answer-time guard: re-capture the pane and check the dialog is still on
* screen before keystrokes are sent at it. Only conclusive when the ORIGINAL
* frame parsed options: if a fresh capture then parses none, the dialog is
* gone (answered in the terminal moments ago), so the item resolves and the
* answer must be refused, because the digit would land in whatever now has
* focus. Unparseable-from-the-start items stay answerable (approve/deny
* only), same risk the terminal user already carries.
*/
verifyStillAnswerable(id: string): boolean {
const item = this.getById(id);
if (!item) return false;
if (item.kind === 'idle' || !item.options) return true;
const capture = this.captures.get(item.sessionId);
if (!capture) return true;
let raw: string | null = null;
try {
raw = capture();
} catch {
return true; // capture hiccup: inconclusive, keep the item answerable
}
const context = normalizeCapturedFrame(raw);
if (!context) return true;
const options = parseDialogOptions(context);
if (!options) {
this.remove(item, 'resolved_in_terminal');
return false;
}
item.context = context;
item.options = options;
return true;
}
/** Pending item for a session, TTL-checked. */
getForSession(sessionId: string): ApprovalItem | undefined {
const item = this.items.get(sessionId);
if (!item) return undefined;
if (this.isExpired(item)) {
this.resolveForSession(sessionId, 'expired');
return undefined;
}
return item;
}
/** Pending item by id, TTL-checked. */
getById(id: string): ApprovalItem | undefined {
const item = this.getForSession(sessionIdOf(id));
return item?.id === id ? item : undefined;
}
/** All pending items, TTL-swept, oldest first. */
listPending(): ApprovalItem[] {
for (const sessionId of [...this.items.keys()]) this.getForSession(sessionId);
return [...this.items.values()].sort((a, b) => a.createdAt - b.createdAt);
}
/**
* Remove the item as `answered` and return it, or undefined if it is no
* longer pending. Callers send keystrokes AFTER a successful take, and
* `restore()` on a failed write.
*/
take(id: string): ApprovalItem | undefined {
const item = this.getById(id);
if (!item) return undefined;
this.remove(item, 'answered');
return item;
}
/** Re-insert a taken item after a failed write, unless superseded meanwhile. */
restore(item: ApprovalItem): void {
if (this.stopped || this.items.has(item.sessionId)) return;
this.items.set(item.sessionId, item);
this.onPending?.(item);
}
/** Remove an item without keystrokes (user chose Dismiss). */
dismiss(id: string): boolean {
const item = this.getById(id);
if (!item) return false;
this.remove(item, 'dismissed');
return true;
}
/**
* Resolve a session's pending item, if any (stop hook, exit, ...). `kinds`
* restricts which item kinds the signal may clear: the heuristic `working`
* transition passes `['idle']` so a mid-turn flap cannot false-clear a
* pending permission/question dialog.
*/
resolveForSession(sessionId: string, resolution: ApprovalResolution, kinds?: ApprovalKind[]): void {
const item = this.items.get(sessionId);
if (!item) return;
if (kinds && !kinds.includes(item.kind)) return;
this.remove(item, resolution);
}
/** Clear all timers (shutdown/tests). Items become inert; no events fire after this. */
stop(): void {
this.stopped = true;
for (const timer of this.recaptureTimers.values()) clearTimeout(timer);
this.recaptureTimers.clear();
this.items.clear();
this.captures.clear();
}
private applyCapture(item: ApprovalItem, capture?: () => string | null): void {
if (!capture) return;
let raw: string | null = null;
try {
raw = capture();
} catch {
// Capture is best-effort; the card still renders from hook fields.
}
const context = normalizeCapturedFrame(raw);
if (!context) return;
item.context = context;
// Idle prompts are not dialogs; never offer digit answers for them.
if (item.kind !== 'idle') item.options = parseDialogOptions(context);
}
private remove(item: ApprovalItem, resolution: ApprovalResolution): void {
this.items.delete(item.sessionId);
this.captures.delete(item.sessionId);
const timer = this.recaptureTimers.get(item.id);
if (timer) {
clearTimeout(timer);
this.recaptureTimers.delete(item.id);
}
if (!this.stopped) {
this.onResolved?.({ id: item.id, sessionId: item.sessionId, kind: item.kind, resolution });
}
}
private isExpired(item: ApprovalItem): boolean {
return Date.now() - item.createdAt > ITEM_TTL_MS;
}
}
function sessionIdOf(itemId: string): string {
return itemId.slice(0, itemId.lastIndexOf(':'));
}
/** Process-wide singleton, mirroring `sessionWaits`. */
export const approvalInbox = new ApprovalInbox();
+161
View File
@@ -237,10 +237,17 @@ const _SSE_HANDLER_MAP = [
[SSE_EVENTS.HOOK_IDLE_PROMPT, '_onHookIdlePrompt'],
[SSE_EVENTS.HOOK_PERMISSION_PROMPT, '_onHookPermissionPrompt'],
[SSE_EVENTS.HOOK_ELICITATION_DIALOG, '_onHookElicitationDialog'],
[SSE_EVENTS.HOOK_ELICITATION_COMPLETE, '_onHookElicitationComplete'],
[SSE_EVENTS.HOOK_ELICITATION_RESPONSE, '_onHookElicitationResponse'],
[SSE_EVENTS.HOOK_STOP, '_onHookStop'],
[SSE_EVENTS.HOOK_TEAMMATE_IDLE, '_onHookTeammateIdle'],
[SSE_EVENTS.HOOK_TASK_COMPLETED, '_onHookTaskCompleted'],
// Approvals Inbox (handlers in approvals-ui.js)
[SSE_EVENTS.APPROVAL_PENDING, '_onApprovalPending'],
[SSE_EVENTS.APPROVAL_UPDATED, '_onApprovalUpdated'],
[SSE_EVENTS.APPROVAL_RESOLVED, '_onApprovalResolved'],
// Subagents (Claude Code background agents)
[SSE_EVENTS.SUBAGENT_DISCOVERED, '_onSubagentDiscovered'],
[SSE_EVENTS.SUBAGENT_UPDATED, '_onSubagentUpdated'],
@@ -615,6 +622,11 @@ class CodemanApp {
this.fileBrowserFilter = '';
this.fileBrowserAllExpanded = false;
this.fileBrowserDragListeners = null;
// Show hidden (dot-prefixed) files and folders in the File Viewer tree.
// Per-device, persisted to its own localStorage key by panels-ui.js. Safe to
// call a mixin method here: instantiation is deferred to DOMContentLoaded,
// so every module's Object.assign has already run.
this.fileBrowserShowHidden = this._loadFileBrowserShowHidden?.() ?? false;
this.filePreviewContent = '';
// Toast container cache (methods in panels-ui.js)
@@ -630,6 +642,9 @@ class CodemanApp {
// Tracks pending hook events that need resolution (permission_prompt, elicitation_dialog, idle_prompt)
this.pendingHooks = new Map();
// Approvals Inbox: Map<approvalId, ApprovalItem> (methods in approvals-ui.js)
this.approvals = new Map();
// WebSocket terminal I/O (low-latency bypass of HTTP POST + SSE)
this._ws = null; // WebSocket instance for active session
this._wsSessionId = null; // Session ID the WS is connected to
@@ -669,6 +684,17 @@ class CodemanApp {
this.maxReconnectAttempts = 10;
this.isOnline = navigator.onLine;
// Connection-loss UI (banner + full-screen overlay). The decision itself is
// pure and lives in constants.js (computeConnectionLossUi); these are just
// its inputs. `_connDownSince` is the timestamp the transport LEFT the
// connected state, which is what the grace window is measured from.
this._connDownSince = null;
this._nextSseRetryAt = null; // when the scheduled SSE retry fires (countdown)
this._offlineOverlayDismissed = false;
this._offlineRetryPending = false; // a user-triggered retry is in flight
this._offlineUiTicker = null;
this._lastOfflineUiKey = '';
// Reliable, durable input delivery (replaces the old best-effort queue).
// Every input byte is recorded with a stable clientId + a monotonic
// per-session seq, persisted to localStorage, and only dropped once the
@@ -1457,6 +1483,10 @@ class CodemanApp {
// then ramp up for real network issues.
const delay = this.reconnectAttempts <= 1 ? 200
: Math.min(500 * Math.pow(2, this.reconnectAttempts - 2), 30000);
// Feeds the "Retrying in Ns" countdown. With a 30s cap on the backoff, a
// silent wait that long is indistinguishable from a hung app.
this._nextSseRetryAt = Date.now() + delay;
this._updateConnectionLossUi();
this.sseReconnectTimeout = setTimeout(() => this.connectSSE(), delay);
};
@@ -2333,7 +2363,17 @@ class CodemanApp {
setConnectionStatus(status) {
this._connectionStatus = status;
// Track when the transport left 'connected'. The connection-loss UI waits
// out a deploy-length blip before showing anything (see constants.js).
if (status === 'connected') {
this._connDownSince = null;
this._nextSseRetryAt = null;
this._offlineOverlayDismissed = false;
} else if (this._connDownSince === null) {
this._connDownSince = Date.now();
}
this._updateConnectionIndicator();
this._updateConnectionLossUi();
if (status === 'connected') {
// Reconnected (SSE) — push any durably-queued input out immediately
// instead of waiting for the next 2s sweep.
@@ -2955,6 +2995,9 @@ class CodemanApp {
window.addEventListener('online', () => {
this.isOnline = true;
this.reconnectAttempts = 0;
// Restart the grace window: the radio just came back, so the next couple
// of seconds of "not connected" are expected, not a server problem.
this._connDownSince = Date.now();
this.connectSSE();
// Network came back — drain durably-queued input right away.
this._redeliverSweep();
@@ -2965,6 +3008,116 @@ class CodemanApp {
});
}
// ── Connection-loss UI ─────────────────────────────────────────────────────
// Why this exists: the service worker serves the cached app shell, so opening
// Codeman with the server unreachable (phone off the tailnet, VPN down,
// server stopped) rendered a normal-looking but empty dashboard whose only
// hint was an 8px red dot in the header corner. The decision of what to show
// is pure (computeConnectionLossUi in constants.js); this is the writer.
/** Apply the offline banner / overlay for the current connection state. */
_updateConnectionLossUi() {
const policy = window.CodemanConnectionLoss;
const banner = this.$('offlineBanner');
const overlay = this.$('offlineOverlay');
if (!policy || !banner || !overlay) return;
const state = policy.compute({
isOnline: this.isOnline,
status: this._connectionStatus,
// Server state has landed at least once this page load (SSE `init`), so
// there is a UI worth keeping visible behind a non-blocking banner.
everLoaded: this._initGeneration > 0,
downSince: this._connDownSince,
now: Date.now(),
nextRetryAt: this._nextSseRetryAt,
overlayDismissed: this._offlineOverlayDismissed,
retryPending: this._offlineRetryPending,
});
// The ticker drives both the countdown and the grace deadline; neither is
// event-driven, so it must run whenever the transport is down, including
// while the decision is still 'hidden' inside the grace window.
if (this._connDownSince === null) this._stopOfflineTicker();
else this._startOfflineTicker();
const retryLabel = this._offlineRetryPending
? 'Reconnecting…'
: state.retryInSec != null && state.retryInSec > 0
? `Retrying in ${state.retryInSec}s`
: 'Retrying…';
// Called every second by the ticker, so skip the DOM writes when the rendered
// result is unchanged (same reasoning as _updateConnectionIndicator).
const key = `${state.mode}|${state.kind}|${retryLabel}`;
if (key === this._lastOfflineUiKey) return;
this._lastOfflineUiKey = key;
banner.hidden = state.mode !== 'banner';
overlay.hidden = state.mode !== 'overlay';
document.body.classList.toggle('connection-lost', state.mode !== 'hidden');
if (state.mode === 'banner') {
const text = this.$('offlineBannerText');
const detail = this.$('offlineBannerDetail');
if (text) text.textContent = state.title;
if (detail) detail.textContent = retryLabel;
} else if (state.mode === 'overlay') {
const title = this.$('offlineOverlayTitle');
const body = this.$('offlineOverlayBody');
const host = this.$('offlineOverlayHost');
const status = this.$('offlineOverlayStatus');
if (title) title.textContent = state.title;
if (body) body.textContent = state.detail;
if (host) host.textContent = location.host;
if (status) status.textContent = retryLabel;
}
}
_startOfflineTicker() {
if (this._offlineUiTicker) return;
this._offlineUiTicker = setInterval(() => this._updateConnectionLossUi(), 1000);
}
_stopOfflineTicker() {
if (!this._offlineUiTicker) return;
clearInterval(this._offlineUiTicker);
this._offlineUiTicker = null;
}
/** Retry button on the banner/overlay: reconnect now instead of waiting out
* the backoff (capped at 30s, and the WS plan can give up entirely). */
retryConnection() {
this._offlineRetryPending = true;
this._nextSseRetryAt = null;
this.reconnectAttempts = 0;
this._clearTimer('sseReconnectTimeout');
this.isOnline = navigator.onLine;
this._lastOfflineUiKey = '';
this._updateConnectionLossUi();
this.connectSSE();
// The terminal socket does not always come back on its own (planWsReconnect
// 'give-up'), so the same button re-arms it.
if (this.activeSessionId && this._wsState !== 'connected') {
this._wsReconnectAttempts = 0;
this._connectWs(this.activeSessionId);
}
this._clearTimer('_offlineRetryTimer');
this._offlineRetryTimer = setTimeout(() => {
this._offlineRetryPending = false;
this._lastOfflineUiKey = '';
this._updateConnectionLossUi();
}, 1500);
}
/** "Show cached view": demote the blocking overlay to the banner for the rest
* of this outage, so the cached UI can be inspected offline. */
dismissOfflineOverlay() {
this._offlineOverlayDismissed = true;
this._lastOfflineUiKey = '';
this._updateConnectionLossUi();
}
/** Show/hide the CJK input textarea based on user setting or server override */
_updateCjkInputState() {
const cjkEl = document.getElementById('cjkInput');
@@ -3030,6 +3183,8 @@ class CodemanApp {
this._predictiveEcho?.clearPredictions();
// Clear pending hooks
this.pendingHooks.clear();
// Clear approvals (re-seeded from GET /api/approvals right after init)
this.approvals?.clear();
// Clear parent name cache (prevents stale session name entries accumulating)
if (this._parentNameCache) this._parentNameCache.clear();
// Clear subagent activity/results maps (prevents leaks if data.subagents is missing)
@@ -3170,6 +3325,10 @@ class CodemanApp {
this.updateCost();
this.renderSessionTabs();
// Approvals Inbox: re-seed pending prompts from the server so alerts
// survive reloads and SSE reconnects (methods in approvals-ui.js).
this.seedApprovals?.();
// Start/stop system stats polling based on session count
if (this.sessions.size > 0) {
this.startSystemStatsPolling();
@@ -3527,6 +3686,8 @@ class CodemanApp {
// (create, delete, idle, working, exit, hook alerts via updateTabAlertFromHooks)
// already funnels through here. No-ops unless that surface is showing.
this._refreshMobileOverviewIfVisible?.();
// Same deal for the desktop home screen's tab column.
this._refreshHomeSessionsIfVisible?.();
}
// Auto-wrap desktop session tabs to a second row when they overflow one row,
+242
View File
@@ -0,0 +1,242 @@
/**
* @fileoverview Approvals Inbox UI: cross-session queue of prompts waiting on a human.
*
* Everything here is gated on the OPT-IN `approvalsInboxEnabled` setting
* (synced, default OFF): with it off, no bell, no drawer, no overview strips,
* no seeding. When on, the header bell renders only while items are pending
* (count badge), opening a right-side drawer of approval cards; pending items
* are seeded from `GET /api/approvals` on init/reconnect (so tab alerts
* survive a reload) and answered in place via `POST /api/approvals/:id/answer`. Cards render
* buttons from the server-parsed dialog options; without parsed options they
* fall back to Approve/Deny (permission/question) or a text prompt (idle).
* Backend: src/web/approval-inbox.ts, design: docs/approvals-inbox-plan.md.
*
* @mixin Extends CodemanApp.prototype via Object.assign
* @dependency app.js (CodemanApp class, this.approvals, setPendingHook/clearPendingHooks, selectSession)
* @dependency constants.js (escapeHtml)
* @dependency api-client.js at runtime (this._apiJson; loads later but is only called after init)
* @loadorder 11.6 of 17, after ultracode-panel.js, before admin-ui.js
*/
/** Map an approval kind to the pendingHooks entry that drives tab alerts. */
function approvalKindToHook(kind) {
return kind === 'permission' ? 'permission_prompt' : kind === 'question' ? 'elicitation_dialog' : 'idle_prompt';
}
Object.assign(CodemanApp.prototype, {
/** Synced setting, default OFF, opt-in via App Settings → Panels. */
approvalsInboxEnabled() {
return this.loadAppSettingsFromStorage().approvalsInboxEnabled === true;
},
/**
* Seed pending approvals from the server. Called from handleInit, i.e. on
* every page load AND SSE reconnect; this is what makes pending alerts
* survive a reload (pre-inbox they lived only in SSE-transient memory).
*/
async seedApprovals() {
if (!this.approvals) this.approvals = new Map();
this.approvals.clear();
if (this.approvalsInboxEnabled()) {
const data = await this._apiJson('/api/approvals');
for (const item of (data && data.approvals) || []) {
this.approvals.set(item.id, item);
// Re-arm the tab alert state machine (idempotent set-add).
this.setPendingHook(item.sessionId, approvalKindToHook(item.kind));
}
}
this.renderApprovals();
},
// ─── SSE handlers ────────────────────────────────────────────
_onApprovalPending(item) {
if (!item || !item.id) return;
if (!this.approvals) this.approvals = new Map();
// One active item per session (server invariant): drop any stale sibling.
for (const [id, existing] of this.approvals) {
if (existing.sessionId === item.sessionId) this.approvals.delete(id);
}
this.approvals.set(item.id, item);
this.renderApprovals();
},
_onApprovalUpdated(item) {
if (!item || !item.id || !this.approvals?.has(item.id)) return;
this.approvals.set(item.id, item);
this.renderApprovals();
},
_onApprovalResolved(info) {
if (!info || !info.id || !this.approvals) return;
if (this.approvals.delete(info.id)) {
// Clear the matching tab alert: the inbox resolves on more signals than
// the hook handlers do (superseded, expired, answered from another
// device), and clearPendingHooks is a no-op when nothing is set.
this.clearPendingHooks(info.sessionId, approvalKindToHook(info.kind));
this.renderApprovals();
}
},
// ─── Actions ─────────────────────────────────────────────────
async answerApproval(id, action, option) {
const body = option !== undefined ? { action, option } : { action };
const data = await this._apiJson(`/api/approvals/${encodeURIComponent(id)}/answer`, {
method: 'POST',
body,
});
if (data) {
this.showToast(action === 'deny' ? 'Denied' : 'Answer sent', 'success');
} else {
// 404/409 = resolved elsewhere or the dialog left the screen; refresh truth.
this.showToast('Could not answer, the prompt may already be resolved', 'warning');
this.seedApprovals();
}
},
/** Idle prompts: send the typed line from the card's input as a prompt. */
async answerApprovalIdleText(id) {
const input = document.getElementById(`approvalText-${id}`);
const text = input ? input.value.trim() : '';
if (!text) return;
const data = await this._apiJson(`/api/approvals/${encodeURIComponent(id)}/answer`, {
method: 'POST',
body: { action: 'text', text },
});
if (data) this.showToast('Prompt sent', 'success');
else {
this.showToast('Could not send, the session may be busy', 'warning');
this.seedApprovals();
}
},
async dismissApproval(id) {
await this._apiJson(`/api/approvals/${encodeURIComponent(id)}/dismiss`, { method: 'POST', body: {} });
// The SSE resolved event also lands; delete now for instant feedback.
if (this.approvals?.delete(id)) this.renderApprovals();
},
openApprovalSession(id) {
const item = this.approvals?.get(id);
if (!item) return;
this.closeApprovalsInbox();
if (this.sessions.has(item.sessionId)) this.selectSession(item.sessionId);
},
/**
* Push-notification action relay (sw.js → settings-ui notification-click →
* here). Falls back to opening the session when the item is unknown, or
* when the inbox is disabled (a stale notification from before the toggle
* flipped can still carry an action).
*/
handleNotificationAction(action, approvalId, sessionId) {
if ((action === 'approve' || action === 'deny') && approvalId && this.approvalsInboxEnabled()) {
this.answerApproval(approvalId, action);
return;
}
if (sessionId && this.sessions.has(sessionId)) this.selectSession(sessionId);
},
// ─── Rendering ───────────────────────────────────────────────
toggleApprovalsInbox() {
const drawer = document.getElementById('approvalsDrawer');
if (!drawer) return;
if (drawer.classList.contains('open')) this.closeApprovalsInbox();
else {
drawer.classList.add('open');
document.querySelector('.btn-approvals')?.setAttribute('aria-expanded', 'true');
this.renderApprovals();
}
},
closeApprovalsInbox() {
document.getElementById('approvalsDrawer')?.classList.remove('open');
document.querySelector('.btn-approvals')?.setAttribute('aria-expanded', 'false');
},
renderApprovals() {
const count = this.approvals ? this.approvals.size : 0;
const btn = document.querySelector('.btn-approvals');
if (btn) {
// Marker-class visibility (base header rules are display !important):
// the bell exists only while something is pending, so the header stays
// untouched for everyone else.
btn.classList.toggle('btn-approvals--hidden', count === 0 || !this.approvalsInboxEnabled());
const badge = document.getElementById('approvalsBadge');
if (badge) badge.textContent = String(count);
}
this.renderApprovalsDrawer();
// Phone overview NEEDS YOU rows re-render on the tab-render tail; nudge it
// so inline approve/deny buttons appear without a state change elsewhere.
this.renderSessionTabs?.();
},
renderApprovalsDrawer() {
const drawer = document.getElementById('approvalsDrawer');
if (!drawer || !drawer.classList.contains('open')) return;
const list = drawer.querySelector('.approvals-list');
if (!list) return;
const items = this.approvals ? [...this.approvals.values()].sort((a, b) => a.createdAt - b.createdAt) : [];
if (items.length === 0) {
list.innerHTML = '<div class="approvals-empty">No pending approvals</div>';
return;
}
list.innerHTML = items.map((item) => this._approvalCardHtml(item)).join('');
},
_approvalCardHtml(item) {
const id = escapeHtml(item.id);
const kindLabel = item.kind === 'permission' ? 'Permission' : item.kind === 'question' ? 'Question' : 'Idle';
const summary = item.toolName
? `${item.toolName}${item.toolSummary ? ': ' + item.toolSummary : ''}`
: item.message || '';
const age = this._approvalAge(item.createdAt);
let actions = '';
if (item.kind === 'idle') {
actions =
`<div class="approval-text-row">` +
`<input type="text" id="approvalText-${id}" class="approval-text-input" placeholder="Send a prompt…" data-i18n-skip ` +
`onkeydown="if(event.key==='Enter')app.answerApprovalIdleText('${id}')">` +
`<button class="approval-btn approval-btn-primary" onclick="app.answerApprovalIdleText('${id}')">Send</button>` +
`</div>`;
} else if (item.options && item.options.length) {
actions = item.options
.map(
(o) =>
`<button class="approval-btn ${o.n === 1 ? 'approval-btn-primary' : ''}" data-i18n-skip ` +
`title="${escapeHtml(o.label)}" onclick="app.answerApproval('${id}','option',${o.n})">` +
`${o.n}. ${escapeHtml(o.label.length > 42 ? o.label.slice(0, 42) + '…' : o.label)}</button>`
)
.join('');
} else {
actions =
`<button class="approval-btn approval-btn-primary" onclick="app.answerApproval('${id}','approve')">Approve</button>` +
`<button class="approval-btn approval-btn-danger" onclick="app.answerApproval('${id}','deny')">Deny (Esc)</button>`;
}
return (
`<div class="approval-card approval-kind-${item.kind}" data-approval-id="${id}">` +
`<div class="approval-card-head">` +
`<span class="approval-kind-badge">${kindLabel}</span>` +
`<span class="approval-session" data-i18n-skip>${escapeHtml(item.sessionName || item.sessionId.slice(0, 8))}</span>` +
`<span class="approval-age" data-i18n-skip>${age}</span>` +
`</div>` +
(summary ? `<div class="approval-summary" data-i18n-skip>${escapeHtml(summary)}</div>` : '') +
(item.context ? `<pre class="approval-context">${escapeHtml(item.context)}</pre>` : '') +
`<div class="approval-actions">${actions}</div>` +
`<div class="approval-meta-actions">` +
`<button class="approval-link" onclick="app.openApprovalSession('${id}')">Open session</button>` +
`<button class="approval-link" onclick="app.dismissApproval('${id}')">Dismiss</button>` +
`</div>` +
`</div>`
);
},
_approvalAge(createdAt) {
const s = Math.max(0, Math.floor((Date.now() - createdAt) / 1000));
if (s < 60) return `${s}s`;
if (s < 3600) return `${Math.floor(s / 60)}m`;
return `${Math.floor(s / 3600)}h`;
},
});
+86
View File
@@ -180,6 +180,81 @@ function planWsReconnect(code, attempt) {
return { action: 'reconnect', delayMs };
}
// Connection-loss UI policy.
//
// With the service worker serving the cached app shell, Codeman still *renders*
// when the server is unreachable (phone off the tailnet, VPN down, server
// stopped): a dashboard with no sessions and an 8px red dot in the header
// corner. That reads as "there are no sessions", not "you are not connected".
// This decides what the app surfaces instead:
//
// 'overlay': full-screen "can't reach Codeman". Used while the page has
// never loaded server state, where the UI behind it is empty
// anyway, so blocking it costs nothing and explains everything.
// 'banner': non-blocking bar under the header. Used once state HAS loaded,
// so the terminal scrollback stays readable while the link is down.
// 'hidden': connected, or still inside the grace window.
//
// Grace: a COM deploy restarts the server and SSE is back in ~200ms. Shouting
// on every deploy trains the user to ignore the warning, so a transport that is
// merely *not yet connected* gets CONNECTION_LOSS_GRACE_MS to recover.
// `navigator.onLine === false` skips the grace entirely: the device itself is
// saying there is no network, which is never a 200ms blip.
//
// Pure: no DOM, no timers, no side effects. `now` is passed in.
const CONNECTION_LOSS_GRACE_MS = 2500;
function computeConnectionLossUi(input) {
const {
isOnline = true,
status = 'connected',
everLoaded = false,
downSince = null,
now = 0,
nextRetryAt = null,
overlayDismissed = false,
retryPending = false,
} = input || {};
const hidden = { mode: 'hidden', kind: 'connected', title: '', detail: '', retryInSec: null };
// The browser's own offline flag outranks the transport state: no network
// means no reconnect is coming until it returns.
const hardOffline = !isOnline || status === 'offline';
if (!hardOffline) {
if (status === 'connected') return hidden;
const downMs = downSince == null ? 0 : Math.max(0, now - downSince);
if (downMs < CONNECTION_LOSS_GRACE_MS) return { ...hidden, kind: 'connecting' };
}
// Dismissing the overlay ("show cached view") demotes it to the banner for
// the rest of this outage, never back to invisible.
const mode = everLoaded || overlayDismissed ? 'banner' : 'overlay';
// A retry the user just triggered has no scheduled time; the caller renders
// an indeterminate "Retrying…" for null.
const retryInSec =
retryPending || nextRetryAt == null ? null : Math.max(0, Math.ceil((nextRetryAt - now) / 1000));
if (hardOffline) {
return {
mode,
kind: 'offline',
title: 'No network connection',
detail: 'This device is offline. Codeman is showing the last cached view.',
retryInSec,
};
}
return {
mode,
kind: 'unreachable',
title: "Can't reach the Codeman server",
detail:
'This device has a network, but the Codeman server is not answering. ' +
'If you reach Codeman over Tailscale or a VPN, check that it is connected.',
retryInSec,
};
}
if (typeof window !== 'undefined') {
window.WEBGL_FALLBACK = WEBGL_FALLBACK;
window.evaluateWebGLLongTaskTrip = evaluateWebGLLongTaskTrip;
@@ -190,6 +265,10 @@ if (typeof window !== 'undefined') {
window.CodemanWsReconnect = {
plan: planWsReconnect,
};
window.CodemanConnectionLoss = {
compute: computeConnectionLossUi,
GRACE_MS: CONNECTION_LOSS_GRACE_MS,
};
}
// Scheduler API — prioritize terminal writes over background UI updates.
@@ -408,10 +487,17 @@ const SSE_EVENTS = {
HOOK_IDLE_PROMPT: 'hook:idle_prompt',
HOOK_PERMISSION_PROMPT: 'hook:permission_prompt',
HOOK_ELICITATION_DIALOG: 'hook:elicitation_dialog',
HOOK_ELICITATION_COMPLETE: 'hook:elicitation_complete',
HOOK_ELICITATION_RESPONSE: 'hook:elicitation_response',
HOOK_STOP: 'hook:stop',
HOOK_TEAMMATE_IDLE: 'hook:teammate_idle',
HOOK_TASK_COMPLETED: 'hook:task_completed',
// Approvals Inbox
APPROVAL_PENDING: 'approval:pending',
APPROVAL_UPDATED: 'approval:updated',
APPROVAL_RESOLVED: 'approval:resolved',
// Subagents (Claude Code background agents)
SUBAGENT_DISCOVERED: 'subagent:discovered',
SUBAGENT_UPDATED: 'subagent:updated',
+335
View File
@@ -0,0 +1,335 @@
/**
* @fileoverview Desktop home screen session list: the open tabs as a vertical
* column down the left of the welcome overlay.
*
* The welcome screen centers ~560px of content in a window that is usually
* 1400px+, so the two gutters are dead space. The left one now carries the same
* list a phone gets on its home screen (mobile-overview.js), turned vertical:
* one row per live tab, in TAB ORDER (not sorted by state) so it reads as the
* tab strip rotated, and so Alt+1..9 still matches what you see.
*
* DESKTOP ONLY, and only in a wide enough window: the column is absolutely
* positioned so the centered welcome content never moves, which means it can
* only exist where the gutter is genuinely wider than the column. Below
* `HOME_SESSIONS_MIN_WIDTH` nothing renders; on a phone the mobile overview owns
* the home screen entirely and this surface stays out of its way.
*
* The working state is deliberately identical to the phone's: a pulsing green
* dot ringed by the spinner a tab shows while it loads (`tab-load-spin`, reused
* from styles.css), plus a green halo. Same signal, same motion, both surfaces.
*
* Everything renders from state the page already holds (`this.sessions`,
* `this.cases`, `this.pendingHooks`, `this.webviews`) — no endpoint, no SSE
* event, no schema. State classification and case matching are reused from
* mobile-overview.js rather than re-derived, so the two home screens can never
* disagree about what "working" means.
*
* @mixin Extends CodemanApp.prototype via Object.assign
* @dependency app.js (this.sessions, this.cases, this.pendingHooks, selectSession)
* @dependency mobile-overview.js (_mobileOverviewState, _mobileOverviewCaseFor, shouldUseMobileOverview)
* @dependency webview-tabs.js (this.webviews, this.webviewOrder, openWebview)
* @dependency mobile-handlers.js (MobileDetection)
* @loadorder 12.56 of 16, after mobile-overview.js, before entrance-animations.js
*/
/**
* Narrowest window that gets the column. The welcome content is 560px wide and
* centered, so at 1180px each gutter is 310px — enough for the 256px column plus
* its 20px offset and still a visible gap. Anything narrower would overlap the
* search panel, which is why this is a width gate and not a device-type gate.
*/
const HOME_SESSIONS_MIN_WIDTH = 1180;
/** Pill copy per state. Same words as the phone overview, same reasons. */
const HOME_SESSIONS_PILL_LABEL = {
needs: 'needs you',
error: 'error',
waiting: 'waiting',
working: 'working',
idle: 'idle',
done: 'done',
};
/** Short backend badge, mirroring `.tab-mode` in the tab strip. */
const HOME_SESSIONS_MODE_BADGE = {
shell: 'sh',
opencode: 'oc',
codex: 'cx',
gemini: 'gm',
antigravity: 'ag',
};
Object.assign(CodemanApp.prototype, {
// ═══════════════════════════════════════════════════════════════
// Gate + visibility
// ═══════════════════════════════════════════════════════════════
/**
* Width-driven, like every other layout decision in the app. Explicitly yields
* to the phone overview: that surface already lists the same sessions, and two
* lists of the same thing on one screen is worse than none.
*/
shouldShowHomeSessions() {
if (this.isSoloWindow) return false;
if (this.shouldUseMobileOverview?.()) return false;
return window.innerWidth >= HOME_SESSIONS_MIN_WIDTH;
},
/** True while the column is the visible home surface. */
isHomeSessionsVisible() {
const el = document.getElementById('homeSessions');
return !!el && !el.hidden;
},
showHomeSessions() {
const el = document.getElementById('homeSessions');
if (!el) return;
this._wireHomeSessions(el);
if (!this.shouldShowHomeSessions()) {
el.hidden = true;
return;
}
el.hidden = false;
this.renderHomeSessions();
},
hideHomeSessions() {
const el = document.getElementById('homeSessions');
if (el) el.hidden = true;
},
/** Re-render only when showing (called from the tab renderer's tail). */
_refreshHomeSessionsIfVisible() {
if (!this.isHomeSessionsVisible()) return;
this._debouncedCall('homeSessions', () => this.renderHomeSessions(), 150);
},
/**
* One delegated click listener for every row, plus a width listener so
* resizing the window while on the home screen adds or drops the column
* instead of leaving it overlapping the content it was sized to clear.
*/
_wireHomeSessions(el) {
if (this._homeSessionsWired) return;
this._homeSessionsWired = true;
el.addEventListener('click', (event) => {
const target = event.target?.closest?.('[data-hs-action]');
if (!target) return;
if (target.dataset.hsAction === 'session') {
void this.selectSession(target.dataset.hsSession);
} else if (target.dataset.hsAction === 'webview') {
void this.openWebview?.(target.dataset.hsWebview);
}
});
if (window.matchMedia) {
const mq = window.matchMedia(`(min-width: ${HOME_SESSIONS_MIN_WIDTH}px)`);
const onChange = () => {
// Only relevant while the welcome screen is up; entering a session
// re-decides through hideWelcome()/showWelcome() anyway.
if (this.activeSessionId) return;
const overlay = document.getElementById('welcomeOverlay');
if (!overlay || !overlay.classList.contains('visible')) return;
this.showHomeSessions();
};
if (mq.addEventListener) mq.addEventListener('change', onChange);
else if (mq.addListener) mq.addListener(onChange);
}
},
// ═══════════════════════════════════════════════════════════════
// Model
// ═══════════════════════════════════════════════════════════════
/**
* One row per live session, in the user's tab order. State classification is
* `_mobileOverviewState()` (mobile-overview.js) so both home screens agree on
* what counts as needing you; the ORDER differs on purpose — the phone sorts
* by urgency because it shows one screenful at a time, this column mirrors the
* tab strip so the number badges line up with Alt+1..9.
* @returns {Array<object>} row descriptors, ready to render
*/
buildHomeSessionRows() {
const cases = Array.isArray(this.cases) ? this.cases : [];
const order = Array.isArray(this.sessionOrder) ? this.sessionOrder : [];
const ids = order.filter((id) => this.sessions?.has(id));
// A session created before the order list caught up would otherwise be
// invisible here while its tab already exists.
for (const id of this.sessions?.keys() || []) if (!ids.includes(id)) ids.push(id);
return ids.map((id, index) => {
const session = this.sessions.get(id);
const matched = this._mobileOverviewCaseFor(session.workingDir, cases);
const state = this._mobileOverviewState(session, this.pendingHooks?.get(id));
const mode = session.mode || 'claude';
return {
id,
index,
name: this.getSessionName ? this.getSessionName(session) : session.name || id.slice(0, 8),
mode,
modeBadge: HOME_SESSIONS_MODE_BADGE[mode] || '',
caseName: matched ? matched.name : '',
dir: this._shortenHomePath ? this._shortenHomePath(session.workingDir) : session.workingDir || '',
state,
pill: HOME_SESSIONS_PILL_LABEL[state] || state,
};
});
},
// ═══════════════════════════════════════════════════════════════
// Render
// ═══════════════════════════════════════════════════════════════
renderHomeSessions() {
const el = document.getElementById('homeSessions');
if (!el) return;
const rows = this.buildHomeSessionRows();
const webviews = (this.webviewOrder || []).map((id) => this.webviews?.get(id)).filter(Boolean);
// Nothing open means nothing to list: an empty framed box next to a
// first-run welcome screen is noise, not information.
if (!rows.length && !webviews.length) {
el.hidden = true;
el.replaceChildren();
return;
}
el.hidden = false;
el.replaceChildren();
el.appendChild(this._buildHomeSessionsHeader(rows.length + webviews.length));
const list = document.createElement('div');
list.className = 'home-sessions-list';
for (const row of rows) list.appendChild(this._buildHomeSessionRow(row));
for (const webview of webviews) list.appendChild(this._buildHomeSessionsWebviewRow(webview));
el.appendChild(list);
},
_buildHomeSessionsHeader(count) {
const header = document.createElement('div');
header.className = 'home-sessions-header';
const label = document.createElement('span');
label.className = 'home-sessions-title';
label.textContent = 'Open tabs';
header.appendChild(label);
const badge = document.createElement('span');
badge.className = 'home-sessions-count';
badge.setAttribute('data-i18n-skip', '');
badge.textContent = String(count);
header.appendChild(badge);
return header;
},
/**
* A session row. The state class drives the same visual language as the
* session tabs and the phone overview: green dot when it is fine (pulsing and
* ringed by the load spinner while working), a yellow row when it wants input,
* a red row when it asked a question.
*/
_buildHomeSessionRow(row) {
const item = document.createElement('button');
item.type = 'button';
item.className = 'home-sessions-row home-sessions-row--' + row.state;
item.dataset.hsAction = 'session';
item.dataset.hsSession = row.id;
item.title = row.dir ? `${row.name} (${row.dir})` : row.name;
if (row.index < 9) {
const number = document.createElement('span');
number.className = 'home-sessions-number';
number.setAttribute('data-i18n-skip', '');
number.textContent = String(row.index + 1);
item.appendChild(number);
}
const dot = document.createElement('span');
dot.className = 'home-sessions-dot home-sessions-dot--' + row.state;
dot.setAttribute('aria-hidden', 'true');
item.appendChild(dot);
const body = document.createElement('span');
body.className = 'home-sessions-row-body';
const line1 = document.createElement('span');
line1.className = 'home-sessions-row-title';
if (row.modeBadge) {
const badge = document.createElement('span');
badge.className = `home-sessions-mode ${row.mode}`;
badge.setAttribute('data-i18n-skip', '');
badge.textContent = row.modeBadge;
line1.appendChild(badge);
}
const name = document.createElement('span');
// .session-name is in the i18n skip list: a session name is user content.
name.className = 'session-name';
name.textContent = row.name;
line1.appendChild(name);
body.appendChild(line1);
const line2 = document.createElement('span');
line2.className = 'home-sessions-row-sub';
line2.setAttribute('data-i18n-skip', '');
line2.textContent = row.caseName || row.dir || row.mode;
body.appendChild(line2);
item.appendChild(body);
const pill = document.createElement('span');
pill.className = 'home-sessions-pill home-sessions-pill--' + row.state;
// Skipped by i18n on purpose: generic single words ("idle", "done", "error")
// that collide with state strings on other surfaces.
pill.setAttribute('data-i18n-skip', '');
pill.textContent = row.pill;
item.appendChild(pill);
return item;
},
/** A saved dashboard, listed after the sessions exactly as in the tab strip. */
_buildHomeSessionsWebviewRow(webview) {
const item = document.createElement('button');
item.type = 'button';
item.className = 'home-sessions-row home-sessions-row--web';
item.dataset.hsAction = 'webview';
item.dataset.hsWebview = webview.id;
item.title = webview.url || webview.name;
const dot = document.createElement('span');
dot.className = 'home-sessions-dot home-sessions-dot--web';
dot.setAttribute('aria-hidden', 'true');
item.appendChild(dot);
const body = document.createElement('span');
body.className = 'home-sessions-row-body';
const title = document.createElement('span');
title.className = 'home-sessions-row-title';
const name = document.createElement('span');
// A dashboard name is user content.
name.className = 'case-name';
name.textContent = webview.name;
title.appendChild(name);
body.appendChild(title);
const sub = document.createElement('span');
sub.className = 'home-sessions-row-sub';
sub.setAttribute('data-i18n-skip', '');
sub.textContent = webview.url || '';
body.appendChild(sub);
item.appendChild(body);
const pill = document.createElement('span');
pill.className = 'home-sessions-pill home-sessions-pill--web';
pill.setAttribute('data-i18n-skip', '');
pill.textContent = 'web';
item.appendChild(pill);
return item;
},
});
+19
View File
@@ -235,6 +235,22 @@
Subagents: '子智能体',
'Ultracode Agents': 'Ultracode 智能体',
'Ultracode Floating Windows': 'Ultracode 浮动窗口',
'Approvals Inbox': '审批收件箱',
Approvals: '审批',
'Prompts waiting on you, across all sessions': '所有会话中等待您处理的提示',
'No pending approvals': '没有待处理的审批',
'Approvals waiting on you': '等待您审批的请求',
'Open approvals inbox': '打开审批收件箱',
'Close approvals inbox': '关闭审批收件箱',
Approve: '批准',
'Deny (Esc)': '拒绝 (Esc)',
Deny: '拒绝',
'Open session': '打开会话',
Dismiss: '忽略',
Send: '发送',
Permission: '权限',
Question: '问题',
Idle: '空闲',
'Subagent Options': '子智能体选项',
'Enable Tracking': '启用跟踪',
'Active Tab Only': '仅活动标签页',
@@ -371,6 +387,9 @@
'在手机上,点击 C 图标打开会话概览(需要你 / 空间 / 空闲),而不是欢迎页',
Phone: '手机',
// Desktop home screen tab column (home-sessions.js)
'Open tabs': '打开的标签',
// Session/case dialogs
'Session Options': '会话选项',
'Session Name': '会话名称',
+74
View File
@@ -131,6 +131,10 @@
<button class="btn-icon-header btn-response-viewer-header btn-response-viewer-header--hidden" onclick="app.toggleResponseViewer()" title="View last response" aria-label="View last response"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
<button class="btn-icon-header btn-away-digest btn-away-digest--hidden" onclick="app.openAwayDigest()" title="Away Digest" aria-label="Open away digest"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/></svg></button>
<button class="btn-icon-header btn-session-manager btn-session-manager--hidden" onclick="app.openSessionManager()" title="Session Manager" aria-label="Open session manager"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg></button>
<button class="btn-icon-header btn-approvals btn-approvals--hidden" id="approvalsBtn" onclick="app.toggleApprovalsInbox()" title="Approvals waiting on you" aria-label="Open approvals inbox" aria-expanded="false">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
<span class="approvals-badge" id="approvalsBadge">0</span>
</button>
<button class="btn-icon-header btn-attachments-history btn-attachments-history--hidden" id="attachmentsHistoryBtn" onclick="app.toggleAttachmentHistory()" title="Attachments" aria-label="Open attachment history" aria-expanded="false">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
<span class="attachment-history-badge" id="attachmentHistoryBadge" style="display:none;">0</span>
@@ -149,6 +153,16 @@
</div>
</header>
<!-- Connection-loss banner: shown once session state HAS loaded and the
link then drops, so the terminal stays readable behind it.
The blocking variant is #offlineOverlay at the end of <body>. -->
<div class="offline-banner" id="offlineBanner" role="status" hidden>
<span class="offline-banner-dot" aria-hidden="true"></span>
<span class="offline-banner-text" id="offlineBannerText">No connection to the Codeman server</span>
<span class="offline-banner-detail" id="offlineBannerDetail"></span>
<button class="offline-banner-retry" id="offlineBannerRetry" onclick="app.retryConnection()">Retry now</button>
</div>
<!-- Timer Banner (shown when timed run is active) -->
<div class="timer-banner" id="timerBanner" style="display: none;">
<div class="timer-content">
@@ -308,6 +322,11 @@
<!-- Welcome Overlay (shown when no session active) -->
<div class="welcome-overlay" id="welcomeOverlay">
<!-- Open tabs as a vertical column in the left gutter (home-sessions.js).
Absolutely positioned so the centered content below never moves, and
therefore only rendered where the gutter is wider than the column;
ships `hidden` and only that module reveals it. -->
<aside class="home-sessions" id="homeSessions" hidden></aside>
<div class="welcome-content">
<h1 class="welcome-title">Codeman</h1>
<p class="welcome-desc">Manage AI Coding tools in persistent tmux sessions.</p>
@@ -407,6 +426,7 @@
<div class="file-browser-header">
<span class="file-browser-title">Files</span>
<div class="file-browser-actions">
<button class="btn-icon-sm btn-file-browser-hidden" onclick="app.toggleFileBrowserHidden()" title="Show hidden files and folders" aria-label="Show hidden files and folders" aria-pressed="false" id="fileBrowserHiddenBtn">.*</button>
<button class="btn-icon-sm" onclick="app.refreshFileBrowser()" title="Refresh">&#x21BB;</button>
<button class="btn-icon-sm" onclick="app.toggleFileBrowserExpand()" title="Expand/Collapse All" id="fileBrowserExpandBtn">&#x229E;</button>
<button class="btn-icon-sm" onclick="app.closeFileBrowserPanel()" title="Close">&times;</button>
@@ -1522,6 +1542,13 @@
<span class="slider"></span>
</label>
</div>
<div class="settings-item" title="Cross-session inbox of prompts waiting on you (permission dialogs, questions, idle prompts) with answer-in-place buttons; the header bell appears only while something is pending">
<span class="settings-item-label">Approvals Inbox</span>
<label class="switch switch-sm">
<input type="checkbox" id="appSettingsApprovalsInbox">
<span class="slider"></span>
</label>
</div>
<div class="settings-item" title="Show ultracode / Workflow runs as a master-detail tab (tasks on the left, agents with tokens + tool calls on the right)">
<span class="settings-item-label">Ultracode Agents</span>
<label class="switch switch-sm">
@@ -2725,6 +2752,51 @@
<!-- Lines drawn dynamically -->
</svg>
<!-- Connection-loss overlay: the app shell is served from the service-worker
cache, so Codeman renders even with nothing reachable. Without this, that
looks like an empty dashboard rather than a dead connection. Only shown
while no server state has loaded this page load. -->
<div class="offline-overlay" id="offlineOverlay" hidden>
<div class="offline-overlay-card" role="alertdialog" aria-labelledby="offlineOverlayTitle" aria-describedby="offlineOverlayBody">
<div class="offline-overlay-icon" aria-hidden="true">
<svg width="46" height="46" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="M1 1l22 22"/>
<path d="M16.72 11.06A10.94 10.94 0 0 1 19 12.55"/>
<path d="M5 12.55a10.94 10.94 0 0 1 5.17-2.39"/>
<path d="M10.71 5.05A16 16 0 0 1 22.58 9"/>
<path d="M1.42 9a15.91 15.91 0 0 1 4.7-2.88"/>
<path d="M8.53 16.11a6 6 0 0 1 6.95 0"/>
<line x1="12" y1="20" x2="12.01" y2="20"/>
</svg>
</div>
<h2 class="offline-overlay-title" id="offlineOverlayTitle">Can't reach the Codeman server</h2>
<p class="offline-overlay-body" id="offlineOverlayBody"></p>
<div class="offline-overlay-host" id="offlineOverlayHost"></div>
<ul class="offline-overlay-hints">
<li>Check Wi-Fi or mobile data</li>
<li>Check your VPN / Tailscale is connected</li>
<li>Check the Codeman server is still running</li>
</ul>
<div class="offline-overlay-actions">
<button class="offline-overlay-btn offline-overlay-btn--primary" id="offlineOverlayRetry" onclick="app.retryConnection()">Retry now</button>
<button class="offline-overlay-btn" onclick="app.dismissOfflineOverlay()">Show cached view</button>
</div>
<div class="offline-overlay-status" id="offlineOverlayStatus">Retrying…</div>
</div>
</div>
<!-- Approvals Inbox drawer (populated by approvals-ui.js; opened from the header bell) -->
<div class="approvals-drawer" id="approvalsDrawer" role="complementary" aria-label="Approvals inbox">
<div class="approvals-header">
<div>
<div class="approvals-title">Approvals</div>
<div class="approvals-subtitle">Prompts waiting on you, across all sessions</div>
</div>
<button class="approvals-close" onclick="app.closeApprovalsInbox()" title="Close" aria-label="Close approvals inbox">✕</button>
</div>
<div class="approvals-list"></div>
</div>
<script defer src="constants.js"></script>
<script defer src="i18n.js"></script>
<script defer src="mobile-handlers.js"></script>
@@ -2743,10 +2815,12 @@
<script defer src="settings-ui.js"></script>
<script defer src="panels-ui.js"></script>
<script defer src="ultracode-panel.js"></script>
<script defer src="approvals-ui.js"></script>
<script defer src="admin-ui.js"></script>
<script defer src="session-ui.js"></script>
<script defer src="webview-tabs.js"></script>
<script defer src="mobile-overview.js"></script>
<script defer src="home-sessions.js"></script>
<script defer src="entrance-animations.js"></script>
<script defer src="ralph-wizard.js"></script>
<script defer src="api-client.js"></script>
+71 -5
View File
@@ -4,10 +4,12 @@
* Defines three exports:
*
* - KeyboardAccessoryBar (singleton object) — Quick action buttons shown above the virtual
* keyboard on mobile: arrow up/down, /init, /clear, /compact, paste, Esc, and dismiss.
* keyboard on mobile: arrow up/down, /init, Tab, paste, Esc, and dismiss (the extended
* bar adds /clear, /compact, Shift+Tab and more). Tab flushes any locally-buffered
* prompt text to the PTY before sending \t, so completion applies to what was typed.
* The paste button opens a dialog that handles both text paste and image attach
* (native picker + best-effort image paste, routed through app._uploadAndInsertImages).
* Destructive actions (/clear, /compact) require double-tap confirmation (2s amber state).
* Destructive actions (/clear, /compact, extended bar only) require double-tap confirmation (2s amber state).
* Commands are sent as text + Enter separately for Ink compatibility.
* Only initializes on touch devices (MobileDetection.isTouchDevice guard).
* - PathPicker (singleton object) — Lazy server-side file/folder browser shared
@@ -33,6 +35,12 @@
// Shared Filesystem Path Picker
// ═══════════════════════════════════════════════════════════════
// Per-device, and deliberately its own key rather than a shared "show hidden"
// preference with the File Viewer: that tree is confined to one workspace, while
// the picker browses Home and every configured root, so wanting dotfiles in a
// project does not imply wanting them in ~.
const PATH_PICKER_SHOW_HIDDEN_KEY = 'codeman:pathPickerShowHidden';
const PathPicker = {
overlay: null,
_options: null,
@@ -43,6 +51,7 @@ const PathPicker = {
_previewOverlay: null,
_previewRequestSequence: 0,
_previewPreviousFocus: null,
_showHidden: false,
/**
* Open the lazy filesystem browser.
@@ -53,6 +62,7 @@ const PathPicker = {
this.close(false);
this._options = options;
this._selectedPath = '';
this._showHidden = this._loadShowHidden();
this._previousFocus = document.activeElement;
this._previousFocus?.blur?.();
@@ -74,6 +84,7 @@ const PathPicker = {
<div class="path-picker-nav">
<button type="button" class="path-picker-up" title="Parent folder" aria-label="Parent folder">&#x2191;</button>
<div class="path-picker-current" title="Current folder"></div>
<button type="button" class="path-picker-hidden" title="Show hidden files and folders" aria-label="Show hidden files and folders" aria-pressed="false">.*</button>
<button type="button" class="path-picker-refresh" title="Refresh" aria-label="Refresh">&#x21BB;</button>
</div>
<div class="path-picker-status" aria-live="polite">Loading...</div>
@@ -100,6 +111,8 @@ const PathPicker = {
if (current) this.select(current);
});
overlay.querySelector('.path-picker-refresh').addEventListener('click', () => this.load());
overlay.querySelector('.path-picker-hidden').addEventListener('click', () => this.toggleHidden());
this._syncHiddenButton();
overlay.querySelector('.path-picker-up').addEventListener('click', () => {
const parent = overlay.querySelector('.path-picker-up').dataset.parent;
if (parent) this.load(parent);
@@ -120,6 +133,38 @@ const PathPicker = {
this.load(options.initialPath || '');
},
_loadShowHidden() {
try {
return localStorage.getItem(PATH_PICKER_SHOW_HIDDEN_KEY) === '1';
} catch {
return false;
}
},
_syncHiddenButton() {
const btn = this.overlay?.querySelector('.path-picker-hidden');
if (!btn) return;
const label = this._showHidden ? 'Hide hidden files and folders' : 'Show hidden files and folders';
btn.classList.toggle('active', this._showHidden);
btn.setAttribute('aria-pressed', this._showHidden ? 'true' : 'false');
btn.setAttribute('title', label);
btn.setAttribute('aria-label', label);
},
toggleHidden() {
if (!this.overlay) return;
this._showHidden = !this._showHidden;
try {
localStorage.setItem(PATH_PICKER_SHOW_HIDDEN_KEY, this._showHidden ? '1' : '0');
} catch {}
this._syncHiddenButton();
// Reload where we are rather than resetting to the root. Turning the toggle
// OFF inside a hidden folder makes the current path unbrowsable again; the
// server answers 403 and load()'s catch falls back to the default root,
// which is the only place left to stand.
this.load(this.overlay.querySelector('.path-picker-current').textContent || '');
},
async load(path) {
if (!this.overlay || !this._options) return;
const loadSequence = ++this._loadSequence;
@@ -131,6 +176,7 @@ const PathPicker = {
const params = new URLSearchParams();
if (path) params.set('path', path);
if (this._options.sessionId) params.set('sessionId', this._options.sessionId);
if (this._showHidden) params.set('showHidden', 'true');
try {
const response = await fetch(`/api/filesystem/browse?${params.toString()}`);
const result = await response.json();
@@ -248,6 +294,9 @@ const PathPicker = {
const requestSequence = ++this._previewRequestSequence;
const params = new URLSearchParams({ path: entry.path });
if (this._options?.sessionId) params.set('sessionId', this._options.sessionId);
// A hidden file is only reachable while the toggle is on, and the preview
// endpoint re-resolves the path independently, so it needs the flag too.
if (this._showHidden) params.set('showHidden', 'true');
const previewUrl = `/api/filesystem/preview?${params.toString()}`;
const overlay = document.createElement('div');
@@ -385,7 +434,7 @@ const KeyboardAccessoryBar = {
</svg>
</button>
<button class="accessory-btn" data-action="init" title="/init">/init</button>
<button class="accessory-btn" data-action="clear" title="/clear">/clear</button>
<button class="accessory-btn" data-action="tab" title="Tab">Tab</button>
<button class="accessory-btn" data-action="paste" title="Paste from clipboard">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>
@@ -515,9 +564,26 @@ const KeyboardAccessoryBar = {
case 'opt-enter':
this.sendKey('\x1b\r');
break;
case 'tab':
this.sendKey('\t');
case 'tab': {
// Tab means "complete what I just typed", but with local echo the typed
// text is still buffered in the overlay and has never reached the PTY —
// a bare \t would ask the CLI to complete an empty composer. Flush the
// pending text first (same steps as the Shift+Enter branch in
// terminal-ui.js), then send \t after the sendCommand settle delay.
const overlay = app._localEchoOverlay;
const pending = (app._localEchoEnabled && overlay?.pendingText) || '';
if (pending) {
overlay.clear();
overlay.suppressBufferDetection?.();
app._flushedOffsets?.delete(app.activeSessionId);
app._flushedTexts?.delete(app.activeSessionId);
app.sendInput(pending);
setTimeout(() => this.sendKey('\t'), 120);
} else {
this.sendKey('\t');
}
break;
}
case 'shift-tab':
this.sendKey('\x1b[Z');
break;
+49
View File
@@ -639,9 +639,58 @@ Object.assign(CodemanApp.prototype, {
chevron.textContent = '›';
item.appendChild(chevron);
// Approvals Inbox: a pending dialog for this session gets an answer strip
// BELOW the row (the row itself is a <button>, so actions cannot nest
// inside it). Tapping the row still opens the session, unchanged.
const approval = this._pendingApprovalForSession(row.id);
if (approval) {
const wrap = document.createElement('div');
wrap.className = 'mobile-overview-row-wrap';
wrap.appendChild(item);
wrap.appendChild(this._buildMobileOverviewApprovalStrip(approval));
return wrap;
}
return item;
},
/** The session's pending approval, when the strip should render (dialogs only). */
_pendingApprovalForSession(sessionId) {
if (!this.approvals || !this.approvalsInboxEnabled || !this.approvalsInboxEnabled()) return null;
for (const item of this.approvals.values()) {
if (item.sessionId === sessionId && item.kind !== 'idle') return item;
}
return null;
},
/** Compact answer buttons for a NEEDS YOU row: parsed options, else Approve/Deny. */
_buildMobileOverviewApprovalStrip(approval) {
const strip = document.createElement('div');
strip.className = 'mobile-overview-approval-strip';
strip.setAttribute('data-i18n-skip', '');
const addBtn = (label, cls, onTap) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'mobile-overview-approval-btn' + (cls ? ' ' + cls : '');
btn.textContent = label;
btn.addEventListener('click', (ev) => {
ev.stopPropagation();
onTap();
});
strip.appendChild(btn);
};
if (approval.options && approval.options.length) {
for (const o of approval.options) {
const label = o.label.length > 24 ? o.label.slice(0, 24) + '…' : o.label;
addBtn(`${o.n}. ${label}`, o.n === 1 ? 'primary' : '', () => this.answerApproval(approval.id, 'option', o.n));
}
} else {
addBtn('Approve', 'primary', () => this.answerApproval(approval.id, 'approve'));
addBtn('Deny', 'danger', () => this.answerApproval(approval.id, 'deny'));
}
return strip;
},
/** A past conversation. Tapping it resumes, which creates a fresh session. */
_buildMobileOverviewPastRow(row) {
const item = document.createElement('button');
+232 -11
View File
@@ -350,16 +350,53 @@ html.mobile-init .file-browser-panel {
Phone Breakpoint (<430px)
============================================================================ */
@media (max-width: 430px) {
/* Phones get a 44px header, up from 36px. Every header control is a touch
target and 44px is the floor for one; the brand "C" that gets you home is
the one that matters most. Redefined as the TOKEN rather than a literal so
the panels positioned off `var(--header-height)` (file browser, insights,
plan overlays in styles.css) follow it instead of drifting 8px under the
header. Costs 8px of terminal height on a phone. */
:root {
--header-height: 44px;
}
/* Phone brand collapses to a single "C" home button: hide the wordmark,
keep the tap target */
.header-brand {
padding-right: 0.25rem;
margin-right: 0.2rem;
padding-right: 0;
margin-right: 0.1rem;
border-right: none;
/* styles.css sizes this to the FULL header height, which is taller than the
header's padding box; centred by the header's `align-items: center` above,
that overflow is symmetric and the button lands flush with both edges.
Do not "fix" it with `height: 100%`: the header sets min/max-height and no
height, so the percentage has no definite containing block to resolve
against and silently falls back to auto. */
}
/* The "C" was a 0.85rem inline span — about a 12x13px hit area, far under the
44px minimum, on the one control that gets you back to the home screen.
It is now a real 44x44 button: 44px wide, and the full height of the phone
header, which is itself 44px for exactly this reason. The negative margin
spends the header's OWN left padding on the target instead of pushing the
tab strip right. */
.header-brand .logo {
font-size: 0.85rem;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 44px;
/* Taller than its parent on purpose: centred in the padded brand box, this
makes the button fill all 36 header pixels edge to edge. */
height: var(--header-height);
margin-left: -0.3rem;
font-size: 1.15rem;
line-height: 1;
border-radius: 8px;
-webkit-tap-highlight-color: transparent;
}
.header-brand .logo:active {
background: rgba(96, 165, 250, 0.16);
}
.header-brand .logo .logo-text {
@@ -404,8 +441,12 @@ html.mobile-init .file-browser-panel {
top: 0;
left: 0;
right: 0;
min-height: 36px;
max-height: 36px;
min-height: var(--header-height);
max-height: var(--header-height);
/* styles.css top-aligns header children. That read as centred while the bar
was 36px and its contents ~31px; in a 44px bar it leaves a visible gap
under everything. */
align-items: center;
padding: 0.15rem 0.3rem;
padding-left: calc(0.3rem + var(--safe-area-left));
padding-right: calc(0.3rem + var(--safe-area-right));
@@ -420,8 +461,8 @@ html.mobile-init .file-browser-panel {
/* iOS safe area adjustment for fixed header - header extends into notch area */
.ios-device .header {
padding-top: calc(0.15rem + var(--safe-area-top));
min-height: calc(36px + var(--safe-area-top));
max-height: calc(36px + var(--safe-area-top));
min-height: calc(var(--header-height) + var(--safe-area-top));
max-height: calc(var(--header-height) + var(--safe-area-top));
}
/* Push ALL content below fixed header (not just .main) so banners
@@ -431,11 +472,13 @@ html.mobile-init .file-browser-panel {
when keyboard is visible, and resetLayout() clears the inline
style to re-expose this CSS value. */
.app {
padding-top: 42px;
/* Header height plus its 1px border and a little slack. Derived from the
token so the offset cannot fall out of step with the bar it clears. */
padding-top: calc(var(--header-height) + 6px);
}
.ios-device .app {
padding-top: calc(42px + var(--safe-area-top));
padding-top: calc(var(--header-height) + 6px + var(--safe-area-top));
}
.main {
@@ -479,7 +522,11 @@ html.mobile-init .file-browser-panel {
.btn-icon-header.btn-lifecycle-log,
.btn-icon-header.btn-away-digest,
.btn-icon-header.btn-session-manager,
.btn-icon-header.btn-file-viewer {
.btn-icon-header.btn-file-viewer,
/* Approvals bell: phones answer from the overview's NEEDS YOU rows instead
(inline approve/deny in mobile-overview.js); the bell would only crowd the
header it was designed to stay out of. */
.btn-icon-header.btn-approvals {
display: none !important;
}
@@ -617,6 +664,16 @@ html.mobile-init .file-browser-panel {
height: 4px;
}
/* The working dot is the one glance-state a phone needs: keep idle tiny, but
let the pulsing green "working" dot read from arm's length. !important on
the glow because the skin block's no-halo rule (styles.css, nested under
html:not([data-skin="og"])) outranks any plain class rule here. */
.session-tab .tab-status.busy {
width: 9px;
height: 9px;
box-shadow: 0 0 8px 2px color-mix(in srgb, var(--green) 55%, transparent) !important;
}
/* Truncate tab names more aggressively on mobile */
.session-tab .tab-name {
max-width: 50px;
@@ -2503,6 +2560,41 @@ html.mobile-init .file-browser-panel {
background: var(--bg-hover);
}
/* Approvals Inbox answer strip: sits under a NEEDS YOU row (sibling of the
row <button>, see _buildMobileOverviewApprovalStrip). Buttons inherit no
toolbar styling on purpose; they are one-tap dialog answers, not runs. */
.mobile-overview-row-wrap {
width: 100%;
}
.mobile-overview-approval-strip {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
padding: 0.4rem 0.2rem 0.1rem;
}
.mobile-overview-approval-btn {
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-card);
color: var(--text);
font-family: inherit;
font-size: 0.72rem;
padding: 0.35rem 0.6rem;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mobile-overview-approval-btn.primary {
background: var(--accent);
border-color: var(--accent);
color: white;
}
.mobile-overview-approval-btn.danger {
border-color: var(--error);
color: var(--error);
}
/* Attention states mirror the session tabs exactly: red blink when the agent
asked something (permission / question), yellow blink when it is waiting for
a prompt. Same hues and same cadence as tab-blink-red / tab-blink-yellow in
@@ -2522,6 +2614,51 @@ html.mobile-init .file-browser-panel {
border-color: var(--red);
}
/* Working is not an alert, so it gets a calm green breathing edge rather than a
blink: at a glance the row reads "this one is moving", without competing with
the two states that actually want you. Slower than both of them on purpose. */
.mobile-overview-row--working {
border-color: var(--green);
animation: mobile-overview-breathe-green 2.2s ease-in-out infinite;
}
@keyframes mobile-overview-breathe-green {
0%,
100% {
background: var(--bg-card);
border-color: var(--border);
}
50% {
background: rgba(34, 197, 94, 0.1);
border-color: var(--green);
}
}
/* The pill picks up a three-dot ellipsis that fills in and empties, so the row
still reads as active on a skin where the border tint is subtle. */
.mobile-overview-pill--working::after {
content: '';
display: inline-block;
width: 0.75em;
text-align: left;
animation: mobile-overview-pill-dots 1.5s steps(1, end) infinite;
}
@keyframes mobile-overview-pill-dots {
0% {
content: '';
}
25% {
content: '.';
}
50% {
content: '..';
}
75% {
content: '...';
}
}
@keyframes mobile-overview-blink-red {
0%,
100% {
@@ -2596,13 +2733,35 @@ html.mobile-init .file-browser-panel {
}
/* Same as .session-tab .tab-status: green when the session is fine, and the
shared `pulse` keyframes while it is working. */
shared `pulse` keyframes while it is working. The halo matches the busy tab
dot and the desktop home column (.home-sessions-dot--working, styles.css):
working reads identically on every surface or it reads as three features. */
.mobile-overview-dot--working {
background: var(--green);
animation: pulse 1.5s infinite;
box-shadow: 0 0 8px 2px color-mix(in srgb, var(--green) 55%, transparent);
will-change: opacity;
}
/* Ring the pulsing dot with the SAME spinner a tab shows while it loads: same
2px ring, same bright leading edge, same `tab-load-spin` keyframes from
styles.css (reused, not re-declared, so the two can never drift). Green
rather than the tab's blue because here it means "running", not "loading":
the motion is the shared part, the color still belongs to the state. */
.mobile-overview-dot {
position: relative;
}
.mobile-overview-dot--working::after {
content: '';
position: absolute;
inset: -4px;
border: 2px solid rgba(34, 197, 94, 0.25);
border-top-color: var(--green);
border-radius: 50%;
animation: tab-load-spin 0.7s linear infinite;
}
.mobile-overview-dot--idle {
background: var(--green);
}
@@ -2713,6 +2872,24 @@ html.mobile-init .file-browser-panel {
.mobile-overview-dot--working {
animation: none;
}
/* The ring stays as a static full circle: it still marks the row, it just
stops turning. */
.mobile-overview-dot--working::after {
border-color: var(--green);
animation: none;
}
/* Working is only informational, so it drops to a static green edge and a
static ellipsis rather than holding a tint the way the alerts do. */
.mobile-overview-row--working {
animation: none;
}
.mobile-overview-pill--working::after {
content: '...';
animation: none;
}
}
/* Light-skin compatibility for mobile-only chrome. These components predate
@@ -2872,3 +3049,47 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
padding: 4px 7px;
}
}
/* ============================================================================
Connection loss: phone sizing
The banner sits in normal flow directly under the fixed header (the container
already reserves that space), so it needs the same safe-area padding as the
other banners. The overlay is fixed and handles its own insets.
============================================================================ */
@media (max-width: 430px) {
.offline-banner {
padding: 0.4rem 0.5rem;
padding-left: calc(0.5rem + var(--safe-area-left));
padding-right: calc(0.5rem + var(--safe-area-right));
font-size: 0.7rem;
gap: 0.4rem;
}
/* The countdown is the first thing to go when the bar gets tight. The
wording plus the Retry button carry the message on their own. */
.offline-banner-detail {
display: none;
}
.offline-banner-retry {
padding: 0.25rem 0.5rem;
margin-left: auto;
}
.offline-overlay-card {
padding: 22px 18px 18px;
}
.offline-overlay-title {
font-size: 1.05rem;
}
.offline-overlay-actions {
flex-direction: column;
}
.offline-overlay-btn {
width: 100%;
padding: 0.65rem 1rem;
}
}
+41 -2
View File
@@ -14,6 +14,7 @@
*/
const AWAY_DIGEST_LAST_VIEWED_KEY = 'codeman-away-digest-last-viewed';
const FILE_BROWSER_SHOW_HIDDEN_KEY = 'codeman:fileBrowserShowHidden';
const AWAY_DIGEST_SECTIONS = [
['needsAttention', 'Needs Attention'],
['completed', 'Completed'],
@@ -2944,18 +2945,56 @@ Object.assign(CodemanApp.prototype, {
// File Browser Panel
// ═══════════════════════════════════════════════════════════════
// Hidden files/folders (dot-prefixed) are filtered SERVER-side by
// GET /api/sessions/:id/files, so the toggle re-fetches rather than
// re-rendering the cached tree (issue #221). The flag is per-device and lives
// in its own localStorage key instead of the app-settings object: that object
// is rebuilt from the settings-modal DOM on every save, so a key toggled from
// outside the modal would be dropped the next time settings are saved.
_loadFileBrowserShowHidden() {
try {
return localStorage.getItem(FILE_BROWSER_SHOW_HIDDEN_KEY) === '1';
} catch {
return false;
}
},
_syncFileBrowserHiddenBtn() {
const btn = this.$('fileBrowserHiddenBtn');
if (!btn) return;
const on = this.fileBrowserShowHidden === true;
btn.classList.toggle('active', on);
btn.setAttribute('aria-pressed', String(on));
const label = on ? 'Hide hidden files and folders' : 'Show hidden files and folders';
btn.setAttribute('title', label);
btn.setAttribute('aria-label', label);
},
async toggleFileBrowserHidden() {
this.fileBrowserShowHidden = !this.fileBrowserShowHidden;
try {
localStorage.setItem(FILE_BROWSER_SHOW_HIDDEN_KEY, this.fileBrowserShowHidden ? '1' : '0');
} catch {}
this._syncFileBrowserHiddenBtn();
// Expanded-directory state is deliberately preserved so toggling does not
// collapse the tree the user just navigated.
if (this.activeSessionId) await this.loadFileBrowser(this.activeSessionId);
},
async loadFileBrowser(sessionId) {
if (!sessionId) return;
const treeEl = this.$('fileBrowserTree');
const statusEl = this.$('fileBrowserStatus');
this._syncFileBrowserHiddenBtn();
if (!treeEl) return;
// Show loading state
treeEl.innerHTML = '<div class="file-browser-loading">Loading files...</div>';
try {
const res = await fetch(`/api/sessions/${sessionId}/files?depth=5&showHidden=false`);
const showHidden = this.fileBrowserShowHidden === true;
const res = await fetch(`/api/sessions/${sessionId}/files?depth=5&showHidden=${showHidden}`);
if (!res.ok) throw new Error('Failed to load files');
const result = await res.json();
@@ -2967,7 +3006,7 @@ Object.assign(CodemanApp.prototype, {
// Update status
if (statusEl) {
const { totalFiles, totalDirectories, truncated } = result.data;
statusEl.textContent = `${totalFiles} files, ${totalDirectories} dirs${truncated ? ' (truncated)' : ''}`;
statusEl.textContent = `${totalFiles} files, ${totalDirectories} dirs${truncated ? ' (truncated)' : ''}${showHidden ? ' · hidden shown' : ''}`;
}
} catch (err) {
console.error('Failed to load file browser:', err);
+22 -2
View File
@@ -38,6 +38,18 @@ Object.assign(CodemanApp.prototype, {
this._notifySession(data.sessionId, 'critical', 'hook-elicitation', 'Question Asked', data.question || 'Claude is asking a question and waiting for your answer');
},
_onHookElicitationComplete(data) {
// Question answered in the terminal: clear the action alert without
// waiting for `stop` (the turn may keep running for a long time).
if (data.sessionId) {
this.clearPendingHooks(data.sessionId, 'elicitation_dialog');
}
},
_onHookElicitationResponse(data) {
this._onHookElicitationComplete(data);
},
_onHookStop(data) {
// Clear all pending hooks when Claude finishes responding
if (data.sessionId) {
@@ -158,8 +170,12 @@ Object.assign(CodemanApp.prototype, {
// Listen for messages from service worker (notification clicks)
navigator.serviceWorker.addEventListener('message', (event) => {
if (event.data?.type === 'notification-click') {
const { sessionId } = event.data;
if (sessionId && this.sessions.has(sessionId)) {
const { sessionId, action, approvalId } = event.data;
if (action) {
// Approve/Deny action buttons on a push: answer via the
// Approvals Inbox instead of just focusing the session.
this.handleNotificationAction?.(action, approvalId, sessionId);
} else if (sessionId && this.sessions.has(sessionId)) {
this.selectSession(sessionId);
}
window.focus();
@@ -326,6 +342,8 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('appSettingsShowFileBrowser').checked = settings.showFileBrowser ?? defaults.showFileBrowser ?? false;
document.getElementById('appSettingsShowSubagents').checked = settings.showSubagents ?? defaults.showSubagents ?? false;
document.getElementById('appSettingsShowUltracodeAgents').checked = settings.showUltracodeAgents ?? defaults.showUltracodeAgents ?? false;
// Approvals Inbox: synced, default OFF (opt-in; only an explicit true enables).
document.getElementById('appSettingsApprovalsInbox').checked = settings.approvalsInboxEnabled === true;
document.getElementById('appSettingsUltracodeFloatingWindows').checked =
settings.ultracodeFloatingWindows ?? defaults.ultracodeFloatingWindows ?? false;
document.getElementById('appSettingsShowMultiMonitorButton').checked = settings.showMultiMonitorButton ?? defaults.showMultiMonitorButton ?? false;
@@ -1525,6 +1543,7 @@ Object.assign(CodemanApp.prototype, {
showFileBrowser: document.getElementById('appSettingsShowFileBrowser').checked,
showSubagents: document.getElementById('appSettingsShowSubagents').checked,
showUltracodeAgents: document.getElementById('appSettingsShowUltracodeAgents').checked,
approvalsInboxEnabled: document.getElementById('appSettingsApprovalsInbox').checked,
ultracodeFloatingWindows: document.getElementById('appSettingsUltracodeFloatingWindows').checked,
showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked,
showPlanUsageLimits: document.getElementById('appSettingsShowPlanUsageLimits').checked,
@@ -1690,6 +1709,7 @@ Object.assign(CodemanApp.prototype, {
this.applyTabWrapSettings();
this._updateTokensImmediate(); // Re-render token display (picks up showCost change)
this.applyMonitorVisibility();
this.renderApprovals?.(); // Approvals Inbox toggle (hide/show bell + drawer)
this.renderProjectInsightsPanel(); // Re-render to apply visibility setting
this.updateSubagentWindowVisibility(); // Apply subagent window visibility setting
+739 -1
View File
@@ -9208,6 +9208,20 @@ kbd {
gap: 0.25rem;
}
/* Show-hidden toggle: a literal `.*` glyph rather than an icon, so its meaning
* (dot-prefixed files and folders) survives every skin and font stack. */
.btn-file-browser-hidden {
font-family: var(--font-mono, monospace);
font-size: 0.85rem;
font-weight: 700;
letter-spacing: -0.05em;
}
.btn-file-browser-hidden.active {
color: var(--accent);
background: var(--bg-hover);
}
.file-browser-search {
padding: 0.4rem;
border-bottom: 1px solid var(--border);
@@ -10659,6 +10673,222 @@ kbd {
display: none !important;
}
/* "Approvals" header bell: appears ONLY while prompts are pending (JS toggles
the marker class on count changes), so it ships hidden and stays out of the
default header. Same marker pattern as the attachments button. */
.btn-approvals {
display: inline-flex !important;
position: relative;
}
.btn-approvals.btn-approvals--hidden {
display: none !important;
}
.approvals-badge {
position: absolute;
top: 2px;
right: 1px;
min-width: 16px;
height: 16px;
padding: 0 4px;
background: var(--error, #e5484d);
color: #fff;
font-size: 0.6rem;
font-weight: 700;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
/* Approvals Inbox drawer: same shell as the attachment history drawer. */
.approvals-drawer {
position: fixed;
top: var(--header-height);
right: 0;
width: 420px;
max-width: calc(100vw - 24px);
height: calc(100vh - var(--header-height) - var(--toolbar-height));
height: calc(100dvh - var(--header-height) - var(--toolbar-height));
background: var(--floating-bg);
border-left: 1px solid var(--border);
z-index: 10000;
display: flex;
flex-direction: column;
transform: translateX(100%);
transition: transform 0.18s ease;
box-shadow: -10px 0 28px rgba(0, 0, 0, 0.36);
}
.approvals-drawer.open {
transform: translateX(0);
}
.approvals-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.approvals-title {
color: var(--text);
font-size: 0.9rem;
font-weight: 650;
}
.approvals-subtitle {
margin-top: 2px;
color: var(--text-dim);
font-size: 0.68rem;
}
.approvals-close {
background: none;
border: none;
color: var(--text-dim);
font-size: 0.9rem;
cursor: pointer;
padding: 4px 8px;
}
.approvals-close:hover {
color: var(--text);
}
.approvals-list {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.approvals-empty {
color: var(--text-dim);
font-size: 0.78rem;
text-align: center;
padding: 24px 8px;
}
.approval-card {
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px;
margin-bottom: 8px;
background: var(--bg-secondary, rgba(255, 255, 255, 0.02));
}
.approval-card-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.approval-kind-badge {
font-size: 0.62rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 2px 6px;
border-radius: 4px;
background: var(--accent);
color: #fff;
}
.approval-kind-question .approval-kind-badge {
background: #d97706;
}
.approval-kind-idle .approval-kind-badge {
background: #6b7280;
}
.approval-session {
color: var(--text);
font-size: 0.78rem;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.approval-age {
margin-left: auto;
color: var(--text-dim);
font-size: 0.68rem;
}
.approval-summary {
color: var(--text);
font-size: 0.76rem;
margin-bottom: 6px;
word-break: break-word;
}
.approval-context {
font-family: var(--font-mono, monospace);
font-size: 0.66rem;
line-height: 1.35;
color: var(--text-dim);
background: rgba(0, 0, 0, 0.25);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px;
margin: 0 0 8px;
max-height: 180px;
overflow: auto;
white-space: pre;
}
.approval-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.approval-btn {
border: 1px solid var(--border);
background: var(--bg-tertiary, rgba(255, 255, 255, 0.05));
color: var(--text);
font-size: 0.72rem;
padding: 5px 10px;
border-radius: 6px;
cursor: pointer;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.approval-btn:hover {
border-color: var(--accent);
}
.approval-btn-primary {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.approval-btn-danger {
border-color: var(--error, #e5484d);
color: var(--error, #e5484d);
}
.approval-text-row {
display: flex;
gap: 6px;
width: 100%;
}
.approval-text-input {
flex: 1;
background: var(--bg, rgba(0, 0, 0, 0.3));
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font-size: 0.74rem;
padding: 5px 8px;
}
.approval-meta-actions {
display: flex;
gap: 12px;
margin-top: 6px;
}
.approval-link {
background: none;
border: none;
color: var(--text-dim);
font-size: 0.68rem;
cursor: pointer;
padding: 0;
text-decoration: underline;
}
.approval-link:hover {
color: var(--text);
}
/* "Attachments" header button — opt-in (App Settings → Display), hidden by
default. Same pattern as the response viewer: a base inline-flex !important so
an inline style can't override it, and a more-specific marker rule to hide. */
@@ -11996,7 +12226,8 @@ body.touch-device.cjk-input-visible .main {
}
.path-picker-up,
.path-picker-refresh {
.path-picker-refresh,
.path-picker-hidden {
flex: 0 0 38px;
height: 38px;
color: var(--text);
@@ -12006,6 +12237,20 @@ body.touch-device.cjk-input-visible .main {
cursor: pointer;
}
/* Show-hidden toggle: a literal `.*` glyph rather than an icon, so its meaning
* (dot-prefixed files and folders) survives every skin and font stack. */
.path-picker-hidden {
font-family: var(--font-mono, monospace);
font-size: 0.9rem;
font-weight: 700;
letter-spacing: -0.05em;
}
.path-picker-hidden.active {
color: var(--accent);
border-color: var(--accent);
}
.path-picker-up:disabled {
opacity: 0.35;
cursor: default;
@@ -13496,3 +13741,496 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
/* Delete sits apart from Cancel/Save so it is not fat-fingered on the way to Save. */
.webview-modal-actions { justify-content: space-between; }
.webview-modal-actions .btn-danger { margin-right: auto; }
/* ═══════════════════════════════════════════════════════════════
Connection loss: banner + full-screen overlay
═══════════════════════════════════════════════════════════════
The service worker serves the cached shell, so an unreachable server used to
render as an empty-but-normal dashboard with only an 8px red dot in the
header. Both surfaces below are deliberately skin-independent (literal
colors, not tokens): "you are disconnected" must read identically on every
skin, including the light ones. Visibility is driven by the `hidden`
attribute, so the display rules need !important to lose to it. */
.offline-banner {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.45rem 1rem;
background: linear-gradient(90deg, #b91c1c, #991b1b);
border-bottom: 1px solid rgba(0, 0, 0, 0.35);
color: #fff;
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.01em;
flex-shrink: 0;
z-index: 1250;
}
.offline-banner[hidden] {
display: none !important;
}
.offline-banner-dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: #fff;
flex-shrink: 0;
animation: offline-banner-pulse 1.4s ease-in-out infinite;
}
@keyframes offline-banner-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.25; }
}
.offline-banner-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.offline-banner-detail {
color: rgba(255, 255, 255, 0.8);
font-weight: 500;
white-space: nowrap;
margin-left: auto;
}
.offline-banner-retry {
flex-shrink: 0;
padding: 0.2rem 0.6rem;
border-radius: 5px;
border: 1px solid rgba(255, 255, 255, 0.55);
background: rgba(255, 255, 255, 0.12);
color: #fff;
font-size: 0.72rem;
font-weight: 600;
font-family: inherit;
cursor: pointer;
}
.offline-banner-retry:hover {
background: rgba(255, 255, 255, 0.24);
}
/* Above the mobile fixed header (1200) and modals (1300): this is a blocking
"nothing works right now" state, and it only appears before any session
state has loaded, so there is no modal underneath to bury. Stays below the
image popup layer (3000). */
.offline-overlay {
position: fixed;
inset: 0;
z-index: 2500;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
padding-top: calc(20px + var(--safe-area-top));
padding-bottom: calc(20px + var(--safe-area-bottom));
background: rgba(6, 8, 12, 0.93);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
overflow-y: auto;
}
.offline-overlay[hidden] {
display: none !important;
}
.offline-overlay-card {
width: min(420px, 100%);
box-sizing: border-box;
padding: 26px 24px 22px;
border-radius: 14px;
border: 1px solid rgba(239, 68, 68, 0.45);
background: #16181d;
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.55);
color: #f3f6fa;
text-align: center;
}
.offline-overlay-icon {
color: #ef4444;
margin-bottom: 10px;
}
.offline-overlay-title {
margin: 0 0 8px;
font-size: 1.15rem;
font-weight: 700;
color: #fff;
}
.offline-overlay-body {
margin: 0 0 14px;
font-size: 0.85rem;
line-height: 1.45;
color: #b9c0cc;
}
.offline-overlay-host {
font-family: 'SF Mono', Monaco, monospace;
font-size: 0.75rem;
color: #8b93a1;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 6px;
padding: 6px 10px;
margin-bottom: 14px;
word-break: break-all;
}
.offline-overlay-hints {
margin: 0 0 18px;
padding: 0;
list-style: none;
text-align: left;
font-size: 0.8rem;
line-height: 1.7;
color: #a7aebb;
}
.offline-overlay-hints li::before {
content: '›';
color: #ef4444;
font-weight: 700;
margin-right: 8px;
}
.offline-overlay-actions {
display: flex;
gap: 10px;
justify-content: center;
flex-wrap: wrap;
}
.offline-overlay-btn {
padding: 0.5rem 1rem;
border-radius: 7px;
border: 1px solid rgba(255, 255, 255, 0.16);
background: rgba(255, 255, 255, 0.06);
color: #e7ebf2;
font-size: 0.82rem;
font-weight: 600;
font-family: inherit;
cursor: pointer;
}
.offline-overlay-btn:hover {
background: rgba(255, 255, 255, 0.12);
}
.offline-overlay-btn--primary {
background: #dc2626;
border-color: #dc2626;
color: #fff;
}
.offline-overlay-btn--primary:hover {
background: #ef4444;
}
.offline-overlay-status {
margin-top: 14px;
font-size: 0.75rem;
color: #8b93a1;
min-height: 1em;
}
/* ══════════════════════════════════════════════════════════════════════════
Home screen: open tabs in the left gutter (home-sessions.js)
The welcome content is 560px wide and centered, so this column lives in dead
space. It is `position: absolute` precisely so that stays true: the centered
content does not move by a pixel whether the column renders or not. That in
turn is why the width gate below has to exist — in a narrow window there is
no gutter to sit in, and an absolute box would simply overlap the search
panel. JS gates on the same 1180px so the two can never disagree.
The working dot is the phone's, exactly: pulsing green ringed by the very
same `tab-load-spin` a tab shows while it loads (reused from above, never
re-declared), plus a green halo. One signal, one motion, both home screens.
══════════════════════════════════════════════════════════════════════════ */
.home-sessions {
position: absolute;
left: 20px;
top: 50%;
transform: translateY(-50%);
display: flex;
flex-direction: column;
gap: 8px;
width: 256px;
max-height: calc(100% - 3rem);
text-align: left;
z-index: 1;
}
/* `hidden` has to be re-asserted over the display above, or the module's only
lever (el.hidden) does nothing. */
.home-sessions[hidden] {
display: none;
}
/* Belt and braces with shouldShowHomeSessions(): a resize that outruns the
matchMedia listener must never leave the column overlapping the content. */
@media (max-width: 1179px) {
.home-sessions {
display: none !important;
}
}
.home-sessions-header {
display: flex;
align-items: center;
gap: 8px;
padding: 0 6px;
}
.home-sessions-title {
font-size: 0.66rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-muted);
}
.home-sessions-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 16px;
padding: 0 5px;
border-radius: 999px;
background: var(--bg-input);
border: 1px solid var(--border);
color: var(--text-dim);
font-size: 0.6rem;
font-weight: 700;
font-family: monospace;
}
.home-sessions-list {
display: flex;
flex-direction: column;
gap: 4px;
overflow-y: auto;
overflow-x: hidden;
padding: 2px 2px 6px;
}
.home-sessions-list::-webkit-scrollbar {
width: 4px;
}
.home-sessions-list::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 2px;
}
.home-sessions-row {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 7px 9px;
border-radius: 9px;
background: var(--bg-card);
border: 1px solid var(--border);
color: var(--text-dim);
font-family: inherit;
font-size: 0.76rem;
text-align: left;
cursor: pointer;
transition: background var(--transition-smooth), border-color var(--transition-smooth), color var(--transition-smooth);
}
.home-sessions-row:hover {
background: var(--bg-hover);
border-color: rgba(34, 197, 94, 0.35);
color: var(--text);
}
.home-sessions-row:active {
background: rgba(34, 197, 94, 0.12);
}
.home-sessions-number {
display: inline-flex;
align-items: center;
justify-content: center;
width: 15px;
height: 15px;
flex-shrink: 0;
border-radius: 3px;
background: var(--bg-input);
border: 1px solid var(--border);
color: var(--text-muted);
font-size: 0.58rem;
font-weight: 700;
font-family: monospace;
}
.home-sessions-dot {
position: relative;
flex-shrink: 0;
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--text-muted);
}
.home-sessions-dot--needs,
.home-sessions-dot--error {
background: var(--red);
}
.home-sessions-dot--waiting {
background: var(--yellow);
}
.home-sessions-dot--idle {
background: var(--green);
}
.home-sessions-dot--done {
background: var(--text-muted);
opacity: 0.5;
}
.home-sessions-dot--web {
background: #60a5fa;
}
.home-sessions-dot--working {
background: var(--green);
animation: pulse 1.5s infinite;
box-shadow: 0 0 8px 2px color-mix(in srgb, var(--green) 55%, transparent);
will-change: opacity;
}
.home-sessions-dot--working::after {
content: '';
position: absolute;
inset: -4px;
border: 2px solid color-mix(in srgb, var(--green) 25%, transparent);
border-top-color: var(--green);
border-radius: 50%;
animation: tab-load-spin 0.7s linear infinite;
}
.home-sessions-row-body {
display: flex;
flex-direction: column;
gap: 1px;
min-width: 0;
flex: 1;
}
.home-sessions-row-title {
display: flex;
align-items: center;
gap: 5px;
min-width: 0;
color: var(--text);
font-weight: 600;
}
.home-sessions-row-title .session-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.home-sessions-row-sub {
font-size: 0.66rem;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.home-sessions-mode {
flex-shrink: 0;
padding: 0 4px;
border-radius: 3px;
background: var(--bg-input);
border: 1px solid var(--border);
color: var(--text-muted);
font-size: 0.55rem;
font-weight: 700;
font-family: monospace;
text-transform: uppercase;
}
.home-sessions-pill {
flex-shrink: 0;
padding: 2px 6px;
border-radius: 999px;
background: var(--bg-input);
border: 1px solid var(--border);
color: var(--text-muted);
font-size: 0.58rem;
font-weight: 700;
letter-spacing: 0.02em;
white-space: nowrap;
}
.home-sessions-pill--needs,
.home-sessions-pill--error {
background: color-mix(in srgb, var(--red) 18%, transparent);
border-color: color-mix(in srgb, var(--red) 45%, transparent);
color: var(--red);
}
.home-sessions-pill--waiting {
background: color-mix(in srgb, var(--yellow) 18%, transparent);
border-color: color-mix(in srgb, var(--yellow) 45%, transparent);
color: var(--yellow);
}
.home-sessions-pill--working,
.home-sessions-pill--idle {
background: color-mix(in srgb, var(--green) 15%, transparent);
border-color: color-mix(in srgb, var(--green) 40%, transparent);
color: var(--green);
}
/* Row accents: same language as the session tabs and the phone overview — red
means a question is pending, yellow means it wants input, green means work is
happening. Nothing else on this screen may reuse these colors. */
.home-sessions-row--needs,
.home-sessions-row--error {
border-color: color-mix(in srgb, var(--red) 50%, transparent);
animation: home-sessions-blink-red 2.5s ease-in-out infinite;
}
.home-sessions-row--waiting {
border-color: color-mix(in srgb, var(--yellow) 50%, transparent);
animation: home-sessions-blink-yellow 3.5s ease-in-out infinite;
}
.home-sessions-row--working {
border-color: color-mix(in srgb, var(--green) 35%, transparent);
}
@keyframes home-sessions-blink-red {
0%, 100% { border-color: color-mix(in srgb, var(--red) 50%, transparent); }
50% { border-color: color-mix(in srgb, var(--red) 95%, transparent); }
}
@keyframes home-sessions-blink-yellow {
0%, 100% { border-color: color-mix(in srgb, var(--yellow) 45%, transparent); }
50% { border-color: color-mix(in srgb, var(--yellow) 90%, transparent); }
}
@media (prefers-reduced-motion: reduce) {
.home-sessions-row,
.home-sessions-dot,
.home-sessions-dot::after {
animation: none !important;
}
}
+44 -20
View File
@@ -111,14 +111,14 @@ self.addEventListener('push', (event) => {
return;
}
const { title, hostTitle, body, tag, sessionId, urgency, actions } = payload;
const { title, hostTitle, body, tag, sessionId, approvalId, urgency, actions } = payload;
const options = {
body: body || '',
tag: tag || 'codeman-default',
icon: '/icon-192.png',
badge: '/icon-192.png',
data: { sessionId, url: sessionId ? `/?session=${sessionId}` : '/' },
data: { sessionId, approvalId, url: sessionId ? `/?session=${sessionId}` : '/' },
renotify: true,
requireInteraction: urgency === 'critical',
};
@@ -142,24 +142,48 @@ self.addEventListener('push', (event) => {
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const { sessionId, url } = event.notification.data || {};
const { sessionId, approvalId, url } = event.notification.data || {};
const targetUrl = url || '/';
const action = event.action || null;
event.waitUntil(
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => {
// Try to find an existing Codeman tab
for (const client of clients) {
if (client.url.includes(self.location.origin)) {
client.postMessage({
type: 'notification-click',
sessionId,
action: event.action || null,
});
return client.focus();
}
}
// No existing tab -- open a new one
return self.clients.openWindow(targetUrl);
})
);
// Approve/Deny action buttons answer the Approvals Inbox item directly from
// the worker, so they work with NO Codeman tab open (lock-screen approvals).
// Same-origin POST with cookie credentials; the CSRF Origin check passes
// because a service worker fetch carries the worker's own (same) origin.
if ((action === 'approve' || action === 'deny') && approvalId) {
event.waitUntil(
fetch(`/api/approvals/${encodeURIComponent(approvalId)}/answer`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action }),
}).then((res) => {
if (res && res.ok) return undefined;
// 401/404/409: let the human see the state by falling back to a tab.
return openOrFocus(sessionId, action, approvalId, targetUrl);
}).catch(() => openOrFocus(sessionId, action, approvalId, targetUrl))
);
return;
}
event.waitUntil(openOrFocus(sessionId, action, approvalId, targetUrl));
});
function openOrFocus(sessionId, action, approvalId, targetUrl) {
return self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => {
// Try to find an existing Codeman tab
for (const client of clients) {
if (client.url.includes(self.location.origin)) {
client.postMessage({
type: 'notification-click',
sessionId,
approvalId,
action,
});
return client.focus();
}
}
// No existing tab -- open a new one
return self.clients.openWindow(targetUrl);
});
}
+5
View File
@@ -1427,6 +1427,7 @@ Object.assign(CodemanApp.prototype, {
if (this.shouldUseMobileOverview?.()) {
const overlay = document.getElementById('welcomeOverlay');
if (overlay) overlay.classList.remove('visible');
this.hideHomeSessions?.();
this.showMobileOverview();
this._updateCjkInputState?.();
return;
@@ -1439,6 +1440,9 @@ Object.assign(CodemanApp.prototype, {
this.applyWelcomeCliVisibility();
this.loadHistorySessions();
this.initSearchPanel();
// Open tabs down the left gutter. Self-gating: a window too narrow to hold
// the column without overlapping the content leaves it hidden.
this.showHomeSessions?.();
}
// Home screen has no input target — hide the CJK textarea (activeSessionId
// is null by the time we get here). Guarded: defined on the app object.
@@ -1447,6 +1451,7 @@ Object.assign(CodemanApp.prototype, {
hideWelcome() {
this.hideMobileOverview?.();
this.hideHomeSessions?.();
const overlay = document.getElementById('welcomeOverlay');
if (overlay) {
overlay.classList.remove('visible');
+10
View File
@@ -335,6 +335,7 @@ export function sanitizeHookData(data: Record<string, unknown> | null | undefine
'permission_mode',
'stop_hook_active',
'transcript_path',
'message',
];
for (const key of allowedKeys) {
@@ -343,6 +344,15 @@ export function sanitizeHookData(data: Record<string, unknown> | null | undefine
}
}
// Notification hooks carry the human-readable prompt text in `message`
// ("Claude needs your permission to use Bash"). Bound it like the
// tool_input summaries; the frontend and the Approvals Inbox both read it.
if (typeof safeFields.message === 'string') {
safeFields.message = safeFields.message.slice(0, 500);
} else if ('message' in safeFields) {
delete safeFields.message;
}
// For tool_input, extract only summary fields (not full file content)
if (safeFields.tool_input && typeof safeFields.tool_input === 'object') {
const input = safeFields.tool_input as Record<string, unknown>;
+125
View File
@@ -0,0 +1,125 @@
/**
* @fileoverview Approvals Inbox routes.
*
* 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
* - `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
*
* Normal authed API surface (NOT the localhost hook-secret bypass). Answering
* is take-then-write: the item is removed BEFORE keystrokes go out so a
* double-tap (or the service worker retrying a push action) cannot
* double-send; a failed write restores the item.
*/
import { FastifyInstance } from 'fastify';
import { ApiErrorCode, createErrorResponse } from '../../types.js';
import { ApprovalAnswerSchema } from '../schemas.js';
import { parseBody, getAuthUser, canAccessOwned, findSessionOrFail } from '../route-helpers.js';
import { approvalInbox, type ApprovalItem } from '../approval-inbox.js';
import { hooksAvailableForMode } from '../session-wait-registry.js';
import type { SessionPort } from '../ports/index.js';
/**
* Keystrokes for an answer, or an error string. Menu answers are a single digit
* or Esc (dialogs react to the keypress itself, so no Enter is ever sent for
* them). Free text is allowed only for idle prompts (there IS no dialog; the
* text lands in the composer and `\r` submits it, per the CLAUDE.md input
* discipline). `option` digits must match a PARSED option so a blind digit can
* never be routed at a dialog we could not read.
*/
function keystrokesFor(
item: ApprovalItem,
answer: { action: 'approve' | 'deny' | 'option' | 'text'; option?: number; text?: string }
): { keys: string } | { error: string } {
switch (answer.action) {
case 'approve':
if (item.kind === 'idle') return { error: 'Idle prompts take a text answer, not approve/deny' };
return { keys: '1' };
case 'deny':
if (item.kind === 'idle') return { error: 'Idle prompts take a text answer, not approve/deny' };
return { keys: '\x1b' };
case 'option': {
if (item.kind === 'idle') return { error: 'Idle prompts take a text answer, not an option digit' };
if (answer.option === undefined) return { error: 'action "option" requires the option field' };
if (!item.options?.some((o) => o.n === answer.option)) {
return { error: `Option ${answer.option} is not among the parsed dialog options` };
}
return { keys: String(answer.option) };
}
case 'text': {
if (item.kind !== 'idle') return { error: 'Text answers are only valid for idle prompts' };
const text = (answer.text ?? '').replace(/[\r\n]+/g, ' ').trim();
if (!text) return { error: 'action "text" requires non-empty text' };
return { keys: `${text}\r` };
}
}
}
export function registerApprovalRoutes(app: FastifyInstance, ctx: SessionPort): void {
// List pending approvals. Items whose session is gone resolve lazily; items
// whose session the caller cannot access are filtered (never 403-leaked),
// matching the session-list scoping policy.
app.get('/api/approvals', async (req) => {
const user = getAuthUser(req);
const approvals = approvalInbox.listPending().filter((item) => {
const session = ctx.sessions.get(item.sessionId);
if (!session) {
approvalInbox.resolveForSession(item.sessionId, 'session_ended');
return false;
}
return canAccessOwned(user, session.owner);
});
return { success: true, data: { approvals } };
});
app.post<{ Params: { id: string } }>('/api/approvals/:id/answer', async (req) => {
const answer = parseBody(ApprovalAnswerSchema, req.body);
const item = approvalInbox.getById(req.params.id);
if (!item) {
// Covers unknown, already-answered, superseded and expired ids alike.
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Approval not found or no longer pending');
}
// Throws 404 (not 403) for sessions the caller does not own, same
// no-existence-leak rule as every other session route.
const session = findSessionOrFail(ctx, item.sessionId, req);
if (!hooksAvailableForMode(session.mode)) {
return createErrorResponse(ApiErrorCode.CONFLICT, 'Session mode cannot have pending approvals');
}
// Re-capture the pane before aiming keystrokes at it: if the dialog was
// answered in the terminal moments ago, the digit would land in whatever
// now has focus. Conclusive only for items whose frame parsed options.
if (!approvalInbox.verifyStillAnswerable(item.id)) {
return createErrorResponse(ApiErrorCode.CONFLICT, 'The dialog is no longer on screen');
}
const resolved = keystrokesFor(item, answer);
if ('error' in resolved) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, resolved.error);
}
const taken = approvalInbox.take(item.id);
if (!taken) {
return createErrorResponse(ApiErrorCode.CONFLICT, 'Approval was resolved by another actor');
}
const written = await session.writeViaMux(resolved.keys);
if (!written) {
approvalInbox.restore(taken);
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Session is not accepting input');
}
return { success: true, data: { id: item.id, sessionId: item.sessionId, action: answer.action } };
});
app.post<{ Params: { id: string } }>('/api/approvals/:id/dismiss', async (req) => {
const item = approvalInbox.getById(req.params.id);
if (!item) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Approval not found or no longer pending');
}
findSessionOrFail(ctx, item.sessionId, req);
approvalInbox.dismiss(item.id);
return { success: true, data: { id: item.id } };
});
}
+27 -8
View File
@@ -315,11 +315,25 @@ function findMatchingPickerRoot(roots: FilesystemBrowseRoot[], candidate: string
.sort((a, b) => b.path.length - a.path.length)[0];
}
/**
* Whether a path has a dot-prefixed segment anywhere below its browse root.
*
* Checked against the REALPATH, so a plainly-named symlink pointing into a
* hidden tree is caught too. Callers skip it when the request opts into hidden
* entries (`showHidden`), which is why the sensitive-path blocklist and the
* blocked-tree checks must stand on their own: with the toggle on, this is no
* longer the thing keeping `~/.config/gh/hosts.yml` out of reach.
*/
function containsHiddenPickerSegment(root: string, candidate: string): boolean {
const rel = relative(root, candidate);
return rel !== '' && rel.split(sep).some((segment) => segment.startsWith('.'));
}
/** Parses the picker's opt-in `showHidden` query flag (absent means off). */
function wantsHiddenPickerEntries(showHidden?: string): boolean {
return showHidden === 'true';
}
function getFilesystemPreviewKind(fileName: string): FilesystemPreviewKind | undefined {
const extension = extname(fileName).slice(1).toLowerCase();
if (FILESYSTEM_IMAGE_PREVIEW_EXTENSIONS.has(extension)) return 'image';
@@ -431,7 +445,8 @@ async function resolveFilesystemPickerPath(
ctx: SessionPort & ConfigPort,
req: FastifyRequest,
requestedPath: string | undefined,
sessionId?: string
sessionId?: string,
showHidden = false
): Promise<ResolvedFilesystemPickerPath> {
const roots = await resolveFilesystemPickerRoots(ctx, req, sessionId);
if (roots.length === 0) {
@@ -453,7 +468,7 @@ async function resolveFilesystemPickerPath(
if (!matchingRoot) {
throwFilesystemPickerError(403, ApiErrorCode.INVALID_INPUT, 'Path is outside the allowed browse roots');
}
if (containsHiddenPickerSegment(matchingRoot.path, resolvedPath)) {
if (!showHidden && containsHiddenPickerSegment(matchingRoot.path, resolvedPath)) {
throwFilesystemPickerError(403, ApiErrorCode.INVALID_INPUT, 'Hidden paths are not available in the file picker');
}
@@ -662,12 +677,14 @@ function inheritedHeaders(reply: {
export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & EventPort & ConfigPort): void {
// Lazy filesystem listing for the Link Existing and mobile input path pickers.
app.get('/api/filesystem/browse', async (req, reply): Promise<ApiResponse<FilesystemBrowseData>> => {
const { path: requestedPath, sessionId } = parseBody(FilesystemBrowseQuerySchema, req.query);
const { path: requestedPath, sessionId, showHidden } = parseBody(FilesystemBrowseQuerySchema, req.query);
const includeHidden = wantsHiddenPickerEntries(showHidden);
const { candidatePath, resolvedPath, roots, matchingRoot, blockedTrees } = await resolveFilesystemPickerPath(
ctx,
req,
requestedPath,
sessionId
sessionId,
includeHidden
);
if (isBlockedPickerPath(resolvedPath, blockedTrees, true)) {
@@ -703,7 +720,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
const entries: FilesystemBrowseEntry[] = [];
let truncated = false;
for (const entry of dirEntries) {
if (entry.name.startsWith('.')) continue;
if (!includeHidden && entry.name.startsWith('.')) continue;
if (entries.length >= FILESYSTEM_PICKER_ENTRY_LIMIT) {
truncated = true;
break;
@@ -718,7 +735,8 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
}
const targetRoot = findMatchingPickerRoot(roots, targetPath);
if (!targetRoot || containsHiddenPickerSegment(targetRoot.path, targetPath)) continue;
if (!targetRoot) continue;
if (!includeHidden && containsHiddenPickerSegment(targetRoot.path, targetPath)) continue;
let type: FilesystemBrowseEntry['type'];
let size: number | undefined;
@@ -783,12 +801,13 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
// Inline preview for files selected through the root-confined filesystem picker.
app.get('/api/filesystem/preview', { compress: false }, async (req, reply): Promise<void> => {
const { path: requestedPath, sessionId } = parseBody(FilesystemPreviewQuerySchema, req.query);
const { path: requestedPath, sessionId, showHidden } = parseBody(FilesystemPreviewQuerySchema, req.query);
const { candidatePath, resolvedPath, blockedTrees } = await resolveFilesystemPickerPath(
ctx,
req,
requestedPath,
sessionId
sessionId,
wantsHiddenPickerEntries(showHidden)
);
if (isBlockedPickerPath(resolvedPath, blockedTrees)) {
throwFilesystemPickerError(403, ApiErrorCode.INVALID_INPUT, 'Access to this file is blocked');
+66 -4
View File
@@ -2,6 +2,9 @@
* @fileoverview Hook event route.
* Receives Claude Code hook events and broadcasts to SSE clients.
* This endpoint bypasses auth (Claude Code hooks curl from localhost).
* Prompt events (permission_prompt / elicitation_dialog / idle_prompt) also
* open Approvals Inbox items; stop and the elicitation-closed events clear
* them (see web/approval-inbox.ts and docs/approvals-inbox-plan.md).
*/
import { FastifyInstance } from 'fastify';
@@ -11,8 +14,19 @@ import { sanitizeHookData, parseBody } from '../route-helpers.js';
import { persistDockerCaseClaudeSessionId } from '../../docker-hosts.js';
import { getDataDir } from '../../config/instance.js';
import { sessionWaits, hooksAvailableForMode } from '../session-wait-registry.js';
import { approvalInbox, type ApprovalKind } from '../approval-inbox.js';
import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js';
/** Hook events that open an Approvals Inbox item. */
const APPROVAL_KIND_BY_EVENT: Record<string, ApprovalKind> = {
permission_prompt: 'permission',
elicitation_dialog: 'question',
idle_prompt: 'idle',
};
/** Hook events that close a session's pending item without an inbox answer. */
const APPROVAL_RESOLVING_EVENTS = new Set(['stop', 'elicitation_complete', 'elicitation_response']);
export function registerHookEventRoutes(
app: FastifyInstance,
ctx: SessionPort & EventPort & RespawnPort & ConfigPort & InfraPort
@@ -88,12 +102,60 @@ export function registerHookEventRoutes(
// Sanitize forwarded data: only include known safe fields, limit size
const safeData = sanitizeHookData(data);
ctx.broadcast(`hook:${event}`, { sessionId, timestamp: Date.now(), ...safeData });
// Send push notifications for hook events
const session = ctx.sessions.get(sessionId);
const sessionName = session?.name ?? sessionId.slice(0, 8);
ctx.sendPushNotifications(`hook:${event}`, { sessionId, sessionName, ...safeData });
// Approvals Inbox: prompt events open an item, dialog-closed/stop events
// clear it. Mode-gated like the wait signals above (hook events carry no
// identity beyond the shared per-instance secret, so a prompt claimed for a
// session that can never show one must not create an answerable item).
let approvalId: string | undefined;
const approvalKind = APPROVAL_KIND_BY_EVENT[event];
if (session && hooksAvailableForMode(session.mode)) {
if (approvalKind) {
const toolInput =
safeData.tool_input && typeof safeData.tool_input === 'object'
? (safeData.tool_input as Record<string, unknown>)
: undefined;
const toolSummary = toolInput
? [toolInput.command, toolInput.file_path, toolInput.description].find((v) => typeof v === 'string')
: undefined;
const item = approvalInbox.notePrompt({
sessionId,
sessionName,
kind: approvalKind,
toolName: typeof safeData.tool_name === 'string' ? safeData.tool_name : undefined,
toolSummary: typeof toolSummary === 'string' ? toolSummary : undefined,
message: typeof safeData.message === 'string' ? safeData.message : undefined,
cwd: typeof safeData.cwd === 'string' ? safeData.cwd : undefined,
// Visible tmux frame first (it IS the dialog); raw byte-buffer tail as
// the fallback for direct-PTY sessions and the no-op test mux.
capture: () => {
const muxName = session.muxName;
const frame = muxName ? (ctx.mux.capturePaneBuffer?.(muxName) ?? null) : null;
return frame ?? session.terminalBuffer.slice(-8192) ?? null;
},
});
approvalId = item.id;
} else if (APPROVAL_RESOLVING_EVENTS.has(event)) {
approvalInbox.resolveForSession(sessionId, 'resolved_in_terminal');
}
}
ctx.broadcast(`hook:${event}`, {
sessionId,
timestamp: Date.now(),
...safeData,
...(approvalId && { approvalId }),
});
// Send push notifications for hook events
ctx.sendPushNotifications(`hook:${event}`, {
sessionId,
sessionName,
...safeData,
...(approvalId && { approvalId }),
});
// Track in run summary
const summaryTracker = ctx.runSummaryTrackers.get(sessionId);
+2
View File
@@ -10,6 +10,8 @@ export { registerScheduledRoutes } from './scheduled-routes.js';
export { registerCronRoutes } from './cron-routes.js';
export { registerSystemRoutes } from './system-routes.js';
export { registerHookEventRoutes } from './hook-event-routes.js';
export { registerApprovalRoutes } from './approval-routes.js';
export { registerReadMyMindRoutes } from './readmymind-routes.js';
export { registerStatusTelemetryRoutes } from './status-telemetry-routes.js';
export { registerCaseRoutes } from './case-routes.js';
export { registerSessionRoutes } from './session-routes.js';
+50
View File
@@ -0,0 +1,50 @@
/**
* @fileoverview Read My Mind intent routes.
*
* Per-case intent profiles feeding the Read My Mind predictor
* (docs/readmymind-plan.md):
* - `GET /api/sessions/:id/intent`: the profile for the session's case
* - `PUT /api/sessions/:id/intent`: replace the goals text
* - `DELETE /api/sessions/:id/intent`: forget the case's profile
*
* The profile is keyed by owner + workingDir, so multi-user scoping is
* structural; session ownership is still enforced via `findSessionOrFail`
* (with `req`, so a foreign session id 404s) to keep the session-routes
* no-existence-leak policy.
*
* Deliberately session-scoped rather than a raw `/api/intents/:key` surface:
* the session resolves owner + workingDir server-side, so a caller can never
* address another case's profile by guessing keys.
*
* Registrations use the bare `app.<method>('path', ...)` + `req.params as`
* shape (session-routes style): these endpoints are documented in the agent
* skill, and the endpoints.md drift test's scanner does not see registrations
* with a generic between the method and the path.
*/
import { FastifyInstance } from 'fastify';
import { IntentGoalsSchema } from '../schemas.js';
import { parseBody, findSessionOrFail } from '../route-helpers.js';
import { intentStore } from '../../intent-store.js';
import type { SessionPort } from '../ports/index.js';
export function registerReadMyMindRoutes(app: FastifyInstance, ctx: SessionPort): void {
app.get('/api/sessions/:id/intent', async (req) => {
const { id } = req.params as { id: string };
const session = findSessionOrFail(ctx, id, req);
return { success: true, data: { intent: intentStore.getProfile(session.owner, session.workingDir) } };
});
app.put('/api/sessions/:id/intent', async (req) => {
const { id } = req.params as { id: string };
const body = parseBody(IntentGoalsSchema, req.body);
const session = findSessionOrFail(ctx, id, req);
return { success: true, data: { intent: intentStore.setGoals(session.owner, session.workingDir, body.goals) } };
});
app.delete('/api/sessions/:id/intent', async (req) => {
const { id } = req.params as { id: string };
const session = findSessionOrFail(ctx, id, req);
return { success: true, data: { deleted: intentStore.deleteProfile(session.owner, session.workingDir) } };
});
}
+69 -2
View File
@@ -65,6 +65,14 @@ const filesystemPickerPathSchema = z
})
.refine((p) => !p.split('/').includes('..'), { message: 'Path traversal is not allowed' });
/**
* Opt-in flag for listing dot-prefixed entries in the path picker. Absent means
* off, so an old client keeps the previous behavior. It is a string rather than
* a boolean because it arrives as a query parameter; `'false'` is accepted (and
* means off) so a client can send the flag unconditionally.
*/
const showHiddenQuerySchema = z.enum(['true', 'false']).optional();
/** Query validation for the lazy, allowlisted filesystem path picker. */
export const FilesystemBrowseQuerySchema = z.object({
path: filesystemPickerPathSchema.optional(),
@@ -73,6 +81,7 @@ export const FilesystemBrowseQuerySchema = z.object({
.max(100)
.regex(/^[a-zA-Z0-9_-]+$/, 'Invalid session id')
.optional(),
showHidden: showHiddenQuerySchema,
});
/** Query validation for a single allowlisted path-picker file preview. */
@@ -83,6 +92,7 @@ export const FilesystemPreviewQuerySchema = z.object({
.max(100)
.regex(/^[a-zA-Z0-9_-]+$/, 'Invalid session id')
.optional(),
showHidden: showHiddenQuerySchema,
});
/**
@@ -114,6 +124,14 @@ export const FileWriteSchema = z
/** Allowlisted env var key prefixes */
const ALLOWED_ENV_PREFIXES = ['CLAUDE_CODE_', 'OPENCODE_', 'CODEX_', 'GEMINI_', 'GOOGLE_', 'ANTIGRAVITY_'];
/**
* Allowlisted exact env var keys (checked alongside the prefixes).
* CLAUDE_CONFIG_DIR relocates the Claude CLI's user config (credentials,
* settings, stats) so a case can run on a separate Claude subscription (#255).
* Exact match only — CLAUDE_CONFIG_DIR_EXTRA etc. stay rejected.
*/
const ALLOWED_ENV_KEYS = new Set(['CLAUDE_CONFIG_DIR']);
/** Env var keys that are always blocked (security-sensitive) */
const BLOCKED_ENV_KEYS = new Set([
'PATH',
@@ -128,6 +146,7 @@ const BLOCKED_ENV_KEYS = new Set([
/** Validate that an env var key is allowed */
function isAllowedEnvKey(key: string): boolean {
if (BLOCKED_ENV_KEYS.has(key)) return false;
if (ALLOWED_ENV_KEYS.has(key)) return true;
return ALLOWED_ENV_PREFIXES.some((prefix) => key.startsWith(prefix));
}
@@ -142,7 +161,7 @@ const safeEnvOverridesSchema = z
},
{
message:
'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_*, OPENCODE_*, CODEX_*, GEMINI_*, GOOGLE_*, and ANTIGRAVITY_* keys are allowed.',
'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_*, OPENCODE_*, CODEX_*, GEMINI_*, GOOGLE_*, ANTIGRAVITY_* keys and CLAUDE_CONFIG_DIR are allowed.',
}
);
@@ -688,11 +707,43 @@ export const QuickStartSchema = z.object({
* Receives Claude Code hook events.
*/
export const HookEventSchema = z.object({
event: z.enum(['permission_prompt', 'elicitation_dialog', 'idle_prompt', 'stop', 'teammate_idle', 'task_completed']),
event: z.enum([
'permission_prompt',
'elicitation_dialog',
'elicitation_complete',
'elicitation_response',
'idle_prompt',
'stop',
'teammate_idle',
'task_completed',
]),
sessionId: z.string().min(1),
data: z.record(z.string(), z.unknown()).nullable().optional(),
});
/**
* Body of POST /api/approvals/:id/answer (Approvals Inbox).
* `option` digits are additionally validated against the item's PARSED options
* in the route; the schema alone must not authorize blind digit-poking.
*/
export const ApprovalAnswerSchema = z
.object({
action: z.enum(['approve', 'deny', 'option', 'text']),
option: z.number().int().min(1).max(9).optional(),
text: z.string().min(1).max(4000).optional(),
})
.strict();
/**
* Body of PUT /api/sessions/:id/intent (Read My Mind). The 8192 cap mirrors
* MAX_GOALS_CHARS in intent-store.ts.
*/
export const IntentGoalsSchema = z
.object({
goals: z.string().max(8192),
})
.strict();
// ========== Configuration ==========
/**
@@ -793,6 +844,22 @@ export const SettingsUpdateSchema = z
* add-only at create; a marker keeps user-authored copies untouched.
*/
agentSkillEnabled: z.boolean().optional(),
/**
* Approvals Inbox (header bell + drawer, phone overview answer buttons,
* push Approve/Deny action buttons). SYNCED, default OFF (opt-in): even
* with items pending, no surface renders and push payloads carry no
* actions/approvalId until this is enabled. The server-side store and the
* answer endpoints run regardless, so flipping it ON shows anything
* already pending immediately.
*/
approvalsInboxEnabled: z.boolean().optional(),
/**
* Read My Mind (docs/readmymind-plan.md): capture the user's submitted
* prompts into per-case intent profiles. SYNCED, default OFF (opt-in:
* captured prompts are sensitive). OFF stops capture immediately; already
* stored profiles stay until DELETE /api/sessions/:id/intent.
*/
readMyMindEnabled: z.boolean().optional(),
tunnelEnabled: z.boolean().optional(),
// Action field (NOT persisted): explicit per-request acknowledgment that the
// operator accepts exposing an UNAUTHENTICATED public tunnel (no CODEMAN_PASSWORD).
+55 -5
View File
@@ -13,23 +13,73 @@
* credentials, dotenv files) while leaving ordinary cross-workspace files
* attachable.
*
* ⚠️ The path picker's `showHidden` option is what makes the dot-prefixed half
* of this list load-bearing. Before it existed, the picker refused every path
* with a hidden segment, so `~/.config/gh/hosts.yml` and friends were
* unreachable by construction and the list only had to cover the few secrets
* that live in plain sight. Opting into hidden entries removes that accident,
* so every credential location below has to be named. Adding a new browse
* surface means re-reading this file, not assuming it already covers you.
*
* ⚠️ Deliberately NOT whole-tree blocks: `~/.codeman/` (the publish skill
* attaches from it) and `~/.claude/` (transcripts and team state are ordinary
* files worth attaching). Only their secret-bearing members are named.
*
* Callers MUST resolve symlinks (realpath) BEFORE calling isSensitivePath so a
* symlink pointing at a sensitive target is also caught.
*/
import { homedir } from 'node:os';
const SENSITIVE_PATTERNS: RegExp[] = [
// System account databases.
/^\/etc\/shadow$/,
/^\/etc\/gshadow$/,
/^\/etc\/master\.passwd$/,
new RegExp(`^${homedir().replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\/\\.ssh\\/`),
// SSH and GPG private key material. `.ssh/` is matched at any depth rather
// than only under homedir(): a per-project or per-deploy key directory holds
// exactly the same secret, and it drops a homedir() read that is captured at
// module load and therefore wrong for anything that changes HOME later.
/\/\.ssh\//,
/\/\.gnupg\//,
// Dotenv, in every conventional spelling (.env, .env.local, .env.production).
/\/\.env$/,
/\/\.env\./,
/\/credentials(\.json|\.yml|\.yaml|\.xml)?$/i,
/\/\.aws\/credentials$/,
// Generic credential files, plus the per-vendor spellings that do not match it.
/\/credentials(\.json|\.yml|\.yaml|\.xml|\.toml|\.db)?$/i,
/\/\.aws\/(credentials|config)$/,
/\/\.aws\/sso\/cache\//,
/\/\.gcloud\/credentials\.db$/,
/\/\.config\/gcloud\//,
/\/\.azure\//,
/\/\.docker\/config\.json$/,
/\/\.kube\/config$/,
// Package-registry and forge tokens. Each of these is a bearer credential in
// a plain-text dotfile, which is exactly what a path picker will surface.
/\/\.npmrc$/,
/\/\.yarnrc\.yml$/,
/\/\.git-credentials$/,
/\/\.config\/gh\//,
/\/\.config\/hub$/,
/\/\.netrc$/,
/\/_netrc$/,
/\/\.pypirc$/,
/\/\.gem\/credentials$/,
/\/\.cargo\/credentials(\.toml)?$/,
/\/\.terraformrc$/,
/\/\.terraform\.d\//,
// Database client credentials.
/\/\.pgpass$/,
/\/\.my\.cnf$/,
// Agent CLI credentials, including Codeman's own hook secret and user table.
// Named individually so the surrounding trees stay attachable (see above).
/\/\.claude\/\.credentials\.json$/,
/\/\.codeman[^/]*\/hook-secret$/,
/\/\.codeman[^/]*\/users\.json$/,
];
/**
+59 -3
View File
@@ -85,7 +85,9 @@ import {
attachSessionListeners,
detachSessionListeners,
} from './session-listener-wiring.js';
import { sessionWaits } from './session-wait-registry.js';
import { sessionWaits, hooksAvailableForMode } from './session-wait-registry.js';
import { intentStore } from '../intent-store.js';
import { approvalInbox } from './approval-inbox.js';
import {
wireRespawnListeners,
setupTimedRespawn,
@@ -147,6 +149,8 @@ import {
registerFileRoutes,
registerScheduledRoutes,
registerHookEventRoutes,
registerApprovalRoutes,
registerReadMyMindRoutes,
registerStatusTelemetryRoutes,
registerSystemRoutes,
registerCaseRoutes,
@@ -343,6 +347,13 @@ export class WebServer extends EventEmitter {
this.cleanup
);
// Approvals Inbox → SSE. The singleton has no server reference; these
// callbacks are its only way out. Broadcasts carry sessionId, so the
// multi-user SSE scoping applies to them like any session event.
approvalInbox.onPending = (item) => this.broadcast(SseEvent.ApprovalPending, { ...item });
approvalInbox.onUpdated = (item) => this.broadcast(SseEvent.ApprovalUpdated, { ...item });
approvalInbox.onResolved = (info) => this.broadcast(SseEvent.ApprovalResolved, { ...info });
// Set up mux event listeners
this.mux.on('sessionCreated', (session) => {
this.broadcast(SseEvent.MuxCreated, session);
@@ -945,6 +956,8 @@ export class WebServer extends EventEmitter {
registerFileRoutes(this.app, ctx);
registerScheduledRoutes(this.app, ctx);
registerHookEventRoutes(this.app, ctx);
registerApprovalRoutes(this.app, ctx);
registerReadMyMindRoutes(this.app, ctx);
registerStatusTelemetryRoutes(this.app, ctx);
registerSystemRoutes(this.app, ctx);
registerCaseRoutes(this.app, ctx);
@@ -1012,6 +1025,10 @@ export class WebServer extends EventEmitter {
console.error(`[Transcript] Error for session ${sessionId}:`, error.message);
});
watcher.on('transcript:user_prompt', (text: string) => {
void this.captureIntentPrompt(sessionId, text);
});
this.transcriptWatchers.set(sessionId, watcher);
}
@@ -1019,6 +1036,24 @@ export class WebServer extends EventEmitter {
watcher.updatePath(transcriptPath);
}
/**
* Read My Mind intent capture: fold one transcript user prompt into the
* case's intent profile (docs/readmymind-plan.md). Opt-in via
* `readMyMindEnabled` (default OFF) and claude-only; the mode gate is
* belt-and-braces since only hook-fed sessions have a transcript watcher.
*/
private async captureIntentPrompt(sessionId: string, text: string): Promise<void> {
const session = this.sessions.get(sessionId);
if (!session || !hooksAvailableForMode(session.mode)) return;
try {
const settings = await this.readSettings();
if (settings.readMyMindEnabled !== true) return;
intentStore.recordPrompt(session.owner, session.workingDir, sessionId, text);
} catch (err) {
console.warn(`[IntentStore] Capture failed for session ${sessionId}:`, err);
}
}
/**
* Stop the transcript watcher for a session.
*/
@@ -1258,6 +1293,7 @@ export class WebServer extends EventEmitter {
// session's own exit event never reaches the registry.
sessionWaits.notifySignal(sessionId, 'exit');
sessionWaits.cancelAll(sessionId);
approvalInbox.resolveForSession(sessionId, 'session_ended');
this.broadcast(SseEvent.SessionDeleted, { id: sessionId });
}
@@ -2033,6 +2069,7 @@ export class WebServer extends EventEmitter {
'plan:',
'orchestrator:',
'hook:',
'approval:',
'image:',
'scheduled:',
'team:',
@@ -2097,13 +2134,27 @@ export class WebServer extends EventEmitter {
* Only events in PUSH_EVENT_MAP trigger push. Per-subscription preferences are checked.
* Expired subscriptions (410/404) are auto-removed.
*/
private sendPushNotifications(event: string, data: Record<string, unknown>): void {
// Async only for the Approvals Inbox settings read below; every call site is
// fire-and-forget (the EventPort signature stays `void`).
private async sendPushNotifications(event: string, data: Record<string, unknown>): Promise<void> {
const template = WebServer.PUSH_EVENT_MAP[event];
if (!template) return;
const subscriptions = this.pushStore.getAll();
if (subscriptions.length === 0) return;
// Approvals Inbox gating: the Approve/Deny action buttons answer through
// the inbox, so both the buttons and the approvalId they act on ship only
// when the OPT-IN `approvalsInboxEnabled` setting is on (default OFF).
// Pre-inbox these buttons rendered and did nothing; stripping them when
// the feature is off is the honest shape. Cheap: the settings read is
// cached (~2s TTL) and only taken for events that carry approval parts.
let approvalsEnabled = false;
if (template.actions || typeof data.approvalId === 'string') {
const settings = await this.readSettings();
approvalsEnabled = settings.approvalsInboxEnabled === true;
}
const vapidKeys = this.pushStore.getVapidKeys();
webpush.setVapidDetails('mailto:codeman@localhost', vapidKeys.publicKey, vapidKeys.privateKey);
@@ -2145,8 +2196,12 @@ export class WebServer extends EventEmitter {
body,
tag: `codeman-${event}-${sessionId}`,
sessionId,
// Approvals Inbox item id: lets sw.js answer an Approve/Deny action
// click directly (POST /api/approvals/:id/answer) with no tab open.
// Gated on the opt-in setting together with the action buttons.
approvalId: approvalsEnabled && typeof data.approvalId === 'string' ? data.approvalId : undefined,
urgency: template.urgency,
actions: template.actions,
actions: approvalsEnabled ? template.actions : undefined,
});
for (const sub of subscriptions) {
@@ -2873,6 +2928,7 @@ export class WebServer extends EventEmitter {
// unref'd (an unref'd timer can let the process exit mid-wait and strand the
// response), so without this a 10-minute wait holds shutdown open.
sessionWaits.cancelEverything();
approvalInbox.stop();
this.lastRecordedTokens.clear();
+9
View File
@@ -28,6 +28,7 @@ import { SseEvent } from './sse-events.js';
import { getLifecycleLog } from '../session-lifecycle-log.js';
import { fileStreamManager } from '../file-stream-manager.js';
import { sessionWaits } from './session-wait-registry.js';
import { approvalInbox } from './approval-inbox.js';
/** Stored listener references for session cleanup (prevents memory leaks) */
export interface SessionListenerRefs {
@@ -163,6 +164,7 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
// burning the caller's entire timeout learning nothing.
sessionWaits.notifySignal(session.id, 'exit');
sessionWaits.cancelAll(session.id);
approvalInbox.resolveForSession(session.id, 'session_ended');
getLifecycleLog().log({
event: 'exit',
sessionId: session.id,
@@ -214,6 +216,13 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
/** Broadcasts `session:working` — Claude started processing */
working: () => {
sessionWaits.notifySignal(session.id, 'working');
// An idle-prompt inbox item means "composer is waiting"; any working
// transition means input arrived, so the item is moot. ONLY the idle
// kind: `working` is heuristic and can flap mid-turn, so clearing a
// pending permission/question dialog on it would false-clear real
// approvals (those resolve via stop / elicitation hooks / answer-time
// re-capture instead).
approvalInbox.resolveForSession(session.id, 'resolved_in_terminal', ['idle']);
deps.broadcast(SseEvent.SessionWorking, { id: session.id });
const tracker = deps.getRunSummaryTracker(session.id);
if (tracker) {
+23 -2
View File
@@ -5,7 +5,7 @@
* and referenced by the frontend (`SSE_EVENTS` in `constants.js`).
* Both files MUST be kept in sync.
*
* 149 event constants organized by category:
* 154 event constants organized by category:
* - **Core** (1): init
* - **Session lifecycle** (23): created, updated, deleted, terminal, idle, working, ...
* - **Session: Ralph** (6): ralphLoopUpdate, todoUpdate, completionDetected, ...
@@ -24,7 +24,8 @@
* - **Plan orchestration** (5): started, progress, subagent, completed, cancelled
* - **Tunnel** (7): started, stopped, progress, error, qrRotated, qrRegenerated, qrAuthUsed
* - **Image / attachments** (2): image:detected, attachment:detected
* - **Hooks** (6): idle_prompt, permission_prompt, elicitation_dialog, stop, teammate_idle, task_completed
* - **Hooks** (8): idle_prompt, permission_prompt, elicitation_dialog, elicitation_complete, elicitation_response, stop, teammate_idle, task_completed
* - **Approvals** (3): pending, updated, resolved (cross-session Approvals Inbox)
* - **Orchestrator** (12): stateChanged, planProgress, planReady, phase*, verification, task*, completed, error
* - **Clipboard** (1): write
* - **Cases** (4): created, linked, deleted, order-changed
@@ -336,6 +337,10 @@ export const HookIdlePrompt = 'hook:idle_prompt' as const;
export const HookPermissionPrompt = 'hook:permission_prompt' as const;
/** Claude Code hook: elicitation dialog (Claude asking a question). */
export const HookElicitationDialog = 'hook:elicitation_dialog' as const;
/** Claude Code hook: elicitation dialog closed (question answered in the terminal). */
export const HookElicitationComplete = 'hook:elicitation_complete' as const;
/** Claude Code hook: elicitation answer submitted. */
export const HookElicitationResponse = 'hook:elicitation_response' as const;
/** Claude Code hook: response complete. */
export const HookStop = 'hook:stop' as const;
/** Claude Code hook: teammate went idle. */
@@ -343,6 +348,15 @@ export const HookTeammateIdle = 'hook:teammate_idle' as const;
/** Claude Code hook: teammate task completed. */
export const HookTaskCompleted = 'hook:task_completed' as const;
// ─── Approvals Inbox ─────────────────────────────────────────────────────────
/** A prompt is waiting on a human (permission dialog, question, idle prompt). */
export const ApprovalPending = 'approval:pending' as const;
/** A pending approval's captured context/options were refreshed. */
export const ApprovalUpdated = 'approval:updated' as const;
/** A pending approval left the inbox (answered, superseded, expired, ...). */
export const ApprovalResolved = 'approval:resolved' as const;
// ─── Orchestrator ────────────────────────────────────────────────────────────
/** Orchestrator state machine transitioned. */
@@ -580,10 +594,17 @@ export const SseEvent = {
HookIdlePrompt,
HookPermissionPrompt,
HookElicitationDialog,
HookElicitationComplete,
HookElicitationResponse,
HookStop,
HookTeammateIdle,
HookTaskCompleted,
// Approvals Inbox
ApprovalPending,
ApprovalUpdated,
ApprovalResolved,
// Orchestrator
OrchestratorStateChanged,
OrchestratorPlanProgress,