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
24 changes: 17 additions & 7 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 @@ -488,16 +488,26 @@
// 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;
Expand Down Expand Up @@ -2013,7 +2023,7 @@
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)
Expand Down Expand Up @@ -2085,7 +2095,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 2098 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
62 changes: 61 additions & 1 deletion public/sidebar.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -730,6 +769,15 @@ function renderProjects(projects, resort) {
const missingIcon = project.missing ? '<svg class="project-missing-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg> ' : '';
header.innerHTML = `<span class="arrow">&#9660;</span> ${missingIcon}<span class="project-name">${escapeHtml(shortName)}</span>`;

// 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';
Expand Down Expand Up @@ -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') {
Expand Down
16 changes: 16 additions & 0 deletions public/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
13 changes: 12 additions & 1 deletion remote-index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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 {
Expand Down
200 changes: 200 additions & 0 deletions test/dom-sidebar-remote-freshness.test.js
Original file line number Diff line number Diff line change
@@ -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(); }
});
Loading
Loading