Compare commits

..
Author SHA1 Message Date
Codeman maintainer ce22c2a608 feat(path-picker): show hidden files and folders, and harden the secret blocklist
The picker behind Link Existing's "Browse" and the mobile keyboard's Path key
refused every path with a dot-prefixed segment, so `.github/workflows/ci.yml`
could not be selected and a hidden folder could not be opened at all. It gains
the same `.*` toggle as the File Viewer: default OFF, per-device, and applied to
both the listing and the preview endpoint, which re-resolves the path
independently.

That dotfile filter was quietly doing security work. The picker's roots include
Home, so with every hidden path unreachable the shared blocklist never had to
name the credentials that live in dot-directories. Lifting the filter removes
that accident, so `isSensitivePath` now covers them explicitly: SSH keys at any
depth rather than only under $HOME, GPG keyrings, AWS/GCloud/Azure/Docker/
Kubernetes credentials, npm, Yarn, git, gh, netrc, PyPI, RubyGems, Cargo and
Terraform tokens, .pgpass and .my.cnf, and the Claude and Codeman agent
credentials. `~/.codeman/` and `~/.claude/` stay attachable as trees, since the
publish skill and the review-card loop read from them; only their secret-bearing
members are named.

Everything else still applies with the toggle on: blocked trees, sensitive
files, root confinement, ownership scoping and symlink-escape checks. A hidden
entry whose realpath is a secret is dropped from the listing, and opening it is
refused.

