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
3 changes: 2 additions & 1 deletion .ai/contexts/ipc-bridge.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,10 @@ This file is the **canonical inventory** of the IPC surface. When you add a new

### Events (main → renderer)

`terminal-data`, `session-detected`, `process-exited`, `terminal-notification`, `cli-busy-state`, `session-forked`, `subagent-spawned`, `subagent-completed`, `subagent-watch-event`, `projects-changed`, `status-update`, `indexing-progress`, `file-changed`, `mcp-open-diff`, `mcp-open-file`, `mcp-close-all-diffs`, `mcp-close-tab`, `updater-event`
`terminal-data`, `session-detected`, `process-exited`, `terminal-notification`, `cli-busy-state`, `session-forked`, `subagent-spawned`, `subagent-completed`, `subagent-watch-event`, `projects-changed`, `status-update`, `indexing-progress`, `file-changed`, `mcp-open-diff`, `mcp-open-file`, `mcp-close-all-diffs`, `mcp-close-tab`, `updater-event`, `session-transcript-activity`

- **`indexing-progress`**: `{coldStart, current, total, sessionsSoFar, done, error?}`. Fired only from `populateCacheViaWorker()` when the `initial_scan_complete` marker was absent at call time (a genuine first launch, a post-migration reset, or the resume of an interrupted first scan) — never on a routine warm-start rebuild. Throttled to ~4 events/s; the first event and the final `done:true` always pass. `done:true` with `error` means the scan failed and the renderer shows the failure in the banner instead of hiding it. Drives the renderer's dismissible first-run banner (`public/app.js`'s `updateIndexingBanner`); see `.ai/contexts/session-cache.md`.
- **`session-transcript-activity`**: `{sessionId, at}`. Fired from inside the raw `fs.watch(PROJECTS_DIR, ...)` callback in `startProjectsWatcher()` — deliberately **not** routed through the debounced `flushChanges()` / `notifyRendererProjectsChanged()` path that also lives there, so it reaches the renderer well before the 500ms debounce plus the cache refresh it triggers. Only fires for a top-level session transcript (`<folder>/<sessionId>.jsonl`, two path segments — a subagent leg is out of scope, see `.ai/contexts/session-state.md` ports table) whose sessionId has no live PTY in `activeSessions` (`sessionHasPty()`, main.js — the OSC busy/attention path already owns a PTY-backed row). Coalesced to at most one emission per second per session by `local-transcript-activity.js`'s `createLocalTranscriptTracker()`. Feeds `public/local-transcript-adapter.js` (`window.api.onSessionTranscriptActivity`) — the `local-transcript` adapter in `.ai/contexts/session-state.md`.

## Invariants

