mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 20:49:41 +02:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44439c951b | ||
|
|
b2f8b03b3c | ||
|
|
e3c496e1a4 | ||
|
|
eb831487a0 | ||
|
|
68594ac395 | ||
|
|
ec38fd11bf | ||
|
|
06f9ff6d9c | ||
|
|
257695ff8e | ||
|
|
2cfccc745f |
@@ -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/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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
@@ -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):
|
||||
---
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
@@ -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
@@ -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')
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
const escaped = src.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
return `<pre style="white-space:pre-wrap;word-break:break-word">${escaped}</pre>`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user