Follows #221

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 16:16:54 +02:00
22 changed files with 650 additions and 971 deletions
+29
View File
@@ -0,0 +1,29 @@
---
'aicodeman': patch
---
The filesystem path picker can show hidden files and folders, and the shared secret blocklist grew to make that safe.
The picker behind Link Existing's "Browse" and the mobile keyboard's `Path` key
refused every path with a dot-prefixed segment, so `.github/workflows/ci.yml`
could not be selected and a hidden folder could not even be opened. It now has
the same `.*` toggle as the File Viewer, default OFF, per-device, and it applies
to both the listing and the preview endpoint (which re-resolves the path
independently).
That filter was quietly doing security work. With every hidden path unreachable,
`isSensitivePath` never had to name the credentials that live in dot-directories,
because the picker's roots include Home. Lifting the filter removes that
accident, so the blocklist now covers them explicitly: SSH keys at any depth (not
only under `$HOME`), GPG keyrings, AWS/GCloud/Azure/Docker/Kubernetes
credentials, npm, Yarn, git, `gh`, netrc, PyPI, RubyGems, Cargo and Terraform
tokens, `.pgpass` and `.my.cnf`, and the Claude and Codeman agent credentials.
`~/.codeman/` and `~/.claude/` stay attachable as trees, since the publish skill
and the review-card loop read from them; only their secret-bearing members are
named.
Blocked trees, sensitive files, root confinement and symlink-escape checks are
all unchanged and still apply with the toggle on: a hidden entry that resolves
to a secret is dropped from the listing, and opening it is refused.
Follows #221.
-2
View File
@@ -186,8 +186,6 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Idle detection**: Multi-layer (completion message → AI check → output silence → token stability). See `docs/respawn-state-machine.md`.
⚠️ **A `❯` sighting is NOT the end of a turn, and neither is silence.** Claude redraws the composer (`❯`) about once a second all through a turn, so the old "saw a ❯, wait 2s → idle" rule flipped every working session to idle two seconds in (measured: a session mid-tool-call at 17 minutes reporting `status:"idle"`). Its working indicator is `✻ Actualizing… (13m 23s · ↓ 47.5k tokens)`: the glyph animates through `· ✢ ✳ ∗ ✻ ✽`, the gerund is randomized, and the finished line (`✻ Cooked for 2m 49s`) carries the same glyph, so neither `SPINNER_PATTERN` (braille, not what current versions draw) nor a keyword list can see it. Matching the new line in the STREAM does not work either: tmux ships partial repaints, so the whole line reaches the PTY only every few tens of seconds. So: `_confirmIdle()` (session.ts) requires the pane to go quiet, and then asks the SCREEN via `capturePaneText()` + `CLAUDE_WORKING_LINE_PATTERN` before believing it; a sustained run of repaints (`session-activity.ts`, pure + unit tested) is what marks a turn as started, with the same screen probe vetoing keystroke echo. Idle now lands ~3-5s after a turn ends instead of 2s into one. Claude-mode only, since an external CLI has no `❯`, so nothing would ever arm the confirmation and the session would latch busy.
**Auto-resume on usage limit** (opt-in per session, top of the Respawn tab): when Claude halts on a subscription limit, `usage-limit-patterns.ts` (pure, unit-tested) parses the reset time and `SessionAutoOps` arms a timer for reset+2min, then sends Esc + `continue`. ⚠️ Respawn cycles are blocked while paused (`isLimitPaused` guard in `onIdleDetected`), which is what prevents `/clear` from wiping the paused conversation. Claude-mode only. → [architecture-invariants#auto-resume-on-usage-limit](docs/architecture-invariants.md#auto-resume-on-usage-limit)
**Plan-usage chip** (statusLine telemetry, `showPlanUsageLimits`, per-device: desktop default **ON**, handhelds OFF via the mobile block in `getDefaultSettings()`): resolve it ONLY through `planUsageChipEnabled()` in settings-ui.js, which backs all three call sites (the App Settings checkbox, the chip's visibility, and the `statusLineTelemetry` flag on session create). A chip shown without telemetry renders `—` forever. Codeman injects its own `statusLine.command` exporter which POSTs Claude's `rate_limits` blob to `POST /api/status-telemetry`. The exporter is identified by a marker, so it only ever adds/updates/removes a statusLine that is **ours**, never a user's hand-authored one, and it prints the footer through so the in-terminal statusline is not blanked. Claude-mode only; distinct from auto-resume, which reacts to the limit *message* rather than showing live %. → [architecture-invariants#plan-usage-chip-statusline-telemetry](docs/architecture-invariants.md#plan-usage-chip-statusline-telemetry), `docs/usage-limits-display-plan.md`
-9
View File
@@ -274,13 +274,4 @@ 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;
}
+2 -7
View File
@@ -8,7 +8,7 @@
* @module respawn-patterns
*/
import { TOKEN_PATTERN, CLAUDE_WORKING_LINE_PATTERN } from './utils/index.js';
import { TOKEN_PATTERN } from './utils/index.js';
// ========== Constants ==========
@@ -108,12 +108,7 @@ export function isCompletionMessage(data: string): boolean {
* @returns True if any working pattern is found in the window
*/
export function hasWorkingPattern(window: string): boolean {
// 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));
return WORKING_PATTERNS.some((pattern) => window.includes(pattern));
}
/**
-93
View File
@@ -1,93 +0,0 @@
/**
* @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;
}
-92
View File
@@ -1,92 +0,0 @@
/**
* @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;
+55 -215
View File
@@ -59,28 +59,11 @@ 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,
@@ -393,13 +376,7 @@ 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 _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 _trustDialogAccepted: boolean = false; // Prevents repeated trust dialog auto-accept
private _taskTracker: TaskTracker;
// Token tracking for auto-clear
@@ -1537,12 +1514,6 @@ 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.
@@ -1772,10 +1743,54 @@ export class Session extends EventEmitter {
this._handleTerminalOutput(data);
// === Auto-accept workspace trust dialog ===
this._maybeAcceptTrustDialog();
// 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');
}
// === Idle/working detection runs on every chunk (latency-sensitive) ===
this._detectInteractiveActivity(data);
// 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);
}
// === Expensive processing (ANSI strip, Ralph, bash parser) is throttled ===
// Instead of running regex-heavy parsers on every PTY chunk, we accumulate
@@ -1824,7 +1839,6 @@ 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);
@@ -1880,180 +1894,6 @@ 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
@@ -2104,22 +1944,22 @@ export class Session extends EventEmitter {
this.parseTaskDescriptionsFromTerminalData(getCleanData());
}
// 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.
// Work keyword detection (text-based, needs clean data)
// Only check if spinner 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._markWorking();
this._isWorking = true;
this._status = 'busy';
this.emit('working');
this._autoOps.notifyWorking();
this._awaitingIdleConfirmation = false;
if (this.activityTimeout) clearTimeout(this.activityTimeout);
}
}
}
-24
View File
@@ -3144,30 +3144,6 @@ 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);
-1
View File
@@ -17,7 +17,6 @@ export {
ANSI_ESCAPE_PATTERN_SIMPLE,
TOKEN_PATTERN,
SPINNER_PATTERN,
CLAUDE_WORKING_LINE_PATTERN,
stripAnsi,
SAFE_PATH_PATTERN,
execPattern,
-18
View File
@@ -60,24 +60,6 @@ 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;
/**
+47
View File
@@ -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">&#x2191;</button>
<div class="path-picker-current" title="Current folder"></div>
<button type="button" class="path-picker-hidden" title="Show hidden files and folders" aria-label="Show hidden files and folders" aria-pressed="false">.*</button>
<button type="button" class="path-picker-refresh" title="Refresh" aria-label="Refresh">&#x21BB;</button>
</div>
<div class="path-picker-status" aria-live="polite">Loading...</div>
@@ -100,6 +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');
-82
View File
@@ -2522,51 +2522,6 @@ 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% {
@@ -2648,25 +2603,6 @@ 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);
}
@@ -2777,24 +2713,6 @@ 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
+16 -1
View File
@@ -11980,7 +11980,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 +11991,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;
+27 -8
View File
@@ -315,11 +315,25 @@ function findMatchingPickerRoot(roots: FilesystemBrowseRoot[], candidate: string
.sort((a, b) => b.path.length - a.path.length)[0];
}
/**
* Whether a path has a dot-prefixed segment anywhere below its browse root.
*
* Checked against the REALPATH, so a plainly-named symlink pointing into a
* hidden tree is caught too. Callers skip it when the request opts into hidden
* entries (`showHidden`), which is why the sensitive-path blocklist and the
* blocked-tree checks must stand on their own: with the toggle on, this is no
* longer the thing keeping `~/.config/gh/hosts.yml` out of reach.
*/
function containsHiddenPickerSegment(root: string, candidate: string): boolean {
const rel = relative(root, candidate);
return rel !== '' && rel.split(sep).some((segment) => segment.startsWith('.'));
}
/** Parses the picker's opt-in `showHidden` query flag (absent means off). */
function wantsHiddenPickerEntries(showHidden?: string): boolean {
return showHidden === 'true';
}
function getFilesystemPreviewKind(fileName: string): FilesystemPreviewKind | undefined {
const extension = extname(fileName).slice(1).toLowerCase();
if (FILESYSTEM_IMAGE_PREVIEW_EXTENSIONS.has(extension)) return 'image';
@@ -431,7 +445,8 @@ async function resolveFilesystemPickerPath(
ctx: SessionPort & ConfigPort,
req: FastifyRequest,
requestedPath: string | undefined,
sessionId?: string
sessionId?: string,
showHidden = false
): Promise<ResolvedFilesystemPickerPath> {
const roots = await resolveFilesystemPickerRoots(ctx, req, sessionId);
if (roots.length === 0) {
@@ -453,7 +468,7 @@ async function resolveFilesystemPickerPath(
if (!matchingRoot) {
throwFilesystemPickerError(403, ApiErrorCode.INVALID_INPUT, 'Path is outside the allowed browse roots');
}
if (containsHiddenPickerSegment(matchingRoot.path, resolvedPath)) {
if (!showHidden && containsHiddenPickerSegment(matchingRoot.path, resolvedPath)) {
throwFilesystemPickerError(403, ApiErrorCode.INVALID_INPUT, 'Hidden paths are not available in the file picker');
}
@@ -662,12 +677,14 @@ function inheritedHeaders(reply: {
export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & EventPort & ConfigPort): void {
// Lazy filesystem listing for the Link Existing and mobile input path pickers.
app.get('/api/filesystem/browse', async (req, reply): Promise<ApiResponse<FilesystemBrowseData>> => {
const { path: requestedPath, sessionId } = parseBody(FilesystemBrowseQuerySchema, req.query);
const { path: requestedPath, sessionId, showHidden } = parseBody(FilesystemBrowseQuerySchema, req.query);
const includeHidden = wantsHiddenPickerEntries(showHidden);
const { candidatePath, resolvedPath, roots, matchingRoot, blockedTrees } = await resolveFilesystemPickerPath(
ctx,
req,
requestedPath,
sessionId
sessionId,
includeHidden
);
if (isBlockedPickerPath(resolvedPath, blockedTrees, true)) {
@@ -703,7 +720,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
const entries: FilesystemBrowseEntry[] = [];
let truncated = false;
for (const entry of dirEntries) {
if (entry.name.startsWith('.')) continue;
if (!includeHidden && entry.name.startsWith('.')) continue;
if (entries.length >= FILESYSTEM_PICKER_ENTRY_LIMIT) {
truncated = true;
break;
@@ -718,7 +735,8 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
}
const targetRoot = findMatchingPickerRoot(roots, targetPath);
if (!targetRoot || containsHiddenPickerSegment(targetRoot.path, targetPath)) continue;
if (!targetRoot) continue;
if (!includeHidden && containsHiddenPickerSegment(targetRoot.path, targetPath)) continue;
let type: FilesystemBrowseEntry['type'];
let size: number | undefined;
@@ -783,12 +801,13 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
// Inline preview for files selected through the root-confined filesystem picker.
app.get('/api/filesystem/preview', { compress: false }, async (req, reply): Promise<void> => {
const { path: requestedPath, sessionId } = parseBody(FilesystemPreviewQuerySchema, req.query);
const { path: requestedPath, sessionId, showHidden } = parseBody(FilesystemPreviewQuerySchema, req.query);
const { candidatePath, resolvedPath, blockedTrees } = await resolveFilesystemPickerPath(
ctx,
req,
requestedPath,
sessionId
sessionId,
wantsHiddenPickerEntries(showHidden)
);
if (isBlockedPickerPath(resolvedPath, blockedTrees)) {
throwFilesystemPickerError(403, ApiErrorCode.INVALID_INPUT, 'Access to this file is blocked');
+10
View File
@@ -65,6 +65,14 @@ const filesystemPickerPathSchema = z
})
.refine((p) => !p.split('/').includes('..'), { message: 'Path traversal is not allowed' });
/**
* Opt-in flag for listing dot-prefixed entries in the path picker. Absent means
* off, so an old client keeps the previous behavior. It is a string rather than
* a boolean because it arrives as a query parameter; `'false'` is accepted (and
* means off) so a client can send the flag unconditionally.
*/
const showHiddenQuerySchema = z.enum(['true', 'false']).optional();
/** Query validation for the lazy, allowlisted filesystem path picker. */
export const FilesystemBrowseQuerySchema = z.object({
path: filesystemPickerPathSchema.optional(),
@@ -73,6 +81,7 @@ export const FilesystemBrowseQuerySchema = z.object({
.max(100)
.regex(/^[a-zA-Z0-9_-]+$/, 'Invalid session id')
.optional(),
showHidden: showHiddenQuerySchema,
});
/** Query validation for a single allowlisted path-picker file preview. */
@@ -83,6 +92,7 @@ export const FilesystemPreviewQuerySchema = z.object({
.max(100)
.regex(/^[a-zA-Z0-9_-]+$/, 'Invalid session id')
.optional(),
showHidden: showHiddenQuerySchema,
});
/**
+55 -5
View File
@@ -13,23 +13,73 @@
* credentials, dotenv files) while leaving ordinary cross-workspace files
* attachable.
*
* ⚠️ The path picker's `showHidden` option is what makes the dot-prefixed half
* of this list load-bearing. Before it existed, the picker refused every path
* with a hidden segment, so `~/.config/gh/hosts.yml` and friends were
* unreachable by construction and the list only had to cover the few secrets
* that live in plain sight. Opting into hidden entries removes that accident,
* so every credential location below has to be named. Adding a new browse
* surface means re-reading this file, not assuming it already covers you.
*
* ⚠️ Deliberately NOT whole-tree blocks: `~/.codeman/` (the publish skill
* attaches from it) and `~/.claude/` (transcripts and team state are ordinary
* files worth attaching). Only their secret-bearing members are named.
*
* Callers MUST resolve symlinks (realpath) BEFORE calling isSensitivePath so a
* symlink pointing at a sensitive target is also caught.
*/
import { homedir } from 'node:os';
const SENSITIVE_PATTERNS: RegExp[] = [
// System account databases.
/^\/etc\/shadow$/,
/^\/etc\/gshadow$/,
/^\/etc\/master\.passwd$/,
new RegExp(`^${homedir().replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\/\\.ssh\\/`),
// SSH and GPG private key material. `.ssh/` is matched at any depth rather
// than only under homedir(): a per-project or per-deploy key directory holds
// exactly the same secret, and it drops a homedir() read that is captured at
// module load and therefore wrong for anything that changes HOME later.
/\/\.ssh\//,
/\/\.gnupg\//,
// Dotenv, in every conventional spelling (.env, .env.local, .env.production).
/\/\.env$/,
/\/\.env\./,
/\/credentials(\.json|\.yml|\.yaml|\.xml)?$/i,
/\/\.aws\/credentials$/,
// Generic credential files, plus the per-vendor spellings that do not match it.
/\/credentials(\.json|\.yml|\.yaml|\.xml|\.toml|\.db)?$/i,
/\/\.aws\/(credentials|config)$/,
/\/\.aws\/sso\/cache\//,
/\/\.gcloud\/credentials\.db$/,
/\/\.config\/gcloud\//,
/\/\.azure\//,
/\/\.docker\/config\.json$/,
/\/\.kube\/config$/,
// Package-registry and forge tokens. Each of these is a bearer credential in
// a plain-text dotfile, which is exactly what a path picker will surface.
/\/\.npmrc$/,
/\/\.yarnrc\.yml$/,
/\/\.git-credentials$/,
/\/\.config\/gh\//,
/\/\.config\/hub$/,
/\/\.netrc$/,
/\/_netrc$/,
/\/\.pypirc$/,
/\/\.gem\/credentials$/,
/\/\.cargo\/credentials(\.toml)?$/,
/\/\.terraformrc$/,
/\/\.terraform\.d\//,
// Database client credentials.
/\/\.pgpass$/,
/\/\.my\.cnf$/,
// Agent CLI credentials, including Codeman's own hook secret and user table.
// Named individually so the surrounding trees stay attachable (see above).
/\/\.claude\/\.credentials\.json$/,
/\/\.codeman[^/]*\/hook-secret$/,
/\/\.codeman[^/]*\/users\.json$/,
];
/**
+187
View File
@@ -0,0 +1,187 @@
/**
* @fileoverview PathPicker "show hidden" toggle (issue #221).
*
* `PathPicker` (keyboard-accessory.js) is the shared browser behind Link
* Existing's "Browse" and the mobile keyboard's `📁 Path` key, so one toggle
* serves both. What can silently go wrong here:
*
* 1. `showHidden` missing from the browse request (toggle looks dead),
* 2. `showHidden` missing from the PREVIEW request, which re-resolves the
* path independently, so the listing would show a hidden file that then
* 403s the moment you tap it,
* 3. the toggle resetting you to the root instead of reloading where you are,
* 4. the flag not surviving a reopen, or a `localStorage` throw taking the
* picker down with it.
*
* The picker builds its dialog with innerHTML and drives it through real
* listeners, so this needs a DOM rather than a `vm` stub. It runs in the DEFAULT
* node environment and constructs a jsdom window here, matching
* markdown-sanitizer.test.ts: a per-file jsdom environment directive
* externalizes node:fs under vite and the suite then fails to load. ⚠️ Do not
* write that directive's literal name anywhere in this file, not even in prose
* like this: vitest scans the whole source for it, so merely explaining the trap
* re-arms it.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { JSDOM } from 'jsdom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const accessoryJs = readFileSync(resolve(PUBLIC, 'keyboard-accessory.js'), 'utf8');
const stylesCss = readFileSync(resolve(PUBLIC, 'styles.css'), 'utf8');
const STORAGE_KEY = 'codeman:pathPickerShowHidden';
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', { url: 'https://localhost/' });
const jsdomWindow = dom.window as unknown as Window & typeof globalThis;
const jsdomDocument = jsdomWindow.document;
/** Evaluate keyboard-accessory.js against the jsdom window and return PathPicker. */
function loadPathPicker(fetchImpl: (url: string) => Promise<unknown>): any {
const MobileDetection = { isTouchDevice: () => false };
const factory = new Function(
'window',
'document',
'localStorage',
'fetch',
'MobileDetection',
`${accessoryJs}\nreturn PathPicker;`
);
return factory(jsdomWindow, jsdomDocument, jsdomWindow.localStorage, fetchImpl, MobileDetection);
}
function browseResponse(entries: Array<{ name: string; type: string }>, path = '/home/dev/project') {
return {
ok: true,
json: async () => ({
success: true,
data: {
path,
parent: null,
root: '/home/dev',
roots: [{ label: 'Home', path: '/home/dev' }],
entries: entries.map((e) => ({ ...e, path: `${path}/${e.name}` })),
truncated: false,
},
}),
};
}
describe('PathPicker show-hidden toggle', () => {
let PathPicker: any;
let urls: string[];
let respond: (url: string) => unknown;
beforeEach(() => {
jsdomWindow.localStorage.clear();
jsdomDocument.body.replaceChildren();
urls = [];
respond = () =>
browseResponse([
{ name: '.github', type: 'directory' },
{ name: 'src', type: 'directory' },
]);
PathPicker = loadPathPicker(async (url: string) => {
urls.push(url);
return respond(url);
});
});
afterEach(() => {
PathPicker?.close?.(false);
jsdomDocument.body.replaceChildren();
});
const open = async (options: Record<string, unknown> = {}) => {
PathPicker.open({ onSelect: () => {}, ...options });
await vi.waitFor(() => expect(urls.length).toBeGreaterThan(0));
};
const toggle = () => jsdomDocument.querySelector('.path-picker-hidden') as HTMLButtonElement;
const previewHref = () =>
(jsdomDocument.querySelector('.path-preview-open') as HTMLAnchorElement).getAttribute('href') ?? '';
it('omits showHidden by default', async () => {
await open();
expect(urls[0]).not.toContain('showHidden');
expect(toggle().getAttribute('aria-pressed')).toBe('false');
expect(toggle().classList.contains('active')).toBe(false);
expect(toggle().getAttribute('title')).toBe('Show hidden files and folders');
});
it('sends showHidden=true after the toggle is pressed, and persists it', async () => {
await open();
toggle().click();
await vi.waitFor(() => expect(urls.length).toBe(2));
expect(urls[1]).toContain('showHidden=true');
expect(jsdomWindow.localStorage.getItem(STORAGE_KEY)).toBe('1');
expect(toggle().getAttribute('aria-pressed')).toBe('true');
expect(toggle().classList.contains('active')).toBe(true);
expect(toggle().getAttribute('title')).toBe('Hide hidden files and folders');
});
it('restores the preference when the picker is reopened', async () => {
jsdomWindow.localStorage.setItem(STORAGE_KEY, '1');
await open();
expect(urls[0]).toContain('showHidden=true');
expect(toggle().getAttribute('aria-pressed')).toBe('true');
});
it('reloads the current folder rather than resetting to the root', async () => {
jsdomWindow.localStorage.setItem(STORAGE_KEY, '1');
// Sitting inside a hidden folder, reachable only because the toggle is on.
respond = () => browseResponse([{ name: 'workflows', type: 'directory' }], '/home/dev/project/.github');
await open({ initialPath: '/home/dev/project/.github' });
toggle().click();
await vi.waitFor(() => expect(urls.length).toBe(2));
expect(decodeURIComponent(urls[1])).toContain('path=/home/dev/project/.github');
expect(urls[1]).not.toContain('showHidden=true');
});
it('carries the flag into the preview request', async () => {
jsdomWindow.localStorage.setItem(STORAGE_KEY, '1');
await open();
PathPicker.openPreview({ name: '.gitignore', path: '/home/dev/project/.gitignore', previewKind: 'text' });
expect(previewHref()).toContain('showHidden=true');
});
it('leaves the preview flag off when the toggle is off', async () => {
await open();
PathPicker.openPreview({ name: 'notes.txt', path: '/home/dev/project/notes.txt', previewKind: 'text' });
expect(previewHref()).not.toContain('showHidden');
});
it('survives a localStorage that throws (private browsing)', async () => {
const storage = Object.getPrototypeOf(jsdomWindow.localStorage);
const getItem = vi.spyOn(storage, 'getItem').mockImplementation(() => {
throw new Error('denied');
});
const setItem = vi.spyOn(storage, 'setItem').mockImplementation(() => {
throw new Error('denied');
});
try {
await open();
expect(urls[0]).not.toContain('showHidden');
toggle().click();
await vi.waitFor(() => expect(urls.length).toBe(2));
expect(urls[1]).toContain('showHidden=true');
} finally {
getItem.mockRestore();
setItem.mockRestore();
}
});
it('styles the active toggle so it reads as on', () => {
expect(stylesCss).toContain('.path-picker-hidden.active');
});
});
-15
View File
@@ -115,21 +115,6 @@ describe('hasWorkingPattern', () => {
});
});
describe('current Claude status line', () => {
it('should detect the randomized gerund by the elapsed timer', () => {
// Live captures on Claude Code 2.1.220. The word changes every turn, so the
// WORKING_PATTERNS list above cannot see any of these.
expect(hasWorkingPattern('✻ Actualizing… (15m 17s · ↓ 47.5k tokens)')).toBe(true);
expect(hasWorkingPattern('· Finagling… (4m 45s · ↓ 13.3k tokens)')).toBe(true);
expect(hasWorkingPattern('✽ Herding… (3s · esc to interrupt)')).toBe(true);
});
it('should NOT treat the completion line as working', () => {
expect(hasWorkingPattern('✻ Cooked for 2m 49s')).toBe(false);
expect(hasWorkingPattern('✻ Brewed for 18m 41s')).toBe(false);
});
});
describe('spinner characters', () => {
it('should detect braille spinner characters', () => {
expect(hasWorkingPattern('Loading... \u280B')).toBe(true);
+112
View File
@@ -167,6 +167,118 @@ describe('file-routes', () => {
expect(res.statusCode).toBe(403);
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.INVALID_INPUT });
});
// ===== showHidden=true (issue #221) =====
//
// The dotfile filter used to be doing security work by accident: with every
// hidden path unreachable, the sensitive-path blocklist never had to cover
// `~/.config/gh/hosts.yml` and friends. These pin that opting in lifts the
// hidden filter and NOTHING else — blocked trees, sensitive files and root
// confinement all still apply.
describe('showHidden=true', () => {
it('lists dot-prefixed entries', async () => {
mockedReaddir.mockResolvedValueOnce([
{ name: '.github', isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false },
{ name: '.gitignore', isDirectory: () => false, isFile: () => true, isSymbolicLink: () => false },
{ name: 'src', isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false },
] as never);
const root = harness.ctx._session.workingDir;
const res = await harness.app.inject({
method: 'GET',
url: `/api/filesystem/browse?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(root)}&showHidden=true`,
});
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body).data.entries.map((e: { name: string }) => e.name)).toEqual([
'.github',
'src',
'.gitignore',
]);
});
it('allows navigating into a hidden descendant', async () => {
mockedReaddir.mockResolvedValueOnce([
{ name: 'workflows', isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false },
] as never);
const hidden = `${harness.ctx._session.workingDir}/.github`;
const res = await harness.app.inject({
method: 'GET',
url: `/api/filesystem/browse?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(hidden)}&showHidden=true`,
});
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body).data.path).toBe(hidden);
});
it('still hides dot-prefixed entries when the flag is absent or false', async () => {
const entries = [
{ name: '.gitignore', isDirectory: () => false, isFile: () => true, isSymbolicLink: () => false },
{ name: 'src', isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false },
];
const root = harness.ctx._session.workingDir;
for (const query of ['', '&showHidden=false']) {
mockedReaddir.mockResolvedValueOnce(entries as never);
const res = await harness.app.inject({
method: 'GET',
url: `/api/filesystem/browse?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(root)}${query}`,
});
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body).data.entries.map((e: { name: string }) => e.name)).toEqual(['src']);
}
});
it('rejects a showHidden value that is not a boolean string', async () => {
const res = await harness.app.inject({
method: 'GET',
url: `/api/filesystem/browse?sessionId=${harness.ctx._sessionId}&showHidden=yes`,
});
expect(res.statusCode).toBe(400);
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.INVALID_INPUT });
});
it('still omits blocked and sensitive entries', async () => {
const root = harness.ctx._session.workingDir;
mockedReaddir.mockResolvedValueOnce([
{ name: '.ssh', isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false },
{ name: '.npmrc', isDirectory: () => false, isFile: () => true, isSymbolicLink: () => false },
{ name: '.env', isDirectory: () => false, isFile: () => true, isSymbolicLink: () => false },
{ name: '.gitignore', isDirectory: () => false, isFile: () => true, isSymbolicLink: () => false },
// A plainly-named symlink whose target is a secret: caught on the
// resolved path, not the visible name.
{ name: 'notes', isDirectory: () => false, isFile: () => false, isSymbolicLink: () => true },
] as never);
mockedRealpathSync.mockImplementation((p: string) =>
p === `${root}/notes` ? (`${root}/.aws/credentials` as never) : (p as never)
);
const res = await harness.app.inject({
method: 'GET',
url: `/api/filesystem/browse?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(root)}&showHidden=true`,
});
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body).data.entries.map((e: { name: string }) => e.name)).toEqual(['.gitignore']);
});
it('refuses a hidden path that resolves outside every root', async () => {
const outside = `${harness.ctx._session.workingDir}/.cache`;
mockedRealpathSync.mockImplementation((p: string) =>
p === outside ? ('/tmp/somewhere-else' as never) : (p as never)
);
const res = await harness.app.inject({
method: 'GET',
url: `/api/filesystem/browse?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(outside)}&showHidden=true`,
});
expect(res.statusCode).toBe(403);
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.INVALID_INPUT });
});
});
});
// ========== Multi-user scoping for the filesystem picker ==========
+110
View File
@@ -0,0 +1,110 @@
/**
* @fileoverview The shared sensitive-path blocklist (`src/web/sensitive-path.ts`).
*
* This list guards every browser-facing file surface: workspace download,
* cross-workspace attachment registration, raw/preview serving, and the
* filesystem path picker.
*
* It became load-bearing when the picker gained `showHidden` (issue #221).
* Before that, the picker refused any path with a dot-prefixed segment, so most
* of the credential locations below were unreachable by construction and the
* list only had to cover secrets that sit in plain sight. Opting into hidden
* entries removes that accident, which is why each entry is pinned here: a
* pattern silently dropped in a refactor would re-expose a real token.
*
* The list is a BLOCKLIST by design (cross-workspace attachment is a supported
* feature), so the "stays attachable" cases matter just as much: over-blocking
* breaks the publish skill and the review-card loop.
*/
import { describe, expect, it } from 'vitest';
import { isSensitivePath } from '../src/web/sensitive-path.js';
const HOME = '/home/dev';
describe('isSensitivePath', () => {
describe('blocks', () => {
const blocked: Array<[string, string]> = [
['system shadow file', '/etc/shadow'],
['system gshadow file', '/etc/gshadow'],
['BSD master password db', '/etc/master.passwd'],
['ssh keys in home', `${HOME}/.ssh/id_ed25519`],
// Not only under homedir(): a deploy key in a project is the same secret,
// and the old homedir()-anchored pattern was captured at module load.
['ssh keys anywhere', '/srv/deploy/.ssh/id_rsa'],
['gpg keyring', `${HOME}/.gnupg/private-keys-v1.d/key.key`],
['dotenv', '/srv/app/.env'],
['suffixed dotenv', '/srv/app/.env.production'],
// Pre-existing and deliberate: `.env.*` is blocked wholesale, so even a
// committed `.env.example` is refused rather than risking the one repo
// whose "example" holds a live key.
['a dotenv example', '/srv/app/.env.example'],
['generic credentials file', '/srv/app/credentials'],
['json credentials', '/srv/app/credentials.json'],
['toml credentials', '/srv/app/credentials.toml'],
['aws credentials', `${HOME}/.aws/credentials`],
['aws config', `${HOME}/.aws/config`],
['aws sso cache', `${HOME}/.aws/sso/cache/abc.json`],
['legacy gcloud credential db', `${HOME}/.gcloud/credentials.db`],
['modern gcloud config tree', `${HOME}/.config/gcloud/application_default_credentials.json`],
['azure profile', `${HOME}/.azure/accessTokens.json`],
['docker registry auth', `${HOME}/.docker/config.json`],
['kubernetes context', `${HOME}/.kube/config`],
['npm token', `${HOME}/.npmrc`],
['yarn token', `${HOME}/.yarnrc.yml`],
['git credential store', `${HOME}/.git-credentials`],
['gh cli token', `${HOME}/.config/gh/hosts.yml`],
['hub token', `${HOME}/.config/hub`],
['netrc', `${HOME}/.netrc`],
['windows netrc', `${HOME}/_netrc`],
['pypi token', `${HOME}/.pypirc`],
['rubygems token', `${HOME}/.gem/credentials`],
['cargo token', `${HOME}/.cargo/credentials.toml`],
['terraform cli config', `${HOME}/.terraformrc`],
['terraform credentials dir', `${HOME}/.terraform.d/credentials.tfrc.json`],
['postgres password file', `${HOME}/.pgpass`],
['mysql client config', `${HOME}/.my.cnf`],
['claude oauth token', `${HOME}/.claude/.credentials.json`],
['codeman hook secret', `${HOME}/.codeman/hook-secret`],
['codeman user table', `${HOME}/.codeman/users.json`],
['codeman hook secret on a named instance', `${HOME}/.codeman-beta/hook-secret`],
];
it.each(blocked)('blocks the %s', (_label, path) => {
expect(isSensitivePath(path)).toBe(true);
});
});
describe('leaves ordinary files attachable', () => {
const allowed: Array<[string, string]> = [
['a source file', '/srv/app/src/index.ts'],
['a dotfile that carries no secret', '/srv/app/.gitignore'],
['a hidden CI directory', '/srv/app/.github/workflows/ci.yml'],
// The publish skill and the review-card loop attach from these trees, so
// only their named secret members are blocked, never the whole tree.
['a codeman screenshot', `${HOME}/.codeman/screenshots/shot.png`],
['a claude transcript', `${HOME}/.claude/projects/proj/session.jsonl`],
['a claude team inbox', `${HOME}/.claude/teams/alpha/inboxes/bob.json`],
// isUnderTree-style separator awareness: a sibling name that merely starts
// with a blocked segment must not be caught.
['an unrelated sshd notes file', '/srv/notes/.sshd-setup.md'],
['a file named credentials-policy.md', '/srv/app/credentials-policy.md'],
];
it.each(allowed)('allows %s', (_label, path) => {
expect(isSensitivePath(path)).toBe(false);
});
});
it('matches on the resolved path, so callers must realpath first', () => {
// The function itself is pure string matching; this pins the contract its
// docblock states, which every caller depends on.
expect(isSensitivePath('/srv/app/looks-innocent')).toBe(false);
expect(isSensitivePath(`${HOME}/.ssh/looks-innocent`)).toBe(true);
});
});
-248
View File
@@ -1,248 +0,0 @@
/**
* Working/idle detection for an interactive Claude pane.
*
* The bug this pins: Claude redraws the composer (`❯`) about once a second all
* the way through a turn, so the old "saw a ❯, wait 2s, call it idle" rule
* flipped a busy session to idle two seconds into every turn. Measured on a live
* worker: `GET /api/sessions` reported `idle` for a session that had been
* running for 17 minutes and was mid-tool-call.
*
* The status-line fixtures below are verbatim captures from live panes
* (`tmux -L codeman capture-pane -p`) on Claude Code 2.1.220.
*/
import { describe, expect, it, vi, afterEach } from 'vitest';
import { Session } from '../src/session.js';
import { CLAUDE_WORKING_LINE_PATTERN } from '../src/utils/regex-patterns.js';
import {
trackActivityStreak,
isSustainedActivity,
isPaneQuiet,
ACTIVITY_GAP_MS,
WORKING_STREAK_MS,
IDLE_SILENCE_MS,
} from '../src/session-activity.js';
type SessionInternals = {
_handleTerminalOutput(data: string): void;
_detectInteractiveActivity(data: string): void;
};
/** One PTY chunk: what the pane emitted, exactly as the interactive handler sees it. */
function feed(session: Session, data: string): void {
const internals = session as unknown as SessionInternals;
internals._handleTerminalOutput(data);
internals._detectInteractiveActivity(data);
}
/**
* A session whose mux reports a fixed (or scripted) screen, so the pane probe has
* something to read. Only `capturePaneText` is exercised by these paths.
*/
function withFakePane(screen: string | (() => string)): Session {
const read = typeof screen === 'function' ? screen : () => screen;
const mux = {
isAvailable: () => true,
capturePaneText: () => read(),
} as unknown as NonNullable<Parameters<typeof Session.prototype.constructor>[0]>['mux'];
return new Session({
workingDir: '/tmp',
mode: 'claude',
mux,
muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: Date.now() },
} as ConstructorParameters<typeof Session>[0]);
}
/** A composer repaint: the frame Claude ships roughly once a second while working. */
const COMPOSER_REPAINT =
'\x1b[31;1H\x1b[38;5;246m❯\xa0\x1b[39m\x1b[0m\x1b[33;1H \x1b[38;5;246mOpus 5 in:143,699 out:669 ctx:14%\x1b[39m';
describe('CLAUDE_WORKING_LINE_PATTERN', () => {
it('matches the live status line, whatever the glyph and gerund are', () => {
// Captured from three different live panes: the glyph animates through
// `· ✢ ✳ ∗ ✻ ✽` and the gerund is randomized per turn, so neither is matchable.
expect(CLAUDE_WORKING_LINE_PATTERN.test('✻ Actualizing… (15m 17s · ↓ 47.5k tokens)')).toBe(true);
expect(CLAUDE_WORKING_LINE_PATTERN.test('* Implementing the backend… (18m 59s · ↓ 69.9k tokens)')).toBe(true);
expect(CLAUDE_WORKING_LINE_PATTERN.test('· Finagling… (4m 45s · ↓ 13.3k tokens)')).toBe(true);
expect(CLAUDE_WORKING_LINE_PATTERN.test('✽ Herding… (3s · esc to interrupt)')).toBe(true);
});
it('does not match the FINISHED line, which carries the same glyph', () => {
// `✻ Cooked for 2m 49s` sits on screen for the whole idle period afterwards.
// Matching the glyph alone would pin such a session at "working" forever.
expect(CLAUDE_WORKING_LINE_PATTERN.test('✻ Cooked for 2m 49s')).toBe(false);
expect(CLAUDE_WORKING_LINE_PATTERN.test('✻ Brewed for 18m 41s')).toBe(false);
expect(CLAUDE_WORKING_LINE_PATTERN.test('✻ Worked for 2m 46s')).toBe(false);
});
it('ignores ordinary prose and the idle footer', () => {
expect(CLAUDE_WORKING_LINE_PATTERN.test(COMPOSER_REPAINT)).toBe(false);
expect(CLAUDE_WORKING_LINE_PATTERN.test(' ⏵⏵ bypass permissions on (shift+tab to cycle) · ← for agents')).toBe(
false
);
expect(CLAUDE_WORKING_LINE_PATTERN.test('the build took 45s to finish')).toBe(false);
});
});
describe('activity streak helpers', () => {
it('extends a streak while chunks keep arriving', () => {
let streak = trackActivityStreak(null, 1000);
streak = trackActivityStreak(streak, 2000);
streak = trackActivityStreak(streak, 3000);
expect(streak).toEqual({ startedAt: 1000, lastAt: 3000 });
});
it('restarts the streak after a gap', () => {
const first = trackActivityStreak(null, 1000);
const after = trackActivityStreak(first, 1000 + ACTIVITY_GAP_MS + 1);
expect(after.startedAt).toBe(1000 + ACTIVITY_GAP_MS + 1);
});
it('calls it working only once the streak spans the threshold', () => {
expect(isSustainedActivity(null)).toBe(false);
expect(isSustainedActivity({ startedAt: 0, lastAt: WORKING_STREAK_MS - 1 })).toBe(false);
expect(isSustainedActivity({ startedAt: 0, lastAt: WORKING_STREAK_MS })).toBe(true);
});
it('measures the streak on its own span, so a stale streak cannot age into working', () => {
// A single old chunk stays a single chunk no matter how much later we ask.
const oneChunk = { startedAt: 0, lastAt: 0 };
expect(isSustainedActivity(oneChunk)).toBe(false);
});
it('calls the pane quiet only after the silence window', () => {
expect(isPaneQuiet(1000, 1000 + IDLE_SILENCE_MS - 1)).toBe(false);
expect(isPaneQuiet(1000, 1000 + IDLE_SILENCE_MS)).toBe(true);
});
});
describe('Session interactive idle detection', () => {
afterEach(() => {
vi.useRealTimers();
});
it('stays busy through a long turn of composer repaints', () => {
vi.useFakeTimers();
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
const events: string[] = [];
session.on('idle', () => events.push('idle'));
session.on('working', () => events.push('working'));
// 30 seconds of the once-a-second repaint a working pane emits. Every one of
// these carries a ❯; the old rule went idle after the first two seconds.
for (let i = 0; i < 30; i++) {
feed(session, COMPOSER_REPAINT);
vi.advanceTimersByTime(1000);
}
expect(events).toEqual(['working']);
expect(session.status).toBe('busy');
});
it('goes idle once the pane falls silent', () => {
vi.useFakeTimers();
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
const events: string[] = [];
session.on('idle', () => events.push('idle'));
for (let i = 0; i < 5; i++) {
feed(session, COMPOSER_REPAINT);
vi.advanceTimersByTime(1000);
}
expect(events).toEqual([]);
// Turn over: nothing more is emitted.
vi.advanceTimersByTime(IDLE_SILENCE_MS + 1000);
expect(events).toEqual(['idle']);
expect(session.status).toBe('idle');
});
it('emits idle once, not once per re-check', () => {
vi.useFakeTimers();
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
const events: string[] = [];
session.on('idle', () => events.push('idle'));
for (let i = 0; i < 4; i++) {
feed(session, COMPOSER_REPAINT);
vi.advanceTimersByTime(1000);
}
vi.advanceTimersByTime(60_000);
expect(events).toEqual(['idle']);
});
it('refuses to go idle while the screen still shows the working line', () => {
vi.useFakeTimers();
// A turn can go completely silent inside one tool call (measured at 20+
// seconds on a live worker) while `✻ Elucidating… (39s · ↓ 2.0k tokens)`
// sits on screen the whole time. Silence alone must not end the turn.
const session = withFakePane('✻ Elucidating… (39s · ↓ 2.0k tokens)\n❯ \n');
const events: string[] = [];
session.on('idle', () => events.push('idle'));
for (let i = 0; i < 3; i++) {
feed(session, COMPOSER_REPAINT);
vi.advanceTimersByTime(1000);
}
vi.advanceTimersByTime(60_000); // silent for a minute
expect(events).toEqual([]);
expect(session.status).toBe('busy');
});
it('goes idle once the working line leaves the screen', () => {
vi.useFakeTimers();
const pane = { text: '✻ Elucidating… (39s · ↓ 2.0k tokens)\n❯ \n' };
const session = withFakePane(() => pane.text);
const events: string[] = [];
session.on('idle', () => events.push('idle'));
for (let i = 0; i < 3; i++) {
feed(session, COMPOSER_REPAINT);
vi.advanceTimersByTime(1000);
}
vi.advanceTimersByTime(20_000);
expect(events).toEqual([]);
// Turn over: the same glyph remains, on the FINISHED line this time.
pane.text = '✻ Cooked for 2m 49s\n❯ \n';
vi.advanceTimersByTime(20_000);
expect(events).toEqual(['idle']);
expect(session.status).toBe('idle');
});
it('does not call typing into the composer "working"', () => {
vi.useFakeTimers();
// Keystroke echo is a steady stream of repaints too, so the streak alone
// would call it work. The screen has no working line, which vetoes it.
const session = withFakePane('❯ some prompt being typed\n');
const events: string[] = [];
session.on('working', () => events.push('working'));
for (let i = 0; i < 10; i++) {
feed(session, '\x1b[31;3Hx');
vi.advanceTimersByTime(300);
}
expect(events).toEqual([]);
expect(session.status).toBe('idle');
});
it('does not mark an external CLI pane working off raw activity', () => {
vi.useFakeTimers();
// Codex/Gemini/OpenCode render their own TUIs and have no ❯, so nothing would
// arm the idle confirmation, so a session marked working here would never recover.
const session = new Session({ workingDir: '/tmp', mode: 'codex' });
const events: string[] = [];
session.on('working', () => events.push('working'));
for (let i = 0; i < 10; i++) {
feed(session, '\x1b[2K▌ Working (12s)');
vi.advanceTimersByTime(1000);
}
expect(events).toEqual([]);
});
});
-151
View File
@@ -1,151 +0,0 @@
/**
* Workspace-trust dialog auto-accept.
*
* The bug this pins: `data.includes('trust this folder')` could never match,
* because tmux repaints a row with cursor-forward escapes instead of spaces, so
* the wire carries `I\x1b[Ctrust\x1b[Cthis\x1b[Cfolder`. Every session on a fresh
* directory sat on the dialog until a human pressed Enter.
*
* RAW_DIALOG_CHUNK below is a verbatim slice of the PTY stream from a live
* session parked on that dialog (Claude Code 2.1.220).
*/
import { describe, expect, it, vi, afterEach } from 'vitest';
import { Session } from '../src/session.js';
import { isTrustDialogScreen, compactScreenText, TRUST_DIALOG_MAX_ATTEMPTS } from '../src/session-trust-dialog.js';
/** Verbatim from the wire: note the `\x1b[C` where every space should be. */
const RAW_DIALOG_CHUNK =
'\x1b[C\x1b[38;5;246m1.\x1b[C\x1b[38;5;153mYes,\x1b[CI\x1b[Ctrust\x1b[Cthis\x1b[Cfolder\x1b[15;4H' +
'\x1b[38;5;246m2.\x1b[C\x1b[39mNo,\x1b[Cexit\x1b[17;2H\x1b[38;5;246mEnter\x1b[Cto\x1b[Cconfirm\x1b[C·\x1b[CEsc\x1b[Cto\x1b[Ccancel';
/** What `tmux capture-pane -p` shows for the same moment. */
const RENDERED_DIALOG = [
' Quick safety check: Is this a project you created or one you trust? (Like your own code, a well-known open source',
' project, or work from your team). If not, take a moment to review what is in this folder first.',
'',
' ❯ 1. Yes, I trust this folder',
' 2. No, exit',
'',
' Enter to confirm · Esc to cancel',
].join('\n');
/** An ordinary working session: no dialog anywhere. */
const RENDERED_MAIN_UI = [
'✻ Actualizing… (13m 23s · ↓ 47.5k tokens)',
'────────────────────────────────',
'❯ ',
' ⏵⏵ bypass permissions on (shift+tab to cycle) · ← for agents',
].join('\n');
describe('isTrustDialogScreen', () => {
it('sees the dialog in the raw space-less repaint', () => {
// The whole point: the literal phrase is NOT in this chunk.
expect(RAW_DIALOG_CHUNK.includes('trust this folder')).toBe(false);
expect(isTrustDialogScreen(RAW_DIALOG_CHUNK)).toBe(true);
});
it('sees the dialog in the rendered screen', () => {
expect(isTrustDialogScreen(RENDERED_DIALOG)).toBe(true);
});
it('does not fire on a normal session screen', () => {
expect(isTrustDialogScreen(RENDERED_MAIN_UI)).toBe(false);
expect(isTrustDialogScreen('')).toBe(false);
});
it('does not fire on text that merely quotes the dialog', () => {
// An agent reading or writing about this feature (this file, for one) must
// not cause an Enter press. The confirm affordance is what separates the
// widget from prose about it.
expect(isTrustDialogScreen('the installer asks you to trust this folder before it runs')).toBe(false);
expect(isTrustDialogScreen('press Enter to confirm the release')).toBe(false);
});
it('compacts away both real spaces and the escapes tmux sends instead', () => {
expect(compactScreenText('I\x1b[Ctrust\x1b[Cthis\x1b[Cfolder')).toBe('itrustthisfolder');
expect(compactScreenText('I trust this folder')).toBe('itrustthisfolder');
});
});
describe('Session trust-dialog auto-accept', () => {
afterEach(() => vi.useRealTimers());
/** A session whose pane renders `screen`, recording everything written to it. */
function sessionShowing(screen: () => string) {
const writes: string[] = [];
const mux = {
isAvailable: () => true,
capturePaneText: () => screen(),
sendInput: (_id: string, data: string) => {
writes.push(data);
return Promise.resolve(true);
},
};
const session = new Session({
workingDir: '/tmp',
mode: 'claude',
mux,
muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: Date.now() },
} as ConstructorParameters<typeof Session>[0]);
const internals = session as unknown as {
_maybeAcceptTrustDialog(): void;
_interactiveStartedAt: number;
};
internals._interactiveStartedAt = Date.now();
return { session, writes, tick: () => internals._maybeAcceptTrustDialog() };
}
it('presses Enter when the dialog is on screen', () => {
vi.useFakeTimers();
const { writes, tick } = sessionShowing(() => RENDERED_DIALOG);
tick();
expect(writes).toEqual(['\r']);
});
it('retries a dropped keystroke, then gives up rather than typing forever', () => {
vi.useFakeTimers();
// Ink can drop a keystroke while it is still mounting the widget, so one
// press is not always enough; a stuck dialog must not become an Enter loop.
const { writes, tick } = sessionShowing(() => RENDERED_DIALOG);
for (let i = 0; i < 20; i++) {
tick();
vi.advanceTimersByTime(2000);
}
expect(writes.length).toBe(TRUST_DIALOG_MAX_ATTEMPTS);
});
it('stops once the dialog is answered', () => {
vi.useFakeTimers();
let screen = RENDERED_DIALOG;
const { writes, tick } = sessionShowing(() => screen);
tick();
expect(writes).toEqual(['\r']);
screen = RENDERED_MAIN_UI;
for (let i = 0; i < 5; i++) {
vi.advanceTimersByTime(2000);
tick();
}
expect(writes).toEqual(['\r']);
});
it('never answers a dialog-looking screen outside the startup window', () => {
vi.useFakeTimers();
// A live agent can print this text hours in; only a launching pane can be
// showing the real widget.
const { writes, tick } = sessionShowing(() => RENDERED_DIALOG);
vi.advanceTimersByTime(10 * 60_000);
tick();
expect(writes).toEqual([]);
});
it('does not press Enter on a normal screen', () => {
vi.useFakeTimers();
const { writes, tick } = sessionShowing(() => RENDERED_MAIN_UI);
for (let i = 0; i < 5; i++) {
tick();
vi.advanceTimersByTime(2000);
}
expect(writes).toEqual([]);
});
});