Expand Down
1 change: 1 addition & 0 deletions .ai/contexts/session-cache.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ From `derive-project-path.js`: `deriveProjectPath(folderPath)`, `resolveWorktree
- **`resolveWorktreePath` collapses `<repo>/.worktrees/<name>` → `<repo>`** when the parent dir exists. Consequence: many `~/.claude/projects/-home-...workspace-myproject--worktrees-X` folders derive to the same projectPath. Callers must dedupe (see `get-work-files` IPC for the pattern).
- **Two-table sidebar payload**: projects are aggregated, but each session row has its own `subagentType` field. A `null`/empty `subagentType` means it's a parent session; anything else (e.g. `'general-purpose'`, `'researcher'`) marks a subagent.
- **`fs.watch` debouncing**: the watcher batches per-folder events in a `pendingChanges = Map<folder, Set<filename> | true>` for ~200 ms before flushing to `refreshFolder`. A `true` value means "full walk needed" (rare path).
- **The same raw watcher callback also feeds a second, lighter-weight signal that bypasses this debounce entirely** (issue #246 step 4): for a top-level session transcript with no live PTY in `activeSessions`, it sends `session-transcript-activity` straight to the renderer, coalesced to ≤1/s per session by `local-transcript-activity.js`. This exists so a session launched outside Switchboard gets a busy indicator without waiting on the cache refresh — see `.ai/contexts/session-state.md` ("The local-transcript adapter") and `.ai/contexts/ipc-bridge.md`.
- **A session's title comes from its first *real* user turn, and a transcript without one is not indexed.** `classifyUserText()` in `read-session-file.js` sorts each user record into `prompt` / `command` / `skip`. `skip` is local-command bookkeeping (`<bash-input>`, `<bash-stdout>`, `<local-command-caveat>`, `<local-command-stdout>` — the CLI writes a command's own output back as a `user` record too); `command` is a bare slash-command record, recognised by a `<command-name>` tag next to a `<command-message>` or `<command-args>` one — the CLI writes both orders (`<command-name>` first for `/clear`, `<command-message>` first for `/auto-compact` and `/pre-compact`), so neither tag can be required to come first. The `skip` test is anchored to the start of the record: a real prompt that *quotes* `<local-command-stdout>` (a pasted transcript excerpt) is a turn, and skipping it can leave a session with no indexable prompt at all. This matters because **`/clear` opens a NEW jsonl and writes only that bookkeeping into it**; `/model`, by contrast, is written into the transcript that is already open, so it is a summary candidate only when it lands before any real prompt. Taking a `command` record as the summary therefore (a) titled every session started by `/clear` "`/clear clear </com…`" (the raw tags survive `cleanDisplayName`'s tag strip as a truncated fragment) and (b) indexed the bookkeeping-only transcript as a phantom sidebar session that the user never started. A `command` record is now a *fallback* title, used only when the transcript also holds an assistant turn (`/code-review high` → a real headless-command session); with no assistant turn both readers return `null` and nothing is indexed, matching how a brand-new session stays out of the sidebar until its first prompt. Rows written by the pre-fix parser cannot self-heal — the phantom ones sit on a file that never changes again, and the real ones keep the bad title because the header-only refresh path only overwrites a summary it can re-derive — so `db.js` migration **v9** purges rows whose summary starts with `<command-name>`, `<command-message>` or `<local-command-stdout>` — from `session_cache`, the three search tables and `session_metrics` (a phantom's file is never re-read, so its metrics would inflate the heatmap and the totals forever) — plus the `cache_meta` gate of their folders, which makes the next reconcile re-read exactly those files. The whole purge runs in one transaction: it cannot be resumed, since the relaunch that follows an interrupted run is already at db_version 9 and no longer matches the rows it dropped.
- **Stats `firstSessionDate`** is computed from `MIN(modified)`, not `MIN(created)`. Old sessions touched by recent reads keep their original `created` but their `modified` reflects the latest indexing — by design (the heatmap measures activity, not creation).

Expand Down
88 changes: 78 additions & 10 deletions .ai/contexts/session-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,14 @@ what actually shipped, not the whole plan.
`.session-icon` slot per sidebar row, replacing `.session-status-dot` for
session/subagent rows) shipped separately — see "The icon slot (step 3b)"
below.
- **Steps 4/5: pending.** There is no `local-transcript` adapter (step 4).
Subagent attribution is not routed through `session-state.js` (`agentsBusy`
exists in the model but nothing local-pty feeds it yet — sidebar.js's
`has-busy-agents` row class is still computed by `parentHasActiveSubagent()`,
independent of the domain module) (step 5).
- **Step 4: done.** The `local-transcript` adapter (`public/local-transcript-adapter.js`)
gives a session launched outside Switchboard (no PTY in this app) a busy
signal from transcript growth — see "The local-transcript adapter (step 4)"
below.
- **Step 5: pending.** Subagent attribution is not routed through
`session-state.js` (`agentsBusy` exists in the model but nothing local-pty
feeds it yet — sidebar.js's `has-busy-agents` row class is still computed by
`parentHasActiveSubagent()`, independent of the domain module).

### The remote-ssh adapter (step 3)

Expand Down Expand Up @@ -66,6 +69,70 @@ Both are driven by the same `active`/`armReady` inputs as the adapter, so the
two projections never disagree in practice; the dual-feed is a known,
temporary duplication, not a race.

### The local-transcript adapter (step 4)

`public/local-transcript-adapter.js` keeps one persistent
`createSessionState('local-transcript')` per session id, in
`localTranscriptStates` — same shape as `remoteSessionStates` above, pruned
by `pruneLocalTranscriptTimers()` (called from `refreshSidebar()` alongside
`pruneRemoteActivityTimers()`). Unlike the remote-ssh adapter it has no
watch-channel descriptor list to poll; its only inputs are:

- **`session-transcript-activity`** (`window.api.onSessionTranscriptActivity`,
main.js's raw watcher callback — see "The `~/.claude/projects` watcher…"
in `.ai/contexts/session-cache.md` and the channel doc in
`.ai/contexts/ipc-bridge.md`): `transcriptTouched` + `busy: true`, then a
20s decay timer (the same constant as the remote-ssh adapter,
`PIP_DECAY_MS`/`LOCAL_TRANSCRIPT_DECAY_MS`, duplicated rather than shared
across the two files — no common module currently holds cross-adapter
constants) applies `busy: false, armReady: false` — never
`responseReady`, exactly like the remote-ssh decay, because this adapter
has no PTY to confirm a turn actually ended either.
- **The session object's own `status`/`statusUpdatedAt`**
(`seedLocalTranscriptDescriptor`, called from inside the activity handler,
reading `sessionMap.get(sessionId)`): `liveness: 'alive'` +
`descriptorStatus(status, at)`, the same two calls `applyRemoteDescriptor`
makes for remote-ssh. **This is a deliberate widening of the issue's
original ports table**, which listed `descriptorStatus`/`liveness` as "no
(no live CLI)" for `local-transcript` — written before `cli-session-state.js`
(issue #245) established that a local session without a PTY *in this app*
can still be a live CLI process elsewhere, discoverable via
`~/.claude/sessions/<pid>.json` exactly the way `snapshotForLocal` already
reads it for the local-pty kind. Feeding it here keeps the two kinds'
snapshots consistent when a row transitions between them; neither field is
consumed by `renderSessionIcon`'s priority ladder yet (see "Ports table"
below), so this has no visible effect today beyond that consistency.
- **Nothing else.** No `attention`, no `waitingForInput` claimed from a
completion signal — see the ports-table note below the table.

**The main-process half has its own two guards, in `local-transcript-activity.js`
(a pure factory, `createLocalTranscriptTracker`, unit-tested without Electron —
same pattern as `remote-activity.js`).** `sessionIdFromWatchParts(parts)`
only resolves a session id for a top-level transcript — exactly
`<folder>/<sessionId>.jsonl`, two path segments; a subagent leg
(`<parent>/subagents/agent-X.jsonl`, or the legacy `<parent>/agent-X.jsonl`)
has three-plus and is out of scope here (step 5, a separate issue). The
injected `hasPty(sessionId)` (main.js wires in `sessionHasPty`, which walks
`activeSessions` the same way `cli-session-state.js`'s `findSession` does —
skip `exited`, match `session.realSessionId || key`) skips a session the OSC
path already owns, mirroring the renderer-side guard below one layer down.

**Guarded against a PTY takeover in both directions**:
`onLocalTranscriptActivity` checks `activePtyIds.has(sessionId)` and refuses
to even allocate state for a session already carrying a PTY in this app (the
OSC path owns it — see `session-activity-dom.js`'s `applyActivityClassesToElement`,
fed by `main.js`'s OSC 0/9 parsing, not this adapter). Going the other way,
`app.js`'s `updateRunningIndicators()` calls `localTranscriptPtyTakeover(id)`
for a non-remote row the instant it transitions into `activePtyIds` (the user
opened it), which clears the pending decay timer and deletes the row's
adapter state — a stale decay firing later must not repaint a row the
local-pty path now owns. `updateRunningIndicators()` also force-repaints that
row via `paintSessionIcon()` in the same pass, so the icon slot doesn't wait
for the next OSC event to reflect the handoff.

Same non-writer discipline as remote-ssh: every path ends in
`projectLocalTranscriptState(sessionId)` → `applyStateClasses()`.

## Shape

Three files, one direction of dependency for data, the reverse for rendering:
Expand Down Expand Up @@ -295,12 +362,13 @@ the others assert on the dot/slot element itself, only on row classes and
| event | local-pty | local-transcript | remote-ssh |
|---|---|---|---|
| `busy` / `attention` (OSC 0 / 9) | yes | never | wired via the watch channel (transcript writes), not OSC — OSC-while-attached is not wired |
| `transcriptTouched(at)` | yes | yes (only signal) | yes — `onRemoteActivityEvent`/`markRemoteBusy` |
| `descriptorStatus(status, at)` / `liveness` | yes | no (no live CLI) | yes (`main.js:539` → `applyRemoteDescriptor`) |
| `transcriptTouched(at)` | yes | yes (only signal) — `onLocalTranscriptActivity` | yes — `onRemoteActivityEvent`/`markRemoteBusy` |
| `descriptorStatus(status, at)` / `liveness` | yes | yes — `seedLocalTranscriptDescriptor`, from `sessionMap`'s `status`/`statusUpdatedAt` (see "The local-transcript adapter" above for why this widens the issue's original "no (no live CLI)") | yes (`main.js:539` → `applyRemoteDescriptor`) |
| `attached` | reserved, unused | reserved, unused | yes — `setRemoteAttached`, driven by the per-row `activePtyIds` transition |
| `subagentSpawned` / `subagentCompleted` | yes | no | no today |

An adapter without a PTY must never claim `waitingForInput` or `responseReady`
from a completion signal it cannot verify — that is why the remote-ssh
`busy: false` transition always passes `armReady: false` (see "The remote-ssh
adapter" above), not a tri-state `busy: unknown`.
from a completion signal it cannot verify — that is why the remote-ssh and
local-transcript `busy: false` transitions always pass `armReady: false` (see
"The remote-ssh adapter" and "The local-transcript adapter" above), not a
tri-state `busy: unknown`.
3 changes: 3 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,9 @@ const rendererCrossFileGlobals = {
paintSessionIcon: 'readonly',
// public/remote-activity-ui.js (remote-ssh adapter, see .ai/contexts/session-state.md)
setRemoteAttached: 'readonly',
// public/local-transcript-adapter.js (local-transcript adapter, see .ai/contexts/session-state.md)
localTranscriptPtyTakeover: 'readonly',
pruneLocalTranscriptTimers: 'readonly',
// public/sidebar.js, consumed by session-activity-dom.js's snapshotForLocal
// (see .ai/contexts/session-state.md, "The icon slot (step 3b)")
parentHasActiveSubagent: 'readonly',
Expand Down
38 changes: 38 additions & 0 deletions local-transcript-activity.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// see .ai/contexts/session-state.md
'use strict';

const SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const DEFAULT_IPC_MIN_MS = 1000;

// top-level transcript only, a subagent leg is out of scope — see .ai/contexts/session-state.md
function sessionIdFromWatchParts(parts) {
if (!Array.isArray(parts) || parts.length !== 2) return null;
const basename = parts[1];
if (typeof basename !== 'string' || !basename.endsWith('.jsonl')) return null;
const sessionId = basename.slice(0, -'.jsonl'.length);
return SESSION_ID_RE.test(sessionId) ? sessionId : null;
}

// hasPty() gates out a row the OSC path already owns — see .ai/contexts/session-state.md
function createLocalTranscriptTracker(opts = {}) {
const ipcMinMs = opts.ipcMinMs || DEFAULT_IPC_MIN_MS;
const now = opts.now || Date.now;
const hasPty = typeof opts.hasPty === 'function' ? opts.hasPty : () => false;

const ipcAt = new Map();

function record(parts) {
const sessionId = sessionIdFromWatchParts(parts);
if (!sessionId) return null;
if (hasPty(sessionId)) return null;
const t = now();
const last = ipcAt.has(sessionId) ? ipcAt.get(sessionId) : -Infinity;
if (t - last < ipcMinMs) return null;
ipcAt.set(sessionId, t);
return { sessionId, at: t };
}

return { record };
}

module.exports = { createLocalTranscriptTracker, sessionIdFromWatchParts, SESSION_ID_RE };
16 changes: 16 additions & 0 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 All @@ -465,6 +465,7 @@
const { createRemoteIndexer } = require('./remote-index');
const { createRemoteWatcher } = require('./remote-watch');
const { createRemoteActivityTracker } = require('./remote-activity');
const { createLocalTranscriptTracker } = require('./local-transcript-activity');

const remoteTransport = createSshTransport({ log });
const remoteIndexer = createRemoteIndexer({
Expand Down Expand Up @@ -2169,7 +2170,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 2173 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 Expand Up @@ -2570,6 +2571,16 @@
},
});

// a session with a live PTY is owned by the OSC path — see .ai/contexts/session-state.md
function sessionHasPty(sessionId) {
for (const [key, session] of activeSessions) {
if (!session || session.exited) continue;
if ((session.realSessionId || key) === sessionId) return true;
}
return false;
}
const localTranscriptTracker = createLocalTranscriptTracker({ hasPty: sessionHasPty });

// --- fs.watch on projects directory ---
let projectsWatcher = null;

Expand Down Expand Up @@ -2643,6 +2654,11 @@
// Specific .jsonl changed — targeted refresh on just this file
const rel = parts.slice(1).join(path.sep);
recordChange(folder, rel);
// out-of-band signal for the local-transcript adapter — see .ai/contexts/session-state.md
const activity = localTranscriptTracker.record(parts);
if (activity && mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('session-transcript-activity', activity);
}
} else {
return;
}
Expand Down
3 changes: 3 additions & 0 deletions preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,9 @@ contextBridge.exposeInMainWorld('api', {
onRemoteActivity: (callback) => {
ipcRenderer.on('remote-activity', (_event, payload) => callback(payload));
},
onSessionTranscriptActivity: (callback) => {
ipcRenderer.on('session-transcript-activity', (_event, payload) => callback(payload));
},
onStatusUpdate: (callback) => {
ipcRenderer.on('status-update', (_event, text, type) => callback(text, type));
},
Expand Down
8 changes: 7 additions & 1 deletion public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,7 @@ function refreshSidebar({ resort = false } = {}) {

renderProjects(projects, resort);
pruneRemoteActivityTimers();
pruneLocalTranscriptTimers();
}

// --- Archive toggle ---
Expand Down Expand Up @@ -822,8 +823,13 @@ function updateRunningIndicators() {
clearActiveSubagentsFor(id);
}
if (item.dataset.remoteAlias) setRemoteAttached(id, running);
// local-pty takes over a row the user just opened — see .ai/contexts/session-state.md
if (running && !item.dataset.remoteAlias) localTranscriptPtyTakeover(id);
const icon = item.querySelector('.session-icon');
if (icon) icon.classList.toggle('running', running);
if (icon) {
icon.classList.toggle('running', running);
if (running && !item.dataset.remoteAlias) paintSessionIcon(icon, id);
}
if (window.ATRACE) window.atrace('class.toggle', id, { el: item.id || null, cls: 'has-running-pty', on: running, icon: !!icon, fn: 'updateRunningIndicators' });
});
// Update slug group running dots
Expand Down
1 change: 1 addition & 0 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@
<script src="dialogs.js"></script>
<script src="sidebar.js"></script>
<script src="remote-activity-ui.js"></script>
<script src="local-transcript-adapter.js"></script>
<script src="memory-workfiles-view.js"></script>
<!-- restore-plan.js decides working-set restore vs. defer-and-retry; consumed by app.js only. -->
<script src="restore-plan.js"></script>
Expand Down
Loading
Loading