Skip to content
Merged
202 changes: 202 additions & 0 deletions server/coding-cli/truly-idle-emitter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
import { EventEmitter } from 'events'

/**
* Truly-idle grace window (pinned wire contract, shared with the Rust server
* port — do not change unilaterally): after a turn boundary the terminal must
* stay quiet (no new session-file activity, no queued user prompt) this long
* before a single `terminal.idle` edge is emitted.
*/
export const TERMINAL_IDLE_GRACE_MS = 2_000

export type TrulyIdleReason = 'grace' | 'queue-empty'

export type TrulyIdleEvent = {
terminalId: string
/** Server epoch ms at emit time. */
at: number
reason: TrulyIdleReason
}

export type TrulyIdleActivityUpsert = {
terminalId: string
/** Provider tracker phase. 'busy' and 'pending' count as busy; anything else is not. */
phase: string
}

export type TrulyIdleActivityChange = {
upsert?: TrulyIdleActivityUpsert[]
remove?: string[]
}

type TerminalIdleState = {
busy: boolean
/** True while the tracker phase is 'pending' (codex submit gate). */
pending: boolean
/** Queue evidence observed since the last emit (queued turn / re-armed submit). */
sawQueueEvidence: boolean
graceTimer?: ReturnType<typeof setTimeout>
}

function isBusyPhase(phase: string): boolean {
return phase === 'busy' || phase === 'pending'
}

/**
* Provider-agnostic truly-idle state machine, keyed by terminalId.
*
* Consumes the two event streams every activity tracker already emits —
* 'changed' (phase upserts / removals) and 'turn.complete' (turn boundaries) —
* and emits a single 'idle' edge per busy→truly-idle transition:
*
* - A turn boundary while the tracker still reports busy is a queued turn
* (claude inFlight ledger keeps phase busy until the queue drains): record
* queue evidence, never arm.
* - A turn boundary landing idle arms a ONE-SHOT grace timer. Any busy flip
* inside the window (new prompt:submit record, task_started, PTY submit
* provisional busy) cancels it — no per-terminal intervals, no polling.
* - A codex busy→pending re-arm (queued submit consumed at turn clear) counts
* as queue evidence; codex emits its completion only when the queue drained.
* - Deadman/signal-loss idle flips arrive WITHOUT a turn boundary and never
* arm; PTY exit / crash removals cancel any armed timer and never emit.
* - OpenCode's genuine turn end arrives as activityRemove followed by
* turnComplete: the removal clears state, the boundary then arms grace-only.
*
* Emits 'idle' with TrulyIdleEvent payloads.
*/
export class TrulyIdleEmitter extends EventEmitter {
private readonly states = new Map<string, TerminalIdleState>()
private readonly graceMs: number
private readonly now: () => number
private readonly setTimeoutFn: typeof setTimeout
private readonly clearTimeoutFn: typeof clearTimeout

constructor(input: {
graceMs?: number
now?: () => number
setTimeoutFn?: typeof setTimeout
clearTimeoutFn?: typeof clearTimeout
} = {}) {
super()
this.graceMs = input.graceMs ?? TERMINAL_IDLE_GRACE_MS
this.now = input.now ?? (() => Date.now())
this.setTimeoutFn = input.setTimeoutFn ?? setTimeout
this.clearTimeoutFn = input.clearTimeoutFn ?? clearTimeout
}

noteActivityChanged(change: TrulyIdleActivityChange): void {
for (const record of change.upsert ?? []) {
const state = this.getOrCreate(record.terminalId)
const nextBusy = isBusyPhase(record.phase)
const nextPending = record.phase === 'pending'
if (nextBusy) {
// Direct busy→pending is a queued submit consumed at a turn clear
// (codex re-arm) — the busy chain continues with a queued prompt.
if (state.busy && !state.pending && nextPending) {
state.sawQueueEvidence = true
}
this.cancelGrace(state)
}
state.busy = nextBusy
state.pending = nextPending
}
for (const terminalId of change.remove ?? []) {
const state = this.states.get(terminalId)
if (!state) continue
// Exit/crash (or an opencode idle removal): never emit from here — only
// a subsequent turn boundary may re-arm.
this.cancelGrace(state)
this.states.delete(terminalId)
}
}

noteTurnComplete(event: { terminalId: string; at: number }): void {
const state = this.getOrCreate(event.terminalId)
if (state.busy) {
// Queued turn still pending (claude inFlight > 0): hold the bell until
// the queue drains to a boundary that lands idle.
state.sawQueueEvidence = true
return
}
this.armGrace(event.terminalId, state)
}

/** Clear every armed grace timer (wiring dispose). */
dispose(): void {
for (const state of this.states.values()) {
this.cancelGrace(state)
}
this.states.clear()
}

private getOrCreate(terminalId: string): TerminalIdleState {
let state = this.states.get(terminalId)
if (!state) {
state = { busy: false, pending: false, sawQueueEvidence: false }
this.states.set(terminalId, state)
}
return state
}

private armGrace(terminalId: string, state: TerminalIdleState): void {
this.cancelGrace(state)
const timer = this.setTimeoutFn(() => {
this.handleGraceExpiry(terminalId)
}, this.graceMs)
// Never keep the event loop alive solely for a grace timer.
;(timer as unknown as { unref?: () => void }).unref?.()
state.graceTimer = timer
}

private cancelGrace(state: TerminalIdleState): void {
if (state.graceTimer !== undefined) {
this.clearTimeoutFn(state.graceTimer)
state.graceTimer = undefined
}
}

private handleGraceExpiry(terminalId: string): void {
const state = this.states.get(terminalId)
if (!state) return
state.graceTimer = undefined
if (state.busy) return
const reason: TrulyIdleReason = state.sawQueueEvidence ? 'queue-empty' : 'grace'
state.sawQueueEvidence = false
this.emit('idle', {
terminalId,
at: this.now(),
reason,
} satisfies TrulyIdleEvent)
}
}

