Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion .ai/contexts/session-cache.md
Original file line number Diff line number Diff line change
Expand Up @@ -794,7 +794,7 @@ also returns `blocked` (why nothing could be read, or null) and the normalised `
- `liveness`: at least one live descriptor. `inject`: a live descriptor with a `messagingSocketPath` that is a POSIX
absolute path. `attach`: a live descriptor naming a tmux pane with a valid pid (the adapter's own test).
The tiers are independent requirements: the reported tier is the highest available one, not the highest contiguous one.
- `launch` is never available: starting a session from here is not implemented.
- `launch`: available under the same rule as attach (probe `tmux: true`, or a live descriptor naming a tmux pane, and not `tmux: false`), and unlike the other tiers it is not withdrawn by a failed last refresh (launch runs its own ssh, as Send does); its reason otherwise starts with "needs tmux on the host". See "Remote hosts — launching a session".
- A tier that needs a live session reads as missing on an idle host; that is "nothing to read it from", not "unsupported".
- `annotateRemoteAttachable` (main.js) puts the profile on the project (`remoteHostProfile`). After 3 consecutive failed
cycles (`attachBlockReason`), it sets `remoteAttachable: false` plus `remoteAttachBlocked` (the last error) on the
Expand Down Expand Up @@ -828,6 +828,18 @@ also returns `blocked` (why nothing could be read, or null) and the normalised `
`session.remoteSendBlocked` disables the button with the reason in its title.
- Stop has no gate (`test/annotate-remote-tier-gates.test.js` and `test/dom-sidebar-remote-tier-gates.test.js` pin it).

## Remote hosts — launching a session (issues #218, #222)

`remote-launch.js` builds the one ssh command and orchestrates launch then attach; the renderer side is `showRemoteLaunchDialog` (dialogs.js) and `launchRemoteSession` (app.js), the IPC is `remote-launch-session`.

- **The command is `sh -c '<script>'`** (`shellSingleQuote`, as `remote-send.js`), so a fish or csh login shell never parses it. The script checks `[ -d "$cwd" ]`, `command -v tmux`, `command -v claude` with exit codes 9, 10, 11 (each mapped to its own message), then `exec tmux new-session -d -P -F '#{session_name}:#{window_id}.#{pane_id} #{pane_pid}' -s switchboard-<uuid8> -c "$cwd" 'claude --session-id <uuid> [flags]'`. The exact string is pinned in `test/remote-launch.test.js`.
- **Validation, not escaping, is the guard.** The cwd must match `CWD_RE` (absolute; letters, digits, space, `. _ + @ : , = / -`; no `..` segment; at most 4096 bytes). That set has no quote, `$`, backtick, backslash, newline or leading `-`, so the single-quoting is a second layer, not the only one. The uuid is matched by regex, the tmux name derives from it, the permission mode is checked against an allow-list. The same checks run in the renderer (UX), in `handleLaunchRequest` and in the adapter.
- **Why attach straight to the created pane.** `-P -F` prints the pane's `session:@window.%pane` and `pane_pid`. `handleLaunchRequest` hands `{ pid, tmux, sessionId, cwd }` to the existing `remoteAttachAdapter.attach`, which discovers the socket from `/proc/<pid>/environ` and runs the same pid-reuse guard (the pane's command line contains `claude`). No wait for the descriptor to show up in the next refresh; `refreshHostNow` is fired afterwards so the row's real descriptor replaces the pending one.
- **The id is generated locally** (`crypto.randomUUID` in the renderer, validated again in main) and passed as `--session-id`, so the pending sidebar row and the later descriptor share one id.
- **Stop is unchanged.** The pane target we created has a pane component, so `buildStopCommand` emits `kill-pane` (pinned for this exact target shape in `remote-launch.test.js`); never `kill-session`. Killing the only pane of the only window ends the tmux session as a side effect of tmux itself.
- **A failed attach after a successful launch** leaves the tmux session running on the host; the error names it, and it appears in the sidebar at the next refresh.
- Not verified here: a real host (the tests use a fake runner, plus a real `sh` with stubbed `tmux`/`claude`), tmux older than the `-P -F` form, and a `claude` that exits at once (the pane then closes and attach fails with the probe error).

## Remote hosts — sending a prompt (issue #219)

