Merge remote-tracking branch 'upstream/master' into feature/docker-uv-uvx

This commit is contained in:
Devvyn
2026-09-25 08:30:56 +08:00
35 changed files with 1758 additions and 81 deletions
+1 -1
View File
@@ -10,7 +10,7 @@
"name": "codeman", "name": "codeman",
"source": "./plugins/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.", "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": { "author": {
"name": "Ark0N", "name": "Ark0N",
"url": "https://github.com/Ark0N" "url": "https://github.com/Ark0N"
+25
View File
@@ -1,5 +1,30 @@
# aicodeman # 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 ## 1.33.0
### Minor Changes ### Minor Changes
+2 -2
View File
@@ -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. 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 ## 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`. ⚠️ **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) **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)
File diff suppressed because one or more lines are too long
+27
View File
@@ -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 (the 200 is the client's ACK). `curl`/legacy callers omit the fields and always
apply. 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 ## Known limitation
Dedup state is in-memory on the server. A **server restart** between a write and 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). semantics (monotonic, per-client, gap-tolerant, eviction-safe).
- `test/routes/session-routes.test.ts` — POST `/input` applies a tagged - `test/routes/session-routes.test.ts` — POST `/input` applies a tagged
`(clientId, seq)` once on redelivery; untagged input always applies. `(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).
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "aicodeman", "name": "aicodeman",
"version": "1.33.0", "version": "1.33.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "aicodeman", "name": "aicodeman",
"version": "1.33.0", "version": "1.33.1",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"workspaces": [ "workspaces": [
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "aicodeman", "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", "description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "codeman", "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.", "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": { "author": {
"name": "Ark0N", "name": "Ark0N",
"url": "https://github.com/Ark0N" "url": "https://github.com/Ark0N"
+8
View File
@@ -336,6 +336,14 @@ export interface TerminalMultiplexer extends EventEmitter {
*/ */
getPaneExit?(muxName: string): PaneExit | undefined; 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. */ /** Forget a session's exit observation, e.g. once its pane has been respawned. */
clearPaneExit?(muxName: string): void; clearPaneExit?(muxName: string): void;
+99
View File
@@ -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;
}
+27 -14
View File
@@ -19,19 +19,22 @@
* touching its status, so a pinned session a reboot killed still reads `idle` or * touching its status, so a pinned session a reboot killed still reads `idle` or
* `busy` and stays eligible. * `busy` and stays eligible.
* *
* ⚠️ Ending the AGENT rather than the session is a shape this module CANNOT * ⚠️ Ending the AGENT rather than the session leaves no trace in `status` or
* recognise today, and a reboot restores it. `/exit` ends the CLI inside the * `pid`. `/exit` ends the CLI inside the pane, `remain-on-exit` keeps the pane,
* pane, `remain-on-exit` keeps the pane, and the PTY Codeman owns is the * and the PTY Codeman owns is the `tmux attach-session` process, which stays
* `tmux attach-session` process, which stays alive throughout — so no exit * alive throughout — so no exit handler runs, no lifecycle `exit` is logged,
* handler runs, no lifecycle `exit` is logged, and the record keeps both its pid * and the record keeps both its pid and `status: 'idle'`. Ark0N/Codeman#446
* and `status: 'idle'`. Nothing durable distinguishes it from a session that was * handles it in two steps. The pane-exit watcher persists `paneExit`, and the
* simply idle when the power went. Ark0N/Codeman#446 covers making Codeman * clean-exit sweep (`pane-exit-sweep.ts`) closes a session whose agent exited
* notice the dead pane; until a record can say the agent is gone, this pass will * with status 0 through `cleanupSession()`, which leaves the durable record
* offer those sessions back, and the user dismisses or closes them. * 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 * The `pid` check below is NOT that rule. It refuses a record whose attach
* attach process was already gone, which is a session that never started or * process was already gone, which is a session that never started or whose
* whose pane died outright. * pane died outright.
* *
* @dependencies types (SessionState), config/cli-registry * @dependencies types (SessionState), config/cli-registry
* @consumedby web/server (plan build at boot), web/routes/reboot-restore-routes * @consumedby web/server (plan build at boot), web/routes/reboot-restore-routes
@@ -41,6 +44,7 @@
import type { SessionState } from './types.js'; import type { SessionState } from './types.js';
import { getCli } from './config/cli-registry/registry.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. */ /** Session statuses a reboot restore may rebuild. `stopped` is the kill marker. */
const RESTORABLE_STATUSES: ReadonlySet<string> = new Set(['idle', 'busy', 'error']); const RESTORABLE_STATUSES: ReadonlySet<string> = 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. * 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 * `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 * 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. * "try again after closing something", the other means the CLI would not start.
@@ -120,6 +124,7 @@ export interface RebootRestoreRejection {
| 'no-persisted-record' | 'no-persisted-record'
| 'intentionally-ended' | 'intentionally-ended'
| 'not-running' | 'not-running'
| 'agent-exited'
| 'respawn-blocked' | 'respawn-blocked'
| 'remote-or-docker' | 'remote-or-docker'
| 'unsupported-mode' | 'unsupported-mode'
@@ -191,7 +196,8 @@ export function planRebootRestore(
// //
// ⚠️ This does NOT catch a session the user ended with `/exit`. See the // ⚠️ 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 // 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 // Conservative on purpose. A session that somehow persisted no pid while
// genuinely running is not offered, and its conversation stays reachable // genuinely running is not offered, and its conversation stays reachable
@@ -200,6 +206,13 @@ export function planRebootRestore(
skipped.push({ sessionId, reason: 'not-running' }); skipped.push({ sessionId, reason: 'not-running' });
continue; 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) { if (state.respawnBlocked === true) {
// The crash-loop breaker tripped on this pane. Re-creating it restarts the loop. // The crash-loop breaker tripped on this pane. Re-creating it restarts the loop.
skipped.push({ sessionId, reason: 'respawn-blocked' }); skipped.push({ sessionId, reason: 'respawn-blocked' });
+76
View File
@@ -582,6 +582,21 @@ export class Session extends EventEmitter {
* rendered as "alive". * rendered as "alive".
*/ */
private _paneExit: PaneExit | null = null; 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 * 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 * 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; 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<T>(op: () => Promise<T>): Promise<T> {
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. * 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 * 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; respawnPaneOptions: import('./mux-interface.js').RespawnPaneOptions;
createSessionOptions: import('./mux-interface.js').CreateSessionOptions; createSessionOptions: import('./mux-interface.js').CreateSessionOptions;
spawnErrLabel: string; 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 }> { }): Promise<{ isRestored: boolean; respawnedResumeId?: string; respawnedDeadPane: boolean }> {
const mux = this._mux!; const mux = this._mux!;
@@ -2071,6 +2137,10 @@ export class Session extends EventEmitter {
* the mux session is gone — see {@link reattachRemote} for that reasoning). * the mux session is gone — see {@link reattachRemote} for that reasoning).
*/ */
async restartCli(): Promise<boolean> { async restartCli(): Promise<boolean> {
return this._withPaneLifecycle(() => this._doRestartCli());
}
private async _doRestartCli(): Promise<boolean> {
if (!this._useMux || !this._mux || !this._muxSession) return false; if (!this._useMux || !this._mux || !this._muxSession) return false;
const mux = this._mux; const mux = this._mux;
@@ -2459,6 +2529,9 @@ export class Session extends EventEmitter {
if (this.ptyProcess) { if (this.ptyProcess) {
throw new Error('Session already has a running process'); 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 // Bounds the workspace-trust scan (see _maybeAcceptTrustDialog). Stamped here
// rather than at PTY spawn so a slow mux attach still counts as startup. // 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) { if (this.ptyProcess) {
throw new Error('Session already has a running process'); throw new Error('Session already has a running process');
} }
if (this._closing) {
throw new Error('Session is being closed');
}
this._resetBuffers(); this._resetBuffers();
+28 -2
View File
@@ -287,6 +287,19 @@ export interface PaneExitObservation {
exit: PaneExit; 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". */ /** Read one optional numeric field; a blank or non-numeric value is "not reported". */
function paneField(fields: string[], index: number): number | undefined { function paneField(fields: string[], index: number): number | undefined {
const raw = fields[index]; 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 * lives on `Session`, because the remote-reconnect watcher above needs the
* raw pane reading. * raw pane reading.
*/ */
private paneExits: Map<string, PaneExitObservation> = new Map(); private paneExits: Map<string, TrackedPaneExit> = new Map();
/** The pane-exit watcher's own interval. Runs whether or not stats are on. */ /** The pane-exit watcher's own interval. Runs whether or not stats are on. */
private paneExitInterval: NodeJS.Timeout | null = null; private paneExitInterval: NodeJS.Timeout | null = null;
/** /**
@@ -3075,6 +3088,16 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
return this.paneExits.get(muxName)?.exit; 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 * 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 * `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 * 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 * observation — the pid is what catches a second command in the same pane
* that happened to exit the same way. * 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<string, PaneExitObservation>): void { applyPaneExits(observed: Map<string, PaneExitObservation>): void {
for (const muxName of [...this.paneExits.keys()]) { for (const muxName of [...this.paneExits.keys()]) {
@@ -3182,7 +3208,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
prev.panePid === next.panePid && prev.panePid === next.panePid &&
prev.exit.status === next.exit.status && prev.exit.status === next.exit.status &&
prev.exit.signal === next.exit.signal; 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 });
} }
} }
+7 -6
View File
@@ -649,12 +649,13 @@ export interface CustomModelBookkeeping extends CustomModelSelection {
* ⚠ AN ABSENT `status` STAYS ABSENT. Never write `status ?? 0`, and never read * ⚠ 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 * "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 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: * the only thing keeping the clean-exit sweep (`pane-exit-sweep.ts`) away
* an agent SIGKILLed by the OOM killer would otherwise read as a user typing * from crashed agents: an agent SIGKILLed by the OOM killer would otherwise
* `/exit` and be swept. Nothing here fails when somebody adds that `??` — the * read as a user typing `/exit` and be closed. Nothing here fails when
* types allow it, the label still renders, and the damage shows up only once * somebody adds that `??` — the types allow it and the label still renders.
* the sweep lands. The rule is enforced in `derivePaneExits()` * The rule is enforced in `derivePaneExits()` (`tmux-manager.ts`), which omits
* (`tmux-manager.ts`), which omits the key rather than defaulting it. * the key rather than defaulting it, and again in `isCleanPaneExit()`, which
* accepts only an explicit 0.
*/ */
export interface PaneExit { export interface PaneExit {
/** /**
+69 -1
View File
@@ -12,7 +12,8 @@
* image dir. * image dir.
*/ */
import fs from 'node:fs/promises'; 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'; import type { SessionPort } from './ports/index.js';
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
@@ -53,6 +54,73 @@ export async function sweepPasteImagesOnce(
return { scanned, deleted }; 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<PasteImageDirUser>;
persisted: Iterable<PasteImageDirUser>;
closingId: string;
workingDir: string;
killing: ReadonlySet<string>;
}): 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<SessionPort, 'sessions'>): () => void { export function startPasteImageGc(ctx: Pick<SessionPort, 'sessions'>): () => void {
const initial = setTimeout(() => { const initial = setTimeout(() => {
void sweepPasteImagesOnce(ctx); void sweepPasteImagesOnce(ctx);
+60 -25
View File
@@ -3427,7 +3427,26 @@ class CodemanApp {
*/ */
_sendInputAsync(sessionId, input, opts) { _sendInputAsync(sessionId, input, opts) {
if (!sessionId || !input) return; 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 // Session no longer exists — the input can never land. Drop it
// rather than retry forever (not a "lost" prompt: the target is gone). // rather than retry forever (not a "lost" prompt: the target is gone).
this._ackDelivery(sessionId, rec.seq); 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 { } else {
break; // offline / 5xx — leave queued; sweep + reconnect retry later 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. */ /** Drop an ACKed record (by exact seq) and persist. */
_ackDelivery(sessionId, seq) { _ackDelivery(sessionId, seq) {
const list = this._pendingDeliveries.get(sessionId); const list = this._pendingDeliveries.get(sessionId);
@@ -3601,6 +3632,13 @@ class CodemanApp {
_onWsInputAck(seq, msg) { _onWsInputAck(seq, msg) {
const sessionId = this._wsSessionId; const sessionId = this._wsSessionId;
if (!sessionId || !Number.isInteger(seq)) return; 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) { if (msg && msg.dup) {
const list = this._pendingDeliveries.get(sessionId); const list = this._pendingDeliveries.get(sessionId);
const rec = list && list.find((r) => r.seq === seq); const rec = list && list.find((r) => r.seq === seq);
@@ -3714,20 +3752,22 @@ class CodemanApp {
if (saved && saved.pending) { if (saved && saved.pending) {
for (const [s, recs] of Object.entries(saved.pending)) { for (const [s, recs] of Object.entries(saved.pending)) {
if (Array.isArray(recs) && recs.length) { if (Array.isArray(recs) && recs.length) {
// Reset sentAt so they re-deliver promptly on this fresh load. const frameMax = window.CodemanInputLimit?.FRAME_MAX_CHARS ?? Infinity;
this._pendingDeliveries.set( const kept = recs
s, .filter((r) => r && typeof r.data === 'string' && Number.isInteger(r.seq))
recs // A frame over the server's limit can never be ACKed; one persisted
.filter((r) => r && typeof r.data === 'string' && Number.isInteger(r.seq)) // by an older build would otherwise come back on every load (#484).
.map((r) => ({ .filter((r) => r.data.length <= frameMax)
seq: r.seq, // Reset sentAt so they re-deliver promptly on this fresh load.
data: r.data, .map((r) => ({
useMux: !!r.useMux, seq: r.seq,
ts: r.ts || Date.now(), data: r.data,
tries: 0, useMux: !!r.useMux,
sentAt: 0, 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 // `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 // 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 // beside a pill saying "idle". A pending alert still wins, exactly as it
// does for the dot. // does for the dot. The rule is `_mobileOverviewExit()`, shared with both
const exited = !!paneExitLabel(session.paneExit) && (state === 'idle' || state === 'working'); // home screens so the three surfaces agree on which sessions have exited.
const exitAt = exited ? Number(session.paneExit.at) || 0 : 0; const exit = this._mobileOverviewExit ? this._mobileOverviewExit(state, session) : null;
const exited = !!exit;
return { return {
state, state,
exited, exited,
@@ -4891,13 +4932,7 @@ class CodemanApp {
// state pill and never replaces it. // state pill and never replaces it.
watching: typeof session.watching === 'string' ? session.watching : '', watching: typeof session.watching === 'string' ? session.watching : '',
createdAt: Number(session.createdAt) || 0, createdAt: Number(session.createdAt) || 0,
since: exitAt since: exit ? exit.since : this._mobileOverviewSince ? this._mobileOverviewSince(state, session) : null,
? { key: 'exited', at: exitAt }
: exited
? null
: this._mobileOverviewSince
? this._mobileOverviewSince(state, session)
: null,
}; };
} }
+48
View File
@@ -762,6 +762,49 @@ function resolveTerminalFontWeights(settings) {
// without a terminal, a clipboard, or a browser. // 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. * Upper bound on an AUTO-copied selection.
* *
@@ -940,6 +983,11 @@ if (typeof window !== 'undefined') {
compare: compareSessionActivity, compare: compareSessionActivity,
sort: sortSessionsByActivity, sort: sortSessionsByActivity,
}; };
window.CodemanInputLimit = {
FRAME_MAX_CHARS: INPUT_FRAME_MAX_CHARS,
PASTE_MAX_CHARS: INPUT_PASTE_MAX_CHARS,
split: splitInputFrames,
};
window.CodemanAutoCopy = { window.CodemanAutoCopy = {
decide: decideAutoCopy, decide: decideAutoCopy,
MAX_CHARS: AUTO_COPY_MAX_CHARS, MAX_CHARS: AUTO_COPY_MAX_CHARS,
+11 -5
View File
@@ -201,6 +201,8 @@ Object.assign(CodemanApp.prototype, {
const session = this.sessions.get(id); const session = this.sessions.get(id);
const matched = this._mobileOverviewCaseFor(session.workingDir, cases); const matched = this._mobileOverviewCaseFor(session.workingDir, cases);
const state = this._mobileOverviewState(session, this.pendingHooks?.get(id)); 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'; const mode = session.mode || 'claude';
return { return {
id, id,
@@ -211,7 +213,10 @@ Object.assign(CodemanApp.prototype, {
caseName: matched ? matched.name : '', caseName: matched ? matched.name : '',
dir: this._shortenHomePath ? this._shortenHomePath(session.workingDir) : session.workingDir || '', dir: this._shortenHomePath ? this._shortenHomePath(session.workingDir) : session.workingDir || '',
state, 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 // 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. // the session payload. Same field, same meaning as on the phone overview.
watching: typeof session.watching === 'string' ? session.watching : '', watching: typeof session.watching === 'string' ? session.watching : '',
@@ -224,7 +229,7 @@ Object.assign(CodemanApp.prototype, {
lastSubmitAt: Number(session.lastSubmitAt) || 0, lastSubmitAt: Number(session.lastSubmitAt) || 0,
// "how long has it been like this", resolved by the phone overview's // "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. // 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) { _buildHomeSessionRow(row) {
const item = document.createElement('button'); const item = document.createElement('button');
item.type = '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.hsAction = 'session';
item.dataset.hsSession = row.id; item.dataset.hsSession = row.id;
item.title = row.dir ? `${row.name} (${row.dir})` : row.name; item.title = row.dir ? `${row.name} (${row.dir})` : row.name;
@@ -409,7 +415,7 @@ Object.assign(CodemanApp.prototype, {
} }
const dot = document.createElement('span'); 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'); dot.setAttribute('aria-hidden', 'true');
item.appendChild(dot); item.appendChild(dot);
@@ -441,7 +447,7 @@ Object.assign(CodemanApp.prototype, {
item.appendChild(body); item.appendChild(body);
const pill = document.createElement('span'); 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") // Skipped by i18n on purpose: generic single words ("idle", "done", "error")
// that collide with state strings on other surfaces. // that collide with state strings on other surfaces.
pill.setAttribute('data-i18n-skip', ''); pill.setAttribute('data-i18n-skip', '');
+2
View File
@@ -106,6 +106,8 @@
'Manage AI Coding tools in persistent tmux sessions.': '在持久化 tmux 会话中管理 AI 编程工具。', 'Manage AI Coding tools in persistent tmux sessions.': '在持久化 tmux 会话中管理 AI 编程工具。',
'Select case': '选择案例', 'Select case': '选择案例',
'Select Case': '选择案例', 'Select Case': '选择案例',
'Search cases': '搜索案例',
'No matching cases': '没有匹配的案例',
'All cases': '全部案例', 'All cases': '全部案例',
'No directory': '未选择目录', 'No directory': '未选择目录',
Run: '运行', Run: '运行',
+6
View File
@@ -3175,6 +3175,7 @@
<h2>Manage</h2> <h2>Manage</h2>
</div> </div>
<p class="set-section-blurb">Reorder or remove cases, and pick up anything exported from a docker case.</p> <p class="set-section-blurb">Reorder or remove cases, and pick up anything exported from a docker case.</p>
<input type="search" id="caseManageSearch" class="set-input" placeholder="Search cases by name or path" autocomplete="off" spellcheck="false" aria-label="Search cases" oninput="app.setCaseManageFilter(this.value)" style="margin-bottom: 8px;">
<div class="case-manage-list" id="caseManageList"> <div class="case-manage-list" id="caseManageList">
<!-- Populated by JS --> <!-- Populated by JS -->
</div> </div>
@@ -3204,7 +3205,12 @@
<h3>Select Case</h3> <h3>Select Case</h3>
<button class="modal-close" onclick="app.closeMobileCasePicker()" aria-label="Close case picker">&times;</button> <button class="modal-close" onclick="app.closeMobileCasePicker()" aria-label="Close case picker">&times;</button>
</div> </div>
<div class="mobile-case-picker-search">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input type="search" id="mobileCaseSearch" placeholder="Search cases" aria-label="Search cases" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" enterkeyhint="go" oninput="app.filterMobileCases()" onkeydown="app.onMobileCaseSearchKey(event)">
</div>
<div class="mobile-case-picker-body"> <div class="mobile-case-picker-body">
<div class="mobile-case-empty" id="mobileCaseEmpty" hidden>No matching cases</div>
<div class="mobile-case-list" id="mobileCaseList"> <div class="mobile-case-list" id="mobileCaseList">
<!-- Cases populated by JS --> <!-- Cases populated by JS -->
</div> </div>
+40 -5
View File
@@ -161,6 +161,36 @@ Object.assign(CodemanApp.prototype, {
return { key: MOBILE_OVERVIEW_SINCE_LABEL[state] || state, at }; 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 * 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 * 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 rows = sessions.map((session) => {
const matched = this._mobileOverviewCaseFor(session.workingDir, cases); const matched = this._mobileOverviewCaseFor(session.workingDir, cases);
const state = this._mobileOverviewState(session, pendingHooks.get && pendingHooks.get(session.id)); const state = this._mobileOverviewState(session, pendingHooks.get && pendingHooks.get(session.id));
const exit = this._mobileOverviewExit(state, session);
const orderIndex = order.indexOf(session.id); const orderIndex = order.indexOf(session.id);
return { return {
id: session.id, id: session.id,
@@ -210,7 +241,10 @@ Object.assign(CodemanApp.prototype, {
caseName: matched ? matched.name : '', caseName: matched ? matched.name : '',
dir: this._shortenHomePath ? this._shortenHomePath(session.workingDir) : session.workingDir || '', dir: this._shortenHomePath ? this._shortenHomePath(session.workingDir) : session.workingDir || '',
state, 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", // 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 // "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. // 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. // pair resolved for DISPLAY, and the two must not drift apart.
lastActivityAt: Number(session.lastActivityAt) || 0, lastActivityAt: Number(session.lastActivityAt) || 0,
lastSubmitAt: Number(session.lastSubmitAt) || 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, orderIndex: orderIndex === -1 ? Number.MAX_SAFE_INTEGER : orderIndex,
}; };
}); });
@@ -698,12 +732,13 @@ Object.assign(CodemanApp.prototype, {
_buildMobileOverviewRow(row) { _buildMobileOverviewRow(row) {
const item = document.createElement('button'); const item = document.createElement('button');
item.type = '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.moAction = 'session';
item.dataset.moSession = row.id; item.dataset.moSession = row.id;
const dot = document.createElement('span'); 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'); dot.setAttribute('aria-hidden', 'true');
item.appendChild(dot); item.appendChild(dot);
@@ -736,7 +771,7 @@ Object.assign(CodemanApp.prototype, {
item.appendChild(body); item.appendChild(body);
const pill = document.createElement('span'); 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", // Skipped by i18n on purpose: the labels are generic single words ("idle",
// "done", "error") that collide with state strings on other surfaces. // "done", "error") that collide with state strings on other surfaces.
pill.setAttribute('data-i18n-skip', ''); pill.setAttribute('data-i18n-skip', '');
+11 -2
View File
@@ -2165,9 +2165,11 @@ html.mobile-init .file-browser-panel {
background: rgba(0, 0, 0, 0.5); 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 { .mobile-case-picker-sheet {
max-height: 60vh; max-height: 80vh;
padding-bottom: var(--safe-area-bottom); max-height: 80dvh;
animation: slideUp 0.2s ease-out; animation: slideUp 0.2s ease-out;
} }
@@ -2978,6 +2980,13 @@ html.mobile-init .file-browser-panel {
color: var(--green); 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 /* 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. */ is not asking the user for anything, and red and yellow are what say it is. */
.mobile-overview-pill--watching { .mobile-overview-pill--watching {
+143 -5
View File
@@ -122,6 +122,9 @@ const RUN_MODE_LAUNCH = {
* (`openSessionOptions`), guaranteed to drift from each other the moment a * (`openSessionOptions`), guaranteed to drift from each other the moment a
* ninth CLI landed in one and not the other. * 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 EXTERNAL_CLI_MODES = new Set(Object.keys(RUN_MODE_LAUNCH));
const BUILT_IN_RUN_MODES = new Set(['claude', 'shell', ...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 input = document.getElementById('quickStartCaseSearch');
const list = document.getElementById('quickStartCaseList'); const list = document.getElementById('quickStartCaseList');
if (!input || !list) return; if (!input || !list) return;
const wasOpen = this._casePickerOpen === true;
this._casePickerOpen = true; this._casePickerOpen = true;
this._casePickerFilter = filter; this._casePickerFilter = filter;
this._casePickerActiveIndex = 0; this._casePickerActiveIndex = 0;
input.setAttribute('aria-expanded', 'true'); input.setAttribute('aria-expanded', 'true');
this.renderCasePickerList(); 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() { closeCasePicker() {
const input = document.getElementById('quickStartCaseSearch'); const input = document.getElementById('quickStartCaseSearch');
const list = document.getElementById('quickStartCaseList'); const list = document.getElementById('quickStartCaseList');
this._stopCasePickerRefresh();
this._casePickerOpen = false; this._casePickerOpen = false;
this._casePickerFilter = ''; this._casePickerFilter = '';
input?.setAttribute('aria-expanded', 'false'); input?.setAttribute('aria-expanded', 'false');
@@ -4286,6 +4347,11 @@ Object.assign(CodemanApp.prototype, {
// Case Management (reorder + delete) // Case Management (reorder + delete)
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
setCaseManageFilter(value) {
this._caseManageFilter = String(value || '');
this.renderCaseManageList();
},
renderCaseManageList() { renderCaseManageList() {
const container = document.getElementById('caseManageList'); const container = document.getElementById('caseManageList');
const cases = this.cases || []; const cases = this.cases || [];
@@ -4294,6 +4360,18 @@ Object.assign(CodemanApp.prototype, {
return; 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). // 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 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. // 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</button> title="Review and delete the scratch cases agent workers left behind">Clean up</button>
</div>` </div>`
: ''; : '';
cases.forEach((c, idx) => { if (filtering && visible.length === 0) {
const isFirst = idx === 0; html += '<div class="form-hint" style="text-align: center; padding: 2rem 0;">No cases match</div>';
const isLast = idx === cases.length - 1; }
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/<user>` only, the mirror image of the Run menu's bug: every // Was `/Users/<user>` only, the mirror image of the Run menu's bug: every
// case path on a Linux host rendered in full, unabbreviated. // case path on a Linux host rendered in full, unabbreviated.
const pathDisplay = c.path ? this._shortenHomePath(c.path) : ''; const pathDisplay = c.path ? this._shortenHomePath(c.path) : '';
@@ -4331,9 +4414,9 @@ Object.assign(CodemanApp.prototype, {
: '' : ''
} }
<button class="case-manage-btn" onclick="app.moveCaseUp(${escapeHtml(JSON.stringify(c.name))})" <button class="case-manage-btn" onclick="app.moveCaseUp(${escapeHtml(JSON.stringify(c.name))})"
title="Move up" ${isFirst ? 'disabled' : ''}>&#x25B2;</button> title="${reorderTitle || 'Move up'}" ${isFirst ? 'disabled' : ''}>&#x25B2;</button>
<button class="case-manage-btn" onclick="app.moveCaseDown(${escapeHtml(JSON.stringify(c.name))})" <button class="case-manage-btn" onclick="app.moveCaseDown(${escapeHtml(JSON.stringify(c.name))})"
title="Move down" ${isLast ? 'disabled' : ''}>&#x25BC;</button> title="${reorderTitle || 'Move down'}" ${isLast ? 'disabled' : ''}>&#x25BC;</button>
<button class="case-manage-btn case-manage-btn-delete" onclick="app.deleteCase(${escapeHtml(JSON.stringify(c.name))})" <button class="case-manage-btn case-manage-btn-delete" onclick="app.deleteCase(${escapeHtml(JSON.stringify(c.name))})"
title="Delete case">&#x2715;</button> title="Delete case">&#x2715;</button>
</div> </div>
@@ -4506,6 +4589,7 @@ Object.assign(CodemanApp.prototype, {
const isSelected = c.name === currentCase; const isSelected = c.name === currentCase;
html += ` html += `
<button class="mobile-case-item ${isSelected ? 'selected' : ''}" <button class="mobile-case-item ${isSelected ? 'selected' : ''}"
data-search="${escapeHtml(`${c.label} ${c.name}`.toLowerCase())}"
onclick="app.selectMobileCase(${escapeHtml(JSON.stringify(c.name))})"> onclick="app.selectMobileCase(${escapeHtml(JSON.stringify(c.name))})">
<span class="mobile-case-item-icon"> <span class="mobile-case-item-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -4528,7 +4612,61 @@ Object.assign(CodemanApp.prototype, {
} }
listContainer.innerHTML = html; listContainer.innerHTML = html;
// Every open starts unfiltered. The search box is not focused on purpose:
// that would raise the phone keyboard over a list most opens just tap.
const search = document.getElementById('mobileCaseSearch');
if (search) search.value = '';
listContainer.parentElement.style.minHeight = '';
this.filterMobileCases();
modal.classList.add('active'); modal.classList.add('active');
// Bring the current case into view when the list is longer than the sheet.
// Scroll the list's own box, never scrollIntoView(), which can also scroll
// the document under the fixed header.
const body = listContainer.parentElement;
const selected = listContainer.querySelector('.mobile-case-item.selected');
if (body && selected) {
const top = selected.offsetTop - body.offsetTop;
if (top + selected.offsetHeight > body.scrollTop + body.clientHeight) {
body.scrollTop = top - (body.clientHeight - selected.offsetHeight) / 2;
}
}
},
/** Hide case rows whose name does not contain every word typed in the search box. */
filterMobileCases() {
const search = document.getElementById('mobileCaseSearch');
const words = (search?.value || '').toLowerCase().split(/\s+/).filter(Boolean);
// Hold the list at its unfiltered height while searching, so the sheet (and
// the input under the thumb) does not jump as rows disappear.
const body = document.querySelector('.mobile-case-picker-body');
if (body && words.length && !body.style.minHeight) body.style.minHeight = `${body.offsetHeight}px`;
let shown = 0;
for (const item of document.querySelectorAll('#mobileCaseList .mobile-case-item')) {
const hay = item.dataset.search || '';
const match = words.every((w) => hay.includes(w));
item.hidden = !match;
if (match) shown++;
}
const empty = document.getElementById('mobileCaseEmpty');
if (empty) empty.hidden = shown > 0;
},
/** Enter picks the case when the search narrows the list to exactly one; Escape clears, then closes. */
onMobileCaseSearchKey(event) {
if (event.key === 'Enter') {
event.preventDefault();
const visible = [...document.querySelectorAll('#mobileCaseList .mobile-case-item:not([hidden])')];
if (visible.length === 1) visible[0].click();
} else if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
if (event.target.value) {
event.target.value = '';
this.filterMobileCases();
} else {
this.closeMobileCasePicker();
}
}
}, },
closeMobileCasePicker() { closeMobileCasePicker() {
+59 -1
View File
@@ -7072,12 +7072,61 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
color: #fff; color: #fff;
} }
.mobile-case-picker-search {
display: flex;
align-items: center;
gap: 8px;
margin: 12px 20px 4px;
padding: 0 12px;
background: var(--bg-input);
border: 1px solid var(--border-light);
border-radius: 8px;
color: var(--text-dim);
}
.mobile-case-picker-search:focus-within {
border-color: var(--accent, #22c55e);
}
.mobile-case-picker-search input {
flex: 1;
min-width: 0;
padding: 10px 0;
background: transparent;
border: none;
outline: none;
color: var(--text);
/* 16px keeps iOS Safari from zooming the page on focus */
font-size: 16px;
}
/* The wrapper draws the focus state; the global input focus ring doubled it */
#mobileCaseSearch:focus {
outline: none;
border: none;
box-shadow: none;
}
.mobile-case-empty {
padding: 20px;
text-align: center;
color: var(--text-dim);
font-size: 0.9rem;
}
.mobile-case-empty[hidden],
.mobile-case-item[hidden] {
display: none;
}
.mobile-case-picker-body { .mobile-case-picker-body {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
padding: 8px 0; padding: 8px 0;
max-height: 50vh; /* Fill the sheet (its max-height is the cap); a separate cap here left the
list shorter than the sheet could show. min-height lets flex shrink it. */
min-height: 0;
} }
.mobile-case-list { .mobile-case-list {
@@ -16392,6 +16441,15 @@ html[data-tab-orientation='vertical'] .home-sessions {
color: color-mix(in srgb, var(--green) 45%, var(--text-muted)); color: color-mix(in srgb, var(--green) 45%, var(--text-muted));
} }
/* An exited agent (Ark0N/Codeman#446): neutral, like the rich rail's exited pill.
No green at all, since nothing is running behind this row. The dot and the row
take the `--exited` class too and fall back to their neutral base rules. */
.home-sessions-pill--exited {
background: color-mix(in srgb, var(--text-muted) 10%, transparent);
border-color: color-mix(in srgb, var(--text-muted) 30%, var(--border));
color: var(--text-muted);
}
/* Accent, deliberately none of the three above: a session that is watching something /* Accent, deliberately none of the three above: a session that is watching something
it started (a monitor, a backgrounded shell, a cloud session) is not asking for it started (a monitor, a backgrounded shell, a cloud session) is not asking for
anything, so it must not borrow the red or the yellow that mean it is. This badge anything, so it must not borrow the red or the yellow that mean it is. This badge
+10 -1
View File
@@ -175,7 +175,16 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHost
try { try {
const msg = JSON.parse(String(raw)); const msg = JSON.parse(String(raw));
if (msg.t === 'i' && typeof msg.d === 'string') { if (msg.t === 'i' && typeof msg.d === 'string') {
if (msg.d.length > MAX_INPUT_LENGTH) return; if (msg.d.length > MAX_INPUT_LENGTH) {
// Refused for good, so say so: a silent return left the frame
// unACKed and the client redelivered it every few seconds forever
// (issue #484). A client that predates `err` reads this as a plain
// ACK and drops the frame, which is also the right outcome.
if (Number.isInteger(msg.seq) && socket.readyState === 1) {
socket.send(`{"t":"ia","seq":${msg.seq as number},"err":"too_large","max":${MAX_INPUT_LENGTH}}`);
}
return;
}
// Reliable delivery: when the frame carries a clientId + seq, apply it // Reliable delivery: when the frame carries a clientId + seq, apply it
// exactly once (skip a duplicate redelivery) but ACK it regardless so // exactly once (skip a duplicate redelivery) but ACK it regardless so
// the client can drop it from its durable queue. Frames without seq // the client can drop it from its durable queue. Frames without seq
+5 -1
View File
@@ -20,6 +20,7 @@ import {
import { MAX_EDITABLE_BYTES } from '../config/file-editing.js'; import { MAX_EDITABLE_BYTES } from '../config/file-editing.js';
import { MIN_MATCH_LENGTH, MAX_MATCH_LENGTH } from '../config/agent-wait.js'; import { MIN_MATCH_LENGTH, MAX_MATCH_LENGTH } from '../config/agent-wait.js';
import { MAX_WAKE_MACS } from '../config/remote-wake-limits.js'; import { MAX_WAKE_MACS } from '../config/remote-wake-limits.js';
import { MAX_INPUT_LENGTH } from '../config/terminal-limits.js';
import { enabledCliIds, enabledClis } from '../config/cli-registry/registry.js'; import { enabledCliIds, enabledClis } from '../config/cli-registry/registry.js';
import type { SessionMode } from '../types.js'; import type { SessionMode } from '../types.js';
@@ -1500,7 +1501,10 @@ export const SettingsUpdateSchema = z
* Schema for POST /api/sessions/:id/input with length limit * Schema for POST /api/sessions/:id/input with length limit
*/ */
export const SessionInputWithLimitSchema = z.object({ export const SessionInputWithLimitSchema = z.object({
input: z.string().max(100000), // 100KB max input // One limit for both transports (issue #484): the route's own length check and
// ws-routes.ts read the same constant, so a schema cap above it only hid which
// check refused the input.
input: z.string().max(MAX_INPUT_LENGTH),
useMux: z.boolean().optional(), useMux: z.boolean().optional(),
// Reliable-delivery dedup (optional; absent for curl/legacy clients). The web // Reliable-delivery dedup (optional; absent for curl/legacy clients). The web
// client tags each input with a stable clientId + a monotonic per-session seq // client tags each input with a stable clientId + a monotonic per-session seq
+97 -3
View File
@@ -33,7 +33,8 @@ import fastifyCookie from '@fastify/cookie';
import fastifyStatic from '@fastify/static'; import fastifyStatic from '@fastify/static';
import fastifyWebsocket from '@fastify/websocket'; import fastifyWebsocket from '@fastify/websocket';
import fastifyMultipart from '@fastify/multipart'; import fastifyMultipart from '@fastify/multipart';
import { startPasteImageGc } from './paste-image-gc.js'; import { pasteImageDirInUseByOtherSession, startPasteImageGc } from './paste-image-gc.js';
import { CLEAN_EXIT_CLOSE_REASON, shouldCloseCleanlyExitedSession } from '../pane-exit-sweep.js';
import { join, dirname } from 'node:path'; import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { existsSync, mkdirSync, readFileSync, chmodSync, rmSync, statSync } from 'node:fs'; import { existsSync, mkdirSync, readFileSync, chmodSync, rmSync, statSync } from 'node:fs';
@@ -1321,16 +1322,32 @@ export class WebServer extends EventEmitter {
// Clean up all resources associated with a session // Clean up all resources associated with a session
// Track sessions currently being cleaned up to prevent concurrent cleanup races // Track sessions currently being cleaned up to prevent concurrent cleanup races
private cleaningUp: Set<string> = new Set(); private cleaningUp: Set<string> = new Set();
/**
* The subset of {@link cleaningUp} whose tmux session is being KILLED rather
* than detached. The paste-image guard needs the difference: a detaching
* session keeps running in tmux and still uses its working directory.
*/
private killingSessions: Set<string> = new Set();
private async cleanupSession(sessionId: string, killMux: boolean = true, reason?: string): Promise<void> { private async cleanupSession(sessionId: string, killMux: boolean = true, reason?: string): Promise<void> {
// Guard against concurrent cleanup of the same session // Guard against concurrent cleanup of the same session
if (this.cleaningUp.has(sessionId)) return; if (this.cleaningUp.has(sessionId)) return;
this.cleaningUp.add(sessionId); this.cleaningUp.add(sessionId);
if (killMux) this.killingSessions.add(sessionId);
// Refuse a start or attach from here on (Ark0N/Codeman#446): a start that
// raced this cleanup would launch a CLI in a tmux session whose record is
// about to be deleted, leaving an orphan the next boot rediscovers.
const session = this.sessions.get(sessionId);
session?.markClosing(true);
try { try {
await this._doCleanupSession(sessionId, killMux, reason); await this._doCleanupSession(sessionId, killMux, reason);
} finally { } finally {
this.cleaningUp.delete(sessionId); this.cleaningUp.delete(sessionId);
this.killingSessions.delete(sessionId);
// A cleanup that failed leaves the session on the board, so it must be
// startable again.
if (this.sessions.get(sessionId) === session) session?.markClosing(false);
} }
} }
@@ -1482,8 +1499,24 @@ export class WebServer extends EventEmitter {
attachmentRegistry.clearSession(sessionId); attachmentRegistry.clearSession(sessionId);
// Stop watching for images in this session's directory // Stop watching for images in this session's directory
imageWatcher.unwatchSession(sessionId); imageWatcher.unwatchSession(sessionId);
// Clean up pasted images directory for this session // Clean up pasted images directory for this session. The dir belongs to the
if (killMux && session.workingDir) { // working directory rather than the session, so it stays while another live
// session in the same case still uses it (Ark0N/Codeman#446).
if (
killMux &&
session.workingDir &&
!pasteImageDirInUseByOtherSession({
live: this.sessions.values(),
persisted: Object.entries(this.store.getSessions()).map(([id, record]) => ({
id,
workingDir: record.workingDir,
status: record.status,
})),
closingId: sessionId,
workingDir: session.workingDir,
killing: this.killingSessions,
})
) {
const pasteImageDir = join(session.workingDir, '.claude-images'); const pasteImageDir = join(session.workingDir, '.claude-images');
try { try {
rmSync(pasteImageDir, { recursive: true, force: true }); rmSync(pasteImageDir, { recursive: true, force: true });
@@ -2502,6 +2535,9 @@ export class WebServer extends EventEmitter {
* Nothing here touches `status` or `pid`. `status: 'error'` belongs to the * Nothing here touches `status` or `pid`. `status: 'error'` belongs to the
* PTY-exit breaker and makes the browser offer a restart, and a null `pid` is * PTY-exit breaker and makes the browser offer a restart, and a null `pid` is
* what makes the browser re-attach and launch a fresh CLI. * what makes the browser re-attach and launch a fresh CLI.
*
* Once the records are current, {@link closeCleanlyExitedSessions} closes the
* sessions whose agent the user ended.
*/ */
private applyPaneExits(): void { private applyPaneExits(): void {
const getPaneExit = this.mux.getPaneExit?.bind(this.mux); const getPaneExit = this.mux.getPaneExit?.bind(this.mux);
@@ -2515,6 +2551,63 @@ export class WebServer extends EventEmitter {
this.persistSessionState(session); this.persistSessionState(session);
this.broadcastSessionStateDebounced(session.id); this.broadcastSessionStateDebounced(session.id);
} }
this.closeCleanlyExitedSessions();
}
/**
* Close every session whose agent exited cleanly, through the same
* `cleanupSession()` the X button uses (Ark0N/Codeman#446). A pinned session
* is demoted to `status: 'stopped'` there rather than removed, and either way
* the reboot restore stops offering it back. The conversation stays
* resumable, since the Resume list reads the lifecycle log and the transcript
* files, and the pane owns neither.
*
* `shouldCloseCleanlyExitedSession()` (`pane-exit-sweep.ts`) holds the rule:
* an explicit status of 0, confirmed by more than one pane read, with no
* start or attach in flight and not within seconds of one (a startup error). A crashed agent keeps its row with the exit
* code on it. `session.paneExit` is already scoped to local mux-backed
* sessions by `setPaneExit()`, so a remote, docker or direct-PTY session is
* never closed here.
*
* The close runs in the background. `cleanupSession()` ignores a second call
* for a session it is already closing, and the `closing` check below keeps
* the next tick from queueing one.
*
* Each exit is attempted ONCE, keyed by session id and the exit's `at`
* stamp. A close that fails leaves the session on the board with its exit
* badge, which is where a crashed agent's row would be too, rather than
* retrying and logging every two seconds. A new exit in the same pane has a
* new `at` and gets its own attempt.
*/
/** Exits the clean-exit sweep has already tried to close, as `<sessionId>:<exit.at>`. */
private cleanExitCloseAttempts: Set<string> = new Set();
private closeCleanlyExitedSessions(): void {
const readCount = this.mux.getPaneExitReadCount?.bind(this.mux);
if (!readCount) return;
// Forget attempts for sessions that are gone, so the set stays bounded.
for (const key of this.cleanExitCloseAttempts) {
if (!this.sessions.has(key.slice(0, key.lastIndexOf(':')))) this.cleanExitCloseAttempts.delete(key);
}
for (const session of [...this.sessions.values()]) {
const muxName = session.muxName;
if (!muxName) continue;
const close = shouldCloseCleanlyExitedSession({
paneExit: session.paneExit,
confirmingReads: readCount(muxName),
paneLifecycleInFlight: session.paneLifecycleInFlight,
closing: this.cleaningUp.has(session.id),
paneStartedAt: session.paneStartedAt,
});
if (!close) continue;
const attempt = `${session.id}:${session.paneExit?.at ?? 0}`;
if (this.cleanExitCloseAttempts.has(attempt)) continue;
this.cleanExitCloseAttempts.add(attempt);
console.log(`[Server] Closing session ${session.id} (${session.name}): ${CLEAN_EXIT_CLOSE_REASON}`);
void this.cleanupSession(session.id, true, CLEAN_EXIT_CLOSE_REASON).catch((err) => {
console.error(`[Server] Failed to close cleanly exited session ${session.id}:`, err);
});
}
} }
// ========== Web Push ========== // ========== Web Push ==========
@@ -3963,6 +4056,7 @@ export class WebServer extends EventEmitter {
} }
this.activePlanOrchestrators.clear(); this.activePlanOrchestrators.clear();
this.cleaningUp.clear(); this.cleaningUp.clear();
this.killingSessions.clear();
// Dispose push store (flush pending saves) // Dispose push store (flush pending saves)
this.pushStore.dispose(); this.pushStore.dispose();
+194
View File
@@ -0,0 +1,194 @@
// Port: none (pure model + fake-DOM row builders — no browser, no server).
//
// The phone overview and the desktop home rail showing an exited agent as
// "exited" rather than "idle" (Ark0N/Codeman#446).
//
// The server publishes `session.paneExit` when the agent inside a local tmux
// pane has exited, while `status` stays `idle` or `busy` by design. Part 1 of
// #446 taught the tab strip and the rich rail rows to say so; both home screens
// still said "idle" beside nothing running. `_mobileOverviewExit()`
// (mobile-overview.js) is now the one rule all three surfaces read. It changes
// what a row SHOWS and leaves its `state` alone, because `state` picks the
// section and the sort order.
//
// `paneExitLabel()` is lifted out of the shipped app.js rather than restated
// here, so a change to what counts as "exited" there reaches these tests.
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
/** The shipped `paneExitLabel()` from app.js, as source text. */
function paneExitLabelSource(): string {
const appJs = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
const match = appJs.match(/function paneExitLabel\(paneExit\) \{[\s\S]*?\n\}\n/);
if (!match) throw new Error('paneExitLabel() not found in app.js');
return match[0];
}
function fakeElement(): any {
const el: any = {
className: '',
type: '',
title: '',
textContent: '',
dataset: {},
style: {},
children: [] as any[],
setAttribute() {},
appendChild(child: any) {
el.children.push(child);
return child;
},
};
return el;
}
/** Every className in a fake-DOM subtree, depth first. */
function classNames(el: any): string[] {
return [el.className, ...(el.children || []).flatMap(classNames)].filter(Boolean);
}
function loadApp({ withPaneExitLabel = true } = {}) {
const CodemanApp = function CodemanApp(this: any) {};
const context = vm.createContext({
CodemanApp,
console,
window: { innerWidth: 1512 },
document: {
documentElement: { getAttribute: () => null },
getElementById: () => null,
createElement: () => fakeElement(),
createElementNS: () => fakeElement(),
},
MobileDetection: { getDeviceType: () => 'desktop' },
});
if (withPaneExitLabel) vm.runInContext(paneExitLabelSource(), context, { filename: 'app.js' });
for (const file of ['constants.js', 'mobile-overview.js', 'home-sessions.js']) {
vm.runInContext(readFileSync(resolve(PUBLIC, file), 'utf8'), context, { filename: file });
}
const app = new (CodemanApp as any)();
app.getSessionName = (session: any) => session.name || session.id.slice(0, 8);
app._shortenHomePath = (p: string) => p || '';
app.loadAppSettingsFromStorage = () => ({});
app.cases = [];
app.pendingHooks = new Map();
return app;
}
const EXIT_AT = 1_700_000_000_000;
function sessions(list: Array<Record<string, any>>) {
return new Map(list.map((s) => [s.id, { status: 'idle', mode: 'claude', workingDir: '/w', ...s }]));
}
describe('the desktop home rail', () => {
it('says "exited" for an idle session whose agent exited, and measures from the exit', () => {
const app = loadApp();
app.sessions = sessions([{ id: 'gone', paneExit: { status: 3, at: EXIT_AT }, lastActivityAt: 5 }]);
app.sessionOrder = ['gone'];
const [row] = app.buildHomeSessionRows();
expect(row.state).toBe('idle');
expect(row.display).toBe('exited');
expect(row.pill).toBe('exited');
expect(row.since).toEqual({ key: 'exited', at: EXIT_AT });
});
it('draws a neutral row: no idle or working class on the row, the dot or the pill', () => {
// A pane whose agent died mid-turn still has `status: 'busy'`, so without
// the display class its dot would pulse green beside "exited".
const app = loadApp();
app.sessions = sessions([{ id: 'gone', status: 'busy', paneExit: { at: EXIT_AT } }]);
app.sessionOrder = ['gone'];
const [row] = app.buildHomeSessionRows();
expect(row.state).toBe('working');
const classes = classNames(app._buildHomeSessionRow(row));
expect(classes).toEqual(
expect.arrayContaining([
'home-sessions-row home-sessions-row--exited',
'home-sessions-dot home-sessions-dot--exited',
'home-sessions-pill home-sessions-pill--exited',
])
);
expect(classes.join(' ')).not.toMatch(/--(working|idle)\b/);
});
it('lets a pending permission prompt win over the exit', () => {
const app = loadApp();
app.sessions = sessions([{ id: 'blocked', paneExit: { status: 0, at: EXIT_AT } }]);
app.sessionOrder = ['blocked'];
app.pendingHooks = new Map([['blocked', new Set(['permission_prompt'])]]);
const [row] = app.buildHomeSessionRows();
expect(row.display).toBe('needs');
expect(row.pill).toBe('needs you');
});
it('leaves a session without an exit exactly as it was', () => {
const app = loadApp();
app.sessions = sessions([{ id: 'live', lastActivityAt: 5 }]);
app.sessionOrder = ['live'];
const [row] = app.buildHomeSessionRows();
expect(row.display).toBe('idle');
expect(row.pill).toBe('idle');
expect(row.since).toEqual({ key: 'idle', at: 5 });
});
it('degrades to no override when a stale cached app.js lacks paneExitLabel', () => {
const app = loadApp({ withPaneExitLabel: false });
app.sessions = sessions([{ id: 'gone', paneExit: { status: 3, at: EXIT_AT } }]);
app.sessionOrder = ['gone'];
expect(app.buildHomeSessionRows()[0].pill).toBe('idle');
});
});
describe('the phone overview', () => {
it('says "exited" and keeps the row in its section', () => {
const app = loadApp();
const model = app.buildMobileOverviewModel({
sessions: sessions([
{ id: 'gone', paneExit: { status: 137, at: EXIT_AT } },
{ id: 'live', lastActivityAt: 5 },
]),
cases: [],
sessionOrder: ['gone', 'live'],
});
const byId = Object.fromEntries(model.current.map((r: any) => [r.id, r]));
expect(byId.gone).toMatchObject({ state: 'idle', display: 'exited', pill: 'exited' });
expect(byId.gone.since).toEqual({ key: 'exited', at: EXIT_AT });
expect(byId.live).toMatchObject({ display: 'idle', pill: 'idle' });
});
it('draws a neutral row', () => {
const app = loadApp();
const model = app.buildMobileOverviewModel({
sessions: sessions([{ id: 'gone', status: 'busy', paneExit: { at: EXIT_AT } }]),
cases: [],
});
app._pendingApprovalForSession = () => null;
const classes = classNames(app._buildMobileOverviewRow(model.current[0]));
expect(classes).toEqual(
expect.arrayContaining([
'mobile-overview-row mobile-overview-row--exited',
'mobile-overview-dot mobile-overview-dot--exited',
'mobile-overview-pill mobile-overview-pill--exited',
])
);
expect(classes.join(' ')).not.toMatch(/--(working|idle)\b/);
});
});
describe('the exited pill styles', () => {
it('gives both home screens a neutral exited pill', () => {
expect(readFileSync(resolve(PUBLIC, 'styles.css'), 'utf8')).toMatch(/\.home-sessions-pill--exited \{/);
expect(readFileSync(resolve(PUBLIC, 'mobile.css'), 'utf8')).toMatch(/\.mobile-overview-pill--exited \{/);
});
});
+108
View File
@@ -0,0 +1,108 @@
/**
* @fileoverview Oversized input must never poison the durable input queue (#484).
*
* The failure: a paste over MAX_INPUT_LENGTH was queued for reliable delivery,
* refused by both transports (the WebSocket silently, the POST with a 400), and
* never dropped by the client, which treated a 400 like a transient failure. It
* was re-sent every 2 s forever, blocked every later input for that session
* behind it, and came back from localStorage on every reload.
*
* The fix, pinned here: the client splits a large paste into in-limit frames
* (one limit, shared with the server), drops a frame the server refused for
* good, and prunes oversized frames persisted by an older build.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it } from 'vitest';
import { MAX_INPUT_LENGTH } from '../src/config/terminal-limits.js';
import { SessionInputWithLimitSchema } from '../src/web/schemas.js';
const pub = (f: string) => readFileSync(resolve(import.meta.dirname, '../src/web/public', f), 'utf8');
const appSource = pub('app.js');
type InputLimit = { FRAME_MAX_CHARS: number; PASTE_MAX_CHARS: number; split: (d: string, max?: number) => string[] };
function loadLimit(): InputLimit {
const context = vm.createContext({ window: {}, globalThis: {} });
vm.runInContext(pub('constants.js'), context, { filename: 'constants.js' });
return (context.window as { CodemanInputLimit: InputLimit }).CodemanInputLimit;
}
describe('one input limit on both sides', () => {
it('the frontend frame limit equals the server MAX_INPUT_LENGTH', () => {
expect(loadLimit().FRAME_MAX_CHARS).toBe(MAX_INPUT_LENGTH);
});
it('the composer budget is derived from the same number', () => {
expect(pub('keyboard-accessory.js')).toMatch(
new RegExp(`COMPOSER_INPUT_FRAME_LIMIT = (64 \\* 1024|${MAX_INPUT_LENGTH});`)
);
expect(MAX_INPUT_LENGTH).toBe(64 * 1024);
});
it('the POST schema caps input at MAX_INPUT_LENGTH, not a second number', () => {
expect(SessionInputWithLimitSchema.safeParse({ input: 'x'.repeat(MAX_INPUT_LENGTH) }).success).toBe(true);
expect(SessionInputWithLimitSchema.safeParse({ input: 'x'.repeat(MAX_INPUT_LENGTH + 1) }).success).toBe(false);
});
});
describe('splitInputFrames', () => {
const { split } = loadLimit();
it('returns a short input as one frame and nothing for empty input', () => {
expect(split('abc')).toEqual(['abc']);
expect(split('')).toEqual([]);
});
it('splits a large paste into in-limit frames that rejoin byte-identically', () => {
const paste = '\x1b[200~' + 'log line\n'.repeat(15000) + '\x1b[201~';
const frames = split(paste);
expect(frames.length).toBeGreaterThan(1);
for (const f of frames) expect(f.length).toBeLessThanOrEqual(MAX_INPUT_LENGTH);
expect(frames.join('')).toBe(paste);
});
it('never cuts a surrogate pair in half', () => {
const paste = 'a' + '😀'.repeat(10); // pairs start at odd offsets
const frames = split(paste, 4);
expect(frames.join('')).toBe(paste);
for (const f of frames) {
const last = f.charCodeAt(f.length - 1);
expect(last >= 0xd800 && last <= 0xdbff).toBe(false);
const first = f.charCodeAt(0);
expect(first >= 0xdc00 && first <= 0xdfff).toBe(false);
}
});
});
describe('the client never queues or keeps an undeliverable frame', () => {
const sendAsync = appSource.slice(
appSource.indexOf('_sendInputAsync(sessionId, input, opts) {'),
appSource.indexOf('_sendInputEphemeral(sessionId, input) {')
);
it('splits an oversized paste, and refuses an oversized mux write or giant paste with a toast', () => {
expect(sendAsync).toContain('limit.split(input)');
expect(sendAsync).toMatch(/useMux \|\| input\.length > limit\.PASTE_MAX_CHARS/);
expect(sendAsync).toContain('showToast');
});
it('drops a POST the server refused as invalid instead of retrying it forever', () => {
const drain = appSource.slice(appSource.indexOf('_drainSession(sessionId) {'));
const body = drain.slice(0, drain.indexOf('_dropRejectedInput(sessionId, rec) {'));
expect(body).toMatch(
/resp\.status === 400 \|\| resp\.status === 413\)\) \{\s*[\s\S]{0,400}this\._dropRejectedInput\(sessionId, rec\);/
);
});
it('drops a frame the WebSocket refused with an error ACK', () => {
const handler = appSource.slice(appSource.indexOf('_onWsInputAck(seq, msg) {'));
expect(handler.slice(0, 600)).toMatch(/if \(msg && msg\.err\)/);
});
it('prunes oversized frames persisted by an older build on load', () => {
const load = appSource.slice(appSource.indexOf('_loadReliableState() {'));
expect(load.slice(0, 3000)).toMatch(/r\.data\.length <= frameMax/);
});
});
+330
View File
@@ -0,0 +1,330 @@
/**
* @fileoverview The clean-exit sweep (Ark0N/Codeman#446, part 2).
*
* A session whose agent the user ended with `/exit` is closed the way the X
* button closes it, so finished sessions stop piling up on the board. A crashed
* agent keeps its row, marked with the exit code.
*
* Each rule pinned here guards against a specific wrong close:
*
* 1. **Only an explicit numeric 0 is clean.** On tmux 3.2a a SIGKILLed pane
* reports neither a status nor a signal, so an absent status is the
* crashed-agent case, not the clean one.
* 2. **Two agreeing reads, not one.** A single reading can describe a pane
* that is about to be revived.
* 3. **Never while a start or attach is in flight.** The dead-pane respawn in
* `_setupOrAttachMuxSession()` revives an exited pane on purpose, and the
* pane reads as dead until it finishes.
* 4. **Only local mux-backed sessions.** A remote session's local pane is its
* ssh client, whose death may be a transport drop.
*
* Port: 3189
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import { Session } from '../src/session.js';
import { WebServer } from '../src/web/server.js';
import type { PaneExit, SessionRemote } from '../src/types.js';
import type { MuxSession, TerminalMultiplexer } from '../src/mux-interface.js';
import {
CLEAN_EXIT_CLOSE_REASON,
CLEAN_EXIT_CONFIRMING_READS,
CLEAN_EXIT_MIN_PANE_LIFETIME_MS,
isCleanPaneExit,
shouldCloseCleanlyExitedSession,
} from '../src/pane-exit-sweep.js';
const PORT = 3189;
const AT = 1_700_000_000_000;
describe('isCleanPaneExit', () => {
it('accepts an explicit status of 0 with no signal', () => {
expect(isCleanPaneExit({ status: 0, at: AT })).toBe(true);
});
it('refuses an absent status, which is how a SIGKILL presents on tmux 3.2a', () => {
expect(isCleanPaneExit({ at: AT })).toBe(false);
});
it('refuses a non-zero status', () => {
expect(isCleanPaneExit({ status: 1, at: AT })).toBe(false);
expect(isCleanPaneExit({ status: 137, at: AT })).toBe(false);
});
it('refuses any reported signal, even beside a 0', () => {
expect(isCleanPaneExit({ signal: 9, at: AT })).toBe(false);
expect(isCleanPaneExit({ status: 0, signal: 15, at: AT })).toBe(false);
});
it('refuses an unknown exit', () => {
expect(isCleanPaneExit(undefined)).toBe(false);
});
});
describe('shouldCloseCleanlyExitedSession', () => {
const clean = {
paneExit: { status: 0, at: AT } as PaneExit,
confirmingReads: CLEAN_EXIT_CONFIRMING_READS,
paneLifecycleInFlight: false,
closing: false,
paneStartedAt: 0,
};
it('keeps a clean exit that lands within the startup window, as a startup failure', () => {
const justStarted = AT - CLEAN_EXIT_MIN_PANE_LIFETIME_MS + 1;
expect(shouldCloseCleanlyExitedSession({ ...clean, paneStartedAt: justStarted, confirmingReads: 9 })).toBe(false);
// An exit read during the start itself is earlier than the stamp.
expect(shouldCloseCleanlyExitedSession({ ...clean, paneStartedAt: AT + 500, confirmingReads: 9 })).toBe(false);
});
it('closes a clean exit once the pane has outlived the startup window', () => {
const settled = AT - CLEAN_EXIT_MIN_PANE_LIFETIME_MS;
expect(shouldCloseCleanlyExitedSession({ ...clean, paneStartedAt: settled })).toBe(true);
});
it('closes a clean exit that enough reads agreed on', () => {
expect(CLEAN_EXIT_CONFIRMING_READS).toBe(2);
expect(shouldCloseCleanlyExitedSession(clean)).toBe(true);
expect(shouldCloseCleanlyExitedSession({ ...clean, confirmingReads: 5 })).toBe(true);
});
it('waits for the second read', () => {
expect(shouldCloseCleanlyExitedSession({ ...clean, confirmingReads: 1 })).toBe(false);
expect(shouldCloseCleanlyExitedSession({ ...clean, confirmingReads: 0 })).toBe(false);
});
it('leaves a session alone while its pane is being started or revived', () => {
expect(shouldCloseCleanlyExitedSession({ ...clean, paneLifecycleInFlight: true })).toBe(false);
});
it('does not queue a second close for a session already closing', () => {
expect(shouldCloseCleanlyExitedSession({ ...clean, closing: true })).toBe(false);
});
it('keeps a crashed agent however many reads saw it', () => {
expect(shouldCloseCleanlyExitedSession({ ...clean, paneExit: { status: 137, at: AT }, confirmingReads: 9 })).toBe(
false
);
expect(shouldCloseCleanlyExitedSession({ ...clean, paneExit: { at: AT }, confirmingReads: 9 })).toBe(false);
});
});
describe('the sweep on a real server', () => {
// Drives the real `paneExitsUpdated` wiring: the mux reports a reading, the
// event fires, and the test checks whether the server closed the session.
let server: WebServer | null = null;
afterEach(async () => {
vi.restoreAllMocks();
await server?.stop?.();
server = null;
});
const remote: SessionRemote = { hostId: 'h1', label: 'box', host: 'box', user: 'dev' } as SessionRemote;
/** The mux members `Session` needs to be a local mux-backed session that can be stopped. */
const sessionMux = () =>
({
isAvailable: () => true,
killSession: async () => true,
}) as unknown as TerminalMultiplexer;
const addSession = (web: WebServer, extra: Record<string, unknown> = {}) => {
const muxName = `codeman-${Math.random().toString(16).slice(2, 10)}`;
const session = new Session({
workingDir: '/tmp',
mode: 'claude',
useMux: true,
mux: sessionMux(),
muxSession: { muxName, sessionId: 'x' } as unknown as MuxSession,
...extra,
});
(web as unknown as { sessions: Map<string, Session> }).sessions.set(session.id, session);
return { session, muxName };
};
/** A server whose mux reports `exit` for every pane, seen by `reads` reads. */
const build = (exit: PaneExit | undefined, reads: number) => {
const web = new WebServer(PORT, false, true);
server = web;
const mux = (web as unknown as { mux: Record<string, unknown> }).mux;
const state = { exit, reads };
mux.getPaneExit = () => state.exit;
mux.getPaneExitReadCount = () => (state.exit ? state.reads : 0);
const tick = () => (mux as unknown as { emit: (e: string) => void }).emit('paneExitsUpdated');
const cleanup = vi
.spyOn(web as unknown as { cleanupSession: (...a: unknown[]) => Promise<void> }, 'cleanupSession')
.mockResolvedValue(undefined);
return { web, state, tick, cleanup };
};
it('closes a cleanly exited session on the second read, with a reason in the lifecycle log', () => {
const { web, state, tick, cleanup } = build({ status: 0, at: AT }, 1);
const { session } = addSession(web);
tick();
expect(cleanup).not.toHaveBeenCalled();
// The exit is on the record already, so the tab says "exited (0)" meanwhile.
expect(session.paneExit).toEqual({ status: 0, at: AT });
state.reads = 2;
tick();
expect(cleanup).toHaveBeenCalledTimes(1);
expect(cleanup).toHaveBeenCalledWith(session.id, true, CLEAN_EXIT_CLOSE_REASON);
});
it('keeps a session whose pane started moments before the clean exit', () => {
const { web, tick, cleanup } = build({ status: 0, at: AT }, 2);
const { session } = addSession(web);
(session as unknown as { _paneStartedAt: number })._paneStartedAt = AT - 2_000;
tick();
expect(cleanup).not.toHaveBeenCalled();
expect(session.paneExit).toEqual({ status: 0, at: AT });
});
it('tries each exit once, so a failed close is not retried every tick', () => {
const { web, tick, cleanup } = build({ status: 0, at: AT }, 2);
const { session } = addSession(web);
tick();
tick();
tick();
expect(cleanup).toHaveBeenCalledTimes(1);
// The spy resolved without removing the session, which is what a failed
// close looks like from here: the row stays, with its exit badge.
expect(session.paneExit).toEqual({ status: 0, at: AT });
});
it('gives a new exit in the same pane its own attempt', () => {
const { web, state, tick, cleanup } = build({ status: 0, at: AT }, 2);
addSession(web);
tick();
state.exit = { status: 0, at: AT + 60_000 };
tick();
expect(cleanup).toHaveBeenCalledTimes(2);
});
it('keeps a crashed agent on the board', () => {
const { web, tick, cleanup } = build({ status: 137, at: AT }, 5);
addSession(web);
tick();
expect(cleanup).not.toHaveBeenCalled();
});
it('keeps an agent whose exit status tmux did not report', () => {
const { web, tick, cleanup } = build({ at: AT }, 5);
addSession(web);
tick();
expect(cleanup).not.toHaveBeenCalled();
});
it('never closes a remote session, whose local pane is only the ssh client', () => {
const { web, tick, cleanup } = build({ status: 0, at: AT }, 5);
addSession(web, { remote });
tick();
expect(cleanup).not.toHaveBeenCalled();
});
it('leaves a session alone while its pane is being revived', () => {
const { web, tick, cleanup } = build({ status: 0, at: AT }, 5);
const { session } = addSession(web);
vi.spyOn(session, 'paneLifecycleInFlight', 'get').mockReturnValue(true);
tick();
expect(cleanup).not.toHaveBeenCalled();
});
it('closes nothing when the mux cannot count its reads', () => {
const { web, tick, cleanup } = build({ status: 0, at: AT }, 5);
delete (web as unknown as { mux: Record<string, unknown> }).mux.getPaneExitReadCount;
addSession(web);
tick();
expect(cleanup).not.toHaveBeenCalled();
});
it('removes the session for real through cleanupSession', async () => {
// No spy on cleanupSession here: the close runs the same path as the X
// button, and the session leaves the server's map.
const web = new WebServer(PORT, false, true);
server = web;
const mux = (web as unknown as { mux: Record<string, unknown> }).mux;
mux.getPaneExit = () => ({ status: 0, at: AT });
mux.getPaneExitReadCount = () => 2;
const { session } = addSession(web);
const sessions = (web as unknown as { sessions: Map<string, Session> }).sessions;
(mux as unknown as { emit: (e: string) => void }).emit('paneExitsUpdated');
await vi.waitFor(() => expect(sessions.has(session.id)).toBe(false));
});
});
describe('a session the server is closing', () => {
const session = () =>
new Session({
workingDir: '/tmp',
mode: 'shell',
useMux: true,
mux: { isAvailable: () => true } as unknown as TerminalMultiplexer,
muxSession: { muxName: 'codeman-aaaa', sessionId: 'aaaa' } as unknown as MuxSession,
});
it('refuses to start, so a racing start cannot orphan a tmux session', async () => {
const s = session();
s.markClosing(true);
await expect(s.startInteractive()).rejects.toThrow('Session is being closed');
await expect(s.startShell()).rejects.toThrow('Session is being closed');
});
it('is cleared again when the server gives the close up', async () => {
const web = new WebServer(PORT, false, true);
try {
const s = session();
const sessions = (web as unknown as { sessions: Map<string, Session> }).sessions;
sessions.set(s.id, s);
const internals = web as unknown as {
_doCleanupSession: (...a: unknown[]) => Promise<void>;
cleanupSession: (id: string, kill: boolean, reason: string) => Promise<void>;
};
let closingDuring = false;
vi.spyOn(internals, '_doCleanupSession').mockImplementation(async () => {
closingDuring = (s as unknown as { _closing: boolean })._closing;
throw new Error('layout prune unavailable');
});
await expect(internals.cleanupSession(s.id, true, 'test')).rejects.toThrow('layout prune unavailable');
expect(closingDuring).toBe(true);
expect((s as unknown as { _closing: boolean })._closing).toBe(false);
} finally {
vi.restoreAllMocks();
await web.stop();
}
});
});
describe('the pane lifecycle mark on Session', () => {
it('is raised for the whole of restartCli and lowered afterwards, even on failure', async () => {
let seenDuring: boolean | null = null;
let session: Session;
const mux = {
isAvailable: () => true,
muxSessionExists: () => true,
respawnPane: async () => {
seenDuring = session.paneLifecycleInFlight;
throw new Error('respawn failed');
},
clearPaneExit: () => {},
} as unknown as TerminalMultiplexer;
session = new Session({
workingDir: '/tmp',
mode: 'claude',
useMux: true,
mux,
muxSession: { muxName: 'codeman-aaaa', sessionId: 'aaaa' } as unknown as MuxSession,
});
expect(session.paneLifecycleInFlight).toBe(false);
await expect(session.restartCli()).rejects.toThrow('respawn failed');
expect(seenDuring).toBe(true);
expect(session.paneLifecycleInFlight).toBe(false);
});
});
+165
View File
@@ -0,0 +1,165 @@
/**
* @fileoverview Deleting a session keeps `.claude-images` while a sibling in the
* same working directory is still live (Ark0N/Codeman#446).
*
* `cleanupSession()` removes `{workingDir}/.claude-images` recursively. That
* dir belongs to the working directory, not to the session, and several
* sessions routinely share one case directory, so closing one used to delete
* the pasted images a live sibling still referred to. The exited-agent sweep
* closes sessions unattended, which turns that from an occasional loss into a
* routine one.
*
* Port: 3188
*/
import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { WebServer } from '../src/web/server.js';
import { pasteImageDirInUseByOtherSession } from '../src/web/paste-image-gc.js';
const PORT = 3188;
describe('pasteImageDirInUseByOtherSession', () => {
const none = new Set<string>();
const check = (
live: Array<{ id: string; workingDir: string }>,
opts: { persisted?: Array<{ id: string; workingDir: string; status?: string }>; killing?: Set<string> } = {}
) =>
pasteImageDirInUseByOtherSession({
live,
persisted: opts.persisted ?? [],
closingId: 'a',
workingDir: '/work/case',
killing: opts.killing ?? none,
});
it('finds a live sibling in the same working directory', () => {
expect(
check([
{ id: 'a', workingDir: '/work/case' },
{ id: 'b', workingDir: '/work/case' },
])
).toBe(true);
});
it('ignores the session being closed', () => {
expect(check([{ id: 'a', workingDir: '/work/case' }])).toBe(false);
});
it('ignores sessions in other directories, including a subdirectory', () => {
expect(
check([
{ id: 'a', workingDir: '/work/case' },
{ id: 'b', workingDir: '/work/other' },
{ id: 'c', workingDir: '/work/case/sub' },
])
).toBe(false);
});
it('normalises a trailing slash and dot segments', () => {
expect(check([{ id: 'b', workingDir: '/work/x/../case/' }])).toBe(true);
});
it('does not count a sibling that is being killed too', () => {
// Two sessions of one case killed together must not each defer to the
// other, or neither removes the dir.
expect(check([{ id: 'b', workingDir: '/work/case' }], { killing: new Set(['a', 'b']) })).toBe(false);
});
it('counts a detached session, which left the map but still runs in tmux', () => {
// `DELETE ?killMux=false` removes the session from the server's map and
// keeps its persisted record and its pane.
expect(check([], { persisted: [{ id: 'b', workingDir: '/work/case', status: 'idle' }] })).toBe(true);
});
it('does not count a persisted record demoted to stopped, or the closing session', () => {
expect(
check([], {
persisted: [
{ id: 'a', workingDir: '/work/case', status: 'idle' },
{ id: 'b', workingDir: '/work/case', status: 'stopped' },
],
})
).toBe(false);
});
it('matches a sibling that reaches the same directory through a symlink', () => {
const root = mkdtempSync(join(tmpdir(), 'codeman-paste-link-'));
try {
const real = join(root, 'case');
mkdirSync(real);
symlinkSync(real, join(root, 'current'));
expect(
pasteImageDirInUseByOtherSession({
live: [{ id: 'b', workingDir: join(root, 'current') }],
persisted: [],
closingId: 'a',
workingDir: real,
killing: none,
})
).toBe(true);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
});
describe('deleting a session that shares its working directory', () => {
let server: WebServer;
let workingDir: string;
const base = `http://localhost:${PORT}`;
beforeAll(async () => {
workingDir = mkdtempSync(join(tmpdir(), 'codeman-paste-shared-'));
server = new WebServer(PORT, false, true);
await server.start();
});
afterAll(async () => {
await server.stop();
rmSync(workingDir, { recursive: true, force: true });
}, 60000);
const create = async (): Promise<string> => {
const res = await fetch(`${base}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir }),
});
const body = await res.json();
return body.data.session.id as string;
};
const remove = (id: string) => fetch(`${base}/api/sessions/${id}`, { method: 'DELETE' });
it('keeps the images while a sibling is live, and removes them with the last session', async () => {
const first = await create();
const second = await create();
const imageDir = join(workingDir, '.claude-images');
mkdirSync(imageDir, { recursive: true });
writeFileSync(join(imageDir, 'paste-1.png'), 'x');
expect((await remove(first)).status).toBe(200);
expect(existsSync(join(imageDir, 'paste-1.png'))).toBe(true);
expect((await remove(second)).status).toBe(200);
expect(existsSync(imageDir)).toBe(false);
});
it('keeps the images while a sibling is only detached, since it still runs in tmux', async () => {
const detached = await create();
const deleted = await create();
// Persisting a new record is debounced, and a detach cancels the pending
// write, so wait for the record a long-running session would already have.
const store = (server as unknown as { store: { getSession: (id: string) => unknown } }).store;
await vi.waitFor(() => expect(store.getSession(detached)).toBeTruthy(), { timeout: 10_000 });
const imageDir = join(workingDir, '.claude-images');
mkdirSync(imageDir, { recursive: true });
writeFileSync(join(imageDir, 'paste-2.png'), 'x');
expect((await fetch(`${base}/api/sessions/${detached}?killMux=false`, { method: 'DELETE' })).status).toBe(200);
expect((await remove(deleted)).status).toBe(200);
expect(existsSync(join(imageDir, 'paste-2.png'))).toBe(true);
});
});
+19
View File
@@ -108,6 +108,25 @@ describe('which dead sessions may be rebuilt', () => {
expect(plan.skipped).toEqual([{ sessionId: 'gone', reason: 'no-persisted-record' }]); expect(plan.skipped).toEqual([{ sessionId: 'gone', reason: 'no-persisted-record' }]);
}); });
it('never revives a session whose agent the user ended with a clean exit (#446)', () => {
// The clean-exit sweep would have closed it, had the power not gone first.
const persisted = { exited: persistedSession({ id: 'exited', paneExit: { status: 0, at: NOW - HOUR } }) };
const plan = planRebootRestore(['exited'], persisted, () => true);
expect(plan.restore).toEqual([]);
expect(plan.skipped).toEqual([{ sessionId: 'exited', reason: 'agent-exited' }]);
});
it('still offers a crashed agent, and one whose exit status tmux never reported', () => {
// Same explicit-0 rule as the sweep: an absent status is unknown, not clean,
// and a crash keeps its row on the board, so it stays eligible here too.
const persisted = {
crashed: persistedSession({ id: 'crashed', paneExit: { status: 137, at: NOW - HOUR } }),
killed: persistedSession({ id: 'killed', paneExit: { at: NOW - HOUR } }),
};
const plan = planRebootRestore(['crashed', 'killed'], persisted, () => true);
expect(plan.restore.map((s) => s.sessionId)).toEqual(['crashed', 'killed']);
});
it('never revives a pane whose PTY-exit breaker had tripped', () => { it('never revives a pane whose PTY-exit breaker had tripped', () => {
const persisted = { crashy: persistedSession({ id: 'crashy', respawnBlocked: true }) }; const persisted = { crashy: persistedSession({ id: 'crashy', respawnBlocked: true }) };
expect(planRebootRestore(['crashy'], persisted, () => true).skipped[0].reason).toBe('respawn-blocked'); expect(planRebootRestore(['crashy'], persisted, () => true).skipped[0].reason).toBe('respawn-blocked');
+16
View File
@@ -282,6 +282,22 @@ describe('ws-routes', () => {
} }
}); });
it('refuses an oversized sequenced frame with an error ACK, so the client can drop it', async () => {
// Issue #484: a silent return left the frame unACKed, and the client's
// durable queue re-sent it every few seconds forever.
const ws = await connectWs('/ws/sessions/ws-test-session/terminal');
try {
const session = ctx._session;
const hugeInput = 'y'.repeat(MAX_INPUT_LENGTH + 1);
ws.send(JSON.stringify({ t: 'i', d: hugeInput, cid: 'c1', seq: 3 }));
expect(await nextMessage(ws)).toEqual({ t: 'ia', seq: 3, err: 'too_large', max: MAX_INPUT_LENGTH });
expect(session.writeBuffer).not.toContain(hugeInput);
} finally {
ws.close();
}
});
it('ignores malformed JSON messages', async () => { it('ignores malformed JSON messages', async () => {
const ws = await connectWs('/ws/sessions/ws-test-session/terminal'); const ws = await connectWs('/ws/sessions/ws-test-session/terminal');
try { try {
+7 -3
View File
@@ -163,9 +163,12 @@ describe('the rich row pill of an exited session', () => {
// which reads `status` and knows nothing about the exit, so without an override // which reads `status` and knows nothing about the exit, so without an override
// the muted dot sat beside a pill saying "idle". // the muted dot sat beside a pill saying "idle".
const appJs = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8'); const appJs = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
const fn = (re: RegExp, name: string) => { // The exit rule itself is shared with both home screens and lives in
const m = appJs.match(re)?.[0]; // mobile-overview.js, so it is lifted from there rather than stubbed.
if (!m) throw new Error(`${name} not found in app.js`); const overviewJs = readFileSync(resolve(import.meta.dirname, '../src/web/public/mobile-overview.js'), 'utf8');
const fn = (re: RegExp, name: string, source = appJs) => {
const m = source.match(re)?.[0];
if (!m) throw new Error(`${name} not found`);
return m; return m;
}; };
type Row = { state: string; exited: boolean; pill: string; since: { key: string; at: number } | null }; type Row = { state: string; exited: boolean; pill: string; since: { key: string; at: number } | null };
@@ -174,6 +177,7 @@ describe('the rich row pill of an exited session', () => {
return { return {
${fn(/ {2}_sidebarRichPillLabel\(state\) \{[\s\S]*?\n {2}\}/, '_sidebarRichPillLabel')}, ${fn(/ {2}_sidebarRichPillLabel\(state\) \{[\s\S]*?\n {2}\}/, '_sidebarRichPillLabel')},
${fn(/ {2}_sidebarRichRow\(id, session\) \{[\s\S]*?\n {2}\}/, '_sidebarRichRow')}, ${fn(/ {2}_sidebarRichRow\(id, session\) \{[\s\S]*?\n {2}\}/, '_sidebarRichRow')},
${fn(/ {2}_mobileOverviewExit\(state, session\) \{[\s\S]*?\n {2}\}/, '_mobileOverviewExit', overviewJs)},
_mobileOverviewState(session, hooks) { _mobileOverviewState(session, hooks) {
if (hooks && hooks.has('permission_prompt')) return 'needs'; if (hooks && hooks.has('permission_prompt')) return 'needs';
if (hooks && hooks.has('idle_prompt')) return 'waiting'; if (hooks && hooks.has('idle_prompt')) return 'waiting';
+45
View File
@@ -1126,6 +1126,37 @@ describe('TmuxManager pane-exit bookkeeping', () => {
manager.applyPaneExits(derivePaneExits(parsePaneRows('codeman-aaaa|100|1|0|'), NOW)); manager.applyPaneExits(derivePaneExits(parsePaneRows('codeman-aaaa|100|1|0|'), NOW));
manager.clearPaneExit('codeman-aaaa'); manager.clearPaneExit('codeman-aaaa');
expect(manager.getPaneExit('codeman-aaaa')).toBeUndefined(); expect(manager.getPaneExit('codeman-aaaa')).toBeUndefined();
expect(manager.getPaneExitReadCount('codeman-aaaa')).toBe(0);
});
// The clean-exit sweep closes a session only once two reads agreed on its
// exit (Ark0N/Codeman#446), so the count must rise only on an exact repeat.
it('counts the reads that agreed on one exit', () => {
const manager = new TmuxManager();
expect(manager.getPaneExitReadCount('codeman-aaaa')).toBe(0);
manager.applyPaneExits(derivePaneExits(parsePaneRows('codeman-aaaa|100|1|0|'), NOW));
expect(manager.getPaneExitReadCount('codeman-aaaa')).toBe(1);
manager.applyPaneExits(derivePaneExits(parsePaneRows('codeman-aaaa|100|1|0|'), NOW + 2000));
expect(manager.getPaneExitReadCount('codeman-aaaa')).toBe(2);
});
it('starts the count again when the status or the pane pid changes', () => {
const manager = new TmuxManager();
manager.applyPaneExits(derivePaneExits(parsePaneRows('codeman-aaaa|100|1|0|'), NOW));
manager.applyPaneExits(derivePaneExits(parsePaneRows('codeman-aaaa|100|1|0|'), NOW + 2000));
manager.applyPaneExits(derivePaneExits(parsePaneRows('codeman-aaaa|100|1|1|'), NOW + 4000));
expect(manager.getPaneExitReadCount('codeman-aaaa')).toBe(1);
manager.applyPaneExits(derivePaneExits(parsePaneRows('codeman-aaaa|101|1|1|'), NOW + 6000));
expect(manager.getPaneExitReadCount('codeman-aaaa')).toBe(1);
});
it('drops the count once a read sees the pane alive again', () => {
const manager = new TmuxManager();
manager.applyPaneExits(derivePaneExits(parsePaneRows('codeman-aaaa|100|1|0|'), NOW));
manager.applyPaneExits(derivePaneExits(parsePaneRows('codeman-aaaa|101|0|||'), NOW + 2000));
expect(manager.getPaneExitReadCount('codeman-aaaa')).toBe(0);
manager.applyPaneExits(derivePaneExits(parsePaneRows('codeman-aaaa|101|1|0|'), NOW + 4000));
expect(manager.getPaneExitReadCount('codeman-aaaa')).toBe(1);
}); });
}); });
@@ -1214,6 +1245,20 @@ describe('the pane-exit watcher tick', () => {
expect(manager.getPaneExit('codeman-s1')).toEqual({ status: 137, at: NOW }); expect(manager.getPaneExit('codeman-s1')).toEqual({ status: 137, at: NOW });
}); });
it('does not count an empty read as confirming an exit', async () => {
// The clean-exit sweep closes on the second agreeing read. A read tmux did
// not answer agrees with nothing, so it must not supply that second read.
const manager = withLocalSession();
manager.rows = parsePaneRows('codeman-s1|100|1|0|');
await manager.refreshPaneExits(NOW);
manager.rows = [];
await manager.refreshPaneExits(NOW + 2000);
expect(manager.getPaneExitReadCount('codeman-s1')).toBe(1);
manager.rows = parsePaneRows('codeman-s1|100|1|0|');
await manager.refreshPaneExits(NOW + 4000);
expect(manager.getPaneExitReadCount('codeman-s1')).toBe(2);
});
it('discards a read that started before the pane was cleared', async () => { it('discards a read that started before the pane was cleared', async () => {
// The guard that stops an in-flight read from republishing a death over // The guard that stops an in-flight read from republishing a death over
// the pane that has just replaced it. // the pane that has just replaced it.