mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
chore: version packages
Give the active-session handoff one owner: closeSession captures wasActive before its await and the session_deleted handler stands down for a close this tab started. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,15 @@
|
||||
# aicodeman
|
||||
|
||||
## 1.19.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Closing the session you are looking at now always moves you to the next tab.
|
||||
|
||||
The delete request and its own `session_deleted` broadcast raced each other: the close path selected the next tab, while the broadcast handler cleared the active session and showed the home screen, and whichever ran first decided what you saw. On one build, closing a tab either switched sessions or dumped you on the welcome screen depending on timing. The close now owns that handoff from beginning to end, and the broadcast handler stays out of the way for a close started in that tab. A session deleted from somewhere else still returns you to the home screen, which is the honest answer when what you were looking at was taken away.
|
||||
|
||||
The next tab is also picked from sessions that still exist, so a stale entry in the tab order can no longer name a tab that is already gone.
|
||||
|
||||
## 1.19.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -74,7 +74,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.19.4 (must match `package.json`)
|
||||
**Version**: 1.19.5 (must match `package.json`)
|
||||
|
||||
## Project Overview
|
||||
|
||||
@@ -202,7 +202,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior off (Ralph tracker, BashToolParser, token/CLI-info parsing, ❯-prompt readiness); these CLIs render their own TUIs, so readiness is output stabilization instead. All five **require tmux with no direct PTY fallback**, because secrets are injected via socket-scoped `tmux setenv` and never on the spawn command line. ⚠️ `run*()` in `session-ui.js` MUST unwrap the `{success,data}` envelope; reading the raw shape silently breaks the run. ⚠️ **Codex sessions use PREDICTIVE WRITE-THROUGH echo, never the buffer overlay** (`_localEchoPolicy` in `_updateLocalEchoState`, terminal-ui.js): codex's composer reacts per keystroke ("/" pops a live-filtering picker, arrows edit server-side state, the composer grows as it wraps), so buffer-until-Enter starved it into issues #218/#219/#220/#222 and stays disabled (`_localEchoEnabled` remains false for codex). Instead, `PredictiveEchoAddon` (separate `vendor/xterm-predictive-echo.js` bundle) paints each keystroke at the predicted cell while the wire path stays BYTE-IDENTICAL: the onData hook (`_predictHookOnData`) is a plain statement with no `return`, so control always falls through into the untouched send path — pinned by vm and E2E byte-identity tests. Predictions reconcile against the parsed buffer and only while the cursor sits on the measured composer row (`isCodexComposerRow`, `/^› /`). Codex also **drops keystrokes that share a PTY read with a bracketed paste**, so flushed text and the paste sequence must go out as separate delayed writes (mirroring the Enter branch's delayed `\r`). Tests: `test/local-echo-codex-gating.test.ts`, `test/codex-predictive-echo.test.ts` (E2E vs real codex), `packages/xterm-zerolag-input/test/codex-replay.test.ts`. ⚠️ **Pi is the opposite kind of CLI and needs the opposite instincts**: it has NO permission prompts and no sandbox, so there is no bypass flag to send and Codeman must not invent one; its privileged knob is the tri-state `approveProjectTrust` (`--approve`/`--no-approve`), which makes pi EXECUTE repo-local `.pi/extensions` TypeScript, so the multi-user clamp puts pi in the **materialize** branch (an absent config still yields `--no-approve` for a non-granted owner) and `--api-key` is never wired. Pi stays OUT of `isAltScreenStripMode()` (main-screen TUI, and its 0.84.0 fullscreen mode is runtime-switchable via `/settings`, where the alt screen is load-bearing), and lands on the `'buffer'` echo policy via the `_updateLocalEchoState` fallthrough. Pi's own tests: `test/pi-mode.test.ts`, `test/routes/external-cli-bypass-clamp.test.ts`; user guide `docs/pi-integration.md`. → [architecture-invariants#external-cli-modes-opencode-codex-gemini-antigravity-pi](docs/architecture-invariants.md#external-cli-modes-opencode-codex-gemini-antigravity-pi)
|
||||
|
||||
**Run launch synchronization**: the Run entrypoint holds an in-flight lock and disables `#runBtn` for the whole launch (≥500ms), so a double click cannot create duplicate sessions with the same `w<n>-<case>` name. `_ensureCreatedSessionVisible()` runs before `selectSession()`, and `_onSessionCreated()` stays an idempotent upsert, so POST-first and SSE-first ordering both produce exactly one rendered tab. → [architecture-invariants#run-launch-synchronization](docs/architecture-invariants.md#run-launch-synchronization)
|
||||
**Run launch synchronization**: the Run entrypoint holds an in-flight lock and disables `#runBtn` for the whole launch (≥500ms), so a double click cannot create duplicate sessions with the same `w<n>-<case>` name. `_ensureCreatedSessionVisible()` runs before `selectSession()`, and `_onSessionCreated()` stays an idempotent upsert, so POST-first and SSE-first ordering both produce exactly one rendered tab. ⚠️ **Closing has the mirror-image race and one owner**: `closeSession()` reads `wasActive` BEFORE its `await` and announces the delete via `_closingSessions`, while `_onSessionDeleted` skips the active-session handoff for an id in that set. Both used to read `activeSessionId` after the fact, so the `session_deleted` broadcast for your own delete could null it first and closing the tab you were on landed on the welcome screen instead of the next session, on the same build, depending on timing. The fallback also picks the first order entry that is still in `sessions` (a dead id can linger in `sessionOrder`, same reason Alt+N indexes a live-filtered list). A delete from ANOTHER client still shows the welcome screen, which is the honest answer when what you were looking at was taken away. Tests: `test/session-close-fallback.test.ts`. → [architecture-invariants#run-launch-synchronization](docs/architecture-invariants.md#run-launch-synchronization)
|
||||
|
||||
**Session lineage lines** (tab → tab it spawned, `sessionLineageLines`, per-device, desktop default ON): a create request may name the session that spawned it, as a `parentSessionId` body field on `POST /api/sessions` / `POST /api/quick-start` or the `X-Codeman-Parent-Session` header (the agent skill sets that once on its shared curl invocation, so every spawn recipe carries it). `resolveParentSessionId()` (route-helpers.ts) **resolves rather than trusts** it: exact id, else a UNIQUE ≥8-char prefix (ids reach agents truncated), it must be a live session the caller can see AND carry the same owner, and **anything unresolvable is DROPPED, never a 400** — a cosmetic field must not be able to fail a worker spawn. It rides `toState()` into `session_created`, so there is no new SSE event. ⚠️ Rendering is an ADDITIONAL LAYER on the existing SVG pass (`_appendLineageConnectionLines` called at the tail of `_updateConnectionLinesImmediate()`, exactly like ultracode), sharing one batched read→write reflow and the `tab:<id>` rect cache; geometry is pure in `computeLineagePath()` (constants.js). ⚠️ **ONE shape, and the second one was the bug**: every pair (flat strip or wrapped) gets a U-bridge hanging below the strip, anchored on both tabs' BOTTOM edges. A wrapped strip used to get a parent-bottom → child-TOP bezier with a ~14px row gap to bend in, which drew a flat line hidden in the gap with siblings overprinting. ⚠️ The dip is a **mis-tuned-in-both-directions corridor** (44px cap = straight thread at strip-wide spans, #285; 104px cap + full row offset = ~106px over-bow into the terminal, 2026-08-15): it now hangs from the **STRIP's bottom edge** (fallback: lower tab bottom), capped at 64px, with NO per-row offsets stacked on top — the strip-bottom baseline is also what keeps a row-1 pair's arc from drawing through row 2's tab labels. Colors cycle per CHILD in first-seen order from `CodemanLineage.COLORS` (first entry empty = the skin-tuned `--session-blue`; the rest vivid fixed hexes), set inline as `--lineage-color` so styles.css keeps owning opacity/glow/dash. ⚠️ **Desktop only**: the overlay is `z-index: 999` and the desktop header is 100 (arcs paint over it, which is what lets them touch tab bottoms), but under 1024px mobile.css makes the header `fixed; z-index: 1200` and would bury them. ⚠️ Paths carry `data-agent-id="lineage:<childId>"` because that is what `_applyLineEntrances()` queries — that one attribute is what gives them the entrance animation and its negative-`animation-delay` resume across `svg.innerHTML=''`. ⚠️ `.session-tabs` is `overflow-x: auto`, so a scrolled-out tab still HAS a rect (over the logo); edges with an endpoint outside the strip are skipped, and a passive `scroll` listener re-anchors the rest.
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.19.4",
|
||||
"version": "1.19.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "1.19.4",
|
||||
"version": "1.19.5",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.19.4",
|
||||
"version": "1.19.5",
|
||||
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
||||
+31
-5
@@ -652,6 +652,11 @@ class CodemanApp {
|
||||
// Tracks pending hook events that need resolution (permission_prompt, elicitation_dialog, idle_prompt)
|
||||
this.pendingHooks = new Map();
|
||||
|
||||
// Sessions THIS tab is closing right now. closeSession() owns the follow-up
|
||||
// selection, so _onSessionDeleted must not race its own delete's SSE
|
||||
// broadcast to the welcome screen. Set<sessionId>, cleared in a finally.
|
||||
this._closingSessions = new Set();
|
||||
|
||||
// Approvals Inbox: Map<approvalId, ApprovalItem> (methods in approvals-ui.js)
|
||||
this.approvals = new Map();
|
||||
|
||||
@@ -1813,7 +1818,13 @@ class CodemanApp {
|
||||
// Dashboard: a detached session ended → clear its detached state/timers.
|
||||
if (this.detachedSessions.has(data.id)) this._redock(data.id);
|
||||
this._cleanupSessionData(data.id);
|
||||
if (this.activeSessionId === data.id) {
|
||||
// ⚠️ Skip the whole active-session handoff while THIS tab is closing that
|
||||
// session: closeSession() owns the follow-up selection and moves you to the
|
||||
// next tab, so acting here would race it and flash the welcome screen (or
|
||||
// strand you on it) for a close the user initiated right here. A delete from
|
||||
// anywhere else still lands on the home screen, which is the honest answer
|
||||
// when the thing you were looking at was taken away.
|
||||
if (this.activeSessionId === data.id && !this._closingSessions.has(data.id)) {
|
||||
this.activeSessionId = null;
|
||||
try { localStorage.removeItem('codeman-active-session'); } catch {}
|
||||
this.terminal.clear();
|
||||
@@ -5721,18 +5732,31 @@ class CodemanApp {
|
||||
}
|
||||
|
||||
async closeSession(sessionId, killMux = true) {
|
||||
// ⚠️ Captured BEFORE the await, and the delete is announced to
|
||||
// _onSessionDeleted through _closingSessions. The `session_deleted` SSE
|
||||
// broadcast for THIS delete routinely lands while the request is still in
|
||||
// flight, and that handler nulls activeSessionId and shows the welcome
|
||||
// screen. Re-reading the field after the await therefore made the fallback
|
||||
// below a coin flip: closing the tab you were on either moved you to the
|
||||
// next session or dumped you on the home screen, depending on which path
|
||||
// won the race (both outcomes measured on one build, 2026-08-17).
|
||||
const wasActive = this.activeSessionId === sessionId;
|
||||
this._closingSessions.add(sessionId);
|
||||
try {
|
||||
await this._apiDelete(`/api/sessions/${sessionId}?killMux=${killMux}`);
|
||||
this._cleanupSessionData(sessionId);
|
||||
|
||||
if (this.activeSessionId === sessionId) {
|
||||
if (wasActive) {
|
||||
this.activeSessionId = null;
|
||||
try { localStorage.removeItem('codeman-active-session'); } catch {}
|
||||
// Select another session or show welcome (use sessionOrder for consistent ordering)
|
||||
if (this.sessionOrder.length > 0 && this.sessions.size > 0) {
|
||||
// Next tab in the user's own order, skipping ids the cleanup has not
|
||||
// caught up with yet: sessionOrder can transiently hold a dead id
|
||||
// (delete racing the order sync), which is the same reason Alt+N
|
||||
// indexes a live-filtered list rather than sessionOrder directly.
|
||||
const nextSessionId = this.sessionOrder.find((id) => id !== sessionId && this.sessions.has(id));
|
||||
if (nextSessionId) {
|
||||
// `auto`: this tab was chosen by the app because the previous one
|
||||
// went away, so it must not spend that session's idle alert.
|
||||
const nextSessionId = this.sessionOrder[0];
|
||||
this.selectSession(nextSessionId, { auto: true });
|
||||
} else {
|
||||
this.terminal.clear();
|
||||
@@ -5750,6 +5774,8 @@ class CodemanApp {
|
||||
}
|
||||
} catch (err) {
|
||||
this.showToast('Failed to close session', 'error');
|
||||
} finally {
|
||||
this._closingSessions.delete(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* @fileoverview Closing the session you are looking at moves you to the next
|
||||
* tab, deterministically.
|
||||
*
|
||||
* `closeSession()` and the `session_deleted` SSE handler both react to the same
|
||||
* delete, and the broadcast routinely lands while the DELETE request is still in
|
||||
* flight. Both used to read `this.activeSessionId` and act on it, so whichever
|
||||
* won decided what the user saw: the SSE handler nulls the field and shows the
|
||||
* welcome screen, which then made closeSession's own "select the next tab"
|
||||
* branch a no-op. Closing a tab therefore either switched sessions or dumped you
|
||||
* on the home screen, on the same build, depending on timing (measured
|
||||
* 2026-08-17 while testing the idle-alert gate).
|
||||
*
|
||||
* The fix is one owner per outcome: closeSession captures `wasActive` BEFORE the
|
||||
* await and announces the delete through `_closingSessions`, and the SSE handler
|
||||
* leaves the active-session handoff alone for a close this tab started. A delete
|
||||
* from anywhere else still lands on the welcome screen.
|
||||
*
|
||||
* Loaded via `vm` with a stubbed context (no jsdom), like input-send-order.test.ts.
|
||||
* Port: N/A.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
function loadCodemanAppClass() {
|
||||
const dir = resolve(import.meta.dirname, '../src/web/public');
|
||||
const constants = readFileSync(resolve(dir, 'constants.js'), 'utf8');
|
||||
const source = readFileSync(resolve(dir, 'app.js'), 'utf8');
|
||||
const context = vm.createContext({
|
||||
console: { ...console, log: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
performance,
|
||||
setInterval: vi.fn(),
|
||||
clearInterval: vi.fn(),
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
requestAnimationFrame: vi.fn(),
|
||||
HTMLCanvasElement: class HTMLCanvasElement {},
|
||||
WebSocket: { OPEN: 1 },
|
||||
fetch: vi.fn(),
|
||||
document: { addEventListener: vi.fn(), getElementById: () => null, querySelector: () => null },
|
||||
localStorage: { length: 0, key: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
|
||||
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
|
||||
MobileDetection: { isTouchDevice: () => false },
|
||||
});
|
||||
vm.runInContext(`${constants}\n${source}\nglobalThis.__CodemanApp = CodemanApp;`, context);
|
||||
return (context as { __CodemanApp: new () => unknown }).__CodemanApp;
|
||||
}
|
||||
|
||||
const CodemanApp = loadCodemanAppClass();
|
||||
const A = 'session-a';
|
||||
const B = 'session-b';
|
||||
|
||||
type TestApp = Record<string, unknown> & {
|
||||
closeSession: (id: string, killMux?: boolean) => Promise<void>;
|
||||
_onSessionDeleted: (data: { id: string }) => void;
|
||||
selectSession: ReturnType<typeof vi.fn>;
|
||||
showWelcome: ReturnType<typeof vi.fn>;
|
||||
activeSessionId: string | null;
|
||||
sessionOrder: string[];
|
||||
sessions: Map<string, unknown>;
|
||||
};
|
||||
|
||||
/** Instance with the session bookkeeping real and everything visual stubbed. */
|
||||
function makeApp(active: string | null, order = [A, B]): TestApp {
|
||||
const app = Object.create((CodemanApp as { prototype: object }).prototype) as TestApp;
|
||||
app.activeSessionId = active;
|
||||
app.sessionOrder = [...order];
|
||||
app.sessions = new Map(order.map((id) => [id, { id }]));
|
||||
app._closingSessions = new Set();
|
||||
app.detachedSessions = new Set();
|
||||
app.isSoloWindow = false;
|
||||
app._wsSessionId = null;
|
||||
app.terminal = { clear: vi.fn() };
|
||||
app._apiDelete = vi.fn(async () => ({ success: true }));
|
||||
// The real one touches ~20 maps; the parts this behavior depends on are the
|
||||
// session map and the tab order, so those are pruned for real.
|
||||
app._cleanupSessionData = vi.fn((id: string) => {
|
||||
app.sessions.delete(id);
|
||||
const i = app.sessionOrder.indexOf(id);
|
||||
if (i !== -1) app.sessionOrder.splice(i, 1);
|
||||
});
|
||||
app.selectSession = vi.fn();
|
||||
app.showWelcome = vi.fn();
|
||||
app.renderSessionTabs = vi.fn();
|
||||
app.renderRalphStatePanel = vi.fn();
|
||||
app.renderProjectInsightsPanel = vi.fn();
|
||||
app.stopSystemStatsPolling = vi.fn();
|
||||
app.showToast = vi.fn();
|
||||
app._disconnectWs = vi.fn();
|
||||
app._redock = vi.fn();
|
||||
return app;
|
||||
}
|
||||
|
||||
describe('closing the active session', () => {
|
||||
it('moves to the next tab when the SSE broadcast arrives DURING the delete', async () => {
|
||||
const app = makeApp(A);
|
||||
// The losing order that used to strand the user: the broadcast for this very
|
||||
// delete lands before the request resolves.
|
||||
app._apiDelete = vi.fn(async () => {
|
||||
app._onSessionDeleted({ id: A });
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
await app.closeSession(A);
|
||||
|
||||
expect(app.selectSession).toHaveBeenCalledWith(B, { auto: true });
|
||||
expect(app.showWelcome).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('moves to the next tab when the broadcast arrives AFTER the delete', async () => {
|
||||
const app = makeApp(A);
|
||||
|
||||
await app.closeSession(A);
|
||||
expect(app.selectSession).toHaveBeenCalledWith(B, { auto: true });
|
||||
|
||||
// The late broadcast must not bounce the user off the tab they just landed on.
|
||||
app.activeSessionId = B;
|
||||
app._onSessionDeleted({ id: A });
|
||||
expect(app.showWelcome).not.toHaveBeenCalled();
|
||||
expect(app.activeSessionId).toBe(B);
|
||||
});
|
||||
|
||||
it('skips ids the cleanup has not caught up with', async () => {
|
||||
const app = makeApp(A, [A, 'ghost', B]);
|
||||
app.sessions.delete('ghost'); // in the order, already gone from the session map
|
||||
|
||||
await app.closeSession(A);
|
||||
|
||||
expect(app.selectSession).toHaveBeenCalledWith(B, { auto: true });
|
||||
});
|
||||
|
||||
it('falls back to the welcome screen when nothing is left', async () => {
|
||||
const app = makeApp(A, [A]);
|
||||
|
||||
await app.closeSession(A);
|
||||
|
||||
expect(app.selectSession).not.toHaveBeenCalled();
|
||||
expect(app.showWelcome).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('closing a session you are NOT on changes nothing on screen', async () => {
|
||||
const app = makeApp(B, [A, B]);
|
||||
|
||||
await app.closeSession(A);
|
||||
|
||||
expect(app.selectSession).not.toHaveBeenCalled();
|
||||
expect(app.showWelcome).not.toHaveBeenCalled();
|
||||
expect(app.activeSessionId).toBe(B);
|
||||
});
|
||||
|
||||
it('a delete from ANOTHER client still shows the welcome screen', () => {
|
||||
// Nobody here initiated it, so there is no follow-up selection to own: the
|
||||
// honest answer is that what you were looking at is gone.
|
||||
const app = makeApp(A);
|
||||
|
||||
app._onSessionDeleted({ id: A });
|
||||
|
||||
expect(app.activeSessionId).toBeNull();
|
||||
expect(app.showWelcome).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stops tracking the close once it finishes, even when the delete throws', async () => {
|
||||
const app = makeApp(A);
|
||||
app._apiDelete = vi.fn(async () => {
|
||||
throw new Error('network');
|
||||
});
|
||||
|
||||
await app.closeSession(A);
|
||||
|
||||
expect((app._closingSessions as Set<string>).size).toBe(0);
|
||||
expect(app.showToast).toHaveBeenCalledWith('Failed to close session', 'error');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user