mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 05:59:43 +02:00
Merge master into feat/offline-state (keep both offline overlay and approvals drawer)
This commit is contained in:
+21
-3
@@ -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)
|
||||
*
|
||||
@@ -332,6 +333,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: [
|
||||
{
|
||||
@@ -662,7 +673,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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
+225
-56
@@ -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
|
||||
@@ -1406,6 +1429,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 +1538,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 +1740,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 +1781,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 +1833,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 +1889,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 +2113,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
@@ -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);
|
||||
|
||||
@@ -105,6 +105,8 @@ export type HookEventType =
|
||||
| 'idle_prompt'
|
||||
| 'permission_prompt'
|
||||
| 'elicitation_dialog'
|
||||
| 'elicitation_complete'
|
||||
| 'elicitation_response'
|
||||
| 'stop'
|
||||
| 'teammate_idle'
|
||||
| 'task_completed';
|
||||
|
||||
@@ -17,6 +17,7 @@ export {
|
||||
ANSI_ESCAPE_PATTERN_SIMPLE,
|
||||
TOKEN_PATTERN,
|
||||
SPINNER_PATTERN,
|
||||
CLAUDE_WORKING_LINE_PATTERN,
|
||||
stripAnsi,
|
||||
SAFE_PATH_PATTERN,
|
||||
execPattern,
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
@@ -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
|
||||
@@ -3168,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)
|
||||
@@ -3308,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();
|
||||
|
||||
@@ -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`;
|
||||
},
|
||||
});
|
||||
@@ -487,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',
|
||||
|
||||
@@ -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': '仅活动标签页',
|
||||
|
||||
@@ -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>
|
||||
@@ -417,6 +421,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">↻</button>
|
||||
<button class="btn-icon-sm" onclick="app.toggleFileBrowserExpand()" title="Expand/Collapse All" id="fileBrowserExpandBtn">⊞</button>
|
||||
<button class="btn-icon-sm" onclick="app.closeFileBrowserPanel()" title="Close">×</button>
|
||||
@@ -1532,6 +1537,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">
|
||||
@@ -2722,6 +2734,18 @@
|
||||
</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>
|
||||
@@ -2740,6 +2764,7 @@
|
||||
<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>
|
||||
|
||||
@@ -33,6 +33,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 +49,7 @@ const PathPicker = {
|
||||
_previewOverlay: null,
|
||||
_previewRequestSequence: 0,
|
||||
_previewPreviousFocus: null,
|
||||
_showHidden: false,
|
||||
|
||||
/**
|
||||
* Open the lazy filesystem browser.
|
||||
@@ -53,6 +60,7 @@ const PathPicker = {
|
||||
this.close(false);
|
||||
this._options = options;
|
||||
this._selectedPath = '';
|
||||
this._showHidden = this._loadShowHidden();
|
||||
this._previousFocus = document.activeElement;
|
||||
this._previousFocus?.blur?.();
|
||||
|
||||
@@ -74,6 +82,7 @@ const PathPicker = {
|
||||
<div class="path-picker-nav">
|
||||
<button type="button" class="path-picker-up" title="Parent folder" aria-label="Parent folder">↑</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">↻</button>
|
||||
</div>
|
||||
<div class="path-picker-status" aria-live="polite">Loading...</div>
|
||||
@@ -100,6 +109,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 +131,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 +174,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 +292,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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
+122
-1
@@ -479,7 +479,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;
|
||||
}
|
||||
|
||||
@@ -2503,6 +2507,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 +2561,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% {
|
||||
@@ -2603,6 +2687,25 @@ html.mobile-init .file-browser-panel {
|
||||
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 +2816,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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+246
-1
@@ -9192,6 +9192,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);
|
||||
@@ -10643,6 +10657,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. */
|
||||
@@ -11980,7 +12210,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);
|
||||
@@ -11990,6 +12221,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;
|
||||
|
||||
+44
-20
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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 } };
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -10,6 +10,7 @@ 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 { registerStatusTelemetryRoutes } from './status-telemetry-routes.js';
|
||||
export { registerCaseRoutes } from './case-routes.js';
|
||||
export { registerSessionRoutes } from './session-routes.js';
|
||||
|
||||
+42
-1
@@ -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,
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -663,11 +673,33 @@ 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();
|
||||
|
||||
// ========== Configuration ==========
|
||||
|
||||
/**
|
||||
@@ -768,6 +800,15 @@ 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(),
|
||||
tunnelEnabled: z.boolean().optional(),
|
||||
// Action field (NOT persisted): explicit per-request acknowledgment that the
|
||||
// operator accepts exposing an UNAUTHENTICATED public tunnel (no CODEMAN_PASSWORD).
|
||||
|
||||
@@ -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$/,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
+33
-2
@@ -86,6 +86,7 @@ import {
|
||||
detachSessionListeners,
|
||||
} from './session-listener-wiring.js';
|
||||
import { sessionWaits } from './session-wait-registry.js';
|
||||
import { approvalInbox } from './approval-inbox.js';
|
||||
import {
|
||||
wireRespawnListeners,
|
||||
setupTimedRespawn,
|
||||
@@ -147,6 +148,7 @@ import {
|
||||
registerFileRoutes,
|
||||
registerScheduledRoutes,
|
||||
registerHookEventRoutes,
|
||||
registerApprovalRoutes,
|
||||
registerStatusTelemetryRoutes,
|
||||
registerSystemRoutes,
|
||||
registerCaseRoutes,
|
||||
@@ -343,6 +345,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 +954,7 @@ export class WebServer extends EventEmitter {
|
||||
registerFileRoutes(this.app, ctx);
|
||||
registerScheduledRoutes(this.app, ctx);
|
||||
registerHookEventRoutes(this.app, ctx);
|
||||
registerApprovalRoutes(this.app, ctx);
|
||||
registerStatusTelemetryRoutes(this.app, ctx);
|
||||
registerSystemRoutes(this.app, ctx);
|
||||
registerCaseRoutes(this.app, ctx);
|
||||
@@ -1258,6 +1268,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 });
|
||||
}
|
||||
@@ -2028,6 +2039,7 @@ export class WebServer extends EventEmitter {
|
||||
'plan:',
|
||||
'orchestrator:',
|
||||
'hook:',
|
||||
'approval:',
|
||||
'image:',
|
||||
'scheduled:',
|
||||
'team:',
|
||||
@@ -2092,13 +2104,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);
|
||||
|
||||
@@ -2140,8 +2166,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) {
|
||||
@@ -2868,6 +2898,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();
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user