Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,14 @@ CLAUDE_IDLE_TIMEOUT_MS=1800000
# so a task keeps running to the end even with no browser connected.
# TASK_DISCONNECT_TIMEOUT_MS=1800000

# Reap an idle subscription-engine pane, and every MCP server it spawned.
# Unset or 0 (default) keeps the old behaviour: `ccs-<id>` lives as long as the
# studio process. Set it to a window (e.g. 900000 = 15 min) and a chat with no
# running turn and no attached viewer for that long has its tmux session killed;
# the next message respawns it with `--resume`, so no history is lost. Measured
# ~1.2 GB per open session (claude ~300 MB + MCP servers ~900 MB).
# CCS_ENGINE_IDLE_KILL_MS=0

# Ceiling on turns per sub-agent in multi-agent mode (default 200, matching the
# UI's "Turns" field). A sub-agent that exhausts its turns auto-continues up to
# 3 times, so worst case per agent is 4x this number. Lower it to cap spend.
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -547,6 +547,7 @@ WORKDIR=./workspace
MAX_TASK_WORKERS=5
CLAUDE_IDLE_TIMEOUT_MS=600000 # kill subprocess after this idle gap (no output); default 10 min, .env.example ships 30 min (legacy alias: CLAUDE_TIMEOUT_MS)
CLAUDE_HARD_CAP_MS=0 # optional absolute timeout ceiling; 0 = disabled (default)
CCS_ENGINE_IDLE_KILL_MS=0 # kill an idle subscription-engine tmux pane (and its MCP servers) after this long; 0 = never (default)
TASK_DISCONNECT_TIMEOUT_MS=1800000 # abort a task this long after the browser tab detaches; 0 = never
MULTI_AGENT_MAX_TURNS_CAP=200 # ceiling on turns per sub-agent; each may auto-continue 3x
TRUST_PROXY=false
Expand Down
1 change: 1 addition & 0 deletions README_RU.md
Original file line number Diff line number Diff line change
Expand Up @@ -547,6 +547,7 @@ WORKDIR=./workspace
MAX_TASK_WORKERS=5
CLAUDE_IDLE_TIMEOUT_MS=600000 # kill subprocess after this idle gap (no output); default 10 min, .env.example ships 30 min (legacy alias: CLAUDE_TIMEOUT_MS)
CLAUDE_HARD_CAP_MS=0 # optional absolute timeout ceiling; 0 = disabled (default)
CCS_ENGINE_IDLE_KILL_MS=0 # kill an idle subscription-engine tmux pane (and its MCP servers) after this long; 0 = never (default)
TASK_DISCONNECT_TIMEOUT_MS=1800000 # abort a task this long after the browser tab detaches; 0 = never
MULTI_AGENT_MAX_TURNS_CAP=200 # ceiling on turns per sub-agent; each may auto-continue 3x
TRUST_PROXY=false
Expand Down
1 change: 1 addition & 0 deletions README_UA.md
Original file line number Diff line number Diff line change
Expand Up @@ -548,6 +548,7 @@ WORKDIR=./workspace
MAX_TASK_WORKERS=5
CLAUDE_IDLE_TIMEOUT_MS=600000 # kill subprocess after this idle gap (no output); default 10 min, .env.example ships 30 min (legacy alias: CLAUDE_TIMEOUT_MS)
CLAUDE_HARD_CAP_MS=0 # optional absolute timeout ceiling; 0 = disabled (default)
CCS_ENGINE_IDLE_KILL_MS=0 # kill an idle subscription-engine tmux pane (and its MCP servers) after this long; 0 = never (default)
TASK_DISCONNECT_TIMEOUT_MS=1800000 # abort a task this long after the browser tab detaches; 0 = never
MULTI_AGENT_MAX_TURNS_CAP=200 # ceiling on turns per sub-agent; each may auto-continue 3x
TRUST_PROXY=false
Expand Down
131 changes: 130 additions & 1 deletion claude-interactive.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,19 @@ const IDLE_TIMEOUT_MS = parseInt(process.env.CLAUDE_IDLE_TIMEOUT_MS || process.e
// Optional absolute ceiling — backstop against a turn that stays "busy" forever. 0 = off.
const HARD_CAP_MS = parseInt(process.env.CLAUDE_HARD_CAP_MS || '0', 10) || 0;

// Opt-in idle reaper for the engine's tmux sessions (GitHub #141). A subscription
// chat keeps `ccs-<id>` — and every MCP server that pane spawned, ~1.2 GB on a
// typical set — alive after its turn ends; nothing else ever reaps it, so memory
// grows with the number of chats opened since the last restart. Unset (or 0) keeps
// exactly the old behaviour. When set, a session with no turn and no attached
// viewer for this long is killed, and the next message respawns it with
// `--resume <cid>` — the path a config-change respawn already uses, so no history
// is lost.
const ENGINE_IDLE_KILL_MS = parseInt(process.env.CCS_ENGINE_IDLE_KILL_MS || '0', 10) || 0;
// Floor for the reaper tick: a `list-clients` + `capture-pane` per engine session
// is cheap, but not free, and nothing here needs sub-15s precision.
const ENGINE_REAPER_MIN_TICK_MS = 15000;

// ─── Interactive-prompt watchdog (GitHub #20) ────────────────────────────────
// How often the turn loop looks at the transcript and the pane.
const POLL_MS = 1500;
Expand Down Expand Up @@ -416,6 +429,112 @@ function killInteractiveTmux(localSessionId) {
} catch {}
}

