diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index af8016aa..ba10fa84 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "name": "codeman", "source": "./plugins/codeman", "description": "Drive Codeman from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts only inside a Codeman-managed session.", - "version": "1.33.0", + "version": "1.33.1", "author": { "name": "Ark0N", "url": "https://github.com/Ark0N" diff --git a/CHANGELOG.md b/CHANGELOG.md index 2dd7d1de..7357dc1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,30 @@ # aicodeman +## 1.33.1 + +### Patch Changes + +- ### Thanks + - @irisitymichaelgrundberg for closing sessions whose agent exited cleanly (#486), built carefully around every way a pane exit can lie (a SIGKILL with no status, a single misread), with the `.claude-images` guard split into its own commit as asked. + - @opticon454 for the live-refreshing case picker and Manage search (#483), and for the uv/uvx, libsecret and pnpm additions to the Docker images (#487, #485). + + **Finished sessions close themselves (#486).** A session whose agent you ended with `/exit` is now closed the same way the X button closes it, so finished sessions stop piling up on the board; the conversation stays resumable from the Resume list and the lifecycle log records "agent exited cleanly (status 0)". Only an explicit exit status 0 with no signal, confirmed by two pane reads, qualifies: a crashed or OOM-killed agent keeps its row with the exit code on the tab. The phone overview and desktop home rail now say `exited` instead of `idle`, reboot restore no longer offers to rebuild a session whose agent had exited, and closing one session no longer deletes the `.claude-images` directory that a sibling session in the same case still uses. Thanks @irisitymichaelgrundberg. + + **Search in the phone Select Case sheet (#488).** The bottom sheet gains a "Search cases" field that filters by name (every word must match, any order, ignoring case), Enter picks the case when exactly one row is left, and Escape clears then closes. Also fixes a dead band under Create New Case and a list shorter than the sheet could show. + + **An oversized paste no longer jams a session's input (#484).** A single input over the 64 KiB frame limit used to be refused by both transports, retried every 2 s forever, block every later input for that session and come back from localStorage on each reload. Pastes over the limit are now split into in-limit frames delivered in order (up to 1 MiB; larger ones are refused with a toast and never queued), a refused frame is dropped instead of retried, frames persisted by an older build are pruned on load, and the WebSocket answers an oversized frame with an explicit `too_large` error instead of silence. + +- 8841bcc: Add a search box to the Manage tab of the Add Case dialog. It filters the case list by name or path, and the reorder arrows are disabled while a filter is active so a swap cannot involve a hidden case. +- 8841bcc: The case picker now refreshes its list from `/api/cases` when it opens and every 5 seconds while it stays open, so folders deleted or created on disk appear without a page reload. If the selected case has been removed, the picker falls back to another case without saving it as the last-used one. + + Thanks @opticon454. + +- 77ba41f: Install `uv` and `uvx` in the Compose server image and the agent image, so MCP servers launched with `uvx` (such as the Nginx Proxy Manager MCP) can be enabled by Codex instead of failing with `uvx` not found. Both images also install `libsecret-1-0`, the native library the `keytar` dependency of the Azure DevOps MCP (`@azure-devops/mcp`) needs; without it the server crashes before answering the MCP initialize handshake. + + The Compose server image now also carries `pnpm`: `dsh plugin` spawns a literal `pnpm` with no npm fallback, so the Run menu's "DeepSeek - add a terminal profile" button failed with `dsh: pnpm not found on PATH` there. Because this release changes `server.Dockerfile`, the in-app updater asks Compose deployments to rebuild the image (`Update-Codeman.sh`) rather than applying it in place. + + Thanks @opticon454 (#487, #485). + ## 1.33.0 ### Minor Changes diff --git a/CLAUDE.md b/CLAUDE.md index 1257c689..c4a910cc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -77,7 +77,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**: 1.33.0 (must match `package.json`) +**Version**: 1.33.1 (must match `package.json`) ## Project Overview @@ -205,7 +205,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph ⚠️ **A quiet pane is not always a pane that wants you.** A CLI can declare an optional `capabilities.workDetect.watchingLine` (a monitor, background shell or cloud hand-off it is still running); the idle probe reads it into `Session.watching` and `notePrompt()` opens that idle item ALREADY acknowledged, so no surface alerts. Only `idle` is eligible, and the label is pane-derived and prompt-injectable, so a pattern must anchor on chrome only that CLI draws. → [architecture-invariants#the-watching-signal-a-quiet-pane-that-is-not-waiting-for-you](docs/architecture-invariants.md#the-watching-signal-a-quiet-pane-that-is-not-waiting-for-you). Tests: `test/session-watching.test.ts`, `test/watching-no-alert.test.ts`. -**An exited agent in a live pane** (`paneExit`, #446): panes use `remain-on-exit on`, so `/exit` leaves a pane, session and pid that look alive; `TmuxManager.startPaneExitWatcher()` publishes `SessionState.paneExit` via `session:updated`. ⚠️ Never set `status: 'error'` or null the `pid` for it; the field is TRI-STATE (absent = UNKNOWN, never alive, scoped by `Session.paneExitApplies`); an absent `#{pane_dead_status}` is not 0; a path that starts a command in a pane must clear the record AND persist. → [architecture-invariants#an-exited-agent-in-a-live-pane-paneexit](docs/architecture-invariants.md#an-exited-agent-in-a-live-pane-paneexit) +**An exited agent in a live pane** (`paneExit`, #446): panes use `remain-on-exit on`, so `/exit` leaves a pane, session and pid that look alive; `TmuxManager.startPaneExitWatcher()` publishes `SessionState.paneExit` via `session:updated`. ⚠️ Never set `status: 'error'` or null the `pid` for it; the field is TRI-STATE (absent = UNKNOWN, never alive, scoped by `Session.paneExitApplies`); an absent `#{pane_dead_status}` is not 0; a path that starts a command in a pane must clear the record AND persist. A clean exit is CLOSED via `cleanupSession()` (`pane-exit-sweep.ts`): only an explicit numeric status 0 with no signal, confirmed by 2 reads, with no start/attach in flight (`paneLifecycleInFlight`) and not within 10 s of one (a startup error keeps its row); a crashed agent keeps its row. → [architecture-invariants#an-exited-agent-in-a-live-pane-paneexit](docs/architecture-invariants.md#an-exited-agent-in-a-live-pane-paneexit) **Dead-pane respawn resume pin** (`_buildRespawnPaneOptionsWithResumePin()`, session.ts): recovering a dead pane, like a custom-model `restartCli()`, must pin the conversation or claude refuses the reused `--session-id`. The pin takes the first transcript-backed candidate (chain tail, launch seed, own id), never `_claudeSessionId`, adds nothing when none is backed, and is never applied to remote or docker sessions. → [architecture-invariants#dead-pane-respawn-the-resume-pin](docs/architecture-invariants.md#dead-pane-respawn-the-resume-pin) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index fbe15b5e..6a25c5b3 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -241,6 +241,15 @@ Further detail, closing: ⚠️ **Closing has the mirror-image race and one owne **Codeman creates every pane with `remain-on-exit on`, so a session whose agent exited still looks alive.** `/exit` ends the CLI, tmux keeps the pane and the tmux session, and the `tmux attach-session` process Codeman records as `Session.pid` runs on, so no PTY exit handler fires and the record keeps its pid and `status: 'idle'` (Ark0N/Codeman#446). `SessionState.paneExit` (`{status?, signal?, at}`) is the fact tmux already knows, published through `toState()` so it rides `session:updated` and lands in `state.json` on the same persist — there is no SSE event for it. One batched `tmux list-panes -a` per tick fills it, from `TmuxManager.startPaneExitWatcher()`, which has its OWN always-on interval: the stats collector cannot carry it, because the browser arms and disarms that one with the Monitor panel (`panels-ui.js`) and boot skips it entirely when no session was recovered. ⚠️ **The field is TRI-STATE and its third state is absence**, meaning UNKNOWN, which renders as nothing and must NEVER read as alive; it covers a running pane, a session the read did not list, a failed probe, and every session shape a dead local pane does not describe. `Session.paneExitApplies` is the single place that scoping lives, and it fails closed for four shapes: a direct-PTY session (no pane), a remote SSH session (the local pane is the ssh client, whose death is a transport drop OR an exit — the whole of #355), a docker case (the local pane is a `docker exec` into the container's own tmux), and a session rebuilt from the socket (`MuxSession.discovered`: its synthetic `restored-` id matches no `state.json` entry, so a remote session rediscovered after `mux-sessions.json` was lost would arrive looking local). ⚠️ **Never set `status: 'error'`** for an exited pane — that value is the PTY-exit breaker's and the browser answers it with a "restart it?" confirm — and **never null the `pid`**, which is what makes `selectSession()` re-attach and launch a fresh CLI. Local panes keep `remain-on-exit on`; flipping them to `failed` ends the tmux session, nulls the pid and reintroduces the auto-revive #355 removed. ⚠️ **An absent `#{pane_dead_status}` is not 0**: measured on tmux 3.2a a SIGKILLed pane reports neither a status nor a signal (`#{pane_dead_signal}` did not exist before tmux 3.4), so folding it into 0 would turn an unexplained death into a clean exit. A session answers only when the read listed EXACTLY ONE pane for it, since Codeman never splits a pane and a session the user split by hand has none that speaks for the agent. The three synchronous `isPaneDead()` callers (the `/wait` route, the TUI, the attach path) keep their own probes — this watcher is never fresh enough for them. ⚠️ **A path that starts a command in a pane must clear the record AND persist**, since the watcher's next tick sees the field already cleared and writes nothing. ⚠️ **The always-on timer gates the READ, never the tick.** `hasObservablePaneSession()` (`tmux-manager.ts`) skips the tmux exec while every session on the manager is one of the shapes `paneExitApplies` forces to UNKNOWN, so an instance running only remote or Docker work keeps ticking and costs nothing; the two predicates are two copies of one rule, and `test/session-pane-exit.test.ts` pins them against each other for the four session shapes that exist today — a FIFTH condition added to one and not the other still fails nothing, so change them together. Skipping retracts nothing, for the same reason a failed read does not. ⚠️ **The muted status dot is a specificity fight, and it is fought on three surfaces.** The tab renders `status` as before, and `tab-agent-exited` only quiets the dot, so the rule excludes three states BY HAND: `.tab-alert-action` and `.tab-alert-idle` on the tab, and `.tab-status.error` on the dot itself. Each of those colours means "this needs you" — the two alerts because a human is blocked, `error` because the browser answers it with a "restart it?" confirm — and each must survive the exit. The rich tab rail needs a SECOND copy of the rule, because its own `tab-state-*` dot rules are (0,9,1) against the strip's (0,5,0) — measured, an exited session on a detailed rail kept a full green dot and the working halo beside a badge reading "exited". Its twin matches that specificity exactly and therefore must stay BELOW those rules in source order. mobile.css needs a THIRD copy, with `!important`, because the phone block enlarges a `busy` dot and gives it a green glow that way, and `status` stays `busy` for a pane whose agent died mid-turn — without it a phone renders a grey dot still wearing the green halo. `test/session-pane-exit-ui.test.ts` resolves the real stylesheets in jsdom rather than matching selector text — styles.css for the desktop cases and both files for the phone ones — so the ordering, the hand-written exclusions and a missing phone rule all fail there. Tests: `test/session-pane-exit.test.ts`, `test/tmux-manager.test.ts`, `test/session-pane-exit-ui.test.ts`. +**A session whose agent exited cleanly is closed, and a crashed one is kept** (Ark0N/Codeman#446, part 2). After every pane read, `closeCleanlyExitedSessions()` (`server.ts`) closes each session that `shouldCloseCleanlyExitedSession()` (`pane-exit-sweep.ts`, pure) accepts, through `cleanupSession(id, true, CLEAN_EXIT_CLOSE_REASON)`. That is the X button's path, so an unpinned session is removed, a pinned one is demoted to `status: 'stopped'`, the lifecycle log records why, and the conversation stays resumable from the Resume list, which reads the lifecycle log and the transcripts rather than the pane. The rule has four parts, and each guards against a wrong close: + +- ⚠️ **The status must be an explicit numeric 0 with no signal** (`isCleanPaneExit()`). An absent status is how a SIGKILL presents on tmux 3.2a, so `status ?? 0` would close an agent the OOM killer took. A non-zero status or any signal keeps the row, marked with the exit, as the crash evidence #210 was filed to keep. +- **At least `CLEAN_EXIT_CONFIRMING_READS` (2) authoritative reads must agree.** `TmuxManager.getPaneExitReadCount()` counts them. A repeat of the same pane pid, status and signal adds one, anything else starts again at 1, and a failed, empty or skipped read never reaches `applyPaneExits()`, so it neither confirms nor resets. A mux without the method never has a session closed. +- **No start, attach or relaunch may be in flight** (`Session.paneLifecycleInFlight`, raised for the whole of `_setupOrAttachMuxSession()` and `restartCli()`). The dead-pane respawn revives an exited pane on purpose, and the pane reads as dead until `clearPaneExitForNewPane()` runs after its startup delay. +- **The pane must have been up for `CLEAN_EXIT_MIN_PANE_LIFETIME_MS` (10 s)** since the last start, attach or relaunch finished (`Session.paneStartedAt`). A CLI that prints a startup error and exits 0 would otherwise lose its tab, and the error with it, seconds after launch; its row stays as `exited (0)` instead. An attach to an already running pane stamps it too, so an `/exit` within seconds of a server restart leaves a row to close by hand. + +Scoping needs no check of its own here: `setPaneExit()` already forces `paneExit` to UNKNOWN for direct-PTY, remote, docker and discovered sessions. There is no setting, by the maintainer's decision on #446. ⚠️ Do not flip local panes to `remain-on-exit failed` to get the same effect: a destroyed pane ends the tmux session, the PTY exit nulls the pid, and the browser's `selectSession()` then launches a fresh CLI. `cleanupSession()` keeps `{workingDir}/.claude-images` while another session still uses that directory (`pasteImageDirInUseByOtherSession()`, `paste-image-gc.ts`), since the sweep would otherwise routinely delete a live sibling's pasted images. Paths are compared by `realpath`, and a detached session counts through its persisted record, because `killMux=false` removes it from the map while its pane keeps running; only a session being KILLED is exempt. Each exit gets ONE close attempt (keyed by session id and `at`), and a session being closed refuses `startInteractive()`/`startShell()` (`Session.markClosing()`), so a start that races the close cannot orphan a tmux session. `planRebootRestore()` refuses a record whose persisted `paneExit` is clean (`agent-exited`), which covers an agent that exited just before the power went, before the sweep reached it. Tests: `test/pane-exit-sweep.test.ts`, `test/paste-image-dir-shared.test.ts`, `test/reboot-restore.test.ts`, `test/tmux-manager.test.ts`. + ### Dead-pane respawn: the resume pin **The dead-pane respawn needs the same resume pin as a custom-model `restartCli()` and shares it** (`_buildRespawnPaneOptionsWithResumePin()` in `session.ts`, used by `restartCli()`, the dead-pane respawn in `_setupOrAttachMuxSession()`, and its create path when that path RELAUNCHES a CLI: after a failed respawn, or when tmux lost the whole session rather than the pane): a pane whose agent EXITED owns a transcript too, so recovering one with the bare launch line hit the same refusal and the conversation was stranded behind a tab that looked merely idle. The pin walks three candidates in order — the conversation chain's tail, the launch seed, then the session's own id — and takes the first one a transcript backs, never `_claudeSessionId` (which also holds history-correlated GUESSES keyed on the working directory, and launching from one would open and write to a conversation that was never this pane's). ⚠️ The create-path pin is written to `_resumeSessionId` as well, so unlike `restartCli()`'s one-respawn pin it PERSISTS through `toState()` as `resumeSessionId`: that field means "what the user asked to resume at creation, or what recovery pinned", and after a dead-pane respawn `_claudeSessionId` names whatever the walk actually pinned rather than the chain tail. ⚠️ A candidate no transcript backs is passed over, and falling off the end of the walk ADDS no pin (the options keep whatever launch seed they already carried): a divergent pin leaves `--session-id ` in the fallback branch, where a failed resume collides all over again, while pinning an id with no transcript prints claude's "No conversation found" into a brand-new session's scrollback and costs the running branch its `nice` priority (`wrapWithNice()` prefixes only the first branch of an `a || b`). ⚠️ Remote and docker sessions are never pinned: their pane commands are already self-healing, the conversation lives on the far side, and a local id resolves to nothing there. diff --git a/docs/reliable-input-delivery.md b/docs/reliable-input-delivery.md index 213dd834..99f65beb 100644 --- a/docs/reliable-input-delivery.md +++ b/docs/reliable-input-delivery.md @@ -66,6 +66,31 @@ each `(clientId, seq)` at most once, so a resend can't type the prompt twice. (the 200 is the client's ACK). `curl`/legacy callers omit the fields and always apply. +## Oversized input (issue #484) + +Delivery has a third outcome besides "applied" and "retry": **refused for good**. +Both transports refuse a frame longer than `MAX_INPUT_LENGTH` (64 KiB, +`src/config/terminal-limits.ts`; the POST schema uses the same constant). Before +#484 the client treated that like a transient failure, so an oversized paste sat +at the head of the queue, was re-sent every 2 s forever, blocked every later +input for the session, and came back from localStorage on each reload. + +- `_sendInputAsync()` splits a paste over the frame limit into in-limit frames + (`CodemanInputLimit.split`, constants.js, never cutting a surrogate pair). They + go out in seq order, so the PTY sees one contiguous stream. A paste over + `PASTE_MAX_CHARS` (1 MiB), or an oversized `useMux` write (line-oriented, never + split), is refused with a toast and never queued. +- The WebSocket answers an oversized sequenced frame with + `{t:'ia', seq, err:'too_large', max}`; the client drops it with a toast. A + client that predates `err` reads it as a plain ACK and drops it too. +- The POST drain drops a frame answered `400`/`413` (`401`/`403` stay transient: + an expired login delivers once the user signs in again). +- `_loadReliableState()` prunes persisted frames over the limit, so a queue + poisoned by an older build heals on the first load after upgrading. +- ⚠️ The frontend limit (`INPUT_FRAME_MAX_CHARS`) and the composer's + `COMPOSER_INPUT_FRAME_LIMIT` must equal `MAX_INPUT_LENGTH`; pinned by + `test/input-size-limit.test.ts`. + ## Known limitation Dedup state is in-memory on the server. A **server restart** between a write and @@ -79,3 +104,5 @@ across the narrow restart window. semantics (monotonic, per-client, gap-tolerant, eviction-safe). - `test/routes/session-routes.test.ts` — POST `/input` applies a tagged `(clientId, seq)` once on redelivery; untagged input always applies. +- `test/input-size-limit.test.ts`: one input limit on both sides, frame + splitting, and dropping (never retrying) a frame refused for good (#484). diff --git a/package-lock.json b/package-lock.json index 004275ea..14841794 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "aicodeman", - "version": "1.33.0", + "version": "1.33.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aicodeman", - "version": "1.33.0", + "version": "1.33.1", "hasInstallScript": true, "license": "MIT", "workspaces": [ diff --git a/package.json b/package.json index 0150b8f9..c51e42cc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "aicodeman", - "version": "1.33.0", + "version": "1.33.1", "description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence", "type": "module", "main": "dist/index.js", diff --git a/plugins/codeman/.claude-plugin/plugin.json b/plugins/codeman/.claude-plugin/plugin.json index e40ac970..7d21a17e 100644 --- a/plugins/codeman/.claude-plugin/plugin.json +++ b/plugins/codeman/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "codeman", "description": "Drive Codeman, the self-hosted session manager for AI coding agents, from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts only inside a Codeman-managed session.", - "version": "1.33.0", + "version": "1.33.1", "author": { "name": "Ark0N", "url": "https://github.com/Ark0N" diff --git a/src/mux-interface.ts b/src/mux-interface.ts index f5d3124e..8461c660 100644 --- a/src/mux-interface.ts +++ b/src/mux-interface.ts @@ -336,6 +336,14 @@ export interface TerminalMultiplexer extends EventEmitter { */ getPaneExit?(muxName: string): PaneExit | undefined; + /** + * How many authoritative pane reads have agreed on the exit `getPaneExit()` + * reports, or 0 when it reports none. The exited-agent sweep closes a session + * only once this reaches `CLEAN_EXIT_CONFIRMING_READS` (`pane-exit-sweep.ts`), + * and a multiplexer without this method never has a session closed by it. + */ + getPaneExitReadCount?(muxName: string): number; + /** Forget a session's exit observation, e.g. once its pane has been respawned. */ clearPaneExit?(muxName: string): void; diff --git a/src/pane-exit-sweep.ts b/src/pane-exit-sweep.ts new file mode 100644 index 00000000..3d71708e --- /dev/null +++ b/src/pane-exit-sweep.ts @@ -0,0 +1,99 @@ +/** + * @fileoverview The exited-agent sweep's decision rule (Ark0N/Codeman#446). + * + * Codeman creates every tmux pane with `remain-on-exit on`, so `/exit` ends the + * CLI while the pane, the tmux session and the `tmux attach-session` process + * all live on. Part 1 of #446 records that as `SessionState.paneExit`. This + * module decides when such a session is closed, the way the X button closes + * it, so finished sessions stop piling up on the board. + * + * The rule closes a session only on a POSITIVE observation of a clean exit: + * + * - The exit status must be an explicit numeric 0 with no signal. An absent + * status is UNKNOWN, never 0: on tmux 3.2a a SIGKILLed pane reports neither a + * status nor a signal, so reading absence as clean would sweep an agent the + * OOM killer took. A non-zero status or any signal keeps the row, marked with + * the exit, as the crash evidence #210 was filed to keep. + * - At least {@link CLEAN_EXIT_CONFIRMING_READS} authoritative pane reads must + * have agreed on that exit. A failed, empty or skipped read counts for + * nothing, because unknown never closes anything. + * - No start, attach or relaunch may be in flight for the session. The + * dead-pane branch of `Session._setupOrAttachMuxSession()` respawns an exited + * pane on purpose, and for a few seconds that pane still reads as dead. + * - The exit must land at least {@link CLEAN_EXIT_MIN_PANE_LIFETIME_MS} after + * the last start, attach or relaunch finished. A CLI that prints a startup + * error ("not logged in", a bad profile, a config error) and exits 0 would + * otherwise lose its tab, and the error with it, seconds after launch. Its + * row stays, marked `exited (0)`, for the user to read and close. + * + * Scoping to local mux-backed sessions happens before this rule runs: + * `Session.setPaneExit()` forces the field to UNKNOWN for direct-PTY, remote, + * docker and discovered sessions, so their `paneExit` never reaches here. + * + * Pure, so the rule is unit-tested without a server (test/pane-exit-sweep.test.ts). + */ +import type { PaneExit } from './types/index.js'; + +/** + * How many authoritative pane reads must agree on a clean exit before the + * session is closed. At the watcher's 2 s cadence two reads mean a finished + * session disappears within about four seconds of its agent exiting. + */ +export const CLEAN_EXIT_CONFIRMING_READS = 2; + +/** + * How long a pane must have been up before a clean exit closes its session. + * An exit sooner than this after the last pane start is read as a startup + * failure rather than a user ending the agent, and the row is kept. + */ +export const CLEAN_EXIT_MIN_PANE_LIFETIME_MS = 10_000; + +/** The lifecycle-log reason recorded when the sweep closes a session. */ +export const CLEAN_EXIT_CLOSE_REASON = 'agent exited cleanly (status 0)'; + +/** + * Is this exit a clean one? True only for an explicit numeric status of 0 with + * no signal reported. + * + * ⚠ Never widen this to `(exit.status ?? 0) === 0` or to "no signal, so it was + * clean". An absent status is how a signal death presents on tmux 3.2a, and + * that shortcut would close crashed agents with nothing failing to warn you. + */ +export function isCleanPaneExit(exit: PaneExit | undefined): boolean { + if (!exit) return false; + if (exit.signal !== undefined) return false; + return exit.status === 0; +} + +/** Everything the sweep needs to know about one session. */ +export interface CleanExitSweepCandidate { + /** The session's published exit, already scoped by `Session.setPaneExit()`. */ + paneExit: PaneExit | undefined; + /** Authoritative pane reads that agreed on that exit (`getPaneExitReadCount()`). */ + confirmingReads: number; + /** A start, attach or relaunch is running for this session's pane. */ + paneLifecycleInFlight: boolean; + /** The session is already being closed or detached. */ + closing: boolean; + /** + * When the last start, attach or relaunch of this pane finished + * (`Session.paneStartedAt`), or 0 when none has run in this process. + */ + paneStartedAt: number; +} + +/** Should the sweep close this session now? See the file overview for the rule. */ +export function shouldCloseCleanlyExitedSession(candidate: CleanExitSweepCandidate): boolean { + if (candidate.closing) return false; + if (candidate.paneLifecycleInFlight) return false; + if (!isCleanPaneExit(candidate.paneExit)) return false; + // `at` is when this server first read the pane dead, so an exit during the + // start itself lands BEFORE `paneStartedAt` and is kept too. + if ( + candidate.paneStartedAt > 0 && + candidate.paneExit!.at - candidate.paneStartedAt < CLEAN_EXIT_MIN_PANE_LIFETIME_MS + ) { + return false; + } + return candidate.confirmingReads >= CLEAN_EXIT_CONFIRMING_READS; +} diff --git a/src/reboot-restore.ts b/src/reboot-restore.ts index 40b29b08..055bd1a3 100644 --- a/src/reboot-restore.ts +++ b/src/reboot-restore.ts @@ -19,19 +19,22 @@ * touching its status, so a pinned session a reboot killed still reads `idle` or * `busy` and stays eligible. * - * ⚠️ Ending the AGENT rather than the session is a shape this module CANNOT - * recognise today, and a reboot restores it. `/exit` ends the CLI inside the - * pane, `remain-on-exit` keeps the pane, and the PTY Codeman owns is the - * `tmux attach-session` process, which stays alive throughout — so no exit - * handler runs, no lifecycle `exit` is logged, and the record keeps both its pid - * and `status: 'idle'`. Nothing durable distinguishes it from a session that was - * simply idle when the power went. Ark0N/Codeman#446 covers making Codeman - * notice the dead pane; until a record can say the agent is gone, this pass will - * offer those sessions back, and the user dismisses or closes them. + * ⚠️ Ending the AGENT rather than the session leaves no trace in `status` or + * `pid`. `/exit` ends the CLI inside the pane, `remain-on-exit` keeps the pane, + * and the PTY Codeman owns is the `tmux attach-session` process, which stays + * alive throughout — so no exit handler runs, no lifecycle `exit` is logged, + * and the record keeps both its pid and `status: 'idle'`. Ark0N/Codeman#446 + * handles it in two steps. The pane-exit watcher persists `paneExit`, and the + * clean-exit sweep (`pane-exit-sweep.ts`) closes a session whose agent exited + * with status 0 through `cleanupSession()`, which leaves the durable record + * described above. This module also refuses a record whose persisted + * `paneExit` is a clean exit, which covers a session that exited moments + * before the power went, before the sweep reached it. A crashed agent's record + * stays eligible, like the row the sweep leaves on the board for it. * - * The `pid` check below is therefore NOT that rule. It refuses a record whose - * attach process was already gone, which is a session that never started or - * whose pane died outright. + * The `pid` check below is NOT that rule. It refuses a record whose attach + * process was already gone, which is a session that never started or whose + * pane died outright. * * @dependencies types (SessionState), config/cli-registry * @consumedby web/server (plan build at boot), web/routes/reboot-restore-routes @@ -41,6 +44,7 @@ import type { SessionState } from './types.js'; import { getCli } from './config/cli-registry/registry.js'; +import { isCleanPaneExit } from './pane-exit-sweep.js'; /** Session statuses a reboot restore may rebuild. `stopped` is the kill marker. */ const RESTORABLE_STATUSES: ReadonlySet = new Set(['idle', 'busy', 'error']); @@ -109,7 +113,7 @@ export function resolveResumeConversationId(state: SessionState): string { /** * Why one session was passed over. Reported for logging and shown to the user. * - * The first seven are decided before anything is built. `capacity-reached` and + * All but the last two are decided before anything is built. `capacity-reached` and * `rebuild-failed` can only happen once a click is spending the plan, and they * are the two the banner must not confuse with a missing workspace: one means * "try again after closing something", the other means the CLI would not start. @@ -120,6 +124,7 @@ export interface RebootRestoreRejection { | 'no-persisted-record' | 'intentionally-ended' | 'not-running' + | 'agent-exited' | 'respawn-blocked' | 'remote-or-docker' | 'unsupported-mode' @@ -191,7 +196,8 @@ export function planRebootRestore( // // ⚠️ This does NOT catch a session the user ended with `/exit`. See the // module header: that leaves the pid in place, because the pid is the tmux - // attach process and `remain-on-exit` keeps it alive. + // attach process and `remain-on-exit` keeps it alive. The `paneExit` check + // below catches it instead. // // Conservative on purpose. A session that somehow persisted no pid while // genuinely running is not offered, and its conversation stays reachable @@ -200,6 +206,13 @@ export function planRebootRestore( skipped.push({ sessionId, reason: 'not-running' }); continue; } + if (isCleanPaneExit(state.paneExit)) { + // The user ended the agent, and the clean-exit sweep would have closed the + // session had the power not gone first (Ark0N/Codeman#446). The same + // explicit-0 rule applies: an absent status is unknown, not clean. + skipped.push({ sessionId, reason: 'agent-exited' }); + continue; + } if (state.respawnBlocked === true) { // The crash-loop breaker tripped on this pane. Re-creating it restarts the loop. skipped.push({ sessionId, reason: 'respawn-blocked' }); diff --git a/src/session.ts b/src/session.ts index d5e6ba16..e8d1c4a3 100644 --- a/src/session.ts +++ b/src/session.ts @@ -582,6 +582,21 @@ export class Session extends EventEmitter { * rendered as "alive". */ private _paneExit: PaneExit | null = null; + /** + * How many starts, attaches or relaunches are running for this session's + * pane. While one is, a dead-pane reading may describe a pane that is being + * revived on purpose, so the exited-agent sweep leaves the session alone + * (Ark0N/Codeman#446). A counter rather than a flag, so two overlapping + * operations cannot clear each other's mark. + */ + private _paneLifecycleOps = 0; + /** When the last pane start, attach or relaunch finished (ms), 0 when none has run. */ + private _paneStartedAt = 0; + /** + * The server has started closing this session, so no start or attach may + * begin (see {@link markClosing}). + */ + private _closing = false; /** * This session was rebuilt from the tmux socket rather than from Codeman's * own records, so its `remote`/`docker` metadata is missing rather than known @@ -1183,6 +1198,49 @@ export class Session extends EventEmitter { return this._paneExit ?? undefined; } + /** + * True while a start, attach or relaunch is running for this session's pane. + * The exited-agent sweep reads it (see `pane-exit-sweep.ts`): the dead-pane + * branch of {@link _setupOrAttachMuxSession} respawns an exited pane, and + * until it finishes and clears the exit, the pane still reads as dead. + */ + get paneLifecycleInFlight(): boolean { + return this._paneLifecycleOps > 0; + } + + /** + * When the last start, attach or relaunch of this pane finished, or 0 when + * none has run in this process. The exited-agent sweep keeps an exit that + * lands within `CLEAN_EXIT_MIN_PANE_LIFETIME_MS` of it, since that reads as a + * CLI failing at startup rather than a user ending it. An attach to a pane + * that was already running stamps it too, which only costs a user who + * `/exit`s within seconds of a server restart a row to close by hand. + */ + get paneStartedAt(): number { + return this._paneStartedAt; + } + + /** + * Mark this session as being closed, or clear the mark after a close that + * failed. While it is set, {@link startInteractive} and {@link startShell} + * refuse to run. A start that raced a close would otherwise launch a CLI in a + * tmux session whose record is about to be deleted (Ark0N/Codeman#446). + */ + markClosing(closing: boolean): void { + this._closing = closing; + } + + /** Run one pane start, attach or relaunch with {@link paneLifecycleInFlight} raised. */ + private async _withPaneLifecycle(op: () => Promise): Promise { + this._paneLifecycleOps++; + try { + return await op(); + } finally { + this._paneLifecycleOps--; + this._paneStartedAt = Date.now(); + } + } + /** * Forget this pane's exit, on both this record and the mux layer's cache. * Every path that starts or relaunches a command in the pane calls it, and @@ -1888,6 +1946,14 @@ export class Session extends EventEmitter { respawnPaneOptions: import('./mux-interface.js').RespawnPaneOptions; createSessionOptions: import('./mux-interface.js').CreateSessionOptions; spawnErrLabel: string; + }): Promise<{ isRestored: boolean; respawnedResumeId?: string; respawnedDeadPane: boolean }> { + return this._withPaneLifecycle(() => this._doSetupOrAttachMuxSession(options)); + } + + private async _doSetupOrAttachMuxSession(options: { + respawnPaneOptions: import('./mux-interface.js').RespawnPaneOptions; + createSessionOptions: import('./mux-interface.js').CreateSessionOptions; + spawnErrLabel: string; }): Promise<{ isRestored: boolean; respawnedResumeId?: string; respawnedDeadPane: boolean }> { const mux = this._mux!; @@ -2071,6 +2137,10 @@ export class Session extends EventEmitter { * the mux session is gone — see {@link reattachRemote} for that reasoning). */ async restartCli(): Promise { + return this._withPaneLifecycle(() => this._doRestartCli()); + } + + private async _doRestartCli(): Promise { if (!this._useMux || !this._mux || !this._muxSession) return false; const mux = this._mux; @@ -2459,6 +2529,9 @@ export class Session extends EventEmitter { if (this.ptyProcess) { throw new Error('Session already has a running process'); } + if (this._closing) { + throw new Error('Session is being closed'); + } // Bounds the workspace-trust scan (see _maybeAcceptTrustDialog). Stamped here // rather than at PTY spawn so a slow mux attach still counts as startup. @@ -3280,6 +3353,9 @@ export class Session extends EventEmitter { if (this.ptyProcess) { throw new Error('Session already has a running process'); } + if (this._closing) { + throw new Error('Session is being closed'); + } this._resetBuffers(); diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index a0ad8d76..77078ec2 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -287,6 +287,19 @@ export interface PaneExitObservation { exit: PaneExit; } +/** + * A {@link PaneExitObservation} as the manager stores it, with a count of the + * authoritative reads that have seen this same exit. The count is what lets + * the exited-agent sweep act only on a death that more than one read agreed on + * (`CLEAN_EXIT_CONFIRMING_READS` in `pane-exit-sweep.ts`). A failed or skipped + * read never reaches {@link TmuxManager.applyPaneExits}, so it neither raises + * the count nor resets it. + */ +interface TrackedPaneExit extends PaneExitObservation { + /** Authoritative reads that saw this exit, counting the first. */ + reads: number; +} + /** Read one optional numeric field; a blank or non-numeric value is "not reported". */ function paneField(fields: string[], index: number): number | undefined { const raw = fields[index]; @@ -1672,7 +1685,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { * lives on `Session`, because the remote-reconnect watcher above needs the * raw pane reading. */ - private paneExits: Map = new Map(); + private paneExits: Map = new Map(); /** The pane-exit watcher's own interval. Runs whether or not stats are on. */ private paneExitInterval: NodeJS.Timeout | null = null; /** @@ -3075,6 +3088,16 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { return this.paneExits.get(muxName)?.exit; } + /** + * How many authoritative pane reads have agreed on the exit that + * {@link getPaneExit} reports, or 0 when it reports none. A new observation + * starts at 1, and every later read that sees the same pane with the same + * status and signal adds one. + */ + getPaneExitReadCount(muxName: string): number { + return this.paneExits.get(muxName)?.reads ?? 0; + } + /** * Re-read every pane on the socket and refresh {@link paneExits}. ONE batched * `tmux list-panes -a` answers for every session at once, which is why this @@ -3170,6 +3193,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { * changed status, a changed signal, or a different pane pid all start a new * observation — the pid is what catches a second command in the same pane * that happened to exit the same way. + * + * The same rule decides the read count: a repeat of the stored exit adds one, + * and anything that starts a new observation starts the count again at 1. */ applyPaneExits(observed: Map): void { for (const muxName of [...this.paneExits.keys()]) { @@ -3182,7 +3208,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { prev.panePid === next.panePid && prev.exit.status === next.exit.status && prev.exit.signal === next.exit.signal; - this.paneExits.set(muxName, sameExit ? prev : next); + this.paneExits.set(muxName, sameExit ? { ...prev, reads: prev.reads + 1 } : { ...next, reads: 1 }); } } diff --git a/src/types/session.ts b/src/types/session.ts index 529ba448..49249aff 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -649,12 +649,13 @@ export interface CustomModelBookkeeping extends CustomModelSelection { * ⚠ AN ABSENT `status` STAYS ABSENT. Never write `status ?? 0`, and never read * "no signal was reported" as "the exit must have been clean". On tmux 3.2a * the absent status IS how a signal death presents, so absent-stays-absent is - * the only thing keeping a future clean-exit sweep away from crashed agents: - * an agent SIGKILLed by the OOM killer would otherwise read as a user typing - * `/exit` and be swept. Nothing here fails when somebody adds that `??` — the - * types allow it, the label still renders, and the damage shows up only once - * the sweep lands. The rule is enforced in `derivePaneExits()` - * (`tmux-manager.ts`), which omits the key rather than defaulting it. + * the only thing keeping the clean-exit sweep (`pane-exit-sweep.ts`) away + * from crashed agents: an agent SIGKILLed by the OOM killer would otherwise + * read as a user typing `/exit` and be closed. Nothing here fails when + * somebody adds that `??` — the types allow it and the label still renders. + * The rule is enforced in `derivePaneExits()` (`tmux-manager.ts`), which omits + * the key rather than defaulting it, and again in `isCleanPaneExit()`, which + * accepts only an explicit 0. */ export interface PaneExit { /** diff --git a/src/web/paste-image-gc.ts b/src/web/paste-image-gc.ts index 0711d3e7..8cee31ce 100644 --- a/src/web/paste-image-gc.ts +++ b/src/web/paste-image-gc.ts @@ -12,7 +12,8 @@ * image dir. */ import fs from 'node:fs/promises'; -import { join } from 'node:path'; +import { realpathSync } from 'node:fs'; +import { join, resolve } from 'node:path'; import type { SessionPort } from './ports/index.js'; const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days @@ -53,6 +54,73 @@ export async function sweepPasteImagesOnce( return { scanned, deleted }; } +/** + * The path two sessions must share to share a paste-image dir: the canonical + * path when it can be resolved, so a sibling that reaches the same directory + * through a symlink matches, and the normalised path otherwise (a directory + * that no longer exists has nothing left to protect). + */ +function canonicalDir(dir: string): string { + try { + return realpathSync(dir); + } catch { + return resolve(dir); + } +} + +/** One session the paste-image guard weighs: its id, directory and, for a persisted record, its status. */ +export interface PasteImageDirUser { + id: string; + workingDir: string; + status?: string; +} + +/** + * Does another live session still use this working directory's paste-image + * dir? Deleting a session removes `{workingDir}/.claude-images` recursively, + * and several sessions routinely share one case directory, so without this + * check closing one session deletes the pasted images a sibling in the same + * case still refers to. + * + * Two kinds of sibling count as live: + * + * - a session in the server's map, unless it is itself being killed; + * - a persisted record whose status is not `stopped`. That covers a session + * detached with `killMux=false`, which leaves the server's map while its + * tmux pane keeps running, and a session whose detach is still in progress. + * + * A session being KILLED does not count. Without that exemption, killing two + * sessions of one case concurrently (a bulk delete, or the exited-agent sweep + * closing two panes on one tick) would have each defer to the other, and + * neither would remove the dir. + * + * Erring toward "in use" only costs a missed deletion, which the periodic + * sweep above ages out. A pinned record whose tmux session is gone keeps its + * status through boot pruning, so it holds the dir this way until unpinned. + */ +export function pasteImageDirInUseByOtherSession(input: { + live: Iterable; + persisted: Iterable; + closingId: string; + workingDir: string; + killing: ReadonlySet; +}): boolean { + const target = canonicalDir(input.workingDir); + const matches = (user: PasteImageDirUser): boolean => + user.id !== input.closingId && + !input.killing.has(user.id) && + !!user.workingDir && + canonicalDir(user.workingDir) === target; + for (const user of input.live) { + if (matches(user)) return true; + } + for (const user of input.persisted) { + if (user.status === 'stopped') continue; + if (matches(user)) return true; + } + return false; +} + export function startPasteImageGc(ctx: Pick): () => void { const initial = setTimeout(() => { void sweepPasteImagesOnce(ctx); diff --git a/src/web/public/app.js b/src/web/public/app.js index 5bfdfe43..05577c34 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -3427,7 +3427,26 @@ class CodemanApp { */ _sendInputAsync(sessionId, input, opts) { if (!sessionId || !input) return; - this._reliableSend(sessionId, input, opts?.useMux === true); + const useMux = opts?.useMux === true; + // Both transports refuse a frame over the server's limit (issue #484), and a + // refused frame used to sit at the head of the durable queue for good. So an + // oversized paste goes out as several in-limit frames, delivered in seq order + // as one contiguous stream. A mux write is line-oriented (it strips newlines + // and sends Enter on its own), so it is never split: refuse it instead. + const limit = window.CodemanInputLimit; + if (limit && input.length > limit.FRAME_MAX_CHARS) { + if (useMux || input.length > limit.PASTE_MAX_CHARS) { + const max = useMux ? limit.FRAME_MAX_CHARS : limit.PASTE_MAX_CHARS; + this.showToast?.( + `Input too large (${Math.ceil(input.length / 1024)} KB, limit ${Math.floor(max / 1024)} KB); not sent`, + 'error' + ); + return; + } + for (const frame of limit.split(input)) this._reliableSend(sessionId, frame, false); + return; + } + this._reliableSend(sessionId, input, useMux); } /** @@ -3547,6 +3566,12 @@ class CodemanApp { // Session no longer exists — the input can never land. Drop it // rather than retry forever (not a "lost" prompt: the target is gone). this._ackDelivery(sessionId, rec.seq); + } else if (resp && (resp.status === 400 || resp.status === 413)) { + // The frame itself was refused, so a retry gets the same answer. Kept + // queued, it was re-POSTed every 2 s forever and blocked every later + // input for this session behind it (issue #484). 401/403 stay + // transient: an expired login delivers fine once the user signs in. + this._dropRejectedInput(sessionId, rec); } else { break; // offline / 5xx — leave queued; sweep + reconnect retry later } @@ -3557,6 +3582,12 @@ class CodemanApp { })(); } + /** Drop a frame the server refused for good, and say so once. */ + _dropRejectedInput(sessionId, rec) { + this._ackDelivery(sessionId, rec.seq); + this.showToast?.(`Input refused by the server (${Math.ceil(rec.data.length / 1024)} KB); not sent`, 'error'); + } + /** Drop an ACKed record (by exact seq) and persist. */ _ackDelivery(sessionId, seq) { const list = this._pendingDeliveries.get(sessionId); @@ -3601,6 +3632,13 @@ class CodemanApp { _onWsInputAck(seq, msg) { const sessionId = this._wsSessionId; if (!sessionId || !Number.isInteger(seq)) return; + if (msg && msg.err) { + // Refused for good (e.g. over the size limit): retrying cannot help. + const rec = (this._pendingDeliveries.get(sessionId) || []).find((r) => r.seq === seq); + if (rec) this._dropRejectedInput(sessionId, rec); + else this._ackDelivery(sessionId, seq); + return; + } if (msg && msg.dup) { const list = this._pendingDeliveries.get(sessionId); const rec = list && list.find((r) => r.seq === seq); @@ -3714,20 +3752,22 @@ class CodemanApp { if (saved && saved.pending) { for (const [s, recs] of Object.entries(saved.pending)) { if (Array.isArray(recs) && recs.length) { - // Reset sentAt so they re-deliver promptly on this fresh load. - this._pendingDeliveries.set( - s, - recs - .filter((r) => r && typeof r.data === 'string' && Number.isInteger(r.seq)) - .map((r) => ({ - seq: r.seq, - data: r.data, - useMux: !!r.useMux, - ts: r.ts || Date.now(), - tries: 0, - sentAt: 0, - })) - ); + const frameMax = window.CodemanInputLimit?.FRAME_MAX_CHARS ?? Infinity; + const kept = recs + .filter((r) => r && typeof r.data === 'string' && Number.isInteger(r.seq)) + // A frame over the server's limit can never be ACKed; one persisted + // by an older build would otherwise come back on every load (#484). + .filter((r) => r.data.length <= frameMax) + // Reset sentAt so they re-deliver promptly on this fresh load. + .map((r) => ({ + seq: r.seq, + data: r.data, + useMux: !!r.useMux, + ts: r.ts || Date.now(), + tries: 0, + sentAt: 0, + })); + if (kept.length) this._pendingDeliveries.set(s, kept); } } } @@ -4878,9 +4918,10 @@ class CodemanApp { // `state` keys SESSION_ACTIVITY_RANK and the sort, while `status` stays idle // or busy for an exited pane by design, so without this the muted dot sits // beside a pill saying "idle". A pending alert still wins, exactly as it - // does for the dot. - const exited = !!paneExitLabel(session.paneExit) && (state === 'idle' || state === 'working'); - const exitAt = exited ? Number(session.paneExit.at) || 0 : 0; + // does for the dot. The rule is `_mobileOverviewExit()`, shared with both + // home screens so the three surfaces agree on which sessions have exited. + const exit = this._mobileOverviewExit ? this._mobileOverviewExit(state, session) : null; + const exited = !!exit; return { state, exited, @@ -4891,13 +4932,7 @@ class CodemanApp { // state pill and never replaces it. watching: typeof session.watching === 'string' ? session.watching : '', createdAt: Number(session.createdAt) || 0, - since: exitAt - ? { key: 'exited', at: exitAt } - : exited - ? null - : this._mobileOverviewSince - ? this._mobileOverviewSince(state, session) - : null, + since: exit ? exit.since : this._mobileOverviewSince ? this._mobileOverviewSince(state, session) : null, }; } diff --git a/src/web/public/constants.js b/src/web/public/constants.js index 15f24368..6f9d85c8 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -762,6 +762,49 @@ function resolveTerminalFontWeights(settings) { // without a terminal, a clipboard, or a browser. // --------------------------------------------------------------------------- +/** + * Largest single input frame the server accepts, in UTF-16 code units. + * ⚠️ Must equal MAX_INPUT_LENGTH in src/config/terminal-limits.ts (pinned by + * test/input-size-limit.test.ts). Both transports reject a longer frame, and + * before issue #484 the durable input queue retried such a frame forever. + */ +const INPUT_FRAME_MAX_CHARS = 64 * 1024; + +/** + * Largest paste the client will deliver at all. Anything up to this is split + * into INPUT_FRAME_MAX_CHARS frames that go out in seq order, so the PTY sees + * one contiguous byte stream (bracketed-paste markers included). Past it the + * input is refused with a toast rather than queued: every frame is persisted + * and retried until ACKed, so a multi-megabyte paste would pin the queue. + */ +const INPUT_PASTE_MAX_CHARS = 1024 * 1024; + +/** + * Split input into frames no longer than `max` code units, never cutting a + * surrogate pair in half (a lone surrogate reaches the PTY as U+FFFD). + * + * @param {string} data + * @param {number} [max] + * @returns {string[]} + */ +function splitInputFrames(data, max = INPUT_FRAME_MAX_CHARS) { + if (typeof data !== 'string' || data.length === 0) return []; + if (!(max >= 2)) max = 2; + if (data.length <= max) return [data]; + const frames = []; + let start = 0; + while (start < data.length) { + let end = Math.min(start + max, data.length); + if (end < data.length) { + const code = data.charCodeAt(end - 1); + if (code >= 0xd800 && code <= 0xdbff) end--; // keep the pair together + } + frames.push(data.slice(start, end)); + start = end; + } + return frames; +} + /** * Upper bound on an AUTO-copied selection. * @@ -940,6 +983,11 @@ if (typeof window !== 'undefined') { compare: compareSessionActivity, sort: sortSessionsByActivity, }; + window.CodemanInputLimit = { + FRAME_MAX_CHARS: INPUT_FRAME_MAX_CHARS, + PASTE_MAX_CHARS: INPUT_PASTE_MAX_CHARS, + split: splitInputFrames, + }; window.CodemanAutoCopy = { decide: decideAutoCopy, MAX_CHARS: AUTO_COPY_MAX_CHARS, diff --git a/src/web/public/home-sessions.js b/src/web/public/home-sessions.js index 66430d9a..eeb12c53 100644 --- a/src/web/public/home-sessions.js +++ b/src/web/public/home-sessions.js @@ -201,6 +201,8 @@ Object.assign(CodemanApp.prototype, { const session = this.sessions.get(id); const matched = this._mobileOverviewCaseFor(session.workingDir, cases); const state = this._mobileOverviewState(session, this.pendingHooks?.get(id)); + // Guarded: a stale cached mobile-overview.js may predate the helper. + const exit = this._mobileOverviewExit ? this._mobileOverviewExit(state, session) : null; const mode = session.mode || 'claude'; return { id, @@ -211,7 +213,10 @@ Object.assign(CodemanApp.prototype, { caseName: matched ? matched.name : '', dir: this._shortenHomePath ? this._shortenHomePath(session.workingDir) : session.workingDir || '', state, - pill: HOME_SESSIONS_PILL_LABEL[state] || state, + // What the row's dot, accent and pill show. It differs from `state` only + // for an exited agent (Ark0N/Codeman#446), whose state still sorts it. + display: exit ? 'exited' : state, + pill: exit ? 'exited' : HOME_SESSIONS_PILL_LABEL[state] || state, // What the pane's footer says is still running in the background, straight off // the session payload. Same field, same meaning as on the phone overview. watching: typeof session.watching === 'string' ? session.watching : '', @@ -224,7 +229,7 @@ Object.assign(CodemanApp.prototype, { lastSubmitAt: Number(session.lastSubmitAt) || 0, // "how long has it been like this", resolved by the phone overview's // helper so both home screens label the same stamp with the same word. - since: this._mobileOverviewSince(state, session), + since: exit ? exit.since : this._mobileOverviewSince(state, session), }; }); @@ -392,7 +397,8 @@ Object.assign(CodemanApp.prototype, { _buildHomeSessionRow(row) { const item = document.createElement('button'); item.type = 'button'; - item.className = 'home-sessions-row home-sessions-row--' + row.state; + const display = row.display || row.state; + item.className = 'home-sessions-row home-sessions-row--' + display; item.dataset.hsAction = 'session'; item.dataset.hsSession = row.id; item.title = row.dir ? `${row.name} (${row.dir})` : row.name; @@ -409,7 +415,7 @@ Object.assign(CodemanApp.prototype, { } const dot = document.createElement('span'); - dot.className = 'home-sessions-dot home-sessions-dot--' + row.state; + dot.className = 'home-sessions-dot home-sessions-dot--' + display; dot.setAttribute('aria-hidden', 'true'); item.appendChild(dot); @@ -441,7 +447,7 @@ Object.assign(CodemanApp.prototype, { item.appendChild(body); const pill = document.createElement('span'); - pill.className = 'home-sessions-pill home-sessions-pill--' + row.state; + pill.className = 'home-sessions-pill home-sessions-pill--' + display; // Skipped by i18n on purpose: generic single words ("idle", "done", "error") // that collide with state strings on other surfaces. pill.setAttribute('data-i18n-skip', ''); diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index 363fc253..82d6e6bb 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -106,6 +106,8 @@ 'Manage AI Coding tools in persistent tmux sessions.': '在持久化 tmux 会话中管理 AI 编程工具。', 'Select case': '选择案例', 'Select Case': '选择案例', + 'Search cases': '搜索案例', + 'No matching cases': '没有匹配的案例', 'All cases': '全部案例', 'No directory': '未选择目录', Run: '运行', diff --git a/src/web/public/index.html b/src/web/public/index.html index 40eb112f..a5009b53 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -3175,6 +3175,7 @@

Manage

Reorder or remove cases, and pick up anything exported from a docker case.

+
@@ -3204,7 +3205,12 @@

Select Case

+
+
diff --git a/src/web/public/mobile-overview.js b/src/web/public/mobile-overview.js index 833177aa..764697a6 100644 --- a/src/web/public/mobile-overview.js +++ b/src/web/public/mobile-overview.js @@ -161,6 +161,36 @@ Object.assign(CodemanApp.prototype, { return { key: MOBILE_OVERVIEW_SINCE_LABEL[state] || state, at }; }, + /** + * The exited-agent override for one row (Ark0N/Codeman#446), or null when + * the row shows its state as usual. + * + * The server publishes `session.paneExit` once the agent inside a local tmux + * pane has exited, while `status` stays `idle` or `busy` by design. So a row + * classified as idle or working may really be a pane with nothing running + * in it. This overrides what the row SHOWS, never its `state`: `state` still + * picks the section and the sort, the way `_sidebarRichRow()` (app.js) does + * for the detailed sidebar and rail. A pending alert still wins, because a + * human being blocked outranks the agent having exited. + * + * Shared by the phone overview, the desktop home rail and the rich tab rows, + * so the three cannot disagree about which sessions have exited. + * + * Guarded like every other cross-file call: `paneExitLabel()` lives in + * app.js, and a stale cached app.js must degrade to no override, not throw. + * + * @returns {{since: {key: string, at: number}|null}|null} + */ + _mobileOverviewExit(state, session) { + if (state !== 'idle' && state !== 'working') return null; + if (typeof paneExitLabel !== 'function' || !paneExitLabel(session.paneExit)) return null; + // `at` is when this server first saw the pane dead, which is what "exited + // 2m" should measure. A row without it shows no duration at all rather + // than a working or idle stamp that no longer describes the pane. + const at = Number(session.paneExit.at) || 0; + return { since: at ? { key: 'exited', at } : null }; + }, + /** * Longest-prefix match of a workingDir against the case list, so a session * started in a subdirectory still belongs to its case. Mirrors the matching in @@ -202,6 +232,7 @@ Object.assign(CodemanApp.prototype, { const rows = sessions.map((session) => { const matched = this._mobileOverviewCaseFor(session.workingDir, cases); const state = this._mobileOverviewState(session, pendingHooks.get && pendingHooks.get(session.id)); + const exit = this._mobileOverviewExit(state, session); const orderIndex = order.indexOf(session.id); return { id: session.id, @@ -210,7 +241,10 @@ Object.assign(CodemanApp.prototype, { caseName: matched ? matched.name : '', dir: this._shortenHomePath ? this._shortenHomePath(session.workingDir) : session.workingDir || '', state, - pill: MOBILE_OVERVIEW_PILL_LABEL[state] || state, + // What the row's dot, accent and pill show. It differs from `state` only + // for an exited agent, whose state still decides the section and sort. + display: exit ? 'exited' : state, + pill: exit ? 'exited' : MOBILE_OVERVIEW_PILL_LABEL[state] || state, // What the pane's own footer says is still running in the background ("1 monitor", // "2 shells"), straight off the session payload. A row that has one is quiet // because the agent is waiting for that, not because it is waiting for you. @@ -222,7 +256,7 @@ Object.assign(CodemanApp.prototype, { // pair resolved for DISPLAY, and the two must not drift apart. lastActivityAt: Number(session.lastActivityAt) || 0, lastSubmitAt: Number(session.lastSubmitAt) || 0, - since: this._mobileOverviewSince(state, session), + since: exit ? exit.since : this._mobileOverviewSince(state, session), orderIndex: orderIndex === -1 ? Number.MAX_SAFE_INTEGER : orderIndex, }; }); @@ -698,12 +732,13 @@ Object.assign(CodemanApp.prototype, { _buildMobileOverviewRow(row) { const item = document.createElement('button'); item.type = 'button'; - item.className = 'mobile-overview-row mobile-overview-row--' + row.state; + const display = row.display || row.state; + item.className = 'mobile-overview-row mobile-overview-row--' + display; item.dataset.moAction = 'session'; item.dataset.moSession = row.id; const dot = document.createElement('span'); - dot.className = 'mobile-overview-dot mobile-overview-dot--' + row.state; + dot.className = 'mobile-overview-dot mobile-overview-dot--' + display; dot.setAttribute('aria-hidden', 'true'); item.appendChild(dot); @@ -736,7 +771,7 @@ Object.assign(CodemanApp.prototype, { item.appendChild(body); const pill = document.createElement('span'); - pill.className = 'mobile-overview-pill mobile-overview-pill--' + row.state; + pill.className = 'mobile-overview-pill mobile-overview-pill--' + display; // Skipped by i18n on purpose: the labels are generic single words ("idle", // "done", "error") that collide with state strings on other surfaces. pill.setAttribute('data-i18n-skip', ''); diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css index f4588d21..1a5136f4 100644 --- a/src/web/public/mobile.css +++ b/src/web/public/mobile.css @@ -2165,9 +2165,11 @@ html.mobile-init .file-browser-panel { background: rgba(0, 0, 0, 0.5); } + /* The footer already reserves the home-indicator inset; padding the sheet too + counted it twice and left a dead band under Create New Case. */ .mobile-case-picker-sheet { - max-height: 60vh; - padding-bottom: var(--safe-area-bottom); + max-height: 80vh; + max-height: 80dvh; animation: slideUp 0.2s ease-out; } @@ -2978,6 +2980,13 @@ html.mobile-init .file-browser-panel { color: var(--green); } + /* An exited agent (Ark0N/Codeman#446): neutral, since nothing is running behind + the row. The dot and the row take `--exited` too and keep their base rules. */ + .mobile-overview-pill--exited { + border-color: var(--text-muted); + color: var(--text-muted); + } + /* Accent, and none of the three above: a session watching work it started itself is not asking the user for anything, and red and yellow are what say it is. */ .mobile-overview-pill--watching { diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 7ee0e02f..307e2ff3 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -122,6 +122,9 @@ const RUN_MODE_LAUNCH = { * (`openSessionOptions`), guaranteed to drift from each other the moment a * ninth CLI landed in one and not the other. */ +/** How often the OPEN case picker re-reads /api/cases (it also refreshes once on open). */ +const CASE_PICKER_REFRESH_MS = 5000; + const EXTERNAL_CLI_MODES = new Set(Object.keys(RUN_MODE_LAUNCH)); const BUILT_IN_RUN_MODES = new Set(['claude', 'shell', ...Object.keys(RUN_MODE_LAUNCH)]); @@ -246,16 +249,74 @@ Object.assign(CodemanApp.prototype, { const input = document.getElementById('quickStartCaseSearch'); const list = document.getElementById('quickStartCaseList'); if (!input || !list) return; + const wasOpen = this._casePickerOpen === true; this._casePickerOpen = true; this._casePickerFilter = filter; this._casePickerActiveIndex = 0; input.setAttribute('aria-expanded', 'true'); this.renderCasePickerList(); + // Every keystroke re-enters here, so only the closed -> open transition + // refreshes and arms the timer; typing must not fire a fetch per key. + if (!wasOpen) this._startCasePickerRefresh(); + }, + + /** Re-read the case list while the picker is open, so folders deleted or created on disk show up without a page reload. */ + _startCasePickerRefresh() { + void this.refreshCasePickerCases(); + if (this._casePickerRefreshTimer) return; + this._casePickerRefreshTimer = setInterval(() => void this.refreshCasePickerCases(), CASE_PICKER_REFRESH_MS); + }, + + _stopCasePickerRefresh() { + if (this._casePickerRefreshTimer) clearInterval(this._casePickerRefreshTimer); + this._casePickerRefreshTimer = null; + }, + + /** + * Lighter than loadQuickStartCases(): that one closes the picker and re-picks a + * selection, which would yank the list away from someone mid-browse. This only + * swaps the data and repaints, and does nothing when the list is unchanged. + */ + async refreshCasePickerCases() { + if (this._casePickerRefreshInFlight) return; + this._casePickerRefreshInFlight = true; + try { + const res = await fetch('/api/cases'); + if (!res.ok) return; + const cases = (await res.json()).data; + if (!Array.isArray(cases) || !this._casePickerOpen) return; + const signature = list => JSON.stringify((list || []).map(c => [c.name, c.path, c.location])); + if (signature(cases) === signature(this.cases)) return; + this.cases = cases; + + const select = document.getElementById('quickStartCase'); + if (select) { + const previous = select.value; + this.renderQuickStartCaseSelectOptions(select, this.getCasePickerOptions()); + if (cases.some(c => c.name === previous)) { + select.value = previous; + } else if (cases.length > 0) { + // The selected case was removed on disk: fall back the way the initial + // load does, without saving it as the user's last-used case. + const fallback = cases.find(c => c.name === 'testcase') || cases[0]; + select.value = fallback.name; + this.updateDirDisplayForCase(fallback.name); + this.updateMobileCaseLabel(fallback.name); + this.updateCasePickerInput(fallback.name); + } + } + this.renderCasePickerList(); + } catch { + // A failed poll leaves the list as it was; the next tick retries. + } finally { + this._casePickerRefreshInFlight = false; + } }, closeCasePicker() { const input = document.getElementById('quickStartCaseSearch'); const list = document.getElementById('quickStartCaseList'); + this._stopCasePickerRefresh(); this._casePickerOpen = false; this._casePickerFilter = ''; input?.setAttribute('aria-expanded', 'false'); @@ -4286,6 +4347,11 @@ Object.assign(CodemanApp.prototype, { // Case Management (reorder + delete) // ═══════════════════════════════════════════════════════════════ + setCaseManageFilter(value) { + this._caseManageFilter = String(value || ''); + this.renderCaseManageList(); + }, + renderCaseManageList() { const container = document.getElementById('caseManageList'); const cases = this.cases || []; @@ -4294,6 +4360,18 @@ Object.assign(CodemanApp.prototype, { return; } + // Every term must appear in the name or path (same rule as the Run picker's + // filter). Reordering stays on the FULL list, so the arrows are disabled while + // a filter is active: a swap with a neighbour the user cannot see is a surprise. + const terms = (this._caseManageFilter || '').trim().toLowerCase().split(/\s+/).filter(Boolean); + const filtering = terms.length > 0; + const visible = filtering + ? cases.filter(c => { + const haystack = `${c.name} ${c.path || ''}`.toLowerCase(); + return terms.every(term => haystack.includes(term)); + }) + : cases; + // Cases an agent worker created (server-side marker file, see agent-case-marker.ts). // A long orchestration leaves one scratch directory per worker behind, so they get // a badge and a bulk cleanup entry point rather than having to be recognised by name. @@ -4305,9 +4383,14 @@ Object.assign(CodemanApp.prototype, { title="Review and delete the scratch cases agent workers left behind">Clean up
` : ''; - cases.forEach((c, idx) => { - const isFirst = idx === 0; - const isLast = idx === cases.length - 1; + if (filtering && visible.length === 0) { + html += '
No cases match
'; + } + visible.forEach(c => { + const idx = cases.indexOf(c); + const isFirst = filtering || idx === 0; + const isLast = filtering || idx === cases.length - 1; + const reorderTitle = filtering ? 'Clear the search to reorder' : null; // Was `/Users/` only, the mirror image of the Run menu's bug: every // case path on a Linux host rendered in full, unabbreviated. const pathDisplay = c.path ? this._shortenHomePath(c.path) : ''; @@ -4331,9 +4414,9 @@ Object.assign(CodemanApp.prototype, { : '' } + title="${reorderTitle || 'Move up'}" ${isFirst ? 'disabled' : ''}>▲ + title="${reorderTitle || 'Move down'}" ${isLast ? 'disabled' : ''}>▼ @@ -4506,6 +4589,7 @@ Object.assign(CodemanApp.prototype, { const isSelected = c.name === currentCase; html += `