mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d55bc28a8e | ||
|
|
d67da5c9d0 | ||
|
|
e6ddb0485a | ||
|
|
334884e96a |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'aicodeman': patch
|
||||
---
|
||||
|
||||
fix(sessions): stop pinning the `w1-myapp` placeholder as Claude's session title. Local Claude spawns passed the tab name as `--name`, which is also the `/resume` picker entry and the terminal title, and a pinned title stops Claude generating its own, so every conversation of a case showed up in `/resume` as the same `w1-myapp` and none got a generated title. Only a name the user chose is pinned now; placeholder and auto-named tabs let Claude title the conversation again. Renaming a Claude tab also reaches `/resume`: the new name is appended to the conversation's transcript as the `custom-title` row `/rename` writes (a tab that was spawned with `--name` keeps re-appending its own title until its next respawn, so the rename wins from then on). Orchestrators that rely on a fixed peer name should give workers a descriptive `sessionName` rather than a `w<N>-` one.
|
||||
@@ -10,7 +10,7 @@
|
||||
"name": "codeman",
|
||||
"source": "./plugins/codeman",
|
||||
"description": "Drive Codeman from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts only inside a Codeman-managed session.",
|
||||
"version": "1.32.1",
|
||||
"version": "1.33.0",
|
||||
"author": {
|
||||
"name": "Ark0N",
|
||||
"url": "https://github.com/Ark0N"
|
||||
|
||||
@@ -1,5 +1,30 @@
|
||||
# aicodeman
|
||||
|
||||
## 1.33.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- CLI management from Settings (#476, finishing the CLI registry work from #343). `~/.codeman/clis.json` used to be hand-edit only; with the new opt-in `cliManagementEnabled` switch (synced, default OFF) App Settings → Agents & CLIs can enable or disable any CLI, install a missing stock CLI with its vetted install command, and add, edit or remove custom CLIs. Six new endpoints back it (`GET`/`POST /api/clis`, `PUT /api/clis/:id`, `POST /api/clis/:id/install`, `PUT /api/clis/custom/:id`, `DELETE /api/clis/:id`), documented in `docs/api-reference.md`. Every write is refused while the switch is off, is admin-only in multi-user mode, is serialized on one queue, and refuses to overwrite a `clis.json` that does not parse or has group/world permission bits. A custom entry is re-validated through the same schema as the stock ones and its install text is never executed. `shell` cannot be disabled. The Run menu and the welcome screen are now built from the enabled catalogue, so the welcome screen also offers Codex, Shell and any custom CLI, and the stock Claude entry is labelled "Claude Code".
|
||||
|
||||
Models: Opus 5.5 (`claude-opus-5-5`, 1M context capable) is offered in App Settings → Models and in task routing (#480).
|
||||
|
||||
Self-update: on a macOS `launchd-daemon` install, a Homebrew node upgrade could leave `update-status.json` stuck at `queued`, which made every later update fail with "An update is already in progress." The updater now falls back to `node` on PATH when the server's own node binary is gone, and an in-flight status that has not been written for 15 minutes is failed on the next read. A graceful shutdown that hangs is now force-exited after 10 s (and the launchd updater SIGKILLs a server that has not exited after 30 s), so launchd can start the new build instead of leaving the service down (#478). Both fixes protect updates that start FROM this release.
|
||||
|
||||
Session Manager (Cmd+K): rows keep their `mode`, `claudeSessionId` and `resumeId`, so the ⋯ menu's Resume session relaunches a Codex row as Codex on its own conversation, and the mode badge shows as it does on the home list (#477).
|
||||
|
||||
Maintainer fixes applied while landing #457: renaming a tab to the name it already has (the Session Options field saves on blur) is now a no-op, so it no longer pins the placeholder as the `/resume` title again; Docker sessions skip the transcript title sync, since their transcript lives in the container; and the agent skill's messaging examples no longer use a `w<N>-` name as the peer name.
|
||||
|
||||
Tests: the suite strips every inherited `CODEMAN_*` variable, so running it inside a Docker Compose deployment no longer writes into the deployment's real case root (#479).
|
||||
|
||||
### Thanks
|
||||
- @opticon454 for CLI management (#476), the last piece of the CLI registry, with every review item answered in one round, and for splitting the test isolation fix out into #479.
|
||||
- @shenlvkang-collab for the `/resume` title fix (#457) and the careful diagnosis behind it.
|
||||
- @julian3xl for the Session Manager row fix (#477), their first contribution.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 69a7128: fix(sessions): stop pinning the `w1-myapp` placeholder as Claude's session title. Local Claude spawns passed the tab name as `--name`, which is also the `/resume` picker entry and the terminal title, and a pinned title stops Claude generating its own, so every conversation of a case showed up in `/resume` as the same `w1-myapp` and none got a generated title. Only a name the user chose is pinned now; placeholder and auto-named tabs let Claude title the conversation again. Renaming a Claude tab also reaches `/resume`: the new name is appended to the conversation's transcript as the `custom-title` row `/rename` writes (a tab that was spawned with `--name` keeps re-appending its own title until its next respawn, so the rename wins from then on). Orchestrators that rely on a fixed peer name should give workers a descriptive `sessionName` rather than a `w<N>-` one.
|
||||
|
||||
## 1.32.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -77,7 +77,7 @@ When user says "COM":
|
||||
|
||||
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
|
||||
|
||||
**Version**: 1.32.1 (must match `package.json`)
|
||||
**Version**: 1.33.0 (must match `package.json`)
|
||||
|
||||
## Project Overview
|
||||
|
||||
|
||||
@@ -66,6 +66,31 @@ each `(clientId, seq)` at most once, so a resend can't type the prompt twice.
|
||||
(the 200 is the client's ACK). `curl`/legacy callers omit the fields and always
|
||||
apply.
|
||||
|
||||
## Oversized input (issue #484)
|
||||
|
||||
Delivery has a third outcome besides "applied" and "retry": **refused for good**.
|
||||
Both transports refuse a frame longer than `MAX_INPUT_LENGTH` (64 KiB,
|
||||
`src/config/terminal-limits.ts`; the POST schema uses the same constant). Before
|
||||
#484 the client treated that like a transient failure, so an oversized paste sat
|
||||
at the head of the queue, was re-sent every 2 s forever, blocked every later
|
||||
input for the session, and came back from localStorage on each reload.
|
||||
|
||||
- `_sendInputAsync()` splits a paste over the frame limit into in-limit frames
|
||||
(`CodemanInputLimit.split`, constants.js, never cutting a surrogate pair). They
|
||||
go out in seq order, so the PTY sees one contiguous stream. A paste over
|
||||
`PASTE_MAX_CHARS` (1 MiB), or an oversized `useMux` write (line-oriented, never
|
||||
split), is refused with a toast and never queued.
|
||||
- The WebSocket answers an oversized sequenced frame with
|
||||
`{t:'ia', seq, err:'too_large', max}`; the client drops it with a toast. A
|
||||
client that predates `err` reads it as a plain ACK and drops it too.
|
||||
- The POST drain drops a frame answered `400`/`413` (`401`/`403` stay transient:
|
||||
an expired login delivers once the user signs in again).
|
||||
- `_loadReliableState()` prunes persisted frames over the limit, so a queue
|
||||
poisoned by an older build heals on the first load after upgrading.
|
||||
- ⚠️ The frontend limit (`INPUT_FRAME_MAX_CHARS`) and the composer's
|
||||
`COMPOSER_INPUT_FRAME_LIMIT` must equal `MAX_INPUT_LENGTH`; pinned by
|
||||
`test/input-size-limit.test.ts`.
|
||||
|
||||
## Known limitation
|
||||
|
||||
Dedup state is in-memory on the server. A **server restart** between a write and
|
||||
@@ -79,3 +104,5 @@ across the narrow restart window.
|
||||
semantics (monotonic, per-client, gap-tolerant, eviction-safe).
|
||||
- `test/routes/session-routes.test.ts` — POST `/input` applies a tagged
|
||||
`(clientId, seq)` once on redelivery; untagged input always applies.
|
||||
- `test/input-size-limit.test.ts`: one input limit on both sides, frame
|
||||
splitting, and dropping (never retrying) a frame refused for good (#484).
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.32.1",
|
||||
"version": "1.33.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "1.32.1",
|
||||
"version": "1.33.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.32.1",
|
||||
"version": "1.33.0",
|
||||
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "codeman",
|
||||
"description": "Drive Codeman, the self-hosted session manager for AI coding agents, from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts only inside a Codeman-managed session.",
|
||||
"version": "1.32.1",
|
||||
"version": "1.33.0",
|
||||
"author": {
|
||||
"name": "Ark0N",
|
||||
"url": "https://github.com/Ark0N"
|
||||
|
||||
+55
-15
@@ -3427,7 +3427,26 @@ class CodemanApp {
|
||||
*/
|
||||
_sendInputAsync(sessionId, input, opts) {
|
||||
if (!sessionId || !input) return;
|
||||
this._reliableSend(sessionId, input, opts?.useMux === true);
|
||||
const useMux = opts?.useMux === true;
|
||||
// Both transports refuse a frame over the server's limit (issue #484), and a
|
||||
// refused frame used to sit at the head of the durable queue for good. So an
|
||||
// oversized paste goes out as several in-limit frames, delivered in seq order
|
||||
// as one contiguous stream. A mux write is line-oriented (it strips newlines
|
||||
// and sends Enter on its own), so it is never split: refuse it instead.
|
||||
const limit = window.CodemanInputLimit;
|
||||
if (limit && input.length > limit.FRAME_MAX_CHARS) {
|
||||
if (useMux || input.length > limit.PASTE_MAX_CHARS) {
|
||||
const max = useMux ? limit.FRAME_MAX_CHARS : limit.PASTE_MAX_CHARS;
|
||||
this.showToast?.(
|
||||
`Input too large (${Math.ceil(input.length / 1024)} KB, limit ${Math.floor(max / 1024)} KB); not sent`,
|
||||
'error'
|
||||
);
|
||||
return;
|
||||
}
|
||||
for (const frame of limit.split(input)) this._reliableSend(sessionId, frame, false);
|
||||
return;
|
||||
}
|
||||
this._reliableSend(sessionId, input, useMux);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3547,6 +3566,12 @@ class CodemanApp {
|
||||
// Session no longer exists — the input can never land. Drop it
|
||||
// rather than retry forever (not a "lost" prompt: the target is gone).
|
||||
this._ackDelivery(sessionId, rec.seq);
|
||||
} else if (resp && (resp.status === 400 || resp.status === 413)) {
|
||||
// The frame itself was refused, so a retry gets the same answer. Kept
|
||||
// queued, it was re-POSTed every 2 s forever and blocked every later
|
||||
// input for this session behind it (issue #484). 401/403 stay
|
||||
// transient: an expired login delivers fine once the user signs in.
|
||||
this._dropRejectedInput(sessionId, rec);
|
||||
} else {
|
||||
break; // offline / 5xx — leave queued; sweep + reconnect retry later
|
||||
}
|
||||
@@ -3557,6 +3582,12 @@ class CodemanApp {
|
||||
})();
|
||||
}
|
||||
|
||||
/** Drop a frame the server refused for good, and say so once. */
|
||||
_dropRejectedInput(sessionId, rec) {
|
||||
this._ackDelivery(sessionId, rec.seq);
|
||||
this.showToast?.(`Input refused by the server (${Math.ceil(rec.data.length / 1024)} KB); not sent`, 'error');
|
||||
}
|
||||
|
||||
/** Drop an ACKed record (by exact seq) and persist. */
|
||||
_ackDelivery(sessionId, seq) {
|
||||
const list = this._pendingDeliveries.get(sessionId);
|
||||
@@ -3601,6 +3632,13 @@ class CodemanApp {
|
||||
_onWsInputAck(seq, msg) {
|
||||
const sessionId = this._wsSessionId;
|
||||
if (!sessionId || !Number.isInteger(seq)) return;
|
||||
if (msg && msg.err) {
|
||||
// Refused for good (e.g. over the size limit): retrying cannot help.
|
||||
const rec = (this._pendingDeliveries.get(sessionId) || []).find((r) => r.seq === seq);
|
||||
if (rec) this._dropRejectedInput(sessionId, rec);
|
||||
else this._ackDelivery(sessionId, seq);
|
||||
return;
|
||||
}
|
||||
if (msg && msg.dup) {
|
||||
const list = this._pendingDeliveries.get(sessionId);
|
||||
const rec = list && list.find((r) => r.seq === seq);
|
||||
@@ -3714,20 +3752,22 @@ class CodemanApp {
|
||||
if (saved && saved.pending) {
|
||||
for (const [s, recs] of Object.entries(saved.pending)) {
|
||||
if (Array.isArray(recs) && recs.length) {
|
||||
// Reset sentAt so they re-deliver promptly on this fresh load.
|
||||
this._pendingDeliveries.set(
|
||||
s,
|
||||
recs
|
||||
.filter((r) => r && typeof r.data === 'string' && Number.isInteger(r.seq))
|
||||
.map((r) => ({
|
||||
seq: r.seq,
|
||||
data: r.data,
|
||||
useMux: !!r.useMux,
|
||||
ts: r.ts || Date.now(),
|
||||
tries: 0,
|
||||
sentAt: 0,
|
||||
}))
|
||||
);
|
||||
const frameMax = window.CodemanInputLimit?.FRAME_MAX_CHARS ?? Infinity;
|
||||
const kept = recs
|
||||
.filter((r) => r && typeof r.data === 'string' && Number.isInteger(r.seq))
|
||||
// A frame over the server's limit can never be ACKed; one persisted
|
||||
// by an older build would otherwise come back on every load (#484).
|
||||
.filter((r) => r.data.length <= frameMax)
|
||||
// Reset sentAt so they re-deliver promptly on this fresh load.
|
||||
.map((r) => ({
|
||||
seq: r.seq,
|
||||
data: r.data,
|
||||
useMux: !!r.useMux,
|
||||
ts: r.ts || Date.now(),
|
||||
tries: 0,
|
||||
sentAt: 0,
|
||||
}));
|
||||
if (kept.length) this._pendingDeliveries.set(s, kept);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -762,6 +762,49 @@ function resolveTerminalFontWeights(settings) {
|
||||
// without a terminal, a clipboard, or a browser.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Largest single input frame the server accepts, in UTF-16 code units.
|
||||
* ⚠️ Must equal MAX_INPUT_LENGTH in src/config/terminal-limits.ts (pinned by
|
||||
* test/input-size-limit.test.ts). Both transports reject a longer frame, and
|
||||
* before issue #484 the durable input queue retried such a frame forever.
|
||||
*/
|
||||
const INPUT_FRAME_MAX_CHARS = 64 * 1024;
|
||||
|
||||
/**
|
||||
* Largest paste the client will deliver at all. Anything up to this is split
|
||||
* into INPUT_FRAME_MAX_CHARS frames that go out in seq order, so the PTY sees
|
||||
* one contiguous byte stream (bracketed-paste markers included). Past it the
|
||||
* input is refused with a toast rather than queued: every frame is persisted
|
||||
* and retried until ACKed, so a multi-megabyte paste would pin the queue.
|
||||
*/
|
||||
const INPUT_PASTE_MAX_CHARS = 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Split input into frames no longer than `max` code units, never cutting a
|
||||
* surrogate pair in half (a lone surrogate reaches the PTY as U+FFFD).
|
||||
*
|
||||
* @param {string} data
|
||||
* @param {number} [max]
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function splitInputFrames(data, max = INPUT_FRAME_MAX_CHARS) {
|
||||
if (typeof data !== 'string' || data.length === 0) return [];
|
||||
if (!(max >= 2)) max = 2;
|
||||
if (data.length <= max) return [data];
|
||||
const frames = [];
|
||||
let start = 0;
|
||||
while (start < data.length) {
|
||||
let end = Math.min(start + max, data.length);
|
||||
if (end < data.length) {
|
||||
const code = data.charCodeAt(end - 1);
|
||||
if (code >= 0xd800 && code <= 0xdbff) end--; // keep the pair together
|
||||
}
|
||||
frames.push(data.slice(start, end));
|
||||
start = end;
|
||||
}
|
||||
return frames;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upper bound on an AUTO-copied selection.
|
||||
*
|
||||
@@ -940,6 +983,11 @@ if (typeof window !== 'undefined') {
|
||||
compare: compareSessionActivity,
|
||||
sort: sortSessionsByActivity,
|
||||
};
|
||||
window.CodemanInputLimit = {
|
||||
FRAME_MAX_CHARS: INPUT_FRAME_MAX_CHARS,
|
||||
PASTE_MAX_CHARS: INPUT_PASTE_MAX_CHARS,
|
||||
split: splitInputFrames,
|
||||
};
|
||||
window.CodemanAutoCopy = {
|
||||
decide: decideAutoCopy,
|
||||
MAX_CHARS: AUTO_COPY_MAX_CHARS,
|
||||
|
||||
@@ -106,6 +106,8 @@
|
||||
'Manage AI Coding tools in persistent tmux sessions.': '在持久化 tmux 会话中管理 AI 编程工具。',
|
||||
'Select case': '选择案例',
|
||||
'Select Case': '选择案例',
|
||||
'Search cases': '搜索案例',
|
||||
'No matching cases': '没有匹配的案例',
|
||||
'All cases': '全部案例',
|
||||
'No directory': '未选择目录',
|
||||
Run: '运行',
|
||||
|
||||
@@ -2137,6 +2137,8 @@
|
||||
<option value="claude-fable-5-1[1m]" data-variant="1m" data-base="claude-fable-5-1">Fable 5.1 (1M context)</option>
|
||||
<option value="claude-fable-5" data-meta="Most powerful" data-base="claude-fable-5" data-ctx="1">Fable 5</option>
|
||||
<option value="claude-fable-5[1m]" data-variant="1m" data-base="claude-fable-5">Fable 5 (1M context)</option>
|
||||
<option value="claude-opus-5-5" data-meta="Latest Opus" data-base="claude-opus-5-5" data-ctx="1">Opus 5.5</option>
|
||||
<option value="claude-opus-5-5[1m]" data-variant="1m" data-base="claude-opus-5-5">Opus 5.5 (1M context)</option>
|
||||
<option value="opus" data-meta="Most capable" data-base="opus" data-ctx="1">Opus</option>
|
||||
<option value="opus[1m]" data-variant="1m" data-base="opus">Opus (1M context)</option>
|
||||
<option value="claude-opus-4-6" data-meta="Previous generation" data-base="claude-opus-4-6" data-ctx="1">Opus 4.6</option>
|
||||
@@ -2147,7 +2149,7 @@
|
||||
<div class="set-row" id="appSettingsContextRow" data-search="1m context window opus long">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">1M context window</span>
|
||||
<span class="set-row-desc" id="appSettingsContextDesc">Available for Fable 5.1, Fable 5, Opus and Opus 4.6.</span>
|
||||
<span class="set-row-desc" id="appSettingsContextDesc">Available for Fable 5.1, Fable 5, Opus 5.5, Opus and Opus 4.6.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsOpusContext1m"><span class="slider"></span></label>
|
||||
</div>
|
||||
@@ -2189,6 +2191,7 @@
|
||||
<option value="">Default (CLI default)</option>
|
||||
<option value="claude-fable-5-1">Fable 5.1 (Latest)</option>
|
||||
<option value="claude-fable-5">Fable 5 (Most powerful)</option>
|
||||
<option value="claude-opus-5-5">Opus 5.5 (Latest Opus)</option>
|
||||
<option value="opus">Opus (Most capable)</option>
|
||||
<option value="sonnet">Sonnet (Balanced)</option>
|
||||
<option value="haiku">Haiku (Fast & cheap)</option>
|
||||
@@ -2204,6 +2207,7 @@
|
||||
<option value="opus">Opus</option>
|
||||
<option value="claude-fable-5-1">Fable 5.1</option>
|
||||
<option value="claude-fable-5">Fable 5</option>
|
||||
<option value="claude-opus-5-5">Opus 5.5</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="set-mini">
|
||||
@@ -2215,6 +2219,7 @@
|
||||
<option value="opus">Opus</option>
|
||||
<option value="claude-fable-5-1">Fable 5.1</option>
|
||||
<option value="claude-fable-5">Fable 5</option>
|
||||
<option value="claude-opus-5-5">Opus 5.5</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="set-mini">
|
||||
@@ -2226,6 +2231,7 @@
|
||||
<option value="opus">Opus</option>
|
||||
<option value="claude-fable-5-1">Fable 5.1</option>
|
||||
<option value="claude-fable-5">Fable 5</option>
|
||||
<option value="claude-opus-5-5">Opus 5.5</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="set-mini">
|
||||
@@ -2237,6 +2243,7 @@
|
||||
<option value="opus">Opus</option>
|
||||
<option value="claude-fable-5-1">Fable 5.1</option>
|
||||
<option value="claude-fable-5">Fable 5</option>
|
||||
<option value="claude-opus-5-5">Opus 5.5</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -3197,7 +3204,12 @@
|
||||
<h3>Select Case</h3>
|
||||
<button class="modal-close" onclick="app.closeMobileCasePicker()" aria-label="Close case picker">×</button>
|
||||
</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-empty" id="mobileCaseEmpty" hidden>No matching cases</div>
|
||||
<div class="mobile-case-list" id="mobileCaseList">
|
||||
<!-- Cases populated by JS -->
|
||||
</div>
|
||||
|
||||
@@ -2165,9 +2165,11 @@ html.mobile-init .file-browser-panel {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
/* The footer already reserves the home-indicator inset; padding the sheet too
|
||||
counted it twice and left a dead band under Create New Case. */
|
||||
.mobile-case-picker-sheet {
|
||||
max-height: 60vh;
|
||||
padding-bottom: var(--safe-area-bottom);
|
||||
max-height: 80vh;
|
||||
max-height: 80dvh;
|
||||
animation: slideUp 0.2s ease-out;
|
||||
}
|
||||
|
||||
|
||||
@@ -4506,6 +4506,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const isSelected = c.name === currentCase;
|
||||
html += `
|
||||
<button class="mobile-case-item ${isSelected ? 'selected' : ''}"
|
||||
data-search="${escapeHtml(`${c.label} ${c.name}`.toLowerCase())}"
|
||||
onclick="app.selectMobileCase(${escapeHtml(JSON.stringify(c.name))})">
|
||||
<span class="mobile-case-item-icon">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
@@ -4528,7 +4529,61 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
|
||||
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');
|
||||
// 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() {
|
||||
|
||||
@@ -987,7 +987,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
desc.textContent = inert
|
||||
? 'The selected model has no 1M variant.'
|
||||
: base
|
||||
? 'Available for Fable 5.1, Fable 5, Opus and Opus 4.6.'
|
||||
? 'Available for Fable 5.1, Fable 5, Opus 5.5, Opus and Opus 4.6.'
|
||||
: 'With no model pinned, this starts new sessions on Opus with a 1M window.';
|
||||
}
|
||||
},
|
||||
|
||||
@@ -7072,12 +7072,61 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
|
||||
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 {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
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 {
|
||||
|
||||
@@ -175,7 +175,16 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHost
|
||||
try {
|
||||
const msg = JSON.parse(String(raw));
|
||||
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
|
||||
// exactly once (skip a duplicate redelivery) but ACK it regardless so
|
||||
// the client can drop it from its durable queue. Frames without seq
|
||||
|
||||
+5
-1
@@ -20,6 +20,7 @@ import {
|
||||
import { MAX_EDITABLE_BYTES } from '../config/file-editing.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_INPUT_LENGTH } from '../config/terminal-limits.js';
|
||||
import { enabledCliIds, enabledClis } from '../config/cli-registry/registry.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
|
||||
*/
|
||||
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(),
|
||||
// Reliable-delivery dedup (optional; absent for curl/legacy clients). The web
|
||||
// client tags each input with a stable clientId + a monotonic per-session seq
|
||||
|
||||
@@ -123,7 +123,13 @@ describe('App Settings modal structure', () => {
|
||||
const select = modal.match(/id="appSettingsClaudeModel"([\s\S]*?)<\/select>/)?.[1] ?? '';
|
||||
// The cards render the base models; the [1m] rows exist so that base + the
|
||||
// context switch can compose back into a real claudeModel value.
|
||||
for (const value of ['opus[1m]', 'claude-fable-5[1m]', 'claude-fable-5-1[1m]', 'claude-opus-4-6[1m]']) {
|
||||
for (const value of [
|
||||
'opus[1m]',
|
||||
'claude-fable-5[1m]',
|
||||
'claude-fable-5-1[1m]',
|
||||
'claude-opus-5-5[1m]',
|
||||
'claude-opus-4-6[1m]',
|
||||
]) {
|
||||
expect(select).toContain(`value="${value}"`);
|
||||
}
|
||||
expect(select).toContain('data-ctx="1"');
|
||||
@@ -148,6 +154,22 @@ describe('App Settings modal structure', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('models: offers Opus 5.5 as a card and to task routing', () => {
|
||||
const modal = settingsModal();
|
||||
const select = modal.match(/id="appSettingsClaudeModel"([\s\S]*?)<\/select>/)?.[1] ?? '';
|
||||
expect(select).toMatch(/value="claude-opus-5-5"[^>]*data-ctx="1"/);
|
||||
for (const id of [
|
||||
'appSettingsDefaultModel',
|
||||
'appSettingsModelExplore',
|
||||
'appSettingsModelImplement',
|
||||
'appSettingsModelTest',
|
||||
'appSettingsModelReview',
|
||||
]) {
|
||||
const routing = modal.match(new RegExp(`id="${id}"([\\s\\S]*?)</select>`))?.[1] ?? '';
|
||||
expect(routing, `${id} does not offer Opus 5.5`).toContain('value="claude-opus-5-5"');
|
||||
}
|
||||
});
|
||||
|
||||
it('has retired the modal-tab chrome everywhere, not just here', () => {
|
||||
// Session Options and Add Case moved onto this same `set-*` surface, so the
|
||||
// old tab classes have no users left. A reappearance means a modal drifted
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
const ws = await connectWs('/ws/sessions/ws-test-session/terminal');
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user