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
11 changes: 11 additions & 0 deletions .ai/contexts/session-cache.md
Original file line number Diff line number Diff line change
Expand Up @@ -856,6 +856,17 @@ also returns `blocked` (why nothing could be read, or null) and the normalised `

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

The trigger entry point also uses the one `remoteSendAdapter` instance when
`remoteTriggers` is enabled. Its global default is in `SETTING_DEFAULTS`; the
context getter checks it without requiring a restart. Send and triggers share
the 30-second dedupe and a bucket per alias/session id: 30 tokens, refill 0.5/s,
reserved before running the command, refunded on definite failures, retained
on ambiguous writes. Failure codes distinguish pre-write refusals from
`timeout`/`exit` with `maybeWritten: true`; success remains exactly `{ ok: true }`.
`findSessionAliases(id, isEnabled)` returns all enabled matching hosts without
changing the older singular lookup. See `trigger-watcher.md`, "Remote socket
targets", for trigger guards and the two-pull rule.

`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.
Expand Down
93 changes: 90 additions & 3 deletions .ai/contexts/trigger-watcher.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,9 @@ probes liveness through a `handle` — `{ write(data), isAlive() }` — that
local handle: `write` calls `ptyProcess.write`, `isAlive` is the same
signal-0 probe (`process.kill(pid, 0)`, `EPERM` counts as alive) that used
to live inline in `trigger-watcher.js` as `defaultIsPtyAlive`. `getPtyForSession`
uses it whenever `session.host == null`; a non-null host would instead take
`session.handle` as given — nothing currently sets that, since no remote
session type exists yet.
uses it whenever `session.host == null`; a non-null host takes
`session.handle` from the tmux-attach entry. Unattached remote targets use
the separate socket branch described below and do not construct a handle.
- `trigger-watcher.js` deduces the same local handle itself
(`resolveHandle(entry)`, wrapping `entry.ptyProcess`) whenever a ctx doesn't
supply `entry.handle` — this keeps every ctx implementation that predates
Expand All @@ -70,6 +70,93 @@ Was a seam only: as of issue #221, `main.js`'s tmux-attach branch is the
first production caller to set a non-null `host` and a real `session.handle`
— see `.ai/contexts/session-cache.md`, "Remote hosts — tmux attach".

### Remote socket targets (issue #437)

Live local and tmux-attached entries retain precedence over `ctx.remote`, so
terminal input still respects the composer. An exited pane returns no entry
and may resolve to a remote socket. The optional context dependency is a
getter: the global `remoteTriggers` setting defaults to false in
`SETTING_DEFAULTS`, and is read at trigger time rather than startup. There is
no UI. Remote reach is opt-in because a writer with access to the triggers
directory may otherwise gain access to unattached sessions; the sandbox bind
policy is owned separately.

`findSessionAliases` filters the cached descriptors using the enabled-host
predicate on every lookup. Each poll obtains a fresh remote dependency from
the getter, reads the global settings once and builds one enabled-alias set.
The context passes the predicate over that set to the indexer without reading
the settings a second time or once per alias. Disabled hosts can remain in
the cache, so a pull's
listing alone is insufficient authorization. No aliases means not found;
multiple aliases means a refusal naming them all. Neither alias nor descriptor,
pid or socket path comes from a trigger field. The shared adapter validates
the main-side path, refuses Windows pipes without reading key files, probes
the pid and puts the session id in the NDJSON line. Prompt text is stdin only;
these modules add no process-spawn site.

Remote targets support a single command, `none`/`idle`, `timeout_ms` and
`expectedCwd`. The cwd guard uses `path.posix.normalize`, is case-sensitive
on all platforms, strips a trailing slash except at root, and refuses a missing
cwd. Chains are refused because a complete turn can occur between pulls and
no inter-step readiness proof is available. The session lock already acquired
by the watcher is held for the entire idle wait and adapter call.

Idle polling uses `pollLoop` and its unref'd timers. It never refreshes a host.
The indexer stores its descriptor list before recording completed-pull `at`;
therefore `at >= start` alone can describe a pre-start fetch. Remember the first
post-start `at`, and accept status only from a later, strictly larger one.
The second pull started after the first completed. It proves a fetch after
the wait began, with a read up to a refresh cycle plus latency and one poll
old; it does not prove idle now. It costs about two cycles, up to ten minutes
at the default interval before latency; the 600000 ms cap can still expire.
No settle window compares the host clock with local time. A change of resolved
host during the wait is refused; pulls from different hosts cannot establish
freshness for one target.

