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
4 changes: 3 additions & 1 deletion src/main/remote/RemoteController.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ function makeManager(): FakeManager {
emitter.list = vi.fn(() => [])
emitter.getScreenSnapshot = vi.fn(() => null)
emitter.getConditionsSnapshot = vi.fn(() => null)
emitter.getTranscriptFile = vi.fn(() => null)
emitter.resolveTranscriptFile = vi.fn(async () => null)
emitter.getSpawnCwd = vi.fn(() => null)
emitter.getLastActivityAt = vi.fn(() => null)
emitter.write = vi.fn(() => true)
emitter.resolveCondition = vi.fn(async () => ({ ok: true as const }))
emitter.deliverPromptToAgent = vi.fn(async () => ({ ok: true as const }))
Expand Down
6 changes: 4 additions & 2 deletions src/main/remote/RemoteServer.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ function makeManager(): FakeManager {
emitter.list = vi.fn(() => [])
emitter.getScreenSnapshot = vi.fn(() => null)
emitter.getConditionsSnapshot = vi.fn(() => null)
emitter.getTranscriptFile = vi.fn(() => null)
emitter.resolveTranscriptFile = vi.fn(async () => null)
emitter.getSpawnCwd = vi.fn(() => null)
emitter.getLastActivityAt = vi.fn(() => null)
emitter.write = vi.fn(() => true)
emitter.resolveCondition = vi.fn(async () => ({ ok: true as const }))
emitter.deliverPromptToAgent = vi.fn(async () => ({ ok: true as const }))
Expand Down Expand Up @@ -331,7 +333,7 @@ describe('inbound scope enforcement on a live socket', () => {
}),
)
await writeFile(transcript, lines.join('\n') + '\n', 'utf8')
;(manager.getTranscriptFile as ReturnType<typeof vi.fn>).mockReturnValue(transcript)
;(manager.resolveTranscriptFile as ReturnType<typeof vi.fn>).mockResolvedValue(transcript)

const { ws, frames, token } = await openAuthed()
ws.send(JSON.stringify({
Expand Down
9 changes: 7 additions & 2 deletions src/main/remote/RemoteServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,9 @@ export type RemoteSessionControl = {
list(): string[]
getScreenSnapshot(sessionId: string): unknown
getConditionsSnapshot(sessionId: string): unknown
getTranscriptFile(sessionId: string): string | null
resolveTranscriptFile(sessionId: string): Promise<string | null>
getSpawnCwd(sessionId: string): string | null
getLastActivityAt(sessionId: string): number | null
write(sessionId: string, data: string): boolean
resolveCondition(
sessionId: string,
Expand Down Expand Up @@ -501,7 +503,10 @@ export class RemoteServer extends EventEmitter {
// relay — see SessionManager.getTranscriptFile). No file yet means
// the session has not written a durable line this run; the phone
// shows live-only and may retry after the first entry lands.
const file = this.deps.manager.getTranscriptFile(msg.sessionId)
// resolveTranscriptFile is resume-aware: restored sessions have a
// durable transcript even before their first NEW jsonl line this app
// run (the restart case field testing hit).
const file = await this.deps.manager.resolveTranscriptFile(msg.sessionId)
if (!file) {
return { ok: false, error: 'no transcript on disk yet for this session' }
}
Expand Down
6 changes: 4 additions & 2 deletions src/main/remote/SessionFeedSource.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ function makeManager(live: string[] = []): SessionManager & EventEmitter {
const anyEmitter = emitter as unknown as Record<string, unknown>
anyEmitter.getSessionKind = vi.fn(() => 'claude')
anyEmitter.list = vi.fn(() => live)
anyEmitter.getSpawnCwd = vi.fn(() => null)
anyEmitter.getLastActivityAt = vi.fn(() => null)
return emitter
}

Expand Down Expand Up @@ -78,8 +80,8 @@ describe('SessionFeedSource', () => {
manager.emit('started', { sessionId: 's1', kind: 'claude', projectDir: '/repo' })
manager.emit('started', { sessionId: 's2', kind: 'codex' })
expect(source.listSessions()).toEqual([
{ sessionId: 's1', kind: 'claude', cwd: '/repo', alive: true },
{ sessionId: 's2', kind: 'codex', cwd: null, alive: true },
{ sessionId: 's1', kind: 'claude', cwd: '/repo', alive: true, lastActivityAt: null },
{ sessionId: 's2', kind: 'codex', cwd: null, alive: true, lastActivityAt: null },
])

manager.emit('exit', { sessionId: 's1', exitCode: 0 })
Expand Down
23 changes: 20 additions & 3 deletions src/main/remote/SessionFeedSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export class SessionFeedSource {
* from the renderer's needs later anyway. */
private readonly pendingJsonl = new Map<string, PendingBurst>()
private readonly unsubscribes: Unsubscribe[] = []
private readonly manager: SessionManager
private disposed = false

constructor(manager: SessionManager) {
Expand All @@ -78,8 +79,14 @@ export class SessionFeedSource {
for (const sessionId of manager.list()) {
const kind = manager.getSessionKind(sessionId)
if (!kind || kind === 'terminal') continue
this.sessions.set(sessionId, { sessionId, kind, cwd: null, alive: true })
this.sessions.set(sessionId, {
sessionId,
kind,
cwd: manager.getSpawnCwd(sessionId),
alive: true,
})
}
this.manager = manager

const sub = <E extends Parameters<SessionManager['on']>[0]>(
event: E,
Expand All @@ -101,7 +108,10 @@ export class SessionFeedSource {
this.sessions.set(payload.sessionId, {
sessionId: payload.sessionId,
kind: payload.kind,
cwd: payload.projectDir ?? null,
// Spawn cwd over the event's projectDir: for Claude, projectDir is
// the TRANSCRIPT directory (~/.claude/projects/...), not the
// workspace — useless as a picker label.
cwd: this.manager.getSpawnCwd(payload.sessionId) ?? payload.projectDir ?? null,
alive: true,
})
this.emit('started', payload)
Expand Down Expand Up @@ -168,7 +178,14 @@ export class SessionFeedSource {
}

listSessions(): OutboundSessionSummary[] {
return [...this.sessions.values()].map(s => ({ ...s }))
// lastActivityAt is read FRESH per call rather than tracked in the map:
// the manager already maintains it for every relayed event, and the list
// is only materialized on connect/state changes — no reason to duplicate
// a hot-path counter here.
return [...this.sessions.values()].map(s => ({
...s,
lastActivityAt: this.manager.getLastActivityAt(s.sessionId),
}))
}

dispose(): void {
Expand Down
4 changes: 4 additions & 0 deletions src/main/remote/protocol/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,10 @@ export type OutboundSessionSummary = {
cwd: string | null
/** Present when the provider session has started; null for spawning/dead. */
alive: boolean
/** Epoch ms of the session's last observed activity — the sort key and
* "active 2m ago" label for the phone's picker. Read fresh from the
* manager at list time; the client bumps it locally from live events. */
lastActivityAt: number | null
}

export type OutboundFrame =
Expand Down
59 changes: 59 additions & 0 deletions src/main/sessionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { EventEmitter } from 'events'
import { performance } from 'perf_hooks'

import { getMainProvider } from '@providers/registry.main.js'
import { resolveProviderTranscriptPath } from '@main/providerSwitch/shared.js'
import { TerminalSession } from '@shared/runtime/terminalSession.js'
// WHY the manager no longer imports ScreenSnapshot from
// @providers/claude/runtime/claudeSession or JsonlEntry from
Expand Down Expand Up @@ -262,6 +263,20 @@ export class SessionManager extends EventEmitter {
// every history chunk, and the phone's TranscriptStore discards a chunk
// whose file disagrees with the file its live frames carry.
private readonly lastTranscriptFile = new Map<string, string>()
// Spawn-time identity captured for LATE resolution. The transcript-file
// cache above starts empty every app run, but RESUMED sessions have a
// durable transcript on disk from the moment they spawn — main just needs
// {cwd, resumeSessionId} to re-derive its path via the provider resolver.
// Without this, the remote companion's history backfill returned nothing
// for every restored session until it wrote a NEW line (field bug: "still
// just dumping the raw terminal" after an app restart). cwd doubles as
// the human-readable workspace label for the remote session list — the
// 'started' event's projectDir is the TRANSCRIPT directory for Claude,
// not the workspace the user thinks of.
private readonly spawnInfo = new Map<
string,
{ cwd: string; resumeSessionId: string | null }
>()
private readonly sessionSizes = new Map<string, PtySize>()
// Coalesce "input write to a session main doesn't own" incidents — the
// restored-agents-null bug can make a renderer spam writes against a stale id,
Expand Down Expand Up @@ -359,6 +374,7 @@ export class SessionManager extends EventEmitter {
this.lastScreenSnapshot.delete(sessionId)
this.lastConditionsSnapshot.delete(sessionId)
this.lastTranscriptFile.delete(sessionId)
this.spawnInfo.delete(sessionId)
// Keep lastActivityAt after removal. Process telemetry can be asked about a
// pane the renderer still knows but whose PTY already exited; deleting this
// tiny timestamp made those recently-exited panes look like they had never
Expand Down Expand Up @@ -400,6 +416,10 @@ export class SessionManager extends EventEmitter {
// restart wake. This reservation makes "spawning" observable to every
// concurrent caller without pretending the backend is ready yet.
this.spawningSessionIds.add(sessionId)
this.spawnInfo.set(sessionId, {
cwd: options.cwd,
resumeSessionId: options.resumeSessionId ?? null,
})
try {
const spawnStartedAt = performance.now()
performanceService.mark('session.spawn.start', {
Expand Down Expand Up @@ -1191,6 +1211,45 @@ export class SessionManager extends EventEmitter {
return this.lastTranscriptFile.get(sessionId) ?? null
}

/**
* Transcript file with a resume-aware fallback: when no jsonl entry has
* been observed THIS app run (the common state for every restored session
* right after a restart), a resumed session's transcript is re-derived
* from its spawn-time {cwd, resumeSessionId} through the same provider
* resolver history loading uses. Fresh sessions genuinely have no
* transcript until their first entry — null is correct for them.
*/
async resolveTranscriptFile(sessionId: string): Promise<string | null> {
const observed = this.lastTranscriptFile.get(sessionId)
if (observed) return observed
const info = this.spawnInfo.get(sessionId)
const kind = this.getSessionKind(sessionId)
if (!info?.resumeSessionId || !kind || kind === 'terminal') return null
try {
return await resolveProviderTranscriptPath({
kind,
cwd: info.cwd,
providerSessionId: info.resumeSessionId,
})
} catch {
// Resolution is best-effort: a missing project dir or unreadable
// sessions root should degrade to "no history yet", not break the
// caller's reply path.
return null
}
}

/** Workspace cwd captured at spawn — the label a session list should
* show (the 'started' event's projectDir is provider-internal). */
getSpawnCwd(sessionId: string): string | null {
return this.spawnInfo.get(sessionId)?.cwd ?? null
}

/** Epoch ms of the last observed activity (any relayed session event). */
getLastActivityAt(sessionId: string): number | null {
return this.lastActivityAt.get(sessionId) ?? null
}

/** Kill every live session. Called on app quit. */
async killAll(): Promise<void> {
const ids = this.list()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ function makeManager(): FakeManager {
emitter.list = vi.fn(() => [])
emitter.getScreenSnapshot = vi.fn(() => null)
emitter.getConditionsSnapshot = vi.fn(() => null)
emitter.getTranscriptFile = vi.fn(() => null)
emitter.resolveTranscriptFile = vi.fn(async () => null)
emitter.getSpawnCwd = vi.fn(() => null)
emitter.getLastActivityAt = vi.fn(() => null)
emitter.write = vi.fn(() => true)
emitter.resolveCondition = vi.fn(async () => ({ ok: true as const, state: { done: true } }))
emitter.deliverPromptToAgent = vi.fn(async () => ({ ok: true as const }))
Expand Down Expand Up @@ -188,7 +190,7 @@ describe('WebSocketSessionFeed against a live RemoteServer', () => {
{ type: 'user', uuid: 'u-live', message: { role: 'user', content: 'do the thing' } },
]
await writeFile(transcript, disk.map(d => JSON.stringify(d)).join('\n') + '\n', 'utf8')
;(manager.getTranscriptFile as ReturnType<typeof vi.fn>).mockReturnValue(transcript)
;(manager.resolveTranscriptFile as ReturnType<typeof vi.fn>).mockResolvedValue(transcript)

const f = makeFeed()
const store = new TranscriptStore(f)
Expand Down
11 changes: 11 additions & 0 deletions src/remote-client/src/WebSocketSessionFeed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,16 @@ export class WebSocketSessionFeed implements SessionFeed {
return
}
case 'session-event': {
// Keep the picker's recency live without re-requesting the list:
// any event for a session IS activity. Cheap (one map when the
// session is present) and mirrors how the server stamps the value.
const activeId = (frame.payload as { sessionId?: string })?.sessionId
if (activeId && this.lastSessionList.some(s => s.sessionId === activeId)) {
this.lastSessionList = this.lastSessionList.map(s =>
s.sessionId === activeId ? { ...s, lastActivityAt: Date.now() } : s,
)
for (const cb of [...this.sessionListListeners]) cb(this.lastSessionList)
}
const set = this.listeners[frame.channel]
if (!set) return
for (const cb of [...set]) (cb as (e: unknown) => void)(frame.payload)
Expand All @@ -313,6 +323,7 @@ export class WebSocketSessionFeed implements SessionFeed {
kind: started.kind,
cwd: started.projectDir ?? null,
alive: true,
lastActivityAt: Date.now(),
},
]
for (const cb of [...this.sessionListListeners]) cb(this.lastSessionList)
Expand Down
Loading