type TrulyIdleTrackerLike = {
on(event: string, handler: (...args: any[]) => void): unknown
off(event: string, handler: (...args: any[]) => void): unknown
}

/**
* Wire a provider activity tracker's 'changed' + 'turn.complete' streams into
* a TrulyIdleEmitter. Returns a dispose that detaches the listeners and clears
* the emitter's timers.
*/
export function wireTrulyIdleEmitter(input: {
tracker: TrulyIdleTrackerLike
emitter: TrulyIdleEmitter
}): { dispose(): void } {
const { tracker, emitter } = input
const onChanged = (change: TrulyIdleActivityChange) => {
emitter.noteActivityChanged(change)
}
const onTurnComplete = (event: { terminalId: string; at: number }) => {
emitter.noteTurnComplete(event)
}
tracker.on('changed', onChanged)
tracker.on('turn.complete', onTurnComplete)
return {
dispose(): void {
tracker.off('changed', onChanged)
tracker.off('turn.complete', onTurnComplete)
emitter.dispose()
},
}
}
14 changes: 14 additions & 0 deletions server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { CodingCliSessionManager } from './coding-cli/session-manager.js'
import { wireCodexActivityTracker } from './coding-cli/codex-activity-wiring.js'
import { wireClaudeActivityTracker } from './coding-cli/claude-activity-wiring.js'
import { wireAmplifierActivityTracker } from './coding-cli/amplifier-activity-wiring.js'
import { TrulyIdleEmitter, wireTrulyIdleEmitter } from './coding-cli/truly-idle-emitter.js'
import { createAmplifierActivityIntegration } from './coding-cli/amplifier-activity-integration.js'
import { AmplifierSessionLocator } from './coding-cli/amplifier-session-locator.js'
import { AmplifierSessionController } from './coding-cli/amplifier-session-controller.js'
Expand Down Expand Up @@ -539,6 +540,18 @@ async function main() {
const sessionsSync = new SessionsSyncService(wsHandler)
const associationCoordinator = new SessionAssociationCoordinator(registry, ASSOCIATION_MAX_AGE_MS)

// Truly-idle alerting (terminal.idle): one emitter per provider tracker,
// fed by the tracker's 'changed' + 'turn.complete' streams. One-shot grace
// timers only — no polling, no per-pane intervals.
const trulyIdleWirings = [codexActivity.tracker, claudeActivity.tracker, amplifierActivity.tracker, opencodeActivity.tracker]
.map((tracker) => {
const emitter = new TrulyIdleEmitter()
emitter.on('idle', (event) => {
wsHandler.broadcastTerminalIdle(event)
})
return wireTrulyIdleEmitter({ tracker, emitter })
})

codexActivity.tracker.on('changed', (payload) => {
wsHandler.broadcastCodexActivityUpdated(payload)
})
Expand Down Expand Up @@ -1227,6 +1240,7 @@ async function main() {
await extWatcher?.close()

// 9b. Stop Codex activity tracker listeners and sweep timer
for (const wiring of trulyIdleWirings) wiring.dispose()
codexActivity.dispose()
claudeActivity.dispose()
amplifierActivity.dispose()
Expand Down
16 changes: 16 additions & 0 deletions server/ws-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import type {
OpencodeActivityRecord,
TerminalTurnCompletionSnapshot,
TerminalTurnCompleteMessage,
TerminalIdleMessage,
} from '../shared/ws-protocol.js'
import type { ExtensionManager } from './extension-manager.js'
import { allocateLocalhostPort } from './local-port.js'
Expand Down Expand Up @@ -71,6 +72,7 @@ import {
OpencodeActivityListSchema,
OpencodeActivityUpdatedSchema,
TerminalTurnCompleteSchema,
TerminalIdleSchema,
HelloSchema,
PingSchema,
ClientDiagnosticSchema,
Expand Down Expand Up @@ -3823,6 +3825,20 @@ export class WsHandler {
this.broadcastAuthenticated(parsed.data)
}

broadcastTerminalIdle(msg: Omit<TerminalIdleMessage, 'type'>): void {
const parsed = TerminalIdleSchema.safeParse({
type: 'terminal.idle',
...msg,
})

if (!parsed.success) {
log.warn({ issues: parsed.error.issues }, 'Invalid terminal.idle payload')
return
}

this.broadcastAuthenticated(parsed.data)
}

/**
* Prepare for hot rebind: close all client connections and set the closed
* flag so the patched server.close() → this.close() is a no-op.
Expand Down
20 changes: 20 additions & 0 deletions shared/ws-protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,24 @@ export const TerminalTurnCompleteSchema = z.object({
completionSeq: z.number().int().positive(),
})

/**
* Truly-idle edge for terminal-mode CLI panes (claude/codex/opencode/amplifier).
* Emitted once per busy -> truly-idle transition, after a grace window with no
* new session-file activity AND no detectable queued user prompt. Never emitted
* after crash/interrupt/exit; subagent completions inside a running turn never
* produce it. This is the ONLY edge the client rings/shades on for terminal CLI
* panes ('terminal.turn.complete' stays informational for them).
*
* Pinned wire contract shared with the Rust server port - do not change
* unilaterally: { terminalId, at (server epoch ms), reason: 'grace' | 'queue-empty' }.
*/
export const TerminalIdleSchema = z.object({
type: z.literal('terminal.idle'),
terminalId: z.string().min(1),
at: z.number().int().nonnegative(),
reason: z.enum(['grace', 'queue-empty']),
})

// ──────────────────────────────────────────────────────────────
// SDK content block schemas (from Claude Code NDJSON)
// ──────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -759,6 +777,7 @@ export type AmplifierActivityListResponseMessage = z.infer<typeof AmplifierActiv
export type AmplifierActivityUpdatedMessage = z.infer<typeof AmplifierActivityUpdatedSchema>

export type TerminalTurnCompleteMessage = z.infer<typeof TerminalTurnCompleteSchema>
export type TerminalIdleMessage = z.infer<typeof TerminalIdleSchema>

// -- Sessions --

Expand Down Expand Up @@ -990,6 +1009,7 @@ export type ServerMessage =
| AmplifierActivityListResponseMessage
| AmplifierActivityUpdatedMessage
| TerminalTurnCompleteMessage
| TerminalIdleMessage
| SessionsChangedMessage
| SettingsUpdatedMessage
| UiCommandMessage
Expand Down
Loading
Loading