Fresh idle permits the send; busy, waiting and shell keep waiting with distinct
deadline reasons. Unknown or absent fresh status refuses immediately. Fewer
than two post-start pulls gives `REASON_REMOTE_NO_FRESH_PULL`; shell uses
`REASON_REMOTE_SHELL`. Missing descriptors or a disabled host end the wait as
`session exited during wait`. Attachment is checked on each poll and immediately
before writing, so a user who attaches during the wait prevents a socket send.
`none` bounds descriptor age to twice `normalizeRefreshMs(remoteRefreshMs)`;
null `at` or an older pull refuses with the last backoff error. A newly started
id is not found until a pull lists it.

The queue channel does not type into a composer: no politeness wait, dialog
hold, Enter retry, busy edge, transcript fallback or post-send confirmation.
Success records `channel: socket`, host and the pre-write descriptor's status,
host `statusUpdatedAt` and local pull time, with absent status fields explicitly
null. Submission is assumed only, with zero retries and no confirmed field.
The adapter's definite failures map to not sent, a dead target maps to target
process not running, and `maybeWritten` maps to send unconfirmed with written
unknown. Callers must not treat an ambiguous write as a safe retry.

One adapter is shared by Send and triggers. It reserves the existing dedupe key
and a token from an alias/session bucket of 30 at 0.5 tokens/s before spawning.
Definite failures refund tokens; timeouts and unknown exits retain them. A
timeout retains dedupe; other non-zero exits preserve the earlier release
behavior. Pruning also removes buckets whose elapsed refill makes them full
and which have no pending send. Depleted buckets retain their rate history;
pending sends retain the bucket referenced by their refund callback.
The remote command check strips leading whitespace and U+200B–U+200D, U+2060
and U+FEFF, then trailing whitespace. This exposes the first visible prefix
without changing plain prompt text. A case-sensitive fixed allow-list accepts
only `/compact` and `/clear`, sent from the constants, without arguments.
First-visible `!` and `#` are refused before waiting, with
`REASON_REMOTE_BASH` and `REASON_REMOTE_MEMORY`; unsupported `/` retains
`REASON_REMOTE_SLASH`. The three exact strings are documented in
`docs/automation.md`, "Remote trigger targets". Invisible characters elsewhere
are preserved rather than banned globally, and mid-prompt `!`/`#` remain text.
Their effect over the socket is UNVERIFIED, as is queuing during a permission
dialog. L1-L7 require a real host and isolated running instance.

Tests exercise the real file watcher with a fake descriptor source and the
real context/indexer for host enablement. Polling tests keep a ref'd interval
in `finally`-protected test scope for Node 20/22; production timers stay unref'd.
U4 and U20 are guard-only tests. Mutation artifacts and commands are recorded
in the local PR body.

## The submission contract

The transport honours `conventions/session-trigger-transport.md` in the harness
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
- Single triggers can send prompts to unattached remote sessions when the global `remoteTriggers` setting is enabled; it defaults to off and has no Settings control yet. (#437)
- 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)
Expand Down
83 changes: 83 additions & 0 deletions docs/automation.md
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,88 @@ The comparison is by directory: two sessions in the same directory are not told
apart. 8.3 short names, `subst` drives, junctions and symbolic links are not
resolved; two spellings of one directory count as a mismatch.

### Remote trigger targets

Set the global `remoteTriggers` setting to `true` to allow a single trigger to
send a prompt to an unattached remote session. It defaults to `false` and has
no UI yet; enabling it is maintainer-only for now. The value is the JSON
property `"remoteTriggers": true` in the SQLite `settings` table's row whose
`key` is `global`; the row's `value` holds the global settings object
(`db.js`, `getSetting`/`setSetting`). Preserve its other properties when
changing it. Changes take effect without a restart. With it off, an unattached
remote id gives `session not found`.

