Compare commits

...
Author SHA1 Message Date
arkon 44439c951b chore: version packages 2026-06-07 04:44:07 +02:00
Tenggan ZhangandTeigen b2f8b03b3c feat: inject effort as soft default via CLI flags instead of env var (#104)
CLAUDE_CODE_EFFORT_LEVEL hard-locks effort for the whole session and makes
Claude reject in-session /effort switching (incl. ultracode). Carry effort
as a dedicated payload field instead, injected at spawn as a soft default:

- regular levels (incl. max) -> claude --effort <level>
  (the settings effortLevel key is enum([low,medium,high,xhigh]) with
  .catch(undefined), so max would be silently dropped there)
- ultracode -> claude --settings '{"ultracode":true}'
  (dedicated boolean settings key, rejected by the --effort flag)

Changes:
- add effort enum field to create/quick-start/ralph-loop schemas and thread
  it through Session -> CreateSessionOptions/RespawnPaneOptions -> spawn
- buildEffortCliArgs() in session-cli-builder, shared by tmux spawn command
  and direct-PTY fallback args
- frontend: buildEnvOverrides() no longer emits CLAUDE_CODE_EFFORT_LEVEL;
  validated effort goes into payloads via getEffortSetting()
- settings UI: add Ultracode option to the Thinking Effort dropdown
- legacy migration: Session constructor extracts CLAUDE_CODE_EFFORT_LEVEL
  from persisted envOverrides; applyEnvOverrides() unsets the stale tmux
  session var so respawned panes are no longer locked
- tests: test/effort-injection.test.ts (13 cases)

Co-authored-by: Teigen <teigenzhang@gmail.com>
2026-06-07 04:33:11 +02:00
arkonandClaude Opus 4.8 e3c496e1a4 chore: version packages
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 20:01:44 +02:00
arkonandClaude Opus 4.8 eb831487a0 feat(web): remove /compact button from mobile keyboard accessory bar
Drops /compact from both the simple and extended accessory-bar layouts,
the action handler (case folded back to clear-only), the refocus guard,
and the JSDoc. /clear retains its double-tap confirmation. Verified on a
touch-emulated viewport: neither layout renders a compact action.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 20:00:38 +02:00
68594ac395 feat(web): response-viewer transcript fallback + code-block rendering (#102)
* feat(web): response-viewer transcript fallback + code-block rendering

- Add _cleanTerminalBuffer(): strip ANSI escapes and Claude CLI chrome
  (status bar, spinner, progress bar, prompt glyphs) from the terminal
  buffer so the response viewer renders clean text when the JSONL
  transcript is missing.
- Add _preprocessAsciiArt(): wrap box-drawing/block-element diagrams in
  fenced code blocks (narrow trigger that excludes arrows/geometric
  shapes common in prose) so marked.js preserves their whitespace.
- Extend .rv-text rules to .response-viewer-body so fallback-rendered
  content gets the same typography, code-block, and table styling.

* refactor(web): drop duplicate _cleanTerminalBuffer/_preprocessAsciiArt

These two methods already exist on master (added in #75). This branch
re-added byte-identical copies above _sanitizeHtml; in a JS class body the
later definition wins, so the duplicates were inert dead code. Remove them,
keeping only the genuinely new work: the _renderMarkdown null-safety fix and
the response-viewer CSS overhaul.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Teigen <teigen@TeigendeMac-mini.local>
Co-authored-by: arkon <arkon.85@hotmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 19:43:52 +02:00
Tenggan ZhangandTeigen ec38fd11bf feat(web): mobile image upload to active session via paste dialog (#101)
- Extend the keyboard accessory paste dialog with an image picker
  (camera / photo library) plus best-effort image paste, routing
  selected files through the existing _uploadAndInsertImages pipeline
- Re-encode images to standard JPEG/PNG in the browser before upload,
  so mislabeled gallery images (e.g. MIUI WebP claiming image/jpeg)
  pass the server magic-byte check; PNG keeps transparency, GIF passes
  through untouched, decode failures fall back to the original file
- Log the real byte header on the paste-image magic-mismatch branch to
  pin down any remaining format mismatches without a guessing loop
- Ignore the runtime .claude-images/ upload directory

Co-authored-by: Teigen <teigen@TeigendeMac-mini.local>
2026-06-01 19:38:48 +02:00
Tenggan ZhangandTeigen 06f9ff6d9c fix: avoid event-loop stalls from synchronous tmux/ps calls (#100)
The stats collector (~2s) and mouse-mode sync (5s) ran execSync (pgrep/ps/
list-panes, 5s timeout each) per session on the server's single thread,
blocking the event loop. With several sessions or a momentarily slow tmux this
froze port 3000 for seconds-to-tens-of-seconds while the process stayed alive
and other ports were unaffected — self-healing, so it never restarted and the
60s loopback healthcheck missed it. Convert these hot-path calls to execAsync.

Also add an always-on event-loop lag monitor (utils/event-loop-monitor.ts) that
logs stalls >=1s to the web log, so this otherwise-invisible class of incident
leaves a quantified, timestamped trace.

Co-authored-by: Teigen <teigen@TeigendeMac-mini.local>
2026-06-01 19:32:24 +02:00
arkonandClaude Opus 4.7 257695ff8e chore: version packages
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 05:17:07 +02:00
arkonandClaude Opus 4.7 2cfccc745f docs: correct sendPendingCtrlL comment (it has no callers)
The prior wording claimed the no-op stub was kept so SSE idle/working
handlers could call it without guards, but there are no callers anywhere.
Reword to reflect that it's a vestigial, intentionally-retained guard
documenting why Ctrl+L must not be auto-sent. Comment-only; minified
build output is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 01:52:15 +02:00
29 changed files with 872 additions and 183 deletions
+3
View File
@@ -66,3 +66,6 @@ commands
todo.md
@fix_plan.md
readme-preview.mjs
# Uploaded images land here under each session working dir (runtime artifact)
.claude-images/
+48
View File
@@ -1,5 +1,53 @@
# aicodeman
## 0.8.1
### Patch Changes
- Thinking Effort now flows as a soft default the user can override in-session (PR #104, by @TeigenZhang).
Previously Codeman carried the effort setting as the `CLAUDE_CODE_EFFORT_LEVEL` env var, which Claude Code treats as a hard override — it locked effort for the whole session and rejected in-session `/effort` switching (including switching to `ultracode`). Effort is now injected at spawn time as a CLI soft default that `/effort` can still change freely in either direction:
- Regular levels (`low`/`medium`/`high`/`xhigh`/`max`) are passed via `claude --effort <level>` (the settings `effortLevel` key silently drops `max`, so the flag is used instead).
- `ultracode` (xhigh effort + standing dynamic-workflow orchestration) is passed via `claude --settings '{"ultracode":true}'`, since the `--effort` flag rejects it.
Details:
- New `effort` field on the create-session, quick-start, and Ralph-loop request schemas; threaded through `Session._effort` to both spawn paths (tmux `buildSpawnCommand` and direct-PTY `buildInteractiveArgs`), persisted in `SessionState.effort`, and restored on reboot recovery.
- `buildEffortCliArgs()` is the single, allowlist-validated source for both carriers (injection-safe).
- Settings UI adds an "Ultracode (multi-agent workflows)" option to the Thinking Effort dropdown; the frontend no longer emits `CLAUDE_CODE_EFFORT_LEVEL`.
- Legacy migration: sessions persisted with the old env var are auto-migrated into the new `effort` field, and the stale tmux env var is unset so respawned panes are no longer locked.
- Adds `test/effort-injection.test.ts` (13 cases) covering carrier mapping, injection guards, args building, and constructor migration.
## 0.8.0
### Minor Changes
- Event-loop responsiveness fix, mobile image upload, response-viewer polish, and a mobile-UI trim.
- **fix: avoid event-loop stalls from synchronous tmux/ps calls (#100):** The session manager ran `execSync` for tmux mouse-mode toggles, `list-panes`, and `ps`/`pgrep` resource-stat queries on the main thread. Under multi-session / many-pane load these blocking spawns froze Node's single event loop, stalling SSE broadcasts and PTY I/O (the ":3000 briefly unreachable, process never restarts" class of incident). Converted those calls to async `execAsync` and updated all callers to `await`. Added a lightweight `utils/event-loop-monitor.ts` that samples loop-delay and logs when a stall threshold is exceeded, started on web-server boot and stopped on shutdown — so future regressions leave a timestamped, quantified log line instead of vanishing silently.
- **feat(web): mobile image upload to active session via paste dialog (#101):** The mobile keyboard-accessory paste dialog now attaches images, not just text — via a native picker (`accept=image/*` → camera / photo library / files) plus best-effort capture of images pasted into the textarea. Both paths reuse the existing `_uploadAndInsertImages()` → `POST /api/sessions/:id/paste-image` pipeline. Images are re-encoded client-side before upload (PNG→PNG to preserve transparency, everything else→JPEG, animated GIFs passed through untouched) so the bytes always match their declared extension — fixing the Android/MIUI case where a WebP/HEIF mislabeled as `image/jpeg` passed the extension allowlist but failed the server's magic-byte check. The server logs a precise diagnostic on any remaining magic-byte mismatch.
- **feat(web): response-viewer transcript fallback + code-block rendering (#102):** A substantial response-viewer styling overhaul — proportional prose font (monospace kept for code), refined heading/code/blockquote/list styling, readable max content width, and a smoother slide-in animation; the `.rv-text` rules now also apply to `.response-viewer-body` so transcript-missing fallback content gets the same typography. Plus a `_renderMarkdown` null-safety fix (`text` → `src = text || ''`).
- **feat(web): remove /compact button from the mobile keyboard accessory bar:** Dropped `/compact` from both the simple and extended accessory-bar layouts and the associated action handling. `/clear` retains its double-tap confirmation. Verified on a touch-emulated viewport that neither layout renders a compact action.
## 0.7.1
### Patch Changes
- **fix(respawn): auto-accept now fires on plan approvals after `Worked for X` line, and on AskUserQuestion menus**
Two related blockers in the respawn controller's auto-accept path:
- Modern Claude Code emits `✻ Worked for Xm Ys` immediately before a plan-approval menu. `_detectCompletionMessage()` cancelled the auto-accept timer and `canAutoAccept()` then rejected on `completionMessageTime !== null`, so plan approvals **never** auto-accepted — the 10 s completion-confirm timer instead started a respawn cycle while the menu sat unanswered.
- The same logic in `signalElicitation()` set a hard flag that blocked auto-accept whenever Claude Code fired the `elicitation_dialog` hook, contradicting the in-UI hint ("Auto-accept presses Enter for plan approvals **and default question options**"). AskUserQuestion menus were therefore never auto-accepted either.
Fix:
- `_detectCompletionMessage()` no longer cancels the auto-accept timer; the auto-accept pre-filter is now the authoritative "is there a numbered selection menu?" gate.
- `canAutoAccept()` and the AI-plan-check callback both accept `'watching'` AND `'confirming_idle'` states (covers the single-PTY-burst case where `Worked for` and the menu arrive together — `_detectCompletionMessage` returns early before the substantial-output check can demote state back to watching). `sendAutoAcceptEnter()` self-transitions back to `'watching'` before sending Enter.
- `signalElicitation()` is now an affirmative hint that primes the auto-accept timer instead of blocking. Still gated on `config.autoAcceptPrompts` AND state ∈ {`watching`, `confirming_idle`} — never fires Enter when respawn is off or auto-accept is disabled.
- AI plan-check prompt broadened to recognize AskUserQuestion / elicitation menus as valid for auto-accept (the verdict name `PLAN_MODE` is preserved for compatibility but now means "auto-accept this selection menu").
- Removed the now-unused `elicitationDetected` field and its assignments.
Two new regression tests cover both the separate-PTY-chunk and single-PTY-chunk cases; the previously misleading "should NOT send Enter when completion message was detected" test was renamed and re-scoped to clarify it tests the **no-menu** path (which still correctly rejects via the pre-filter).
**docs(web): correct `sendPendingCtrlL` comment** — removed the stale "called by foo/bar" note from the dead-call-graph helper after #99.
## 0.7.0
### Minor Changes
+2 -1
View File
@@ -56,7 +56,7 @@ When user says "COM":
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
**Version**: 0.7.0 (must match `package.json`)
**Version**: 0.8.1 (must match `package.json`)
## Project Overview
@@ -95,6 +95,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
- **Package ≠ product name** — npm: `aicodeman`, product: **Codeman**. Release renames tags accordingly
- **Global regex `lastIndex`** — Shared `g`-flag patterns in loops must reset `lastIndex = 0` first, or use the `execPattern()` helper in `utils/regex-patterns.ts` (resets automatically)
- **`envOverrides` flow `CLAUDE_CODE_*` / `OPENCODE_*` env vars** — Set via `POST /api/sessions { envOverrides }`, stored on `Session._envOverrides`, exported by `tmux-manager.buildEnvExports()` at spawn time, persisted in `SessionState.envOverrides`. **Do NOT** write these to `<case>/.claude/settings.local.json` — that's the old path and creates UI/disk drift
- **Effort is NOT an env var** — never carry effort as `CLAUDE_CODE_EFFORT_LEVEL`: the env var hard-locks effort and blocks in-session `/effort` switching (incl. ultracode). It flows as the dedicated `effort` payload field → `Session._effort` → `claude --effort <level>` for regular levels incl. `max` (the settings `effortLevel` key is `enum(["low","medium","high","xhigh"]).catch(undefined)` — `max` gets SILENTLY dropped there), or `claude --settings '{"ultracode":true}'` for ultracode (rejected by `--effort`). Both are soft defaults the user can override anytime. Legacy env-var entries are auto-migrated by the Session constructor and unset from tmux sessions in `applyEnvOverrides()`. See `buildEffortCliArgs()` in `session-cli-builder.ts`, tests in `test/effort-injection.test.ts`
- **Dual-CLI prefix discipline** — Codeman supports both Claude Code and OpenCode (`claude-cli-resolver.ts` / `opencode-cli-resolver.ts`); env-var prefix is CLI-specific (`CLAUDE_CODE_*` vs `OPENCODE_*`) and the allowlist in `schemas.ts` enforces this. When adding settings, decide which CLI(s) it applies to and gate the env export accordingly — don't blindly forward both prefixes. See `docs/opencode-integration.md` for the OpenCode resolver design
- **Zod `.optional()` rejects `null`** — accepts `undefined` only. When the frontend builds a request body with `JSON.stringify`, an explicit `null` field is preserved on the wire and fails validation with `INVALID_INPUT`. Convert `null` → `undefined` before stringifying (e.g. `field: value ?? undefined`), or declare the schema `.nullish()`. Real bugs caused: 0.6.4 (`durationMinutes` for ∞ respawn), and the same shape pattern hit `opusContext1mEnabled` in 0.6.3
- **`xterm-zerolag-input` is duplicated** — the local-echo overlay lives in BOTH `packages/xterm-zerolag-input/src/` (published to npm as a standalone library for external consumers — see README "Published Packages") AND inline inside `src/web/public/app.js` (runtime copy the web UI actually loads, since the page ships as plain JS without a bundler). Any change to overlay behavior MUST be applied to both, or dev and prod diverge — and a public API break in the package warrants a separate version bump for `xterm-zerolag-input` in the changeset. Always test on mobile after touching it. See `docs/local-echo-overlay-plan.md`.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "aicodeman",
"version": "0.7.0",
"version": "0.8.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
"version": "0.7.0",
"version": "0.8.1",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "0.7.0",
"version": "0.8.1",
"description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module",
"main": "dist/index.js",
+16 -9
View File
@@ -64,20 +64,27 @@ const DEFAULT_PLAN_CHECK_CONFIG: AiPlanCheckConfig = {
const VERDICT_PATTERN = /^\s*(PLAN_MODE|NOT_PLAN_MODE)\b/i;
/** The prompt sent to the AI plan checker */
const AI_PLAN_CHECK_PROMPT = `Analyze this terminal output from a running Claude Code session. Determine if the terminal is currently showing a PLAN MODE APPROVAL PROMPT or not.
const AI_PLAN_CHECK_PROMPT = `Analyze this terminal output from a running Claude Code session. Determine if the terminal is currently showing a NUMBERED SELECTION MENU that is waiting for the user to press Enter on the highlighted default option.
A plan mode approval prompt is a numbered selection menu that Claude Code shows when it wants the user to approve a plan before proceeding. It typically has these characteristics:
A qualifying menu has all of these characteristics:
- A numbered list of options (e.g., "1. Yes", "2. No", "3. Type your own")
- A selection indicator arrow (❯ or >) pointing to one of the options
- Text asking for approval like "Would you like to proceed?" or "Ready to implement?"
- The prompt appears at the BOTTOM of the output (most recent content)
- A selection indicator arrow (❯ or >) pointing to one of the options (the default)
- The menu appears at the BOTTOM of the output (most recent content)
- It is asking the user to choose, not just displaying numbered information
NOT a plan mode prompt:
This includes BOTH:
- Plan-mode approval prompts ("Would you like to proceed?" / "Ready to implement?")
- AskUserQuestion / elicitation dialogs (Claude Code's numbered question menus)
NOT a qualifying menu:
- Claude actively working (spinners, "Thinking", tool execution)
- A completed response with no selection menu
- An AskUserQuestion/elicitation dialog (different format, free-text input)
- A completed response with no selection menu visible
- A free-text input field with no numbered options
- A numbered LIST in the assistant's prose with no selection arrow
- Network lag or mid-output pause
- Any state without a visible numbered selection menu
- Any state without a visible selector arrow on a numbered option
The verdict name PLAN_MODE is historical — it now means "auto-accept this selection menu by pressing Enter on the default".
Terminal output (most recent at bottom):
---
+6 -1
View File
@@ -14,6 +14,7 @@ import type {
ClaudeMode,
SessionMode,
OpenCodeConfig,
EffortLevel,
} from './types.js';
/**
@@ -63,8 +64,10 @@ export interface CreateSessionOptions {
openCodeConfig?: OpenCodeConfig;
/** When restoring after reboot, resume a previous Claude conversation by its session ID */
resumeSessionId?: string;
/** Extra env vars exported before launching the CLI (e.g., CLAUDE_CODE_EFFORT_LEVEL). Ephemeral — not written to disk. */
/** Extra env vars exported before launching the CLI (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Ephemeral — not written to disk. */
envOverrides?: Record<string, string>;
/** Claude CLI effort level, injected as a `--settings` soft default (overridable via /effort in-session) */
effort?: EffortLevel;
}
/** Options for respawning a dead pane. */
@@ -81,6 +84,8 @@ export interface RespawnPaneOptions {
resumeSessionId?: string;
/** Extra env vars exported before launching the CLI (preserved across respawns). */
envOverrides?: Record<string, string>;
/** Claude CLI effort level (preserved across respawns, injected via `--settings`) */
effort?: EffortLevel;
}
/**
+33 -25
View File
@@ -513,7 +513,7 @@ const DEFAULT_CONFIG: RespawnConfig = {
sendInit: true, // send /init after /clear
completionConfirmMs: 10000, // 10 seconds of silence after completion message
noOutputTimeoutMs: 30000, // 30 seconds fallback if no output at all
autoAcceptPrompts: true, // auto-accept plan mode prompts (not questions)
autoAcceptPrompts: true, // auto-accept numbered selection menus (plan approvals + question dialogs)
autoAcceptDelayMs: 8000, // 8 seconds before auto-accepting
aiIdleCheckEnabled: true, // use AI to confirm idle state
aiIdleCheckModel: AI_CHECK_MODEL,
@@ -623,9 +623,6 @@ export class RespawnController extends EventEmitter {
/** Whether any terminal output has been received since start/last-auto-accept */
private hasReceivedOutput: boolean = false;
/** Whether an elicitation dialog (AskUserQuestion) was detected via hook signal */
private elicitationDetected: boolean = false;
// ========== Hook-Based Detection State (Layer 0 - Highest Priority) ==========
/** Whether a Stop hook was received (definitive idle signal from Claude Code) */
@@ -1369,7 +1366,12 @@ export class RespawnController extends EventEmitter {
this.clearWorkingPatternWindow();
this.workingDetected = false;
this.completionMessageTime = now;
this.cancelAutoAcceptTimer(); // Normal idle flow handles this
// Don't cancel the auto-accept timer here — modern Claude Code emits "Worked for X"
// immediately before a plan-approval menu, and the auto-accept pre-filter is
// responsible for distinguishing menu-present from menu-absent. Cancelling here
// would silently block auto-accept for every plan approval and AskUserQuestion
// dialog. If no menu is in the buffer, the pre-filter rejects and the
// completion-confirm timer (started below) drives the normal idle flow.
this.log(`Completion message detected: "${data.trim().substring(0, 50)}..."`);
// In watching state, start completion confirmation timer
@@ -1417,7 +1419,6 @@ export class RespawnController extends EventEmitter {
this.workingDetected = true;
this.promptDetected = false;
this.elicitationDetected = false; // Clear on new work cycle
this.resetHookState(); // Clear hook signals on new work
this.lastWorkingPatternTime = now;
@@ -2222,11 +2223,11 @@ export class RespawnController extends EventEmitter {
* @returns True if auto-accept should proceed to the AI confirmation stage
*/
private canAutoAccept(): boolean {
// Only auto-accept in watching state (not during a respawn cycle)
if (this._state !== 'watching') return false;
// Don't auto-accept if a completion message was detected (normal idle handles it)
if (this.completionMessageTime !== null) return false;
// Allow auto-accept from 'watching' AND 'confirming_idle'. The latter is reached
// when "Worked for X" was detected — which Claude Code now emits in the same PTY
// burst as a plan-approval menu. `sendAutoAcceptEnter()` self-transitions back to
// 'watching' before sending Enter. Reject any other state (respawn cycle, etc.).
if (this._state !== 'watching' && this._state !== 'confirming_idle') return false;
// Don't auto-accept if disabled
if (!this.config.autoAcceptPrompts) return false;
@@ -2234,15 +2235,15 @@ export class RespawnController extends EventEmitter {
// Don't auto-accept if we haven't received any output yet (prevents spurious Enter on fresh start)
if (!this.hasReceivedOutput) return false;
// Don't auto-accept if an elicitation dialog (AskUserQuestion) was detected
if (this.elicitationDetected) {
this.log('Skipping auto-accept: elicitation dialog detected (AskUserQuestion)');
return false;
}
// Note: completionMessageTime and elicitationDetected used to block here, but both
// legitimately co-occur with selection menus (Claude Code emits "Worked for X"
// before plan approvals, and AskUserQuestion fires the elicitation hook). The
// pre-filter below is the authoritative gate for "is there a numbered menu?".
// Stage 1: Pre-filter — check if buffer looks like plan mode
// Stage 1: Pre-filter — check if buffer looks like a numbered selection menu
// (covers both plan-mode approvals and AskUserQuestion dialogs)
if (!this.isPlanModePreFilterMatch(this.terminalBuffer.value)) {
this.log('Skipping auto-accept: pre-filter did not match plan mode patterns');
this.log('Skipping auto-accept: pre-filter did not match selection-menu patterns');
return false;
}
@@ -2308,8 +2309,10 @@ export class RespawnController extends EventEmitter {
}
if (result.verdict === 'PLAN_MODE') {
// Don't send Enter if state changed (e.g., AI idle check started or respawn cycle began)
if (this._state !== 'watching') {
// Don't send Enter if state moved into a respawn cycle while the check ran.
// 'watching' and 'confirming_idle' are both valid — sendAutoAcceptEnter()
// self-transitions to 'watching' before sending.
if (this._state !== 'watching' && this._state !== 'confirming_idle') {
this.logAction('plan-check', `Verdict: PLAN_MODE but state is ${this._state}, not sending Enter`);
return;
}
@@ -2368,13 +2371,18 @@ export class RespawnController extends EventEmitter {
/**
* Signal that an elicitation dialog (AskUserQuestion) was detected via hook.
* This prevents auto-accept from firing, since the user needs to make a selection.
* The flag is cleared when working patterns are detected (new turn starts).
* Used as a positive hint that a numbered selection menu is about to render —
* we restart the auto-accept timer so the pre-filter gets a fresh shot at it
* once the menu finishes drawing. The actual gate is `isPlanModePreFilterMatch()`
* plus (optionally) the AI plan check; this hook just primes the timer.
* No-op if respawn isn't `'watching'`/`'confirming_idle'` or `autoAcceptPrompts`
* is off, so this can never fire Enter when the user has disabled auto-accept.
*/
signalElicitation(): void {
this.elicitationDetected = true;
this.cancelAutoAcceptTimer();
this.log('Elicitation dialog signaled - auto-accept blocked until next work cycle');
this.log('Elicitation dialog signaled - auto-accept will trigger if pre-filter matches');
if (this.config.autoAcceptPrompts && (this._state === 'watching' || this._state === 'confirming_idle')) {
this.startAutoAcceptTimer();
}
}
/**
+23 -2
View File
@@ -8,7 +8,8 @@
* @module session-cli-builder
*/
import type { ClaudeMode } from './types.js';
import type { ClaudeMode, EffortLevel } from './types.js';
import { isEffortLevel } from './types.js';
import { getAugmentedPath } from './utils/index.js';
/**
@@ -31,6 +32,23 @@ function buildPermissionArgs(claudeMode: ClaudeMode, allowedTools?: string): str
}
}
/**
* Build the CLI args carrying the effort level as a SOFT default (switchable
* in-session via /effort). The CLAUDE_CODE_EFFORT_LEVEL env var is deliberately
* avoided — it hard-locks effort and blocks in-session `/effort` switching.
*
* Two carriers are needed because neither covers all levels:
* - regular levels (incl. `max`) → `--effort <level>` (the settings `effortLevel`
* key is enum(["low","medium","high","xhigh"]) with .catch(undefined), so `max`
* would be SILENTLY dropped there)
* - `ultracode` → `--settings '{"ultracode":true}'` (its own boolean settings key,
* claude >= 2.1.154; rejected by the --effort flag)
*/
export function buildEffortCliArgs(effort?: EffortLevel): string[] {
if (!effort || !isEffortLevel(effort)) return [];
return effort === 'ultracode' ? ['--settings', '{"ultracode":true}'] : ['--effort', effort];
}
/**
* Build args for an interactive Claude CLI session (direct PTY, non-mux fallback).
*
@@ -38,16 +56,19 @@ function buildPermissionArgs(claudeMode: ClaudeMode, allowedTools?: string): str
* @param claudeMode - Permission mode for the CLI
* @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)
* @returns Array of CLI arguments
*/
export function buildInteractiveArgs(
sessionId: string,
claudeMode: ClaudeMode,
model?: string,
allowedTools?: string
allowedTools?: string,
effort?: EffortLevel
): string[] {
const args = [...buildPermissionArgs(claudeMode, allowedTools), '--session-id', sessionId];
if (model) args.push('--model', model);
args.push(...buildEffortCliArgs(effort));
return args;
}
+27 -5
View File
@@ -42,9 +42,11 @@ import {
NiceConfig,
DEFAULT_NICE_CONFIG,
getErrorMessage,
isEffortLevel,
type ClaudeMode,
type SessionMode,
type OpenCodeConfig,
type EffortLevel,
} from './types.js';
import type { TerminalMultiplexer, MuxSession } from './mux-interface.js';
import { TaskTracker, type BackgroundTask } from './task-tracker.js';
@@ -311,10 +313,15 @@ export class Session extends EventEmitter {
private _openCodeConfig: OpenCodeConfig | undefined;
private _resumeSessionId: string | undefined;
// Ephemeral env overrides (e.g., CLAUDE_CODE_EFFORT_LEVEL). Exported by tmux at spawn,
// preserved across respawns via persisted state. Not written to .claude/settings.local.json.
// Ephemeral env overrides (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Exported by tmux
// at spawn, preserved across respawns via persisted state. Not written to .claude/settings.local.json.
private _envOverrides: Record<string, string> | undefined;
// Claude CLI effort level — injected as a `--settings` soft default at spawn so the
// user can still switch in-session via /effort (incl. ultracode). Never carried as
// the CLAUDE_CODE_EFFORT_LEVEL env var, which would hard-lock the session.
private _effort: EffortLevel | undefined;
// Session color for visual differentiation
private _color: import('./types.js').SessionColor = 'default';
@@ -376,6 +383,8 @@ export class Session extends EventEmitter {
resumeSessionId?: string;
/** Extra env vars exported to the CLI at spawn time (no disk persistence) */
envOverrides?: Record<string, string>;
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
effort?: EffortLevel;
}
) {
super();
@@ -423,9 +432,19 @@ export class Session extends EventEmitter {
this._openCodeConfig = config.openCodeConfig;
}
// Apply env overrides (exported at spawn, not persisted to disk)
// Apply env overrides (exported at spawn, not persisted to disk).
// Legacy migration: pre-0.7.2 carried effort as the CLAUDE_CODE_EFFORT_LEVEL env var,
// which hard-locks /effort switching. Extract it into _effort (--settings soft default)
// and never export it as an env var again. Explicit config.effort wins over legacy.
if (config.envOverrides && Object.keys(config.envOverrides).length > 0) {
this._envOverrides = { ...config.envOverrides };
const { CLAUDE_CODE_EFFORT_LEVEL: legacyEffort, ...restOverrides } = config.envOverrides;
this._envOverrides = Object.keys(restOverrides).length > 0 ? restOverrides : undefined;
if (legacyEffort && isEffortLevel(legacyEffort)) {
this._effort = legacyEffort;
}
}
if (config.effort && isEffortLevel(config.effort)) {
this._effort = config.effort;
}
// Initialize task tracker and forward events (store handlers for cleanup)
@@ -847,6 +866,7 @@ export class Session extends EventEmitter {
cliLatestVersion: this._cliLatestVersion || undefined,
openCodeConfig: this._openCodeConfig,
resumeSessionId: this._resumeSessionId,
effort: this._effort,
// envOverrides intentionally NOT on the public SessionState type — they must not
// leak into SSE / GET /api/sessions broadcasts (schema allows OPENCODE_*, which
// can carry secrets). For disk persistence, session-manager calls
@@ -1038,6 +1058,7 @@ export class Session extends EventEmitter {
openCodeConfig: this._openCodeConfig,
resumeSessionId: this._resumeSessionId,
envOverrides: this._envOverrides,
effort: this._effort,
},
createSessionOptions: {
sessionId: this.id,
@@ -1051,6 +1072,7 @@ export class Session extends EventEmitter {
openCodeConfig: this._openCodeConfig,
resumeSessionId: this._resumeSessionId,
envOverrides: this._envOverrides,
effort: this._effort,
},
spawnErrLabel: 'mux attachment',
});
@@ -1120,7 +1142,7 @@ 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);
const args = buildInteractiveArgs(this.id, this._claudeMode, this._model, this._allowedTools, this._effort);
this.ptyProcess = pty.spawn('claude', args, {
name: 'xterm-256color',
cols: 120,
+81 -37
View File
@@ -39,7 +39,9 @@ import {
type ClaudeMode,
type SessionMode,
type OpenCodeConfig,
type EffortLevel,
} from './types.js';
import { buildEffortCliArgs } from './session-cli-builder.js';
import { wrapWithNice, SAFE_PATH_PATTERN, findClaudeDir, resolveOpenCodeDir } from './utils/index.js';
import type {
TerminalMultiplexer,
@@ -259,6 +261,20 @@ function buildOpenCodeCommand(config?: OpenCodeConfig): string {
* Build the spawn command for any session mode.
* Shared by createSession() and respawnPane() to avoid duplication.
*/
/**
* Build the shell fragment carrying the effort level as a SOFT default
* (see buildEffortCliArgs — `--effort <level>` for regular levels incl. max,
* `--settings '{"ultracode":true}'` for ultracode; deliberately not the
* CLAUDE_CODE_EFFORT_LEVEL env var, which hard-locks /effort switching).
*
* Injection-safe: effort is validated against the EFFORT_LEVELS allowlist inside
* buildEffortCliArgs, so the single-quoted values contain no user-controlled characters.
*/
function buildEffortSettingsFlag(effort?: EffortLevel): string {
const [flag, value] = buildEffortCliArgs(effort);
return flag && value ? ` ${flag} '${value}'` : '';
}
function buildSpawnCommand(options: {
mode: SessionMode;
sessionId: string;
@@ -267,11 +283,13 @@ function buildSpawnCommand(options: {
allowedTools?: string;
openCodeConfig?: OpenCodeConfig;
resumeSessionId?: string;
effort?: EffortLevel;
}): 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);
// 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.
@@ -279,11 +297,11 @@ 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}`;
const fallbackCmd = `claude${permFlags} --session-id "${options.sessionId}"${modelFlag}`;
const resumeCmd = `claude${permFlags} --resume "${safeResumeId}"${modelFlag}${effortFlag}`;
const fallbackCmd = `claude${permFlags} --session-id "${options.sessionId}"${modelFlag}${effortFlag}`;
return `${resumeCmd} || ${fallbackCmd}`;
}
return `claude${permFlags} --session-id "${options.sessionId}"${modelFlag}`;
return `claude${permFlags} --session-id "${options.sessionId}"${modelFlag}${effortFlag}`;
}
if (options.mode === 'opencode') {
return buildOpenCodeCommand(options.openCodeConfig);
@@ -518,6 +536,18 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
* shell-metachar injection even if upstream schema check is bypassed.
*/
private applyEnvOverrides(muxName: string, envOverrides?: Record<string, string>): void {
// Legacy cleanup: pre-0.7.2 set CLAUDE_CODE_EFFORT_LEVEL via setenv, which persists
// on the tmux session and hard-locks /effort switching in every respawned pane.
// Effort now flows as a `--settings` soft default (see buildEffortSettingsFlag),
// so unconditionally unset the stale var before applying current overrides.
try {
execSync(`${this.tmux()} setenv -t ${shellescape(muxName)} -u CLAUDE_CODE_EFFORT_LEVEL`, {
timeout: EXEC_TIMEOUT_MS,
stdio: ['pipe', 'pipe', 'pipe'],
});
} catch {
/* Non-critical — var may not exist */
}
if (!envOverrides) return;
const VALID_KEY = /^[A-Z_][A-Z0-9_]*$/;
for (const [key, value] of Object.entries(envOverrides)) {
@@ -582,6 +612,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
openCodeConfig,
resumeSessionId,
envOverrides,
effort,
} = options;
const muxName = `codeman-${sessionId.slice(0, 8)}`;
@@ -628,6 +659,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
allowedTools,
openCodeConfig,
resumeSessionId,
effort,
});
const config = niceConfig || DEFAULT_NICE_CONFIG;
@@ -821,6 +853,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
openCodeConfig,
resumeSessionId,
envOverrides,
effort,
} = options;
const session = this.sessions.get(sessionId);
if (!session) return null;
@@ -841,6 +874,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
allowedTools,
openCodeConfig,
resumeSessionId,
effort,
});
const config = niceConfig || DEFAULT_NICE_CONFIG;
const cmd = wrapWithNice(baseCmd, config);
@@ -1153,19 +1187,23 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
try {
const psOutput = execSync(`ps -o rss=,pcpu= -p ${session.pid} 2>/dev/null || echo "0 0"`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}).trim();
const psOutput = (
await execAsync(`ps -o rss=,pcpu= -p ${session.pid} 2>/dev/null || echo "0 0"`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
})
).stdout.trim();
const [rss, cpu] = psOutput.split(/\s+/).map((x) => parseFloat(x) || 0);
let childCount = 0;
try {
const childOutput = execSync(`pgrep -P ${session.pid} | wc -l`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}).trim();
const childOutput = (
await execAsync(`pgrep -P ${session.pid} | wc -l`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
})
).stdout.trim();
childCount = parseInt(childOutput, 10) || 0;
} catch {
// No children or command failed
@@ -1202,13 +1240,15 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// Step 1: Get descendant PIDs
const descendantMap = new Map<number, number[]>();
const pgrepOutput = execSync(
`for p in ${sessionPids.join(' ')}; do children=$(pgrep -P $p 2>/dev/null | tr '\\n' ','); echo "$p:$children"; done`,
{
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}
).trim();
const pgrepOutput = (
await execAsync(
`for p in ${sessionPids.join(' ')}; do children=$(pgrep -P $p 2>/dev/null | tr '\\n' ','); echo "$p:$children"; done`,
{
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}
)
).stdout.trim();
for (const line of pgrepOutput.split('\n')) {
const [pidStr, childrenStr] = line.split(':');
@@ -1233,10 +1273,12 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// Step 3: Single ps call
const pidArray = Array.from(allPids);
if (pidArray.length > 0) {
const psOutput = execSync(`ps -o pid=,rss=,pcpu= -p ${pidArray.join(',')} 2>/dev/null || true`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}).trim();
const psOutput = (
await execAsync(`ps -o pid=,rss=,pcpu= -p ${pidArray.join(',')} 2>/dev/null || true`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
})
).stdout.trim();
const processStats = new Map<number, { rss: number; cpu: number }>();
for (const line of psOutput.split('\n')) {
@@ -1324,11 +1366,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
clearInterval(this.mouseSyncInterval);
}
this.mouseSyncInterval = setInterval(() => {
this.mouseSyncInterval = setInterval(async () => {
if (IS_TEST_MODE) return;
for (const session of this.sessions.values()) {
const panes = this.listPanes(session.muxName);
const panes = await this.listPanes(session.muxName);
const count = panes.length;
if (count === 0) continue;
@@ -1337,12 +1379,12 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// Pane count changed — toggle mouse mode
if (count > 1) {
if (this.enableMouseMode(session.muxName)) {
if (await this.enableMouseMode(session.muxName)) {
this.lastPaneCount.set(session.muxName, count);
}
// If enableMouseMode fails, DON'T update lastPaneCount — retry next poll
} else {
if (this.disableMouseMode(session.muxName)) {
if (await this.disableMouseMode(session.muxName)) {
this.lastPaneCount.set(session.muxName, count);
}
}
@@ -1473,7 +1515,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
* Allows clicking to select panes in agent team split-pane layouts.
* When mouse mode is on, tmux intercepts mouse events (slow selection, no browser copy).
*/
enableMouseMode(muxName: string): boolean {
async enableMouseMode(muxName: string): Promise<boolean> {
if (IS_TEST_MODE) return true;
if (!isValidMuxName(muxName)) {
console.error('[TmuxManager] Invalid session name in enableMouseMode:', muxName);
@@ -1481,7 +1523,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
try {
execSync(`${this.tmux()} set-option -t "${muxName}" mouse on`, {
await execAsync(`${this.tmux()} set-option -t "${muxName}" mouse on`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
});
@@ -1497,7 +1539,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
* Disable mouse mode for an existing tmux session.
* Restores native xterm.js text selection and browser clipboard copy.
*/
disableMouseMode(muxName: string): boolean {
async disableMouseMode(muxName: string): Promise<boolean> {
if (IS_TEST_MODE) return true;
if (!isValidMuxName(muxName)) {
console.error('[TmuxManager] Invalid session name in disableMouseMode:', muxName);
@@ -1505,7 +1547,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
try {
execSync(`${this.tmux()} set-option -t "${muxName}" mouse off`, {
await execAsync(`${this.tmux()} set-option -t "${muxName}" mouse off`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
});
@@ -1522,9 +1564,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
* Called by TeamWatcher when teammates spawn/despawn panes.
* Uses `tmux list-panes` for bulletproof detection — counts actual panes, not config.
*/
syncMouseMode(muxName: string): boolean {
async syncMouseMode(muxName: string): Promise<boolean> {
if (IS_TEST_MODE) return true;
const panes = this.listPanes(muxName);
const panes = await this.listPanes(muxName);
if (panes.length > 1) {
return this.enableMouseMode(muxName);
} else {
@@ -1536,7 +1578,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
* List all panes in a tmux session.
* Returns structured info for each pane.
*/
listPanes(muxName: string): PaneInfo[] {
async listPanes(muxName: string): Promise<PaneInfo[]> {
if (IS_TEST_MODE) return [];
if (!isValidMuxName(muxName)) {
console.error('[TmuxManager] Invalid session name in listPanes:', muxName);
@@ -1544,10 +1586,12 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
try {
const output = execSync(
`${this.tmux()} list-panes -t "${muxName}" -F '#{pane_id}:#{pane_index}:#{pane_pid}:#{pane_width}:#{pane_height}'`,
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
).trim();
const output = (
await execAsync(
`${this.tmux()} list-panes -t "${muxName}" -F '#{pane_id}:#{pane_index}:#{pane_pid}:#{pane_width}:#{pane_height}'`,
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
)
).stdout.trim();
return output
.split('\n')
+17
View File
@@ -40,6 +40,21 @@ export type ClaudeMode = 'dangerously-skip-permissions' | 'normal' | 'allowedToo
/** Session mode: which CLI backend a session runs */
export type SessionMode = 'claude' | 'shell' | 'opencode';
/**
* Valid Claude CLI effort levels (claude >= 2.1.154).
* `ultracode` = xhigh effort + standing dynamic-workflow orchestration; it is a
* separate `ultracode` settings key rather than an `effortLevel` value.
*/
export const EFFORT_LEVELS = ['low', 'medium', 'high', 'xhigh', 'max', 'ultracode'] as const;
/** Claude CLI effort level for new sessions (soft default, switchable via /effort in-session) */
export type EffortLevel = (typeof EFFORT_LEVELS)[number];
/** Type guard: is the string a valid EffortLevel? */
export function isEffortLevel(value: string | undefined): value is EffortLevel {
return value !== undefined && (EFFORT_LEVELS as readonly string[]).includes(value);
}
/** OpenCode session configuration */
export interface OpenCodeConfig {
/** Model identifier (e.g., "anthropic/claude-sonnet-4-5", "openai/gpt-5.2", "ollama/codellama") */
@@ -145,6 +160,8 @@ export interface SessionState {
openCodeConfig?: OpenCodeConfig;
/** Claude conversation session ID to resume after reboot (set by restore script) */
resumeSessionId?: string;
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
effort?: EffortLevel;
}
/**
+54
View File
@@ -0,0 +1,54 @@
/**
* @fileoverview Event-loop lag monitor.
*
* Node is single-threaded: any synchronous work (e.g. a blocking `execSync`)
* freezes the whole event loop, so the HTTP server stops answering on its port
* while the process stays alive and other ports are unaffected. Such stalls
* self-heal and never restart the process, so a periodic loopback healthcheck
* misses them entirely — they leave no trace.
*
* This monitor samples how late a fixed-interval timer actually fires versus when
* it was scheduled; the excess is time the loop was blocked. When that exceeds a
* threshold it logs the measured stall, turning otherwise-invisible "port briefly
* unreachable" incidents into a timestamped, quantified log line.
*
* @module utils/event-loop-monitor
*/
export interface EventLoopMonitorHandle {
stop(): void;
}
/**
* Start sampling event-loop lag.
*
* @param sampleMs How often to sample (and the baseline interval lag is measured against).
* @param thresholdMs Only stalls at or above this many ms are logged (noise floor).
* @param log Sink for stall reports; defaults to console.warn (lands in the web log).
*/
export function startEventLoopMonitor(
sampleMs = 1000,
thresholdMs = 1000,
log: (msg: string) => void = (m) => console.warn(m)
): EventLoopMonitorHandle {
let last = performance.now();
const timer = setInterval(() => {
const now = performance.now();
// Lag = elapsed beyond the scheduled interval = time the loop was blocked.
const lag = Math.round(now - last - sampleMs);
if (lag >= thresholdMs) {
log(`[EventLoopLag] event loop blocked ~${lag}ms (at ${new Date().toISOString()})`);
}
last = now;
}, sampleMs);
// Never keep the process alive solely for this monitor.
timer.unref?.();
return {
stop() {
clearInterval(timer);
},
};
}
+2
View File
@@ -9,6 +9,8 @@
export { BufferAccumulator } from './buffer-accumulator.js';
export { CleanupManager } from './cleanup-manager.js';
export { Debouncer, KeyedDebouncer } from './debouncer.js';
export { startEventLoopMonitor } from './event-loop-monitor.js';
export type { EventLoopMonitorHandle } from './event-loop-monitor.js';
export { StaleExpirationMap } from './stale-expiration-map.js';
export {
ANSI_ESCAPE_PATTERN_FULL,
+3 -2
View File
@@ -1110,9 +1110,10 @@ class CodemanApp {
/** Render markdown to sanitized HTML, falling back to plain text if marked.js unavailable */
_renderMarkdown(text) {
const src = text || '';
if (typeof marked !== 'undefined' && marked.parse) {
try {
const prepared = this._preprocessAsciiArt(text);
const prepared = this._preprocessAsciiArt(src);
let html = this._sanitizeHtml(marked.parse(prepared, { breaks: true, gfm: true }));
// Wrap tables in a horizontal-scroll container so they overflow gracefully
// on mobile without collapsing into block-level cells.
@@ -1168,7 +1169,7 @@ class CodemanApp {
} catch { /* fall through */ }
}
// Fallback: escape HTML and preserve whitespace
const escaped = text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const escaped = src.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
return `<pre style="white-space:pre-wrap;word-break:break-word">${escaped}</pre>`;
}
+53 -1
View File
@@ -113,7 +113,14 @@ Object.assign(CodemanApp.prototype, {
const paths = [];
for (const file of files) {
try {
const path = await this._uploadPasteImage(sessionId, file);
// Re-encode to a standard JPEG/PNG before upload. Galleries on some
// phones (notably Android/MIUI) hand back a WebP/HEIF whose filename and
// MIME claim "image/jpeg", which passes the server's extension allowlist
// but fails its magic-byte check ("bytes do not match declared type").
// Decoding through the browser and re-encoding guarantees the bytes
// match the extension we send.
const normalized = await this._normalizeImageForUpload(file);
const path = await this._uploadPasteImage(sessionId, normalized);
paths.push(path);
} catch (err) {
this.showToast('Upload failed: ' + (err.message || 'unknown error'), 'error');
@@ -145,4 +152,49 @@ Object.assign(CodemanApp.prototype, {
return data.path;
},
// Decode an image File through the browser and re-encode it to a format the
// server accepts, so the uploaded bytes always match their declared
// extension. PNG is re-encoded as PNG (preserves transparency); everything
// else (JPEG, WebP, HEIF, unknown) becomes JPEG. Animated GIFs are passed
// through untouched since a canvas would flatten them to one frame. On any
// decode/encode failure the original file is returned unchanged so the server
// still gets a chance (and logs a precise diagnostic).
async _normalizeImageForUpload(file) {
if (file.type === 'image/gif') return file;
const toPng = file.type === 'image/png';
const url = URL.createObjectURL(file);
try {
const img = new Image();
await new Promise((resolve, reject) => {
img.onload = () => resolve();
img.onerror = () => reject(new Error('decode failed'));
img.src = url;
});
const width = img.naturalWidth;
const height = img.naturalHeight;
if (!width || !height) return file;
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) return file;
ctx.drawImage(img, 0, 0);
const mime = toPng ? 'image/png' : 'image/jpeg';
const blob = await new Promise((resolve) => canvas.toBlob(resolve, mime, 0.92));
if (!blob) return file;
const baseName = (file.name || 'image').replace(/\.[^.]+$/, '') || 'image';
return new File([blob], baseName + (toPng ? '.png' : '.jpg'), { type: mime });
} catch (err) {
console.warn('Image re-encode failed, uploading original:', err);
return file;
} finally {
URL.revokeObjectURL(url);
}
},
});
+2 -1
View File
@@ -1104,8 +1104,9 @@
<option value="high">High</option>
<option value="xhigh">XHigh</option>
<option value="max">Max</option>
<option value="ultracode">Ultracode (multi-agent workflows)</option>
</select>
<span class="form-hint">Set CLAUDE_CODE_EFFORT_LEVEL for all new sessions (default = no override)</span>
<span class="form-hint">Default effort for new Claude sessions — soft default, switchable anytime in-session via /effort (e.g. /effort ultracode)</span>
</div>
<!-- Nice Priority Section -->
<div class="form-section-header">Nice Priority</div>
+62 -17
View File
@@ -4,8 +4,10 @@
* Defines two exports:
*
* - KeyboardAccessoryBar (singleton object) — Quick action buttons shown above the virtual
* keyboard on mobile: arrow up/down, /init, /clear, /compact, paste, and dismiss.
* Destructive actions (/clear, /compact) require double-tap confirmation (2s amber state).
* keyboard on mobile: arrow up/down, /init, /clear, paste, and dismiss.
* The paste button opens a dialog that handles both text paste and image attach
* (native picker + best-effort image paste, routed through app._uploadAndInsertImages).
* Destructive actions (/clear) require double-tap confirmation (2s amber state).
* Commands are sent as text + Enter separately for Ink compatibility.
* Only initializes on touch devices (MobileDetection.isTouchDevice guard).
*
@@ -49,7 +51,6 @@ const KeyboardAccessoryBar = {
</button>
<button class="accessory-btn" data-action="init" title="/init">/init</button>
<button class="accessory-btn" data-action="clear" title="/clear">/clear</button>
<button class="accessory-btn" data-action="compact" title="/compact">/compact</button>
<button class="accessory-btn" data-action="paste" title="Paste from clipboard">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>
@@ -98,7 +99,6 @@ const KeyboardAccessoryBar = {
<button class="accessory-btn" data-action="esc" title="Escape">Esc</button>
<button class="accessory-btn" data-action="init" title="/init">/init</button>
<button class="accessory-btn" data-action="clear" title="/clear">/clear</button>
<button class="accessory-btn" data-action="compact" title="/compact">/compact</button>
<button class="accessory-btn accessory-btn-dismiss" data-action="dismiss" title="Dismiss keyboard">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
<path d="M19 9l-7 7-7-7"/>
@@ -128,7 +128,7 @@ const KeyboardAccessoryBar = {
// Refocus terminal so keyboard stays open (tap blurs terminal → keyboard dismisses → toolbar shifts)
const refocusActions = new Set(['scroll-up', 'scroll-down', 'arrow-left', 'arrow-right', 'tab', 'shift-tab', 'ctrl-o', 'opt-enter', 'esc', 'effort-max']);
if (refocusActions.has(action) ||
((action === 'clear' || action === 'compact') && this._confirmAction)) {
(action === 'clear' && this._confirmAction)) {
if (typeof app !== 'undefined' && app.terminal) {
app.terminal.focus();
}
@@ -191,13 +191,11 @@ const KeyboardAccessoryBar = {
case 'init':
this.sendCommand('/init');
break;
case 'clear':
case 'compact': {
case 'clear': {
// Require double-tap: first tap turns amber, second tap within 2s sends
const cmd = action === 'clear' ? '/clear' : '/compact';
if (this._confirmAction === action && this._confirmTimer) {
this.clearConfirm();
this.sendCommand(cmd);
this.sendCommand('/clear');
} else {
this.setConfirm(action, btn);
}
@@ -264,8 +262,17 @@ const KeyboardAccessoryBar = {
}).catch(() => {});
},
/** Read clipboard and send contents as input */
/** Show a paste overlay with a textarea for iOS compatibility */
/** Show a paste overlay for iOS compatibility.
* Handles three input paths from one dialog:
* - Text: long-press the textarea → Paste → Send (unchanged).
* - Image (picker): the "Image" button opens a native file picker
* (accept=image/* → camera / photo library / files), the most reliable
* way to attach a photo on mobile.
* - Image (paste): if the browser exposes image blobs on the textarea's
* paste event, we intercept them and upload directly. Support is spotty
* on mobile, so it is a best-effort enhancement layered on the picker.
* All image paths reuse app._uploadAndInsertImages() (image-input.js), which
* uploads to /api/sessions/:id/paste-image and inserts the saved path. */
pasteFromClipboard() {
if (typeof app === 'undefined' || !app.activeSessionId) return;
@@ -274,23 +281,61 @@ const KeyboardAccessoryBar = {
overlay.className = 'paste-overlay';
overlay.innerHTML = `
<div class="paste-dialog">
<textarea class="paste-textarea" placeholder="Long-press here and tap Paste"></textarea>
<textarea class="paste-textarea" placeholder="Long-press to paste text — or tap 🖼 to attach an image"></textarea>
<div class="paste-actions">
<button class="paste-image">🖼 Image</button>
<button class="paste-cancel">Cancel</button>
<button class="paste-send">Send</button>
</div>
<input type="file" class="paste-file-input" accept="image/*" multiple hidden>
</div>
`;
const textarea = overlay.querySelector('.paste-textarea');
const send = () => {
const fileInput = overlay.querySelector('.paste-file-input');
const close = () => overlay.remove();
const sendText = () => {
const text = textarea.value;
overlay.remove();
close();
if (text) app.sendInput(text);
};
overlay.querySelector('.paste-cancel').addEventListener('click', () => overlay.remove());
overlay.querySelector('.paste-send').addEventListener('click', send);
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
// Filter to images, close the dialog, and hand off to the shared
// upload+insert pipeline. Returns true if any image was handled.
const handleImages = (files) => {
const images = Array.from(files || []).filter((f) => f.type.startsWith('image/'));
if (images.length === 0) return false;
close();
if (typeof app._uploadAndInsertImages === 'function') app._uploadAndInsertImages(images);
return true;
};
// Image picker (camera / photo library) — the reliable mobile path.
overlay.querySelector('.paste-image').addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', () => handleImages(fileInput.files));
// Best-effort: capture images pasted straight into the textarea.
textarea.addEventListener('paste', (e) => {
const items = e.clipboardData && e.clipboardData.items;
if (!items) return;
const imageFiles = [];
for (let i = 0; i < items.length; i++) {
if (items[i].type.startsWith('image/')) {
const blob = items[i].getAsFile();
if (blob) imageFiles.push(blob);
}
}
if (imageFiles.length > 0) {
e.preventDefault();
handleImages(imageFiles);
}
});
overlay.querySelector('.paste-cancel').addEventListener('click', close);
overlay.querySelector('.paste-send').addEventListener('click', sendText);
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
document.body.appendChild(overlay);
textarea.focus();
+9 -1
View File
@@ -1043,7 +1043,7 @@ html.mobile-init .file-browser-panel {
margin-top: 10px;
}
.paste-cancel, .paste-new, .paste-send {
.paste-cancel, .paste-new, .paste-send, .paste-image {
padding: 8px 18px;
border: none;
border-radius: 8px;
@@ -1051,6 +1051,14 @@ html.mobile-init .file-browser-panel {
cursor: pointer;
}
/* Image attach button — left-aligned, accent outline */
.paste-image {
margin-right: auto;
background: var(--bg-tertiary, #333);
color: var(--accent-color, #7aa2f7);
border: 1px solid var(--accent-color, #7aa2f7);
}
.paste-cancel {
background: var(--bg-tertiary, #333);
color: var(--text-secondary, #aaa);
+4 -4
View File
@@ -1032,10 +1032,9 @@ Object.assign(CodemanApp.prototype, {
const enabledItems = config.generatedPlan?.filter(i => i.enabled);
try {
const envOverrides = this.buildEnvOverrides(
this.getCaseSettings(config.caseName),
this.loadAppSettingsFromStorage()
);
const ralphGlobalSettings = this.loadAppSettingsFromStorage();
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(config.caseName), ralphGlobalSettings);
const effort = this.getEffortSetting(ralphGlobalSettings);
const res = await fetch('/api/ralph-loop/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -1047,6 +1046,7 @@ Object.assign(CodemanApp.prototype, {
enableRespawn: config.enableRespawn,
planItems: enabledItems?.length ? enabledItems : undefined,
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
...(effort ? { effort } : {}),
}),
});
const data = await res.json();
+19 -4
View File
@@ -22,12 +22,24 @@ Object.assign(CodemanApp.prototype, {
if (caseSettings?.agentTeams || globalSettings?.agentTeamsEnabled) {
env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = '1';
}
if (globalSettings?.thinkingEffort) {
env.CLAUDE_CODE_EFFORT_LEVEL = globalSettings.thinkingEffort;
}
// NOTE: thinkingEffort is intentionally NOT emitted as CLAUDE_CODE_EFFORT_LEVEL —
// the env var hard-locks effort and blocks in-session /effort switching (e.g.,
// ultracode). It flows as the dedicated `effort` payload field instead, which the
// backend injects as a `--settings` soft default. See getEffortSetting().
return env;
},
/**
* Resolve the effort level for new sessions from global settings.
* Returns a valid effort string or undefined (= no override, CLI default).
* Sent as the `effort` payload field — backend turns it into `claude --settings ...`.
*/
getEffortSetting(globalSettings) {
const effort = globalSettings?.thinkingEffort;
const valid = ['low', 'medium', 'high', 'xhigh', 'max', 'ultracode'];
return valid.includes(effort) ? effort : undefined;
},
// ═══════════════════════════════════════════════════════════════
// Quick Start
// ═══════════════════════════════════════════════════════════════
@@ -337,6 +349,7 @@ Object.assign(CodemanApp.prototype, {
const globalSettings = this.loadAppSettingsFromStorage();
const envOverrides = this.buildEnvOverrides(caseSettings, globalSettings);
const hasEnvOverrides = Object.keys(envOverrides).length > 0;
const effort = this.getEffortSetting(globalSettings);
const useOpus1m = caseSettings.opusContext1m || globalSettings.opusContext1mEnabled;
const modelOverride = useOpus1m ? 'opus[1m]' : '';
@@ -349,6 +362,7 @@ Object.assign(CodemanApp.prototype, {
body: JSON.stringify({
workingDir, name,
...(hasEnvOverrides ? { envOverrides } : {}),
...(effort ? { effort } : {}),
...(modelOverride !== undefined ? { modelOverride } : {}),
})
}).then(r => r.json())
@@ -538,7 +552,8 @@ Object.assign(CodemanApp.prototype, {
return;
}
// Quick-start with opencode mode (auto-allow tools by default)
// Quick-start with opencode mode (auto-allow tools by default).
// No `effort` field — it's Claude-specific (OpenCode has no /effort).
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
const res = await fetch('/api/quick-start', {
method: 'POST',
+122 -59
View File
@@ -7971,14 +7971,15 @@ kbd {
bottom: 0;
left: 0;
right: 0;
max-height: 85vh;
background: #1a1a2e;
border-top: 1px solid #333;
border-radius: 12px 12px 0 0;
max-height: 88vh;
background: #14141f;
border-top: 1px solid #2a2a3a;
border-radius: 14px 14px 0 0;
box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.45);
z-index: 5000;
flex-direction: column;
transform: translateY(100%);
transition: transform 0.25s ease-out;
transition: transform 0.28s cubic-bezier(0.22, 1, 0.36, 1);
}
.response-viewer.visible {
@@ -7990,12 +7991,13 @@ kbd {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid #333;
padding: 14px 20px;
border-bottom: 1px solid #2a2a3a;
flex-shrink: 0;
font-size: 14px;
font-weight: 600;
color: #e0e0e0;
color: #e8e8ec;
letter-spacing: 0.2px;
}
.response-viewer-actions {
@@ -8078,42 +8080,80 @@ kbd {
background: rgba(109, 219, 127, 0.12);
}
/* Markdown rendered content inside response viewer */
.rv-text {
word-break: break-word;
line-height: 1.6;
/* Markdown rendered content inside response viewer.
Prose uses a proportional font for readability; code keeps monospace. */
.rv-text,
.response-viewer-body > :not(.rv-message) {
word-break: normal;
overflow-wrap: anywhere;
line-height: 1.7;
}
.rv-text p {
margin: 0 0 0.6em;
.rv-text p,
.response-viewer-body > p {
margin: 0 0 0.85em;
}
.rv-text p:last-child {
.rv-text p:last-child,
.response-viewer-body > p:last-child {
margin-bottom: 0;
}
.rv-text h1, .rv-text h2, .rv-text h3, .rv-text h4 {
color: #e0e0e0;
margin: 1em 0 0.4em;
.rv-text h1, .rv-text h2, .rv-text h3, .rv-text h4,
.response-viewer-body > h1, .response-viewer-body > h2,
.response-viewer-body > h3, .response-viewer-body > h4 {
color: #f2f2f6;
margin: 1.4em 0 0.5em;
line-height: 1.3;
font-weight: 700;
letter-spacing: -0.01em;
}
.rv-text h1 { font-size: 1.3em; }
.rv-text h2 { font-size: 1.15em; }
.rv-text h3 { font-size: 1.05em; }
.rv-text h1:first-child, .rv-text h2:first-child,
.response-viewer-body > h1:first-child, .response-viewer-body > h2:first-child {
margin-top: 0;
}
.rv-text code {
background: #2a2a3e;
padding: 1px 5px;
border-radius: 3px;
.rv-text h1, .response-viewer-body > h1 {
font-size: 1.55em;
padding-bottom: 0.3em;
border-bottom: 1px solid #2d2d40;
}
.rv-text h2, .response-viewer-body > h2 {
font-size: 1.3em;
color: #ffd27a;
}
.rv-text h3, .response-viewer-body > h3 {
font-size: 1.13em;
color: #bfc8ff;
}
.rv-text h4, .response-viewer-body > h4 {
font-size: 1em;
color: #c9c9d5;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.rv-text code,
.response-viewer-body > :not(pre) code {
background: #262638;
color: #ffb4a2;
padding: 1px 6px;
border-radius: 4px;
font-family: 'Fira Code', 'JetBrains Mono', 'SF Mono', Menlo, Monaco, monospace;
font-size: 0.9em;
}
.rv-text pre {
background: #1e1e2e;
border: 1px solid #333;
border-radius: 6px;
padding: 10px 12px;
/* Descendant (not child) combinator: code blocks are wrapped in .rv-code-wrap,
so the latest-response view (markdown rendered straight into the body) nests
<pre> one level deeper than a direct child. The historical .rv-text path
already matched via descendant; keep both in lockstep. */
.rv-text pre,
.response-viewer-body pre {
background: #0f0f1a;
border: 1px solid #2a2a3d;
border-radius: 8px;
padding: 14px 16px;
overflow-x: auto;
margin: 1em 0;
-webkit-overflow-scrolling: touch;
@@ -8125,7 +8165,7 @@ kbd {
Preserve indentation (pre-wrap) but allow breaks inside long tokens
(URLs, paths, identifiers) so they don't overflow. */
.rv-text pre code,
.response-viewer-body > pre code {
.response-viewer-body pre code {
background: none;
color: #e6e6f0;
padding: 0;
@@ -8294,38 +8334,43 @@ kbd {
.rv-text ul, .rv-text ol,
.response-viewer-body > ul, .response-viewer-body > ol {
margin: 0.6em 0;
padding-left: 1.5em;
}
.rv-text pre code {
background: none;
padding: 0;
font-size: 0.85em;
line-height: 1.5;
.rv-text li,
.response-viewer-body > ul > li, .response-viewer-body > ol > li {
margin-bottom: 0.3em;
}
.rv-text ul, .rv-text ol {
margin: 0.4em 0;
padding-left: 1.4em;
.rv-text li > p { margin: 0.2em 0; }
.rv-text blockquote,
.response-viewer-body > blockquote {
border-left: 3px solid #5c7cfa;
background: rgba(92, 124, 250, 0.06);
margin: 0.8em 0;
padding: 0.5em 14px;
color: #b8b8c8;
border-radius: 0 6px 6px 0;
}
.rv-text li {
margin-bottom: 0.2em;
.rv-text strong,
.response-viewer-body > p strong,
.response-viewer-body > li strong {
color: #ffffff;
font-weight: 700;
}
.rv-text blockquote {
border-left: 3px solid #444;
margin: 0.6em 0;
padding: 0.3em 0 0.3em 12px;
color: #999;
.rv-text em,
.response-viewer-body em {
color: #e0e0ec;
}
.rv-text strong {
color: #f0f0f0;
}
.rv-text a {
color: #5c7cfa;
.rv-text a,
.response-viewer-body a {
color: #7aa2ff;
text-decoration: none;
border-bottom: 1px solid rgba(122, 162, 255, 0.35);
}
.rv-text a:hover,
@@ -8403,19 +8448,37 @@ kbd {
.rv-text hr,
.response-viewer-body > hr {
border: none;
border-top: 1px solid #333;
margin: 1em 0;
border-top: 1px solid #2d2d40;
margin: 1.5em 0;
}
.response-viewer-body {
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: 16px;
font-family: 'Fira Code', 'Cascadia Code', 'JetBrains Mono', 'SF Mono', Monaco, monospace;
font-size: 13px;
line-height: 1.5;
color: #d4d4d4;
overscroll-behavior: contain;
padding: 20px 22px 28px;
/* Proportional font for prose — monospace only for code/pre */
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'PingFang SC',
'Hiragino Sans GB', 'Segoe UI', 'Helvetica Neue', Helvetica, Arial,
'Noto Sans CJK SC', sans-serif;
font-size: 15px;
line-height: 1.7;
color: #d8d8e0;
/* Comfortable reading width on wider viewports */
--rv-content-max: 720px;
}
/* Constrain content width for readability; code blocks can still scroll horizontally */
.response-viewer-body > * {
max-width: var(--rv-content-max);
margin-left: auto;
margin-right: auto;
}
.response-viewer-body > pre,
.response-viewer-body > table,
.response-viewer-body > .rv-message {
max-width: var(--rv-content-max);
}
.response-viewer-body:empty::after {
+6 -3
View File
@@ -1244,7 +1244,9 @@ Object.assign(CodemanApp.prototype, {
// Match by path (not basename) so linked/renamed cases still resolve correctly.
const matchingCase = (this.cases || []).find((c) => c.path === workingDir);
const caseName = matchingCase?.name || workingDir.split('/').pop() || '';
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
const globalSettings = this.loadAppSettingsFromStorage();
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), globalSettings);
const effort = this.getEffortSetting(globalSettings);
const createRes = await fetch('/api/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -1253,6 +1255,7 @@ Object.assign(CodemanApp.prototype, {
name,
resumeSessionId: sessionId,
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
...(effort ? { effort } : {}),
}),
});
const createData = await createRes.json();
@@ -1731,8 +1734,8 @@ Object.assign(CodemanApp.prototype, {
}
},
// Legacy hook for newly-created sessions; kept as a no-op so the SSE
// idle/working handlers can still call it without conditional guards.
// Vestigial no-op: this method has no callers today. It's kept (not deleted)
// as a documented guard so the Ctrl+L behavior below isn't reintroduced.
//
// Originally this sent Ctrl+L (\x0c) when a flagged session first reached
// idle/working to scrub mux-init junk from the screen. Two problems:
+11 -2
View File
@@ -268,8 +268,16 @@ export function registerRalphRoutes(
);
}
const { caseName, taskDescription, completionPhrase, maxIterations, enableRespawn, planItems, envOverrides } =
parseBody(RalphLoopStartSchema, req.body);
const {
caseName,
taskDescription,
completionPhrase,
maxIterations,
enableRespawn,
planItems,
envOverrides,
effort,
} = parseBody(RalphLoopStartSchema, req.body);
const casePath = join(CASES_DIR, caseName);
@@ -315,6 +323,7 @@ export function registerRalphRoutes(
claudeMode: rlClaudeModeConfig.claudeMode,
allowedTools: rlClaudeModeConfig.allowedTools,
envOverrides,
effort,
});
// Configure Ralph tracker
+11
View File
@@ -335,6 +335,7 @@ export function registerSessionRoutes(
openCodeConfig: mode === 'opencode' ? body.openCodeConfig : undefined,
resumeSessionId: validatedResumeId,
envOverrides: body.envOverrides,
effort: body.effort,
});
ctx.addSession(session);
@@ -1106,6 +1107,7 @@ export function registerSessionRoutes(
mode = 'claude',
openCodeConfig,
envOverrides,
effort,
} = parseBody(QuickStartSchema, req.body);
// Check OpenCode availability if requested
@@ -1186,6 +1188,7 @@ export function registerSessionRoutes(
allowedTools: qsClaudeModeConfig.allowedTools,
openCodeConfig: mode === 'opencode' ? openCodeConfig : undefined,
envOverrides,
effort,
});
// Auto-detect completion phrase from CLAUDE.md BEFORE broadcasting
@@ -1674,6 +1677,14 @@ export function registerSessionRoutes(
// Sniff actual bytes — filename and Content-Type are both attacker-supplied.
// Polyglot HTML/PNG would otherwise pass and serve back with image/png MIME.
if (!imageMagicMatchesExt(imageBytes, ext)) {
// Diagnostic: on some Android galleries (e.g. MIUI) a WebP/HEIF is
// mislabeled as image/jpeg, so the declared ext passes the allowlist but
// the magic bytes do not. Log the real header so format mismatches can be
// pinned down without a reproduce-and-guess loop. The client now
// re-encodes images to JPEG/PNG before upload, so this should be rare.
console.warn(
`[paste-image] magic mismatch: filename=${JSON.stringify(part.filename)} mime=${JSON.stringify(part.mimetype)} declaredExt=${ext} magic=${imageBytes.subarray(0, 12).toString('hex')}`
);
reply.code(415);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Image bytes do not match declared type ${ext}`);
}
+15
View File
@@ -80,6 +80,15 @@ const safeEnvOverridesSchema = z
}
);
// ========== Effort Level ==========
/**
* Claude CLI effort level for new sessions. Injected as a `--settings` soft default
* (NOT the CLAUDE_CODE_EFFORT_LEVEL env var, which would hard-lock the session and
* block in-session `/effort` switching). `ultracode` enables dynamic workflow orchestration.
*/
const effortLevelSchema = z.enum(['low', 'medium', 'high', 'xhigh', 'max', 'ultracode']).optional();
// ========== Session Routes ==========
/**
@@ -124,6 +133,8 @@ export const CreateSessionSchema = z.object({
mode: z.enum(['claude', 'shell', 'opencode']).optional(),
name: z.string().max(100).optional(),
envOverrides: safeEnvOverridesSchema,
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
effort: effortLevelSchema,
/** Model override to write to .claude/settings.local.json (e.g., "opus[1m]"). Empty string clears. */
modelOverride: z.string().max(50).optional(),
openCodeConfig: OpenCodeConfigSchema,
@@ -179,6 +190,8 @@ export const QuickStartSchema = z.object({
mode: z.enum(['claude', 'shell', 'opencode']).optional(),
openCodeConfig: OpenCodeConfigSchema,
envOverrides: safeEnvOverridesSchema,
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
effort: effortLevelSchema,
});
// ========== Hook Events ==========
@@ -543,6 +556,8 @@ export const RalphLoopStartSchema = z.object({
maxIterations: z.number().int().min(0).max(1000).nullable().default(10),
enableRespawn: z.boolean().default(false),
envOverrides: safeEnvOverridesSchema,
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
effort: effortLevelSchema,
planItems: z
.array(
z.object({
+15 -1
View File
@@ -94,7 +94,8 @@ import {
type ImageDetectedEvent,
DEFAULT_NICE_CONFIG,
} from '../types.js';
import { CleanupManager, KeyedDebouncer, StaleExpirationMap } from '../utils/index.js';
import { CleanupManager, KeyedDebouncer, StaleExpirationMap, startEventLoopMonitor } from '../utils/index.js';
import type { EventLoopMonitorHandle } from '../utils/index.js';
import { MAX_CONCURRENT_SESSIONS, MAX_SSE_CLIENTS } from '../config/map-limits.js';
import { SseEvent } from './sse-events.js';
import type { ScheduledRun } from './ports/index.js';
@@ -231,6 +232,7 @@ export class WebServer extends EventEmitter {
private teamWatcher: TeamWatcher = new TeamWatcher();
private _orchestratorLoop: import('../orchestrator-loop.js').OrchestratorLoop | null = null;
private _pasteImageGcStop: (() => void) | null = null;
private _eventLoopMonitor: EventLoopMonitorHandle | null = null;
private teamWatcherHandlers: {
teamCreated: (config: unknown) => void;
teamUpdated: (config: unknown) => void;
@@ -1551,6 +1553,10 @@ export class WebServer extends EventEmitter {
// older than 7 days from each live session's .claude-images/ hourly.
if (!this.testMode) {
this._pasteImageGcStop = startPasteImageGc({ sessions: this.sessions });
// Surface event-loop stalls (e.g. a slow synchronous tmux/ps call) so the
// intermittent ":3000 briefly unreachable, process never restarts" class of
// incident leaves a quantified log line instead of vanishing silently.
this._eventLoopMonitor = startEventLoopMonitor();
}
await this.app.listen({ port: this.port, host: '0.0.0.0' });
@@ -1710,6 +1716,8 @@ export class WebServer extends EventEmitter {
const recoveryClaudeMode = await this.getClaudeModeConfig();
// Recover envOverrides from the internal __envOverrides field written by
// session-manager (see updateSessionState). Cast to read the non-public field.
// Note: a legacy CLAUDE_CODE_EFFORT_LEVEL entry is auto-migrated to `effort`
// by the Session constructor (env var would hard-lock /effort switching).
const savedEnvOverrides = (savedState as { __envOverrides?: Record<string, string> })?.__envOverrides;
const session = new Session({
id: muxSession.sessionId, // Preserve the original session ID
@@ -1722,6 +1730,7 @@ export class WebServer extends EventEmitter {
claudeMode: recoveryClaudeMode.claudeMode,
allowedTools: recoveryClaudeMode.allowedTools,
envOverrides: savedEnvOverrides,
effort: savedState?.effort,
});
// Update session name if it was a "Restored:" placeholder or doesn't match saved name
@@ -1911,6 +1920,11 @@ export class WebServer extends EventEmitter {
this._pasteImageGcStop = null;
}
if (this._eventLoopMonitor) {
this._eventLoopMonitor.stop();
this._eventLoopMonitor = null;
}
// Dispose all managed timers (intervals + resettable timeouts)
this.cleanup.dispose();
+122
View File
@@ -0,0 +1,122 @@
/**
* @fileoverview Tests for Claude CLI effort level injection.
*
* Effort must flow as a `--settings` SOFT default (overridable in-session via
* /effort, incl. ultracode) — never as the CLAUDE_CODE_EFFORT_LEVEL env var,
* which hard-locks the session. Also covers the legacy migration path: old
* persisted sessions carried effort inside __envOverrides.
*/
import { describe, it, expect } from 'vitest';
import { buildEffortCliArgs, buildInteractiveArgs } from '../src/session-cli-builder.js';
import { isEffortLevel, EFFORT_LEVELS } from '../src/types.js';
import { Session } from '../src/session.js';
describe('buildEffortCliArgs', () => {
it('maps regular levels (incl. max) to the --effort flag', () => {
// NOT the settings effortLevel key: its enum lacks "max" and silently drops it
expect(buildEffortCliArgs('low')).toEqual(['--effort', 'low']);
expect(buildEffortCliArgs('high')).toEqual(['--effort', 'high']);
expect(buildEffortCliArgs('xhigh')).toEqual(['--effort', 'xhigh']);
expect(buildEffortCliArgs('max')).toEqual(['--effort', 'max']);
});
it('maps ultracode to its dedicated --settings boolean key', () => {
// The --effort flag rejects ultracode; only the settings key enables it at spawn
expect(buildEffortCliArgs('ultracode')).toEqual(['--settings', '{"ultracode":true}']);
});
it('returns empty args for missing or invalid values', () => {
expect(buildEffortCliArgs(undefined)).toEqual([]);
// Invalid strings must not reach the shell command (injection guard)
expect(buildEffortCliArgs('"; rm -rf /' as never)).toEqual([]);
expect(buildEffortCliArgs('turbo' as never)).toEqual([]);
});
it('produces a flag/value pair for every allowed level', () => {
for (const level of EFFORT_LEVELS) {
const args = buildEffortCliArgs(level);
expect(args).toHaveLength(2);
expect(args[0]).toMatch(/^--(effort|settings)$/);
}
});
});
describe('isEffortLevel', () => {
it('accepts all defined levels and rejects everything else', () => {
for (const level of EFFORT_LEVELS) {
expect(isEffortLevel(level)).toBe(true);
}
expect(isEffortLevel(undefined)).toBe(false);
expect(isEffortLevel('')).toBe(false);
expect(isEffortLevel('ULTRACODE')).toBe(false);
});
});
describe('buildInteractiveArgs with effort', () => {
it('appends --settings for ultracode', () => {
const args = buildInteractiveArgs('sid-123', 'dangerously-skip-permissions', undefined, undefined, 'ultracode');
const idx = args.indexOf('--settings');
expect(idx).toBeGreaterThan(-1);
expect(args[idx + 1]).toBe('{"ultracode":true}');
});
it('appends --effort for max', () => {
const args = buildInteractiveArgs('sid-123', 'dangerously-skip-permissions', undefined, undefined, 'max');
const idx = args.indexOf('--effort');
expect(idx).toBeGreaterThan(-1);
expect(args[idx + 1]).toBe('max');
});
it('omits effort args when effort is absent', () => {
const args = buildInteractiveArgs('sid-123', 'dangerously-skip-permissions');
expect(args).not.toContain('--settings');
expect(args).not.toContain('--effort');
});
});
describe('Session effort handling', () => {
it('stores explicit effort and exposes it in toState()', () => {
const session = new Session({ workingDir: '/tmp', effort: 'ultracode' });
expect(session.toState().effort).toBe('ultracode');
});
it('migrates legacy CLAUDE_CODE_EFFORT_LEVEL out of envOverrides', () => {
const session = new Session({
workingDir: '/tmp',
envOverrides: {
CLAUDE_CODE_EFFORT_LEVEL: 'high',
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
},
});
// Legacy env var becomes the soft-default effort...
expect(session.toState().effort).toBe('high');
// ...and is never persisted (or exported) as an env var again
expect(session.getEnvOverridesForPersist()).toEqual({
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
});
});
it('drops an invalid legacy effort value instead of forwarding it', () => {
const session = new Session({
workingDir: '/tmp',
envOverrides: { CLAUDE_CODE_EFFORT_LEVEL: 'bogus-value' },
});
expect(session.toState().effort).toBeUndefined();
expect(session.getEnvOverridesForPersist()).toBeUndefined();
});
it('prefers explicit effort over the legacy env var', () => {
const session = new Session({
workingDir: '/tmp',
effort: 'ultracode',
envOverrides: { CLAUDE_CODE_EFFORT_LEVEL: 'low' },
});
expect(session.toState().effort).toBe('ultracode');
});
it('leaves effort undefined when nothing is configured', () => {
const session = new Session({ workingDir: '/tmp' });
expect(session.toState().effort).toBeUndefined();
});
});
+103 -5
View File
@@ -931,13 +931,17 @@ describe('RespawnController Edge Cases', () => {
autoAcceptController.stop();
});
it('should NOT send Enter when completion message was detected', async () => {
it('should NOT send Enter when completion message has no selection menu', async () => {
// A bare "Worked for X" with no menu following must not trigger auto-accept —
// the pre-filter is what gates this (since v0.7+, completion-message + menu IS
// allowed; see "should send Enter even when a completion message preceded the menu").
const autoAcceptController = new RespawnController(session as unknown as Session, {
autoAcceptPrompts: true,
autoAcceptDelayMs: 100,
completionConfirmMs: 200, // Longer than autoAcceptDelay
noOutputTimeoutMs: 5000,
aiIdleCheckEnabled: false,
aiPlanCheckEnabled: false,
});
let autoAcceptFired = false;
@@ -947,7 +951,7 @@ describe('RespawnController Edge Cases', () => {
autoAcceptController.start();
// Simulate completion message - normal idle flow should handle this
// Completion message with no menu — pre-filter must reject
session.simulateCompletionMessage();
// Wait for autoAcceptDelayMs
@@ -957,6 +961,97 @@ describe('RespawnController Edge Cases', () => {
autoAcceptController.stop();
});
it('should send Enter when completion message and menu arrive in separate PTY chunks', async () => {
// Modern Claude Code emits "Worked for X" right before a plan-approval menu.
// Two-chunk path: completion detected → confirming_idle, menu chunk hits the
// substantial-output cancel and brings state back to watching.
const autoAcceptController = new RespawnController(session as unknown as Session, {
autoAcceptPrompts: true,
autoAcceptDelayMs: 100,
completionConfirmMs: 1000, // Longer than autoAcceptDelay so timer wins
noOutputTimeoutMs: 5000,
aiIdleCheckEnabled: false,
aiPlanCheckEnabled: false, // Pre-filter only for this test
});
let autoAcceptFired = false;
autoAcceptController.on('autoAcceptSent', () => {
autoAcceptFired = true;
});
autoAcceptController.start();
// "Worked for X" first (as Claude Code emits it before the menu)…
session.simulateCompletionMessage();
// …then the plan-approval menu renders.
session.simulateTerminalOutput('\nWould you like to proceed?\n❯ 1. Yes\n 2. No\n');
await new Promise((resolve) => setTimeout(resolve, 200));
expect(autoAcceptFired).toBe(true);
autoAcceptController.stop();
});
it('should send Enter when completion message and menu arrive in a single PTY chunk', async () => {
// Same-burst path: "Worked for X" and the menu arrive in one data chunk.
// _detectCompletionMessage returns early so the substantial-output cancel
// never fires — state stays 'confirming_idle'. canAutoAccept must accept it.
const autoAcceptController = new RespawnController(session as unknown as Session, {
autoAcceptPrompts: true,
autoAcceptDelayMs: 100,
completionConfirmMs: 1000, // Longer than autoAcceptDelay so auto-accept wins
noOutputTimeoutMs: 5000,
aiIdleCheckEnabled: false,
aiPlanCheckEnabled: false,
});
let autoAcceptFired = false;
autoAcceptController.on('autoAcceptSent', () => {
autoAcceptFired = true;
});
autoAcceptController.start();
// Single chunk: completion message immediately followed by the menu.
session.simulateTerminalOutput('✻ Worked for 1m 30s\n\nWould you like to proceed?\n❯ 1. Yes\n 2. No\n');
await new Promise((resolve) => setTimeout(resolve, 200));
expect(autoAcceptFired).toBe(true);
autoAcceptController.stop();
});
it('should send Enter on AskUserQuestion menu after elicitation hook fires', async () => {
// The elicitation_dialog hook now hints "menu coming" rather than blocking.
// Once the menu renders, pre-filter matches and auto-accept fires.
const autoAcceptController = new RespawnController(session as unknown as Session, {
autoAcceptPrompts: true,
autoAcceptDelayMs: 100,
completionConfirmMs: 5000,
noOutputTimeoutMs: 5000,
aiIdleCheckEnabled: false,
aiPlanCheckEnabled: false,
});
let autoAcceptFired = false;
autoAcceptController.on('autoAcceptSent', () => {
autoAcceptFired = true;
});
autoAcceptController.start();
// Question prose, then hook fires (Claude Code signals dialog opening)…
session.simulateTerminalOutput('Which option do you prefer?');
autoAcceptController.signalElicitation();
// …then the numbered menu renders.
session.simulateTerminalOutput('\n❯ 1. Yes\n 2. No\n');
await new Promise((resolve) => setTimeout(resolve, 200));
expect(autoAcceptFired).toBe(true);
autoAcceptController.stop();
});
it('should NOT send Enter when disabled', async () => {
const autoAcceptController = new RespawnController(session as unknown as Session, {
autoAcceptPrompts: false,
@@ -1102,13 +1197,16 @@ describe('RespawnController Edge Cases', () => {
autoAcceptController.stop();
});
it('should NOT auto-accept when elicitation dialog is signaled', async () => {
it('should NOT auto-accept on elicitation hook alone (without menu pattern)', async () => {
// The elicitation hook hints "menu coming", but if the menu never actually
// renders (no numbered options + selector), the pre-filter rejects.
const autoAcceptController = new RespawnController(session as unknown as Session, {
autoAcceptPrompts: true,
autoAcceptDelayMs: 100,
completionConfirmMs: 50,
noOutputTimeoutMs: 5000,
aiIdleCheckEnabled: false,
aiPlanCheckEnabled: false,
});
let autoAcceptFired = false;
@@ -1119,13 +1217,13 @@ describe('RespawnController Edge Cases', () => {
autoAcceptController.start();
session.simulateTerminalOutput('Which option do you prefer?');
// Signal that an elicitation dialog (AskUserQuestion) was detected
// Hook fires but no menu actually renders
autoAcceptController.signalElicitation();
// Wait for autoAcceptDelayMs to expire
await new Promise((resolve) => setTimeout(resolve, 200));
// Auto-accept should NOT fire because elicitation was signaled
// Pre-filter rejects (no numbered option / no selector arrow)
expect(autoAcceptFired).toBe(false);
autoAcceptController.stop();
});