// ─── Idle reaper (GitHub #141) ────────────────────────────────────────────────
// Every helper below is READ-ONLY on tmux except the two touch/kill calls. The
// reaper is deliberately dumb about WHO owns a turn: it asks the caller
// (`isLive`), because only server.js knows the activeTasks / activeChatSessions /
// in_progress-task registries. Everything else — a viewer, a pending prompt, a
// spinner still animating — is visible in the pane itself, so it is checked here.
function listEngineSessions() {
try {
const r = spawnSync('tmux', tmuxArgs(['list-sessions', '-F', '#{session_name}']), { encoding: 'utf8' });
if (r.error || r.status !== 0) return [];
return String(r.stdout || '').split('\n').map(s => s.trim()).filter(n => /^ccs-/.test(n));
} catch { return []; }
}

// `list-clients -t` targets a CLIENT, not a session, so the filter must read the
// client's session name from its format instead. A failure here reports 1 (assume
// somebody is watching): a skipped reap costs memory, a wrong one costs the chat.
function engineSessionClients(name) {
try {
const r = spawnSync('tmux', tmuxArgs(['list-clients', '-F', '#{client_session}']), { encoding: 'utf8' });
if (r.error || r.status !== 0) return 1;
return String(r.stdout || '').split('\n').map(s => s.trim()).filter(s => s === name).length;
} catch { return 1; }
}

function engineSessionId(name) {
const raw = getTmuxEnv(name, 'CCS_SESSION');
if (raw) return raw;
const bare = String(name || '').replace(/^ccs-/, '');
return bare || null;
}

function touchEngineSession(name, now = Date.now()) {
setTmuxEnv(name, 'CCS_LAST_TURN', String(now));
}