Use the same `sessionId`, `command`, `wait` (`none` or `idle`), `timeout_ms`
and optional `expectedCwd` fields. A live local or tmux-attached terminal takes
precedence and follows the existing terminal path. An exited pane can fall
through to the socket. Enabled hosts are checked at trigger time and on every
poll; an id listed by more than one enabled host is refused with both aliases
in the reason. Trigger fields such as `host` cannot choose a host. An id
started since the last pull is not found until the next pull. A remote chain
is refused, with `steps_total` set to its length. `expectedCwd` compares the
descriptor's cwd using POSIX normalization, including on Windows: case matters,
a trailing slash does not, and a missing cwd refuses the write.

`wait: none` needs a completed pull no older than twice the host's refresh
interval; a missing or older pull refuses the write and the reason includes
the last refresh error. It does not require a readable status.

`wait: idle` polls the descriptors from the normal refresh cycle; it never
forces a refresh. Only the second distinct completed pull after the wait began
can establish readiness: the first may have fetched its descriptors before the
wait began. `idle` permits the send, `busy`, `waiting` and `shell` keep waiting,
and an absent or unknown fresh status refuses the send. At the deadline the
reason distinguishes busy, a dialog, a shell command and fewer than two fresh
pulls. Losing the descriptor or disabling the host during the wait gives
`session exited during wait`. Attaching a terminal during the wait aborts it.

This may need about two refresh cycles: about two minutes at the 60-second
floor or ten minutes at the default 300 seconds, plus pull latency. At the
default interval, use `timeout_ms: 600000` (the cap); it can still expire before
two pulls finish. The idle read is at most one cycle plus pull latency and one
poll old, and does not prove the session is idle at the moment of sending.
Host status timestamps are never compared with the local clock. A target
also cannot move to another host during the wait; that change refuses
the send rather than combining pulls from different hosts. The per-session
lock remains held throughout the idle wait and the bounded 15-second send;
another trigger for that id queues behind it.

The socket queues text without typing into the composer, so no composer check,
dialog hold, Enter retry or transcript confirmation runs on this path. Windows
hosts are refused with the key-file reason. Only exact `/compact` and `/clear`
after prefix normalization are allowed slash commands; their constant text is
sent. Normalization removes leading whitespace and U+200B–U+200D, U+2060 and
U+FEFF, then trailing whitespace. Other leading-slash commands, arguments and
case variants are refused before any wait. A first visible `!` (bash mode) or
`#` (memory) is also refused. Plain prompts retain their original text,
including `!` and `#` in the middle and invisible characters outside the
prefix check. Each refusal returns `error: "not sent"` with its own reason:

| Prefix | `reason` |
|---|---|
| Unsupported `/` | `a slash command other than /compact and /clear cannot be sent to a remote session; nothing was written` |
| `!` | `a bash-mode command cannot be sent to a remote session; nothing was written` |
| `#` | `a memory command cannot be sent to a remote session; nothing was written` |

**The effect of `/compact` and `/clear` over the socket is UNVERIFIED**;
they may execute as commands or arrive as plain text. Whether a prompt during
a permission dialog is queued or lost is also unverified.

Successful socket results add `channel: "socket"`, `host` and
`descriptor: { status, status_updated_at, pulled_at }`, captured before the
write; absent status fields are `null`. `pulled_at` is local epoch milliseconds,
while `status_updated_at` is the host's timestamp. Success is always
`submitted: "assumed"`, `submit_retries: 0`, with no `submit_confirmed`: the
channel has no reply and can silently drop a prompt. Compare a later status
timestamp from the same host to confirm the effect yourself.

The Send dialog and triggers share the adapter's 30-second dedupe and a bucket
of 30 prompts per host and session, refilling one token every two seconds.
Identical text repeated within 30 seconds is refused before any send; a timeout
also holds that dedupe reservation. A definite failure refunds its bucket token;
an uncertain write keeps it. Other non-zero exits retain the existing dedupe
release behavior. The server can still silently drop a prompt.

### Reading a result

