diff --git a/main.js b/main.js
index 4b94ed0e..cf1b6b02 100644
--- a/main.js
+++ b/main.js
@@ -488,16 +488,26 @@ const remoteAttachAdapter = createTmuxAttachAdapter({
// renderer can route a click without ever naming an attach mechanism itself
// — see .ai/contexts/session-cache.md ("Remote hosts — tmux attach").
function annotateRemoteAttachable(projects) {
- const descriptorsByAlias = new Map();
+ const hostInfoByAlias = new Map();
+ 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])) });
+ }
+ return hostInfoByAlias.get(alias);
+ }
for (const project of projects) {
+ if (project.remoteAlias) {
+ const info = hostInfo(project.remoteAlias);
+ project.remoteHostAt = info.at;
+ project.remoteHostError = info.error;
+ }
for (const session of project.sessions) {
if (!session.remoteAlias) continue;
- if (!descriptorsByAlias.has(session.remoteAlias)) {
- const descriptors = remoteIndexer.getRemoteSessions(session.remoteAlias);
- descriptorsByAlias.set(session.remoteAlias, new Map(descriptors.map(d => [d.sessionId, d])));
- }
- const descriptor = descriptorsByAlias.get(session.remoteAlias).get(session.sessionId);
+ const descriptor = hostInfo(session.remoteAlias).byId.get(session.sessionId);
session.remoteAttachable = !!(descriptor && remoteAttachAdapter.supports(descriptor));
+ session.remoteStatus = descriptor ? (descriptor.status || null) : null;
+ session.remoteStatusUpdatedAt = descriptor ? (descriptor.statusUpdatedAt || null) : null;
}
}
return projects;
@@ -2013,7 +2023,7 @@ ipcMain.handle('open-terminal', async (_event, sessionId, projectPath, isNew, se
try { cachedFolder = getCachedFolder(sessionId); } catch {}
if (isRemoteFolder(cachedFolder)) {
const { alias } = parseFolderKey(cachedFolder);
- const descriptor = remoteIndexer.getRemoteSessions(alias).find(s => s.sessionId === sessionId);
+ const descriptor = remoteIndexer.getRemoteSessions(alias).sessions.find(s => s.sessionId === sessionId);
const localPtySize = normalizePtySize(initialSize);
const attachResult = descriptor
? await remoteAttachAdapter.attach(alias, descriptor, localPtySize)
diff --git a/public/sidebar.js b/public/sidebar.js
index eb1f619e..0ffee734 100644
--- a/public/sidebar.js
+++ b/public/sidebar.js
@@ -17,6 +17,45 @@ function folderId(projectPath) {
return 'project-' + projectPath.replace(/[^a-zA-Z0-9_-]/g, '_');
}
+// see .ai/contexts/session-cache.md ("Remote hosts — freshness contract")
+function formatRemoteAge(epochMs) {
+ if (!Number.isFinite(epochMs)) return null;
+ const deltaMs = Date.now() - epochMs;
+ const s = Math.max(0, Math.floor(deltaMs / 1000));
+ if (s < 60) return s + 's ago';
+ const m = Math.floor(s / 60);
+ if (m < 60) return m + 'm ago';
+ const h = Math.floor(m / 60);
+ if (h < 24) return h + 'h ago';
+ const d = Math.floor(h / 24);
+ return d + 'd ago';
+}
+
+// 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 = formatRemoteAge(project.remoteHostAt);
+ return {
+ cls: 'remote-host-error',
+ detail: 'host unreachable: ' + project.remoteHostError
+ + (age ? ' (last confirmed ' + age + ')' : ', never confirmed'),
+ };
+ }
+ if (!Number.isFinite(project.remoteHostAt)) {
+ return { cls: 'remote-host-unknown', detail: 'not yet synced with this host' };
+ }
+ const age = formatRemoteAge(project.remoteHostAt);
+ const liveCount = (project.sessions || []).filter(s => s.remoteStatus).length;
+ return {
+ cls: liveCount > 0 ? 'remote-host-live' : 'remote-host-empty',
+ detail: (liveCount > 0 ? liveCount + ' live session' + (liveCount > 1 ? 's' : '') : 'no live session')
+ + ' (confirmed ' + age + ')',
+ };
+}
+
// --- Subagent localStorage helpers ---
// One-time GC: prune sessionIds that no longer exist in sessionMap.
@@ -730,6 +769,15 @@ function renderProjects(projects, resort) {
const missingIcon = project.missing ? ' ' : '';
header.innerHTML = `▼ ${missingIcon}${escapeHtml(shortName)}`;
+ // see .ai/contexts/session-cache.md ("Remote hosts — freshness contract")
+ if (project.remoteAlias) {
+ const state = remoteHostState(project);
+ const hostDot = document.createElement('span');
+ hostDot.className = 'session-status-dot remote-host-dot ' + state.cls;
+ hostDot.title = state.detail;
+ header.querySelector('.project-name').after(hostDot);
+ }
+
const scheduleBtn = document.createElement('button');
scheduleBtn.className = 'project-schedule-btn';
scheduleBtn.title = 'Create scheduled task';
@@ -1276,9 +1324,21 @@ function buildSessionItem(session) {
if (session.remoteAlias) {
const badge = document.createElement('span');
badge.className = 'remote-badge';
- badge.title = 'Read-only session mirrored from ' + session.remoteAlias;
+ badge.title = session.remoteAttachable
+ ? 'Live session on ' + session.remoteAlias + ' — click to attach'
+ : 'Session on ' + session.remoteAlias + ' — no live process, click to read its transcript';
badge.textContent = session.remoteAlias;
summaryEl.prepend(badge);
+
+ // status is the descriptor's last recorded transition, not a heartbeat —
+ // see .ai/contexts/session-cache.md ("Remote hosts — freshness contract")
+ if (session.remoteStatus) {
+ const age = formatRemoteAge(session.remoteStatusUpdatedAt);
+ const statusEl = document.createElement('span');
+ statusEl.className = 'session-remote-status';
+ statusEl.textContent = session.remoteStatus + (age ? ' · ' + age : '');
+ metaEl.appendChild(statusEl);
+ }
}
if (session.type === 'terminal') {
diff --git a/public/style.css b/public/style.css
index f19057c8..d07b5167 100644
--- a/public/style.css
+++ b/public/style.css
@@ -4150,6 +4150,22 @@ body { display: flex; flex-direction: column; }
white-space: nowrap;
}
+/* Issue #212 — see .ai/contexts/session-cache.md ("Remote hosts — freshness contract") */
+.session-remote-status {
+ color: #7fb3e0;
+}
+
+.remote-host-dot {
+ display: inline-block;
+ margin: 0 0 0 4px;
+ vertical-align: middle;
+}
+
+.remote-host-dot.remote-host-error { background: #e05a5a; }
+.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; }
+
.remote-hosts-list {
margin: 8px 0;
}
diff --git a/remote-index.js b/remote-index.js
index 1e48e613..1c3ac246 100644
--- a/remote-index.js
+++ b/remote-index.js
@@ -45,6 +45,7 @@ function createRemoteIndexer(ctx) {
let inFlight = false;
let stopped = false;
const remoteSessions = new Map(); // alias -> sessions array, from the same ssh cycle as the inventory
+ const remoteSessionsAt = new Map(); // alias -> epoch ms of the last cycle that did not throw
const hostBackoff = new Map(); // alias -> { failures, lastError, nextAttemptAt }
function backoffState(alias) {
@@ -116,6 +117,9 @@ function createRemoteIndexer(ctx) {
for (const alias of [...remoteSessions.keys()]) {
if (!known.has(alias)) remoteSessions.delete(alias);
}
+ for (const alias of [...remoteSessionsAt.keys()]) {
+ if (!known.has(alias)) remoteSessionsAt.delete(alias);
+ }
for (const alias of [...hostBackoff.keys()]) {
if (!known.has(alias)) hostBackoff.delete(alias);
}
@@ -212,6 +216,7 @@ function createRemoteIndexer(ctx) {
try {
if (await refreshHost(host)) changed = true;
onHostSuccess(host.alias);
+ remoteSessionsAt.set(host.alias, now());
} catch (err) {
remoteSessions.set(host.alias, []);
errors.push({ alias: host.alias, error: err.message });
@@ -260,8 +265,14 @@ function createRemoteIndexer(ctx) {
return start();
}
+ // Freshness contract (issue #212) — see .ai/contexts/session-cache.md ("Remote hosts — freshness contract")
function getRemoteSessions(alias) {
- return remoteSessions.get(alias) || [];
+ const backoff = hostBackoff.get(alias);
+ return {
+ sessions: remoteSessions.get(alias) || [],
+ at: remoteSessionsAt.get(alias) || null,
+ error: backoff ? backoff.lastError : null,
+ };
}
return {
diff --git a/test/dom-sidebar-remote-freshness.test.js b/test/dom-sidebar-remote-freshness.test.js
new file mode 100644
index 00000000..aa0fe1a6
--- /dev/null
+++ b/test/dom-sidebar-remote-freshness.test.js
@@ -0,0 +1,200 @@
+// Issue #212 — getRemoteSessions(alias) used to render an empty array for
+// three situations nothing distinguished: the host has no live session, the
+// host was never read, or the last cycle failed (descriptors deliberately
+// cleared). These tests pin that the sidebar now shows three distinct states,
+// and that a stale status age never reads as fresh.
+
+const test = require('node:test');
+const assert = require('node:assert/strict');
+
+const { setupSidebarDom, makeSampleProject } = require('./dom-setup');
+
+function remoteProject(overrides) {
+ return makeSampleProject({
+ sessions: [],
+ ...overrides,
+ });
+}
+
+function hostDot(ctx, projectPath) {
+ const fId = ctx.sidebar.folderId(projectPath);
+ const header = ctx.document.getElementById('ph-' + fId);
+ assert.ok(header, 'project header must render for ' + projectPath);
+ return header.querySelector('.remote-host-dot');
+}
+
+test('a host whose last cycle failed shows an unknown state distinct from a genuinely empty host, reason visible', () => {
+ const ctx = setupSidebarDom();
+ try {
+ const failedHost = remoteProject({
+ projectPath: '/srv/failed-host',
+ folder: 'planificator::-srv-failed-host',
+ remoteAlias: 'planificator',
+ remoteHostAt: Date.parse('2026-09-08T10:00:00Z'),
+ remoteHostError: 'ssh: connect to host planificator port 22: timed out',
+ });
+ const emptyHost = remoteProject({
+ projectPath: '/srv/empty-host',
+ folder: 'planificator::-srv-empty-host',
+ remoteAlias: 'planificator',
+ remoteHostAt: Date.parse('2026-09-09T09:00:00Z'),
+ remoteHostError: null,
+ });
+
+ ctx.sidebar.renderProjects([failedHost, emptyHost], true);
+
+ const failedDot = hostDot(ctx, '/srv/failed-host');
+ const emptyDot = hostDot(ctx, '/srv/empty-host');
+
+ assert.ok(failedDot, 'a failed-cycle host must carry a status dot');
+ assert.ok(emptyDot, 'a genuinely empty host must carry a status dot');
+ assert.ok(failedDot.classList.contains('remote-host-error'));
+ assert.ok(emptyDot.classList.contains('remote-host-empty'));
+ assert.notEqual(failedDot.className, emptyDot.className, 'the two states must render as visually distinct');
+
+ assert.match(failedDot.title, /host unreachable/i, 'the reason must be visible, not just "empty"');
+ assert.match(failedDot.title, /timed out/, 'the actual ssh failure reason must reach the UI');
+ assert.doesNotMatch(emptyDot.title, /unreachable/i, 'a genuinely empty host must not be worded like a failure');
+ } finally { ctx.destroy(); }
+});
+
+test('a host never yet read is distinct from both a failed host and an empty host', () => {
+ const ctx = setupSidebarDom();
+ try {
+ const neverRead = remoteProject({
+ projectPath: '/srv/never-read',
+ folder: 'planificator::-srv-never-read',
+ remoteAlias: 'planificator',
+ // remoteHostAt / remoteHostError intentionally absent: annotateRemoteAttachable
+ // never ran (or the alias was never refreshed) — this is the "never read" case.
+ });
+ const failedHost = remoteProject({
+ projectPath: '/srv/failed-host-2',
+ folder: 'planificator::-srv-failed-host-2',
+ remoteAlias: 'planificator',
+ remoteHostAt: Date.parse('2026-09-08T10:00:00Z'),
+ remoteHostError: 'ssh: connect to host planificator port 22: timed out',
+ });
+ const emptyHost = remoteProject({
+ projectPath: '/srv/empty-host-2',
+ folder: 'planificator::-srv-empty-host-2',
+ remoteAlias: 'planificator',
+ remoteHostAt: Date.parse('2026-09-09T09:00:00Z'),
+ remoteHostError: null,
+ });
+
+ ctx.sidebar.renderProjects([neverRead, failedHost, emptyHost], true);
+
+ const neverReadDot = hostDot(ctx, '/srv/never-read');
+ const failedDot = hostDot(ctx, '/srv/failed-host-2');
+ const emptyDot = hostDot(ctx, '/srv/empty-host-2');
+
+ assert.ok(neverReadDot.classList.contains('remote-host-unknown'));
+ assert.match(neverReadDot.title, /not yet synced/i);
+
+ const classes = new Set([neverReadDot.className, failedDot.className, emptyDot.className]);
+ assert.equal(classes.size, 3, 'never-read, failed and empty must be three visually distinct states');
+ } finally { ctx.destroy(); }
+});
+
+test('a live remote session shows its status and age, and a 24h-old status does not read as fresh', () => {
+ const ctx = setupSidebarDom();
+ try {
+ const now = Date.parse('2026-09-09T12:00:00Z');
+ const realNow = ctx.window.Date.now;
+ ctx.window.Date.now = () => now;
+ try {
+ const staleSession = {
+ sessionId: 'remote-stale',
+ summary: 'stale one',
+ modified: '2026-09-08T10:00:00.000Z',
+ starred: false,
+ archived: 0,
+ messageCount: 1,
+ projectPath: '/srv/live-host',
+ remoteAlias: 'planificator',
+ remoteStatus: 'idle',
+ remoteStatusUpdatedAt: now - 23 * 3600 * 1000, // 23h ago
+ };
+ const freshSession = {
+ sessionId: 'remote-fresh',
+ summary: 'fresh one',
+ modified: '2026-09-09T11:59:00.000Z',
+ starred: false,
+ archived: 0,
+ messageCount: 1,
+ projectPath: '/srv/live-host',
+ remoteAlias: 'planificator',
+ remoteStatus: 'idle',
+ remoteStatusUpdatedAt: now - 26 * 1000, // 26s ago
+ };
+ const project = remoteProject({
+ projectPath: '/srv/live-host',
+ folder: 'planificator::-srv-live-host',
+ remoteAlias: 'planificator',
+ remoteHostAt: now,
+ remoteHostError: null,
+ sessions: [staleSession, freshSession],
+ });
+
+ ctx.sidebar.renderProjects([project], true);
+
+ const staleEl = ctx.document.getElementById('si-remote-stale').querySelector('.session-remote-status');
+ const freshEl = ctx.document.getElementById('si-remote-fresh').querySelector('.session-remote-status');
+ assert.ok(staleEl, 'the stale session must show a status/age indicator');
+ assert.ok(freshEl, 'the fresh session must show a status/age indicator');
+
+ assert.match(freshEl.textContent, /idle.*26s ago/);
+ assert.match(staleEl.textContent, /idle.*23h ago/);
+ assert.notEqual(staleEl.textContent, freshEl.textContent);
+ assert.doesNotMatch(staleEl.textContent, /\ds ago/, 'a 24h-old status must not read as a fresh few-seconds-old one');
+ } finally {
+ ctx.window.Date.now = realNow;
+ }
+ } finally { ctx.destroy(); }
+});
+
+test('a host genuinely without any live session is distinct from a host with a live one', () => {
+ const ctx = setupSidebarDom();
+ try {
+ const liveSession = {
+ sessionId: 'remote-live',
+ summary: 'live one',
+ modified: '2026-09-09T11:59:00.000Z',
+ starred: false,
+ archived: 0,
+ messageCount: 1,
+ projectPath: '/srv/live-project',
+ remoteAlias: 'planificator',
+ remoteStatus: 'busy',
+ remoteStatusUpdatedAt: Date.now() - 5000,
+ };
+ const liveProject = remoteProject({
+ projectPath: '/srv/live-project',
+ folder: 'planificator::-srv-live-project',
+ remoteAlias: 'planificator',
+ remoteHostAt: Date.now(),
+ remoteHostError: null,
+ sessions: [liveSession],
+ });
+ const emptyProject = remoteProject({
+ projectPath: '/srv/really-empty',
+ folder: 'planificator::-srv-really-empty',
+ remoteAlias: 'planificator',
+ remoteHostAt: Date.now(),
+ remoteHostError: null,
+ sessions: [],
+ });
+
+ ctx.sidebar.renderProjects([liveProject, emptyProject], true);
+
+ const liveDot = hostDot(ctx, '/srv/live-project');
+ const emptyDot = hostDot(ctx, '/srv/really-empty');
+
+ assert.ok(liveDot.classList.contains('remote-host-live'));
+ assert.ok(emptyDot.classList.contains('remote-host-empty'));
+ assert.notEqual(liveDot.className, emptyDot.className);
+ assert.match(liveDot.title, /1 live session/);
+ assert.match(emptyDot.title, /no live session/i);
+ } finally { ctx.destroy(); }
+});
diff --git a/test/dom-sidebar-remote-session.test.js b/test/dom-sidebar-remote-session.test.js
index 85073e5e..76ee219e 100644
--- a/test/dom-sidebar-remote-session.test.js
+++ b/test/dom-sidebar-remote-session.test.js
@@ -64,6 +64,9 @@ test('a remote session with no live attachable descriptor opens the transcript,
assert.deepEqual(viewed, ['remote-1']);
assert.deepEqual(opened, [], 'openSession would spawn a PTY in a cwd that is not on this machine');
assert.match(item.title, /not currently attachable/i, 'the row must say why it fell back to the transcript');
+ const badge = item.querySelector('.remote-badge');
+ assert.match(badge.title, /transcript/i, 'the badge must not promise an attach it will not perform');
+ assert.doesNotMatch(badge.title, /read-only session mirrored/i, 'the pre-attach wording is obsolete');
assert.doesNotMatch(item.title, /tmux/i, 'the renderer must never name a multiplexer');
} finally { ctx.destroy(); }
});
@@ -91,6 +94,9 @@ test('a remote session with a live attachable descriptor opens a terminal, not t
assert.deepEqual(opened, ['remote-2']);
assert.deepEqual(viewed, [], 'an attachable remote session must open a terminal, not the read-only transcript');
assert.ok(!item.title, 'an attachable session carries no fallback-reason title');
+ const badge = item.querySelector('.remote-badge');
+ assert.match(badge.title, /attach/i, 'an attachable session must not be described as read-only');
+ assert.doesNotMatch(badge.title, /read-only/i, 'an attachable session must not be described as read-only');
} finally { ctx.destroy(); }
});
diff --git a/test/remote-index.test.js b/test/remote-index.test.js
index 2676f4e2..ef2b564d 100644
--- a/test/remote-index.test.js
+++ b/test/remote-index.test.js
@@ -171,10 +171,18 @@ test('getRemoteSessions surfaces per-host session descriptors from the same sync
const r = await indexer.refreshNow();
assert.deepEqual(r.errors, [], 'both hosts complete without error');
- assert.deepEqual(indexer.getRemoteSessions('withSessions'), sessionsByAlias.withSessions);
- assert.deepEqual(indexer.getRemoteSessions('empty'), []);
- assert.deepEqual(indexer.getRemoteSessions('some-alias-never-refreshed'), [],
- 'an unknown alias must never throw or return undefined');
+ const withSessions = indexer.getRemoteSessions('withSessions');
+ assert.deepEqual(withSessions.sessions, sessionsByAlias.withSessions);
+ assert.ok(Number.isInteger(withSessions.at), 'a successful cycle records when it happened');
+ assert.equal(withSessions.error, null);
+ const empty = indexer.getRemoteSessions('empty');
+ assert.deepEqual(empty.sessions, []);
+ assert.ok(Number.isInteger(empty.at), 'a host with zero live sessions still had a successful read');
+ assert.equal(empty.error, null);
+ const neverRefreshed = indexer.getRemoteSessions('some-alias-never-refreshed');
+ assert.deepEqual(neverRefreshed.sessions, [], 'an unknown alias must never throw or return undefined');
+ assert.equal(neverRefreshed.at, null, 'an alias never refreshed has no successful-read timestamp');
+ assert.equal(neverRefreshed.error, null);
} finally { fs.rmSync(dataDir, { recursive: true, force: true }); }
});
@@ -203,12 +211,20 @@ test('getRemoteSessions is cleared, not left stale, after a cycle where sync() t
const r1 = await indexer.refreshNow();
assert.deepEqual(r1.errors, []);
- assert.deepEqual(indexer.getRemoteSessions('planificator'), [{ pid: 1, sessionId: 'still-alive' }]);
+ const afterSuccess = indexer.getRemoteSessions('planificator');
+ assert.deepEqual(afterSuccess.sessions, [{ pid: 1, sessionId: 'still-alive' }]);
+ assert.ok(Number.isInteger(afterSuccess.at));
+ assert.equal(afterSuccess.error, null);
const r2 = await indexer.refreshNow();
assert.equal(r2.errors.length, 1, 'the second cycle must be reported as failed');
- assert.deepEqual(indexer.getRemoteSessions('planificator'), [],
+ const afterFailure = indexer.getRemoteSessions('planificator');
+ assert.deepEqual(afterFailure.sessions, [],
'a failed cycle must not keep reporting hours-old sessions as live');
+ assert.match(afterFailure.error, /timed out/,
+ 'the failure reason must survive on the accessor so the UI can distinguish it from a genuinely idle host');
+ assert.equal(afterFailure.at, afterSuccess.at,
+ 'the last-successful-read timestamp is not a heartbeat: a failed cycle does not bump it');
} finally { fs.rmSync(dataDir, { recursive: true, force: true }); }
});