// Kill `ccs-<id>` panes that have been idle for longer than `idleMs`. Returns the
// local session ids it killed. Injected seams (all optional) exist so the decision
// table can be tested without tmux.
function reapIdleEngineSessions(opts = {}) {
const idleMs = Number.isFinite(opts.idleMs) ? opts.idleMs : ENGINE_IDLE_KILL_MS;
if (!(idleMs > 0)) return []; // opt-in only
const now = Number.isFinite(opts.now) ? opts.now : Date.now();
const isLive = typeof opts.isLive === 'function' ? opts.isLive : () => false;
const list = typeof opts.list === 'function' ? opts.list : listEngineSessions;
const clientsOf = typeof opts.clientsOf === 'function' ? opts.clientsOf : engineSessionClients;
const paneOf = typeof opts.paneOf === 'function' ? opts.paneOf : capturePane;
const idOf = typeof opts.sessionIdOf === 'function' ? opts.sessionIdOf : engineSessionId;
const lastTurnOf = typeof opts.lastTurnOf === 'function'
? opts.lastTurnOf
: (name) => parseInt(getTmuxEnv(name, 'CCS_LAST_TURN') || '', 10);
const kill = typeof opts.kill === 'function' ? opts.kill : (id) => killInteractiveTmux(id);
const touch = typeof opts.touch === 'function' ? opts.touch : (name) => touchEngineSession(name, now);

const killed = [];
for (const name of list() || []) {
if (!/^ccs-/.test(String(name))) continue; // never touch anything else on the socket
const id = idOf(name);
if (!id) continue;
// A turn is running. The caller's registries also cover the window before the
// subprocess exists (autoSkill classification), which a pane check cannot see.
if (isLive(id)) { touch(name); continue; }
// A viewer is attached (view=engine, or a human in `tmux attach`). Treat it as
// activity: otherwise a viewer that reads for an hour then detaches would leave
// an already-expired clock behind and the pane would die on the next tick.
if (clientsOf(name) > 0) { touch(name); continue; }
// A pending prompt is waiting on a human (#20). Its turn may already have ended
// (grace expiry), but the pane must survive until the widget is answered — a
// killed pane turns "waiting for you" into a lost conversation.
const pane = paneOf(name);
if (paneBusy(pane, null) || paneAwaitingInput(pane) || paneAwaitingSelection(pane)) { touch(name); continue; }
const last = Number(lastTurnOf(name));
// Unknown age: a pane from before this feature, or a studio restart that lost the
// tmux environment. Start the clock now rather than kill a conversation of
// unknown age — the next full window still reaps it.
if (!(last > 0) || last > now) { touch(name); continue; }
if (now - last >= idleMs) { kill(id); killed.push(id); }
}
return killed;
}

// Start the periodic reaper. Returns null when CCS_ENGINE_IDLE_KILL_MS is unset —
// no timer, no tmux calls, nothing to opt out of later.
function startEngineIdleReaper(opts = {}) {
const idleMs = Number.isFinite(opts.idleMs) ? opts.idleMs : ENGINE_IDLE_KILL_MS;
if (!(idleMs > 0)) return null;
const tickMs = Number.isFinite(opts.tickMs) && opts.tickMs > 0
? opts.tickMs
: Math.max(ENGINE_REAPER_MIN_TICK_MS, Math.min(60000, Math.floor(idleMs / 4)));
const log = opts.log || console;
const timer = setInterval(() => {
try {
const killed = reapIdleEngineSessions({ ...opts, idleMs });
for (const id of killed) {
try { log.info('engine idle session reaped', { sessionId: id, idleMs }); } catch {}
try { if (typeof opts.onKill === 'function') opts.onKill(id); } catch {}
}
} catch (e) {
// A reaper bug must never take the studio down with it.
try { log.error('engine idle reaper failed', { error: e && e.message ? e.message : String(e) }); } catch {}
}
}, tickMs);
try { if (timer.unref) timer.unref(); } catch {}
return timer;
}

// Run one message through the persistent interactive tmux session.
// Params: same object as server.js runCliSingle — uses prompt, systemPrompt, model,
// mode, ws, sessionId, abortController, claudeSessionId, workdir, tabId; ignores the rest.
Expand Down Expand Up @@ -517,6 +636,13 @@ async function runInteractiveSingle(params) {

setTmuxEnv(name, 'CCS_CFG', cfgHash);

// Identity and idle clock for the reaper (GitHub #141). CCS_SESSION keeps the
// exact local session id even when tmuxName() had to sanitize it (the reaper
// must kill by the same name the engine uses); CCS_LAST_TURN lives in tmux's
// session environment, so it survives a studio restart together with the pane.
setTmuxEnv(name, 'CCS_SESSION', String(sessionId));
setTmuxEnv(name, 'CCS_LAST_TURN', String(Date.now()));

// Wait for the TUI to settle: two consecutive identical, non-empty captures
let prev = null;
const settleDeadline = Date.now() + 20000;
Expand Down Expand Up @@ -900,6 +1026,9 @@ async function runInteractiveSingle(params) {
} finally {
// The turn is over whichever way it ended; the banner must not outlive it.
if (awaitAnnounced) { awaitAnnounced = false; wsSendAll({ type: 'input_resolved', sessionId }); }
// The idle clock starts when the turn ENDS, not when the pane was spawned — a
// long first turn must never count as idle time. Skipped if the pane died.
if (tmuxHasSession(name)) setTmuxEnv(name, 'CCS_LAST_TURN', String(Date.now()));
}
}