Every outcome — success, refusal, timeout, missing session — writes
Expand Down Expand Up @@ -444,6 +526,7 @@ never in `error`: `not sent: input pending` is not `not sent`.
| `error` | What it promises | What to do |
|---|---|---|
| `not sent` | **not one byte reached the session**: no idle came, politeness never allowed a write, or the trigger was refused before any write (stale, bad `wait`, bad `expectedCwd`, target guard) | nothing happened; it is safe to send again |
| `send unconfirmed` | a socket write timed out or returned an unclassified non-zero exit; the line may have reached the session; `written` is `unknown` and `submitted` is `no` | check the session before retrying; a timeout reserves identical text for 30 seconds |
| `chain timeout` | at least one step **was written**, and the expected effect was not observed before the deadline | assume the written steps landed |
| `step not confirmed` | a chain step **was written**, its submission was not confirmed by the CLI's descriptor, and the recovery Enter was withheld (the descriptor reads `busy` or `waiting`, or input of your own is pending in the composer); the chain stopped there and nothing more was typed. For a local session whose descriptor reads `busy`, the chain first waits for the step to show in the session transcript with its turn finished: up to 30 s for the step to show at all (up to the step's deadline for `/compact`, which shows only when compaction ends), then up to the step's deadline for its turn to finish; `reason` says which wait ran out | after a dialog or input of your own, the step may sit unsubmitted in the composer: look before sending again. After a wait under `busy`, the step may still be running or queued in the CLI: look at the session before sending again |
| anything else | free text: `session not found`, `target process not running`, `missing required field`, `invalid timeout_ms`, `command and chain are mutually exclusive`, `trigger too large (max 64 KB)`, `command too long (max 4 KB)`, `trigger must be a regular file`, `pty write failed: …` | read `submitted` to know whether anything landed |
Expand Down
9 changes: 9 additions & 0 deletions docs/remote-hosts.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,15 @@ works, read-only, by running git over ssh in the session's directory.

## Send a prompt

[Remote triggers](automation.md#remote-trigger-targets) can use the same socket
adapter when the global `remoteTriggers` setting is enabled (default off, no
Settings control yet). They share the Send dialog's 30-second dedupe and the
per-host-session bucket of 30 prompts, refilling one every two seconds. Only
single commands to unattached sessions use the socket; attached terminals keep
their existing trigger behavior. Results say `assumed` on success and
`send unconfirmed` when a write may have happened. Remote idle waits read two
completed pulls passively; they never request a refresh.

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
Expand Down
19 changes: 17 additions & 2 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 Down Expand Up @@ -482,13 +482,13 @@
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');

// --- Remote SSH hosts (observation only) — see .ai/contexts/session-cache.md ---
const { isRemoteFolder, parseFolderKey, joinFolderKey, enabledHosts, normalizeHosts } = require('./remote-hosts');
const { isRemoteFolder, parseFolderKey, joinFolderKey, enabledHosts, normalizeHosts, normalizeRefreshMs } = require('./remote-hosts');
const { handleEnrolRequest } = require('./remote-enrol');
const REMOTE_READ_ONLY = 'remote sessions are read-only — this build observes them, it does not attach to them';
const { createSshTransport } = require('./remote-transport');
Expand Down Expand Up @@ -2478,7 +2478,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 2481 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 Expand Up @@ -3215,7 +3215,22 @@
// I3: wrapped in try/catch so a boot failure here doesn't abort
// app.whenReady (auto-updater, etc. would otherwise be silently lost).
try {
require('./trigger-watcher').start(createTriggerContext({ activeSessions, log, getCliStatus: (id) => cliSessionState.getStatus(id), projectsDir: PROJECTS_DIR }));
require('./trigger-watcher').start(createTriggerContext({
activeSessions, log, getCliStatus: (id) => cliSessionState.getStatus(id), projectsDir: PROJECTS_DIR,
get remote() {
const settings = getSetting('global') || {};
if ((settings.remoteTriggers ?? SETTING_DEFAULTS.remoteTriggers) === true) {
const aliases = new Set(enabledHosts(settings.remoteHosts).map(host => host.alias));
return {
indexer: remoteIndexer,
adapter: remoteSendAdapter,
isEnabled: alias => aliases.has(alias),
maxAgeMs: 2 * normalizeRefreshMs(settings.remoteRefreshMs),
};
}
return undefined;
},
}));
} catch (err) {
log.error('[trigger-watcher] Failed to start trigger watcher:', err.message);
}
Expand Down
1 change: 1 addition & 0 deletions public/setting-defaults.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const SETTING_DEFAULTS = {
autoUpdate: true,
shellProfile: 'auto',
activityReporting: false, // see .ai/contexts/activitywatch.md
remoteTriggers: false,
lastSeenVersion: null, // see docs/changelog.md
};

Expand Down
Loading
Loading