Merge #510 feat(adopt): adopt tmux sessions a human started, in all three locations

The attach line now takes the rule #770 gave remote and docker launches: bashCArg() single-quotes it at both respawn-pane sites, so it reaches bash verbatim. quoteAdoptLaunch is retired, since its double quotes kept the same $ and backtick safety but lost a real newline. #510's no-cd condition stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-06 05:36:20 +01:00
commit c65706a3e4
64 files changed
+5321 -103

No files matched your search

+2
View File
@@ -46,6 +46,8 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
**Desktop home tab rail** (`home-sessions.js`, desktop only): the welcome overlay's left gutter carries the open tabs as a rail docked flush left, full height, in overview order, each row showing `created … · <state> <duration>` from `_mobileOverviewSince()`; state classification is reused from mobile-overview.js (so it loads after it). ⚠️ The number badge is the Alt+1..9 tab-strip index, never renumber it to row position. ⚠️ The width gate lives in two places that must stay equal: `HOME_SESSIONS_MIN_WIDTH` (1180) and a `max-width: 1179px` media query. ⚠️ `.home-sessions[hidden]` must re-assert `display: none`. ⚠️ Size all children in `em` off the one `clamp()` knob, never `rem`/px. Age stamps tick in place (`_tickHomeSessionsTimes()`), never by re-render. Test: `test/home-sessions.test.ts`. → [architecture-invariants#desktop-home-tab-rail](../../docs/architecture-invariants.md#desktop-home-tab-rail)
**Opened outside Codeman** (`foreign-sessions.js`, both home screens): the tmux sessions a human started, listed from `GET /api/mux/foreign` (root CLAUDE.md, Adopted tmux sessions). ONE renderer, `renderForeignSessions(container)`, fills the welcome block and the phone overview's, and every row goes through `textContent`, since a session name and a command line come from someone else's process. ⚠️ It polls only while a home screen is up (`startForeignPolling`/`stopForeignPolling` from `showWelcome()`/`hideWelcome()`), and the poll is local only: the docker and ssh scan costs one connection per target, so it runs once on the toggle and again on Rescan, and its rows and notes are kept apart (`_foreignWideSessions`, `_foreignWideNotes`) or the next local poll would wipe them. ⚠️ A 403 is a non-admin in multi-user mode, an answer and not a failed poll: `_foreignRefused` stops the polling and shows no block. ⚠️ `closeSession()` re-reads the list after an adopted tab's DELETE, since the home screen's own load races it and the row would say `Go to tab` until the next poll. ⚠️ An adopted tab has no kill outcome to offer, so the close dialog hides the red button and always tears the wrapper down. ⚠️ `session.remote` and `session.docker` are undefined on an adopted session, so a check that hides a row for those two usually needs `session.adopt` as well. Tests: `test/foreign-sessions-ui.test.ts`, `test/foreign-sessions-i18n.test.ts` (harvests every string from the real module).
**Home-screen session order** (`CodemanSessionOrder` in constants.js, pure): BOTH home screens (phone overview, desktop rail) must order rows through this ONE comparator. Rank `needs` → `error` → `waiting` → `working` → `idle` → `done`. ⚠️ The tiebreak flips: states a session is still IN sort oldest-first, states it has STOPPED sort newest-first. ⚠️ The running group keys off `lastSubmitAt`, never `lastActivityAt` (a working pane repaints constantly). ⚠️ A 0 stamp means unknown and sorts last within its state. Final tiebreak is `orderIndex`, so the list never shuffles. The tab strip itself is NOT sorted by this. Test: `test/session-overview-order.test.ts`. → [architecture-invariants#home-screen-session-order](../../docs/architecture-invariants.md#home-screen-session-order)
**Welcome "Resume Conversation" list** (terminal-ui.js): `loadHistorySessions()` fetches once and caches the corpus on `_historyAll`/`_historyCases`; every subsequent view (filter box, sort select, expand, the periodic refresh in panels-ui.js) goes through `_renderHistoryList()`, so never append rows to `#historyList` directly or re-fetch to re-sort. ⚠️ The box height is **class-driven**: expanding the list without `.history-list.expanded` leaves the collapsed `max-height` in place and just deepens a scroll well, which is the bug #260 reported (35 sessions in a ~4-row box). ⚠️ The A–Z sort keys off `_historyRowLabel()`, the SAME string the row renders (`name || firstPrompt || path`), most rows are transcript-backed and have no session name, so sorting on `name` alone silently does nothing. ⚠️ A filter implies expansion, and `_renderSearch()` hides `#historyHeader` (title + controls) as one unit while a search is active. Tests: `test/history-list-controls.test.ts`.
+3
View File
@@ -79,6 +79,8 @@ export interface EditHistorySession {
mode: string;
remote: SessionRemote | undefined;
docker?: unknown;
/** Set on a wrapper around a foreign tmux session: Codeman holds no transcript id for what runs there. */
adopt?: unknown;
claudeSessionId: string | null;
/** The session's own `CLAUDE_CONFIG_DIR` (Codeman env override), if it has one. */
claudeConfigDir?: string;
@@ -129,6 +131,7 @@ export function remoteTranscriptIds(session: EditHistorySession, storedTranscrip
/** Why a session has no edit history, or undefined when it can have one. */
export function unsupportedReason(session: EditHistorySession): string | undefined {
if (session.docker) return 'docker';
if (session.adopt) return 'adopted';
if (getCli(session.mode)?.capabilities.transcript !== 'claude-jsonl') return 'mode';
return undefined;
}
+5 -3
View File
@@ -57,7 +57,9 @@ export const DISCORD_RECEIVED_DIR = '.codeman-discord';
* and is listed, or the uploads of a workspace like `~/.codeman/app` would
* never be collected), and nothing for a remote (SSH) session, whose
* workingDir is the remote path and would name a same-named LOCAL directory
* here. The working directory is a user-chosen path, so it is probed BOUNDED
* here, or for an adopted session, whose workingDir is the neutral `/tmp` that
* other sessions may really work in (it receives no uploads). The working
* directory is a user-chosen path, so it is probed BOUNDED
* first (#516): one on a mount that stopped answering reads `unknown` and is
* skipped, never touched. The sweep keeps the probe's stall cap; the delete,
* acting on one path at the user's request, passes `pastCap`. The check is
@@ -66,10 +68,10 @@ export const DISCORD_RECEIVED_DIR = '.codeman-discord';
* can.
*/
export async function uploadDirs(
session: { workingDir: string; remote?: unknown },
session: { workingDir: string; remote?: unknown; adopt?: unknown },
probe: PathProbeOptions = {}
): Promise<string[]> {
if (session.remote) return [];
if (session.remote || session.adopt) return [];
if ((await probePathKind(session.workingDir, probe)) !== 'directory') return [];
const dataDir = await realDir(getDataDir());
const dirs: string[] = [];
+37 -4
View File
@@ -10308,11 +10308,11 @@ class CodemanApp {
// response re-reports the count, so a pane that starts keeping history
// (claude switched to its inline view) goes back to the tail on the next
// switch.
// A remote (ssh) or Docker session's local pane hosts a nested tmux client in the
// alternate screen, so it reports 0 while its byte tail is real scrollback.
// A remote (ssh), Docker or adopted session's local pane hosts a nested tmux client in
// the alternate screen, so it reports 0 while its byte tail is real scrollback.
const shellSession = session?.mode === 'shell';
const paneKeepsNoHistory =
!session?.remote && !session?.docker && this._paneHistoryLines?.get(sessionId) === 0;
!session?.remote && !session?.docker && !session?.adopt && this._paneHistoryLines?.get(sessionId) === 0;
const useFullHistory = !shellSession && (paneKeepsNoHistory || !this._fullHistoryLoaded.has(sessionId));
if (useFullHistory) this._fullHistoryLoaded.add(sessionId);
const fetchStartedAt = performance.now();
@@ -10920,6 +10920,9 @@ class CodemanApp {
// An SSE resync (handleInit) that landed mid-request rebuilds the list from
// the server, which still had the session then.
if (this.sessions.has(sessionId)) this._onSessionDeleted({ id: sessionId });
// The home screen read the outside-sessions list when the tab went, before this
// delete landed, so its row would say "Go to tab" with no tab until the next poll.
if (session?.adopt) void this.loadForeignSessions?.();
if (killMux) {
this.showToast('Session closed and tmux killed', 'success');
} else {
@@ -10955,6 +10958,25 @@ class CodemanApp {
const sessionNameEl = document.getElementById('closeConfirmSessionName');
sessionNameEl.textContent = name;
// ⚠️ An ADOPTED session has no "kill" outcome to offer. Its wrapper is the
// only thing Codeman owns; the server's killSession refuses to reach the
// foreign server at all (see the adopted early return in tmux-manager). So
// the red "Terminate the session completely" option is not merely redundant
// here, it is a false promise about someone else's live work — and the kind
// of false promise that stops a user closing the tab at all. Hide it, and say
// what actually happens instead.
const killBtn = document.getElementById('closeConfirmKillBtn');
const keepTitle = document.getElementById('closeConfirmKeepTitle');
const keepDesc = document.getElementById('closeConfirmKeepDesc');
const adopted = !!session.adopt;
if (killBtn) killBtn.style.display = adopted ? 'none' : '';
if (keepTitle) keepTitle.textContent = adopted ? 'Close Tab' : 'Remove Tab';
if (keepDesc) {
keepDesc.textContent = adopted
? 'Detaches only. The tmux session keeps running for whoever started it.'
: 'Tmux session keeps running in background';
}
// A session in a Codeman-managed git worktree also offers to remove the worktree
// (git worktree remove, never forced) once the session is gone. Read now, before the
// session leaves this.sessions.
@@ -11014,7 +11036,18 @@ class CodemanApp {
this.cancelCloseSession();
if (sessionId) {
const gone = await this.closeSession(sessionId, killMux);
// ⚠️ For an ADOPTED session `killMux` does not mean what it means everywhere
// else. There is no agent of ours to terminate: the only thing it can reach
// is the WRAPPER we created, and the server's adopted branch in
// `killSession` tears that down without ever touching the foreign server.
// So "keep the tmux session running" has no useful meaning here: taking it
// literally leaves our wrapper AND its grouped view session parked on the
// user's own socket forever, one pair per tab they ever closed (measured:
// `codeman-view-*` survived every close and the row stayed "Go to tab" with
// no tab behind it). Always tear the wrapper down; the foreign session is
// structurally out of reach either way.
const adopted = !!this.sessions.get(sessionId)?.adopt;
const gone = await this.closeSession(sessionId, adopted ? true : killMux);
if (gone && worktree) await this._removeWorktreeAfterClose?.(worktree, deleteBranch);
}
}
+459
View File
@@ -0,0 +1,459 @@
/**
* @fileoverview The "sessions someone opened by hand" block on the home screen.
*
* A tmux session a human started (`tmux new -s work`, then `claude`, or just a
* shell) is invisible to Codeman: it lives on the DEFAULT socket, not the
* instance-scoped one Codeman owns. `GET /api/mux/foreign` finds those, and one
* click wraps one in a Codeman tab (`POST /api/sessions/adopt`).
*
* ## One renderer, two containers
*
* The welcome screen and the phone overview both show this list. They call the
* SAME `renderForeignSessions(container)` rather than each building rows, because
* two renderers are how one surface ends up calling a session "shell" while the
* other calls it "bash". Same reason `CodemanSessionOrder` is shared.
*
* ## Polling only while the home screen is up
*
* The list is a poll, not an SSE stream: the server caches a local scan on a TTL,
* so N tabs polling costs one scan per TTL no matter how many are open. But an
* open tab that has LEFT the home screen must stop — otherwise every background
* tab keeps sweeping tmux sockets forever. `startForeignPolling` /
* `stopForeignPolling` are called from `showWelcome`/`hideWelcome`.
*
* ⚠️ Docker and remote locations are NOT in that poll. Each costs a `docker exec`
* or a full ssh handshake per target, each with a ~12s timeout, so riding them
* along on an 8s tick stacks connections on a slow host — the one cost the
* server-side design explicitly refuses. The wide scan is therefore ONE-SHOT:
* turning the toggle on fetches once, and after that it is the user's Rescan
* button. The periodic poll stays local-only forever.
*
* The wide rows are kept separately (`_foreignWideSessions`) and merged into
* every local render, because a local-only poll's payload does not contain them
* and would otherwise wipe the container/remote rows off the list one tick after
* they appeared. Turning the toggle off discards them.
*
* ⚠️ Adoption creates a REAL session, so the button locks while in flight and the
* result goes through the app's normal idempotent create path
* (`_onSessionCreated` then `selectSession`) — never by inserting a tab here.
*
* @mixin Extends CodemanApp.prototype via Object.assign
* @dependency app.js (this.sessions, _onSessionCreated, selectSession)
* @dependency api-client.js (_api)
* @dependency ralph-panel.js (formatRelativeTime)
* @dependency i18n.js (codemanT, optional)
* @loadorder 12.565 of 16, after home-sessions.js, before git-status-ui.js
*/
/** Fallback poll cadence; the server sends its own in `pollIntervalMs`. */
const FOREIGN_POLL_FALLBACK_MS = 8000;
/** i18n.js's translator, or the English source where it has not loaded. */
function foreignT(source) {
return typeof codemanT === 'function' ? codemanT(source) : source;
}
/** A row's mode, in the registry's own word for it. Falls back to the id, never a list of ids here. */
function foreignModeLabel(mode) {
const catalog =
typeof window !== 'undefined' && Array.isArray(window.__codemanCliCatalog) ? window.__codemanCliCatalog : [];
return catalog.find((entry) => entry.id === mode)?.label || mode;
}
const FOREIGN_LOCATION_LABEL = {
local: 'this machine',
docker: 'container',
remote: 'remote',
};
Object.assign(CodemanApp.prototype, {
// ═══════════════════════════════════════════════════════════════
// Lifecycle
// ═══════════════════════════════════════════════════════════════
/**
* Begin polling. Idempotent: the home screen re-shows on several paths
* (boot, closing the last tab, a breakpoint change) and each would otherwise
* stack another interval on top of the last.
*/
startForeignPolling() {
if (this._foreignPollTimer || this._foreignRefused) return;
void this.loadForeignSessions();
this._foreignPollTimer = setInterval(() => {
// Local only — see @fileoverview. The wide scan never rides the timer.
void this.loadForeignSessions();
}, this._foreignPollMs || FOREIGN_POLL_FALLBACK_MS);
},
/** Stop polling. MUST run on leaving the home screen — see @fileoverview. */
stopForeignPolling() {
if (!this._foreignPollTimer) return;
clearInterval(this._foreignPollTimer);
this._foreignPollTimer = null;
},
/** Whether the user asked for the expensive docker/remote scan too. */
foreignScanRemote() {
return this._foreignScanRemote === true;
},
/**
* Flip the wide scan. ON fetches containers and hosts ONCE; OFF discards what
* that fetch found, because those rows are not re-confirmed by any later poll
* and leaving them up would show a container session that may be long gone.
*/
toggleForeignScanRemote() {
this._foreignScanRemote = !this._foreignScanRemote;
if (!this._foreignScanRemote) {
this._foreignWideSessions = [];
this._foreignWideNotes = [];
this.renderAllForeignSessions();
return;
}
void this.loadForeignSessions({ wide: true });
},
/** Re-run the expensive scan on demand. The only way wide rows ever refresh. */
rescanForeignWide() {
if (!this.foreignScanRemote()) return;
void this.loadForeignSessions({ wide: true });
},
// ═══════════════════════════════════════════════════════════════
// Data
// ═══════════════════════════════════════════════════════════════
/**
* Fetch the list. `wide` asks the server for containers and ssh hosts too, and
* ONLY an explicit user action ever passes it — never the interval.
*/
async loadForeignSessions({ wide = false } = {}) {
if (this._foreignRefused) return;
const qs = wide ? '?docker=1&remote=1' : '';
if (wide) this._foreignWideLoading = true;
this.renderAllForeignSessions();
const res = await this._api(`/api/mux/foreign${qs}`);
if (wide) this._foreignWideLoading = false;
// ⚠️ A 403 is an answer, not a failed poll. The list is admin-only in multi-user
// mode (it shows other people's processes), so a non-admin has nothing to see here
// and nothing later on this page changes that: stop asking and show no block,
// instead of "Could not reach the server" under a heading they cannot use.
if (res?.status === 403) {
this._foreignRefused = true;
this.stopForeignPolling();
this.renderAllForeignSessions();
return;
}
const body = res?.ok ? await this._readJsonBody(res) : null;
const data = body?.success === true ? body.data : body?.success === false ? null : body;
if (!data) {
// A failed poll must not blank a list the user is looking at: keep the
// last good result and let the next tick recover.
this._foreignError = true;
this.renderAllForeignSessions();
return;
}
this._foreignError = false;
const rows = Array.isArray(data.sessions) ? data.sessions : [];
const notes = Array.isArray(data.notes) ? data.notes : [];
// Every note opens with its location (foreign-tmux-discovery.ts). A local one is
// re-read on each poll; a container or host one only by the next explicit scan,
// so it has to outlive the local polls in between.
const isLocalNote = (note) => String(note).startsWith('local:');
this._foreignNotes = notes.filter(isLocalNote);
if (wide) {
// Split by location: the local rows refresh on every tick, the rest only
// here, so they have to be stored apart or the next local-only payload
// (which cannot contain them) would read as "those sessions are gone".
this._foreignSessions = rows.filter((r) => (r.location || 'local') === 'local');
this._foreignWideSessions = rows.filter((r) => (r.location || 'local') !== 'local');
this._foreignWideNotes = notes.filter((note) => !isLocalNote(note));
this._foreignWideAt = Date.now();
} else {
this._foreignSessions = rows;
}
this._foreignCanScanWide = data.canScanWide === true;
if (Number.isFinite(data.pollIntervalMs)) this._foreignPollMs = data.pollIntervalMs;
this.renderAllForeignSessions();
},
/** Local rows plus whatever the last wide scan found. The render's one input. */
_allForeignRows() {
const local = Array.isArray(this._foreignSessions) ? this._foreignSessions : [];
if (!this.foreignScanRemote()) return local;
const wide = Array.isArray(this._foreignWideSessions) ? this._foreignWideSessions : [];
return wide.length ? local.concat(wide) : local;
},
/**
* Adopt one candidate.
*
* The in-flight lock is global, one adoption at a time from this page: it only
* stops a double-click. The server holds the real one-wrapper-per-target
* guarantee.
*/
async adoptForeignSession(id, buttonEl) {
if (this._foreignAdoptInFlight) return;
this._foreignAdoptInFlight = true;
if (buttonEl) {
buttonEl.disabled = true;
buttonEl.textContent = foreignT('Opening…');
}
try {
const body = { id };
if (this.foreignScanRemote()) {
body.docker = true;
body.remote = true;
}
// ⚠️ `_api`, not `_apiJson`: the latter folds a network failure, a non-2xx
// and `success:false` all into null, and the server deliberately tells
// two different stories here — "that tmux session is no longer there" vs
// "could not reach it just now (…) — try again". Collapsing them showed a
// live remote session as deleted every time the link flickered.
const res = await this._api('/api/sessions/adopt', { method: 'POST', body });
const payload = await this._readJsonBody(res);
const session = payload && payload.success !== false ? payload.data?.session : null;
if (!session) {
const fallback = foreignT('Could not open that session. Refreshing the list.');
this.showToast?.(payload?.error || fallback, 'error');
await this.loadForeignSessions();
return;
}
const data = { session };
// Go through the app's normal create path so tab order, lineage lines and
// SSE-vs-POST ordering behave exactly as they do for a Run.
this._onSessionCreated?.(data.session);
await this.selectSession(data.session.id);
// Still one explicit user action, so a wide row's "Open" refreshes the
// wide list too — otherwise the row just adopted keeps reading as free.
await this.loadForeignSessions({ wide: this.foreignScanRemote() });
} finally {
this._foreignAdoptInFlight = false;
if (buttonEl) {
buttonEl.disabled = false;
buttonEl.textContent = foreignT('Open');
}
}
},
/** The error envelope, or null when there was no readable body at all. */
async _readJsonBody(res) {
if (!res) return null;
try {
return await res.json();
} catch {
return null;
}
},
// ═══════════════════════════════════════════════════════════════
// Render
// ═══════════════════════════════════════════════════════════════
/** Paint every mounted container (welcome + phone overview). */
renderAllForeignSessions() {
for (const id of ['foreignSessions', 'mobileForeignSessions']) {
const el = document.getElementById(id);
if (el) this.renderForeignSessions(el);
}
},
/**
* Render into one container. The ONLY row builder — see @fileoverview.
*
* Everything is built with DOM APIs and `textContent`: a session name and a
* command line come from another user's process, so they are never allowed
* near `innerHTML`.
*/
renderForeignSessions(container) {
const rows = this._allForeignRows();
container.innerHTML = '';
if (this._foreignRefused) {
container.hidden = true;
return;
}
if (!rows.length) {
// Hidden rather than shown-empty: on a first run there is usually nothing,
// and an empty block on the welcome screen reads as a broken feature.
// A note is a REASON the list is empty, so a block carrying one must stay
// visible — hiding it is exactly how "where did my session go" becomes
// unanswerable.
const hasNotes =
(Array.isArray(this._foreignNotes) && this._foreignNotes.length > 0) ||
(Array.isArray(this._foreignWideNotes) && this._foreignWideNotes.length > 0);
// ⚠️ `canScanWide` must keep the block visible even with nothing to show.
// The scan toggle lives in the header, so hiding an empty block also hides
// the only control that could fill it — on a host with containers but no
// local tmux sessions that made the whole feature unreachable.
container.hidden = !this.foreignScanRemote() && !this._foreignError && !hasNotes && !this._foreignCanScanWide;
if (!container.hidden) {
container.appendChild(this._foreignHeader(0));
const empty = document.createElement('div');
empty.className = 'foreign-empty';
this._appendForeignNotes(container);
empty.textContent = this._foreignError
? foreignT('Could not reach the server.')
: foreignT('No sessions found outside Codeman.');
container.appendChild(empty);
}
return;
}
container.hidden = false;
container.appendChild(this._foreignHeader(rows.length));
this._appendForeignNotes(container);
const list = document.createElement('div');
list.className = 'foreign-list';
// Adoptable first: an already-open one is not an action, it is a reminder.
const sorted = rows.slice().sort((a, b) => {
const aOpen = a.adoptedBy ? 1 : 0;
const bOpen = b.adoptedBy ? 1 : 0;
if (aOpen !== bOpen) return aOpen - bOpen;
return (b.createdAt || 0) - (a.createdAt || 0);
});
for (const row of sorted) list.appendChild(this._foreignRow(row));
container.appendChild(list);
},
/**
* Why a scan produced less than the user expected.
*
* Without this, a session skipped for an unsafe name or a host that could not
* be reached is simply ABSENT, and "my tmux session does not show up" has no
* answer anywhere in the product. Notes are server-authored strings, rendered
* as text.
*/
_appendForeignNotes(container) {
const local = Array.isArray(this._foreignNotes) ? this._foreignNotes : [];
const wide = this.foreignScanRemote() && Array.isArray(this._foreignWideNotes) ? this._foreignWideNotes : [];
const notes = wide.length ? local.concat(wide) : local;
if (!notes.length) return;
const box = document.createElement('div');
box.className = 'foreign-notes';
box.setAttribute('data-i18n-skip', '');
for (const n of notes) {
const line = document.createElement('div');
line.className = 'foreign-note';
line.textContent = n;
box.appendChild(line);
}
container.appendChild(box);
},
_foreignHeader(count) {
const header = document.createElement('div');
header.className = 'foreign-header';
const title = document.createElement('h3');
title.className = 'foreign-title';
title.textContent = foreignT('Opened outside Codeman');
header.appendChild(title);
if (count) {
const badge = document.createElement('span');
badge.className = 'foreign-count';
badge.setAttribute('data-i18n-skip', '');
badge.textContent = String(count);
header.appendChild(badge);
}
const scan = document.createElement('button');
scan.type = 'button';
scan.className = 'foreign-scan-toggle';
scan.dataset.foreignAction = 'toggle-scan';
scan.setAttribute('aria-pressed', String(this.foreignScanRemote()));
scan.title = foreignT('Also scan containers and remote hosts (slower)');
scan.textContent = this.foreignScanRemote() ? '⟳ all' : '⟳ local';
header.appendChild(scan);
// The wide scan is one-shot, so without this button its rows could only
// ever be refreshed by toggling off and on again.
if (this.foreignScanRemote()) {
const rescan = document.createElement('button');
rescan.type = 'button';
rescan.className = 'foreign-scan-toggle';
rescan.dataset.foreignAction = 'rescan-wide';
rescan.disabled = this._foreignWideLoading === true;
rescan.title = foreignT('Scan containers and remote hosts again');
rescan.textContent = this._foreignWideLoading ? foreignT('Scanning…') : foreignT('Rescan');
header.appendChild(rescan);
}
return header;
},
_foreignRow(row) {
const item = document.createElement('div');
item.className = 'foreign-row';
if (row.adoptedBy) item.classList.add('foreign-row--open');
const dot = document.createElement('span');
dot.className = `foreign-dot foreign-dot--${row.mode || 'shell'}`;
item.appendChild(dot);
const body = document.createElement('span');
body.className = 'foreign-row-body';
const line1 = document.createElement('span');
line1.className = 'foreign-row-name';
line1.setAttribute('data-i18n-skip', '');
line1.textContent = row.sessionName || '(unnamed)';
body.appendChild(line1);
const line2 = document.createElement('span');
line2.className = 'foreign-row-sub';
line2.setAttribute('data-i18n-skip', '');
const bits = [foreignModeLabel(row.mode)];
const where = row.hostLabel || foreignT(FOREIGN_LOCATION_LABEL[row.location] || row.location);
if (where) bits.push(where);
if (row.workingDir) bits.push(row.workingDir);
line2.textContent = bits.join(' · ');
line2.title = row.command || '';
body.appendChild(line2);
item.appendChild(body);
const action = document.createElement('button');
action.type = 'button';
action.className = 'foreign-open-btn';
if (row.adoptedBy) {
action.dataset.foreignAction = 'select';
action.dataset.foreignSession = row.adoptedBy;
action.textContent = foreignT('Go to tab');
} else {
action.dataset.foreignAction = 'adopt';
action.dataset.foreignId = row.id;
action.textContent = foreignT('Open');
}
item.appendChild(action);
return item;
},
/**
* One delegated listener per container, wired once. Delegation matters here
* because the list is fully rebuilt on every poll — per-button listeners would
* leak one set per tick.
*/
wireForeignSessions() {
if (this._foreignWired) return;
this._foreignWired = true;
document.addEventListener('click', (event) => {
const el = event.target?.closest?.('[data-foreign-action]');
if (!el) return;
const action = el.dataset.foreignAction;
if (action === 'adopt') {
void this.adoptForeignSession(el.dataset.foreignId, el);
} else if (action === 'select') {
void this.selectSession(el.dataset.foreignSession);
} else if (action === 'toggle-scan') {
this.toggleForeignScanRemote();
} else if (action === 'rescan-wide') {
this.rescanForeignWide();
}
});
},
});
+5 -3
View File
@@ -425,9 +425,11 @@ Object.assign(CodemanApp.prototype, {
overview.state === 'not-a-repo'
? 'No git repository here: this session’s folder is not one, and none was found inside it (up to two levels down).'
: overview.state === 'unsupported'
? overview.reason === 'docker'
? 'Git status is not available for Docker sessions, or for folders inside a Docker case workspace.'
: 'Git status is not available for remote (SSH) sessions.'
? overview.reason === 'adopted'
? 'Git status is not available for a tmux session opened outside Codeman.'
: overview.reason === 'docker'
? 'Git status is not available for Docker sessions, or for folders inside a Docker case workspace.'
: 'Git status is not available for remote (SSH) sessions.'
: `Could not read the repository: ${overview.error || 'git failed'}`;
body.append(el('div', 'git-status-empty', why));
clearChrome();
+23
View File
@@ -1379,6 +1379,29 @@
'Remove Tab': '移除标签页',
'Remove All Tabs': '移除所有标签页',
'Use arrows to reorder. Changes are saved automatically.': '使用方向键重新排序;更改会自动保存。',
// Sessions opened outside Codeman (foreign-sessions.js, the close dialog and Session Options).
'Opened outside Codeman': '在 Codeman 之外打开',
'Also scan containers and remote hosts (slower)': '同时扫描容器和远程主机(较慢)',
'Scan containers and remote hosts again': '重新扫描容器和远程主机',
'⟳ all': '⟳ 全部',
'⟳ local': '⟳ 本机',
'Scanning…': '扫描中…',
Rescan: '重新扫描',
'Opening…': '正在打开…',
'Go to tab': '转到标签页',
'this machine': '本机',
container: '容器',
remote: '远程',
'No sessions found outside Codeman.': '未发现在 Codeman 之外打开的会话。',
'Could not reach the server.': '无法连接服务器。',
'Could not open that session. Refreshing the list.': '无法打开该会话,正在刷新列表。',
'Close Tab': '关闭标签页',
'Detaches only. The tmux session keeps running for whoever started it.':
'仅断开连接。该 tmux 会话会继续运行,供其启动者使用。',
'Where the tmux session was when it was opened here. It was started outside Codeman and keeps running if this tab closes.':
'该 tmux 会话在此处打开时所在的位置。它是在 Codeman 之外启动的,关闭此标签页后仍会继续运行。',
'Git status is not available for a tmux session opened outside Codeman.':
'在 Codeman 之外打开的 tmux 会话无法使用 Git 状态。',
});
const ZH_CN_LOWER = new Map(Object.entries(ZH_CN).map(([key, value]) => [key.toLocaleLowerCase('en'), value]));
+11 -4
View File
@@ -583,6 +583,12 @@
</div>
<div class="history-list" id="historyList"></div>
</div>
<!-- Sessions a human started outside Codeman (foreign-sessions.js).
Hidden until a scan finds one; never rendered empty on a fresh
install, where an empty block reads as a broken feature. -->
<div class="welcome-foreign">
<div class="foreign-sessions" id="foreignSessions" hidden></div>
</div>
<p class="welcome-hint">Or click Run to start</p>
<button class="welcome-ralph-link" onclick="app.showRalphWizard()">Start Ralph Loop &rarr;</button>
</div>
@@ -1834,11 +1840,11 @@
<p class="modal-session-name" id="closeConfirmSessionName"></p>
</div>
<div class="close-options">
<button class="close-option" onclick="app.confirmCloseSession(false)">
<span class="close-option-title">Remove Tab</span>
<span class="close-option-desc">Tmux session keeps running in background</span>
<button class="close-option" onclick="app.confirmCloseSession(false)" id="closeConfirmKeepBtn">
<span class="close-option-title" id="closeConfirmKeepTitle">Remove Tab</span>
<span class="close-option-desc" id="closeConfirmKeepDesc">Tmux session keeps running in background</span>
</button>
<button class="close-option close-option-danger" onclick="app.confirmCloseSession(true)">
<button class="close-option close-option-danger" onclick="app.confirmCloseSession(true)" id="closeConfirmKillBtn">
<span class="close-option-title" id="closeConfirmKillTitle">Kill Tmux & Claude Code</span>
<span class="close-option-desc">Terminate the session completely</span>
</button>
@@ -4676,6 +4682,7 @@
<script defer src="webview-tabs.js"></script>
<script defer src="mobile-overview.js"></script>
<script defer src="home-sessions.js"></script>
<script defer src="foreign-sessions.js"></script>
<script defer src="git-status-ui.js"></script>
<script defer src="edit-history-ui.js"></script>
<script defer src="entrance-animations.js"></script>
+10
View File
@@ -508,6 +508,16 @@ Object.assign(CodemanApp.prototype, {
)
);
// Sessions a human opened outside Codeman. Its own container, rebuilt by the
// ONE renderer in foreign-sessions.js — the phone must not grow a second row
// builder that could describe the same session differently from the desktop.
const foreign = document.createElement('div');
foreign.className = 'foreign-sessions mobile-foreign-sessions';
foreign.id = 'mobileForeignSessions';
foreign.hidden = true;
el.appendChild(foreign);
this.renderForeignSessions?.(foreign);
el.appendChild(
this._buildMobileOverviewSection(
'Past sessions',
+12
View File
@@ -4217,6 +4217,18 @@ html[data-session-list="sidebar"] .session-sidebar .session-tab .tab-close {
}
}
/* Foreign sessions block inside the phone overview (foreign-sessions.js).
The desktop block sits inside the welcome column; here it is a full-width
section between CURRENT and PAST, so it only needs the surrounding spacing —
every row style is shared with styles.css on purpose. */
.mobile-foreign-sessions {
margin: 0.75rem 0.75rem 0;
}
.mobile-foreign-sessions .foreign-list {
max-height: none;
}
/* Folding devices, tabletop pose: cap the response viewer to the bottom
segment. Twin of the rule at the end of styles.css, needed here because the
phone block (under 600px) above sets `max-height: 92dvh` on the same selector at the same
+23 -17
View File
@@ -2757,8 +2757,9 @@ Object.assign(CodemanApp.prototype, {
/** Copy the open session's working folder (Session Options > Session > Working folder). */
async copySessionPath() {
const session = this.sessions.get(this.editingSessionId);
if (!session?.workingDir) return;
const ok = await this._copyText(session.workingDir);
const path = session?.adopt?.paneCurrentPath || session?.workingDir;
if (!path) return;
const ok = await this._copyText(path);
this.showToast(ok ? 'Path copied' : 'Copy failed', ok ? 'success' : 'error');
},
@@ -2833,8 +2834,9 @@ Object.assign(CodemanApp.prototype, {
this.updateGitFetchStatus(sessionId);
const imageWatcherToggle = document.getElementById('modalImageWatcherEnabled');
imageWatcherToggle.checked = session.imageWatcherEnabled ?? true;
// The server never watches a remote session's directory (it is on another host), so the row is hidden.
imageWatcherToggle.closest('.set-row').style.display = session.remote ? 'none' : '';
// The server never watches a remote session's directory (it is on another host), nor an adopted
// session's (a neutral wrapper folder), so the row is hidden.
imageWatcherToggle.closest('.set-row').style.display = session.remote || session.adopt ? 'none' : '';
document.getElementById('modalFlickerFilterEnabled').checked = session.flickerFilterEnabled ?? false;
// Populate session name input with prefix/suffix split
@@ -2853,17 +2855,21 @@ Object.assign(CodemanApp.prototype, {
}
// Working folder: the path the CLI was launched in on the Codeman host
// (an adopted session's is the neutral wrapper folder, so the row shows where the pane was)
const _pathEl = document.getElementById('modalSessionPath');
_pathEl.textContent = session.workingDir || '';
_pathEl.title = session.workingDir || '';
document.getElementById('modalSessionPathCopy').disabled = !session.workingDir;
const _shownPath = session.adopt?.paneCurrentPath || session.workingDir || '';
_pathEl.textContent = _shownPath;
_pathEl.title = _shownPath;
document.getElementById('modalSessionPathCopy').disabled = !_shownPath;
// A remote session's workingDir is the REMOTE path; a docker session's is the
// host bind-mount source the container sees.
document.getElementById('modalSessionPathDesc').textContent = session.remote
? "Where this session's CLI runs, on the remote host it reaches over SSH, not on this machine."
: session.docker
? "On the machine hosting Codeman, mounted into the Docker container this session's CLI runs in."
: "Where this session's CLI runs, on the machine hosting Codeman.";
document.getElementById('modalSessionPathDesc').textContent = session.adopt
? 'Where the tmux session was when it was opened here. It was started outside Codeman and keeps running if this tab closes.'
: session.remote
? "Where this session's CLI runs, on the remote host it reaches over SSH, not on this machine."
: session.docker
? "On the machine hosting Codeman, mounted into the Docker container this session's CLI runs in."
: "Where this session's CLI runs, on the machine hosting Codeman.";
// Initialize color picker with current session color
const currentColor = session.color || 'default';
@@ -2890,11 +2896,11 @@ Object.assign(CodemanApp.prototype, {
if (respawnTabBtn) respawnTabBtn.style.display = '';
}
// Git is local git only: a remote (SSH) or Docker case answers 'unsupported' on both git routes and is
// never fetched by the interval scheduler, so its Git tab has nothing to say and its rail entry goes
// hidden the same way.
// Git is local git only: a remote (SSH) or Docker case, or an adopted session, answers 'unsupported' on
// both git routes and is never fetched by the interval scheduler, so its Git tab has nothing to say and
// its rail entry goes hidden the same way.
const gitTabBtn = document.querySelector('#sessionOptionsModal .set-rail-item[data-tab="git"]');
if (gitTabBtn) gitTabBtn.style.display = session.remote || session.docker ? 'none' : '';
if (gitTabBtn) gitTabBtn.style.display = session.remote || session.docker || session.adopt ? 'none' : '';
// Populate Ralph Wiggum form with current session values (skip for external CLI sessions)
if (!isExternalCli) {
@@ -6112,7 +6118,7 @@ Object.assign(CodemanApp.prototype, {
/** The Codeman-managed worktree a session runs in, or null (see managedWorktreeOfDir). */
_managedWorktreeOfSession(session) {
if (!session || session.remote || session.docker) return null;
if (!session || session.remote || session.docker || session.adopt) return null;
return managedWorktreeOfDir(session.workingDir, this.cases);
},
+170
View File
@@ -22133,6 +22133,176 @@ html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.til
color: var(--text-muted);
}
/* ═══════════════════════════════════════════════════════════════
Foreign sessions — tmux sessions a human started outside Codeman
(foreign-sessions.js). Rendered on the welcome screen and, with the
same row builder, inside the phone overview.
Colour vocabulary is deliberately the session-tab one: a mode dot on
the left, name over a dim meta line, action pinned right. A block that
invented its own language here would read as a different product.
═══════════════════════════════════════════════════════════════ */
.welcome-foreign {
width: 100%;
margin-top: 0.75rem;
}
.foreign-sessions {
display: flex;
flex-direction: column;
gap: 0.4rem;
text-align: left;
}
/* `.foreign-sessions` is a flex container, so `[hidden]` needs re-asserting or
the module's only visibility lever does nothing (same trap as .home-sessions). */
.foreign-sessions[hidden] {
display: none;
}
.foreign-header {
display: flex;
align-items: center;
gap: 0.5rem;
}
.foreign-title {
font-size: 0.85rem;
color: var(--text-dim);
font-weight: 500;
text-align: left;
}
.foreign-count {
font-size: 0.68rem;
color: var(--text-dim);
background: rgba(255, 255, 255, 0.05);
border-radius: 999px;
padding: 0.1rem 0.45rem;
white-space: nowrap;
}
.foreign-scan-toggle {
margin-left: auto;
font-size: 0.68rem;
color: var(--text-dim);
background: transparent;
border: 1px solid var(--border);
border-radius: 999px;
padding: 0.12rem 0.5rem;
cursor: pointer;
}
.foreign-scan-toggle[aria-pressed='true'] {
color: var(--session-blue, #4a9eff);
border-color: var(--session-blue, #4a9eff);
}
.foreign-list {
display: flex;
flex-direction: column;
gap: 0.3rem;
max-height: min(40vh, 320px);
overflow-y: auto;
}
.foreign-row {
display: flex;
align-items: center;
gap: 0.55rem;
padding: 0.4rem 0.55rem;
border: 1px solid var(--border);
border-radius: 6px;
background: rgba(255, 255, 255, 0.02);
min-width: 0;
}
.foreign-row--open {
opacity: 0.62;
}
.foreign-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex: 0 0 auto;
background: var(--text-muted, #888);
}
.foreign-dot--claude {
background: #d97757;
}
.foreign-dot--codex {
background: #9b8cff;
}
.foreign-dot--shell {
background: #4caf7d;
}
.foreign-row-body {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1 1 auto;
}
.foreign-row-name {
font-size: 0.82rem;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.foreign-row-sub {
font-size: 0.68rem;
color: var(--text-dim);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.foreign-open-btn {
flex: 0 0 auto;
font-size: 0.7rem;
padding: 0.22rem 0.6rem;
border-radius: 5px;
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.04);
color: var(--text);
cursor: pointer;
}
.foreign-open-btn:hover:not(:disabled) {
border-color: var(--session-blue, #4a9eff);
color: var(--session-blue, #4a9eff);
}
.foreign-open-btn:disabled {
opacity: 0.55;
cursor: default;
}
.foreign-empty {
font-size: 0.72rem;
color: var(--text-dim);
padding: 0.3rem 0.1rem;
}
.foreign-notes {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.foreign-note {
font-size: 0.66rem;
color: var(--text-dim);
opacity: 0.85;
line-height: 1.35;
}
/* XLSX preview in the file-preview overlay (spreadsheet-preview.js). Read-only; cells are
absolutely positioned inside a virtualized grid, so only the visible tile is in the DOM. */
.spreadsheet-preview-shell {
+9 -1
View File
@@ -2943,6 +2943,9 @@ Object.assign(CodemanApp.prototype, {
if (overlay) overlay.classList.remove('visible');
this.hideHomeSessions?.();
this.showMobileOverview();
// The phone overview hosts the same list in its own container.
this.wireForeignSessions?.();
this.startForeignPolling?.();
this._updateCjkInputState?.();
return;
}
@@ -2957,6 +2960,10 @@ Object.assign(CodemanApp.prototype, {
// Open tabs down the left gutter. Self-gating: a window too narrow to hold
// the column without overlapping the content leaves it hidden.
this.showHomeSessions?.();
// Sessions a human opened outside Codeman. Polls only while this screen is
// up (stopped in hideWelcome) — see foreign-sessions.js.
this.wireForeignSessions?.();
this.startForeignPolling?.();
}
// Home screen has no input target — hide the CJK textarea (activeSessionId
// is null by the time we get here). Guarded: defined on the app object.
@@ -2966,6 +2973,7 @@ Object.assign(CodemanApp.prototype, {
hideWelcome() {
this.hideMobileOverview?.();
this.hideHomeSessions?.();
this.stopForeignPolling?.();
const overlay = document.getElementById('welcomeOverlay');
if (overlay) {
overlay.classList.remove('visible');
@@ -6706,7 +6714,7 @@ Object.assign(CodemanApp.prototype, {
_keyboardHoldEligible(sessionId = this.activeSessionId) {
if (this.loadAppSettingsFromStorage?.()?.keyboardHoldsRows === false) return false;
const session = sessionId ? this.sessions?.get(sessionId) : null;
if (!session || session.remote || session.docker || session.cliMouseTracking) return false;
if (!session || session.remote || session.docker || session.adopt || session.cliMouseTracking) return false;
const modes = window.__codemanKeyboardHoldModes;
return Array.isArray(modes) && modes.includes(session.mode || 'claude');
},
+1 -1
View File
@@ -197,7 +197,7 @@ export function registerDiscordRoutes(app: FastifyInstance, deps: DiscordRouteDe
}
let upload: { name: string; data: Uint8Array } | undefined;
if (file !== undefined) {
const problem = await fileProblem(session.workingDir, !!session.remote, file);
const problem = await fileProblem(session.workingDir, !!session.remote || session.isAdopted, file);
if (typeof problem === 'string') {
refundNotifyToken(session.id);
reply.code(400);
+1
View File
@@ -99,6 +99,7 @@ export function registerEditHistoryRoutes(app: FastifyInstance, ctx: SessionPort
mode: session.mode,
remote: session.remote,
docker: session.docker,
adopt: session.adopt,
claudeSessionId: session.claudeSessionId,
claudeConfigDir: session.claudeConfigDir(),
};
+8 -2
View File
@@ -81,6 +81,8 @@ export function registerGitStatusRoutes(
const session = findSessionOrFail(ctx, id, req);
if (session.remote) return { success: true, data: emptyOverview('unsupported', { reason: 'remote' }) };
if (session.docker) return { success: true, data: emptyOverview('unsupported', { reason: 'docker' }) };
// An adopted wrapper's folder is the neutral `/tmp`, so a scan would run git on whatever sits there.
if (session.isAdopted) return { success: true, data: emptyOverview('unsupported', { reason: 'adopted' }) };
return {
success: true,
data: await getGitWorkspaceOverview(session.workingDir, {
@@ -103,9 +105,12 @@ export function registerGitStatusRoutes(
const { repo, path, kind } = query;
const limits = limitsFrom(query);
const session = findSessionOrFail(ctx, id, req);
if (session.remote || session.docker) {
if (session.remote || session.docker || session.isAdopted) {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Git is not available for remote or Docker sessions');
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'Git is not available for remote, Docker or adopted sessions'
);
}
const repoRoot = repo
? await findWorkspaceRepo(session.workingDir, repo, {
@@ -159,6 +164,7 @@ export function registerGitStatusRoutes(
const session = findSessionOrFail(ctx, id, req);
if (session.remote) return { success: true, data: emptyReport('unsupported', { reason: 'remote' }) };
if (session.docker) return { success: true, data: emptyReport('unsupported', { reason: 'docker' }) };
if (session.isAdopted) return { success: true, data: emptyReport('unsupported', { reason: 'adopted' }) };
// Fail CLOSED on a Docker case list that cannot be read: a fetch runs the repository's hooks, so it never
// runs without knowing which folders a container can write to. Answered as a failed run (`state: 'error'`),
// published like one, so the Session Options Git row shows it.
+64 -3
View File
@@ -1,15 +1,25 @@
/**
* @fileoverview Mux (tmux) session management routes.
* Provides mux session listing, killing, reconciliation, and stats control.
* Provides mux session listing, killing, reconciliation, stats control, and
* discovery of FOREIGN tmux sessions (ones a human started outside Codeman).
*
* Discovery lives here rather than beside the adopt endpoint on purpose: like
* every other route in this file it exposes cross-user process state — other
* people's session names, commands and working directories — so it inherits the
* admin gate this file already applies. Adoption is a session CREATE and stays in
* `session-routes.ts`, where the owner, capacity and case-space gates live.
*/
import { FastifyInstance } from 'fastify';
import type { InfraPort } from '../ports/index.js';
import type { InfraPort, SessionPort } from '../ports/index.js';
import { STATS_COLLECTION_INTERVAL_MS } from '../../config/server-timing.js';
import { requireAdmin } from '../route-helpers.js';
import { isMultiUserMode } from '../../config/multiuser.js';
import { discoverForeignSessions, readAllDockerCases, readAllRemoteHosts } from '../../foreign-tmux-discovery.js';
import { adoptTargetIdentity, foreignTargetKey } from '../../foreign-tmux.js';
import { FOREIGN_POLL_INTERVAL_MS } from '../../config/foreign-tmux.js';
export function registerMuxRoutes(app: FastifyInstance, ctx: InfraPort): void {
export function registerMuxRoutes(app: FastifyInstance, ctx: InfraPort & SessionPort): void {
app.get('/api/mux-sessions', async (req, reply) => {
// Multi-user: this recovery/debug surface exposes every user's tmux + workdirs → admin-only
// (requireAdmin is a no-op allow-all in single-user mode, so flag-off is unchanged).
@@ -36,6 +46,57 @@ export function registerMuxRoutes(app: FastifyInstance, ctx: InfraPort): void {
return result;
});
/**
* Foreign tmux sessions available for adoption.
*
* LOCAL results are always included and are TTL-cached, because the home screen
* polls this endpoint while it is open. DOCKER and REMOTE are opt-in per
* request (`?docker=1`, `?remote=1`): each costs one `docker exec` or one ssh
* per target, and having the home page fan those out on every load is the one
* cost this design refuses to pay.
*
* `adoptedBy` is filled from the live sessions, so a target Codeman already wraps
* renders as "open" rather than offering a second wrapper. Sessions and not mux
* records: the tmux record only exists once the tab's first attach starts the pane.
*/
app.get('/api/mux/foreign', async (req, reply) => {
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
const q = (req.query ?? {}) as Record<string, string | undefined>;
const wantDocker = q.docker === '1' || q.docker === 'true';
const wantRemote = q.remote === '1' || q.remote === 'true';
// Read the registries either way: `canScanWide` tells the browser whether the
// expensive scan has anywhere to go. Without it the UI hides an empty block —
// and with it the toggle that is the ONLY way to populate that block, which on
// a host with containers but no local tmux sessions made the feature invisible.
const dockerCases = await readAllDockerCases();
const remoteHosts = await readAllRemoteHosts();
const result = await discoverForeignSessions({
local: true,
force: q.force === '1',
dockerCases: wantDocker ? dockerCases : undefined,
remoteHosts: wantRemote ? remoteHosts : undefined,
});
// The same key the adopt route's one-wrapper check reads (see foreignTargetKey).
const wrapped = new Map<string, string>();
for (const session of ctx.sessions.values()) {
if (session.adopt) wrapped.set(foreignTargetKey(adoptTargetIdentity(session.adopt)), session.id);
}
return {
sessions: result.sessions.map((f) => ({
...f,
adoptedBy: wrapped.get(foreignTargetKey(f)),
})),
scannedAt: result.scannedAt,
notes: result.notes,
pollIntervalMs: FOREIGN_POLL_INTERVAL_MS,
canScanWide: dockerCases.length > 0 || remoteHosts.length > 0,
};
});
app.post('/api/mux-sessions/stats/start', async (req, reply) => {
// Multi-user: process-wide stats collection toggle → admin-only.
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
+23
View File
@@ -37,6 +37,15 @@ import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from
/** Cap on a remote `@fix_plan.md` read over ssh (a task list, not a document). */
const MAX_REMOTE_FIX_PLAN_BYTES = 1024 * 1024;
/**
* An adopted session's `workingDir` is the neutral wrapper folder `/tmp`, not a workspace.
* Using it would write a predictable name into a folder anyone can write to, and read one
* back, while the agent in the pane never sees either file.
*/
function refuseAdoptedRalphFile(file: string) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `${file} is not available for an adopted session`);
}
export function registerRalphRoutes(
app: FastifyInstance,
ctx: SessionPort & EventPort & RespawnPort & ConfigPort & InfraPort
@@ -60,6 +69,17 @@ export function registerRalphRoutes(
};
const session = findSessionOrFail(ctx, id, req);
// ⚠️ Adoption gate, kept SEPARATE from the external-CLI gate above: an adopted
// session can be `mode: 'claude'` and still be a process we never launched.
// Everything below drives the pane on the assumption Codeman owns what runs
// in it — sending `/clear`, killing and relaunching the agent — which against
// someone else's live session is destructive, not merely unsupported.
if (session.isAdopted) {
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'The Ralph tracker is not available for adopted sessions: Codeman did not start this agent and must not drive its lifecycle'
);
}
// Ralph tracker is not supported for external-CLI sessions (opencode/codex)
if (isExternalCliMode(session.mode)) {
return createErrorResponse(
@@ -197,6 +217,7 @@ export function registerRalphRoutes(
app.post('/api/sessions/:id/fix-plan/write', async (req) => {
const { id } = req.params as { id: string };
const session = findSessionOrFail(ctx, id, req);
if (session.isAdopted) return refuseAdoptedRalphFile('@fix_plan.md');
// A remote session's workingDir is a path on the remote host. Writing it with local fs would land
// the file in a same-named local folder (or fail), and remote writes are out of scope by design
// (src/remote-files.ts is read-only), so it is refused, like the File Viewer's remote edit.
@@ -233,6 +254,7 @@ export function registerRalphRoutes(
app.post('/api/sessions/:id/fix-plan/read', async (req) => {
const { id } = req.params as { id: string };
const session = findSessionOrFail(ctx, id, req);
if (session.isAdopted) return refuseAdoptedRalphFile('@fix_plan.md');
const workingDir = session.workingDir;
if (!workingDir) {
@@ -280,6 +302,7 @@ export function registerRalphRoutes(
const { id } = req.params as { id: string };
const { content } = parseBody(RalphPromptWriteSchema, req.body, 'Invalid request body');
const session = findSessionOrFail(ctx, id, req);
if (session.isAdopted) return refuseAdoptedRalphFile('@ralph_prompt.md');
// Same rule as fix-plan/write: never write a remote session's file on the local disk.
if (session.remote) {
return createErrorResponse(
+3 -2
View File
@@ -91,8 +91,9 @@ export function registerReadMyMindRoutes(app: FastifyInstance, ctx: SessionPort
const transcriptPath = ctx.getTranscriptPath(id);
const transcript = transcriptPath ? await readTranscriptSignals(transcriptPath) : null;
// Remote-SSH cases skip git: workingDir is not local. Docker cases are
// fine (the workspace is bind-mounted at the same host path).
const workspace = session.remote ? null : await collectWorkspaceSignals(session.workingDir);
// fine (the workspace is bind-mounted at the same host path). An adopted
// session's workingDir is a neutral wrapper folder, so it skips git too.
const workspace = session.remote || session.isAdopted ? null : await collectWorkspaceSignals(session.workingDir);
const lastPromptTs = profile.recentPrompts[profile.recentPrompts.length - 1]?.ts;
const tracker = ctx.runSummaryTrackers.get(id);
+33
View File
@@ -98,6 +98,17 @@ export function registerRespawnRoutes(
}
const session = findSessionOrFail(ctx, id, req);
// ⚠️ Adoption gate, kept SEPARATE from the external-CLI gate above: an adopted
// session can be `mode: 'claude'` and still be a process we never launched.
// Everything below drives the pane on the assumption Codeman owns what runs
// in it — sending `/clear`, killing and relaunching the agent — which against
// someone else's live session is destructive, not merely unsupported.
if (session.isAdopted) {
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'Respawn is not available for adopted sessions: Codeman did not start this agent and must not drive its lifecycle'
);
}
// Respawn is not supported for external-CLI sessions (opencode/codex)
if (isExternalCliMode(session.mode)) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Respawn is not supported for ${session.mode} sessions`);
@@ -241,6 +252,17 @@ export function registerRespawnRoutes(
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
}
// ⚠️ Adoption gate, kept SEPARATE from the external-CLI gate above: an adopted
// session can be `mode: 'claude'` and still be a process we never launched.
// Everything below drives the pane on the assumption Codeman owns what runs
// in it — sending `/clear`, killing and relaunching the agent — which against
// someone else's live session is destructive, not merely unsupported.
if (session.isAdopted) {
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'Respawn is not available for adopted sessions: Codeman did not start this agent and must not drive its lifecycle'
);
}
// Respawn is not supported for external-CLI sessions (opencode/codex)
if (isExternalCliMode(session.mode)) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Respawn is not supported for ${session.mode} sessions`);
@@ -310,6 +332,17 @@ export function registerRespawnRoutes(
const body = reResult.data as { config?: Partial<RespawnConfig>; durationMinutes?: number };
const session = findSessionOrFail(ctx, id, req);
// ⚠️ Adoption gate, kept SEPARATE from the external-CLI gate above: an adopted
// session can be `mode: 'claude'` and still be a process we never launched.
// Everything below drives the pane on the assumption Codeman owns what runs
// in it — sending `/clear`, killing and relaunching the agent — which against
// someone else's live session is destructive, not merely unsupported.
if (session.isAdopted) {
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'Respawn is not available for adopted sessions: Codeman did not start this agent and must not drive its lifecycle'
);
}
// Respawn is not supported for external-CLI sessions (opencode/codex)
if (isExternalCliMode(session.mode)) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Respawn is not supported for ${session.mode} sessions`);
+267 -3
View File
@@ -34,6 +34,17 @@ import {
type OmpConfig,
type RemoteHost,
} from '../../types.js';
import { AdoptForeignSessionSchema } from '../schemas.js';
import {
discoverForeignSessions,
invalidateForeignCache,
readAllDockerCases,
readAllRemoteHosts,
} from '../../foreign-tmux-discovery.js';
import { adoptTargetIdentity, foreignTargetKey, foreignViewSessionName } from '../../foreign-tmux.js';
import { requireAdmin } from '../route-helpers.js';
import type { SessionAdopt } from '../../types/session.js';
import type { ForeignTmuxSession } from '../../types/foreign-tmux.js';
import {
GuardedInputBusyError,
Session,
@@ -1490,6 +1501,187 @@ export function registerSessionRoutes(
};
});
// ========== Adopt a foreign tmux session ==========
/**
* The wrapper pane for an adopted session runs `tmux attach`, never the agent,
* so it needs no workspace, and must not borrow the foreign one. Same value
* `resolveMuxAttachCwd()` gives remote and docker panes.
*/
const ADOPTED_WRAPPER_WORKING_DIR = '/tmp';
/**
* Targets with a wrapper being created right now. The live-session check alone
* leaves a gap: between it and `addSession` the route awaits the host registries,
* the history config and the owner's layout lock, so two devices opening one row
* could each pass it.
*/
const adoptsInFlight = new Set<string>();
/**
* Wrap a tmux session a HUMAN started (local, in a container, or over ssh) in a
* Codeman session, so it appears as a tab and can be driven from the browser.
*
* Four things make this safe, and each is load-bearing:
*
* 1. **The body carries only an opaque id.** The socket path, session name and
* host are re-resolved by re-running discovery here. A browser therefore
* never supplies a fragment of the command we are about to run, which is the
* same rule that keeps docker-adopt and remote-attach injection-free.
* 2. **The candidate must still exist.** Discovery is re-run rather than cached,
* so a session that died between the listing and the click fails with a 404
* instead of producing a wrapper attached to nothing.
* 3. **One wrapper per target.** Two wrappers on one foreign session would each
* create their own grouped view and each think they own the tab; the guard
* is here rather than in the button's in-flight lock, which only stops a
* double-click on one device.
* 4. **Admin-only under multi-user.** Discovery already is (it exposes other
* users' processes), and adopting someone's `shell` is arbitrary execution
* as the server account, which is exactly what the `can-bypass-permissions`
* grant gates elsewhere. The admin gate subsumes it, so there is deliberately
* no second grant check here.
*/
app.post('/api/sessions/adopt', async (req, reply) => {
if (isMultiUserMode() && !requireAdmin(req, reply)) return;
const owner = ownerFor(req);
const body = parseBody(AdoptForeignSessionSchema, req.body, 'Invalid request body');
// Re-resolve rather than trust: point 1 and 2 above.
const found = await discoverForeignSessions({
local: true,
force: true,
dockerCases: body.docker ? await readAllDockerCases() : undefined,
remoteHosts: body.remote ? await readAllRemoteHosts() : undefined,
});
const target = found.sessions.find((f) => f.id === body.id);
if (!target) {
// ⚠️ "Not in the re-resolve" has two very different causes and they must not
// be reported as one. The session really being gone is the ordinary case;
// the OTHER case is a location we could not reach this time, which on a
// flaky link makes a perfectly live remote session read as deleted. Measured
// against a real VM whose ssh path dropped ~10% of connections: clicking
// Open failed with "no longer there" while the session was sitting right
// there. Discovery already knows which it was: it wrote a note, so say so.
const reach = found.notes.filter((n) => !/skipped/.test(n));
return createErrorResponse(
ApiErrorCode.NOT_FOUND,
reach.length
? `Could not reach it just now (${reach.join('; ')}). It may still be running, try again.`
: 'That tmux session is no longer there. Refresh the list and try again.'
);
}
// Point 3: one wrapper per target. Live sessions and not mux records, which
// only exist once the first attach starts the pane, so reading those let a
// second adopt through before the first tab had started.
const targetKey = foreignTargetKey(target);
for (const session of ctx.sessions.values()) {
if (session.adopt && foreignTargetKey(adoptTargetIdentity(session.adopt)) === targetKey) {
return { session: ctx.getSessionStateWithRespawn(session), alreadyAdopted: true };
}
}
if (adoptsInFlight.has(targetKey)) {
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'That tmux session is already being opened');
}
// After the reuse above, which creates nothing and so must not be refused at capacity.
const capMsg = sessionCapacityMessage(ctx.sessions, owner);
if (capMsg) return createErrorResponse(ApiErrorCode.SESSION_BUSY, capMsg);
adoptsInFlight.add(targetKey);
try {
return await createAdoptedSession(req, target, owner, body);
} finally {
adoptsInFlight.delete(targetKey);
}
});
/** Everything past the guards in `POST /api/sessions/adopt`, held under its in-flight mark. */
async function createAdoptedSession(
req: FastifyRequest,
target: ForeignTmuxSession,
owner: string | undefined,
body: { name?: string; parentSessionId?: string }
) {
// Connection facts are copied onto the session rather than referenced by id:
// a wrapper restored after a server restart must be able to rebuild its
// command even if the host registry was edited in the meantime.
const adopt: SessionAdopt = {
location: target.location,
socketPath: target.socketPath,
targetSession: target.sessionName,
viewSession: '',
paneCurrentPath: target.workingDir,
};
if (target.location === 'docker') {
const hosts = await readDockerHosts(CODEMAN_CONFIG_DIR);
const host = hosts.find((h) => h.id === target.hostId);
if (!target.containerName) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Container name missing for a docker candidate');
}
adopt.docker = {
hostId: target.hostId ?? '',
label: target.hostLabel ?? target.containerName,
engine: host?.engine ?? 'docker',
containerName: target.containerName,
daemonHost: host?.daemonHost,
context: host?.context,
};
} else if (target.location === 'remote') {
const host = (await readRemoteHosts(CODEMAN_CONFIG_DIR)).find((h) => h.id === target.hostId);
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Remote host not found');
adopt.remote = {
hostId: host.id,
label: host.label,
host: host.host,
username: host.username,
port: host.port,
identityFile: host.identityFile,
socksProxy: host.socksProxy,
jumpHost: host.jumpHost,
extraSshOptions: host.extraSshOptions,
};
}
const adoptHistoryConfig = await ctx.getTerminalHistoryConfig();
// ⚠️ The wrapper's `workingDir` is a NEUTRAL LOCAL path, never the foreign
// pane's cwd. That cwd is an observation about another machine: it can carry
// characters outside SAFE_PATH_PATTERN (`~/c++`, `Program Files (x86)`), which
// makes `createSession` throw so the tab never starts at all, and when it is a
// container or ssh path every local consumer of `workingDir` — the Files panel,
// the boot hook sweep, the fs watchers — reads it as a path on THIS host. The
// foreign cwd stays on `adopt.paneCurrentPath`, which is for display only.
// `/tmp` is the same neutral cwd `resolveMuxAttachCwd` already gives the pane.
const session = new Session({
workingDir: ADOPTED_WRAPPER_WORKING_DIR,
mode: target.mode,
name: body.name || target.sessionName,
mux: ctx.mux,
useMux: true,
tmuxHistoryLimit: adoptHistoryConfig.tmuxHistoryLimit,
adopt,
owner,
parentSessionId: resolveParentSessionId(ctx, req, body.parentSessionId, owner),
});
// The view session name is derived from the Codeman session id, so it can only
// be filled once the Session exists.
adopt.viewSession = foreignViewSessionName(session.id);
await ctx.addSession(session);
ctx.store.incrementSessionsCreated();
ctx.persistSessionState(session);
await ctx.setupSessionListeners(session);
getLifecycleLog().log({ event: 'created', sessionId: session.id, name: session.name });
invalidateForeignCache();
const lightState = ctx.getSessionStateWithRespawn(session);
ctx.broadcast(SseEvent.SessionCreated, lightState);
return { session: lightState, adopted: true };
}
// ========== Rename Session ==========
app.put('/api/sessions/:id/name', async (req) => {
@@ -1553,6 +1745,13 @@ export function registerSessionRoutes(
'Custom model endpoints are not supported for remote (SSH) or Docker sessions yet'
);
}
// Its own check: this restarts the CLI in the pane, and an adopted pane's CLI is not ours to restart.
if (session.isAdopted) {
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'Custom model endpoints are not available for adopted sessions: Codeman did not start this agent'
);
}
if (session.isBusy()) {
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
}
@@ -1912,6 +2111,10 @@ export function registerSessionRoutes(
// and belongs in its own PR, not in a refactor that is meant to change nothing.
if (
!isExternalCliMode(session.mode) &&
// ⚠️ And never for an adopted session, whose mode can be claude while the
// agent in that pane belongs to someone else. Kept as its own clause for
// the same reason the respawn and Ralph routes keep theirs separate.
!session.isAdopted &&
ctx.store.getConfig().ralphEnabled &&
!session.ralphTracker.autoEnableDisabled
) {
@@ -2850,7 +3053,13 @@ export function registerSessionRoutes(
* effort: the tab rename has already happened and must not fail on this.
*/
async function syncClaudeTitle(session: Session): Promise<void> {
if (getCli(session.mode)?.capabilities.transcript !== 'claude-jsonl' || session.remote || session.docker) return;
if (
getCli(session.mode)?.capabilities.transcript !== 'claude-jsonl' ||
session.remote ||
session.docker ||
session.isAdopted
)
return;
try {
const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects');
const hookPath = ctx.getTranscriptPath(session.id);
@@ -2928,6 +3137,17 @@ export function registerSessionRoutes(
// The body of GET /api/sessions/:id/last-response, so the Discord bridge reads the same transcript
// the response viewer does (per-CLI reader, pane fallback) without a second copy of this logic.
const lastResponseFor = async (session: Session, context?: string) => {
// ⚠️ An adopted session has no transcript Codeman can name. Every reader below pairs one by
// the id Codeman launched the CLI with or by the session's folder, and neither means anything
// here: the CLI chose its own id, and the folder is the neutral `/tmp`, so the newest file for
// that folder would be another conversation's answer shown as this one's. Nothing said yet is
// the honest answer.
if (session.isAdopted) {
return context === 'full' || context === 'turn'
? { text: '', timestamp: '', messages: [] }
: { text: '', timestamp: '' };
}
// Codex sessions don't write to ~/.claude/projects — their transcripts
// live in ~/.codex/sessions/**. Branch to a Codex-specific reader so the
// response-viewer works for Codex panes too.
@@ -3658,6 +3878,16 @@ export function registerSessionRoutes(
const { id } = req.params as { id: string };
const body = parseBody(AutoClearSchema, req.body, 'Invalid request body');
const session = findSessionOrFail(ctx, id, req);
// ⚠️ Separate from the external-CLI gate on purpose: an adopted session's mode
// can perfectly well BE claude, and this drives the pane of a session someone
// else is using. Merging the two checks means a future change to the
// external-CLI rule silently reopens this one.
if (session.isAdopted) {
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'Auto-clear is not available for adopted sessions: Codeman did not start this agent and must not drive it'
);
}
session.setAutoClear(body.enabled, body.threshold);
persistAndBroadcastSession(ctx, session);
@@ -3679,6 +3909,16 @@ export function registerSessionRoutes(
const { id } = req.params as { id: string };
const body = parseBody(AutoCompactSchema, req.body, 'Invalid request body');
const session = findSessionOrFail(ctx, id, req);
// ⚠️ Separate from the external-CLI gate on purpose: an adopted session's mode
// can perfectly well BE claude, and this drives the pane of a session someone
// else is using. Merging the two checks means a future change to the
// external-CLI rule silently reopens this one.
if (session.isAdopted) {
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'Auto-compact is not available for adopted sessions: Codeman did not start this agent and must not drive it'
);
}
session.setAutoCompact(body.enabled, body.threshold, body.prompt);
persistAndBroadcastSession(ctx, session);
@@ -3741,8 +3981,11 @@ export function registerSessionRoutes(
const { id } = req.params as { id: string };
const body = parseBody(GitFetchSettingsSchema, req.body, 'Invalid request body');
const session = findSessionOrFail(ctx, id, req);
if (session.remote || session.docker) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Git fetch does not run for remote or Docker sessions');
if (session.remote || session.docker || session.isAdopted) {
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'Git fetch does not run for remote, Docker or adopted sessions'
);
}
const enabled = 'enabled' in body ? (body.enabled ?? undefined) : session.gitFetchEnabled;
@@ -3768,6 +4011,16 @@ export function registerSessionRoutes(
const { id } = req.params as { id: string };
const body = parseBody(AutoResumeSchema, req.body, 'Invalid request body');
const session = findSessionOrFail(ctx, id, req);
// ⚠️ Separate from the external-CLI gate on purpose: an adopted session's mode
// can perfectly well BE claude, and this drives the pane of a session someone
// else is using. Merging the two checks means a future change to the
// external-CLI rule silently reopens this one.
if (session.isAdopted) {
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'Auto-resume is not available for adopted sessions: Codeman did not start this agent and must not drive it'
);
}
session.setAutoResume(body.enabled);
persistAndBroadcastSession(ctx, session);
@@ -3838,6 +4091,10 @@ export function registerSessionRoutes(
if (body.enabled && session.remote) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Image watching does not run for remote sessions');
}
// An adopted session's folder is the neutral wrapper folder, not the foreign pane's work.
if (body.enabled && session.isAdopted) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Image watching does not run for adopted sessions');
}
if (body.enabled) {
imageWatcher.watchSession(session.id, session.workingDir);
@@ -5804,6 +6061,13 @@ export function registerSessionRoutes(
'Prompt uploads are not supported for remote (SSH) sessions'
);
}
// An adopted session has no workspace of ours: its folder is the neutral `/tmp`, shared with
// whatever else runs there, and an agent in a container or on an ssh host could not read it.
// The foreign pane's own folder is not ours to write into either.
if (session.isAdopted) {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Prompt uploads are not supported for adopted sessions');
}
// The upload is written under session.workingDir (ensureUploadDir below), and
// a live session's workspace can sit on a network mount that stopped answering.
+3 -2
View File
@@ -1185,9 +1185,10 @@ export function registerSystemRoutes(
// Handle image watcher toggle dynamically
toggleService((merged.imageWatcherEnabled as boolean) ?? false, imageWatcher, 'Image watcher', () => {
// Re-watch all active sessions that have image watcher enabled (never a remote session: its
// workingDir is a path on the remote host, not on this disk)
// workingDir is a path on the remote host, not on this disk; nor an adopted one, whose folder
// is the neutral wrapper folder and not the foreign pane's work)
for (const session of ctx.sessions.values()) {
if (session.imageWatcherEnabled && !session.remote) {
if (session.imageWatcherEnabled && !session.remote && !session.isAdopted) {
imageWatcher.watchSession(session.id, session.workingDir);
}
}
+21
View File
@@ -2523,6 +2523,27 @@ export const CustomModelSelectionSchema = z.union([
}),
z.object({ clear: z.literal(true) }),
]);
/**
* Adopt a FOREIGN tmux session (one a human started outside Codeman).
*
* ⚠️ The body carries ONLY the opaque candidate id from `GET /api/mux/foreign`.
* The socket path, session name and host are re-resolved server-side by re-running
* discovery, so a browser can never hand the launch chain a path or a session name
* to interpolate. That is the same discipline that keeps the docker-adopt and
* remote-attach paths free of caller-supplied command fragments.
*/
export const AdoptForeignSessionSchema = z
.object({
id: z.string().min(1).max(64),
/** Optional tab name; defaults to the foreign session's own name. */
name: z.string().max(128).optional(),
/** Include docker locations in the re-resolve (must match the listing call). */
docker: z.boolean().optional(),
/** Include remote locations in the re-resolve. */
remote: z.boolean().optional(),
parentSessionId: z.string().max(64).optional(),
})
.strict();
/** Agent inspection bounds are bytes of evidence, independent of the JSON envelope. */
export const AgentInspectQuerySchema = z
+27 -4
View File
@@ -569,9 +569,10 @@ export class WebServer extends EventEmitter {
agentExited: (id) => !!this.sessions.get(id)?.paneExit,
awaitingChoice: (id) => approvalInbox.listPending().some((item) => item.sessionId === id && item.kind !== 'idle'),
// Remote (ssh) sessions keep their folder on another host, so received files have nowhere local to go.
// An adopted session's folder is the neutral wrapper folder, which its agent need not be able to read.
sessionDir: (sessionId) => {
const session = this.sessions.get(sessionId);
return session && !session.remote ? session.workingDir : undefined;
return session && !session.remote && !session.isAdopted ? session.workingDir : undefined;
},
});
private teamWatcher: TeamWatcher = new TeamWatcher();
@@ -2207,7 +2208,9 @@ export class WebServer extends EventEmitter {
// fs.watch()es it on the LOCAL disk, so a remote session only records the path: the fix plan stays
// authoritative as for a local session (keeping the output-based completion guessers off), and its
// file is read over ssh only on an explicit import (fix-plan/read).
if (!isExternalCliMode(session.mode)) {
// ⚠️ An ADOPTED session is skipped outright: Ralph is refused for one anyway, and its
// `workingDir` is the neutral wrapper folder, not the foreign pane's work.
if (!isExternalCliMode(session.mode) && !session.isAdopted) {
if (session.remote) {
session.ralphTracker.setRemoteWorkingDir(session.workingDir);
} else {
@@ -2217,8 +2220,10 @@ export class WebServer extends EventEmitter {
// Start watching for new images in this session's working directory (if enabled globally and per-session).
// A remote session's workingDir is a path on the remote host, and the watcher watches it on the LOCAL
// disk, so it is skipped there (the per-session and app-wide toggles skip it too).
if ((await this.isImageWatcherEnabled()) && session.imageWatcherEnabled && !session.remote) {
// disk, so it is skipped there (the per-session and app-wide toggles skip it too). An ADOPTED session
// is skipped for the same reason as the Ralph watcher above: nothing in its wrapper folder is the
// foreign pane's work.
if ((await this.isImageWatcherEnabled()) && session.imageWatcherEnabled && !session.remote && !session.isAdopted) {
imageWatcher.watchSession(session.id, session.workingDir);
}
@@ -2305,6 +2310,9 @@ export class WebServer extends EventEmitter {
): Promise<void> {
const session = this.sessions.get(sessionId);
if (!session) return;
// ⚠️ A link scanned from an adopted pane was printed by a process on another host or in a
// container, while confinement here would be to this host's `/tmp`, the wrapper's folder.
if (session.isAdopted) return;
const event =
source === 'codex-generated'
? await registerGeneratedArtifactAttachment({
@@ -4152,6 +4160,14 @@ export class WebServer extends EventEmitter {
// MuxSession.docker; state.json carries SessionState.docker), so recovery
// rebuilds the `docker exec` launch instead of a broken local command.
docker: muxSession.docker ?? savedState?.docker,
// Adoption metadata round-trips for the same reason remote/docker do,
// and one more: it is the ONLY thing that marks this session as
// wrapping a process Codeman never launched. Dropping it on recovery
// silently re-enabled respawn, Ralph and hook-backed waits against
// someone else's live tmux session after every server restart —
// measured, not hypothetical. The mux record is preferred because it
// is what `killSession`'s detach-not-kill guard already reads.
adopt: muxSession.adopt ?? savedState?.adopt,
owner: recoveredOwner,
// Tab lineage survives a restart. It is only decoration, so a parent
// that did NOT come back is harmless: the frontend draws an edge only
@@ -4435,6 +4451,13 @@ export class WebServer extends EventEmitter {
const workspaces = new Set<string>();
for (const session of this.sessions.values()) {
if (session.mode !== 'claude' || session.remote) continue;
// ⚠️ An ADOPTED session is not caught by the `remote` check above: its
// connection facts live on `adopt.remote` / `adopt.docker`, so a session
// wrapping someone's in-container or over-ssh claude looks local here.
// Its workspace was never Codeman's to begin with, so installing hooks
// there writes `.claude/settings.local.json` into the human's own repo —
// on EVERY restart, since this sweep runs from boot recovery.
if (session.isAdopted) continue;
if (session.docker && !session.docker.hooksEnabled) continue;
if (session.workingDir) workspaces.add(session.workingDir);
}
+17
View File
@@ -200,6 +200,18 @@ export interface HookCapabilityOptions {
* timeout on every turn.
*/
deepSeekBridgeUnreachable?: boolean;
/**
* True when the session is a WRAPPER around a tmux session a human started
* outside Codeman.
*
* This one overrides the mode entirely, and it has to: an adopted session can
* be `mode: 'claude'` and still have no hooks, because hooks are installed into
* a WORKSPACE at session-create time (`applyWorkspaceHooks`) and we never
* created this one. Answering from the mode there would promise `stop` and
* `blocked` for a process that can never post either — the exact
* infinite-wait-dressed-as-a-timeout this predicate exists to prevent.
*/
adopted?: boolean;
}
/**
@@ -234,6 +246,9 @@ export interface HookCapabilityOptions {
* function only about hook SIGNALS.
*/
export function hooksAvailableForMode(mode: SessionMode, options: HookCapabilityOptions = {}): boolean {
// Checked BEFORE the mode: adoption is about who launched the process, and no
// mode can vouch for a workspace Codeman never touched. See `adopted` above.
if (options.adopted) return false;
// A TRI-state capability, not a boolean, because the three answers are genuinely
// different questions — see CliCapabilities.hooks.
switch (getCli(mode)?.capabilities.hooks) {
@@ -269,10 +284,12 @@ export function sessionHookOptions(session: {
deepSeekStatusReporting?: boolean;
docker?: unknown;
remote?: unknown;
adopt?: unknown;
}): HookCapabilityOptions {
return {
deepSeekStatusReporting: session.deepSeekStatusReporting,
deepSeekBridgeUnreachable: Boolean(session.docker || session.remote),
adopted: Boolean(session.adopt),
};
}