From dab589b9a17ff804d397294e6041f808e6690e8f Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Date: Sat, 3 Oct 2026 09:44:56 +0200 Subject: [PATCH 1/2] (remote): probe tmux and inotifywait and gate attach, send and new session by tier A remote host's affordances were decided row by row from descriptors, so a host without tmux still offered attach and an idle host with tmux read as missing it. One light ssh per host, at the first successful refresh and then every six hours, now reports whether tmux and inotifywait are installed; a failed probe leaves the profile unknown. Attach is withheld while tmux is known missing, Send a prompt is disabled with the inject reason, and the new-session button states the launch tier's reason. Stop is untouched. Refs #218 --- .ai/contexts/session-cache.md | 32 +++- CHANGELOG.md | 1 + docs/remote-hosts.md | 31 +++- main.js | 3 +- public/sidebar.js | 16 +- public/style.css | 8 + remote-host-profile.js | 43 ++++- remote-index.js | 38 ++++- remote-transport.js | 37 ++++- ...ate-remote-attachable-local-status.test.js | 6 +- test/annotate-remote-tier-gates.test.js | 103 ++++++++++++ test/dom-sidebar-remote-tier-gates.test.js | 138 ++++++++++++++++ test/remote-host-profile.test.js | 73 +++++++- test/remote-index-probe.test.js | 156 ++++++++++++++++++ test/remote-transport-probe.test.js | 120 ++++++++++++++ 15 files changed, 778 insertions(+), 27 deletions(-) create mode 100644 test/annotate-remote-tier-gates.test.js create mode 100644 test/dom-sidebar-remote-tier-gates.test.js create mode 100644 test/remote-index-probe.test.js create mode 100644 test/remote-transport-probe.test.js diff --git a/.ai/contexts/session-cache.md b/.ai/contexts/session-cache.md index 53ff58a5..af8777a1 100644 --- a/.ai/contexts/session-cache.md +++ b/.ai/contexts/session-cache.md @@ -781,12 +781,13 @@ 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 — capability tiers (issue #218, first slice) +### Remote hosts — capability tiers (issue #218, first and second slice) `remote-host-profile.js` is a pure function: `computeHostProfile({ at, error, descriptors })` returns `{ tier, tiers, missing }`, the highest of `observe < liveness < inject < attach < launch` that is available plus, for every tier above it, the reason it is not. The indexer's `getRemoteHostProfile(alias)` -feeds it the last cycle's own data (`at`, `error`, live descriptors), so there is no probe and no extra ssh. +feeds it the last cycle's own data (`at`, `error`, live descriptors) and the probe result (`tools`), and the profile +also returns `blocked` (why nothing could be read, or null) and the normalised `tools`. - `none`: never synced, or the last cycle failed. A failed `find ~/.claude/projects` fails the whole cycle, so an unreadable projects directory and an unreachable host are not told apart; the ssh error is the reason. @@ -801,8 +802,31 @@ feeds it the last cycle's own data (`at`, `error`, live descriptors), so there i Stop is never blocked, it runs its own ssh. The renderer only shows the strings: the host dot's tooltip (which states the last error from the first failure), the row and badge titles. - The new-session button was already disabled for every remote host; it is unchanged. -- Not done: the probe for what the descriptors cannot tell (multiplexer installed but no session in it, `inotifywait`), - the inject affordance (issue #219), the launch tier. +- The new-session button was already disabled for every remote host; its title now carries the launch tier's reason. +- Not done: the launch tier itself (issue #222), multiplexers other than tmux (`parseTmuxField` is the only recogniser). + +#### The probe and the gates (second slice) + +- `PROBE_COMMAND` (remote-transport.js) is its own ssh command, pinned exactly in `test/remote-transport-probe.test.js`; + `LIST_COMMAND` is not widened. It prints `tmux=0|1` and `inotifywait=0|1`; `parseProbe` accepts exactly those two + lines and `probeTools` throws on a timeout (15 s), a cap overrun (1 KiB), a non-zero exit or any other output. It goes + through the same `run()`, so the same ssh binary, `BatchMode` and `ConnectTimeout` apply and no new spawn site exists. +- The indexer probes at the end of `refreshHost`, after a successful sync only, at most every `PROBE_INTERVAL_MS` (6 h); + a failed probe is retried after `PROBE_RETRY_MS` (30 min), keeps the previous answer, and never fails the cycle. A + forced reconnect re-arms it without forgetting the answer. A removed alias drops its answer. A transport without + `probeTools` is skipped. +- `tools.` is `true`, `false` or `null` (unknown). Only `false` withholds anything. `tmux === false` makes the attach tier + unavailable even when a descriptor names a pane, because the attach adapter runs a bare `tmux` over the same + non-interactive ssh, so a failing `command -v tmux` there is a failing attach. `tmux === true` makes attach available on + an idle host. Liveness is unchanged: it needs a descriptor, which the probe cannot tell. +- `attachBlockReason(profile, failures)` returns the observe reason from the third failure on (the fallback for a host + whose ssh keeps failing, which wins), else the tmux reason when tmux is known missing, else null. The session carries it in + `remoteAttachBlocked`; the renderer already opens the transcript and puts it in the row and badge titles. +- `sendBlockReason(profile)` is the inject tier's reason on a host that was read (`blocked` null), else null: a failing host + does not disable Send, which runs its own ssh. The tier is host-level, "some live session reports a socket", so a + session without its own socket on a host where another one has one is still offered Send and fails at the click. + `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 — sending a prompt (issue #219) diff --git a/CHANGELOG.md b/CHANGELOG.md index c3f1cda0..80cdd23b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ What changes for you in each release of Switchboard. How to write an entry: [doc - 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) ### Changed +- Switchboard now checks once per host, at the first refresh and then every six hours, 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) - A trigger that gave up waiting for a session now says, in its result file's `reason`, when the session was blocked on a dialog such as a permission prompt or a question: for a single trigger, a chain's first wait, and a chain step whose turn never finished. Without a dialog the result is as before. (#379) ## v0.0.87 — 2026-10-02 diff --git a/docs/remote-hosts.md b/docs/remote-hosts.md index 0efae426..c6589654 100644 --- a/docs/remote-hosts.md +++ b/docs/remote-hosts.md @@ -81,7 +81,7 @@ next use; one installed after nothing was found is seen after a restart. `~/.claude/sessions/.json` descriptors. - For live updates: `inotifywait` (inotify-tools). Without it, only the periodic pull runs, and a warning is logged. -- For attach: sessions started inside tmux. +- For attach: `tmux` installed, and sessions started inside it. - For the [Changes view](changes-view.md): `git`. ## What is copied @@ -122,10 +122,31 @@ The project header carries a dot for the host's state, with a tooltip: Hovering the dot also lists the host's capability: the highest of observe, liveness, inject, attach and launch that its last refresh could confirm, and for -each one above it why it is missing (for example no live session names a tmux -pane). A tier that needs a live session reads as missing on an idle host. After -three failed refreshes in a row, a row that would attach opens its transcript, -with the reason in its tooltip; **Stop** is never disabled, it runs its own ssh. +each one above it why it is missing (for example no live session reports a +messaging socket). Liveness and inject need a live session to read, so they read +as missing on an idle host. + +Once per host, at its first successful refresh and then every six hours (and on +**Reconnect**), one extra ssh asks whether `tmux` and `inotifywait` are +installed and nothing else. A host with `tmux` and no session offers attach; a +host without it does not, even when a descriptor names a pane, and the dot says +so. A host without `inotifywait` says that only the periodic pull runs. When the +probe fails (timeout after 15 seconds, refused, unreadable answer), the host +stays as it was and the probe is tried again 30 minutes later: an unknown answer +is never shown as missing. + +What the tier gates: + +- **Attach.** A row attaches only when the host has `tmux` (as far as the probe + 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. +- **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. +- **Stop** is never disabled, it runs its own ssh. A failing host is retried with a doubling delay, up to 30 minutes, and never dropped; one success resets it. **Reconnect** on the header retries at once and diff --git a/main.js b/main.js index 95e56b23..d1f2b13b 100644 --- a/main.js +++ b/main.js @@ -82,7 +82,7 @@ const { handleTerminalInput } = require('./terminal-input'); const { createTriggerContext } = require('./trigger-context'); const { createTmuxAttachAdapter } = require('./remote-attach'); const { createRemoteStopAdapter } = require('./remote-stop'); -const { attachBlockReason } = require('./remote-host-profile'); +const { attachBlockReason, sendBlockReason } = require('./remote-host-profile'); const { createRemoteSendAdapter, handleSendRequest } = require('./remote-send'); const { createGitChangesRunner, localGitEnv } = require('./git-changes-runner'); const { runToExit } = require('./run-to-exit'); @@ -594,6 +594,7 @@ function annotateRemoteAttachable(projects) { const supportsAttach = !!(descriptor && remoteAttachAdapter.supports(descriptor)); session.remoteAttachable = supportsAttach && !hostBlocked; session.remoteAttachBlocked = supportsAttach ? hostBlocked : null; + session.remoteSendBlocked = sendBlockReason(info.profile); session.status = descriptor ? (descriptor.status || null) : null; session.statusUpdatedAt = descriptor ? (descriptor.statusUpdatedAt || null) : null; session.waitingFor = descriptor ? (descriptor.waitingFor || null) : null; diff --git a/public/sidebar.js b/public/sidebar.js index d5adc0f6..748a7e69 100644 --- a/public/sidebar.js +++ b/public/sidebar.js @@ -58,8 +58,11 @@ function formatNextAttemptIn(epochMs) { // session right now. See .ai/contexts/session-cache.md. function remoteHostTierLines(profile) { if (!profile || typeof profile.tier !== 'string' || !Array.isArray(profile.missing)) return ''; + const watch = profile.tools && profile.tools.inotifywait === false + ? '\nlive updates: inotifywait is not installed: only the periodic pull runs' : ''; return '\nCapability: ' + profile.tier - + profile.missing.map(m => '\n' + m.tier + ' unavailable: ' + m.reason).join(''); + + profile.missing.map(m => '\n' + m.tier + ' unavailable: ' + m.reason).join('') + + watch; } function remoteHostState(project) { @@ -888,7 +891,11 @@ function renderProjects(projects, resort) { newBtn.innerHTML = ''; if (project.remoteAlias) { newBtn.disabled = true; - newBtn.title = 'Read-only mirror of ' + project.remoteAlias + ' — new sessions must be started on that host'; + const launch = project.remoteHostProfile && Array.isArray(project.remoteHostProfile.tiers) + && project.remoteHostProfile.tiers.find(t => t.tier === 'launch'); + newBtn.title = launch && launch.reason + ? 'New session unavailable on ' + project.remoteAlias + ': ' + launch.reason + : 'Read-only mirror of ' + project.remoteAlias + ' — new sessions must be started on that host'; } else { newBtn.title = 'New session'; } @@ -1290,6 +1297,7 @@ function rebindSidebarEvents(projects) { if (sendBtn) { sendBtn.onclick = (e) => { e.stopPropagation(); + if (session.remoteSendBlocked) return; showSendPromptDialog(session); }; } @@ -1515,6 +1523,10 @@ function buildSessionItem(session) { sendBtn.className = 'session-send-btn'; sendBtn.title = 'Send a prompt…'; sendBtn.innerHTML = ''; + if (session.remoteSendBlocked) { + sendBtn.disabled = true; + sendBtn.title = 'Send unavailable: ' + session.remoteSendBlocked; + } const archiveBtn = document.createElement('button'); archiveBtn.className = 'session-archive-btn'; diff --git a/public/style.css b/public/style.css index a48a81f5..282aeb0f 100644 --- a/public/style.css +++ b/public/style.css @@ -1205,6 +1205,14 @@ body { display: flex; flex-direction: column; } background: rgba(62,207,90,0.1); } +.session-send-btn:disabled, +.session-send-btn:disabled:hover { + color: #5a7a6a; + background: none; + opacity: 0.4; + cursor: not-allowed; +} + .session-launch-config-btn { color: #5a7a6a; outline: none; diff --git a/remote-host-profile.js b/remote-host-profile.js index dba99fc2..50de059b 100644 --- a/remote-host-profile.js +++ b/remote-host-profile.js @@ -5,6 +5,7 @@ const { parseTmuxField, isValidPid } = require('./remote-attach'); const ATTACH_BLOCK_AFTER_FAILURES = 3; const TIERS = ['observe', 'liveness', 'inject', 'attach', 'launch']; +const TMUX_MISSING_REASON = 'tmux is not installed on this host (command -v tmux found nothing)'; function hasPosixSocketPath(descriptor) { const p = descriptor && descriptor.messagingSocketPath; @@ -15,18 +16,31 @@ function namesTmuxPane(descriptor) { return !!(descriptor && parseTmuxField(descriptor.tmux) && isValidPid(descriptor.pid)); } +function toolFlag(value) { + return value === true || value === false ? value : null; +} + +function normalizeTools(tools) { + const t = tools && typeof tools === 'object' ? tools : {}; + return { tmux: toolFlag(t.tmux), inotifywait: toolFlag(t.inotifywait) }; +} + /** * Highest capability tier of a host, from what its last refresh cycle knew. * input: { at: epoch ms of the last successful cycle | null, * error: last cycle's error message | null, - * descriptors: live session descriptors of that cycle } + * descriptors: live session descriptors of that cycle, + * tools: { tmux, inotifywait } each true | false | null (unknown), from the probe } * -> { tier: 'none' | a TIERS name, + * blocked: why nothing could be read, or null, + * tools: the normalised probe result, * tiers: [{ tier, available, reason }], * missing: [{ tier, reason }] for every tier above `tier` } */ function computeHostProfile(input) { const { at, error } = input || {}; const descriptors = Array.isArray(input && input.descriptors) ? input.descriptors.filter(d => d && typeof d === 'object') : []; + const tools = normalizeTools(input && input.tools); let blocked = null; if (error) blocked = `last refresh of this host failed: ${error}`; @@ -41,8 +55,9 @@ function computeHostProfile(input) { : 'no live session descriptor under ~/.claude/sessions on this host (one appears while a session runs)'; reasons.inject = descriptors.some(hasPosixSocketPath) ? null : 'no live session reports a messagingSocketPath on a POSIX path'; - reasons.attach = descriptors.some(namesTmuxPane) ? null - : 'no live session names a tmux pane in its descriptor (start it inside tmux)'; + if (tools.tmux === false) reasons.attach = TMUX_MISSING_REASON; + else if (tools.tmux === true || descriptors.some(namesTmuxPane)) reasons.attach = null; + else reasons.attach = 'no live session names a tmux pane in its descriptor (start it inside tmux)'; reasons.launch = 'new sessions cannot be started from here; they must be started on the host'; } @@ -51,15 +66,29 @@ function computeHostProfile(input) { tiers.forEach((t, i) => { if (t.available) top = i; }); return { tier: top === -1 ? 'none' : TIERS[top], + blocked, + tools, tiers, missing: tiers.slice(top + 1).map(t => ({ tier: t.tier, reason: t.reason })), }; } -function attachBlockReason(profile, consecutiveFailures) { - if (!(consecutiveFailures >= ATTACH_BLOCK_AFTER_FAILURES)) return null; - const hit = profile && Array.isArray(profile.tiers) && profile.tiers.find(t => t.tier === 'observe'); +function tierReason(profile, tier) { + const hit = profile && Array.isArray(profile.tiers) && profile.tiers.find(t => t.tier === tier); return hit && hit.reason ? hit.reason : null; } -module.exports = { computeHostProfile, attachBlockReason, ATTACH_BLOCK_AFTER_FAILURES, TIERS }; +function attachBlockReason(profile, consecutiveFailures) { + if (consecutiveFailures >= ATTACH_BLOCK_AFTER_FAILURES) return tierReason(profile, 'observe'); + if (profile && profile.tools && profile.tools.tmux === false) return TMUX_MISSING_REASON; + return null; +} + +function sendBlockReason(profile) { + if (!profile || profile.blocked) return null; + return tierReason(profile, 'inject'); +} + +module.exports = { + computeHostProfile, attachBlockReason, sendBlockReason, ATTACH_BLOCK_AFTER_FAILURES, TIERS, TMUX_MISSING_REASON, +}; diff --git a/remote-index.js b/remote-index.js index c3c6bacc..c4b36890 100644 --- a/remote-index.js +++ b/remote-index.js @@ -19,6 +19,10 @@ const NOOP_LOG = { info() {}, warn() {}, error() {} }; // see .ai/contexts/session-cache.md ("Remote hosts backoff") const MAX_BACKOFF_MS = 30 * 60 * 1000; +// see .ai/contexts/session-cache.md ("Remote hosts — capability tiers", the probe) +const PROBE_INTERVAL_MS = 6 * 60 * 60 * 1000; +const PROBE_RETRY_MS = 30 * 60 * 1000; + // see .ai/contexts/session-cache.md ("Remote hosts backoff") function backoffDelayMs(failures, intervalMs) { if (failures <= 0) return 0; @@ -106,6 +110,7 @@ function createRemoteIndexer(ctx) { const remoteSessionsAt = new Map(); // alias -> epoch ms of the last cycle that did not throw const hostBackoff = new Map(); // alias -> { failures, lastError, nextAttemptAt } const hostInFlight = new Set(); + const hostTools = new Map(); // alias -> { tools: { tmux, inotifywait } | null, nextProbeAt } function backoffState(alias) { let s = hostBackoff.get(alias); @@ -177,6 +182,9 @@ function createRemoteIndexer(ctx) { for (const alias of [...remoteSessionsAt.keys()]) { if (!known.has(alias)) remoteSessionsAt.delete(alias); } + for (const alias of [...hostTools.keys()]) { + if (!known.has(alias)) hostTools.delete(alias); + } for (const alias of [...hostBackoff.keys()]) { if (!known.has(alias)) hostBackoff.delete(alias); } @@ -193,6 +201,26 @@ function createRemoteIndexer(ctx) { } } + function rearmProbe(alias) { + const entry = hostTools.get(alias); + if (entry) entry.nextProbeAt = 0; + } + + async function probeHostTools(alias) { + if (!ctx.transport || typeof ctx.transport.probeTools !== 'function') return false; + const prev = hostTools.get(alias); + if (prev && now() < prev.nextProbeAt) return false; + let tools = null; + try { + tools = await ctx.transport.probeTools(alias); + } catch (err) { + log.warn(`[remote:${alias}] tool probe failed: ${err.message}`); + } + const kept = tools || (prev ? prev.tools : null); + hostTools.set(alias, { tools: kept, nextProbeAt: now() + (tools ? PROBE_INTERVAL_MS : PROBE_RETRY_MS) }); + return !!tools && (!prev || !prev.tools || prev.tools.tmux !== tools.tmux || prev.tools.inotifywait !== tools.inotifywait); + } + async function refreshHost(host) { const projectsDir = mirrorProjectsDirFor(ctx.dataDir, host.alias); const manifestPath = manifestPathFor(ctx.dataDir, host.alias); @@ -254,7 +282,8 @@ function createRemoteIndexer(ctx) { log.info(`[remote:${host.alias}] ${result.fetched} fetched, ${result.unchanged} unchanged, ` + `${result.removed} removed, ${result.failed} failed, ${toScan.size} folders indexed`); - return toScan.size > 0; + const toolsChanged = await probeHostTools(host.alias); + return toScan.size > 0 || toolsChanged; } // see .ai/contexts/session-cache.md ("Remote hosts backoff" — manual reconnect, issue #252) @@ -276,6 +305,7 @@ function createRemoteIndexer(ctx) { if (force) { state.failures = 0; state.nextAttemptAt = 0; + rearmProbe(host.alias); } else if (now() < state.nextAttemptAt) { continue; // still backing off: no attempt, no log, no ssh } @@ -308,6 +338,7 @@ function createRemoteIndexer(ctx) { if (force) { state.failures = 0; state.nextAttemptAt = 0; + rearmProbe(alias); } else if (now() < state.nextAttemptAt) { return { skipped: true }; } @@ -378,7 +409,8 @@ function createRemoteIndexer(ctx) { // see .ai/contexts/session-cache.md ("Remote hosts — capability tiers") function getRemoteHostProfile(alias) { const { sessions, at, error } = getRemoteSessions(alias); - return computeHostProfile({ at, error, descriptors: sessions }); + const known = hostTools.get(alias); + return computeHostProfile({ at, error, descriptors: sessions, tools: known ? known.tools : null }); } // see .ai/contexts/session-cache.md ("Remote hosts — descriptor-only sessions") @@ -430,4 +462,4 @@ function createRemoteIndexer(ctx) { }; } -module.exports = { createRemoteIndexer, backoffDelayMs, buildPlaceholderSession, placeholderTitle, sanitizeWaitingFor }; +module.exports = { createRemoteIndexer, PROBE_INTERVAL_MS, PROBE_RETRY_MS, backoffDelayMs, buildPlaceholderSession, placeholderTitle, sanitizeWaitingFor }; diff --git a/remote-transport.js b/remote-transport.js index 2ea2bcaf..9c4350b2 100644 --- a/remote-transport.js +++ b/remote-transport.js @@ -17,6 +17,8 @@ const MAX_SESSION_DESCRIPTOR_BYTES = 8192; const DEFAULT_CONNECT_TIMEOUT_S = 10; const DEFAULT_LIST_TIMEOUT_MS = 60_000; const DEFAULT_FETCH_TIMEOUT_MS = 120_000; +const DEFAULT_PROBE_TIMEOUT_MS = 15_000; +const MAX_PROBE_BYTES = 1024; const MAX_LIST_BYTES = 8 * 1024 * 1024; const DEFAULT_CONCURRENCY = 4; // see .ai/contexts/session-cache.md ("Remote hosts — incremental fetch") @@ -40,6 +42,23 @@ const LIST_COMMAND = `head -n ${MAX_SESSION_DESCRIPTORS} | while IFS= read -r f; do head -c ${MAX_SESSION_DESCRIPTOR_BYTES} "$f"; printf '\\n'; ` + `pid=$(basename "$f" .json); printf '\\002ALIVE:%s\\n' "$( [ -d "/proc/$pid" ] && echo 1 || echo 0 )"; done`; +// see .ai/contexts/session-cache.md ("Remote hosts — capability tiers", the probe) +const PROBE_COMMAND = + 'if command -v tmux >/dev/null 2>&1; then echo tmux=1; else echo tmux=0; fi; ' + + 'if command -v inotifywait >/dev/null 2>&1; then echo inotifywait=1; else echo inotifywait=0; fi'; + +function parseProbe(stdout) { + const lines = String(stdout).split('\n').map(l => l.replace(/\r$/, '')).filter(l => l !== ''); + if (lines.length !== 2) return null; + const out = {}; + for (const name of ['tmux', 'inotifywait']) { + const line = lines.find(l => l.startsWith(name + '=')); + if (line !== name + '=1' && line !== name + '=0') return null; + out[name] = line === name + '=1'; + } + return out; +} + function parseInventory(stdout) { const out = []; for (const line of stdout.split('\n')) { @@ -131,6 +150,7 @@ function createSshTransport(opts = {}) { const resolveScpPath = opts.resolveScpPath || defaultResolveScpPath; const log = opts.log || { info() {}, warn() {}, error() {} }; const listTimeoutMs = opts.listTimeoutMs || DEFAULT_LIST_TIMEOUT_MS; + const probeTimeoutMs = opts.probeTimeoutMs || DEFAULT_PROBE_TIMEOUT_MS; const fetchTimeoutMs = opts.fetchTimeoutMs || DEFAULT_FETCH_TIMEOUT_MS; const concurrency = Math.max(1, Math.min(8, opts.concurrency || DEFAULT_CONCURRENCY)); @@ -235,6 +255,19 @@ function createSshTransport(opts = {}) { return { files, sessions }; } + async function probeTools(alias) { + const res = await run(resolveSshPath(), [...SSH_BASE_OPTS, '-n', alias, PROBE_COMMAND], { + timeoutMs: probeTimeoutMs, + maxBytes: MAX_PROBE_BYTES, + }); + if (res.timedOut) throw new Error(`ssh probe timed out after ${probeTimeoutMs} ms`); + if (res.truncated) throw new Error('ssh probe output exceeded the size cap'); + if (res.code !== 0) throw new Error(`ssh probe failed (exit ${res.code}): ${res.stderr.trim() || 'no stderr'}`); + const tools = parseProbe(res.stdout); + if (!tools) throw new Error('ssh probe returned unexpected output'); + return tools; + } + async function fetchOne(alias, rel, destRoot) { const destPath = path.join(destRoot, rel); fs.mkdirSync(path.dirname(destPath), { recursive: true }); @@ -351,7 +384,7 @@ function createSshTransport(opts = {}) { cancelInFlight(); } - return { listFiles, fetchFiles, fetchIncremental, cancelInFlight, dispose, liveCount: () => live.size }; + return { listFiles, probeTools, fetchFiles, fetchIncremental, cancelInFlight, dispose, liveCount: () => live.size }; } module.exports = { @@ -361,6 +394,8 @@ module.exports = { splitListOutput, transcriptSessionIds, LIST_COMMAND, + PROBE_COMMAND, + parseProbe, ALIVE_MARKER_PREFIX, REMOTE_PROJECTS_REL, REMOTE_SESSIONS_REL, diff --git a/test/annotate-remote-attachable-local-status.test.js b/test/annotate-remote-attachable-local-status.test.js index 80a84f19..49006d0c 100644 --- a/test/annotate-remote-attachable-local-status.test.js +++ b/test/annotate-remote-attachable-local-status.test.js @@ -15,7 +15,7 @@ const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); -const { computeHostProfile, attachBlockReason } = require('../remote-host-profile'); +const { computeHostProfile, attachBlockReason, sendBlockReason } = require('../remote-host-profile'); const root = path.join(__dirname, '..'); @@ -38,7 +38,7 @@ function makeAnnotate(mocks) { const source = extractAnnotateRemoteAttachableSource(); const factory = new Function( 'remoteIndexer', 'remoteAttachAdapter', 'remoteActivityTracker', 'cliSessionState', - 'attachBlockReason', + 'attachBlockReason', 'sendBlockReason', source + '\nreturn annotateRemoteAttachable;' ); return factory( @@ -50,7 +50,7 @@ function makeAnnotate(mocks) { mocks.remoteAttachAdapter || { supports: () => false }, mocks.remoteActivityTracker || { activeAt: () => null }, mocks.cliSessionState || { getStatus: () => undefined }, - attachBlockReason + attachBlockReason, sendBlockReason ); } diff --git a/test/annotate-remote-tier-gates.test.js b/test/annotate-remote-tier-gates.test.js new file mode 100644 index 00000000..9bf3d953 --- /dev/null +++ b/test/annotate-remote-tier-gates.test.js @@ -0,0 +1,103 @@ +'use strict'; + +// Issue #218: annotateRemoteAttachable (main.js) turns the host profile into +// per-session gates: attach withheld while tmux is known missing, send withheld +// while the inject tier is unavailable. The real function body is extracted +// from main.js, as in annotate-remote-attachable-local-status.test.js. + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const { computeHostProfile, attachBlockReason, sendBlockReason } = require('../remote-host-profile'); + +function extractSource() { + const src = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8'); + const start = src.indexOf('function annotateRemoteAttachable(projects)'); + assert.ok(start !== -1, 'main.js must define annotateRemoteAttachable'); + const bodyOpen = src.indexOf('{', start); + let depth = 0; + let end = -1; + for (let i = bodyOpen; i < src.length; i++) { + if (src[i] === '{') depth++; + else if (src[i] === '}') { depth--; if (depth === 0) { end = i; break; } } + } + return src.slice(start, end + 1); +} + +const AT = 111; +const TMUX_DESCRIPTOR = { sessionId: 'remote-1', pid: 4, tmux: 'main:@0.%0' }; +const SOCKET_DESCRIPTOR = { sessionId: 'remote-1', pid: 4, messagingSocketPath: '/run/user/1000/c.sock' }; + +function annotateFor({ descriptors, tools, error = null, failures = 0, supports = true }) { + const factory = new Function( + 'remoteIndexer', 'remoteAttachAdapter', 'remoteActivityTracker', 'cliSessionState', + 'attachBlockReason', 'sendBlockReason', + extractSource() + '\nreturn annotateRemoteAttachable;' + ); + return factory( + { + getRemoteSessions: () => ({ sessions: descriptors, at: AT, error }), + getRemoteHostState: () => ({ consecutiveFailures: failures, lastError: error, nextAttemptAt: 0 }), + getRemoteHostProfile: () => computeHostProfile({ at: AT, error, descriptors, tools }), + }, + { supports: () => supports }, + { activeAt: () => null }, + { getStatus: () => undefined }, + attachBlockReason, + sendBlockReason + ); +} + +function projects() { + return [{ projectPath: '/srv/p', remoteAlias: 'box', sessions: [{ sessionId: 'remote-1', remoteAlias: 'box' }] }]; +} + +test('a session whose descriptor names a tmux pane is not attachable on a host without tmux, and says so', () => { + const list = projects(); + annotateFor({ descriptors: [TMUX_DESCRIPTOR], tools: { tmux: false, inotifywait: true } })(list); + const session = list[0].sessions[0]; + assert.equal(session.remoteAttachable, false); + assert.match(session.remoteAttachBlocked, /tmux is not installed/); +}); + +test('the same session stays attachable when the probe found tmux or has not answered', () => { + for (const tools of [{ tmux: true, inotifywait: true }, null]) { + const list = projects(); + annotateFor({ descriptors: [TMUX_DESCRIPTOR], tools })(list); + assert.equal(list[0].sessions[0].remoteAttachable, true, JSON.stringify(tools)); + assert.equal(list[0].sessions[0].remoteAttachBlocked, null); + } +}); + +test('a session that cannot attach anyway carries no attach reason, whatever the probe says', () => { + const list = projects(); + annotateFor({ descriptors: [TMUX_DESCRIPTOR], tools: { tmux: false, inotifywait: false }, supports: false })(list); + assert.equal(list[0].sessions[0].remoteAttachable, false); + assert.equal(list[0].sessions[0].remoteAttachBlocked, null); +}); + +test('send is withheld with the inject reason on a synced host that reports no messaging socket', () => { + const list = projects(); + annotateFor({ descriptors: [TMUX_DESCRIPTOR], tools: null })(list); + assert.match(list[0].sessions[0].remoteSendBlocked, /messagingSocketPath/); +}); + +test('send carries no reason when the host reports a messaging socket', () => { + const list = projects(); + annotateFor({ descriptors: [SOCKET_DESCRIPTOR], tools: null })(list); + assert.equal(list[0].sessions[0].remoteSendBlocked, null); +}); + +test('send is not withheld by a host that failed its refresh: it runs its own ssh', () => { + const list = projects(); + annotateFor({ descriptors: [TMUX_DESCRIPTOR], tools: null, error: 'connect timed out', failures: 5 })(list); + assert.equal(list[0].sessions[0].remoteSendBlocked, null); +}); + +test('stop is never blocked, whatever the gates say', () => { + const list = projects(); + annotateFor({ descriptors: [TMUX_DESCRIPTOR], tools: { tmux: false, inotifywait: false }, failures: 5, error: 'down' })(list); + assert.equal(list[0].sessions[0].remoteStopBlocked, undefined); +}); diff --git a/test/dom-sidebar-remote-tier-gates.test.js b/test/dom-sidebar-remote-tier-gates.test.js new file mode 100644 index 00000000..d5234660 --- /dev/null +++ b/test/dom-sidebar-remote-tier-gates.test.js @@ -0,0 +1,138 @@ +'use strict'; + +// Issue #218: affordances above a remote host's tier are disabled, and the +// title states why. Stop is not one of them. + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { setupSidebarDom, makeSampleProject } = require('./dom-setup'); +const { computeHostProfile, TMUX_MISSING_REASON } = require('../remote-host-profile'); + +const AT = Date.parse('2026-10-01T10:00:00Z'); + +function session(extra = {}) { + return { + sessionId: 'remote-1', + summary: 'ripcord protocol', + modified: '2026-09-06T10:00:00.000Z', + starred: false, + archived: 0, + messageCount: 4, + projectPath: '/srv/supervision', + remoteAlias: 'planificator', + remoteDescriptorSeen: true, + ...extra, + }; +} + +function project(profile, s) { + return makeSampleProject({ + projectPath: '/srv/supervision', + folder: 'planificator::-srv-supervision', + remoteAlias: 'planificator', + remoteHostAt: AT, + remoteHostError: null, + remoteHostProfile: profile, + sessions: [s], + }); +} + +function render(ctx, profile, s) { + ctx.window.sessionMap.set(s.sessionId, s); + ctx.sidebar.renderProjects([project(profile, s)], true); + const header = ctx.document.getElementById('ph-' + ctx.sidebar.folderId('/srv/supervision')); + return { header, item: ctx.document.getElementById('si-remote-1') }; +} + +test('the new-session button is disabled and its title gives the launch tier reason', () => { + const ctx = setupSidebarDom(); + try { + const profile = computeHostProfile({ at: AT, error: null, descriptors: [] }); + const { header } = render(ctx, profile, session()); + const btn = header.querySelector('.project-new-btn'); + assert.equal(btn.disabled, true); + assert.match(btn.title, /planificator/); + assert.ok(btn.title.includes('new sessions cannot be started from here; they must be started on the host'), btn.title); + } finally { ctx.destroy(); } +}); + +test('a host with no profile keeps the plain read-only title on the new-session button', () => { + const ctx = setupSidebarDom(); + try { + const { header } = render(ctx, undefined, session()); + const btn = header.querySelector('.project-new-btn'); + assert.equal(btn.disabled, true); + assert.match(btn.title, /Read-only mirror of planificator/); + } finally { ctx.destroy(); } +}); + +test('the send button is disabled with the reason as title, and a click opens nothing', () => { + const ctx = setupSidebarDom(); + try { + const { item } = render(ctx, undefined, session({ remoteSendBlocked: 'no live session reports a messagingSocketPath on a POSIX path' })); + const btn = item.querySelector('.session-send-btn'); + assert.equal(btn.disabled, true); + assert.match(btn.title, /Send unavailable: no live session reports a messagingSocketPath/); + const dialogs = []; + ctx.window.showSendPromptDialog = (s) => dialogs.push(s.sessionId); + btn.onclick({ stopPropagation() {} }); + assert.deepEqual(dialogs, [], 'a disabled button must not reach the dialog even if its handler is called'); + } finally { ctx.destroy(); } +}); + +test('the send button stays enabled and titled as before when nothing blocks it', () => { + const ctx = setupSidebarDom(); + try { + const { item } = render(ctx, undefined, session({ remoteSendBlocked: null })); + const btn = item.querySelector('.session-send-btn'); + assert.equal(btn.disabled, false); + assert.match(btn.title, /^Send a prompt/); + const dialogs = []; + ctx.window.showSendPromptDialog = (s) => dialogs.push(s.sessionId); + btn.click(); + assert.deepEqual(dialogs, ['remote-1']); + } finally { ctx.destroy(); } +}); + +test('the stop button is never disabled, even when attach and send are withheld', () => { + const ctx = setupSidebarDom(); + try { + const { item } = render(ctx, undefined, session({ + remoteAttachable: false, remoteAttachBlocked: TMUX_MISSING_REASON, remoteSendBlocked: 'no socket', + })); + const btn = item.querySelector('.session-stop-btn'); + assert.ok(btn); + assert.equal(btn.disabled, false); + } finally { ctx.destroy(); } +}); + +test('a row on a host without tmux opens its transcript and the title states the tmux reason', () => { + const ctx = setupSidebarDom(); + try { + const { item } = render(ctx, undefined, session({ remoteAttachable: false, remoteAttachBlocked: TMUX_MISSING_REASON })); + assert.ok(item.title.includes('Attach unavailable: ' + TMUX_MISSING_REASON), item.title); + assert.ok(item.querySelector('.remote-badge').title.includes(TMUX_MISSING_REASON)); + const viewed = []; + const opened = []; + ctx.window.showJsonlViewer = (s) => viewed.push(s.sessionId); + ctx.window.openSession = (s) => opened.push(s.sessionId); + item.onclick(); + assert.deepEqual(viewed, ['remote-1']); + assert.deepEqual(opened, []); + } finally { ctx.destroy(); } +}); + +test('the host dot says live updates are off when inotifywait is known missing, and only then', () => { + const ctx = setupSidebarDom(); + try { + const missing = computeHostProfile({ at: AT, error: null, descriptors: [], tools: { tmux: true, inotifywait: false } }); + let { header } = render(ctx, missing, session()); + assert.match(header.querySelector('.remote-host-dot').title, /inotifywait is not installed: only the periodic pull runs/); + for (const tools of [{ tmux: true, inotifywait: true }, null]) { + const profile = computeHostProfile({ at: AT, error: null, descriptors: [], tools }); + ({ header } = render(ctx, profile, session())); + assert.doesNotMatch(header.querySelector('.remote-host-dot').title, /inotifywait/, JSON.stringify(tools)); + } + } finally { ctx.destroy(); } +}); diff --git a/test/remote-host-profile.test.js b/test/remote-host-profile.test.js index 1eecbc25..edf8ba19 100644 --- a/test/remote-host-profile.test.js +++ b/test/remote-host-profile.test.js @@ -2,7 +2,7 @@ const test = require('node:test'); const assert = require('node:assert/strict'); -const { computeHostProfile, attachBlockReason, ATTACH_BLOCK_AFTER_FAILURES, TIERS } = require('../remote-host-profile'); +const { computeHostProfile, attachBlockReason, sendBlockReason, ATTACH_BLOCK_AFTER_FAILURES, TIERS } = require('../remote-host-profile'); const AT = Date.parse('2026-10-01T10:00:00Z'); const tmuxDescriptor = { pid: 101, sessionId: 'a', tmux: 'main:@0.%0' }; @@ -98,3 +98,74 @@ test('attach is never blocked on a host with no error, whatever the failure coun const profile = computeHostProfile({ at: AT, error: null, descriptors: [tmuxDescriptor] }); assert.equal(attachBlockReason(profile, 5), null); }); + +test('the tools default to unknown and a host without a probe keeps the descriptor-only profile', () => { + const profile = computeHostProfile({ at: AT, error: null, descriptors: [] }); + assert.deepEqual(profile.tools, { tmux: null, inotifywait: null }); + assert.equal(profile.tier, 'observe'); +}); + +test('an idle synced host with tmux installed offers attach and no longer lists it as missing', () => { + const profile = computeHostProfile({ at: AT, error: null, descriptors: [], tools: { tmux: true, inotifywait: true } }); + assert.equal(profile.tiers.find(t => t.tier === 'attach').available, true); + assert.equal(reasonFor(profile, 'attach'), undefined); + assert.equal(profile.tier, 'attach'); +}); + +test('an idle host with tmux installed still says liveness needs a live descriptor', () => { + const profile = computeHostProfile({ at: AT, error: null, descriptors: [], tools: { tmux: true, inotifywait: false } }); + assert.match(profile.tiers.find(t => t.tier === 'liveness').reason, /no live session descriptor/); +}); + +test('a host where tmux is not installed says so as the attach reason, even when a descriptor names a pane', () => { + const profile = computeHostProfile({ at: AT, error: null, descriptors: [tmuxDescriptor], tools: { tmux: false, inotifywait: true } }); + assert.equal(profile.tiers.find(t => t.tier === 'attach').available, false); + assert.match(reasonFor(profile, 'attach'), /tmux is not installed/); + assert.equal(profile.tier, 'liveness'); +}); + +test('an unknown tool value is treated as unknown, never as installed or missing', () => { + for (const bad of ['yes', 1, undefined, null, {}]) { + const profile = computeHostProfile({ at: AT, error: null, descriptors: [], tools: { tmux: bad, inotifywait: bad } }); + assert.deepEqual(profile.tools, { tmux: null, inotifywait: null }, JSON.stringify(bad)); + } + assert.deepEqual(computeHostProfile({ at: AT, error: null, descriptors: [], tools: 'nope' }).tools, { tmux: null, inotifywait: null }); +}); + +test('a failed or unsynced host reports blocked and keeps the probe result for the gates', () => { + const failed = computeHostProfile({ at: AT, error: 'connect timed out', descriptors: [], tools: { tmux: false, inotifywait: false } }); + assert.match(failed.blocked, /connect timed out/); + assert.deepEqual(failed.tools, { tmux: false, inotifywait: false }); + assert.equal(computeHostProfile({ at: AT, error: null, descriptors: [] }).blocked, null); + assert.match(computeHostProfile({ at: null, error: null, descriptors: [] }).blocked, /not yet synced/); +}); + +test('attach is withheld while tmux is known missing, from the first failure on, and the tmux reason wins below three failures', () => { + const profile = computeHostProfile({ at: AT, error: 'connect timed out', descriptors: [tmuxDescriptor], tools: { tmux: false, inotifywait: null } }); + assert.match(attachBlockReason(profile, 0), /tmux is not installed/); + assert.match(attachBlockReason(profile, 1), /tmux is not installed/); + assert.match(attachBlockReason(profile, 3), /connect timed out/); +}); + +test('attach is not withheld when tmux is installed or unknown', () => { + for (const tmux of [true, null]) { + const profile = computeHostProfile({ at: AT, error: null, descriptors: [tmuxDescriptor], tools: { tmux, inotifywait: null } }); + assert.equal(attachBlockReason(profile, 0), null, String(tmux)); + } +}); + +test('send is withheld with the inject reason when a synced host has no messaging socket', () => { + const profile = computeHostProfile({ at: AT, error: null, descriptors: [tmuxDescriptor] }); + assert.match(sendBlockReason(profile), /messagingSocketPath/); +}); + +test('send is offered when the inject tier is available', () => { + const profile = computeHostProfile({ at: AT, error: null, descriptors: [socketDescriptor] }); + assert.equal(sendBlockReason(profile), null); +}); + +test('send is never withheld by a failed or unsynced host: it runs its own ssh', () => { + assert.equal(sendBlockReason(computeHostProfile({ at: AT, error: 'down', descriptors: [] })), null); + assert.equal(sendBlockReason(computeHostProfile({ at: null, error: null, descriptors: [] })), null); + assert.equal(sendBlockReason(undefined), null); +}); diff --git a/test/remote-index-probe.test.js b/test/remote-index-probe.test.js new file mode 100644 index 00000000..5edb45c1 --- /dev/null +++ b/test/remote-index-probe.test.js @@ -0,0 +1,156 @@ +'use strict'; + +// Issue #218: the indexer runs the tmux/inotifywait probe on first sync, then +// rarely, and a probe failure never turns into a refresh failure. + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { createRemoteIndexer, PROBE_INTERVAL_MS, PROBE_RETRY_MS } = require('../remote-index'); + +function tmp() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'switchboard-idx-probe-')); +} + +function fakeTimers() { + return { setInterval: () => ({}), clearInterval: () => {} }; +} + +function harness({ probe, syncFails = () => false, hosts = [{ alias: 'box' }] } = {}) { + const dataDir = tmp(); + let t = 1_000; + const probes = []; + const notifies = []; + const warnings = []; + const transport = probe ? { probeTools: (alias) => { probes.push(alias); return probe(probes.length); } } : {}; + const indexer = createRemoteIndexer({ + getHosts: () => hosts, + getRefreshMs: () => 60_000, + dataDir, + transport, + scanFolders: () => Promise.resolve({ ok: true }), + listIndexedFolderKeys: () => [], + dropFolder: () => {}, + timers: fakeTimers(), + notify: () => notifies.push(t), + log: { info() {}, warn: (m) => warnings.push(m), error() {} }, + now: () => t, + sync: async () => { + if (syncFails()) throw new Error('connect timed out'); + return { changedFolders: [], sessions: [] }; + }, + }); + return { indexer, probes, notifies, warnings, advance: (ms) => { t += ms; }, cleanup: () => fs.rmSync(dataDir, { recursive: true, force: true }) }; +} + +test('the first successful sync probes the host once and the profile carries the result', async () => { + const h = harness({ probe: async () => ({ tmux: true, inotifywait: false }) }); + try { + assert.deepEqual(h.indexer.getRemoteHostProfile('box').tools, { tmux: null, inotifywait: null }); + await h.indexer.refreshNow(); + assert.deepEqual(h.probes, ['box']); + const profile = h.indexer.getRemoteHostProfile('box'); + assert.deepEqual(profile.tools, { tmux: true, inotifywait: false }); + assert.equal(profile.tiers.find(t => t.tier === 'attach').available, true, 'idle host with tmux offers attach'); + } finally { h.cleanup(); } +}); + +test('a changed probe answer notifies the renderer even when the sync changed nothing, an unchanged one does not', async () => { + let answer = { tmux: true, inotifywait: true }; + const h = harness({ probe: async () => answer }); + try { + await h.indexer.refreshNow(); + assert.equal(h.notifies.length, 1, 'the first answer changes the profile'); + h.advance(PROBE_INTERVAL_MS); + await h.indexer.refreshNow(); + assert.equal(h.notifies.length, 1, 'the same answer changes nothing'); + answer = { tmux: false, inotifywait: true }; + h.advance(PROBE_INTERVAL_MS); + await h.indexer.refreshNow(); + assert.equal(h.notifies.length, 2); + } finally { h.cleanup(); } +}); + +test('a later cycle inside the probe interval does not probe again, one past it does', async () => { + const h = harness({ probe: async () => ({ tmux: true, inotifywait: true }) }); + try { + await h.indexer.refreshNow(); + h.advance(PROBE_INTERVAL_MS - 1); + await h.indexer.refreshNow(); + await h.indexer.refreshHostNow('box'); + assert.equal(h.probes.length, 1); + h.advance(1); + await h.indexer.refreshNow(); + assert.equal(h.probes.length, 2); + } finally { h.cleanup(); } +}); + +test('a probe failure leaves the profile unknown, does not fail the refresh, and retries only after the retry delay', async () => { + let fail = true; + const h = harness({ probe: async () => { if (fail) throw new Error('ssh probe timed out after 15000 ms'); return { tmux: false, inotifywait: false }; } }); + try { + const result = await h.indexer.refreshNow(); + assert.deepEqual(result.errors, []); + assert.deepEqual(h.indexer.getRemoteHostState('box').consecutiveFailures, 0); + assert.deepEqual(h.indexer.getRemoteHostProfile('box').tools, { tmux: null, inotifywait: null }); + assert.equal(h.indexer.getRemoteHostProfile('box').tier, 'observe'); + + h.advance(PROBE_RETRY_MS - 1); + await h.indexer.refreshNow(); + assert.equal(h.probes.length, 1, 'still inside the retry delay'); + await h.indexer.refreshNow({ force: true }); + assert.equal(h.probes.length, 2, 'a forced reconnect probes again'); + fail = false; + h.advance(PROBE_RETRY_MS); + await h.indexer.refreshHostNow('box'); + assert.equal(h.probes.length, 3); + assert.deepEqual(h.indexer.getRemoteHostProfile('box').tools, { tmux: false, inotifywait: false }); + } finally { h.cleanup(); } +}); + +test('a failed probe keeps the previous answer rather than forgetting it', async () => { + let n = 0; + const h = harness({ probe: async () => { n++; if (n === 2) throw new Error('boom'); return { tmux: true, inotifywait: true }; } }); + try { + await h.indexer.refreshNow(); + h.advance(PROBE_INTERVAL_MS); + await h.indexer.refreshNow(); + assert.equal(h.probes.length, 2); + assert.deepEqual(h.indexer.getRemoteHostProfile('box').tools, { tmux: true, inotifywait: true }); + } finally { h.cleanup(); } +}); + +test('no probe runs when the sync itself failed', async () => { + const h = harness({ probe: async () => ({ tmux: true, inotifywait: true }), syncFails: () => true }); + try { + await h.indexer.refreshNow(); + assert.deepEqual(h.probes, []); + } finally { h.cleanup(); } +}); + +test('a transport with no probe is left alone', async () => { + const h = harness({}); + try { + const result = await h.indexer.refreshNow(); + assert.deepEqual(result.errors, []); + assert.deepEqual(h.warnings, [], 'a transport that has no probe is not an error'); + assert.deepEqual(h.indexer.getRemoteHostProfile('box').tools, { tmux: null, inotifywait: null }); + } finally { h.cleanup(); } +}); + +test('a host removed from the settings forgets its probe result', async () => { + let hosts = [{ alias: 'box' }]; + const h = harness({ probe: async () => ({ tmux: true, inotifywait: true }), hosts }); + try { + await h.indexer.refreshNow(); + hosts.length = 0; + hosts.push({ alias: 'other' }); + await h.indexer.refreshNow(); + hosts.length = 0; + hosts.push({ alias: 'box' }); + assert.deepEqual(h.indexer.getRemoteHostProfile('box').tools, { tmux: null, inotifywait: null }); + } finally { h.cleanup(); } +}); diff --git a/test/remote-transport-probe.test.js b/test/remote-transport-probe.test.js new file mode 100644 index 00000000..b73ad3c4 --- /dev/null +++ b/test/remote-transport-probe.test.js @@ -0,0 +1,120 @@ +'use strict'; + +// Issue #218: the per-host probe for tmux and inotifywait. LIST_COMMAND stays +// pinned elsewhere; this command is its own, run rarely, and answers two booleans. + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { spawnSync } = require('child_process'); +const { EventEmitter } = require('events'); +const { Readable } = require('stream'); +const { spawnSyncRetryingCrash } = require('./spawn-retry'); + +const { createSshTransport, PROBE_COMMAND, parseProbe } = require('../remote-transport'); +const { resolveSshPath } = require('../remote-ssh-binary'); + +function fakeChild() { + const child = new EventEmitter(); + child.stdout = new Readable({ read() {} }); + child.stderr = new Readable({ read() {} }); + child.killed = 0; + child.kill = () => { child.killed++; child.emit('close', null); }; + return child; +} + +function spawnRecorder(handler) { + const calls = []; + const spawn = (cmd, args) => { + const child = fakeChild(); + calls.push({ cmd, args, child }); + if (handler) setImmediate(() => handler(child, cmd, args)); + return child; + }; + spawn.calls = calls; + return spawn; +} + +test('PROBE_COMMAND is pinned exactly: it asks for tmux and inotifywait and nothing else', () => { + assert.equal(PROBE_COMMAND, + 'if command -v tmux >/dev/null 2>&1; then echo tmux=1; else echo tmux=0; fi; ' + + 'if command -v inotifywait >/dev/null 2>&1; then echo inotifywait=1; else echo inotifywait=0; fi'); +}); + +test('parseProbe reads the two booleans and refuses anything else', () => { + assert.deepEqual(parseProbe('tmux=1\ninotifywait=0\n'), { tmux: true, inotifywait: false }); + assert.deepEqual(parseProbe('tmux=0\ninotifywait=1\n'), { tmux: false, inotifywait: true }); + assert.equal(parseProbe(''), null); + assert.equal(parseProbe('tmux=1\n'), null); + assert.equal(parseProbe('tmux=2\ninotifywait=1\n'), null); + assert.equal(parseProbe('motd: welcome\ntmux=1\ninotifywait=1\n'), null); +}); + +test('probeTools spawns one bounded ssh with the same options and the alias as an operand', async () => { + const spawn = spawnRecorder((child) => { + child.stdout.push('tmux=1\ninotifywait=0\n'); + child.stdout.push(null); + child.emit('close', 0); + }); + const t = createSshTransport({ spawn }); + + const result = await t.probeTools('planificator'); + + assert.deepEqual(result, { tmux: true, inotifywait: false }); + assert.equal(spawn.calls.length, 1); + const { cmd, args } = spawn.calls[0]; + assert.equal(cmd, resolveSshPath()); + assert.ok(args.includes('BatchMode=yes')); + assert.ok(args.some(a => /^ConnectTimeout=/.test(a))); + assert.equal(args[args.length - 2], 'planificator'); + assert.equal(args[args.length - 1], PROBE_COMMAND); + assert.equal(t.liveCount(), 0); +}); + +test('probeTools rejects on a failed exit and on unparseable output', async () => { + const failing = createSshTransport({ spawn: spawnRecorder((child) => { + child.stderr.push('Permission denied'); + child.stdout.push(null); + child.emit('close', 255); + }) }); + await assert.rejects(() => failing.probeTools('vps'), /exit 255/); + + const garbage = createSshTransport({ spawn: spawnRecorder((child) => { + child.stdout.push('hello\n'); + child.stdout.push(null); + child.emit('close', 0); + }) }); + await assert.rejects(() => garbage.probeTools('vps'), /unexpected output/); +}); + +test('probeTools kills a hung ssh at its own timeout', async () => { + const hung = spawnRecorder(null); + const t = createSshTransport({ spawn: hung, probeTimeoutMs: 30 }); + await assert.rejects(() => t.probeTools('vps'), /timed out/); + assert.equal(hung.calls[0].child.killed, 1); + assert.equal(t.liveCount(), 0); +}); + +test('probeTools caps the output it reads', async () => { + const spawn = spawnRecorder((child) => { + child.stdout.push('x'.repeat(4096)); + child.stdout.push(null); + }); + const t = createSshTransport({ spawn }); + await assert.rejects(() => t.probeTools('vps'), /size cap/); + assert.equal(spawn.calls[0].child.killed, 1); +}); + +const SH_SKIP = spawnSync('sh', ['-c', 'exit 0']).error ? 'sh is not available on this machine' : false; + +function runProbe(prelude) { + const result = spawnSyncRetryingCrash('sh', ['-c', prelude + PROBE_COMMAND], { encoding: 'utf8' }); + assert.equal(result.status, 0, 'a missing tool is an answer, not a failure'); + return parseProbe(result.stdout); +} + +test('PROBE_COMMAND reports each tool independently through a real shell', { skip: SH_SKIP }, () => { + assert.deepEqual(runProbe('PATH=/nonexistent; '), { tmux: false, inotifywait: false }); + assert.deepEqual(runProbe('PATH=/nonexistent; tmux() { :; }; '), { tmux: true, inotifywait: false }); + assert.deepEqual(runProbe('PATH=/nonexistent; inotifywait() { :; }; '), { tmux: false, inotifywait: true }); + assert.deepEqual(runProbe('PATH=/nonexistent; tmux() { :; }; inotifywait() { :; }; '), { tmux: true, inotifywait: true }); +}); From 6058d6203b3a00463cda3f1665e643566ab067ae Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Date: Sat, 3 Oct 2026 09:56:21 +0200 Subject: [PATCH 2/2] (remote): re-probe a host sooner while tmux or inotifywait is missing A false answer was kept for six hours, so attach stayed withheld long after tmux was installed. A host with a missing tool is now asked again after 30 minutes; one with both present keeps the six-hour interval. Refs #218 --- CHANGELOG.md | 2 +- docs/remote-hosts.md | 2 +- remote-index.js | 2 +- test/remote-index-probe.test.js | 19 +++++++++++++++++++ 4 files changed, 22 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 80cdd23b..fb3278e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ What changes for you in each release of Switchboard. How to write an entry: [doc - 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) ### Changed -- Switchboard now checks once per host, at the first refresh and then every six hours, 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) +- 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) - A trigger that gave up waiting for a session now says, in its result file's `reason`, when the session was blocked on a dialog such as a permission prompt or a question: for a single trigger, a chain's first wait, and a chain step whose turn never finished. Without a dialog the result is as before. (#379) ## v0.0.87 — 2026-10-02 diff --git a/docs/remote-hosts.md b/docs/remote-hosts.md index c6589654..b80f435f 100644 --- a/docs/remote-hosts.md +++ b/docs/remote-hosts.md @@ -128,7 +128,7 @@ as missing on an idle host. Once per host, at its first successful refresh and then every six hours (and on **Reconnect**), one extra ssh asks whether `tmux` and `inotifywait` are -installed and nothing else. A host with `tmux` and no session offers attach; a +installed and nothing else; while one of them is missing it asks again every 30 minutes, so an install is noticed. A host with `tmux` and no session offers attach; a host without it does not, even when a descriptor names a pane, and the dot says so. A host without `inotifywait` says that only the periodic pull runs. When the probe fails (timeout after 15 seconds, refused, unreadable answer), the host diff --git a/remote-index.js b/remote-index.js index c4b36890..d69c74ad 100644 --- a/remote-index.js +++ b/remote-index.js @@ -217,7 +217,7 @@ function createRemoteIndexer(ctx) { log.warn(`[remote:${alias}] tool probe failed: ${err.message}`); } const kept = tools || (prev ? prev.tools : null); - hostTools.set(alias, { tools: kept, nextProbeAt: now() + (tools ? PROBE_INTERVAL_MS : PROBE_RETRY_MS) }); + hostTools.set(alias, { tools: kept, nextProbeAt: now() + (kept && kept.tmux && kept.inotifywait && tools ? PROBE_INTERVAL_MS : PROBE_RETRY_MS) }); return !!tools && (!prev || !prev.tools || prev.tools.tmux !== tools.tmux || prev.tools.inotifywait !== tools.inotifywait); } diff --git a/test/remote-index-probe.test.js b/test/remote-index-probe.test.js index 5edb45c1..96b9eaa8 100644 --- a/test/remote-index-probe.test.js +++ b/test/remote-index-probe.test.js @@ -88,6 +88,25 @@ test('a later cycle inside the probe interval does not probe again, one past it } finally { h.cleanup(); } }); +test('an answer with a missing tool is asked again after the retry delay, one with both present only after the interval', async () => { + for (const [answer, delay] of [ + [{ tmux: false, inotifywait: true }, PROBE_RETRY_MS], + [{ tmux: true, inotifywait: false }, PROBE_RETRY_MS], + [{ tmux: true, inotifywait: true }, PROBE_INTERVAL_MS], + ]) { + const h = harness({ probe: async () => answer }); + try { + await h.indexer.refreshNow(); + h.advance(delay - 1); + await h.indexer.refreshNow(); + assert.equal(h.probes.length, 1, JSON.stringify(answer) + ' before the delay'); + h.advance(1); + await h.indexer.refreshNow(); + assert.equal(h.probes.length, 2, JSON.stringify(answer) + ' at the delay'); + } finally { h.cleanup(); } + } +}); + test('a probe failure leaves the profile unknown, does not fail the refresh, and retries only after the retry delay', async () => { let fail = true; const h = harness({ probe: async () => { if (fail) throw new Error('ssh probe timed out after 15000 ms'); return { tmux: false, inotifywait: false }; } });