diff --git a/__tests__/integration/components/LiveActivity.reconcile.test.tsx b/__tests__/integration/components/LiveActivity.reconcile.test.tsx
index 69870046..ab59ad33 100644
--- a/__tests__/integration/components/LiveActivity.reconcile.test.tsx
+++ b/__tests__/integration/components/LiveActivity.reconcile.test.tsx
@@ -66,7 +66,10 @@ describe('live activity reconciler', () => {
it('ends the activity when the session turns terminal', async () => {
await openTurn('srv-1', session())
const handle = factory.start.mock.results[0].value
- await reconcile('srv-1', session({ completedAt: '2026-07-25T11:00:00.000Z' }))
+ await reconcile(
+ 'srv-1',
+ session({ status: 'idle', ptyAttached: false, lifecycle: 'completed' }),
+ )
expect(handle.end).toHaveBeenCalledWith('immediate')
})
diff --git a/__tests__/integration/components/SessionScreen.endedRedirect.test.tsx b/__tests__/integration/components/SessionScreen.endedRedirect.test.tsx
index 3f845a46..b5bf9b73 100644
--- a/__tests__/integration/components/SessionScreen.endedRedirect.test.tsx
+++ b/__tests__/integration/components/SessionScreen.endedRedirect.test.tsx
@@ -1,12 +1,13 @@
/**
* SessionScreen — ended-session redirect.
*
- * When a session has ended (ptyAttached:false, status:'idle') the screen should
- * open its conversation history if the session HAS a conversation. The trigger
- * is boundConversationId ?? conversationId, NOT promptCount — promptCount counts
- * only prompts sent through the app, so an adopted / externally-started session
- * with real history reads promptCount 0 and must still redirect rather than
- * strand on the read-only "Running in another terminal" placeholder.
+ * When a session has no live process (streamer `lifecycle` completed/failed/
+ * resumable, or the legacy idle+detached pair on older servers) the screen
+ * should open its conversation history if the session HAS a conversation.
+ * The trigger is boundConversationId ?? conversationId, NOT promptCount —
+ * promptCount counts only prompts sent through the app, so an adopted /
+ * externally-started session with real history reads promptCount 0 and must
+ * still redirect rather than strand on the read-only placeholder.
*/
import React from 'react'
import { render } from '@testing-library/react-native'
@@ -113,27 +114,57 @@ describe('SessionScreen — ended-session redirect', () => {
mockParams = { id: SESSION_UUID, server: 'srv1' }
})
- it('redirects to the conversation when the ended session has a conversationId, even with promptCount 0', async () => {
- mockSessionData = endedSession({ conversationId: SESSION_UUID, promptCount: 0 })
+ it('redirects when lifecycle is completed and a conversationId is present', async () => {
+ mockSessionData = endedSession({
+ conversationId: SESSION_UUID,
+ promptCount: 0,
+ lifecycle: 'completed',
+ })
+ await render(, { wrapper: createWrapper() })
+ expect(mockReplace).toHaveBeenCalledWith(`/conversation/${SESSION_UUID}?server=srv1`)
+ })
+
+ it('redirects a held (resumable) session to conversation history for resume', async () => {
+ mockSessionData = endedSession({
+ conversationId: SESSION_UUID,
+ lifecycle: 'resumable',
+ completedAt: '2026-08-01T00:00:00Z',
+ })
await render(, { wrapper: createWrapper() })
expect(mockReplace).toHaveBeenCalledWith(`/conversation/${SESSION_UUID}?server=srv1`)
})
it('redirects via boundConversationId (codex) even without conversationId', async () => {
- mockSessionData = endedSession({ boundConversationId: SESSION_UUID, conversationId: null, promptCount: 0 })
+ mockSessionData = endedSession({
+ boundConversationId: SESSION_UUID,
+ conversationId: null,
+ promptCount: 0,
+ lifecycle: 'completed',
+ })
await render(, { wrapper: createWrapper() })
expect(mockReplace).toHaveBeenCalledWith(`/conversation/${SESSION_UUID}?server=srv1`)
})
it('does NOT redirect when the ended session has no conversation at all', async () => {
- mockSessionData = endedSession({ conversationId: null, boundConversationId: null, promptCount: 0 })
+ mockSessionData = endedSession({
+ conversationId: null,
+ boundConversationId: null,
+ promptCount: 0,
+ lifecycle: 'completed',
+ })
await render(, { wrapper: createWrapper() })
expect(mockReplace).not.toHaveBeenCalled()
})
- // A just-resumed session reads idle + detached until its PTY attaches, which
- // looks identical to an ended one. Redirecting then bounced the user straight
- // back to the conversation they had just tapped Resume on.
+ it('falls back to idle+detached redirect when lifecycle is absent', async () => {
+ mockSessionData = endedSession({ conversationId: SESSION_UUID, promptCount: 0 })
+ await render(, { wrapper: createWrapper() })
+ expect(mockReplace).toHaveBeenCalledWith(`/conversation/${SESSION_UUID}?server=srv1`)
+ })
+
+ // A just-resumed session on an older server (no lifecycle) can read
+ // idle+detached until its PTY attaches. Redirecting then bounced the user
+ // straight back to the conversation they had just tapped Resume on.
it('does NOT redirect a starting session that has not attached its PTY yet', async () => {
mockParams = { id: SESSION_UUID, server: 'srv1', starting: '1' }
mockSessionData = endedSession({ conversationId: SESSION_UUID, promptCount: 0 })
@@ -141,12 +172,24 @@ describe('SessionScreen — ended-session redirect', () => {
expect(mockReplace).not.toHaveBeenCalled()
})
+ it('does NOT redirect an attached live session', async () => {
+ mockSessionData = endedSession({
+ conversationId: SESSION_UUID,
+ ptyAttached: true,
+ status: 'waiting_input',
+ lifecycle: 'attached',
+ })
+ await render(, { wrapper: createWrapper() })
+ expect(mockReplace).not.toHaveBeenCalled()
+ })
+
it('drops the starting screen once the PTY is attached', async () => {
mockParams = { id: SESSION_UUID, server: 'srv1', starting: '1' }
mockSessionData = endedSession({
conversationId: SESSION_UUID,
ptyAttached: true,
status: 'waiting_input',
+ lifecycle: 'attached',
})
const { queryByText } = await render(, { wrapper: createWrapper() })
expect(queryByText('Starting session…')).toBeNull()
diff --git a/__tests__/unit/lib/sessionPresentation.test.ts b/__tests__/unit/lib/sessionPresentation.test.ts
index 8f680d9b..dcfbcf83 100644
--- a/__tests__/unit/lib/sessionPresentation.test.ts
+++ b/__tests__/unit/lib/sessionPresentation.test.ts
@@ -1,6 +1,7 @@
import {
deriveConversationPresentation,
deriveSessionPresentation,
+ sessionOpensAsHistory,
sessionPhase,
} from '@/lib/sessionPresentation'
@@ -120,17 +121,47 @@ describe('deriveSessionPresentation', () => {
capabilities: { canResume: true, canSendInput: false, isObserveOnly: true },
})
})
+
+ it('prefers lifecycle completed/failed over status idle', () => {
+ expect(
+ deriveSessionPresentation(
+ base({ status: 'idle', ptyAttached: false, lifecycle: 'completed' }),
+ ),
+ ).toMatchObject({ kind: 'completed', live: false })
+ expect(
+ deriveSessionPresentation(
+ base({
+ status: 'idle',
+ ptyAttached: false,
+ lifecycle: 'failed',
+ failureReason: 'boom',
+ }),
+ ).labelKey,
+ ).toBe('status.failed')
+ })
+
+ it('treats lifecycle resumable as historical (resume, observe-only)', () => {
+ expect(
+ deriveSessionPresentation(
+ base({ status: 'idle', ptyAttached: false, lifecycle: 'resumable' }),
+ ),
+ ).toMatchObject({
+ kind: 'historical',
+ live: false,
+ capabilities: { canResume: true, isObserveOnly: true },
+ })
+ })
})
describe('sessionPhase', () => {
- it('reads an idle, detached session with no completedAt as starting, not ended', () => {
+ it('reads an idle, detached session with no lifecycle as starting, not ended', () => {
expect(sessionPhase({ status: 'idle', ptyAttached: false })).toBe('starting')
})
- it('reads an idle, detached session with completedAt as ended', () => {
+ it('does not treat completedAt alone as ended (holds stamp it too)', () => {
expect(
sessionPhase({ status: 'idle', ptyAttached: false, completedAt: '2026-08-01T00:00:00Z' }),
- ).toBe('ended')
+ ).toBe('starting')
})
it('reads an idle, attached session as live', () => {
@@ -145,15 +176,47 @@ describe('sessionPhase', () => {
expect(sessionPhase({ status: 'running', ptyAttached: true })).toBe('live')
})
- it('gives completedAt precedence over ptyAttached still reading true', () => {
- // The server can report a stale ptyAttached: true on the same payload
- // that finally sets completedAt; completedAt is the authoritative signal.
+ it('maps streamer lifecycle onto the coarse phase', () => {
+ expect(sessionPhase({ status: 'running', ptyAttached: true, lifecycle: 'attached' })).toBe(
+ 'live',
+ )
+ expect(sessionPhase({ status: 'idle', ptyAttached: false, lifecycle: 'resumable' })).toBe(
+ 'resumable',
+ )
+ expect(sessionPhase({ status: 'idle', ptyAttached: false, lifecycle: 'completed' })).toBe(
+ 'ended',
+ )
+ expect(sessionPhase({ status: 'idle', ptyAttached: false, lifecycle: 'failed' })).toBe(
+ 'ended',
+ )
+ })
+
+ it('gives lifecycle precedence over a stale ptyAttached: true', () => {
expect(
- sessionPhase({ status: 'idle', ptyAttached: true, completedAt: '2026-08-01T00:00:00Z' }),
+ sessionPhase({ status: 'idle', ptyAttached: true, lifecycle: 'completed' }),
).toBe('ended')
})
})
+describe('sessionOpensAsHistory', () => {
+ it('opens history for ended and resumable lifecycles', () => {
+ expect(
+ sessionOpensAsHistory({ status: 'idle', ptyAttached: false, lifecycle: 'completed' }),
+ ).toBe(true)
+ expect(
+ sessionOpensAsHistory({ status: 'idle', ptyAttached: false, lifecycle: 'resumable' }),
+ ).toBe(true)
+ expect(
+ sessionOpensAsHistory({ status: 'running', ptyAttached: true, lifecycle: 'attached' }),
+ ).toBe(false)
+ })
+
+ it('falls back to idle+detached when lifecycle is absent', () => {
+ expect(sessionOpensAsHistory({ status: 'idle', ptyAttached: false })).toBe(true)
+ expect(sessionOpensAsHistory({ status: 'running', ptyAttached: false })).toBe(false)
+ })
+})
+
describe('deriveConversationPresentation', () => {
it('returns unavailable when resume is blocked', () => {
expect(
diff --git a/__tests__/unit/services/live-activity.test.ts b/__tests__/unit/services/live-activity.test.ts
index 95991864..4c569b1e 100644
--- a/__tests__/unit/services/live-activity.test.ts
+++ b/__tests__/unit/services/live-activity.test.ts
@@ -40,16 +40,26 @@ function makeState(overrides: Partial = {}): LiveSessionState
describe('isTerminal', () => {
it('treats each end-of-life signal independently', () => {
expect(isTerminal(makeSession({ processLiveness: 'gone' }))).toBe(true)
- expect(isTerminal(makeSession({ completedAt: '2026-07-25T11:00:00.000Z' }))).toBe(true)
+ expect(isTerminal(makeSession({ status: 'idle', ptyAttached: false, lifecycle: 'completed' }))).toBe(
+ true,
+ )
+ expect(isTerminal(makeSession({ status: 'idle', ptyAttached: false, lifecycle: 'resumable' }))).toBe(
+ true,
+ )
expect(isTerminal(makeSession({ status: 'idle', ptyAttached: false }))).toBe(true)
})
+ it('does not treat completedAt alone as terminal (holds stamp it too)', () => {
+ expect(isTerminal(makeSession({ completedAt: '2026-07-25T11:00:00.000Z' }))).toBe(false)
+ })
+
it('does not treat a live session as terminal', () => {
expect(isTerminal(makeSession())).toBe(false)
expect(isTerminal(makeSession({ status: 'waiting_input' }))).toBe(false)
// idle but still attached is a lull, not an ending
expect(isTerminal(makeSession({ status: 'idle', ptyAttached: true }))).toBe(false)
expect(isTerminal(makeSession({ processLiveness: 'unknown' }))).toBe(false)
+ expect(isTerminal(makeSession({ lifecycle: 'attached' }))).toBe(false)
})
})
@@ -63,7 +73,12 @@ describe('toLiveState', () => {
it('returns null for every terminal signal', () => {
expect(toLiveState(makeSession({ processLiveness: 'gone' }), 'srv-1')).toBeNull()
- expect(toLiveState(makeSession({ completedAt: STARTED_AT }), 'srv-1')).toBeNull()
+ expect(
+ toLiveState(
+ makeSession({ status: 'idle', ptyAttached: false, lifecycle: 'completed' }),
+ 'srv-1',
+ ),
+ ).toBeNull()
expect(toLiveState(makeSession({ status: 'idle', ptyAttached: false }), 'srv-1')).toBeNull()
})
diff --git a/app/conversation/[id].tsx b/app/conversation/[id].tsx
index 57780b53..c54ae203 100644
--- a/app/conversation/[id].tsx
+++ b/app/conversation/[id].tsx
@@ -308,8 +308,12 @@ export default function ConversationDetailScreen() {
// Only redirect to the live session view when the session is actually
// attached. An idle, detached session bounces straight back here, so
- // redirecting on mere existence is an infinite loop.
- const isSessionLive = liveSession?.ptyAttached === true
+ // redirecting on mere existence is an infinite loop. Prefer streamer
+ // `lifecycle` when present; fall back to ptyAttached on older servers.
+ const isSessionLive =
+ liveSession?.lifecycle != null
+ ? liveSession.lifecycle === 'attached'
+ : liveSession?.ptyAttached === true
useEffect(() => {
if (isConvNotFound && isSessionLive) {
router.replace(`/session/${id}?server=${serverId}`)
diff --git a/app/session/[id].tsx b/app/session/[id].tsx
index 4002c6ca..32386a1e 100644
--- a/app/session/[id].tsx
+++ b/app/session/[id].tsx
@@ -16,7 +16,7 @@ import { useLocalSearchParams, useRouter, useNavigation } from 'expo-router'
import * as Clipboard from 'expo-clipboard'
import { CopySimple, InfoIcon, PencilSimple, Star, StopCircle, GitDiff, Warning } from 'phosphor-react-native'
import { SessionStatusBadge } from '@/components/sessions/SessionStatusBadge'
-import { deriveSessionPresentation } from '@/lib/sessionPresentation'
+import { deriveSessionPresentation, sessionOpensAsHistory } from '@/lib/sessionPresentation'
import { useSessionDetail } from '@/hooks/useSession'
import { useSessionActions } from '@/hooks/useSessionActions'
import { useTerminalStream } from '@/hooks/useTerminalStream'
@@ -592,25 +592,30 @@ export default function SessionDetailScreen() {
}
useEffect(() => {
- // Redirect an ended session to its conversation history. Gate on "has a
- // conversation" (boundConversationId ?? conversationId), NOT promptCount:
- // promptCount counts only prompts sent through the app, so an adopted /
- // externally-started session that has real history reads promptCount 0 and
- // would otherwise strand on the read-only placeholder.
+ // Redirect when there is no live process to attach to and the session has
+ // conversation history. Prefer streamer `lifecycle` (via sessionOpensAsHistory)
+ // so a hold (`resumable`) is not conflated with a genuine end — both open
+ // history (resume lives there), but `completedAt` / idle+detached alone
+ // cannot tell them apart.
//
- // Skip while starting: a session that is spawning also reads
- // idle + detached, and redirecting then bounces the user back to the very
- // conversation they just resumed from.
+ // Gate on "has a conversation" (boundConversationId ?? conversationId), NOT
+ // promptCount: promptCount counts only prompts sent through the app, so an
+ // adopted / externally-started session that has real history reads
+ // promptCount 0 and would otherwise strand on the read-only placeholder.
+ //
+ // Skip while starting: older servers omit `lifecycle`, and a spawning
+ // session can still read idle+detached on those builds.
if (isPending) return
const hasConversation = !!(session?.boundConversationId ?? session?.conversationId)
- if (session?.ptyAttached === false &&
- session?.status === 'idle' &&
+ if (
+ session != null &&
+ sessionOpensAsHistory(session) &&
hasConversation &&
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id ?? '')
) {
router.replace(`/conversation/${id}?server=${serverId}`)
}
- }, [isPending, session?.ptyAttached, session?.status, session?.boundConversationId, session?.conversationId, id, serverId, router])
+ }, [isPending, session, id, serverId, router])
// Codex bind race: before boundConversationId arrives, history may 404 on the
// placeholder id. When the streamer first publishes the rollout UUID, switch
diff --git a/e2e/fixtures/session-detail.json b/e2e/fixtures/session-detail.json
index de4a8bba..376a7dc7 100644
--- a/e2e/fixtures/session-detail.json
+++ b/e2e/fixtures/session-detail.json
@@ -2,6 +2,7 @@
"id": "session-abc123",
"status": "running",
"ptyAttached": true,
+ "lifecycle": "attached",
"projectPath": "/home/user/my-project",
"projectName": "my-project",
"branch": "main",
diff --git a/e2e/fixtures/session-first-run.json b/e2e/fixtures/session-first-run.json
index 0ce46b81..7194608f 100644
--- a/e2e/fixtures/session-first-run.json
+++ b/e2e/fixtures/session-first-run.json
@@ -2,6 +2,7 @@
"id": "session-first-run",
"status": "running",
"ptyAttached": true,
+ "lifecycle": "attached",
"projectPath": "/home/user/my-project",
"projectName": "my-project",
"branch": "main",
diff --git a/e2e/fixtures/session-missing-path.json b/e2e/fixtures/session-missing-path.json
index b4504c2f..00bddab9 100644
--- a/e2e/fixtures/session-missing-path.json
+++ b/e2e/fixtures/session-missing-path.json
@@ -2,6 +2,7 @@
"id": "session-missing-path",
"status": "idle",
"ptyAttached": false,
+ "lifecycle": "failed",
"projectPath": "/Users/ronenmars/Desktop/dev/personal/new/ai-tools",
"projectName": "ai-tools",
"branch": "main",
diff --git a/e2e/fixtures/session-waiting-input.json b/e2e/fixtures/session-waiting-input.json
index 97be5c81..eefafb80 100644
--- a/e2e/fixtures/session-waiting-input.json
+++ b/e2e/fixtures/session-waiting-input.json
@@ -2,6 +2,7 @@
"id": "session-waiting",
"status": "waiting_input",
"ptyAttached": true,
+ "lifecycle": "attached",
"projectPath": "/home/user/my-project",
"projectName": "my-project",
"branch": "main",
diff --git a/e2e/fixtures/sessions.json b/e2e/fixtures/sessions.json
index 168d0881..09fe73e5 100644
--- a/e2e/fixtures/sessions.json
+++ b/e2e/fixtures/sessions.json
@@ -5,6 +5,7 @@
"serverLabel": "Local Dev",
"status": "running",
"ptyAttached": true,
+ "lifecycle": "attached",
"projectPath": "/home/user/my-project",
"projectName": "my-project",
"branch": "main",
@@ -19,6 +20,7 @@
"serverLabel": "Local Dev",
"status": "waiting_input",
"ptyAttached": true,
+ "lifecycle": "attached",
"projectPath": "/home/user/my-project",
"projectName": "my-project",
"branch": "feature/auth",
@@ -33,6 +35,7 @@
"serverLabel": "Local Dev",
"status": "idle",
"ptyAttached": false,
+ "lifecycle": "completed",
"projectPath": "/home/user/my-project",
"projectName": "my-project",
"branch": "fix/login",
@@ -47,6 +50,7 @@
"serverLabel": "Remote Dev",
"status": "running",
"ptyAttached": true,
+ "lifecycle": "attached",
"projectPath": "/home/user/other-project",
"projectName": "other-project",
"branch": "main",
diff --git a/lib/sessionPresentation.ts b/lib/sessionPresentation.ts
index 66b8d7d9..d7d0346d 100644
--- a/lib/sessionPresentation.ts
+++ b/lib/sessionPresentation.ts
@@ -1,4 +1,4 @@
-import type { UnavailableReason } from '@/types/api'
+import type { SessionLifecycle, UnavailableReason } from '@/types/api'
/**
* Canonical session/conversation presentation kinds for hub rows, badges, and
@@ -67,6 +67,8 @@ export type SessionPresentationInput = {
resumedFromConversationId?: string | null
completedAt?: string
failureReason?: string
+ /** Process-lifetime axis from the streamer. Additive; older servers omit it. */
+ lifecycle?: SessionLifecycle
}
export interface ConversationPresentationInput {
@@ -149,6 +151,25 @@ export function deriveSessionPresentation(
}
}
+ // Prefer streamer `lifecycle` when present — `completedAt` is stamped on both
+ // a real exit and a hold, so it cannot separate these two branches.
+ if (session.lifecycle === 'completed' || session.lifecycle === 'failed') {
+ return {
+ kind: 'completed',
+ labelKey:
+ session.lifecycle === 'failed' || session.failureReason
+ ? 'status.failed'
+ : 'status.completed',
+ live: false,
+ externalLive: false,
+ colorToken:
+ session.lifecycle === 'failed' || session.failureReason ? 'failed' : 'completed',
+ confidence,
+ activityAt,
+ capabilities: IDLE_MANAGED_CAPS,
+ }
+ }
+
if (status === 'completed' || status === 'failed') {
if (status === 'failed' || session.failureReason) {
return {
@@ -200,7 +221,14 @@ export function deriveSessionPresentation(
}
}
- if (session.ownership === 'historical' || (external && !externalAlive)) {
+ // Held / rehydrated / historical: no live process, resume from conversation.
+ // `lifecycle: "resumable"` is the streamer's signal for a hold (and for
+ // rehydrated stubs); do not conflate it with `completed` / `failed`.
+ if (
+ session.lifecycle === 'resumable' ||
+ session.ownership === 'historical' ||
+ (external && !externalAlive)
+ ) {
// A rehydrated stub's `status` had to flatten to `idle`; `interruptedStatus`
// is the streamer telling us what it was actually doing when it stopped it.
// Label only — kind, colour and capabilities stay put, so the row is still
@@ -295,17 +323,43 @@ export function isPresentationLive(session: SessionPresentationInput): boolean {
return deriveSessionPresentation(session).live
}
-export type SessionPhase = 'starting' | 'live' | 'ended'
+export type SessionPhase = 'starting' | 'live' | 'ended' | 'resumable'
/**
+ * Coarse client phase derived from the streamer's `lifecycle` when present.
+ *
* `ptyAttached === false && status === 'idle'` is ambiguous on its own: it is
- * equally true of a session that has not started yet (`/api/sessions/start`
- * and `/api/sessions/resume` both respond before the PTY attaches) and one
- * that has already ended. `completedAt` is the field the server only sets on
- * a genuine end, so it — not the idle/detached pair — is the discriminator.
+ * equally true of a held (still-resumable) session and one that has genuinely
+ * ended, and `completedAt` is stamped on both. Prefer `lifecycle`.
+ *
+ * Without `lifecycle`, fall back to attach/status only — never to `completedAt`.
*/
export function sessionPhase(s: SessionPresentationInput): SessionPhase {
- if (s.completedAt) return 'ended'
+ switch (s.lifecycle) {
+ case 'attached':
+ case 'detached':
+ case 'orphaned':
+ return 'live'
+ case 'resumable':
+ return 'resumable'
+ case 'completed':
+ case 'failed':
+ return 'ended'
+ default:
+ break
+ }
if (s.ptyAttached) return 'live'
return s.status === 'idle' ? 'starting' : 'live'
}
+
+/**
+ * Conversation history is the right surface when there is no live process to
+ * attach to — both a genuine end and a hold/rehydrate (resume lives there).
+ */
+export function sessionOpensAsHistory(s: SessionPresentationInput): boolean {
+ if (s.lifecycle != null) {
+ const phase = sessionPhase(s)
+ return phase === 'ended' || phase === 'resumable'
+ }
+ return s.ptyAttached === false && s.status === 'idle'
+}
diff --git a/services/live-activity.ts b/services/live-activity.ts
index 14c3fae3..8dca8beb 100644
--- a/services/live-activity.ts
+++ b/services/live-activity.ts
@@ -1,7 +1,7 @@
import type { LiveActivity } from 'expo-widgets'
import SessionLiveActivity from '@/widgets/SessionLiveActivity'
-import { sessionPhase } from '@/lib/sessionPresentation'
+import { sessionOpensAsHistory, sessionPhase } from '@/lib/sessionPresentation'
import type { Session } from '@/types/api'
import {
LAST_OUTPUT_MAX_CHARS,
@@ -17,19 +17,20 @@ export function liveActivityKey(serverId: string, sessionId: string): string {
}
/**
- * A session is over when any of three signals fire. Managed servers set
- * `completedAt`; external ones report `processLiveness`; older servers do
- * neither, leaving the legacy idle-without-a-PTY heuristic as the only tell.
+ * A session should leave the Live Activity surface when there is no process
+ * left to mirror. Prefer streamer `lifecycle` (ended or resumable/held);
+ * `completedAt` alone is not enough — holds stamp it too. Older servers omit
+ * `lifecycle`, leaving the idle-without-a-PTY heuristic as the only tell —
+ * safe here because a Live Activity only ever exists for a session that
+ * already went live.
*/
export function isTerminal(session: Session): boolean {
- return (
- session.processLiveness === 'gone' ||
- sessionPhase(session) === 'ended' ||
- // A live activity only ever exists for a session that already went live,
- // so unlike a cold landing this can't be a not-yet-started session —
- // idle+detached here means it ended on a server too old to set completedAt.
- (session.status === 'idle' && !session.ptyAttached)
- )
+ if (session.processLiveness === 'gone') return true
+ if (session.lifecycle != null) {
+ const phase = sessionPhase(session)
+ return phase === 'ended' || phase === 'resumable'
+ }
+ return sessionOpensAsHistory(session)
}
function truncateOutput(raw: string): string {
diff --git a/types/api.ts b/types/api.ts
index af124014..92e9ed75 100644
--- a/types/api.ts
+++ b/types/api.ts
@@ -3,6 +3,20 @@ import type { DeviceCapability } from '@/types/devices'
export type SessionStatus = 'running' | 'waiting_input' | 'idle'
+/**
+ * Process-lifetime axis from the streamer, orthogonal to `status`.
+ * Additive; older servers omit it. Prefer this over inferring end/hold from
+ * `ptyAttached` + `status` or from `completedAt` (which is stamped on both a
+ * real exit and a hold).
+ */
+export type SessionLifecycle =
+ | 'attached'
+ | 'detached'
+ | 'orphaned'
+ | 'resumable'
+ | 'completed'
+ | 'failed'
+
export interface Session {
id: string
provider?: ProviderName
@@ -41,7 +55,21 @@ export interface Session {
elapsedMs: number
promptCount: number
startedAt: string
+ /**
+ * ISO timestamp when the streamer recorded an end-or-hold. Not a reliable
+ * "session ended" signal on its own — `putOnHold` stamps it too. Prefer
+ * `lifecycle`. Additive; older servers omit it.
+ */
completedAt?: string
+ /**
+ * Process-lifetime axis from the streamer. Additive; older servers omit it.
+ * When present, prefer this over `idle && !ptyAttached` for ended/hold/live.
+ */
+ lifecycle?: SessionLifecycle
+ /** How `lifecycle` was determined. Additive; older servers omit it. */
+ lifecycleSource?: 'spawn' | 'exit' | 'probe' | 'reconcile'
+ /** ISO timestamp when `lifecycle` last changed. Additive; older servers omit it. */
+ lifecycleUpdatedAt?: string
failureReason?: string
/** Set when this session was started via `/api/sessions/resume`. */
resumedFromConversationId?: string | null