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
416 changes: 416 additions & 0 deletions docs/plans/2026-07-08-amplifier-session-durability-plan.md

Large diffs are not rendered by default.

70 changes: 70 additions & 0 deletions docs/plans/ACTIVITY_TRACKING_SPEC.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,75 @@
# Terminal Activity Tracking Feature Specification

> **Note (2026-07):** Sections below under "Historical: client-side activity
> indicators" describe a client-side feature from Jan 2026 that was rolled
> back. Current activity tracking is **server-authoritative**, per provider —
> see [Server-side coding-CLI activity tracking](#server-side-coding-cli-activity-tracking-2026-07)
> for the current design, including Amplifier's events-driven tracker.

## Server-side coding-CLI activity tracking (2026-07)

Each coding-CLI provider has a server-side activity tracker keyed by
`terminalId` (`server/coding-cli/*-activity-tracker.ts`) that derives turn
lifecycle (busy/idle + `turn.complete` events) from provider-native signals:

| Provider | Turn-start signal | Turn-end signal |
|---|---|---|
| claude | PTY submit (Enter) | Stop-hook BEL in PTY output |
| codex | PTY submit / app-server `onTurnStarted` | app-server `onTurnCompleted` / BEL / JSONL reconcile (deduped per turn) |
| opencode | SSE `session.status: busy` | SSE `session.idle` |
| amplifier | see events-driven design below | see events-driven design below |

All four trackers share a `TurnCompletionLedger`
(`server/coding-cli/turn-completion-ledger.ts`): a per-terminal monotonic
`completionSeq` plus the latest `TerminalTurnCompletionSnapshot`
(`shared/ws-protocol.ts`), surfaced via each tracker's
`listLatestCompletions()`. Ledger state is intentionally never cleared on
terminal removal, so the sequence stays monotonic across re-tracks and
late-attaching clients still receive the last completion.

### Amplifier events-driven design (single path)

Implemented per `docs/plans/2026-07-08-amplifier-session-durability-plan.md`
(§6). 2026-07-08: the feature flag (`FRESHELL_AMPLIFIER_EVENTS_TRACKING`) and
the degraded timing lane were removed by maintainer decision — single code
path; sessions without `events.jsonl` get no busy/turn signal.

Amplifier writes a schema-versioned event log per session
(`~/.amplifier/projects/<slug>/sessions/<id>/events.jsonl`, schema
`amplifier.log` ver 1.x) carrying `prompt:submit` / `prompt:complete` /
`session:end` lifecycle records. The tracker
(`server/coding-cli/amplifier-activity-tracker.ts`) runs one state machine per
terminal:

- `prompt:submit` is the only input that (re)enters busy; `prompt:complete` is
the single turn boundary (exactly one `turn.complete` via the
`TurnCompletionLedger`); `session:end` also ends a busy turn. PTY Enter is
only *provisionally* busy with a 2s grace reversion (one force-read retry,
then a silent revert — empty-Enter writes no events); PTY output only
refreshes liveness. The 120s deadman **never fabricates a completion** — it
requests a force-read of the events tail (WSL2 inotify backstop) and stays
busy. PTY exit removes state unconditionally.
- **Signal loss** (tailer error, schema mismatch, file reset, attach failure,
detach): no timing fallback. The phase reverts to idle silently (no
`turn.complete`), a single `amplifier_events_lane_degraded` warn is logged,
and tracking stops — the terminal then only shows the 2s provisional-busy
pulses from submit-grace. Terminals whose session never produces an
`events.jsonl` (bundle without the hooks-logging module) behave the same:
never confirmed busy, never `turn.complete` — acceptable, documented
behavior.

Composition: `amplifier-events-tailer` → `amplifier-events-reducer` (pure) →
`tracker.applyLifecycle()`, assembled by `amplifier-activity-integration.ts`
(which only attaches/detaches tailers). Fresh-session PTY↔session association
is handled by `amplifier-session-locator.ts` (first-prompt ↔ new-dir
correlation) + `amplifier-session-controller.ts`, with the coordinator slow
path and the indexer fast path (`source: 'amplifier_new_session'`) as
fallbacks.

---

## Historical: client-side activity indicators (Jan 2026, rolled back)

This document captures the implementation of terminal activity tracking that was added between commits `f910fbf` and `9d9f9cc` (12 commits total, implemented Jan 31 2026).

## Feature Overview
Expand Down
120 changes: 120 additions & 0 deletions server/coding-cli/activity-wiring-factory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import type {
TerminalInputRawEvent,
TerminalOutputRawEvent,
TerminalSessionBoundEvent,
} from '../terminal-stream/registry-events.js'

/**
* Shared registry→tracker wiring for PTY-signal-driven activity trackers
* (docs/plans/2026-07-08-amplifier-session-durability-plan.md §9 Phase 4:
* claude/amplifier wiring unification — the two modules were byte-identical
* except for the mode string, the sweep interval, and tracker disposal).
*
* Parameterized by:
* - `mode`: the terminal mode AND the `terminal.session.bound` provider string
* (identical for both current users: 'claude', 'amplifier').
* - `sweepIntervalMs`: the tracker's expire() sweep cadence.
* - `disposeTracker`: optional per-tracker teardown run on dispose() (the
* amplifier tracker clears its per-terminal debounce/grace timers; the
* claude tracker has no timers to clear).
*/

export type ActivityWiringTerminalSnapshot = {
terminalId: string
mode: string
status: string
}

export type ActivityWiringRegistry = {
list: () => Array<{ terminalId: string }>
get: (terminalId: string) => ActivityWiringTerminalSnapshot | undefined | null
on: (event: string, handler: (...args: any[]) => void) => void
off: (event: string, handler: (...args: any[]) => void) => void
}

export type PtyActivityTracker = {
trackTerminal(input: { terminalId: string; sessionId?: string; at: number }): void
bindSession(input: { terminalId: string; sessionId: string; at: number }): void
noteInput(input: { terminalId: string; data: string; at: number }): void
noteOutput(input: { terminalId: string; data: string; at: number }): void
noteExit(input: { terminalId: string }): void
expire(at: number): void
}

export function wirePtyActivityTracker<T extends PtyActivityTracker>(input: {
mode: string
tracker: T
sweepIntervalMs: number
registry: ActivityWiringRegistry
now?: () => number
setIntervalFn?: typeof setInterval
clearIntervalFn?: typeof clearInterval
disposeTracker?: (tracker: T) => void
}): { tracker: T; dispose(): void } {
const {
mode,
tracker,
sweepIntervalMs,
registry,
now = () => Date.now(),
setIntervalFn = setInterval,
clearIntervalFn = clearInterval,
disposeTracker,
} = input

const startTracking = (record: ActivityWiringTerminalSnapshot) => {
if (record.mode !== mode || record.status !== 'running') return
tracker.trackTerminal({ terminalId: record.terminalId, at: now() })
}

const onCreated = (record: ActivityWiringTerminalSnapshot) => {
startTracking(record)
}
const onBound = (event: TerminalSessionBoundEvent) => {
if (event.provider !== mode) return
// Production emits 'terminal.session.bound' BEFORE 'terminal.created', so a plain
// bindSession would be a no-op (no record yet) and drop the sessionId. Ensure the
// record exists with its sessionId first (trackTerminal is idempotent and updates
// the sessionId on an existing record); a later 'terminal.created' won't clobber it.
tracker.trackTerminal({ terminalId: event.terminalId, sessionId: event.sessionId, at: now() })
tracker.bindSession({ terminalId: event.terminalId, sessionId: event.sessionId, at: now() })
}
const onInput = (event: TerminalInputRawEvent) => {
tracker.noteInput({ terminalId: event.terminalId, data: event.data, at: event.at })
}
const onOutput = (event: TerminalOutputRawEvent) => {
tracker.noteOutput({ terminalId: event.terminalId, data: event.data, at: event.at })
}
const onExit = (event: { terminalId?: string }) => {
if (!event.terminalId) return
tracker.noteExit({ terminalId: event.terminalId })
}

registry.on('terminal.created', onCreated)
registry.on('terminal.session.bound', onBound)
registry.on('terminal.input.raw', onInput)
registry.on('terminal.output.raw', onOutput)
registry.on('terminal.exit', onExit)

for (const listed of registry.list()) {
const record = registry.get(listed.terminalId)
if (record) startTracking(record)
}

const sweepTimer = setIntervalFn(() => {
tracker.expire(now())
}, sweepIntervalMs)

return {
tracker,
dispose(): void {
registry.off('terminal.created', onCreated)
registry.off('terminal.session.bound', onBound)
registry.off('terminal.input.raw', onInput)
registry.off('terminal.output.raw', onOutput)
registry.off('terminal.exit', onExit)
clearIntervalFn(sweepTimer)
disposeTracker?.(tracker)
},
}
}
Loading
Loading