mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 02:49:33 +01:00
feat(adopt): adopt tmux sessions a human started, in all three locations
A tmux session someone opened by hand (`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. The home screen now lists those, and one click wraps one in a Codeman tab. Local, in-container and over-ssh all work. The shape is a WRAPPER session, not a direct attach: adoption creates an ordinary `codeman-<8hex>` session on our own socket whose pane runs the attach. That makes "detach, never kill the foreign session" structural rather than a matter of discipline — `killSession` can only ever reach our own wrapper, so no shape of caller bug reaches the foreign server. View reclamation differs by location: local relies on client death and ssh on SIGHUP, but a `docker exec` does NOT die with its client, so the in-container view has to be reclaimed explicitly on kill or every adoption leaks one. Measured facts, recorded in the code comments: - A grouped session (`new-session -t`) buys `status off` and an independent current window. It does NOT buy an independent size. A window is one object with one size and a group shares it: on tmux 3.3a a bare attach and a grouped attach both shrink a 200x49 target to 80x23. Only `window-size largest` holds it, and that is a shared window option which survives our detach, so setting it would permanently rewrite the owner's configuration. Left unset. - Session names come from other people, while the local launch chain ends in `bash -c` + `JSON.stringify`, which escapes neither `$` nor backticks — the outer shell performs command substitution before the inner single quotes are considered (verified: both substitutions inside the inner quotes ran). So session names and socket paths go through a character allowlist and are dropped at DISCOVERY, which means a non-conforming candidate never gets an id and the adopt endpoint fails closed when it re-resolves. - `pane_current_command` is `node` for both claude and codex, so the mode can only come from a bounded process-tree walk of `pane_pid`'s argv; anything unrecognized is treated as a shell. An adopted session has to say no to every assumption that Codeman owns the pane: respawn, Ralph, the orchestrator and hook waits all refuse. The hook check runs BEFORE the mode check, because adoption asks who launched the process and no mode can vouch for a workspace Codeman never touched. `paneExit` is forced to unknown, since the pane runs `tmux attach` rather than the agent and its exiting with 0 only means the connection ended. Reboot restore refuses too: all Codeman ever had was an attach command, and replaying it would build a connection to a foreign session a reboot has almost certainly taken with it, while presenting whatever it lands on as a session we restored. Adoption is admin-only under multi-user mode: adopting someone's shell is equivalent to arbitrary host execution. Discovery is read-only, never creates a session, and degrades to an empty list whenever a probe cannot reach its target.
This commit is contained in:
1 parent
848ab48b0a
commit
245ee68c7c
27 files changed
+2622
-35
No files matched your search
+33
-3
@@ -7477,6 +7477,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 — "${session.adopt.targetSession}" keeps running for whoever started it`
|
||||
: 'Tmux session keeps running in background';
|
||||
}
|
||||
|
||||
// Update kill button text based on session mode
|
||||
const killTitle = document.getElementById('closeConfirmKillTitle');
|
||||
if (killTitle) {
|
||||
@@ -7511,9 +7530,20 @@ class CodemanApp {
|
||||
const sessionId = this.pendingCloseSessionId;
|
||||
this.cancelCloseSession();
|
||||
|
||||
if (sessionId) {
|
||||
await this.closeSession(sessionId, killMux);
|
||||
}
|
||||
if (!sessionId) return;
|
||||
|
||||
// ⚠️ 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;
|
||||
await this.closeSession(sessionId, adopted ? true : killMux);
|
||||
}
|
||||
|
||||
nextSession() {
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* @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 polled. Each costs a `docker exec` or a
|
||||
* full ssh handshake per target, and doing that on every home-screen load is the
|
||||
* one cost the server-side design explicitly refuses. They are fetched only when
|
||||
* the user asks, via the "scan containers & hosts" toggle, which then rides along
|
||||
* with the same poll.
|
||||
*
|
||||
* ⚠️ 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, _apiJson)
|
||||
* @dependency api-client.js (_apiJson / _apiPost envelope unwrapping)
|
||||
* @dependency ralph-panel.js (formatRelativeTime)
|
||||
* @loadorder 12.57 of 16, after home-sessions.js, before entrance-animations.js
|
||||
*/
|
||||
|
||||
/** Fallback poll cadence; the server sends its own in `pollIntervalMs`. */
|
||||
const FOREIGN_POLL_FALLBACK_MS = 8000;
|
||||
|
||||
/** Mode label shown on a row. Deliberately the same words the tab strip uses. */
|
||||
const FOREIGN_MODE_LABEL = {
|
||||
claude: 'Claude',
|
||||
codex: 'Codex',
|
||||
opencode: 'OpenCode',
|
||||
gemini: 'Gemini',
|
||||
antigravity: 'Antigravity',
|
||||
pi: 'Pi',
|
||||
grok: 'Grok',
|
||||
deepseek: 'DeepSeek',
|
||||
shell: 'Shell',
|
||||
};
|
||||
|
||||
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) return;
|
||||
void this.loadForeignSessions();
|
||||
this._foreignPollTimer = setInterval(() => {
|
||||
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;
|
||||
},
|
||||
|
||||
toggleForeignScanRemote() {
|
||||
this._foreignScanRemote = !this._foreignScanRemote;
|
||||
void this.loadForeignSessions();
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Data
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
async loadForeignSessions() {
|
||||
const wide = this.foreignScanRemote();
|
||||
const qs = wide ? '?docker=1&remote=1' : '';
|
||||
const data = await this._apiJson(`/api/mux/foreign${qs}`);
|
||||
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;
|
||||
this._foreignSessions = Array.isArray(data.sessions) ? data.sessions : [];
|
||||
this._foreignNotes = Array.isArray(data.notes) ? data.notes : [];
|
||||
this._foreignCanScanWide = data.canScanWide === true;
|
||||
if (Number.isFinite(data.pollIntervalMs)) this._foreignPollMs = data.pollIntervalMs;
|
||||
this.renderAllForeignSessions();
|
||||
},
|
||||
|
||||
/**
|
||||
* Adopt one candidate.
|
||||
*
|
||||
* The in-flight lock is per BUTTON, not global: two different foreign sessions
|
||||
* can legitimately be adopted back to back. The server holds the real
|
||||
* one-wrapper-per-target guarantee; this only stops a double-click.
|
||||
*/
|
||||
async adoptForeignSession(id, buttonEl) {
|
||||
if (this._foreignAdoptInFlight) return;
|
||||
this._foreignAdoptInFlight = true;
|
||||
if (buttonEl) {
|
||||
buttonEl.disabled = true;
|
||||
buttonEl.textContent = codemanT ? codemanT('Opening…') : 'Opening…';
|
||||
}
|
||||
try {
|
||||
const body = { id };
|
||||
if (this.foreignScanRemote()) {
|
||||
body.docker = true;
|
||||
body.remote = true;
|
||||
}
|
||||
const data = await this._apiJson('/api/sessions/adopt', { method: 'POST', body });
|
||||
if (!data || !data.session) {
|
||||
this.showToast?.(
|
||||
codemanT
|
||||
? codemanT('That session is gone. Refreshing the list.')
|
||||
: 'That session is gone. Refreshing the list.',
|
||||
'error'
|
||||
);
|
||||
await this.loadForeignSessions();
|
||||
return;
|
||||
}
|
||||
// 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);
|
||||
await this.loadForeignSessions();
|
||||
} finally {
|
||||
this._foreignAdoptInFlight = false;
|
||||
if (buttonEl) {
|
||||
buttonEl.disabled = false;
|
||||
buttonEl.textContent = codemanT ? codemanT('Open') : 'Open';
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 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 = Array.isArray(this._foreignSessions) ? this._foreignSessions : [];
|
||||
container.innerHTML = '';
|
||||
|
||||
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;
|
||||
// ⚠️ `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
|
||||
? codemanT
|
||||
? codemanT('Could not reach the server.')
|
||||
: 'Could not reach the server.'
|
||||
: codemanT
|
||||
? codemanT('No sessions found outside Codeman.')
|
||||
: '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 notes = Array.isArray(this._foreignNotes) ? this._foreignNotes : [];
|
||||
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 = codemanT ? codemanT('Opened outside Codeman') : '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 = codemanT
|
||||
? codemanT('Also scan containers and remote hosts (slower)')
|
||||
: 'Also scan containers and remote hosts (slower)';
|
||||
scan.textContent = this.foreignScanRemote() ? '⟳ all' : '⟳ local';
|
||||
header.appendChild(scan);
|
||||
|
||||
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 = [FOREIGN_MODE_LABEL[row.mode] || row.mode];
|
||||
const where = row.hostLabel || 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 = codemanT ? codemanT('Go to tab') : 'Go to tab';
|
||||
} else {
|
||||
action.dataset.foreignAction = 'adopt';
|
||||
action.dataset.foreignId = row.id;
|
||||
action.textContent = codemanT ? codemanT('Open') : '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();
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -524,6 +524,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 →</button>
|
||||
</div>
|
||||
@@ -1562,11 +1568,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>
|
||||
@@ -3759,6 +3765,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="entrance-animations.js"></script>
|
||||
<script defer src="ralph-wizard.js"></script>
|
||||
<script defer src="api-client.js"></script>
|
||||
|
||||
@@ -507,6 +507,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',
|
||||
|
||||
@@ -4027,6 +4027,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
|
||||
|
||||
@@ -19088,3 +19088,173 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle
|
||||
font-size: 13px;
|
||||
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;
|
||||
}
|
||||
@@ -2215,6 +2215,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;
|
||||
}
|
||||
@@ -2229,6 +2232,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.
|
||||
@@ -2238,6 +2245,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
hideWelcome() {
|
||||
this.hideMobileOverview?.();
|
||||
this.hideHomeSessions?.();
|
||||
this.stopForeignPolling?.();
|
||||
const overlay = document.getElementById('welcomeOverlay');
|
||||
if (overlay) {
|
||||
overlay.classList.remove('visible');
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
/**
|
||||
* @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';
|
||||
@@ -8,6 +15,8 @@ import type { InfraPort } 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 { FOREIGN_POLL_INTERVAL_MS } from '../../config/foreign-tmux.js';
|
||||
|
||||
export function registerMuxRoutes(app: FastifyInstance, ctx: InfraPort): void {
|
||||
app.get('/api/mux-sessions', async (req, reply) => {
|
||||
@@ -36,6 +45,58 @@ 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 mux sessions, so a target Codeman already
|
||||
* wraps renders as "open" rather than offering a second wrapper.
|
||||
*/
|
||||
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,
|
||||
});
|
||||
|
||||
// Match on the (socket, session) pair rather than on our opaque candidate id:
|
||||
// the id encodes a host key that a restored wrapper does not carry, while the
|
||||
// pair is exactly what the wrapper stores and what it re-attaches to.
|
||||
const wrapped = new Map<string, string>();
|
||||
for (const m of ctx.mux.getSessions()) {
|
||||
if (m.adopt) wrapped.set(`${m.adopt.socketPath}\u0000${m.adopt.targetSession}`, m.sessionId);
|
||||
}
|
||||
|
||||
return {
|
||||
sessions: result.sessions.map((f) => ({
|
||||
...f,
|
||||
adoptedBy: wrapped.get(`${f.socketPath}\u0000${f.sessionName}`),
|
||||
})),
|
||||
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;
|
||||
|
||||
@@ -53,6 +53,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(
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -30,6 +30,16 @@ import {
|
||||
type OmpConfig,
|
||||
type RemoteHost,
|
||||
} from '../../types.js';
|
||||
import { AdoptForeignSessionSchema } from '../schemas.js';
|
||||
import {
|
||||
discoverForeignSessions,
|
||||
invalidateForeignCache,
|
||||
readAllDockerCases,
|
||||
readAllRemoteHosts,
|
||||
} from '../../foreign-tmux-discovery.js';
|
||||
import { foreignViewSessionName } from '../../foreign-tmux.js';
|
||||
import { requireAdmin } from '../route-helpers.js';
|
||||
import type { SessionAdopt } from '../../types/session.js';
|
||||
import { Session, isAltScreenStripMode, isExternalCliMode, isMuxAltScreenOnlyStripMode } from '../../session.js';
|
||||
import type { PaneCaptureOptions } from '../../mux-interface.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
@@ -1156,6 +1166,149 @@ export function registerSessionRoutes(
|
||||
return { session: lightState };
|
||||
});
|
||||
|
||||
// ========== Adopt a foreign tmux session ==========
|
||||
|
||||
/**
|
||||
* 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 capMsg = sessionCapacityMessage(ctx.sessions, owner);
|
||||
if (capMsg) return createErrorResponse(ApiErrorCode.SESSION_BUSY, capMsg);
|
||||
|
||||
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 (socket, session).
|
||||
const existing = ctx.mux
|
||||
.getSessions()
|
||||
.find((m) => m.adopt?.socketPath === target.socketPath && m.adopt?.targetSession === target.sessionName);
|
||||
if (existing) {
|
||||
const live = ctx.sessions.get(existing.sessionId);
|
||||
if (live) return { session: ctx.getSessionStateWithRespawn(live), alreadyAdopted: true };
|
||||
}
|
||||
|
||||
// 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();
|
||||
|
||||
// ⚠️ `workingDir` for an adopted session is the FOREIGN pane's cwd, which may
|
||||
// not exist on this host (a container path, a remote path). It is recorded as
|
||||
// an observation for display; the wrapper pane is never `cd`'d into it, and
|
||||
// the case-space confinement that guards a real workingDir does not apply
|
||||
// because nothing is created there.
|
||||
const session = new Session({
|
||||
workingDir: target.workingDir || process.cwd(),
|
||||
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) => {
|
||||
|
||||
@@ -2067,3 +2067,24 @@ 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();
|
||||
+18
-3
@@ -1821,13 +1821,20 @@ export class WebServer extends EventEmitter {
|
||||
this.runSummaryTrackers.set(session.id, summaryTracker);
|
||||
summaryTracker.recordSessionStarted(session.mode, session.workingDir);
|
||||
|
||||
// Set working directory for Ralph tracker to auto-load @fix_plan.md (not supported for external CLIs)
|
||||
if (!isExternalCliMode(session.mode)) {
|
||||
// Set working directory for Ralph tracker to auto-load @fix_plan.md (not supported for external CLIs).
|
||||
// ⚠️ Also skipped for an ADOPTED session, and for two reasons: Ralph is refused
|
||||
// for one anyway, and its `workingDir` is the FOREIGN pane's cwd — a path that
|
||||
// need not exist on this host at all. Watching it logged a caught ENOENT on
|
||||
// every in-container adoption (`watch '/workspace/pythonserver'`), which is
|
||||
// noise pointing at a real category error rather than a real failure.
|
||||
if (!isExternalCliMode(session.mode) && !session.isAdopted) {
|
||||
session.ralphTracker.setWorkingDir(session.workingDir);
|
||||
}
|
||||
|
||||
// Start watching for new images in this session's working directory (if enabled globally and per-session)
|
||||
if ((await this.isImageWatcherEnabled()) && session.imageWatcherEnabled) {
|
||||
if ((await this.isImageWatcherEnabled()) && session.imageWatcherEnabled && !session.isAdopted) {
|
||||
// Same reason as the Ralph watcher above: an adopted session's workingDir is
|
||||
// an observation about ANOTHER host's (or container's) filesystem.
|
||||
imageWatcher.watchSession(session.id, session.workingDir);
|
||||
}
|
||||
|
||||
@@ -3524,6 +3531,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
|
||||
|
||||
@@ -190,6 +190,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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -224,6 +236,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) {
|
||||
@@ -259,10 +274,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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user