Expand Down Expand Up @@ -991,4 +1120,4 @@ function catchUpFromTranscript({ cid, startOffset = 0 } = {}) {
return out;
}

module.exports = { runInteractiveSingle, killInteractiveTmux, tmuxAvailable, catchUpFromTranscript, transcriptSize, tmuxName, listOrphanedDefaultSocketSessions, paneAwaitingInput, paneAwaitingSelection, promptExcerpt, buildInteractiveCommand, tmuxLaunchCommand };
module.exports = { runInteractiveSingle, killInteractiveTmux, tmuxAvailable, catchUpFromTranscript, transcriptSize, tmuxName, listOrphanedDefaultSocketSessions, paneAwaitingInput, paneAwaitingSelection, promptExcerpt, buildInteractiveCommand, tmuxLaunchCommand, ENGINE_IDLE_KILL_MS, listEngineSessions, engineSessionClients, engineSessionId, touchEngineSession, reapIdleEngineSessions, startEngineIdleReaper };
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"scripts": {
"start": "node server.js",
"dev": "node --watch server.js",
"test": "node --test test/render/*.test.mjs && node test/overload-detector.test.js && node test/usage-limit.test.js && node test/auth-errors.test.js && node test/env-load-order.test.js && node test/multi-agent-result.test.js && node test/terminal-session.test.js && node test/terminal-schema.test.js && node test/bots.test.js && node test/bot-inbox.test.js && node test/bots-api.test.js && node test/running-sessions.test.js && node test/session-liveness.test.js && node test/terminal-bridge.integration.test.js && node test/tmux-composite.test.js && node test/engine-spawn-cmd.test.js && node test/claude-firstrun.test.js && node test/telegram-format.test.js && node test/telegram-behaviour.test.js && node test/ask-user-question.test.js && node test/delegate-terminal.test.js && node test/agent-catalog.test.js && node test/update-flow.test.js && node test/kanban-schedule.test.js && node test/kanban-run-badges.test.js && node test/task-backlog.test.js && node test/i18n-completeness.test.js && node test/config-resolve.test.js && node test/chat-defaults.test.js && node test/chat-defaults-api.test.js && node test/run-continuation.test.js && node test/worktree-manager.test.js && node test/session-expiry.test.js && node test/setup-gate.test.js && node test/path-guard.test.js && node test/subscribe-nocatchup.test.js && node test/cli-import-remote.test.js && node test/remote-list-parse.test.js && node test/config-migrate.test.js && node test/engine-pane.test.js && node test/global-workspace.test.js && node test/ssh-secret.test.js && node test/agent-dag.test.js && node test/ssh-parser.test.js && node test/ssh-termination.test.js && node test/session-restart.test.js && node test/remote-env.test.js && node test/remote-files.test.js && node test/remote-files-api.test.js && node test/auth-token.test.js && node test/cmd-model.test.js && node test/origin-guard.test.js && node test/interrupted-recovery.test.js && node test/queue-persistence.test.js && node test/interrupt-idle-config.test.js && node test/agents-md.test.js && node test/editor-links.test.js && node test/editor-open-api.test.js && node test/interrupt-delivery.test.js && node test/terminal-pane-guard.integration.test.js && node test/composer-terminal.test.js && node test/claude-cli-status.test.js && node test/asar-helpers.test.js && node test/mac-signing.test.js && node test/release-mac.test.js && node test/task-workers.test.js && node test/git-clone.test.js && node test/responsive-ui.test.js && node test/kanban-delegation.test.js && node test/kanban-group-button.test.js && node test/terminal-keys.test.js && node test/electron-navigation.test.js && node test/cli-runtime.test.js && node test/worktree-safety.test.js && node test/auth-boundary.test.js && node test/cli-process-tree.test.js && node test/schedule-actions.test.js && node test/install-hooks.test.js && node test/frontend-recovery.test.js && node test/db-revisions.test.js && node test/frontend-refresh-races.test.js && node test/release-gate.test.js && node test/focused-workspace.test.js && node test/secondary-navigation.test.js && node test/section-controls-ux.test.js && node test/settings-ux.test.js && node test/team-forms-ux.test.js && node test/page-forms-ux.test.js && node test/sidebar-ux.test.js && node test/task-text-limit.test.js && node test/terminal-clipboard.test.js && node test/terminal-addons.test.js && node test/file-preview-copy.test.js && node test/telegram-lang.test.js",
"test": "node --test test/render/*.test.mjs && node test/overload-detector.test.js && node test/usage-limit.test.js && node test/auth-errors.test.js && node test/env-load-order.test.js && node test/multi-agent-result.test.js && node test/terminal-session.test.js && node test/terminal-schema.test.js && node test/bots.test.js && node test/bot-inbox.test.js && node test/bots-api.test.js && node test/running-sessions.test.js && node test/session-liveness.test.js && node test/terminal-bridge.integration.test.js && node test/tmux-composite.test.js && node test/engine-spawn-cmd.test.js && node test/claude-firstrun.test.js && node test/telegram-format.test.js && node test/telegram-behaviour.test.js && node test/ask-user-question.test.js && node test/delegate-terminal.test.js && node test/agent-catalog.test.js && node test/update-flow.test.js && node test/kanban-schedule.test.js && node test/kanban-run-badges.test.js && node test/task-backlog.test.js && node test/i18n-completeness.test.js && node test/config-resolve.test.js && node test/chat-defaults.test.js && node test/chat-defaults-api.test.js && node test/run-continuation.test.js && node test/worktree-manager.test.js && node test/session-expiry.test.js && node test/setup-gate.test.js && node test/path-guard.test.js && node test/subscribe-nocatchup.test.js && node test/cli-import-remote.test.js && node test/remote-list-parse.test.js && node test/config-migrate.test.js && node test/engine-pane.test.js && node test/engine-idle-reaper.test.js && node test/global-workspace.test.js && node test/ssh-secret.test.js && node test/agent-dag.test.js && node test/ssh-parser.test.js && node test/ssh-termination.test.js && node test/session-restart.test.js && node test/remote-env.test.js && node test/remote-files.test.js && node test/remote-files-api.test.js && node test/auth-token.test.js && node test/cmd-model.test.js && node test/origin-guard.test.js && node test/interrupted-recovery.test.js && node test/queue-persistence.test.js && node test/interrupt-idle-config.test.js && node test/agents-md.test.js && node test/editor-links.test.js && node test/editor-open-api.test.js && node test/interrupt-delivery.test.js && node test/terminal-pane-guard.integration.test.js && node test/composer-terminal.test.js && node test/claude-cli-status.test.js && node test/asar-helpers.test.js && node test/mac-signing.test.js && node test/release-mac.test.js && node test/task-workers.test.js && node test/git-clone.test.js && node test/responsive-ui.test.js && node test/kanban-delegation.test.js && node test/kanban-group-button.test.js && node test/terminal-keys.test.js && node test/electron-navigation.test.js && node test/cli-runtime.test.js && node test/worktree-safety.test.js && node test/auth-boundary.test.js && node test/cli-process-tree.test.js && node test/schedule-actions.test.js && node test/install-hooks.test.js && node test/frontend-recovery.test.js && node test/db-revisions.test.js && node test/frontend-refresh-races.test.js && node test/release-gate.test.js && node test/focused-workspace.test.js && node test/secondary-navigation.test.js && node test/section-controls-ux.test.js && node test/settings-ux.test.js && node test/team-forms-ux.test.js && node test/page-forms-ux.test.js && node test/sidebar-ux.test.js && node test/task-text-limit.test.js && node test/terminal-clipboard.test.js && node test/terminal-addons.test.js && node test/file-preview-copy.test.js && node test/telegram-lang.test.js",
"postinstall": "node scripts/install-hooks.js",
"release": "node scripts/release.js",
"release:mac": "node scripts/release-mac.js",
Expand Down
1 change: 1 addition & 0 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3961,6 +3961,7 @@ <h2 id="gwTitle" style="font-size:14px;display:flex;align-items:center;gap:7px">

<!-- Settings-only layout and keyboard affordances. -->
<style>
#cfgModal .modal { width:min(1180px,96vw);max-width:96vw;height:88vh;height:88dvh;max-height:88dvh }
#cfgModal .settings-note { padding:10px 20px;color:var(--muted);font-size:12px;line-height:1.5 }
#cfgModal #cfgTabs { overflow-x:auto;flex-shrink:0 }
#cfgModal #cfgTabs button { white-space:nowrap;font:inherit }
Expand Down
Loading
Loading