diff --git a/.ai/contexts/cli-session-state.md b/.ai/contexts/cli-session-state.md index e7a4b51b..b0ad2b7c 100644 --- a/.ai/contexts/cli-session-state.md +++ b/.ai/contexts/cli-session-state.md @@ -136,6 +136,29 @@ throw. The CLI does not write this file atomically. Degrading to "the tick handles it" is always an acceptable outcome, which is what makes depending on an undocumented file defensible at all. +## Surfacing status on the session object (issue #245) + +`getStatus(sessionId)` is a pure `Map` lookup over the `{status, +statusUpdatedAt}` pairs `seed()`/`handleFile()` already parse for every state +file they see — it adds no disk read, no watcher, and never calls `onIdle`, so +it does not touch the one invariant above. `main.js`'s `annotateRemoteAttachable` +calls it for every session without a `remoteAlias`, writing the result to the +same `session.status` / `session.statusUpdatedAt` pair a remote session gets +from its host's mirrored descriptor — one field pair, one renderer code path +(`public/sidebar.js`), for both a local and a remote session. The renderer +(`public/sidebar.js`, the state+age line built from `session.status` / +`session.statusUpdatedAt`) treats both sources identically and renders +regardless of `session.remoteAlias` — see also `.ai/contexts/session-cache.md` +("Remote hosts — freshness contract") for the remote half of that contract. + +The backing `statusBySession` map (kept alongside `known`, filename-keyed) +holds an entry **only while `isProcessAlive(state.pid)` is true** — a +descriptor a crashed or killed CLI left behind (the CLI only deletes its file +on a clean exit) must not surface as a permanently "live" status on a closed +session. Both `seed()` and `handleFile()` apply this gate before writing to +`statusBySession`; `handleFile()` also deletes the entry outright once the +liveness check fails, same as it does when the file itself disappears. + ## Canary tests `test/canary-*.test.js` is a convention this module introduces. A canary diff --git a/cli-session-state.js b/cli-session-state.js index 60728432..98bc3c67 100644 --- a/cli-session-state.js +++ b/cli-session-state.js @@ -24,6 +24,8 @@ let flushTimer = null; const pending = new Set(); const known = new Map(); const lastRescanAt = new Map(); +// sessionId -> { status, statusUpdatedAt } for live pids only -- see .ai/contexts/cli-session-state.md +const statusBySession = new Map(); function defaultIsProcessAlive(pid) { try { @@ -74,6 +76,8 @@ function handleFile(name) { try { text = fs.readFileSync(path.join(dir, name), 'utf8'); } catch { + const stale = known.get(name); + if (stale && stale.sessionId) statusBySession.delete(stale.sessionId); known.delete(name); return; } @@ -82,7 +86,13 @@ function handleFile(name) { if (!state) return; const prev = known.get(name); - known.set(name, { procStart: state.procStart, status: state.status }); + if (prev && prev.sessionId && prev.sessionId !== state.sessionId) statusBySession.delete(prev.sessionId); + known.set(name, { procStart: state.procStart, status: state.status, sessionId: state.sessionId }); + if (isProcessAlive(state.pid)) { + statusBySession.set(state.sessionId, { status: state.status, statusUpdatedAt: state.statusUpdatedAt }); + } else { + statusBySession.delete(state.sessionId); + } const reused = !!prev && prev.procStart !== state.procStart; if (!prev || reused) return; @@ -124,7 +134,12 @@ function seed() { let text; try { text = fs.readFileSync(path.join(dir, name), 'utf8'); } catch { continue; } const state = parseState(text); - if (state) known.set(name, { procStart: state.procStart, status: state.status }); + if (state) { + known.set(name, { procStart: state.procStart, status: state.status, sessionId: state.sessionId }); + if (isProcessAlive(state.pid)) { + statusBySession.set(state.sessionId, { status: state.status, statusUpdatedAt: state.statusUpdatedAt }); + } + } } } @@ -169,14 +184,26 @@ function stop() { } pending.clear(); known.clear(); + statusBySession.clear(); lastRescanAt.clear(); } +/** + * Pure lookup: the last {status, statusUpdatedAt} parsed for `sessionId`, or + * undefined if no state file has ever named it. Never touches disk, never + * arms anything -- see .ai/contexts/cli-session-state.md ("the one invariant"). + */ +function getStatus(sessionId) { + const entry = statusBySession.get(sessionId); + return entry ? { status: entry.status, statusUpdatedAt: entry.statusUpdatedAt } : undefined; +} + module.exports = { init, ensureWatching, stop, parseState, + getStatus, KNOWN_STATUSES, DEFAULT_DIR, FLUSH_MS, diff --git a/main.js b/main.js index 277163c8..7a48f9ec 100644 --- a/main.js +++ b/main.js @@ -533,12 +533,19 @@ function annotateRemoteAttachable(projects) { project.remoteHostError = info.error; } for (const session of project.sessions) { - if (!session.remoteAlias) continue; - 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; - session.remoteActiveAt = remoteActivityTracker.activeAt(session.remoteAlias, session.sessionId); + if (session.remoteAlias) { + const descriptor = hostInfo(session.remoteAlias).byId.get(session.sessionId); + session.remoteAttachable = !!(descriptor && remoteAttachAdapter.supports(descriptor)); + session.status = descriptor ? (descriptor.status || null) : null; + session.statusUpdatedAt = descriptor ? (descriptor.statusUpdatedAt || null) : null; + session.remoteActiveAt = remoteActivityTracker.activeAt(session.remoteAlias, session.sessionId); + } else { + // Same descriptor vocabulary, read from the local ~/.claude/sessions/.json + // instead of a remote host's mirror -- see .ai/contexts/cli-session-state.md + const local = cliSessionState.getStatus(session.sessionId); + session.status = local ? local.status : undefined; + session.statusUpdatedAt = local ? local.statusUpdatedAt : undefined; + } } } return projects; diff --git a/public/sidebar.js b/public/sidebar.js index 9c9b2c8b..8a0b9f94 100644 --- a/public/sidebar.js +++ b/public/sidebar.js @@ -17,8 +17,9 @@ 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) { +// see .ai/contexts/session-cache.md ("Remote hosts — freshness contract") and +// .ai/contexts/cli-session-state.md (local sessions use the same field pair) +function formatStatusAge(epochMs) { if (!Number.isFinite(epochMs)) return null; const deltaMs = Date.now() - epochMs; const s = Math.max(0, Math.floor(deltaMs / 1000)); @@ -37,7 +38,7 @@ function formatRemoteAge(epochMs) { // session right now. See .ai/contexts/session-cache.md. function remoteHostState(project) { if (project.remoteHostError) { - const age = formatRemoteAge(project.remoteHostAt); + const age = formatStatusAge(project.remoteHostAt); return { cls: 'remote-host-error', detail: 'host unreachable: ' + project.remoteHostError @@ -47,8 +48,8 @@ function remoteHostState(project) { 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; + const age = formatStatusAge(project.remoteHostAt); + const liveCount = (project.sessions || []).filter(s => s.status).length; return { cls: liveCount > 0 ? 'remote-host-live' : 'remote-host-empty', detail: (liveCount > 0 ? liveCount + ' live session' + (liveCount > 1 ? 's' : '') : 'no live session') @@ -1333,16 +1334,15 @@ function buildSessionItem(session) { : '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); - } + // see .ai/contexts/cli-session-state.md ("Surfacing status on the session object") + if (session.status) { + const age = formatStatusAge(session.statusUpdatedAt); + const statusEl = document.createElement('span'); + statusEl.className = 'session-status'; + statusEl.textContent = session.status + (age ? ' · ' + age : ''); + metaEl.appendChild(statusEl); } if (session.type === 'terminal') { diff --git a/public/style.css b/public/style.css index d07b5167..2245ecba 100644 --- a/public/style.css +++ b/public/style.css @@ -4150,8 +4150,10 @@ body { display: flex; flex-direction: column; } white-space: nowrap; } -/* Issue #212 — see .ai/contexts/session-cache.md ("Remote hosts — freshness contract") */ -.session-remote-status { +/* Issue #212/#245 — see .ai/contexts/session-cache.md ("Remote hosts — + freshness contract") and .ai/contexts/cli-session-state.md; shared by + remote and local sessions, which carry the same status/statusUpdatedAt pair */ +.session-status { color: #7fb3e0; } diff --git a/test/annotate-remote-attachable-local-status.test.js b/test/annotate-remote-attachable-local-status.test.js new file mode 100644 index 00000000..05b42c72 --- /dev/null +++ b/test/annotate-remote-attachable-local-status.test.js @@ -0,0 +1,109 @@ +// test/annotate-remote-attachable-local-status.test.js — issue #245. +// +// annotateRemoteAttachable() (main.js) attaches the shared status/statusUpdatedAt +// pair to every session: from the remote host's mirrored descriptor when the +// session carries a remoteAlias, and from the local ~/.claude/sessions/.json +// descriptor (via cliSessionState.getStatus) otherwise. This test extracts the +// REAL function body from main.js's source (same brace-matching technique +// test/get-projects-cold-start-reconcile.test.js uses) so it exercises the +// actual shipped logic, not a hand-copied re-implementation that could drift. + +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const root = path.join(__dirname, '..'); + +function extractAnnotateRemoteAttachableSource() { + const src = fs.readFileSync(path.join(root, 'main.js'), 'utf8'); + const marker = 'function annotateRemoteAttachable(projects)'; + const start = src.indexOf(marker); + assert.ok(start !== -1, 'main.js must define annotateRemoteAttachable'); + const bodyOpen = src.indexOf('{', start); + let depth = 0, 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; } } + } + assert.ok(end !== -1, 'annotateRemoteAttachable body must be balanced'); + return src.slice(start, end + 1); +} + +function makeAnnotate(mocks) { + const source = extractAnnotateRemoteAttachableSource(); + const factory = new Function( + 'remoteIndexer', 'remoteAttachAdapter', 'remoteActivityTracker', 'cliSessionState', + source + '\nreturn annotateRemoteAttachable;' + ); + return factory( + mocks.remoteIndexer || { getRemoteSessions: () => ({ sessions: [], at: null, error: null }) }, + mocks.remoteAttachAdapter || { supports: () => false }, + mocks.remoteActivityTracker || { activeAt: () => null }, + mocks.cliSessionState || { getStatus: () => undefined } + ); +} + +test('a local session (no remoteAlias) whose sessionId matches a local descriptor gets status/statusUpdatedAt attached', () => { + const annotateRemoteAttachable = makeAnnotate({ + cliSessionState: { + getStatus: (sessionId) => (sessionId === 'local-1' ? { status: 'idle', statusUpdatedAt: 12345 } : undefined), + }, + }); + + const projects = [{ + projectPath: '/home/dev/proj', + sessions: [{ sessionId: 'local-1' }], + }]; + + annotateRemoteAttachable(projects); + + assert.equal(projects[0].sessions[0].status, 'idle'); + assert.equal(projects[0].sessions[0].statusUpdatedAt, 12345); +}); + +test('a local session with no matching local descriptor leaves status/statusUpdatedAt undefined', () => { + const annotateRemoteAttachable = makeAnnotate({ + cliSessionState: { getStatus: () => undefined }, + }); + + const projects = [{ + projectPath: '/home/dev/proj', + sessions: [{ sessionId: 'no-descriptor' }], + }]; + + annotateRemoteAttachable(projects); + + assert.equal(projects[0].sessions[0].status, undefined); + assert.equal(projects[0].sessions[0].statusUpdatedAt, undefined); +}); + +test('a remote session still gets status/statusUpdatedAt from the remote descriptor, not from cliSessionState', () => { + const annotateRemoteAttachable = makeAnnotate({ + remoteIndexer: { + getRemoteSessions: (alias) => ({ + sessions: alias === 'planificator' + ? [{ sessionId: 'remote-1', status: 'busy', statusUpdatedAt: 999 }] + : [], + at: 111, + error: null, + }), + }, + remoteAttachAdapter: { supports: () => true }, + cliSessionState: { getStatus: () => { throw new Error('must not be called for a remote session'); } }, + }); + + const projects = [{ + projectPath: '/srv/proj', + remoteAlias: 'planificator', + sessions: [{ sessionId: 'remote-1', remoteAlias: 'planificator' }], + }]; + + annotateRemoteAttachable(projects); + + assert.equal(projects[0].sessions[0].status, 'busy'); + assert.equal(projects[0].sessions[0].statusUpdatedAt, 999); + assert.equal(projects[0].sessions[0].remoteAttachable, true); +}); diff --git a/test/cli-session-state.test.js b/test/cli-session-state.test.js index d57b90f6..2ac1215b 100644 --- a/test/cli-session-state.test.js +++ b/test/cli-session-state.test.js @@ -260,6 +260,117 @@ test('a missing directory attaches nothing and costs nothing', () => { cliSessionState.stop(); }); +test('getStatus returns the last parsed status/statusUpdatedAt for a sessionId', async () => { + const dir = mkTmp(); + try { + writeState(dir, 4242, { status: 'busy', statusUpdatedAt: 1000 }); + boot(dir, oneSession()); + await waitFor(() => cliSessionState.getStatus('sess-1') !== undefined); + assert.deepEqual(cliSessionState.getStatus('sess-1'), { status: 'busy', statusUpdatedAt: 1000 }); + + writeState(dir, 4242, { status: 'idle', statusUpdatedAt: 2000 }); + await waitFor(() => cliSessionState.getStatus('sess-1').status === 'idle'); + assert.deepEqual(cliSessionState.getStatus('sess-1'), { status: 'idle', statusUpdatedAt: 2000 }); + } finally { + cliSessionState.stop(); + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('getStatus works for a CLI descriptor with no matching Switchboard session (started outside Switchboard)', async () => { + const dir = mkTmp(); + try { + writeState(dir, 4242, { status: 'busy', sessionId: 'somebody-elses-session', statusUpdatedAt: 42 }); + // No Switchboard session at all — findSession() would never match this, + // but getStatus() is a pure sessionId lookup, independent of activeSessions. + boot(dir, new Map()); + await waitFor(() => cliSessionState.getStatus('somebody-elses-session') !== undefined); + assert.deepEqual(cliSessionState.getStatus('somebody-elses-session'), { status: 'busy', statusUpdatedAt: 42 }); + } finally { + cliSessionState.stop(); + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('getStatus never surfaces a descriptor left behind by a crashed process (seeded at startup)', async () => { + // The CLI deletes its state file on a clean exit only. A crash or a reboot + // can leave one behind indefinitely, and it must never read as a live status. + const dir = mkTmp(); + try { + writeState(dir, 4242, { status: 'idle', statusUpdatedAt: 1000 }); + boot(dir, oneSession(), { isProcessAlive: () => false }); + await delay(SETTLE_MS); + assert.equal(cliSessionState.getStatus('sess-1'), undefined, + 'a dead pid must never surface a status, even when seeded at startup'); + } finally { + cliSessionState.stop(); + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('getStatus never surfaces a descriptor left behind by a crashed process (written after the watcher started)', async () => { + const dir = mkTmp(); + try { + const { rescans } = boot(dir, oneSession(), { isProcessAlive: () => false }); + writeState(dir, 4242, { status: 'idle', statusUpdatedAt: 1000 }); + await delay(SETTLE_MS); + assert.equal(cliSessionState.getStatus('sess-1'), undefined, + 'a dead pid written after the watcher started must never surface a status'); + assert.equal(rescans.length, 0, 'a dead pid must still never rescan either (unchanged behavior)'); + } finally { + cliSessionState.stop(); + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('getStatus clears once a previously-live descriptor is next observed with a dead pid', async () => { + const dir = mkTmp(); + let alive = true; + try { + writeState(dir, 4242, { status: 'busy', statusUpdatedAt: 1000 }); + boot(dir, oneSession(), { isProcessAlive: () => alive }); + await waitFor(() => cliSessionState.getStatus('sess-1') !== undefined); + + alive = false; + writeState(dir, 4242, { status: 'idle', statusUpdatedAt: 2000 }); + await waitFor(() => cliSessionState.getStatus('sess-1') === undefined); + } finally { + cliSessionState.stop(); + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('getStatus returns undefined once the descriptor file is removed', async () => { + const dir = mkTmp(); + try { + writeState(dir, 4242, { status: 'idle', statusUpdatedAt: 1000 }); + boot(dir, oneSession()); + await waitFor(() => cliSessionState.getStatus('sess-1') !== undefined); + + fs.unlinkSync(path.join(dir, '4242.json')); + await waitFor(() => cliSessionState.getStatus('sess-1') === undefined); + } finally { + cliSessionState.stop(); + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('getStatus returns undefined for an sessionId never seen and stop() clears it', async () => { + const dir = mkTmp(); + try { + writeState(dir, 4242, { status: 'idle', statusUpdatedAt: 1000 }); + boot(dir, oneSession()); + await waitFor(() => cliSessionState.getStatus('sess-1') !== undefined); + assert.equal(cliSessionState.getStatus('unknown-session'), undefined); + + cliSessionState.stop(); + assert.equal(cliSessionState.getStatus('sess-1'), undefined, 'stop() must clear the cache'); + } finally { + cliSessionState.stop(); + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + test('parseState rejects everything that is not a usable state file', () => { const { parseState } = cliSessionState; assert.equal(parseState('not json'), null); diff --git a/test/dom-sidebar-local-status.test.js b/test/dom-sidebar-local-status.test.js new file mode 100644 index 00000000..198a1c6f --- /dev/null +++ b/test/dom-sidebar-local-status.test.js @@ -0,0 +1,112 @@ +// Issue #245 — a local session carrying the same status/statusUpdatedAt pair +// a remote session gets from its host descriptor must render the same +// `.session-status` state+age line, regardless of session.remoteAlias. + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { setupSidebarDom, makeSampleProject } = require('./dom-setup'); + +test('a local session with a status renders the state+age line', () => { + 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 session = { + sessionId: 'local-with-status', + summary: 'local session', + modified: '2026-09-09T11:59:00.000Z', + starred: false, + archived: 0, + messageCount: 1, + status: 'idle', + statusUpdatedAt: now - 3 * 60 * 1000, // 3 min ago + }; + + const item = ctx.sidebar.buildSessionItem(session); + + const statusEl = item.querySelector('.session-status'); + assert.ok(statusEl, 'a local session with status must render the status line'); + assert.match(statusEl.textContent, /idle.*3m ago/); + assert.equal(item.querySelector('.remote-badge'), null, 'a local session must not get a remote badge'); + } finally { + ctx.window.Date.now = realNow; + } + } finally { ctx.destroy(); } +}); + +test('a local session with no status renders no status line', () => { + const ctx = setupSidebarDom(); + try { + const session = { + sessionId: 'local-no-status', + summary: 'local session', + modified: '2026-09-09T11:59:00.000Z', + starred: false, + archived: 0, + messageCount: 1, + }; + + const item = ctx.sidebar.buildSessionItem(session); + + assert.equal(item.querySelector('.session-status'), null, 'no status field must mean no status line'); + } finally { ctx.destroy(); } +}); + +test('a remote session still renders both its remote badge and the status line', () => { + 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 session = { + sessionId: 'remote-with-status', + summary: 'remote session', + modified: '2026-09-09T11:59:00.000Z', + starred: false, + archived: 0, + messageCount: 1, + remoteAlias: 'planificator', + remoteAttachable: true, + status: 'busy', + statusUpdatedAt: now - 5000, + }; + + const item = ctx.sidebar.buildSessionItem(session); + + assert.ok(item.querySelector('.remote-badge'), 'a remote session must still carry its badge'); + const statusEl = item.querySelector('.session-status'); + assert.ok(statusEl, 'a remote session must still render the status line'); + assert.match(statusEl.textContent, /busy.*5s ago/); + } finally { + ctx.window.Date.now = realNow; + } + } finally { ctx.destroy(); } +}); + +test('renderProjects wires a real project fixture with a mix of local and remote sessions correctly', () => { + const ctx = setupSidebarDom(); + try { + const project = makeSampleProject({ + sessions: [{ + sessionId: 'local-in-project', + summary: 'local one', + modified: '2026-05-22T10:00:00.000Z', + starred: false, + archived: 0, + messageCount: 1, + status: 'waiting', + statusUpdatedAt: Date.now() - 1000, + }], + }); + + ctx.sidebar.renderProjects([project], true); + + const el = ctx.document.getElementById('si-local-in-project'); + assert.ok(el, 'the local session row must render'); + assert.ok(el.querySelector('.session-status'), 'the local session row must show its status line'); + } finally { ctx.destroy(); } +}); diff --git a/test/dom-sidebar-remote-freshness.test.js b/test/dom-sidebar-remote-freshness.test.js index aa0fe1a6..1e493d6c 100644 --- a/test/dom-sidebar-remote-freshness.test.js +++ b/test/dom-sidebar-remote-freshness.test.js @@ -113,8 +113,8 @@ test('a live remote session shows its status and age, and a 24h-old status does messageCount: 1, projectPath: '/srv/live-host', remoteAlias: 'planificator', - remoteStatus: 'idle', - remoteStatusUpdatedAt: now - 23 * 3600 * 1000, // 23h ago + status: 'idle', + statusUpdatedAt: now - 23 * 3600 * 1000, // 23h ago }; const freshSession = { sessionId: 'remote-fresh', @@ -125,8 +125,8 @@ test('a live remote session shows its status and age, and a 24h-old status does messageCount: 1, projectPath: '/srv/live-host', remoteAlias: 'planificator', - remoteStatus: 'idle', - remoteStatusUpdatedAt: now - 26 * 1000, // 26s ago + status: 'idle', + statusUpdatedAt: now - 26 * 1000, // 26s ago }; const project = remoteProject({ projectPath: '/srv/live-host', @@ -139,8 +139,8 @@ test('a live remote session shows its status and age, and a 24h-old status does 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'); + const staleEl = ctx.document.getElementById('si-remote-stale').querySelector('.session-status'); + const freshEl = ctx.document.getElementById('si-remote-fresh').querySelector('.session-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'); @@ -166,8 +166,8 @@ test('a host genuinely without any live session is distinct from a host with a l messageCount: 1, projectPath: '/srv/live-project', remoteAlias: 'planificator', - remoteStatus: 'busy', - remoteStatusUpdatedAt: Date.now() - 5000, + status: 'busy', + statusUpdatedAt: Date.now() - 5000, }; const liveProject = remoteProject({ projectPath: '/srv/live-project',