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
23 changes: 23 additions & 0 deletions .ai/contexts/cli-session-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 29 additions & 2 deletions cli-session-state.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
}
Expand All @@ -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;
Expand Down Expand Up @@ -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 });
}
}
}
}

Expand Down Expand Up @@ -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,
Expand Down
19 changes: 13 additions & 6 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 @@ -533,12 +533,19 @@
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/<pid>.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;
Expand Down Expand Up @@ -2129,7 +2136,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 2139 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
28 changes: 14 additions & 14 deletions public/sidebar.js
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -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
Expand All @@ -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')
Expand Down Expand Up @@ -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') {
Expand Down
6 changes: 4 additions & 2 deletions public/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
109 changes: 109 additions & 0 deletions test/annotate-remote-attachable-local-status.test.js
Original file line number Diff line number Diff line change
@@ -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/<pid>.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);
});
Loading
Loading