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
49 changes: 46 additions & 3 deletions .ai/contexts/session-cache.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,9 +176,35 @@ the exact remote command, and the mutation proofs are in
the first failure and each change of tier (`onHostFailure` in
`remote-index.js`), not every attempt, so the same field incident would have
produced roughly 5 lines instead of 226. Per-host state is readable via
`getRemoteHostState(alias)` (mirrors `getRemoteSessions(alias)`); no GUI
reads it yet. Proven in `test/remote-index.test.js` with an injected
`now()` clock — no real timers, no `setTimeout` waits.
`getRemoteHostState(alias)` (mirrors `getRemoteSessions(alias)`); the sidebar
reads its `nextAttemptAt` for the host dot's error tooltip (below). Proven in
`test/remote-index.test.js` with an injected `now()` clock — no real timers,
no `setTimeout` waits.

- **A manual refresh means "I know the host is back": it ignores the backoff
instead of waiting it out (issue #252).** Field incident 2026-09-10 17:41: a
single transient ssh timeout put a host in backoff, and because
`refreshHostNow`/`refreshNow` honoured `nextAttemptAt` unconditionally, the
sidebar refresh button and a per-host reconnect action were both powerless
for the whole ≥300 s window even though the tmux sessions were alive.
`refreshHostNow(alias, { force: true })` and `refreshNow({ force: true })`
now reset `failures`/`nextAttemptAt` to nominal (`lastError` is left alone —
it only clears on an actual success, or gets overwritten by a fresh failure)
and run the transport immediately regardless of `nextAttemptAt`. The
automatic callers — the periodic timer's `refreshNow()` and the watch
channel's `onRemoteWatchEvent` → `refreshHostNow(alias)` in `main.js` — call
both functions with no options, so `force` defaults to `false` and the
backoff keeps applying exactly as before. IPC `remote-hosts-refresh` (all
enabled hosts) and `remote-host-refresh` (one alias, `main.js`) both force;
after the refresh, both also restart that alias's watch channel
(`remoteWatcher.stop` then the same `start(alias, onRemoteWatchEvent,
onRemoteWatchActivity)` `syncRemoteWatchers()` uses, factored into
`startWatcherForHost`/`restartWatcherForAlias` so the callback wiring is
never duplicated) — this also clears a channel stuck on the
`SWITCHBOARD-NO-INOTIFYWAIT` marker, since `remoteWatcher.start()` resets
`unwatchable`. Proven in `test/remote-index.test.js`: `force` bypassing the
backoff and clearing it on success, and the automatic (non-forced) path
still honouring it, both on an injected clock.

- **The mirror is indexed off the main thread.** `workers/scan-projects.js` takes
`folderPrefix` and a `folders` subset in `workerData`, and
Expand Down Expand Up @@ -374,6 +400,23 @@ untouched.
cannot grow unboundedly across host-list edits. Attach now exists off this
data (issue #221, below); capacity tiers and a liveness badge in the UI
(#218, #212) still don't.
**Corrected 2026-09-10 (issue #252): only a SUCCESSFUL cycle replaces this
map.** `refreshHost()`'s failure path used to also do `remoteSessions.set(alias,
[])`, so one transient ssh timeout wiped every live descriptor and
`annotateRemoteAttachable` (`main.js`) then found none — every remote
session read as non-attachable for the whole backoff window even though
the tmux sessions were alive (field incident 2026-09-10 17:41). The wipe is
removed from both `refreshNow()`'s and `refreshHostNow()`'s catch blocks;
`getRemoteSessions(alias)` on a failed cycle now returns the previous
`sessions` list unchanged, the previous `at`, and the fresh `error` from
`hostBackoff` — the host dot already renders `error` as "unreachable", so
the UI signal is unchanged, only the underlying data survives. Attach
itself is the honest failure mode for a genuinely dead host: it tries the
stale descriptor and the ssh call inside it fails. A host that is
disabled or removed is still pruned by `pruneUnknownAliases()`, unaffected
by this change. Proven by `test/remote-index.test.js` ("getRemoteSessions
keeps the last known descriptors, not wiped, after a cycle where sync()
throws").
- **Remote hosts — meta.json sidecars (issue #244).** A subagent's agent
type lives in a sidecar `agent-<id>.meta.json` next to its transcript,
read by `readSubagentMeta()` (`read-session-file.js`). `LIST_COMMAND`'s
Expand Down
37 changes: 34 additions & 3 deletions main.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@
}

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

Check warning on line 73 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 73 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 } = require('./schedule-runner');
const { encodeProjectPath } = require('./encode-project-path');
const { isSensitivePath, isAllowedMemoryPath: _isAllowedMemoryPath, resolveAllowedMemoryPath: _resolveAllowedMemoryPath, isKnownProjectRoot: _isKnownProjectRoot } = require('./ipc-path-validator');
Expand Down Expand Up @@ -453,8 +453,8 @@
isInitialScanComplete, setInitialScanComplete,
},
});
const { readSessionFile, readFolderFromFilesystem, refreshFolder, reconcileCacheFromFilesystem,

Check warning on line 456 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 456 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 457 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 } = sessionCache;
const { resolveJsonlPath, enumerateSessionFiles } = require('./read-session-file');

Expand Down Expand Up @@ -496,18 +496,32 @@
}
}

function startWatcherForHost(host) {
remoteWatcher.start(host.alias, onRemoteWatchEvent, onRemoteWatchActivity);
}

function syncRemoteWatchers() {
const declared = enabledHosts((getSetting('global') || {}).remoteHosts);
const wanted = new Set(declared.map(h => h.alias));
for (const alias of watchedAliases) {
if (!wanted.has(alias)) remoteWatcher.stop(alias);
}
for (const host of declared) {
if (!remoteWatcher.isRunning(host.alias)) remoteWatcher.start(host.alias, onRemoteWatchEvent, onRemoteWatchActivity);
if (!remoteWatcher.isRunning(host.alias)) startWatcherForHost(host);
}
watchedAliases = wanted;
}

// see .ai/contexts/session-cache.md ("Remote hosts backoff" — manual reconnect, issue #252)
function restartWatcherForAlias(alias) {
const declared = enabledHosts((getSetting('global') || {}).remoteHosts);
const host = declared.find(h => h.alias === alias);
if (!host) return;
remoteWatcher.stop(alias);
startWatcherForHost(host);
watchedAliases.add(alias);
}

// see .ai/contexts/session-cache.md ("Remote hosts — tmux attach")
const remoteAttachAdapter = createTmuxAttachAdapter({
spawnPty: (file, args, ptyOpts) => spawnPty(file, args, { ...ptyOpts, cwd: os.homedir(), env: cleanPtyEnv }),
Expand All @@ -522,7 +536,8 @@
function hostInfo(alias) {
if (!hostInfoByAlias.has(alias)) {
const { sessions, at, error } = remoteIndexer.getRemoteSessions(alias);
hostInfoByAlias.set(alias, { at, error, byId: new Map(sessions.map(d => [d.sessionId, d])) });
const { nextAttemptAt } = remoteIndexer.getRemoteHostState(alias);
hostInfoByAlias.set(alias, { at, error, nextAttemptAt, byId: new Map(sessions.map(d => [d.sessionId, d])) });
}
return hostInfoByAlias.get(alias);
}
Expand All @@ -531,6 +546,7 @@
const info = hostInfo(project.remoteAlias);
project.remoteHostAt = info.at;
project.remoteHostError = info.error;
project.remoteHostNextAttemptAt = info.nextAttemptAt || null;
}
for (const session of project.sessions) {
if (session.remoteAlias) {
Expand Down Expand Up @@ -1521,9 +1537,24 @@
}
});

// see .ai/contexts/session-cache.md ("Remote hosts backoff" — manual reconnect, issue #252)
ipcMain.handle('remote-hosts-refresh', async () => {
try {
return { ok: true, ...(await remoteIndexer.refreshNow()) };
const result = await remoteIndexer.refreshNow({ force: true });
for (const host of enabledHosts((getSetting('global') || {}).remoteHosts)) {
restartWatcherForAlias(host.alias);
}
return { ok: true, ...result };
} catch (err) {
return { ok: false, error: err.message };
}
});

ipcMain.handle('remote-host-refresh', async (_event, alias) => {
try {
const result = await remoteIndexer.refreshHostNow(alias, { force: true });
restartWatcherForAlias(alias);
return { ok: true, ...result };
} catch (err) {
return { ok: false, error: err.message };
}
Expand Down Expand Up @@ -2136,7 +2167,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 2170 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 @@ -39,6 +39,7 @@ contextBridge.exposeInMainWorld('api', {
getEffectiveSettings: (projectPath) => ipcRenderer.invoke('get-effective-settings', projectPath),
remoteHostsApply: () => ipcRenderer.invoke('remote-hosts-apply'),
remoteHostsRefresh: () => ipcRenderer.invoke('remote-hosts-refresh'),
remoteHostRefresh: (alias) => ipcRenderer.invoke('remote-host-refresh', alias),
getScheduleCreatorCommand: () => ipcRenderer.invoke('get-schedule-creator-command'),
createScheduleSession: (projectPath) => ipcRenderer.invoke('create-schedule-session', projectPath),
runScheduleNow: (filePath) => ipcRenderer.invoke('run-schedule-now', filePath),
Expand Down
5 changes: 5 additions & 0 deletions public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -720,6 +720,11 @@ async function triggerRebuildAndSearch() {
if (rebuildInFlight) return;
rebuildInFlight = true;
if (searchRefreshBtn) searchRefreshBtn.classList.add('spinning');
// Fire-and-forget: a manual refresh also reconnects every enabled remote
// host (issue #252), but that must never block the local reindex below.
window.api.remoteHostsRefresh().catch((err) => {
console.error('remote hosts refresh failed', err);
});
try {
await window.api.rebuildCache();
} catch {}
Expand Down
37 changes: 35 additions & 2 deletions public/sidebar.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,17 +32,29 @@ function formatStatusAge(epochMs) {
return d + 'd ago';
}

// see .ai/contexts/session-cache.md ("Remote hosts backoff" — manual reconnect, issue #252)
function formatNextAttemptIn(epochMs) {
if (!Number.isFinite(epochMs)) return null;
const deltaMs = epochMs - Date.now();
if (deltaMs <= 0) return null;
const s = Math.ceil(deltaMs / 1000);
if (s < 60) return s + 's';
return Math.ceil(s / 60) + 'm';
}

// Three states a remote host's project header can carry (issue #212): the last
// sync cycle failed (host unreachable, reason visible), the host has never
// been read yet, or it was read successfully and genuinely has no live
// session right now. See .ai/contexts/session-cache.md.
function remoteHostState(project) {
if (project.remoteHostError) {
const age = formatStatusAge(project.remoteHostAt);
const nextIn = formatNextAttemptIn(project.remoteHostNextAttemptAt);
return {
cls: 'remote-host-error',
detail: 'host unreachable: ' + project.remoteHostError
+ (age ? ' (last confirmed ' + age + ')' : ', never confirmed'),
+ (age ? ' (last confirmed ' + age + ')' : ', never confirmed')
+ (nextIn ? '; next automatic attempt in ' + nextIn : ''),
};
}
if (!Number.isFinite(project.remoteHostAt)) {
Expand Down Expand Up @@ -781,6 +793,13 @@ function renderProjects(projects, resort) {
hostDot.className = 'session-status-dot remote-host-dot ' + state.cls;
hostDot.title = state.detail;
header.querySelector('.project-name').after(hostDot);

// see .ai/contexts/session-cache.md ("Remote hosts backoff" — manual reconnect, issue #252)
const hostRefreshBtn = document.createElement('button');
hostRefreshBtn.className = 'remote-host-refresh-btn';
hostRefreshBtn.title = 'Reconnect ' + project.remoteAlias + ' now';
hostRefreshBtn.innerHTML = '<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-3-6.7L21 8"/><path d="M21 3v5h-5"/></svg>';
hostDot.after(hostRefreshBtn);
}

const scheduleBtn = document.createElement('button');
Expand Down Expand Up @@ -1015,6 +1034,20 @@ function rebindSidebarEvents(projects) {
loadProjects();
};
}
const hostRefreshBtn = header.querySelector('.remote-host-refresh-btn');
if (hostRefreshBtn) {
hostRefreshBtn.onclick = async (e) => {
e.stopPropagation();
const hostDot = header.querySelector('.remote-host-dot');
if (hostDot) hostDot.className = 'session-status-dot remote-host-dot remote-host-connecting';
try {
await window.api.remoteHostRefresh(project.remoteAlias);
} catch (err) {
console.error('remote host refresh failed for ' + project.remoteAlias, err);
}
loadProjects();
};
}
const remapBtn = header.querySelector('.project-remap-btn');
if (remapBtn) {
remapBtn.onclick = async (e) => {
Expand All @@ -1032,7 +1065,7 @@ function rebindSidebarEvents(projects) {
};
}
header.onclick = (e) => {
if (e.target.closest('.project-new-btn') || e.target.closest('.project-archive-btn') || e.target.closest('.project-settings-btn') || e.target.closest('.project-schedule-btn') || e.target.closest('.project-remap-btn')) return;
if (e.target.closest('.project-new-btn') || e.target.closest('.project-archive-btn') || e.target.closest('.project-settings-btn') || e.target.closest('.project-schedule-btn') || e.target.closest('.project-remap-btn') || e.target.closest('.remote-host-refresh-btn')) return;
header.classList.toggle('collapsed');
};
}
Expand Down
40 changes: 40 additions & 0 deletions public/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -4167,6 +4167,46 @@ body { display: flex; flex-direction: column; }
.remote-host-dot.remote-host-unknown { background: #f5be3c; }
.remote-host-dot.remote-host-empty { background: rgba(255,255,255,0.18); }
.remote-host-dot.remote-host-live { background: #3ecf5a; }
/* Manual reconnect in flight (issue #252) — same grey as "never synced",
pulsed so it reads as active rather than unknown. */
.remote-host-dot.remote-host-connecting {
background: #f5be3c;
animation: pulse-dot 1s steps(4) infinite;
}

/* Project header manual reconnect button, next to the remote host dot */
.remote-host-refresh-btn {
background: transparent;
border: none;
color: #7a7a90;
width: 16px;
height: 16px;
margin: 0 0 0 2px;
border-radius: 4px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
line-height: 0;
vertical-align: middle;
transition: all 0.15s;
opacity: 0;
}

.remote-host-refresh-btn svg {
display: block;
}

.project-header:hover .remote-host-refresh-btn {
opacity: 0.7;
}

.remote-host-refresh-btn:hover {
opacity: 1;
background: rgba(255,255,255,0.04);
color: #b0b0c4;
}

.remote-hosts-list {
margin: 8px 0;
Expand Down
28 changes: 21 additions & 7 deletions remote-index.js
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,8 @@ function createRemoteIndexer(ctx) {
return toScan.size > 0;
}

async function refreshNow() {
// see .ai/contexts/session-cache.md ("Remote hosts backoff" — manual reconnect, issue #252)
async function refreshNow({ force = false } = {}) {
if (stopped || inFlight) return { skipped: true };
const list = hosts();
publishRoots(list);
Expand All @@ -214,13 +215,19 @@ function createRemoteIndexer(ctx) {
if (stopped) break;
if (hostInFlight.has(host.alias)) continue;
const state = backoffState(host.alias);
if (now() < state.nextAttemptAt) continue; // still backing off: no attempt, no log, no ssh
if (force) {
state.failures = 0;
state.nextAttemptAt = 0;
} else if (now() < state.nextAttemptAt) {
continue; // still backing off: no attempt, no log, no ssh
}
try {
if (await refreshHost(host)) changed = true;
onHostSuccess(host.alias);
remoteSessionsAt.set(host.alias, now());
} catch (err) {
remoteSessions.set(host.alias, []);
// A failed cycle keeps the last known descriptors — see
// .ai/contexts/session-cache.md ("Remote hosts — freshness contract").
errors.push({ alias: host.alias, error: err.message });
onHostFailure(host.alias, err, intervalMs);
}
Expand All @@ -233,13 +240,19 @@ function createRemoteIndexer(ctx) {
return { skipped: false, hosts: list.length, changed, errors };
}

// see .ai/contexts/session-cache.md ("Remote hosts — watch channel")
async function refreshHostNow(alias) {
// see .ai/contexts/session-cache.md ("Remote hosts — watch channel" and,
// for `force`, "Remote hosts backoff" — manual reconnect, issue #252)
async function refreshHostNow(alias, { force = false } = {}) {
if (stopped || inFlight || hostInFlight.has(alias)) return { skipped: true };
const host = hosts().find(h => h.alias === alias);
if (!host) return { skipped: true };
const state = backoffState(alias);
if (now() < state.nextAttemptAt) return { skipped: true };
if (force) {
state.failures = 0;
state.nextAttemptAt = 0;
} else if (now() < state.nextAttemptAt) {
return { skipped: true };
}
const intervalMs = normalizeRefreshMs(ctx.getRefreshMs ? ctx.getRefreshMs() : undefined);
hostInFlight.add(alias);
let changed = false;
Expand All @@ -249,7 +262,8 @@ function createRemoteIndexer(ctx) {
onHostSuccess(alias);
remoteSessionsAt.set(alias, now());
} catch (err) {
remoteSessions.set(alias, []);
// A failed cycle keeps the last known descriptors — see
// .ai/contexts/session-cache.md ("Remote hosts — freshness contract").
onHostFailure(alias, err, intervalMs);
error = err.message;
} finally {
Expand Down
6 changes: 5 additions & 1 deletion test/annotate-remote-attachable-local-status.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,10 @@ function makeAnnotate(mocks) {
source + '\nreturn annotateRemoteAttachable;'
);
return factory(
mocks.remoteIndexer || { getRemoteSessions: () => ({ sessions: [], at: null, error: null }) },
mocks.remoteIndexer || {
getRemoteSessions: () => ({ sessions: [], at: null, error: null }),
getRemoteHostState: () => ({ consecutiveFailures: 0, lastError: null, nextAttemptAt: 0 }),
},
mocks.remoteAttachAdapter || { supports: () => false },
mocks.remoteActivityTracker || { activeAt: () => null },
mocks.cliSessionState || { getStatus: () => undefined }
Expand Down Expand Up @@ -90,6 +93,7 @@ test('a remote session still gets status/statusUpdatedAt from the remote descrip
at: 111,
error: null,
}),
getRemoteHostState: () => ({ consecutiveFailures: 0, lastError: null, nextAttemptAt: 0 }),
},
remoteAttachAdapter: { supports: () => true },
cliSessionState: { getStatus: () => { throw new Error('must not be called for a remote session'); } },
Expand Down
5 changes: 5 additions & 0 deletions test/dom-setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ function setupSidebarDom() {
const apiTarget = {
onSubagentSpawned: (cb) => { apiTarget._subagentSpawnedCb = cb; },
onSubagentCompleted: (cb) => { apiTarget._subagentCompletedCb = cb; },
// Manual remote reconnect (issue #252) — explicit defaults so a test that
// doesn't care about these calls still gets a resolved promise; a test
// that does override them per-call, the same way archiveSession etc. do.
remoteHostsRefresh: () => Promise.resolve({ ok: true }),
remoteHostRefresh: () => Promise.resolve({ ok: true }),
};
window.api = new Proxy(apiTarget, {
get(target, prop) {
Expand Down
Loading
Loading