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
54 changes: 54 additions & 0 deletions .ai/contexts/session-cache.md
Original file line number Diff line number Diff line change
Expand Up @@ -758,6 +758,60 @@ created the `.jsonl`; a manual host refresh did not help.
rel path, not its own, because `readSubagentMeta()` in the transcript's
row is what actually needs re-deriving.

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

`remote-send.js` writes one prompt to a live, unattached remote session through
the CLI's own messaging socket. Send only: nothing is read back, the state comes
from the descriptor the refresh cycle already pulls.

- **Protocol** (measured in the issue, CLI 2.1.263): NDJSON over a unix socket,
one line `{"type":"user","message":{"role":"user","content":...},"msgV":1,"session_id":...}`
terminated by `
`, capped at 1 MiB, first line within 30 s. The connection is
one-way; the server never answers on it. No auth line on POSIX (the peer is
identified by `SO_PEERCRED`); on Windows the token lives in a `.key` file that
the descriptor fetch and the denylist exclude on purpose, so a `\.\pipe\`
path is refused, not worked around.
- **`session_id` is in the line** so a descriptor that outlived its process, whose
pid was reused, never has its prompt accepted by another session.
- **The text is stdin only.** `defaultRunRemoteCommand` takes an `input` option:
stdin becomes a pipe, `-n` (which points ssh's stdin at the null device) is
dropped, the line is written and stdin closed. Same spawn site as every other
remote ssh, so `remote-ssh-spawn-sites.test.js` is unchanged. The remote
command holds fixed text, the integer pid and the single-quoted path.
The script is passed as `sh -c '<script>'` (one single-quoted word), so the
login shell of the host never parses it; `$(...)` and `if ...; then` fail under
fish. The tmux probe and stop commands in `remote-attach.js` / `remote-stop.js`
are still raw strings and share that problem; not changed here.
- **The path is main-side only.** `messagingSocketPath` stays in the descriptor
`parseSessions` keeps; the renderer sends `{alias, sessionId, text}` and
`handleSendRequest` looks the descriptor up. `validateSocketPath` is stricter
than `isSafeSocketPath` (which also guards tmux sockets): `^/[A-Za-z0-9._/-]+\.sock$`,
no `..`, at most 107 bytes (`sockaddr_un`). `buildSendCommand` throws on a path
it would refuse.
- **nc variants**: the command probes `ncat --help` for `--send-only` and
`nc -h` for an OpenBSD usage line carrying `N` and `U`; a BusyBox or
netcat-traditional `nc` is never run with flags it would reject, the command
exits 127 instead. **Exit codes** of the remote command: 7 the pid is no longer a `claude`
process, 8 the socket is gone, 127 no `ncat`/`nc`. Anything else is a failure
carrying ssh's stderr. A timeout (nc did not exit after the line was written)
is a failure saying nothing confirms the write, never a success.
- **30 s dedupe** is client-side and per host, session and text, on an injectable
clock. The key is reserved before the ssh spawns, so two concurrent sends of the
same text go once; a definite failure releases it, a timeout keeps it (the line
may already be on the socket). The server also has a
30-token bucket refilling at 0.5/s; nothing here retries.
- **Entry point**: the `session-send-btn` on remote rows (CSS-gated like Stop:
shown for `.is-alive` and not `.has-running-pty`), and `showSendPromptDialog`
in `public/dialogs.js`. An attached session is refused main-side as well.
- Not done, on purpose: replies and idle notification (they need an inbox of our
own and a published key), Windows hosts, trigger files targeting remote ids,
and the attention state.
- Tests: `remote-send.test.js` (the line, the path, the command run through a real
`sh` with a fake `nc`, exit codes, byte cap, dedupe, IPC contract),
`remote-run-input.test.js`, `dom-sidebar-remote-send.test.js`,
`dom-send-prompt-dialog.test.js`.

## Remote hosts — tmux attach (issue #221)

`open-terminal` no longer refuses every remote session outright. When
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ What changes for you in each release of Switchboard. How to write an entry: [doc
## Unreleased

### New
- A live session on a remote host that is not open in a terminal has a Send a prompt… button on its row: type a text and it is written to the running session as a new prompt, without attaching. It needs `ncat` or an OpenBSD `nc` on the host, and is refused for a Windows host. The dialog says "Sent": the session's own status shows whether it picked the prompt up. (#219)
- A remote session that is not open in a tab and waits on a dialog on its host, such as a permission prompt or a question, shows the orange attention state, and its status line says what it waits for. It appears and clears with the next refresh of the host. (#394)

## v0.0.86 — 2026-10-01
Expand Down
26 changes: 26 additions & 0 deletions docs/remote-hosts.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,32 @@ works, read-only, by running git over ssh in the session's directory.
- **Delete** is refused: the mirrored transcript is a copy that the next pull
would fetch again.

## Send a prompt

A live session that is not attached in a terminal has a **Send a prompt…**
button next to Stop. It opens a small dialog; Send (or Ctrl+Enter) writes the
text to the running session as a new prompt. The dialog says *Sent*, never
*delivered*: nothing comes back on that channel, so Switchboard cannot know the
session read it. Read the result in the row's status, which the next refresh
picks up from the session's descriptor.

How it works: the session's descriptor names a messaging socket
(`messagingSocketPath`). Switchboard runs one `ssh` to the host, checks that the
pid is still a `claude` process and that the socket exists, then pipes a single
line of JSON into the socket with `ncat --send-only -U` or `nc -N -U`. The text
travels on ssh's standard input only, never on a command line.

- The host needs `ncat` or an OpenBSD `nc` that supports `-U` and closes on end
of input. A BusyBox `nc` has no `-U`; the dialog then says nc was not found.
- The socket path is read from the descriptor on the host, never typed or sent
by the interface, and must be an absolute `.sock` path of plain characters.
- A prompt is limited to 1 MiB once encoded. The same text sent to the same
session twice within 30 seconds is refused here, because the session would
drop it.
- A host running Windows is refused: its channel needs the session's key file,
which Switchboard does not read.
- A session attached in a terminal is refused: type in the terminal.

## Known limits

Session ids are not namespaced per host. Two hosts with a session of the same
Expand Down
1 change: 1 addition & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ const rendererCrossFileGlobals = {
renderActivityTraceFiles: 'readonly',
openActivityTraceFile: 'readonly',
showResumeSessionDialog: 'readonly',
showSendPromptDialog: 'readonly',
showJsonlViewer: 'readonly',
showSubagentTranscript: 'readonly',
narrowSessionsToSearch: 'readonly',
Expand Down
15 changes: 15 additions & 0 deletions main.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@
}

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

Check warning on line 69 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 69 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, refusedScheduleBinds, resolveScheduleSandbox, scheduleRegistry } = require('./schedule-runner');
const { encodeProjectPath } = require('./encode-project-path');
const { SETTING_DEFAULTS } = require('./public/setting-defaults');
Expand All @@ -80,6 +80,7 @@
const { createTriggerContext } = require('./trigger-context');
const { createTmuxAttachAdapter } = require('./remote-attach');
const { createRemoteStopAdapter } = require('./remote-stop');
const { createRemoteSendAdapter, handleSendRequest } = require('./remote-send');
const { createGitChangesRunner } = require('./git-changes-runner');
const gitChangesTarget = require('./git-changes-target');
const terminalPathTarget = require('./terminal-path-target');
Expand Down Expand Up @@ -472,8 +473,8 @@
isInitialScanComplete, setInitialScanComplete,
},
});
const { readSessionFile, readFolderFromFilesystem, refreshFolder, reconcileCacheFromFilesystem,

Check warning on line 476 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 476 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 477 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, enumerateSessionFiles } = require('./read-session-file');

Expand Down Expand Up @@ -552,6 +553,9 @@
// see .ai/contexts/session-state.md ("The two lifecycle verbs: detach and stop")
const remoteStopAdapter = createRemoteStopAdapter({ log });

// see .ai/contexts/session-cache.md ("Remote hosts — sending a prompt")
const remoteSendAdapter = createRemoteSendAdapter({ 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 @@ -1705,6 +1709,17 @@
return result;
});

// --- IPC: remote-send-prompt ---
// see .ai/contexts/session-cache.md ("Remote hosts — sending a prompt")
ipcMain.handle('remote-send-prompt', (_event, payload) => handleSendRequest(payload, {
getDescriptor: (alias, sessionId) => remoteIndexer.getRemoteSessions(alias).sessions.find(s => s.sessionId === sessionId),
isAttached: (sessionId) => {
const attached = activeSessions.get(sessionId);
return !!(attached && attached.kind === 'remote-attach' && !attached.exited);
},
adapter: remoteSendAdapter,
}));

// --- IPC: git-changes-status / git-changes-diff — see .ai/contexts/changes-view.md ---
function resolveGitChangesTarget(sessionId) {
return gitChangesTarget.resolveGitChangesTarget(sessionId, {
Expand Down Expand Up @@ -2373,7 +2388,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 2391 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 }),
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),
archiveSession: (id, archived) => ipcRenderer.invoke('archive-session', id, archived),
Expand Down
67 changes: 67 additions & 0 deletions public/dialogs.js
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,73 @@ async function showResumeSessionDialog(session) {
document.addEventListener('keydown', onKey);
}

// see .ai/contexts/session-cache.md ("Remote hosts — sending a prompt")
function showSendPromptDialog(session) {
const overlay = document.createElement('div');
overlay.className = 'new-session-overlay';

const dialog = document.createElement('div');
dialog.className = 'new-session-dialog';

const title = document.createElement('h3');
title.textContent = 'Send a prompt — ' + session.remoteAlias;
const textarea = document.createElement('textarea');
textarea.className = 'send-prompt-textarea';
textarea.rows = 6;
textarea.spellcheck = false;
textarea.placeholder = 'The text is written to the running session as a new prompt';
const status = document.createElement('div');
status.className = 'send-prompt-status';
const actions = document.createElement('div');
actions.className = 'new-session-actions';
const cancelBtn = document.createElement('button');
cancelBtn.className = 'new-session-cancel-btn';
cancelBtn.textContent = 'Close';
const sendBtn = document.createElement('button');
sendBtn.className = 'new-session-start-btn send-prompt-send-btn';
sendBtn.textContent = 'Send';
actions.append(cancelBtn, sendBtn);
dialog.append(title, textarea, status, actions);
overlay.appendChild(dialog);
document.body.appendChild(overlay);
overlay.tabIndex = -1;
textarea.focus();
dialog.addEventListener('click', (e) => { if (e.target !== textarea) textarea.focus(); });

function close() {
overlay.remove();
}

async function send() {
const text = textarea.value;
if (!text.trim() || sendBtn.disabled) return;
sendBtn.disabled = true;
status.textContent = 'Sending…';
let result;
try {
result = await window.api.remoteSendPrompt(session.remoteAlias, session.sessionId, text);
} catch (err) {
result = { ok: false, error: err && err.message ? err.message : 'unknown error' };
}
sendBtn.disabled = false;
if (result && result.ok) {
textarea.value = '';
status.textContent = 'Sent. The session reads it when it is next free.';
} else {
status.textContent = (result && result.error) || 'unknown error';
}
}

cancelBtn.onclick = close;
sendBtn.onclick = send;
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });

overlay.addEventListener('keydown', (e) => {
if (e.key === 'Escape') close();
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) send();
});
}

// Settings viewer is in settings-panel.js (openSettingsViewer / closeSettingsViewer)
// Global settings button & add project button bindings are in app.js (need DOM refs)

Expand Down
14 changes: 14 additions & 0 deletions public/sidebar.js
Original file line number Diff line number Diff line change
Expand Up @@ -1272,6 +1272,14 @@ function rebindSidebarEvents(projects) {
};
}

const sendBtn = item.querySelector('.session-send-btn');
if (sendBtn) {
sendBtn.onclick = (e) => {
e.stopPropagation();
showSendPromptDialog(session);
};
}

const launchConfigBtn = item.querySelector('.session-launch-config-btn');
if (launchConfigBtn) {
launchConfigBtn.onclick = (e) => {
Expand Down Expand Up @@ -1487,6 +1495,11 @@ function buildSessionItem(session) {
stopBtn.title = 'Stop session';
stopBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor"><rect x="2" y="2" width="8" height="8" rx="1"/></svg>';

const sendBtn = document.createElement('button');
sendBtn.className = 'session-send-btn';
sendBtn.title = 'Send a prompt…';
sendBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 2 11 13"/><path d="M22 2 15 22l-4-9-9-4z"/></svg>';

const archiveBtn = document.createElement('button');
archiveBtn.className = 'session-archive-btn';
archiveBtn.title = session.archived ? 'Unarchive' : 'Archive';
Expand Down Expand Up @@ -1518,6 +1531,7 @@ function buildSessionItem(session) {
launchConfigBtn.innerHTML = ICONS.launchConfig(14);

actions.appendChild(stopBtn);
if (session.remoteAlias) actions.appendChild(sendBtn);
if (session.type !== 'terminal') {
actions.appendChild(forkBtn);
// see .ai/contexts/session-cache.md ("Remote hosts — descriptor-only sessions")
Expand Down
39 changes: 39 additions & 0 deletions public/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -1166,6 +1166,7 @@ body { display: flex; flex-direction: column; }
}

.session-stop-btn,
.session-send-btn,
.session-launch-config-btn,
.session-fork-btn,
.session-jsonl-btn,
Expand Down Expand Up @@ -1195,6 +1196,15 @@ body { display: flex; flex-direction: column; }
color: #9b4058;
}

.session-send-btn {
color: #5a7a6a;
}

.session-send-btn:hover {
color: #3ecf5a;
background: rgba(62,207,90,0.1);
}

.session-launch-config-btn {
color: #5a7a6a;
outline: none;
Expand Down Expand Up @@ -1241,6 +1251,14 @@ body { display: flex; flex-direction: column; }
display: flex;
}

.session-send-btn {
display: none;
}

.session-item.is-alive:not(.has-running-pty) .session-send-btn {
display: flex;
}

.session-item.has-running-pty .session-launch-config-btn {
display: none;
}
Expand Down Expand Up @@ -4139,6 +4157,27 @@ body { display: flex; flex-direction: column; }
color: rgba(224,80,112,0.5);
}

.send-prompt-textarea {
width: 100%;
box-sizing: border-box;
resize: vertical;
background: var(--control-surface);
border: 1px solid var(--control-border);
border-radius: 6px;
color: inherit;
font-family: inherit;
font-size: 13px;
padding: 8px 10px;
}

.send-prompt-status {
min-height: 18px;
margin-top: 8px;
font-size: 12px;
color: var(--text-muted);
word-break: break-word;
}

.new-session-actions {
display: flex;
justify-content: flex-end;
Expand Down
23 changes: 15 additions & 8 deletions remote-attach.js
Original file line number Diff line number Diff line change
Expand Up @@ -228,22 +228,24 @@ function parseDiscoveryProbeOutput(stdout) {
}

// see .ai/contexts/session-cache.md ("Remote hosts — tmux attach", ConnectTimeout on the probe/restore ssh)
function buildRemoteCommandArgs(alias, command) {
return ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5', '-n', alias, command];
function buildRemoteCommandArgs(alias, command, { input } = {}) {
const head = ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5'];
return typeof input === 'string' ? [...head, alias, command] : [...head, '-n', alias, command];
}

// Default stdout cap for a single ssh exec — see .ai/contexts/changes-view.md ("Remote transport stdout cap").
const DEFAULT_MAX_STDOUT_BYTES = 8 * 1024 * 1024;

// see .ai/contexts/session-cache.md ("Remote hosts — tmux attach") and .ai/contexts/changes-view.md ("Remote transport stdout cap")
function defaultRunRemoteCommand(alias, command, { timeoutMs, maxStdoutBytes, spawnFn, resolveSshPath = defaultResolveSshPath } = {}) {
// see .ai/contexts/session-cache.md ("Remote hosts — tmux attach") and .ai/contexts/changes-view.md ("Remote transport stdout cap"); `input` — .ai/contexts/session-cache.md ("Remote hosts — sending a prompt")
function defaultRunRemoteCommand(alias, command, { timeoutMs, maxStdoutBytes, spawnFn, input, resolveSshPath = defaultResolveSshPath } = {}) {
const spawn = spawnFn || require('child_process').spawn;
const stdoutCap = typeof maxStdoutBytes === 'number' ? maxStdoutBytes : DEFAULT_MAX_STDOUT_BYTES;
const hasInput = typeof input === 'string';
return new Promise((resolve) => {
let child;
try {
child = spawn(resolveSshPath(), buildRemoteCommandArgs(alias, command), {
windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'],
child = spawn(resolveSshPath(), buildRemoteCommandArgs(alias, command, { input }), {
windowsHide: true, stdio: [hasInput ? 'pipe' : 'ignore', 'pipe', 'pipe'],
});
} catch (err) {
resolve({ code: -1, stdout: '', stderr: err.message });
Expand All @@ -254,7 +256,8 @@ function defaultRunRemoteCommand(alias, command, { timeoutMs, maxStdoutBytes, sp
let stderr = '';
let settled = false;
let overflowed = false;
const timer = setTimeout(() => { try { child.kill('SIGKILL'); } catch {} }, timeoutMs || DEFAULT_PROBE_TIMEOUT_MS);
let timedOut = false;
const timer = setTimeout(() => { timedOut = true; try { child.kill('SIGKILL'); } catch {} }, timeoutMs || DEFAULT_PROBE_TIMEOUT_MS);
const finish = (code) => {
if (settled) return;
settled = true;
Expand All @@ -263,8 +266,12 @@ function defaultRunRemoteCommand(alias, command, { timeoutMs, maxStdoutBytes, sp
resolve({ code: -1, stdout: '', stderr: `stdout exceeded ${stdoutCap} bytes` });
return;
}
resolve({ code, stdout, stderr: stderr.slice(0, 4096) });
resolve(timedOut ? { code, stdout, stderr: stderr.slice(0, 4096), timedOut: true } : { code, stdout, stderr: stderr.slice(0, 4096) });
};
if (hasInput && child.stdin) {
child.stdin.on('error', () => {});
child.stdin.end(input);
}
if (child.stdout) child.stdout.on('data', (c) => {
if (overflowed) return;
stdoutBytes += Buffer.byteLength(c);
Expand Down
Loading
Loading