`remote-send.js` writes one prompt to a live, unattached remote session through
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ What changes for you in each release of Switchboard. How to write an entry: [doc
### New
- A session's **Touched** tab, next to Changes in the terminal header, lists the files its file tools (Edit, Write, MultiEdit, NotebookEdit) touched, its subagents' included, with what is on disk now (present, gone, unreadable) and the tools and agents behind each. It works outside any git repository. It is not the complete set of files the session changed: files changed through Bash commands or scripts are not listed, and the tab says so. Local sessions only. (#309)
- With Debug mode on, the activity trace now records how hard each terminal is being drawn: once a second per session, how many writes reached it, how large they were and how often its glyph atlas was rebuilt, to tell a legitimately busy terminal from a runaway one. (#175)
- On a remote host with `tmux`, the project's `+` now starts a new Claude session there: pick or type a directory on the host, choose a permission mode, and Switchboard starts it in a tmux session and attaches to it. The directory must already exist on the host, `claude` must be on the PATH of an ssh command, and signing in is done on the host. (#218, #222)
### Changed
- A single trigger is no longer typed into a dialog such as a permission prompt or a question: with `wait: "none"` (write now, the default) it holds while the CLI shows a dialog, and with `wait: "idle"` until the CLI is at its prompt, up to its `timeout_ms`; then it fails `not sent` with a `reason` that says a dialog is open instead of being written into it. `wait: "none"` still writes at once while the CLI is busy. Without a readable CLI descriptor it is written as before. Input you type yourself in the terminal is never held back. (#379)
- Switchboard now checks once per host, at the first successful refresh and then every six hours (every 30 minutes while one is missing), whether `tmux` and `inotifywait` are installed. A host with `tmux` and no session running no longer shows attach as missing; a host without `tmux` no longer offers to attach to a session and opens its transcript, saying why in the tooltip; and the host's tooltip says when live updates are off because `inotifywait` is missing. On a remote host, the new-session button's tooltip now gives the reason, and Send a prompt… is disabled, with the reason, while no live session on the host reports a messaging socket. Stop is never disabled. (#218)
Expand Down
39 changes: 35 additions & 4 deletions docs/remote-hosts.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,9 @@ growing delay when it fails.
## In the sidebar

A remote host's projects are listed like local ones, with the alias as a badge
on each session. Their `+` is disabled (*Read-only mirror of &lt;alias&gt; — new
sessions must be started on that host*).
on each session. Their `+` starts a new session on the host when it has
`tmux` (see [Launch a session](#launch-a-session)); without it the button is
disabled and its tooltip says the host needs tmux.

### Status

Expand Down Expand Up @@ -141,8 +142,9 @@ What the tier gates:
knows) and the descriptor names a pane. Otherwise it opens its transcript, with
the reason in its tooltip. After three failed refreshes in a row it also opens
its transcript, whatever the probe said.
- **New session** stays disabled on a remote host; its tooltip gives the launch
tier's reason: starting a session from here is not implemented.
- **New session** is enabled when the host has `tmux` (probe answer, or a live
session naming a tmux pane). Otherwise it is disabled and its tooltip gives the
launch tier's reason: the host needs tmux.
- **Send a prompt…** is disabled, with the inject reason in its tooltip, while no
live session on the host reports a messaging socket. A host whose refresh
failed does not disable it: it runs its own ssh.
Expand Down Expand Up @@ -217,6 +219,35 @@ travels on ssh's standard input only, never on a command line.
which Switchboard does not read.
- A session attached in a terminal is refused: type in the terminal.

## Launch a session

The `+` of a remote project opens a dialog: the directory (a list of the host's
known project paths, or any absolute path typed) and the permission mode, with
Dangerous Skip as locally. Start runs one `ssh` that checks the directory
exists (`test -d`), that `tmux` and `claude` are found, then starts
`claude --session-id <uuid>` in a new detached tmux session named
`switchboard-<first 8 of the uuid>`, in that directory. The uuid is generated
by Switchboard. Switchboard then attaches to the new pane the way it attaches to
any running session, so you land in it.

- Only tmux hosts can launch. There is no launch without a multiplexer. A failed
last refresh does not disable it: it runs its own ssh.
- A directory that does not exist on the host refuses the launch, and the
terminal tab says so.
- Authentication is done on the host, by you. Switchboard copies no credential;
a CLI that is not logged in shows its own login prompt in the pane.
- `claude` must be on the `PATH` of a non-interactive ssh command. When it is
only added by an interactive shell profile, the launch says it was not found.
- The directory may only contain letters, digits, space and `. _ + @ : , = / -`,
must be absolute and must not contain a `..` segment. Anything else is refused
before ssh runs.
- Only the permission mode maps to a CLI flag. The local dialog's worktree, Chrome,
sandbox, pre-launch command and additional directories do not apply to a remote
launch.
- Stop works as for any remote session: it kills the pane, never the tmux
session. Closing the only pane of a session ends that session.
- Linux hosts only, as for attach.

## Known limits

Session ids are not namespaced per host. Two hosts with a session of the same
Expand Down
2 changes: 2 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ const rendererCrossFileGlobals = {
confirmAndStopSession: 'readonly',
pollActiveSessions: 'readonly',
showNewSessionPopover: 'readonly',
showRemoteLaunchDialog: 'readonly',
openSettingsViewer: 'readonly',
wireActivityTraceToggle: 'readonly',
wireActivityReportingToggle: 'readonly',
Expand Down Expand Up @@ -281,6 +282,7 @@ const rendererCrossFileGlobals = {
setSessionSandboxed: 'readonly',
destroySession: 'readonly',
launchNewSession: 'readonly',
launchRemoteSession: 'readonly',
launchTerminalSession: 'readonly',
launchScheduleCreator: 'readonly',
resolveDefaultSessionOptions: 'readonly',
Expand Down
62 changes: 47 additions & 15 deletions main.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@
}

// Shell profiles → shell-profiles.js
const { discoverShellProfiles, getShellProfiles, resolveShell, isWindows, isWslShell, windowsToWslPath, shellArgs, quoteArgvForShell } = require('./shell-profiles');

Check warning on line 71 in main.js

View workflow job for this annotation

GitHub Actions / lint

'isWindows' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 71 in main.js

View workflow job for this annotation

GitHub Actions / lint

'discoverShellProfiles' is assigned a value but never used. Allowed unused vars must match /^_/u
const { startScheduler, scheduleBindRefusals, resolveScheduleSandbox, scheduleRegistry, initialScheduleProjects } = require('./schedule-runner');
const { encodeProjectPath } = require('./encode-project-path');
const { SETTING_DEFAULTS } = require('./public/setting-defaults');
Expand All @@ -82,7 +82,8 @@
const { createTriggerContext } = require('./trigger-context');
const { createTmuxAttachAdapter } = require('./remote-attach');
const { createRemoteStopAdapter } = require('./remote-stop');
const { attachBlockReason, sendBlockReason } = require('./remote-host-profile');
const { attachBlockReason, sendBlockReason, launchBlockReason } = require('./remote-host-profile');
const { createRemoteLaunchAdapter, handleLaunchRequest } = require('./remote-launch');
const { createRemoteSendAdapter, handleSendRequest } = require('./remote-send');
const { createGitChangesRunner, localGitEnv } = require('./git-changes-runner');
const { runToExit } = require('./run-to-exit');
Expand Down Expand Up @@ -481,8 +482,8 @@
isInitialScanComplete, setInitialScanComplete,
},
});
const { readSessionFile, readFolderFromFilesystem, refreshFolder, reconcileCacheFromFilesystem,

Check warning on line 485 in main.js

View workflow job for this annotation

GitHub Actions / lint

'readFolderFromFilesystem' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 485 in main.js

View workflow job for this annotation

GitHub Actions / lint

'readSessionFile' is assigned a value but never used. Allowed unused vars must match /^_/u
buildProjectsFromCache, notifyRendererProjectsChanged, sendStatus, populateCacheViaWorker,

Check warning on line 486 in main.js

View workflow job for this annotation

GitHub Actions / lint

'sendStatus' is assigned a value but never used. Allowed unused vars must match /^_/u
scanFoldersViaWorker, setRemoteRoots, resolveFolderDir, isIndexingFinished } = sessionCache;
const { resolveJsonlPath, readSubagentMeta } = require('./read-session-file');

Expand Down Expand Up @@ -564,6 +565,9 @@
// see .ai/contexts/session-cache.md ("Remote hosts — sending a prompt")
const remoteSendAdapter = createRemoteSendAdapter({ log });

// see .ai/contexts/session-cache.md ("Remote hosts — launching a session")
const remoteLaunchAdapter = createRemoteLaunchAdapter({ log });

// Joins the sidebar's remote sessions to the indexer's live descriptors so the
// renderer can route a click without ever naming an attach mechanism itself
// — see .ai/contexts/session-cache.md ("Remote hosts — tmux attach").
Expand Down Expand Up @@ -1693,6 +1697,27 @@
return result;
});

// --- IPC: remote-launch-session ---
// see .ai/contexts/session-cache.md ("Remote hosts — launching a session")
ipcMain.handle('remote-launch-session', async (_event, payload) => {
if (!mainWindow) return { ok: false, error: 'no window' };
const rawId = payload && payload.sessionId;
const sessionId = typeof rawId === 'string' ? rawId.toLowerCase() : null;
if (sessionId && activeSessions.has(sessionId)) return { ok: false, error: 'invalid request' };
const result = await handleLaunchRequest(payload, {
hasHost: (alias) => enabledHosts((getSetting('global') || {}).remoteHosts).some(h => h.alias === alias),
launchBlockReason: (alias) => launchBlockReason(remoteIndexer.getRemoteHostProfile(alias)),
adapter: remoteLaunchAdapter,
attach: (alias, descriptor, size) => remoteAttachAdapter.attach(alias, descriptor, size),
});
if (!result.ok) return { ok: false, error: result.error };
const session = registerRemoteAttachSession(result.descriptor.sessionId, {
alias: payload.alias, projectPath: result.descriptor.cwd, cwd: result.descriptor.cwd, ptyProcess: result.attachResult.ptyProcess,
});
remoteIndexer.refreshHostNow(payload.alias, { force: true }).catch(() => {});
return { ok: true, remote: true, generation: session.generation };
});

// --- IPC: remote-send-prompt ---
// see .ai/contexts/session-cache.md ("Remote hosts — sending a prompt")
ipcMain.handle('remote-send-prompt', (_event, payload) => handleSendRequest(payload, {
Expand Down Expand Up @@ -2315,6 +2340,24 @@
}

// --- IPC: open-terminal ---
function registerRemoteAttachSession(sessionId, { alias, projectPath, cwd, ptyProcess }) {
const remoteSession = {
pty: ptyProcess,
// handle: {write, isAlive} — see .ai/contexts/trigger-watcher.md, "Session handle"
handle: ptyProcess,
host: alias, kind: 'remote-attach',
rendererAttached: true, exited: false,
outputBuffer: [], outputBufferSize: 0, altScreen: false,
projectPath, firstResize: true,
cwd,
isPlainTerminal: false,
_openedAt: Date.now(),
};
activeSessions.set(sessionId, remoteSession);
wireSessionPty(remoteSession, sessionId, ptyProcess);
return remoteSession;
}

ipcMain.handle('open-terminal', async (_event, sessionId, projectPath, isNew, sessionOptions, initialSize) => {
if (!mainWindow) return { ok: false, error: 'no window' };

Expand Down Expand Up @@ -2365,20 +2408,9 @@
if (!attachResult.ok) return { ok: false, error: attachResult.error || REMOTE_READ_ONLY };

const remoteCwd = (descriptor && typeof descriptor.cwd === 'string') ? descriptor.cwd : null;
const remoteSession = {
pty: attachResult.ptyProcess,
// handle: {write, isAlive} — see .ai/contexts/trigger-watcher.md, "Session handle"
handle: attachResult.ptyProcess,
host: alias, kind: 'remote-attach',
rendererAttached: true, exited: false,
outputBuffer: [], outputBufferSize: 0, altScreen: false,
projectPath, firstResize: true,
cwd: remoteCwd,
isPlainTerminal: false,
_openedAt: Date.now(),
};
activeSessions.set(sessionId, remoteSession);
wireSessionPty(remoteSession, sessionId, attachResult.ptyProcess);
const remoteSession = registerRemoteAttachSession(sessionId, {
alias, projectPath, cwd: remoteCwd, ptyProcess: attachResult.ptyProcess,
});
return { ok: true, reattached: false, remote: true, sandbox: false, generation: remoteSession.generation };
}
}
Expand Down Expand Up @@ -2439,7 +2471,7 @@
// WSL profiles only work for plain terminals — Claude CLI sessions need the
// Windows shell because session data lives on the Windows filesystem.
const requestedProfile = resolveShell(effectiveProfileId);
const useWslProfile = isWslShell(requestedProfile.path) && isPlainTerminal;

Check warning on line 2474 in main.js

View workflow job for this annotation

GitHub Actions / lint

'useWslProfile' is assigned a value but never used. Allowed unused vars must match /^_/u
const shellProfile = (isWslShell(requestedProfile.path) && !isPlainTerminal)
? resolveShell('auto')
: requestedProfile;
Expand Down
1 change: 1 addition & 0 deletions preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ contextBridge.exposeInMainWorld('api', {
stopSession: (id) => ipcRenderer.invoke('stop-session', id),
// see .ai/contexts/session-state.md ("The two lifecycle verbs: detach and stop")
remoteStopSession: (alias, sessionId) => ipcRenderer.invoke('remote-stop-session', { alias, sessionId }),
remoteLaunchSession: (payload) => ipcRenderer.invoke('remote-launch-session', payload),
remoteSendPrompt: (alias, sessionId, text) => ipcRenderer.invoke('remote-send-prompt', { alias, sessionId, text }),
toggleStar: (id) => ipcRenderer.invoke('toggle-star', id),
renameSession: (id, name) => ipcRenderer.invoke('rename-session', id, name),
Expand Down
49 changes: 48 additions & 1 deletion public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -1115,10 +1115,12 @@ async function loadProjects({ resort = false } = {}) {
hasReinjected = true;
// Still pending — re-inject into cached data
for (const projList of [cachedProjects, cachedAllProjects]) {
let proj = projList.find(p => p.projectPath === pending.projectPath);
const pendingAlias = pending.session.remoteAlias || null;
let proj = projList.find(p => p.projectPath === pending.projectPath && (p.remoteAlias || null) === pendingAlias);
if (!proj) {
// Project not in list (no other sessions) — create a synthetic entry
proj = { folder: pending.folder, projectPath: pending.projectPath, sessions: [] };
if (pendingAlias) proj.remoteAlias = pendingAlias;
projList.unshift(proj);
}
if (!proj.sessions.some(s => s.sessionId === sid)) {
Expand Down Expand Up @@ -1206,6 +1208,51 @@ async function launchNewSession(project, sessionOptions) {
pollActiveSessions();
}

// see .ai/contexts/session-cache.md ("Remote hosts — launching a session")
async function launchRemoteSession(project, { cwd, options }) {
const alias = project.remoteAlias;
const sessionId = crypto.randomUUID();
const session = {
sessionId,
summary: 'New session',
firstPrompt: '',
projectPath: cwd,
name: null,
starred: 0,
archived: 0,
messageCount: 0,
modified: new Date().toISOString(),
created: new Date().toISOString(),
remoteAlias: alias,
};

const folder = alias + '::' + encodeProjectPath(cwd);
pendingSessions.set(sessionId, { session, projectPath: cwd, folder });
sessionMap.set(sessionId, session);
for (const projList of [cachedProjects, cachedAllProjects]) {
let proj = projList.find(p => p.remoteAlias === alias && p.projectPath === cwd);
if (!proj) {
proj = { folder, projectPath: cwd, remoteAlias: alias, sessions: [] };
projList.unshift(proj);
}
proj.sessions.unshift(session);
}
refreshSidebar();

const entry = createTerminalEntry(session);
const result = await window.api.remoteLaunchSession({ alias, sessionId, cwd, options, initialSize: entry.initialSize });
if (!result.ok) {
entry.terminal.write(`\r\nError: ${result.error}\r\n`);
entry.closed = true;
showSession(sessionId);
return;
}
syncPtySizeAfterOpen(entry);
showSession(sessionId);
schedulePersistWorkingSet();
pollActiveSessions();
}

// Legacy alias
function openNewSession(project) {
return launchNewSession(project);
Expand Down
Loading
Loading