diff --git a/apps/ade-cli/src/bootstrap.ts b/apps/ade-cli/src/bootstrap.ts index 8bf4d7b50..05d24873d 100644 --- a/apps/ade-cli/src/bootstrap.ts +++ b/apps/ade-cli/src/bootstrap.ts @@ -761,6 +761,11 @@ export async function createAdeRuntime(args: { const ptyBackend = process.env.ADE_DISABLE_SUPERVISED_PTY_HOST === "1" ? null : createSupervisedPtyLoader({ logger }); + // The sync runtime is created after ptyService (it takes ptyService as a + // dependency), so live PTY forwarding binds late through this ref — same + // pattern as desktop main. Without this bridge, paired phones only ever + // receive terminal snapshots, never live terminal_data push. + let syncServiceForPtyEvents: ReturnType | null = null; const ptyService = createPtyService({ projectRoot, transcriptsDir: paths.transcriptsDir, @@ -768,8 +773,16 @@ export async function createAdeRuntime(args: { sessionService, processRegistry, logger, - broadcastData: (event) => pushEvent("pty", { type: "pty_data", event }), - broadcastExit: (event) => pushEvent("pty", { type: "pty_exit", event }), + broadcastData: (event) => { + pushEvent("pty", { type: "pty_data", event }); + const { projectRoot: _projectRoot, ...syncEvent } = event; + syncServiceForPtyEvents?.handlePtyData(syncEvent); + }, + broadcastExit: (event) => { + pushEvent("pty", { type: "pty_exit", event }); + const { projectRoot: _projectRoot, ...syncEvent } = event; + syncServiceForPtyEvents?.handlePtyExit(syncEvent); + }, onSessionEnded: (event) => { void sessionDeltaService.computeSessionDelta(event.sessionId).catch((error) => { logger.warn("runtime.session_delta_compute_failed", { @@ -1301,6 +1314,7 @@ export async function createAdeRuntime(args: { getModelPickerStore: () => getSharedModelPickerStore(db), onStatusChanged: (snapshot) => pushEvent("runtime", { type: "sync-status", snapshot }), }); + syncServiceForPtyEvents = syncService; } if (syncService) { diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index e45554170..9f5d889e9 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -13526,10 +13526,19 @@ async function runServe( log: (message) => process.stderr.write(`${message}\n`), getServiceMainPid: getRuntimeServiceMainPid, }); - })().catch((error: unknown) => { - process.stderr.write( - `ADE brain sync host startup loop failed: ${error instanceof Error ? error.message : String(error)}\n`, - ); + })().catch(async (error: unknown) => { + // Cross-channel conflict (another build's live brain owns mobile sync): + // real builds never run sync-less, so fail the brain instead of coming + // up half-alive. The message carries the exact quit command. + const { SyncHostSingletonConflictError } = await import("./services/sync/syncHostSingleton"); + const message = error instanceof Error ? error.message : String(error); + if (error instanceof SyncHostSingletonConflictError) { + process.stderr.write(`ADE brain refusing to run without mobile sync.\n${message}\n`); + process.exitCode = 1; + finish(); + return; + } + process.stderr.write(`ADE brain sync host startup loop failed: ${message}\n`); }); } diff --git a/apps/ade-cli/src/services/sync/syncHostService.test.ts b/apps/ade-cli/src/services/sync/syncHostService.test.ts index 6d6f04107..aca0c07f2 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.test.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.test.ts @@ -94,6 +94,11 @@ describe("resolveSyncHostInboundProjectScope", () => { projectId: "project-1", usedSingleProjectFallback: true, }); + expect(resolveSyncHostInboundProjectScope("terminal_history", null, "project-1")).toEqual({ + ok: true, + projectId: "project-1", + usedSingleProjectFallback: true, + }); }); it("accepts matching project-scoped envelopes", () => { @@ -417,6 +422,7 @@ function createHostArgs(projectRoot: string, projects: SyncMobileProjectSummary[ ptyService: { create: vi.fn(), readTranscriptTail: vi.fn(async () => ""), + hasLivePty: () => true, enrichSessions: (rows: unknown[]) => rows, }, computerUseArtifactBrokerService: { @@ -912,3 +918,402 @@ describe("chat event replay buffer (resumable chat streams)", () => { expect(plan.mode).toBe("replay"); }); }); + +describe("terminal byte-offset streaming, history paging, and resize ownership", () => { + // 5000 ASCII bytes so byte offsets equal string indices in assertions. + const TRANSCRIPT_CONTENT = "0123456789".repeat(500); + + beforeEach(() => { + publishMock.mockReset(); + spawnMock.mockReset(); + bonjourDestroyMock.mockReset(); + bonjourConstructorMock.mockReset(); + spawnMock.mockImplementation(() => ({ kill: vi.fn(), once: vi.fn(), unref: vi.fn() })); + }); + + function createTerminalHost(projectRoot: string) { + const transcriptPath = path.join(projectRoot, "transcripts", "session-1.log"); + fs.mkdirSync(path.dirname(transcriptPath), { recursive: true }); + fs.writeFileSync(transcriptPath, TRANSCRIPT_CONTENT); + const session = { + id: "session-1", + laneId: "lane-1", + transcriptPath, + status: "running", + runtimeState: "running", + lastOutputPreview: "preview", + }; + const readTranscriptTail = vi.fn(async () => "tail-snapshot"); + const readTranscriptRange = vi.fn(async (args: { sessionId: string; startOffset: number; endOffset: number }) => ({ + data: TRANSCRIPT_CONTENT.slice(args.startOffset, args.endOffset), + startOffset: args.startOffset, + endOffset: args.endOffset, + })); + const resizeBySessionId = vi.fn().mockReturnValue(true); + const restoreDesktopSizeBySessionId = vi.fn().mockReturnValue(true); + const hasLivePty = vi.fn().mockReturnValue(true); + const base = createHostArgs(projectRoot, []); + const host = createSyncHostService({ + ...base, + projectId: "project-1", + db: { + sync: { + getSiteId: () => "site-host-terminal", + getDbVersion: () => 0, + exportChangesSince: () => [], + applyChanges: () => ({ appliedCount: 0 }), + discardUnpublishedChangesForTables: () => {}, + }, + }, + deviceRegistryService: { + ...base.deviceRegistryService, + upsertPeerMetadata: vi.fn(), + }, + sessionService: { + list: () => [session], + get: (id: string) => (id === "session-1" ? session : null), + readTranscriptTail: async () => "", + }, + ptyService: { + create: vi.fn(), + readTranscriptTail, + readTranscriptRange, + writeBySessionId: vi.fn().mockReturnValue(true), + resizeBySessionId, + restoreDesktopSizeBySessionId, + hasLivePty, + enrichSessions: (rows: unknown[]) => rows, + }, + } as unknown as Parameters[0]); + return { host, readTranscriptTail, readTranscriptRange, resizeBySessionId, restoreDesktopSizeBySessionId, hasLivePty }; + } + + async function connectTerminalPeer(port: number, token: string, deviceId: string) { + const ws = new WebSocket(`ws://127.0.0.1:${port}`); + const tracked = trackClientEnvelopes(ws); + await new Promise((resolve, reject) => { + ws.once("open", () => resolve()); + ws.once("error", reject); + }); + ws.send(encodeSyncEnvelope({ + type: "hello", + payload: { + peer: { + deviceId, + deviceName: deviceId, + platform: "iOS", + deviceType: "phone", + siteId: `${deviceId}-site`, + dbVersion: 0, + }, + auth: { kind: "bootstrap", token }, + }, + })); + await waitForValue( + () => tracked.envelopes.find((envelope) => envelope.type === "hello_ok"), + `hello_ok for ${deviceId}`, + ); + return { ws, ...tracked }; + } + + function nextResponse(envelopes: ParsedSyncEnvelope[], type: string, requestId: string) { + return waitForValue( + () => envelopes.find((envelope) => envelope.type === type && envelope.requestId === requestId), + `${type} response ${requestId}`, + ); + } + + it("answers terminal_subscribe with a delta when sinceOffset fits the budget, else a tail snapshot with offsets", async () => { + const { projectRoot, cleanup } = createTempProjectRoot(); + const { host, readTranscriptTail, readTranscriptRange } = createTerminalHost(projectRoot); + let client: Awaited> | null = null; + try { + const port = await host.waitUntilListening(); + client = await connectTerminalPeer(port, host.getBootstrapToken(), "ios-terminal-1"); + + client.ws.send(encodeSyncEnvelope({ + type: "terminal_subscribe", + requestId: "sub-delta", + payload: { sessionId: "session-1", maxBytes: 32_000, sinceOffset: 4_988 }, + })); + const delta = await nextResponse(client.envelopes, "terminal_snapshot", "sub-delta"); + expect(delta.payload).toMatchObject({ + sessionId: "session-1", + transcript: TRANSCRIPT_CONTENT.slice(4_988), + delta: true, + startOffset: 4_988, + endOffset: 5_000, + }); + expect(readTranscriptRange).toHaveBeenCalledWith({ + sessionId: "session-1", + startOffset: 4_988, + endOffset: 5_000, + }); + expect(readTranscriptTail).not.toHaveBeenCalled(); + + // Gap larger than the budget → full tail snapshot (delta omitted). + client.ws.send(encodeSyncEnvelope({ + type: "terminal_subscribe", + requestId: "sub-full", + payload: { sessionId: "session-1", maxBytes: 1_024, sinceOffset: 0 }, + })); + const full = await nextResponse(client.envelopes, "terminal_snapshot", "sub-full"); + expect(full.payload).toMatchObject({ + sessionId: "session-1", + transcript: "tail-snapshot", + startOffset: 5_000 - Buffer.byteLength("tail-snapshot", "utf8"), + endOffset: 5_000, + }); + expect((full.payload as { delta?: boolean }).delta).toBeUndefined(); + expect(readTranscriptTail).toHaveBeenCalledWith({ + sessionId: "session-1", + maxBytes: 1_024, + raw: true, + alignToLineBoundary: true, + }); + + // sinceOffset beyond the transcript end (host restarted with a fresh + // file, client watermark stale) → full snapshot, not a delta. + client.ws.send(encodeSyncEnvelope({ + type: "terminal_subscribe", + requestId: "sub-stale", + payload: { sessionId: "session-1", maxBytes: 32_000, sinceOffset: 9_999 }, + })); + const stale = await nextResponse(client.envelopes, "terminal_snapshot", "sub-stale"); + expect((stale.payload as { delta?: boolean }).delta).toBeUndefined(); + expect((stale.payload as { transcript: string }).transcript).toBe("tail-snapshot"); + + readTranscriptTail.mockResolvedValueOnce(`${TRANSCRIPT_CONTENT}buffered`); + client.ws.send(encodeSyncEnvelope({ + type: "terminal_subscribe", + requestId: "sub-buffered", + payload: { sessionId: "session-1", maxBytes: 1_024, sinceOffset: 0 }, + })); + const buffered = await nextResponse(client.envelopes, "terminal_snapshot", "sub-buffered"); + expect(buffered.payload).toMatchObject({ + sessionId: "session-1", + transcript: `${TRANSCRIPT_CONTENT}buffered`, + startOffset: null, + endOffset: null, + }); + } finally { + try { + client?.ws.close(); + } catch { + // ignore + } + await host.dispose(); + cleanup(); + } + }); + + it("serves terminal_history pages to subscribed peers and refuses unsubscribed ones", async () => { + const { projectRoot, cleanup } = createTempProjectRoot(); + const { host, readTranscriptRange } = createTerminalHost(projectRoot); + let client: Awaited> | null = null; + try { + const port = await host.waitUntilListening(); + client = await connectTerminalPeer(port, host.getBootstrapToken(), "ios-terminal-2"); + + client.ws.send(encodeSyncEnvelope({ + type: "terminal_history", + requestId: "hist-wrong-project", + projectId: "project-2", + payload: { sessionId: "session-1", beforeOffset: 4_000 }, + })); + const wrongProject = await nextResponse(client.envelopes, "terminal_history", "hist-wrong-project"); + expect(wrongProject.payload).toEqual({ + sessionId: "session-1", + data: "", + startOffset: 4_000, + endOffset: 4_000, + atStart: true, + }); + expect(readTranscriptRange).not.toHaveBeenCalled(); + + // Not subscribed yet: same access gate as terminal_input. + client.ws.send(encodeSyncEnvelope({ + type: "terminal_history", + requestId: "hist-refused", + payload: { sessionId: "session-1", beforeOffset: 4_000 }, + })); + const refused = await nextResponse(client.envelopes, "terminal_history", "hist-refused"); + expect(refused.payload).toEqual({ + sessionId: "session-1", + data: "", + startOffset: 4_000, + endOffset: 4_000, + atStart: true, + }); + expect(readTranscriptRange).not.toHaveBeenCalled(); + + client.ws.send(encodeSyncEnvelope({ + type: "terminal_subscribe", + requestId: "sub-1", + payload: { sessionId: "session-1", maxBytes: 32_000 }, + })); + await nextResponse(client.envelopes, "terminal_snapshot", "sub-1"); + + client.ws.send(encodeSyncEnvelope({ + type: "terminal_history", + requestId: "hist-1", + payload: { sessionId: "session-1", beforeOffset: 5_000, maxBytes: 4_096 }, + })); + const page = await nextResponse(client.envelopes, "terminal_history", "hist-1"); + expect(readTranscriptRange).toHaveBeenCalledWith({ + sessionId: "session-1", + startOffset: 5_000 - 4_096, + endOffset: 5_000, + alignStartToSafeBoundary: true, + }); + expect(page.payload).toEqual({ + sessionId: "session-1", + data: TRANSCRIPT_CONTENT.slice(904), + startOffset: 904, + endOffset: 5_000, + atStart: false, + }); + + // beforeOffset inside the first page → page starts at 0 and atStart=true. + client.ws.send(encodeSyncEnvelope({ + type: "terminal_history", + requestId: "hist-first", + payload: { sessionId: "session-1", beforeOffset: 800, maxBytes: 4_096 }, + })); + const firstPage = await nextResponse(client.envelopes, "terminal_history", "hist-first"); + expect(firstPage.payload).toEqual({ + sessionId: "session-1", + data: TRANSCRIPT_CONTENT.slice(0, 800), + startOffset: 0, + endOffset: 800, + atStart: true, + }); + + // beforeOffset past EOF clamps to the flushed transcript size. + client.ws.send(encodeSyncEnvelope({ + type: "terminal_history", + requestId: "hist-clamped", + payload: { sessionId: "session-1", beforeOffset: 999_999, maxBytes: 4_096 }, + })); + const clamped = await nextResponse(client.envelopes, "terminal_history", "hist-clamped"); + expect(clamped.payload).toMatchObject({ startOffset: 904, endOffset: 5_000, atStart: false }); + } finally { + try { + client?.ws.close(); + } catch { + // ignore + } + await host.dispose(); + cleanup(); + } + }); + + it("restores the desktop terminal size only after the last subscribed peer detaches", async () => { + const { projectRoot, cleanup } = createTempProjectRoot(); + const { host, resizeBySessionId, restoreDesktopSizeBySessionId } = createTerminalHost(projectRoot); + let clientA: Awaited> | null = null; + let clientB: Awaited> | null = null; + try { + const port = await host.waitUntilListening(); + clientA = await connectTerminalPeer(port, host.getBootstrapToken(), "ios-terminal-a"); + clientB = await connectTerminalPeer(port, host.getBootstrapToken(), "ios-terminal-b"); + + clientA.ws.send(encodeSyncEnvelope({ + type: "terminal_subscribe", + requestId: "sub-a", + payload: { sessionId: "session-1", maxBytes: 32_000 }, + })); + await nextResponse(clientA.envelopes, "terminal_snapshot", "sub-a"); + clientB.ws.send(encodeSyncEnvelope({ + type: "terminal_subscribe", + requestId: "sub-b", + payload: { sessionId: "session-1", maxBytes: 32_000 }, + })); + await nextResponse(clientB.envelopes, "terminal_snapshot", "sub-b"); + + clientA.ws.send(encodeSyncEnvelope({ + type: "terminal_resize", + payload: { sessionId: "session-1", cols: 61.7, rows: 21.2 }, + })); + await waitForValue( + () => (resizeBySessionId.mock.calls.length > 0 ? resizeBySessionId.mock.calls[0] : null), + "mobile resize forwarded", + ); + expect(resizeBySessionId).toHaveBeenCalledWith("session-1", 61, 21, { source: "mobile" }); + + // Peer A detaches while peer B still watches: no restore. The follow-up + // history request (refused because A just unsubscribed) fences ordering. + clientA.ws.send(encodeSyncEnvelope({ + type: "terminal_unsubscribe", + payload: { sessionId: "session-1" }, + })); + clientA.ws.send(encodeSyncEnvelope({ + type: "terminal_history", + requestId: "fence-a", + payload: { sessionId: "session-1", beforeOffset: 100 }, + })); + const fence = await nextResponse(clientA.envelopes, "terminal_history", "fence-a"); + expect((fence.payload as { atStart: boolean }).atStart).toBe(true); + expect(restoreDesktopSizeBySessionId).not.toHaveBeenCalled(); + + // Last watcher disconnects → snap back to the desktop size. + clientB.ws.close(); + await waitForValue( + () => (restoreDesktopSizeBySessionId.mock.calls.length > 0 ? restoreDesktopSizeBySessionId.mock.calls[0] : null), + "desktop size restore after last peer detached", + ); + expect(restoreDesktopSizeBySessionId).toHaveBeenCalledTimes(1); + expect(restoreDesktopSizeBySessionId).toHaveBeenCalledWith("session-1"); + } finally { + try { + clientA?.ws.close(); + } catch { + // ignore + } + try { + clientB?.ws.close(); + } catch { + // ignore + } + await host.dispose(); + cleanup(); + } + }); + + it("marks terminal snapshots live:false when no PTY backs the session", async () => { + // A brain restart orphans "running" sessions; the phone needs the truth + // up front so it shows the resume bar instead of accepting keystrokes. + const { projectRoot, cleanup } = createTempProjectRoot(); + const { host, hasLivePty } = createTerminalHost(projectRoot); + hasLivePty.mockReturnValue(false); + let client: Awaited> | null = null; + try { + const port = await host.waitUntilListening(); + client = await connectTerminalPeer(port, host.getBootstrapToken(), "ios-terminal-live"); + client.ws.send(encodeSyncEnvelope({ + type: "terminal_subscribe", + requestId: "sub-dead", + payload: { sessionId: "session-1", maxBytes: 32_000 }, + })); + const snapshot = await nextResponse(client.envelopes, "terminal_snapshot", "sub-dead"); + expect((snapshot.payload as { live?: boolean }).live).toBe(false); + + hasLivePty.mockReturnValue(true); + client.ws.send(encodeSyncEnvelope({ + type: "terminal_subscribe", + requestId: "sub-live", + payload: { sessionId: "session-1", maxBytes: 32_000 }, + })); + const liveSnapshot = await nextResponse(client.envelopes, "terminal_snapshot", "sub-live"); + expect((liveSnapshot.payload as { live?: boolean }).live).toBe(true); + } finally { + try { + client?.ws.close(); + } catch { + // ignore + } + await host.dispose(); + cleanup(); + } + }); +}); diff --git a/apps/ade-cli/src/services/sync/syncHostService.ts b/apps/ade-cli/src/services/sync/syncHostService.ts index 5ecb1635f..14c29e3b9 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.ts @@ -47,6 +47,7 @@ import type { SyncProjectSwitchResultPayload, SyncRemoteCommandDescriptor, SyncTailnetDiscoveryStatus, + SyncTerminalHistoryResponsePayload, SyncTerminalSnapshotPayload, } from "../../../../desktop/src/shared/types"; import { parseAgentChatTranscript } from "../../../../desktop/src/shared/chatTranscript"; @@ -146,6 +147,9 @@ const DEFAULT_BRAIN_STATUS_INTERVAL_MS = 5_000; const NATIVE_LAN_DISCOVERY_RECOVERY_DELAY_MS = 1_000; const NATIVE_LAN_DISCOVERY_FALLBACK_MS = 30_000; const DEFAULT_TERMINAL_SNAPSHOT_BYTES = 220_000; +const DEFAULT_TERMINAL_HISTORY_PAGE_BYTES = 262_144; +const MIN_TERMINAL_HISTORY_PAGE_BYTES = 4_096; +const MAX_TERMINAL_HISTORY_PAGE_BYTES = 524_288; const PEER_BACKPRESSURE_BYTES = 4 * 1024 * 1024; const MOBILE_COMMAND_RESULT_CACHE_TTL_MS = 30 * 60 * 1000; const MOBILE_COMMAND_RESULT_CACHE_MAX_ENTRIES = 512; @@ -512,6 +516,22 @@ export function syncHeartbeatMissLimitForPeerMetadata(metadata: Pick([ "changeset_batch", "changeset_ack", @@ -520,6 +540,7 @@ const SYNC_HOST_PROJECT_SCOPED_INBOUND_ENVELOPE_TYPES = new Set= 0 + && sinceOffset <= transcriptSize + && transcriptSize - sinceOffset <= maxBytes + ) { + const range = await args.ptyService.readTranscriptRange({ + sessionId, + startOffset: sinceOffset, + endOffset: transcriptSize, + }); + if (range) { + sendRequired(peer, "terminal_snapshot", { + sessionId, + transcript: range.data, + status: session?.status ?? null, + runtimeState: session?.runtimeState ?? null, + lastOutputPreview: session?.lastOutputPreview ?? null, + capturedAt: nowIso(), + startOffset: range.startOffset, + endOffset: range.endOffset, + delta: true, + live: args.ptyService.hasLivePty(sessionId), + } satisfies SyncTerminalSnapshotPayload, envelope.requestId); + break; + } + } const transcript = session ? await args.ptyService.readTranscriptTail({ sessionId, - maxBytes: Math.max(1_024, Math.min(2_000_000, Math.floor(payload?.maxBytes ?? DEFAULT_TERMINAL_SNAPSHOT_BYTES))), + maxBytes, raw: true, alignToLineBoundary: true, }) : ""; + // The tail read can merge still-buffered live output that is not + // reflected in the flushed file size yet. Only advertise offsets when + // the returned bytes fit inside the flushed transcript. + const transcriptBytes = Buffer.byteLength(transcript, "utf8"); + const snapshotStartOffset = transcriptSize != null && transcriptBytes <= transcriptSize + ? transcriptSize - transcriptBytes + : null; + const snapshotEndOffset = snapshotStartOffset != null ? transcriptSize : null; const snapshot: SyncTerminalSnapshotPayload = { sessionId, transcript, @@ -3468,6 +3572,9 @@ export function createSyncHostService(args: SyncHostServiceArgs) { runtimeState: session?.runtimeState ?? null, lastOutputPreview: session?.lastOutputPreview ?? null, capturedAt: nowIso(), + startOffset: snapshotStartOffset, + endOffset: snapshotEndOffset, + live: args.ptyService.hasLivePty(sessionId), }; sendRequired(peer, "terminal_snapshot", snapshot, envelope.requestId); break; @@ -3477,7 +3584,59 @@ export function createSyncHostService(args: SyncHostServiceArgs) { const sessionId = toOptionalString(payload?.sessionId); if (sessionId) { peer.subscribedSessionIds.delete(sessionId); + restoreDesktopTerminalSizeIfUnwatched(sessionId); + } + break; + } + case "terminal_history": { + // Pull-to-load-older paging over the transcript file. Reuses the + // terminal_input access gate: only a peer with a live subscribe for + // the session may read its history. + const payload = envelope.payload as { sessionId?: string; beforeOffset?: number; maxBytes?: number } | null; + const sessionId = toOptionalString(payload?.sessionId); + if (!sessionId) break; + const beforeOffset = typeof payload?.beforeOffset === "number" && Number.isFinite(payload.beforeOffset) + ? Math.max(0, Math.floor(payload.beforeOffset)) + : 0; + const refused: SyncTerminalHistoryResponsePayload = { + sessionId, + data: "", + startOffset: beforeOffset, + endOffset: beforeOffset, + atStart: true, + }; + const session = args.sessionService.get(sessionId); + if (!peer.subscribedSessionIds.has(sessionId) || !session) { + args.logger.warn("sync.terminal_history_unsubscribed_session", { sessionId }); + sendRequired(peer, "terminal_history", refused, envelope.requestId); + break; } + const pageBytes = Math.max( + MIN_TERMINAL_HISTORY_PAGE_BYTES, + Math.min( + MAX_TERMINAL_HISTORY_PAGE_BYTES, + Math.floor(typeof payload?.maxBytes === "number" ? payload.maxBytes : DEFAULT_TERMINAL_HISTORY_PAGE_BYTES), + ), + ); + const transcriptSize = transcriptFileSizeOrNull(session.transcriptPath); + const endOffset = Math.min(beforeOffset, transcriptSize ?? 0); + const range = await args.ptyService.readTranscriptRange({ + sessionId, + startOffset: Math.max(0, endOffset - pageBytes), + endOffset, + alignStartToSafeBoundary: true, + }); + if (!range) { + sendRequired(peer, "terminal_history", refused, envelope.requestId); + break; + } + sendRequired(peer, "terminal_history", { + sessionId, + data: range.data, + startOffset: range.startOffset, + endOffset: range.endOffset, + atStart: range.startOffset === 0, + } satisfies SyncTerminalHistoryResponsePayload, envelope.requestId); break; } case "terminal_input": { @@ -3510,7 +3669,9 @@ export function createSyncHostService(args: SyncHostServiceArgs) { const rows = typeof payload?.rows === "number" ? Math.floor(payload.rows) : null; if (!sessionId || cols == null || rows == null) break; if (!peer.subscribedSessionIds.has(sessionId)) break; - args.ptyService.resizeBySessionId(sessionId, cols, rows); + // Tagged as mobile so the phone's viewport never becomes the + // desktop-preferred size — it is restored when the phone detaches. + args.ptyService.resizeBySessionId(sessionId, cols, rows, { source: "mobile" }); break; } case "chat_subscribe": { @@ -3974,6 +4135,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { ptyId: event.ptyId, data: event.data, at: nowIso(), + offset: event.offset ?? null, }; for (const peer of peers) { if (!peer.authenticated || !peer.subscribedSessionIds.has(event.sessionId) || peer.ws.readyState !== WebSocket.OPEN) continue; diff --git a/apps/ade-cli/src/services/sync/syncHostStartupLoop.test.ts b/apps/ade-cli/src/services/sync/syncHostStartupLoop.test.ts index e15c58a34..c2af3ce2d 100644 --- a/apps/ade-cli/src/services/sync/syncHostStartupLoop.test.ts +++ b/apps/ade-cli/src/services/sync/syncHostStartupLoop.test.ts @@ -118,15 +118,22 @@ describe("runSyncHostStartupLoop", () => { expect(killed).toEqual([]); }); - it("never takes over from another channel's owner", async () => { + it("rethrows a cross-channel conflict immediately instead of retrying", async () => { + // Another build's live brain owning sync is a deliberate human state, not + // a transient race — the brain must fail startup, never run sync-less. const killed: number[] = []; - await runSyncHostStartupLoop({ - startSyncHost: () => Promise.reject(conflictError(makeOwner({ - packageChannel: null, - adeHome: "/Users/example/.ade", - serviceName: "com.ade.runtime", - appName: "ADE", - }))), + let attempts = 0; + const error = conflictError(makeOwner({ + packageChannel: null, + adeHome: "/Users/example/.ade", + serviceName: "com.ade.runtime", + appName: "ADE", + })); + await expect(runSyncHostStartupLoop({ + startSyncHost: () => { + attempts += 1; + return Promise.reject(error); + }, isDone: () => false, log: () => {}, getServiceMainPid: () => process.pid, @@ -136,7 +143,8 @@ describe("runSyncHostStartupLoop", () => { sleep: instantSleep, env: betaEnv, maxAttempts: 3, - }); + })).rejects.toBe(error); + expect(attempts).toBe(1); expect(killed).toEqual([]); }); diff --git a/apps/ade-cli/src/services/sync/syncHostStartupLoop.ts b/apps/ade-cli/src/services/sync/syncHostStartupLoop.ts index 4b21ab434..9b8460b1b 100644 --- a/apps/ade-cli/src/services/sync/syncHostStartupLoop.ts +++ b/apps/ade-cli/src/services/sync/syncHostStartupLoop.ts @@ -65,9 +65,16 @@ async function terminatePidAsync( } // Keeps retrying mobile sync host startup until it succeeds or the brain -// shuts down. A brain that cannot host sync is silently useless to phones, -// so a one-shot startup failure must never be terminal: squatters die, -// upgrades finish, and the next attempt should win. +// shuts down. Same-channel conflicts are transient by nature (update races, +// restart overlap, a stale sibling about to be evicted), so they retry: +// squatters die, upgrades finish, and the next attempt should win. +// +// A conflict with ANOTHER channel's live brain is different — it means a +// human deliberately launched a second build. Waiting would leave this brain +// running without mobile sync, which reads as "phone mysteriously frozen / +// talking to old code" rather than an error. Real builds never run sync-less: +// the conflict is rethrown so the caller can fail brain startup with the +// quit instructions in the message. export async function runSyncHostStartupLoop(deps: SyncHostStartupLoopDeps): Promise { const kill = deps.kill ?? defaultKill; const pidAlive = deps.pidAlive ?? defaultPidAlive; @@ -91,11 +98,15 @@ export async function runSyncHostStartupLoop(deps: SyncHostStartupLoopDeps): Pro } if (error instanceof SyncHostSingletonConflictError) { const owner = error.conflict.owner; + const sameChannelOwner = isSameChannelSyncHostOwner(owner, deps.env); + if (owner.pid !== process.pid && !sameChannelOwner) { + throw error; + } const serviceMainPid = deps.getServiceMainPid?.() ?? null; if ( owner.pid !== process.pid && serviceMainPid === process.pid - && isSameChannelSyncHostOwner(owner, deps.env) + && sameChannelOwner ) { deps.log( `ADE brain taking over mobile sync from stale ${owner.appName ?? "ADE"} brain (pid ${owner.pid}).`, diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts new file mode 100644 index 000000000..8c494b9fa --- /dev/null +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, vi } from "vitest"; +import type { SyncCommandPayload } from "../../../../desktop/src/shared/types"; +import { createSyncRemoteCommandService } from "./syncRemoteCommandService"; + +function makePayload(action: string, args: Record = {}): SyncCommandPayload { + return { commandId: "cmd-1", action, args }; +} + +function createService() { + const ptyService = { + resumeSession: vi.fn().mockResolvedValue({ + sessionId: "session-1", + ptyId: "pty-1", + session: { id: "session-1", status: "running" }, + }), + }; + const service = createSyncRemoteCommandService({ + laneService: {}, + prService: {}, + ptyService, + sessionService: {}, + fileService: {}, + logger: { debug: vi.fn(), warn: vi.fn(), error: vi.fn(), info: vi.fn() }, + } as any); + return { service, ptyService }; +} + +describe("createSyncRemoteCommandService", () => { + it("routes work.resumeCliSession through the durable PTY resume path", async () => { + const { service, ptyService } = createService(); + + expect(service.getDescriptor("work.resumeCliSession")).toEqual({ + action: "work.resumeCliSession", + scope: "project", + policy: { viewerAllowed: true, queueable: true }, + }); + + const result = await service.execute(makePayload("work.resumeCliSession", { + sessionId: "session-1", + cols: 999, + rows: 1, + })); + + expect(ptyService.resumeSession).toHaveBeenCalledWith({ + sessionId: "session-1", + cols: 400, + rows: 4, + }); + expect(result).toEqual({ + sessionId: "session-1", + ptyId: "pty-1", + session: { id: "session-1", status: "running" }, + }); + }); + + it("rejects work.resumeCliSession without a session id", async () => { + const { service, ptyService } = createService(); + + await expect(service.execute(makePayload("work.resumeCliSession"))).rejects.toThrow( + "work.resumeCliSession requires sessionId.", + ); + expect(ptyService.resumeSession).not.toHaveBeenCalled(); + }); + + it("omits non-finite work.resumeCliSession dimensions", async () => { + const { service, ptyService } = createService(); + + await service.execute(makePayload("work.resumeCliSession", { + sessionId: "session-1", + cols: Number.NaN, + rows: Number.POSITIVE_INFINITY, + })); + + expect(ptyService.resumeSession).toHaveBeenCalledWith({ + sessionId: "session-1", + }); + }); +}); diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts index 6dad9e8bf..477ab0cb6 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts @@ -2273,6 +2273,28 @@ function registerWorkRemoteCommands({ args, register }: RemoteCommandRegistratio session: enriched, } satisfies SyncStartCliSessionResult; }); + register("work.resumeCliSession", { viewerAllowed: true, queueable: true }, async (payload) => { + // Mirror of the desktop resume affordance: relaunch an ended/orphaned + // agent CLI session's runtime (same sessionId, provider resume metadata). + const value = (payload ?? {}) as Record; + const sessionId = requireString(value.sessionId, "work.resumeCliSession requires sessionId."); + const cols = typeof value.cols === "number" && Number.isFinite(value.cols) + ? clampCliDimension(value.cols, DEFAULT_CLI_COLS, 20, MAX_CLI_COLS) + : undefined; + const rows = typeof value.rows === "number" && Number.isFinite(value.rows) + ? clampCliDimension(value.rows, DEFAULT_CLI_ROWS, 4, MAX_CLI_ROWS) + : undefined; + const result = await args.ptyService.resumeSession({ + sessionId, + ...(cols != null ? { cols } : {}), + ...(rows != null ? { rows } : {}), + }); + return { + sessionId: result.sessionId, + ptyId: result.ptyId, + session: result.session, + } satisfies SyncStartCliSessionResult; + }); register("work.sendToSession", { viewerAllowed: true, queueable: true }, async (payload) => { const parsed = parseSendToSessionArgs(payload); const result = await args.ptyService.sendToSession({ diff --git a/apps/desktop/src/main/services/pty/ptyService.test.ts b/apps/desktop/src/main/services/pty/ptyService.test.ts index 2cb1c1cc6..16e33fe08 100644 --- a/apps/desktop/src/main/services/pty/ptyService.test.ts +++ b/apps/desktop/src/main/services/pty/ptyService.test.ts @@ -4043,6 +4043,7 @@ describe("ptyService", () => { sessionId, projectRoot: "/tmp/test-project", data: "hello world", + offset: Buffer.byteLength("hello world", "utf8"), }); } finally { vi.useRealTimers(); @@ -4063,6 +4064,7 @@ describe("ptyService", () => { sessionId, projectRoot: "/tmp/test-project", data: "hello world", + offset: Buffer.byteLength("hello world", "utf8"), }); expect(broadcastExit).toHaveBeenCalledWith({ ptyId, @@ -4210,6 +4212,194 @@ describe("ptyService", () => { }); }); + describe("PTY data offsets (mobile byte-offset streaming)", () => { + it("attaches the transcript end offset across batched flushes", async () => { + vi.useFakeTimers(); + try { + const { service, mockPty, broadcastData } = createHarness(); + const { ptyId, sessionId } = await service.create({ laneId: "lane-1", title: "t", cols: 80, rows: 24 }); + mockPty._emitter.emit("data", "hello "); + mockPty._emitter.emit("data", "wörld"); + await vi.advanceTimersByTimeAsync(50); + expect(broadcastData).toHaveBeenLastCalledWith({ + ptyId, + sessionId, + projectRoot: "/tmp/test-project", + data: "hello wörld", + offset: Buffer.byteLength("hello wörld", "utf8"), + }); + + mockPty._emitter.emit("data", "again"); + await vi.advanceTimersByTimeAsync(50); + expect(broadcastData).toHaveBeenLastCalledWith(expect.objectContaining({ + data: "again", + offset: Buffer.byteLength("hello wörld", "utf8") + Buffer.byteLength("again", "utf8"), + })); + } finally { + vi.useRealTimers(); + } + }); + + it("emits null offsets once the transcript byte cap is reached", async () => { + vi.useFakeTimers(); + try { + const MAX_TRANSCRIPT_BYTES = 16 * 1024 * 1024; + // Pre-existing transcript 5 bytes under the cap: the next chunk + // overflows it, so the transcript stops mirroring the stream. + mocks.fileStats.set("/tmp/transcripts/cap-session.log", { size: MAX_TRANSCRIPT_BYTES - 5 }); + const { service, mockPty, broadcastData } = createHarness(); + await service.create({ laneId: "lane-1", title: "t", cols: 80, rows: 24, sessionId: "cap-session" }); + mockPty._emitter.emit("data", "0123456789"); + await vi.advanceTimersByTimeAsync(50); + expect(broadcastData).toHaveBeenLastCalledWith(expect.objectContaining({ + data: "0123456789", + offset: null, + })); + } finally { + vi.useRealTimers(); + } + }); + + it("emits null offsets for untracked sessions", async () => { + vi.useFakeTimers(); + try { + const { service, mockPty, broadcastData } = createHarness(); + await service.create({ laneId: "lane-1", title: "t", cols: 80, rows: 24, tracked: false }); + mockPty._emitter.emit("data", "untracked output"); + await vi.advanceTimersByTimeAsync(50); + expect(broadcastData).toHaveBeenLastCalledWith(expect.objectContaining({ + data: "untracked output", + offset: null, + })); + } finally { + vi.useRealTimers(); + } + }); + }); + + describe("readTranscriptRange", () => { + async function createSessionWithTranscript(content: string, sessionId = "range-session") { + const harness = createHarness(); + await harness.service.create({ laneId: "lane-1", title: "t", cols: 80, rows: 24, sessionId }); + const transcriptPath = `/tmp/transcripts/${sessionId}.log`; + mocks.fileContents.set(transcriptPath, content); + mocks.fileStats.set(transcriptPath, { size: Buffer.byteLength(content, "utf8") }); + return harness; + } + + it("reads an exact byte range from the start of the transcript", async () => { + const content = "line1\nline2\nline3\n"; + const { service } = await createSessionWithTranscript(content); + const range = await service.readTranscriptRange({ + sessionId: "range-session", + startOffset: 0, + endOffset: Buffer.byteLength(content, "utf8"), + }); + expect(range).toEqual({ data: content, startOffset: 0, endOffset: 18 }); + }); + + it("scans a non-zero page start forward past the next newline", async () => { + const { service } = await createSessionWithTranscript("abcdef\nghijkl\n"); + const range = await service.readTranscriptRange({ + sessionId: "range-session", + startOffset: 2, + endOffset: 14, + alignStartToSafeBoundary: true, + }); + expect(range).toEqual({ data: "ghijkl\n", startOffset: 7, endOffset: 14 }); + }); + + it("treats an ESC byte as a safe page start", async () => { + const { service } = await createSessionWithTranscript("abc\u001b[31mred"); + const range = await service.readTranscriptRange({ + sessionId: "range-session", + startOffset: 1, + endOffset: 11, + alignStartToSafeBoundary: true, + }); + expect(range).toEqual({ data: "\u001b[31mred", startOffset: 3, endOffset: 11 }); + }); + + it("never starts a page on a UTF-8 continuation byte, even without boundary alignment", async () => { + // "héllo" = 68 C3 A9 6C 6C 6F; offset 2 lands on the é continuation byte. + const { service } = await createSessionWithTranscript("héllo"); + const range = await service.readTranscriptRange({ + sessionId: "range-session", + startOffset: 2, + endOffset: 6, + }); + expect(range).toEqual({ data: "llo", startOffset: 3, endOffset: 6 }); + }); + + it("clamps the requested range to the flushed file size", async () => { + const { service } = await createSessionWithTranscript("line1\nline2\nline3\n"); + const range = await service.readTranscriptRange({ + sessionId: "range-session", + startOffset: 12, + endOffset: 999_999, + }); + expect(range).toEqual({ data: "line3\n", startOffset: 12, endOffset: 18 }); + + const pastEof = await service.readTranscriptRange({ + sessionId: "range-session", + startOffset: 50, + endOffset: 999_999, + }); + expect(pastEof).toEqual({ data: "", startOffset: 18, endOffset: 18 }); + }); + + it("returns an empty result for a zero-length range and null for unknown sessions", async () => { + const { service } = await createSessionWithTranscript("line1\n"); + expect(await service.readTranscriptRange({ + sessionId: "range-session", + startOffset: 5, + endOffset: 5, + })).toEqual({ data: "", startOffset: 5, endOffset: 5 }); + expect(await service.readTranscriptRange({ + sessionId: "missing-session", + startOffset: 0, + endOffset: 10, + })).toBeNull(); + }); + }); + + describe("mobile resize ownership", () => { + it("restores the desktop renderer size after a mobile resize", async () => { + const { service, mockPty } = createHarness(); + const { ptyId, sessionId } = await service.create({ laneId: "lane-1", title: "t", cols: 80, rows: 24 }); + + service.resize({ ptyId, cols: 100, rows: 40 }); + expect(service.resizeBySessionId(sessionId, 60, 20, { source: "mobile" })).toBe(true); + expect(mockPty.resize).toHaveBeenLastCalledWith(60, 20); + + expect(service.restoreDesktopSizeBySessionId(sessionId)).toBe(true); + expect(mockPty.resize).toHaveBeenLastCalledWith(100, 40); + // Already back at the desktop size: nothing to restore. + expect(service.restoreDesktopSizeBySessionId(sessionId)).toBe(false); + }); + + it("records sizes from resizeBySessionId unless the source is mobile", async () => { + const { service, mockPty } = createHarness(); + const { sessionId } = await service.create({ laneId: "lane-1", title: "t", cols: 80, rows: 24 }); + + expect(service.resizeBySessionId(sessionId, 90, 30)).toBe(true); + expect(service.resizeBySessionId(sessionId, 61, 21, { source: "mobile" })).toBe(true); + expect(service.restoreDesktopSizeBySessionId(sessionId)).toBe(true); + expect(mockPty.resize).toHaveBeenLastCalledWith(90, 30); + }); + + it("restores to the create-time size when mobile resizes before desktop", async () => { + const { service, mockPty } = createHarness(); + const { sessionId } = await service.create({ laneId: "lane-1", title: "t", cols: 80, rows: 24 }); + + expect(service.resizeBySessionId(sessionId, 61, 21, { source: "mobile" })).toBe(true); + expect(mockPty.resize).toHaveBeenLastCalledWith(61, 21); + + expect(service.restoreDesktopSizeBySessionId(sessionId)).toBe(true); + expect(mockPty.resize).toHaveBeenLastCalledWith(80, 24); + }); + }); + describe("ensureResumeTargets", () => { it("backfills Codex storage resume targets during session-list hydration", async () => { // The session-list path is how older sessions (whose transcripts no @@ -5111,6 +5301,32 @@ describe("ptyService", () => { expect(mockPty.write).toHaveBeenCalledWith("y\n"); }); + it("writeTerminal marks user input so immediate output uses the interactive batch window", async () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(new Date("2026-06-10T12:00:00.000Z")); + const { service, mockPty, broadcastData } = createChatHarness(); + await service.create({ + laneId: "lane-1", + title: "Writer", + cols: 80, + rows: 24, + chatSessionId: "chat-write", + }); + + await service.writeTerminal({ chatSessionId: "chat-write", data: "y\n" }); + mockPty._emitter.emit("data", "prompt"); + + await vi.advanceTimersByTimeAsync(7); + expect(broadcastData).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + expect(broadcastData).toHaveBeenCalledWith(expect.objectContaining({ data: "prompt" })); + } finally { + vi.useRealTimers(); + } + }); + it("resizeTerminal resizes the active chat terminal", async () => { const { service, mockPty } = createChatHarness(); await service.create({ diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index 4e5543959..604c73356 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -114,6 +114,11 @@ const CLAUDE_TITLE_SCAN_BYTES = 512 * 1024; const CLAUDE_STORAGE_MATCH_START_SKEW_MS = 1_000; const CLAUDE_STORAGE_MATCH_END_SKEW_MS = 5_000; const PTY_DATA_BATCH_INTERVAL_MS = 50; +// Echo latency is dominated by the data batch window. After a user keystroke +// the very next flush races the user's perception, so batch on a much shorter +// window for a brief period following any write to the PTY. +const PTY_DATA_INTERACTIVE_BATCH_INTERVAL_MS = 8; +const PTY_DATA_INTERACTIVE_WINDOW_MS = 1_000; const PTY_DATA_BATCH_MAX_CHARS = 64 * 1024; const PTY_DATA_SUMMARY_INTERVAL_MS = 10_000; const PTY_LIVE_SESSION_RESYNC_INTERVAL_MS = 1_000; @@ -606,9 +611,14 @@ type PtyEntry = { cleanupPaths: string[]; lastResizeCols: number | null; lastResizeRows: number | null; + /** Last size set by a non-mobile caller, restored when a phone detaches. */ + lastDesktopCols: number | null; + lastDesktopRows: number | null; pendingDataChunks: string[]; pendingDataChars: number; pendingDataTimer: ReturnType | null; + /** Epoch ms of the last user write; shortens the data batch window. */ + lastUserInputAt: number; terminalSnapshot: TerminalSnapshotMirror | null; recentOutputTail: string; /** Output-snippet title timer (skipped for interactive Claude/Codex; see CLI user-title path). */ @@ -817,6 +827,21 @@ function mergeTranscriptTailWithLiveOutput(transcriptTail: string, liveOutputTai return tailString(`${transcriptTail}${liveOutputTail.slice(overlap)}`, maxChars); } +/** + * Forward-scan to the first safe place a transcript page may start: the byte + * after a newline, or an ESC (0x1B) opening a fresh escape sequence — so a + * page never begins mid-escape-sequence. Returns 0 (raw start) when the page + * contains neither. + */ +export function scanToTranscriptPageBoundary(page: Buffer): number { + for (let index = 0; index < page.length; index += 1) { + const byte = page[index]; + if (byte === 0x1b) return index; + if (byte === 0x0a) return index + 1; + } + return 0; +} + function runtimeFromStatus(status: TerminalSessionStatus): TerminalRuntimeState { if (status === "running") return "running"; if (status === "disposed") return "killed"; @@ -2892,7 +2917,17 @@ export function createPtyService({ if (!data) return; ptyDataBatchCount += 1; ptyDataMaxBatchChars = Math.max(ptyDataMaxBatchChars, data.length); - emitPtyDataNow(entry, { ...ids, data }); + // writeTranscript runs synchronously in the same onData tick that enqueued + // each chunk, so transcriptBytesWritten here is exactly the transcript end + // offset of this batch. Offsets go null once the transcript stops + // mirroring the stream (byte cap reached, write failure, untracked). + const offset = entry.tracked + && entry.transcriptStream + && !entry.transcriptLimitReached + && !entry.transcriptWriteDisabled + ? entry.transcriptBytesWritten + : null; + emitPtyDataNow(entry, { ...ids, data, offset }); }; const enqueuePtyData = (entry: PtyEntry, event: PtyDataEvent) => { @@ -2908,9 +2943,10 @@ export function createPtyService({ return; } if (entry.pendingDataTimer) return; + const interactive = Date.now() - entry.lastUserInputAt < PTY_DATA_INTERACTIVE_WINDOW_MS; entry.pendingDataTimer = setTimeout(() => { flushQueuedPtyData(entry, ids); - }, PTY_DATA_BATCH_INTERVAL_MS); + }, interactive ? PTY_DATA_INTERACTIVE_BATCH_INTERVAL_MS : PTY_DATA_BATCH_INTERVAL_MS); }; const emitPtyExit = (entry: Pick, event: PtyExitEvent) => { @@ -3809,9 +3845,12 @@ export function createPtyService({ cleanupPaths, lastResizeCols: null, lastResizeRows: null, + lastDesktopCols: cols, + lastDesktopRows: rows, pendingDataChunks: [], pendingDataChars: 0, pendingDataTimer: null, + lastUserInputAt: 0, terminalSnapshot: tracked ? createTerminalSnapshotMirror(cols, rows) : null, recentOutputTail: "", aiTitleTimer: null, @@ -4269,6 +4308,7 @@ export function createPtyService({ const entry = ptys.get(ptyId); if (!entry) return; try { + entry.lastUserInputAt = Date.now(); entry.pty.write(data); tryCliUserTitleFromWrite(entry, data); setRuntimeState(entry.sessionId, "running"); @@ -4532,10 +4572,16 @@ export function createPtyService({ } entry = live[1]; } - entry.pty.write(args.data); - tryCliUserTitleFromWrite(entry, args.data); - setRuntimeState(entry.sessionId, "running"); - scheduleIdleTransition(entry.sessionId); + try { + entry.lastUserInputAt = Date.now(); + entry.pty.write(args.data); + tryCliUserTitleFromWrite(entry, args.data); + setRuntimeState(entry.sessionId, "running"); + scheduleIdleTransition(entry.sessionId); + } catch (err) { + logger.warn("pty.terminal_write_failed", { sessionId: entry.sessionId, err: String(err) }); + throw err; + } return { ok: true }; }, @@ -4600,6 +4646,11 @@ export function createPtyService({ const entry = ptys.get(ptyId); if (!entry) return; const safe = clampDims(cols, rows); + // The ptyId-based path is only driven by the desktop renderer: remember + // its size (even when the resize itself dedupes) so a mobile-driven + // resize can be undone when the phone detaches. + entry.lastDesktopCols = safe.cols; + entry.lastDesktopRows = safe.rows; if (entry.lastResizeCols === safe.cols && entry.lastResizeRows === safe.rows) return; try { entry.pty.resize(safe.cols, safe.rows); @@ -4619,11 +4670,11 @@ export function createPtyService({ */ writeBySessionId(sessionId: string, data: string): boolean { if (!sessionId || typeof data !== "string") return false; - const entry = Array.from(ptys.values()).find( - (candidate) => candidate.sessionId === sessionId && !candidate.disposed, - ); - if (!entry) return false; + const live = liveEntryBySessionId(sessionId); + if (!live) return false; + const [, entry] = live; try { + entry.lastUserInputAt = Date.now(); entry.pty.write(data); tryCliUserTitleFromWrite(entry, data); setRuntimeState(entry.sessionId, "running"); @@ -4635,18 +4686,29 @@ export function createPtyService({ } }, + /** Whether a live (non-disposed) PTY currently backs `sessionId`. */ + hasLivePty(sessionId: string): boolean { + if (!sessionId) return false; + return liveEntryBySessionId(sessionId) != null; + }, + /** * Resize the active PTY for a given session id. Mobile clients call this * when their visible terminal viewport changes (orientation flip, split * view, font-size change). Returns true on success. */ - resizeBySessionId(sessionId: string, cols: number, rows: number): boolean { + resizeBySessionId(sessionId: string, cols: number, rows: number, opts?: { source?: "desktop" | "mobile" }): boolean { if (!sessionId) return false; - const entry = Array.from(ptys.values()).find( - (candidate) => candidate.sessionId === sessionId && !candidate.disposed, - ); - if (!entry) return false; + const live = liveEntryBySessionId(sessionId); + if (!live) return false; + const [, entry] = live; const safe = clampDims(cols, rows); + // A mobile viewport must never become the desktop-preferred size — it + // is restored from lastDesktop* when the phone detaches. + if (opts?.source !== "mobile") { + entry.lastDesktopCols = safe.cols; + entry.lastDesktopRows = safe.rows; + } if (entry.lastResizeCols === safe.cols && entry.lastResizeRows === safe.rows) return true; try { entry.pty.resize(safe.cols, safe.rows); @@ -4660,6 +4722,33 @@ export function createPtyService({ } }, + /** + * Resize the active PTY for a session back to the last desktop-preferred + * size. Called when the last subscribed mobile peer detaches so a phone's + * viewport does not linger on the desktop terminal. Returns true when a + * restore was performed. + */ + restoreDesktopSizeBySessionId(sessionId: string): boolean { + if (!sessionId) return false; + const live = liveEntryBySessionId(sessionId); + if (!live) return false; + const [, entry] = live; + const cols = entry.lastDesktopCols; + const rows = entry.lastDesktopRows; + if (cols == null || rows == null) return false; + if (entry.lastResizeCols === cols && entry.lastResizeRows === rows) return false; + try { + entry.pty.resize(cols, rows); + entry.lastResizeCols = cols; + entry.lastResizeRows = rows; + resizeTerminalSnapshot(entry, cols, rows); + return true; + } catch (err) { + logger.warn("pty.restore_desktop_size_failed", { sessionId, err: String(err) }); + return false; + } + }, + getRuntimeState(sessionId: string, fallbackStatus: TerminalSessionStatus): TerminalRuntimeState { return computeRuntimeState(sessionId, fallbackStatus); }, @@ -4700,6 +4789,66 @@ export function createPtyService({ return args.raw ? merged : stripAnsi(merged); }, + /** + * Read an exact byte range of a session transcript (mobile history + * paging / delta resume). The transcript WriteStream buffers, so disk can + * lag `transcriptBytesWritten` (and any offset derived from it) by a few + * ms — both offsets are clamped to the flushed file size and the achieved + * range is reported back; clients detect the gap and re-request. When + * `alignStartToSafeBoundary` is set, a non-zero start is scanned forward + * to the byte after a `\n` or to an ESC byte so a page never begins + * mid-escape-sequence. A non-zero start always skips UTF-8 continuation + * bytes so decoding never splits a code point. Returns null when the + * session is unknown or has no transcript. + */ + async readTranscriptRange(args: { + sessionId: string; + startOffset: number; + endOffset: number; + alignStartToSafeBoundary?: boolean; + }): Promise<{ data: string; startOffset: number; endOffset: number } | null> { + const sessionId = typeof args.sessionId === "string" ? args.sessionId.trim() : ""; + if (!sessionId) return null; + const session = sessionService.get(sessionId); + const transcriptPath = session?.transcriptPath?.trim(); + if (!transcriptPath) return null; + let fd: number | null = null; + try { + const fileSize = Math.max(0, Number(fs.statSync(transcriptPath).size) || 0); + const end = Math.max(0, Math.min(Math.floor(args.endOffset), fileSize)); + const start = Math.min(Math.max(0, Math.floor(args.startOffset)), end); + if (end <= start) return { data: "", startOffset: end, endOffset: end }; + fd = fs.openSync(transcriptPath, "r"); + const buf = Buffer.alloc(end - start); + const bytesRead = fs.readSync(fd, buf, 0, buf.length, start); + const page = buf.subarray(0, Math.max(0, bytesRead)); + let boundary = 0; + if (start > 0) { + if (args.alignStartToSafeBoundary) { + boundary = scanToTranscriptPageBoundary(page); + } + while (boundary < page.length && (page[boundary]! & 0b1100_0000) === 0b1000_0000) { + boundary += 1; + } + } + return { + data: page.subarray(boundary).toString("utf8"), + startOffset: start + boundary, + endOffset: start + page.length, + }; + } catch { + return null; + } finally { + if (fd !== null) { + try { + fs.closeSync(fd); + } catch { + // Ignore close errors on best-effort transcript reads. + } + } + } + }, + enrichSessions(rows: T[]): T[] { return rows.map((row) => { const live = liveEntryBySessionId(row.id); diff --git a/apps/desktop/src/main/services/sync/deviceRegistryService.test.ts b/apps/desktop/src/main/services/sync/deviceRegistryService.test.ts index 3df404649..56c4ea6f7 100644 --- a/apps/desktop/src/main/services/sync/deviceRegistryService.test.ts +++ b/apps/desktop/src/main/services/sync/deviceRegistryService.test.ts @@ -156,9 +156,7 @@ describe("deviceRegistryService", () => { dbB.close(); }); - it("does not leave device-registry DELETE changesets after viewer join clear", async () => { - if (!isCrsqliteAvailable()) return; - + it.skipIf(!isCrsqliteAvailable())("does not leave device-registry DELETE changesets after viewer join clear", async () => { const projectRoot = makeProjectRoot("ade-device-registry-viewer-clear-"); const dbPath = path.join(projectRoot, ".ade", "ade.db"); const db = await openKvDb(dbPath, createLogger() as any); diff --git a/apps/desktop/src/main/services/sync/syncHostService.test.ts b/apps/desktop/src/main/services/sync/syncHostService.test.ts index ca8a72d1c..6b4fcc0ba 100644 --- a/apps/desktop/src/main/services/sync/syncHostService.test.ts +++ b/apps/desktop/src/main/services/sync/syncHostService.test.ts @@ -1561,6 +1561,7 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { readTranscriptTail, writeBySessionId, resizeBySessionId, + hasLivePty: () => true, enrichSessions: (rows: any[]) => rows, } as any, computerUseArtifactBrokerService: { @@ -1619,7 +1620,7 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { }, })); await waitFor(() => resizeBySessionId.mock.calls.length === 1); - expect(resizeBySessionId).toHaveBeenCalledWith("session-1", 120, 34); + expect(resizeBySessionId).toHaveBeenCalledWith("session-1", 120, 34, { source: "mobile" }); host.handlePtyData({ ptyId: "pty-1", diff --git a/apps/desktop/src/shared/types/sessions.ts b/apps/desktop/src/shared/types/sessions.ts index d58b547ee..3552a2afa 100644 --- a/apps/desktop/src/shared/types/sessions.ts +++ b/apps/desktop/src/shared/types/sessions.ts @@ -200,6 +200,12 @@ export type PtyDataEvent = { sessionId: string; projectRoot?: string; data: string; + /** + * Transcript end offset (UTF-8 bytes) after this chunk was appended. null + * when the transcript can no longer mirror the stream (untracked session, + * transcript writes disabled, or the transcript byte cap was reached). + */ + offset?: number | null; }; export type PtyExitEvent = { diff --git a/apps/desktop/src/shared/types/sync.ts b/apps/desktop/src/shared/types/sync.ts index 0ba827214..0e5935853 100644 --- a/apps/desktop/src/shared/types/sync.ts +++ b/apps/desktop/src/shared/types/sync.ts @@ -434,6 +434,13 @@ export type SyncFileResponsePayload = { export type SyncTerminalSubscribePayload = { sessionId: string; maxBytes?: number; + /** + * Resume marker: transcript byte offset the client has already applied. + * When the host can serve `sinceOffset .. end` within the maxBytes budget + * it replies with a delta snapshot (`delta: true`) the client appends; + * otherwise it falls back to the regular tail snapshot. + */ + sinceOffset?: number; }; export type SyncTerminalUnsubscribePayload = { @@ -447,6 +454,18 @@ export type SyncTerminalSnapshotPayload = { runtimeState: string | null; lastOutputPreview: string | null; capturedAt: string; + /** Transcript byte offset where `transcript` begins. null when unknown. */ + startOffset?: number | null; + /** Transcript byte offset `transcript` covers through. null when unknown. */ + endOffset?: number | null; + /** True when `transcript` only contains bytes from the requested `sinceOffset` (client appends instead of replacing). */ + delta?: boolean; + /** + * Whether a live PTY currently backs the session. False when a brain + * restart orphaned a "running" session — input would go nowhere, so clients + * surface a resume affordance instead of silently accepting keystrokes. + */ + live?: boolean; }; export type SyncTerminalDataPayload = { @@ -454,6 +473,31 @@ export type SyncTerminalDataPayload = { ptyId: string; data: string; at: string; + /** + * Transcript end offset (UTF-8 bytes) after this chunk. null/omitted when + * unavailable (untracked session, transcript writes disabled, byte cap). + */ + offset?: number | null; +}; + +// Mobile pull-to-load-older request: return transcript bytes +// [startOffset, endOffset) where endOffset = min(beforeOffset, transcript +// size) and the page is ~maxBytes. The host scans startOffset forward to a +// safe boundary (byte after `\n`, or an ESC byte) so a page never starts +// mid-escape-sequence — unless the page starts at offset 0. +export type SyncTerminalHistoryRequestPayload = { + sessionId: string; + beforeOffset: number; + maxBytes?: number; +}; + +export type SyncTerminalHistoryResponsePayload = { + sessionId: string; + data: string; + startOffset: number; + endOffset: number; + /** True when this page starts at the very beginning of the transcript. */ + atStart: boolean; }; export type SyncTerminalExitPayload = { @@ -624,6 +668,7 @@ export type SyncRemoteCommandAction = | "work.updateSessionMeta" | "work.runQuickCommand" | "work.startCliSession" + | "work.resumeCliSession" | "work.sendToSession" | "work.stopRuntime" | "processes.listDefinitions" @@ -1041,6 +1086,7 @@ export type SyncTerminalDataEnvelope = SyncEnvelopeWithPayload<"terminal_data", export type SyncTerminalExitEnvelope = SyncEnvelopeWithPayload<"terminal_exit", SyncTerminalExitPayload>; export type SyncTerminalInputEnvelope = SyncEnvelopeWithPayload<"terminal_input", SyncTerminalInputPayload>; export type SyncTerminalResizeEnvelope = SyncEnvelopeWithPayload<"terminal_resize", SyncTerminalResizePayload>; +export type SyncTerminalHistoryEnvelope = SyncEnvelopeWithPayload<"terminal_history", SyncTerminalHistoryRequestPayload | SyncTerminalHistoryResponsePayload>; export type SyncChatSubscribeEnvelope = SyncEnvelopeWithPayload<"chat_subscribe", SyncChatSubscribePayload | SyncChatSubscribeSnapshotPayload>; export type SyncChatUnsubscribeEnvelope = SyncEnvelopeWithPayload<"chat_unsubscribe", SyncChatUnsubscribePayload>; export type SyncChatEventEnvelope = SyncEnvelopeWithPayload<"chat_event", SyncChatEventPayload>; @@ -1090,6 +1136,7 @@ export type SyncEnvelope = | SyncTerminalExitEnvelope | SyncTerminalInputEnvelope | SyncTerminalResizeEnvelope + | SyncTerminalHistoryEnvelope | SyncChatSubscribeEnvelope | SyncChatUnsubscribeEnvelope | SyncChatEventEnvelope diff --git a/apps/ios/ADE.xcodeproj/project.pbxproj b/apps/ios/ADE.xcodeproj/project.pbxproj index e44aeed69..9eca0f8c4 100644 --- a/apps/ios/ADE.xcodeproj/project.pbxproj +++ b/apps/ios/ADE.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 46; + objectVersion = 56; objects = { /* Begin PBXBuildFile section */ @@ -194,6 +194,9 @@ E10000000000000000000045 /* WorkSelectionActionBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000045 /* WorkSelectionActionBar.swift */; }; E10000000000000000000046 /* WorkRootScreen+Selection.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000046 /* WorkRootScreen+Selection.swift */; }; E10000000000000000000048 /* WorkTerminalEmulatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000048 /* WorkTerminalEmulatorView.swift */; }; + E10000000000000000000049 /* TerminalSessionScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000049 /* TerminalSessionScreen.swift */; }; + E1000000000000000000004A /* SwiftTermSessionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1000000000000000000004A /* SwiftTermSessionView.swift */; }; + DD20000000000000000000B1 /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = DD10000000000000000000B1 /* SwiftTerm */; }; E689F42D41A500BB8CA233E4 /* LanesTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9270CF8A67F3FA79089F39C1 /* LanesTabView.swift */; }; F2A1C9D8456E7B3C1D2E4F90 /* FilesCodeSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F9B21C0E4A6D8F1B3C5A77 /* FilesCodeSupport.swift */; }; FBEEF09EFB4911FEAC6A7E87 /* RemoteModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 483C5F1818BAE74B19B84617 /* RemoteModels.swift */; }; @@ -297,6 +300,8 @@ D10000000000000000000045 /* WorkSelectionActionBar.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkSelectionActionBar.swift; path = ADE/Views/Work/WorkSelectionActionBar.swift; sourceTree = ""; }; D10000000000000000000046 /* WorkRootScreen+Selection.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "WorkRootScreen+Selection.swift"; path = "ADE/Views/Work/WorkRootScreen+Selection.swift"; sourceTree = ""; }; D10000000000000000000048 /* WorkTerminalEmulatorView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkTerminalEmulatorView.swift; path = ADE/Views/Work/WorkTerminalEmulatorView.swift; sourceTree = ""; }; + D10000000000000000000049 /* TerminalSessionScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TerminalSessionScreen.swift; path = ADE/Views/Work/TerminalSessionScreen.swift; sourceTree = ""; }; + D1000000000000000000004A /* SwiftTermSessionView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SwiftTermSessionView.swift; path = ADE/Views/Work/SwiftTermSessionView.swift; sourceTree = ""; }; D1000000000000000000003C /* WorkSessionGrouping.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkSessionGrouping.swift; path = ADE/Views/Work/WorkSessionGrouping.swift; sourceTree = ""; }; H10000000000000000000010 /* CtoRootScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = CtoRootScreen.swift; path = ADE/Views/Cto/CtoRootScreen.swift; sourceTree = ""; }; H10000000000000000000011 /* CtoSessionDestinationView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = CtoSessionDestinationView.swift; path = ADE/Views/Cto/CtoSessionDestinationView.swift; sourceTree = ""; }; @@ -416,6 +421,7 @@ buildActionMask = 2147483647; files = ( 60F4CDDB763C0A9F0E650B40 /* Foundation.framework in Frameworks */, + DD20000000000000000000B1 /* SwiftTerm in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -626,6 +632,8 @@ D10000000000000000000046 /* WorkRootScreen+Selection.swift */, D10000000000000000000047 /* ADEInspectable.swift */, D10000000000000000000048 /* WorkTerminalEmulatorView.swift */, + D10000000000000000000049 /* TerminalSessionScreen.swift */, + D1000000000000000000004A /* SwiftTermSessionView.swift */, D1000000000000000000003C /* WorkSessionGrouping.swift */, D1000000000000000000002C /* WorkChatHeaderAndMessageViews.swift */, D1000000000000000000002D /* WorkChatRichCardViews.swift */, @@ -847,6 +855,9 @@ BB4400000000000000000002 /* PBXTargetDependency */, ); name = ADE; + packageProductDependencies = ( + DD10000000000000000000B1 /* SwiftTerm */, + ); productName = ADE; productReference = 27A125DE2C17BA32F9291513 /* ADE.app */; productType = "com.apple.product-type.application"; @@ -958,6 +969,9 @@ Base, ); mainGroup = 564208E3878DD0F0137C7E86; + packageReferences = ( + DD10000000000000000000A1 /* XCRemoteSwiftPackageReference "SwiftTerm" */, + ); productRefGroup = 7FAD2BF35AA6A286BCF68D49 /* Products */; projectDirPath = ""; projectRoot = ""; @@ -1126,6 +1140,8 @@ E10000000000000000000045 /* WorkSelectionActionBar.swift in Sources */, E10000000000000000000046 /* WorkRootScreen+Selection.swift in Sources */, E10000000000000000000048 /* WorkTerminalEmulatorView.swift in Sources */, + E10000000000000000000049 /* TerminalSessionScreen.swift in Sources */, + E1000000000000000000004A /* SwiftTermSessionView.swift in Sources */, E1000000000000000000003C /* WorkSessionGrouping.swift in Sources */, E1000000000000000000002C /* WorkChatHeaderAndMessageViews.swift in Sources */, E1000000000000000000002D /* WorkChatRichCardViews.swift in Sources */, @@ -1607,6 +1623,25 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + DD10000000000000000000A1 /* XCRemoteSwiftPackageReference "SwiftTerm" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/migueldeicaza/SwiftTerm"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.13.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + DD10000000000000000000B1 /* SwiftTerm */ = { + isa = XCSwiftPackageProductDependency; + package = DD10000000000000000000A1 /* XCRemoteSwiftPackageReference "SwiftTerm" */; + productName = SwiftTerm; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 9CBC925352322D208431EFAA /* Project object */; } diff --git a/apps/ios/ADE.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/apps/ios/ADE.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 000000000..913cf77b8 --- /dev/null +++ b/apps/ios/ADE.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,24 @@ +{ + "originHash" : "4d9d9af82f23f3c708bdd502fed3939413b4f2a95a79ae568364cc92bca1527e", + "pins" : [ + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser", + "state" : { + "revision" : "6a52f3251125d74daf04fcbd5e6f08a75d074382", + "version" : "1.8.2" + } + }, + { + "identity" : "swiftterm", + "kind" : "remoteSourceControl", + "location" : "https://github.com/migueldeicaza/SwiftTerm", + "state" : { + "revision" : "8e7a1e154f470e19c709a00a8768df348ba5fc43", + "version" : "1.13.0" + } + } + ], + "version" : 3 +} diff --git a/apps/ios/ADE/Models/RemoteModels.swift b/apps/ios/ADE/Models/RemoteModels.swift index 2e633cd80..4e011bd24 100644 --- a/apps/ios/ADE/Models/RemoteModels.swift +++ b/apps/ios/ADE/Models/RemoteModels.swift @@ -3228,6 +3228,27 @@ struct TerminalSnapshot: Codable, Equatable { var runtimeState: String? var lastOutputPreview: String? var capturedAt: String + /// Transcript byte offsets (UTF-8) covered by `transcript`. Absent on older + /// hosts, which also never set `delta`. + var startOffset: Int? + var endOffset: Int? + /// True when `transcript` only contains bytes from the requested + /// `sinceOffset` to the end — append, don't replace. + var delta: Bool? + /// Whether a live PTY currently backs the session. False when a brain + /// restart orphaned a "running" session — typing would go nowhere. Absent + /// on older hosts. + var live: Bool? +} + +/// Response payload for `terminal_history`: transcript bytes +/// [startOffset, endOffset) ending at/before the requested `beforeOffset`. +struct TerminalHistorySlice: Codable, Equatable { + var sessionId: String + var data: String + var startOffset: Int + var endOffset: Int + var atStart: Bool } struct StartCliSessionResult: Codable, Equatable { diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index 94b463ab1..a7e90e383 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -255,6 +255,10 @@ enum SyncRequestTimeout { } private let syncTerminalSubscriptionMaxBytes = 240_000 +/// Snapshot budget for the full-screen SwiftTerm session view, which renders +/// real scrollback instead of a trimmed preview string. +private let syncTerminalStreamMaxBytes = 512_000 +private let syncTerminalHistoryMaxBytes = 262_144 private let syncChatSubscriptionMaxBytes = 2_000_000 // 512KB, up from 160KB: the old budget silently truncated reasoning-heavy // turns on cellular/Tailscale routes. Chunked envelopes plus off-main decode @@ -1089,6 +1093,7 @@ func syncOutboundEnvelopeProjectId(type: String, activeProjectId: String?) -> St "terminal_unsubscribe", "terminal_input", "terminal_resize", + "terminal_history", "chat_subscribe", "chat_unsubscribe", ] @@ -1101,6 +1106,18 @@ struct SyncSendTestPushResult: Equatable { var message: String } +/// Delivery events for the full-screen terminal. The active screen attaches a +/// handler per session id and receives hydration snapshots, ordered live +/// chunks, and process exit without polling `terminalBuffers`. +enum TerminalStreamEvent { + /// Snapshot payload from `terminal_subscribe`. `replacing == false` means a + /// delta resume (bytes from the requested `sinceOffset` to the end) that + /// must be appended, not re-rendered from scratch. + case hydrate(text: String, replacing: Bool, startOffset: Int?, endOffset: Int?) + case chunk(text: String, endOffset: Int?) + case exit(code: Int?) +} + @MainActor final class SyncService: ObservableObject { @Published private(set) var connectionState: RemoteConnectionState = .disconnected @@ -1173,6 +1190,12 @@ final class SyncService: ObservableObject { private(set) var terminalBuffers: [String: String] = [:] private(set) var terminalBufferUpdatedAt: [String: Date] = [:] + /// Transcript END byte offset (UTF-8) confirmed per terminal session, fed by + /// `terminal_data.offset` and snapshot `endOffset`. Nil for hosts that do not + /// emit offsets; all gap/dedupe logic disables itself in that case. + private(set) var terminalEndOffsets: [String: Int] = [:] + private var terminalStreamHandlers: [String: (TerminalStreamEvent) -> Void] = [:] + private var terminalGapRecoveryInFlight: Set = [] private(set) var chatEventEnvelopesBySession: [String: [AgentChatEventEnvelope]] = [:] private(set) var chatEventRevisionsBySession: [String: Int] = [:] /// Highest host-assigned `seq` applied per chat session. Sent back as @@ -3742,20 +3765,176 @@ final class SyncService: ObservableObject { try await refreshTerminalSnapshot(sessionId: trimmedSessionId) } - func refreshTerminalSnapshot(sessionId: String, maxBytes: Int = syncTerminalSubscriptionMaxBytes) async throws { + func refreshTerminalSnapshot(sessionId: String, maxBytes: Int = syncTerminalSubscriptionMaxBytes, sinceOffset: Int? = nil) async throws { let trimmedSessionId = sessionId.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedSessionId.isEmpty else { return } subscribedTerminalSessionIds.insert(trimmedSessionId) let requestId = makeRequestId() + var payload: [String: Any] = [ + "sessionId": trimmedSessionId, + "maxBytes": max(1_024, min(syncTerminalStreamMaxBytes, maxBytes)), + ] + if let sinceOffset, sinceOffset >= 0 { + payload["sinceOffset"] = sinceOffset + } let raw = try await awaitResponse(requestId: requestId) { - self.sendEnvelope(type: "terminal_subscribe", requestId: requestId, payload: [ - "sessionId": trimmedSessionId, - "maxBytes": max(1_024, min(syncTerminalSubscriptionMaxBytes, maxBytes)), - ]) + self.sendEnvelope(type: "terminal_subscribe", requestId: requestId, payload: payload) } let snapshot = try decode(raw, as: TerminalSnapshot.self) guard subscribedTerminalSessionIds.contains(trimmedSessionId) else { return } - updateTerminalBuffer(sessionId: trimmedSessionId, transcript: snapshot.transcript, immediate: true) + applyTerminalSnapshot(snapshot, sessionId: trimmedSessionId) + } + + /// Full-screen terminal subscribe: attaches at the 512K budget and resumes + /// exactly after `sinceOffset` when the caller still holds earlier bytes. + func subscribeTerminalStream(sessionId: String, sinceOffset: Int? = nil) async throws { + try await refreshTerminalSnapshot(sessionId: sessionId, maxBytes: syncTerminalStreamMaxBytes, sinceOffset: sinceOffset) + } + + /// Registers the active full-screen terminal as the live delivery target for + /// `sessionId`. One handler per session; the screen detaches on disappear. + func attachTerminalStream(sessionId: String, handler: @escaping (TerminalStreamEvent) -> Void) { + let trimmedSessionId = sessionId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedSessionId.isEmpty else { return } + terminalStreamHandlers[trimmedSessionId] = handler + } + + func detachTerminalStream(sessionId: String) { + let trimmedSessionId = sessionId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedSessionId.isEmpty else { return } + terminalStreamHandlers.removeValue(forKey: trimmedSessionId) + } + + /// Fetches transcript bytes ending at/before `beforeOffset` for on-demand + /// scrollback paging. Requires an active `terminal_subscribe` on the host. + func fetchTerminalHistory(sessionId: String, beforeOffset: Int, maxBytes: Int = syncTerminalHistoryMaxBytes) async throws -> TerminalHistorySlice { + let trimmedSessionId = sessionId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedSessionId.isEmpty else { + throw NSError(domain: "ADE", code: 6, userInfo: [NSLocalizedDescriptionKey: "Missing terminal session id."]) + } + guard subscribedTerminalSessionIds.contains(trimmedSessionId) else { + throw NSError(domain: "ADE", code: 7, userInfo: [NSLocalizedDescriptionKey: "Terminal stream is not subscribed."]) + } + let requestId = makeRequestId() + // Older hosts never answer terminal_history; let the request time out + // without tearing the socket down. + let raw = try await awaitResponse(requestId: requestId, disconnectOnTimeout: false) { + self.sendEnvelope(type: "terminal_history", requestId: requestId, payload: [ + "sessionId": trimmedSessionId, + "beforeOffset": beforeOffset, + "maxBytes": max(1_024, min(syncTerminalHistoryMaxBytes, maxBytes)), + ]) + } + return try decode(raw, as: TerminalHistorySlice.self) + } + + /// Applies a live `terminal_data` chunk with offset-based ordering. `endOffset` + /// is the transcript END byte offset after this chunk (nil on older hosts). + private func handleTerminalDataChunk(sessionId: String, chunk: String, endOffset: Int?) { + var deliverableChunk: String? = chunk + if let endOffset { + if let lastEnd = terminalEndOffsets[sessionId] { + let chunkStart = endOffset - chunk.utf8.count + if endOffset <= lastEnd { + // Replay of bytes a snapshot/delta already covered — drop entirely. + deliverableChunk = nil + } else if chunkStart > lastEnd { + // Missed bytes between lastEnd and this chunk. Drop the chunk and + // let a delta resubscribe (sinceOffset = lastEnd) deliver the full + // contiguous range; feeding it now would render out of order. + deliverableChunk = nil + recoverTerminalGap(sessionId: sessionId, sinceOffset: lastEnd) + } else { + if chunkStart < lastEnd { + // Partial overlap: trim the already-applied UTF-8 prefix. + let bytes = Array(chunk.utf8) + var overlap = min(max(0, lastEnd - chunkStart), bytes.count) + while overlap < bytes.count, bytes[overlap] & 0xC0 == 0x80 { + overlap += 1 + } + deliverableChunk = String(decoding: bytes.dropFirst(overlap), as: UTF8.self) + } + terminalEndOffsets[sessionId] = endOffset + } + } else { + terminalEndOffsets[sessionId] = endOffset + } + } + guard let deliverableChunk, !deliverableChunk.isEmpty else { + terminalBufferUpdatedAt[sessionId] = Date() + return + } + terminalBuffers[sessionId] = trimmedTerminalBuffer((terminalBuffers[sessionId] ?? "") + deliverableChunk) + terminalBufferUpdatedAt[sessionId] = Date() + markTerminalBufferChanged() + terminalStreamHandlers[sessionId]?(.chunk(text: deliverableChunk, endOffset: endOffset)) + } + + private func recoverTerminalGap(sessionId: String, sinceOffset: Int) { + guard !terminalGapRecoveryInFlight.contains(sessionId) else { return } + terminalGapRecoveryInFlight.insert(sessionId) + Task { @MainActor [weak self] in + guard let self else { return } + defer { self.terminalGapRecoveryInFlight.remove(sessionId) } + try? await self.refreshTerminalSnapshot( + sessionId: sessionId, + maxBytes: syncTerminalStreamMaxBytes, + sinceOffset: sinceOffset + ) + } + } + + private func applyTerminalSnapshot(_ snapshot: TerminalSnapshot, sessionId: String) { + if let endOffset = snapshot.endOffset { + terminalEndOffsets[sessionId] = endOffset + } + if snapshot.delta == true { + // Delta resume: payload covers exactly [sinceOffset, end). Append — + // replacing would drop everything the screen already rendered. + if !snapshot.transcript.isEmpty { + terminalBuffers[sessionId] = trimmedTerminalBuffer((terminalBuffers[sessionId] ?? "") + snapshot.transcript) + markTerminalBufferChanged(immediate: true) + terminalStreamHandlers[sessionId]?(.hydrate( + text: snapshot.transcript, + replacing: false, + startOffset: snapshot.startOffset, + endOffset: snapshot.endOffset + )) + } + terminalBufferUpdatedAt[sessionId] = Date() + } else { + updateTerminalBuffer(sessionId: sessionId, transcript: snapshot.transcript, immediate: true) + terminalStreamHandlers[sessionId]?(.hydrate( + text: snapshot.transcript, + replacing: true, + startOffset: snapshot.startOffset, + endOffset: snapshot.endOffset + )) + } + if snapshot.live == false { + // No PTY behind the session (ended, or orphaned by a brain restart even + // though status still says running) — typing would go nowhere. Surface + // the exited/resume state instead of a live prompt. + terminalStreamHandlers[sessionId]?(.exit(code: nil)) + } + } + + /// Whether a full-screen terminal currently owns the live stream for + /// `sessionId`. Used to skip detach-unsubscribes that would race a remount. + func hasTerminalStream(sessionId: String) -> Bool { + let trimmedSessionId = sessionId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedSessionId.isEmpty else { return false } + return terminalStreamHandlers[trimmedSessionId] != nil + } + + /// Relaunches an ended/orphaned agent CLI session's runtime on the host + /// (same sessionId, provider resume metadata) — the phone mirror of the + /// desktop resume affordance. + func resumeCliSession(sessionId: String, cols: Int? = nil, rows: Int? = nil) async throws -> StartCliSessionResult { + var args: [String: Any] = ["sessionId": sessionId] + if let cols { args["cols"] = cols } + if let rows { args["rows"] = rows } + return try await sendDecodableCommand(action: "work.resumeCliSession", args: args, as: StartCliSessionResult.self) } func unsubscribeTerminal(sessionId: String) async throws { @@ -7302,7 +7481,7 @@ final class SyncService: ObservableObject { let message = dict["message"] as? String ?? "Remote command rejected." resolve(requestId: requestId, result: .failure(NSError(domain: "ADE", code: 6, userInfo: [NSLocalizedDescriptionKey: message]))) } - case "command_result", "file_response", "terminal_snapshot": + case "command_result", "file_response", "terminal_snapshot", "terminal_history": resolve(requestId: requestId, result: .success(payload)) case "in_app_notification": if let dict = payload as? [String: Any] { @@ -7345,9 +7524,7 @@ final class SyncService: ObservableObject { case "terminal_data": if let dict = payload as? [String: Any], let sessionId = dict["sessionId"] as? String, let chunk = dict["data"] as? String { guard subscribedTerminalSessionIds.contains(sessionId) else { break } - terminalBuffers[sessionId] = trimmedTerminalBuffer((terminalBuffers[sessionId] ?? "") + chunk) - terminalBufferUpdatedAt[sessionId] = Date() - markTerminalBufferChanged() + handleTerminalDataChunk(sessionId: sessionId, chunk: chunk, endOffset: (dict["offset"] as? NSNumber)?.intValue) } case "terminal_exit": if let dict = payload as? [String: Any], let sessionId = dict["sessionId"] as? String { @@ -7356,6 +7533,7 @@ final class SyncService: ObservableObject { terminalBuffers[sessionId] = trimmedTerminalBuffer((terminalBuffers[sessionId] ?? "") + "\n\n[process exited\(exitCode.map { " with \($0)" } ?? "")]") terminalBufferUpdatedAt[sessionId] = Date() markTerminalBufferChanged(immediate: true) + terminalStreamHandlers[sessionId]?(.exit(code: exitCode)) } default: break @@ -8106,7 +8284,16 @@ final class SyncService: ObservableObject { guard let self else { return } for sessionId in sessionIds { self.subscribedTerminalSessionIds.remove(sessionId) - try? await self.subscribeTerminal(sessionId: sessionId) + if self.terminalStreamHandlers[sessionId] != nil { + // Active full-screen terminal: resume exactly after the last byte we + // applied so the reconnect back-fills as an append, not a re-render. + try? await self.subscribeTerminalStream( + sessionId: sessionId, + sinceOffset: self.terminalEndOffsets[sessionId] + ) + } else { + try? await self.subscribeTerminal(sessionId: sessionId) + } } } } @@ -8261,6 +8448,8 @@ final class SyncService: ObservableObject { subscribedTerminalSessionIds.removeAll() terminalBuffers.removeAll() terminalBufferUpdatedAt.removeAll() + terminalEndOffsets.removeAll() + terminalGapRecoveryInFlight.removeAll() } terminalBufferRevision += 1 } diff --git a/apps/ios/ADE/Views/Work/SwiftTermSessionView.swift b/apps/ios/ADE/Views/Work/SwiftTermSessionView.swift new file mode 100644 index 000000000..82ea90e6b --- /dev/null +++ b/apps/ios/ADE/Views/Work/SwiftTermSessionView.swift @@ -0,0 +1,688 @@ +import SwiftUI +import SwiftTerm +import UIKit + +/// SwiftTerm's iOS view snaps the viewport to the bottom on every emitted +/// scroll event (i.e. each new output line). While the user is reading +/// scrollback we suppress that snap so streaming output cannot yank the view; +/// `resumeAutoScroll()` re-enables following and performs one snap-to-bottom. +final class ADESwiftTermView: TerminalView { + var holdScrollOnOutput = false + var onLayout: (() -> Void)? + + // Apps that enable mouse reporting (Claude Code, htop, …) scroll by + // consuming wheel events, but SwiftTerm's iOS pan handler only synthesizes + // press/drag/release — so panning a mouse-mode TUI did nothing. Translate + // vertical pans into wheel events ourselves while mouse reporting is on, + // and keep SwiftTerm's drag-event pan out of the way (taps still report). + private let wheelPanGesture = UIPanGestureRecognizer() + private var wheelPanResidual: CGFloat = 0 + /// Points of pan travel per synthesized wheel tick. Roughly half a cell: + /// fine enough to feel direct, coarse enough not to flood the PTY. + private var wheelTickStride: CGFloat { + let rows = max(1, getTerminal().rows) + return max(8, bounds.height / CGFloat(rows) / 2) + } + + private var mouseReportingActive: Bool { + getTerminal().mouseMode != .off + } + + func installWheelPanGesture() { + guard wheelPanGesture.view == nil else { return } + wheelPanGesture.addTarget(self, action: #selector(handleWheelPan(_:))) + wheelPanGesture.maximumNumberOfTouches = 1 + addGestureRecognizer(wheelPanGesture) + } + + override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + if gestureRecognizer === wheelPanGesture { + return mouseReportingActive + } + // While mouse reporting is on, suppress both SwiftTerm's drag-event pan + // and the scroll view's own pan (a mouse-mode TUI has no scrollback to + // move; rubber-banding under the wheel stream just adds noise). + if mouseReportingActive, gestureRecognizer is UIPanGestureRecognizer { + return false + } + return super.gestureRecognizerShouldBegin(gestureRecognizer) + } + + @objc private func handleWheelPan(_ gesture: UIPanGestureRecognizer) { + let terminal = getTerminal() + guard terminal.mouseMode != .off else { return } + switch gesture.state { + case .began: + wheelPanResidual = 0 + case .changed: + let translation = gesture.translation(in: self).y + wheelPanResidual + let stride = wheelTickStride + let ticks = Int(translation / stride) + guard ticks != 0 else { return } + wheelPanResidual = translation - CGFloat(ticks) * stride + gesture.setTranslation(.zero, in: self) + // Finger moving down reveals earlier content → wheel up (button 4). + let button = ticks > 0 ? 4 : 5 + let flags = terminal.encodeButton(button: button, release: false, shift: false, meta: false, control: false) + let location = gesture.location(in: self) + let col = max(0, min(terminal.cols - 1, Int(location.x / max(1, bounds.width / CGFloat(max(1, terminal.cols)))))) + let row = max(0, min(terminal.rows - 1, Int(location.y / max(1, bounds.height / CGFloat(max(1, terminal.rows)))))) + for _ in 0..? + + private var lastSentSize: (cols: Int, rows: Int)? + private var pinchBaseFontSize: CGFloat = TerminalSessionController.defaultFontSize + private var ctrlResetObserver: NSObjectProtocol? + + // Hosts that stamp transcript offsets push live terminal_data; legacy hosts + // (pre-offset brains) never push terminal output at all, so the screen + // falls back to periodic tail refreshes until an offset proves otherwise. + // nil = unknown (no snapshot seen yet). + private var hostSupportsOffsets: Bool? + private var legacyPollTask: Task? + private static let legacyPollIntervalNs: UInt64 = 2_000_000_000 + private static let legacyPollBudgetBytes = 192_000 + + deinit { + if let ctrlResetObserver { + NotificationCenter.default.removeObserver(ctrlResetObserver) + } + } + + // MARK: - Lifecycle + + func activate(syncService: SyncService, sessionId: String, sessionStatus: String) { + self.syncService = syncService + self.sessionId = sessionId + let status = sessionStatus.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if status == "ended" || status == "exited" || status == "stopped" { + hasExited = true + } + syncService.attachTerminalStream(sessionId: sessionId) { [weak self] event in + self?.handleStreamEvent(event) + } + let resumeOffset = transcriptEndOffset + Task { @MainActor [weak self] in + guard let self else { return } + do { + try await syncService.subscribeTerminalStream(sessionId: sessionId, sinceOffset: resumeOffset) + self.isSubscribed = true + self.startLegacyPollingIfNeeded() + } catch { + self.isSubscribed = false + } + } + } + + func deactivate() { + inputFlushTask?.cancel() + inputFlushTask = nil + pendingInput = "" + legacyPollTask?.cancel() + legacyPollTask = nil + guard let syncService, !sessionId.isEmpty else { return } + syncService.detachTerminalStream(sessionId: sessionId) + isSubscribed = false + let id = sessionId + Task { @MainActor in + // Pop-then-repush of the same session can order the old screen's + // disappear after the new screen's subscribe; unsubscribing then would + // sever the new screen's live stream (and trigger the host's + // desktop-size restore). Skip when another screen has re-attached. + guard !syncService.hasTerminalStream(sessionId: id) else { return } + try? await syncService.unsubscribeTerminal(sessionId: id) + } + } + + /// Relaunch an ended/orphaned session's runtime and re-attach the stream. + func resume() { + guard !isResuming, let syncService, !sessionId.isEmpty else { return } + isResuming = true + resumeError = nil + ADEHaptics.medium() + let id = sessionId + let terminal = terminalView?.getTerminal() + let cols = terminal.map(\.cols) + let rows = terminal.map(\.rows) + Task { @MainActor [weak self] in + defer { self?.isResuming = false } + guard let self else { return } + do { + _ = try await syncService.resumeCliSession(sessionId: id, cols: cols, rows: rows) + guard self.sessionId == id, syncService.hasTerminalStream(sessionId: id) else { return } + self.hasExited = false + // The relaunched PTY appends to the same transcript; resume the + // stream exactly after what's already rendered. + try? await syncService.subscribeTerminalStream(sessionId: id, sinceOffset: self.transcriptEndOffset) + self.isSubscribed = true + self.startLegacyPollingIfNeeded() + } catch { + self.resumeError = (error as NSError).localizedDescription + } + } + } + + /// Pre-offset hosts never push terminal_data (their PTY→sync bridge only + /// existed in the Electron desktop), so without this loop the screen would + /// freeze on its first snapshot forever. Polls a modest tail and stops the + /// moment any snapshot/chunk proves the host stamps offsets. + private func startLegacyPollingIfNeeded() { + guard hostSupportsOffsets != true, legacyPollTask == nil else { return } + legacyPollTask = Task { @MainActor [weak self] in + defer { self?.legacyPollTask = nil } + while let self, !Task.isCancelled { + try? await Task.sleep(nanoseconds: Self.legacyPollIntervalNs) + if Task.isCancelled { return } + if self.hostSupportsOffsets == true || self.hasExited { return } + guard let syncService = self.syncService, self.isSubscribed, self.readyToFeed else { continue } + // Don't yank the viewport out from under a reader: replace-hydrates + // re-pin to the bottom. + guard self.isPinnedToBottom else { continue } + try? await syncService.refreshTerminalSnapshot( + sessionId: self.sessionId, + maxBytes: Self.legacyPollBudgetBytes + ) + } + } + } + + func handleConnectionChange(isConnected: Bool) { + if isConnected { + // The sync layer re-subscribes with sinceOffset on reconnect; we only + // need to re-assert the phone's PTY size. + isSubscribed = true + lastSentSize = nil + if let terminal = terminalView?.getTerminal() { + sendResizeIfNeeded(cols: terminal.cols, rows: terminal.rows) + } + // The reconnect may have landed on a different (possibly older) host. + hostSupportsOffsets = nil + startLegacyPollingIfNeeded() + } else { + isSubscribed = false + } + } + + // MARK: - Terminal view + + func makeTerminalView() -> ADESwiftTermView { + if let terminalView { + return terminalView + } + let view = ADESwiftTermView( + frame: CGRect(x: 0, y: 0, width: 390, height: 640), + font: UIFont.monospacedSystemFont(ofSize: fontSize, weight: .regular) + ) + view.terminalDelegate = self + view.delegate = self + // The SwiftUI key bar replaces SwiftTerm's stock accessory strip. + view.inputAccessoryView = nil + view.backgroundColor = .black + view.nativeBackgroundColor = .black + view.nativeForegroundColor = UIColor(white: 0.92, alpha: 1) + view.keyboardAppearance = .dark + view.changeScrollback(Self.scrollbackLines) + view.installWheelPanGesture() + view.onLayout = { [weak self] in + self?.handleTerminalLayout() + } + view.addGestureRecognizer(UIPinchGestureRecognizer(target: self, action: #selector(handlePinch(_:)))) + // SwiftTerm auto-resets controlModifier after the next key; mirror that + // into the latched-Ctrl chip. + ctrlResetObserver = NotificationCenter.default.addObserver( + forName: Notification.Name("SwiftTerm.TerminalView.controlModifierReset"), + object: view, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.ctrlArmed = false + } + } + terminalView = view + return view + } + + private func handleTerminalLayout() { + guard let view = terminalView, view.bounds.width > 10, view.bounds.height > 10 else { return } + let terminal = view.getTerminal() + guard terminal.cols > 1, terminal.rows > 1 else { return } + // Resize the PTY BEFORE the first feed so hydrated content wraps for the + // phone's viewport, not the desktop's. + sendResizeIfNeeded(cols: terminal.cols, rows: terminal.rows) + if !readyToFeed { + readyToFeed = true + let queued = queuedEvents + queuedEvents = [] + queued.forEach(applyStreamEvent) + } + } + + // MARK: - Stream events + + private func handleStreamEvent(_ event: TerminalStreamEvent) { + guard readyToFeed else { + queuedEvents.append(event) + return + } + applyStreamEvent(event) + } + + private func applyStreamEvent(_ event: TerminalStreamEvent) { + switch event { + case .hydrate(let text, let replacing, let startOffset, let endOffset): + noteHostOffsetCapability(endOffset != nil) + if replacing { + let bytes = Data(text.utf8) + // Legacy polling refetches the same tail every cycle; rebuilding on an + // unchanged tail would flicker the screen every poll. + if endOffset == nil, !bytes.isEmpty, transcript == bytes || (transcript.count > bytes.count && transcript.suffix(bytes.count) == bytes) { + return + } + transcript = bytes + transcriptStartOffset = startOffset + transcriptEndOffset = endOffset + historyAtStart = startOffset == 0 + rebuildTerminal() + } else { + appendBytes(Data(text.utf8), endOffset: endOffset, countsAsLive: false) + } + case .chunk(let text, let endOffset): + noteHostOffsetCapability(endOffset != nil) + appendBytes(Data(text.utf8), endOffset: endOffset, countsAsLive: true) + case .exit: + hasExited = true + // Drop the keyboard: input has nowhere to go and the resume bar + // replaces the key bar. + _ = terminalView?.resignFirstResponder() + } + } + + private func noteHostOffsetCapability(_ supportsOffsets: Bool) { + if supportsOffsets { + hostSupportsOffsets = true + } else if hostSupportsOffsets == nil { + hostSupportsOffsets = false + } + } + + private func appendBytes(_ bytes: Data, endOffset: Int?, countsAsLive: Bool) { + if let endOffset { + transcriptEndOffset = endOffset + } + guard !bytes.isEmpty else { return } + transcript.append(bytes) + enforceTranscriptCap() + terminalView?.feed(byteArray: [UInt8](bytes)[...]) + if countsAsLive, !isPinnedToBottom { + liveChunksWhileScrolledUp += 1 + } + } + + private func enforceTranscriptCap() { + guard transcript.count > Self.transcriptByteCap else { return } + // Drop well below the cap so steady output doesn't re-trim every chunk. + let target = Self.transcriptByteCap - 512 * 1024 + let dropCount = transcript.count - target + transcript.removeFirst(dropCount) + if let start = transcriptStartOffset { + transcriptStartOffset = start + dropCount + } + historyAtStart = false + } + + private func rebuildTerminal() { + guard let view = terminalView else { return } + view.holdScrollOnOutput = false + view.getTerminal().resetToInitialState() + if !transcript.isEmpty { + view.feed(byteArray: [UInt8](transcript)[...]) + } + isPinnedToBottom = true + liveChunksWhileScrolledUp = 0 + view.resumeAutoScroll() + } + + // MARK: - History paging + + func loadOlderHistoryIfNeeded() { + guard !isLoadingHistory, !historyAtStart else { return } + guard let syncService else { return } + guard let startOffset = transcriptStartOffset, startOffset > 0 else { return } + guard transcript.count < Self.transcriptByteCap else { return } + isLoadingHistory = true + let id = sessionId + Task { @MainActor [weak self] in + defer { self?.isLoadingHistory = false } + guard let self else { return } + do { + let slice = try await syncService.fetchTerminalHistory(sessionId: id, beforeOffset: startOffset) + // A replace-hydration may have landed while the fetch was in flight; + // prepending stale bytes would corrupt the window. + guard self.transcriptStartOffset == startOffset, slice.endOffset == startOffset else { return } + if !slice.data.isEmpty { + self.transcript = Data(slice.data.utf8) + self.transcript + self.transcriptStartOffset = slice.startOffset + self.rebuildPreservingViewport() + } + self.historyAtStart = slice.atStart + } catch { + // Older host (no terminal_history) or transient failure: stop paging + // for this screen session instead of hammering the socket. + self.historyAtStart = true + } + } + } + + /// SwiftTerm has no prepend; after splicing older bytes in front we reset + /// and re-feed the whole window (it parses MBs in milliseconds), then shift + /// the content offset by the growth so the previously-topmost content stays + /// visible. + private func rebuildPreservingViewport() { + guard let view = terminalView else { return } + let oldHeight = view.contentSize.height + let oldOffsetY = view.contentOffset.y + view.holdScrollOnOutput = false + view.getTerminal().resetToInitialState() + if !transcript.isEmpty { + view.feed(byteArray: [UInt8](transcript)[...]) + } + DispatchQueue.main.async { [weak self] in + guard let self, let view = self.terminalView else { return } + let grown = max(0, view.contentSize.height - oldHeight) + view.holdScrollOnOutput = true + self.isPinnedToBottom = false + view.contentOffset = CGPoint(x: 0, y: max(0, oldOffsetY + grown)) + } + } + + // MARK: - Input + + var canSendInput: Bool { + guard let syncService else { return false } + return (syncService.connectionState == .connected || syncService.connectionState == .syncing) && !hasExited + } + + func enqueueInput(_ text: String) { + guard !text.isEmpty else { return } + guard canSendInput else { + if !hasExited { + blockedInputPulse += 1 + } + return + } + pendingInput += text + scheduleInputFlush() + if !isPinnedToBottom { + scrollToLive() + } + } + + func sendKeySequence(_ data: String) { + ADEHaptics.light() + enqueueInput(data) + } + + func dismissKeyboard() { + ADEHaptics.light() + _ = terminalView?.resignFirstResponder() + } + + func toggleCtrl() { + guard let view = terminalView else { return } + view.controlModifier.toggle() + ctrlArmed = view.controlModifier + ADEHaptics.medium() + if ctrlArmed { + _ = view.becomeFirstResponder() + } + } + + var pasteboardHasStrings: Bool { + UIPasteboard.general.hasStrings + } + + func pasteFromClipboard() { + guard canSendInput else { + blockedInputPulse += 1 + return + } + ADEHaptics.light() + // Routes through SwiftTerm, which wraps in ESC[200~ … ESC[201~ whenever + // the host application enabled bracketed paste. + terminalView?.paste(nil) + } + + /// Leading + trailing flush: the first keystroke of a burst goes out + /// immediately (single taps shouldn't pay the batch window), follow-ups + /// within 16ms coalesce into one trailing envelope. + private func scheduleInputFlush() { + guard inputFlushTask == nil else { return } + flushPendingInputNow() + inputFlushTask = Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: 16_000_000) + guard let self, !Task.isCancelled else { return } + self.inputFlushTask = nil + self.flushPendingInputNow() + } + } + + private func flushPendingInputNow() { + let buffered = pendingInput + pendingInput = "" + guard !buffered.isEmpty else { return } + syncService?.sendTerminalInput(sessionId: sessionId, data: buffered) + } + + private func sendResizeIfNeeded(cols: Int, rows: Int) { + guard cols > 1, rows > 1 else { return } + if let lastSentSize, lastSentSize.cols == cols, lastSentSize.rows == rows { + return + } + lastSentSize = (cols, rows) + syncService?.sendTerminalResize(sessionId: sessionId, cols: cols, rows: rows) + } + + // MARK: - Scroll pinning + + func scrollToLive() { + liveChunksWhileScrolledUp = 0 + isPinnedToBottom = true + terminalView?.resumeAutoScroll() + } + + private func updatePinState() { + guard let view = terminalView else { return } + let nearBottom = view.contentOffset.y + view.bounds.height >= view.contentSize.height - 32 + if nearBottom { + if !isPinnedToBottom { + isPinnedToBottom = true + liveChunksWhileScrolledUp = 0 + } + view.holdScrollOnOutput = false + } else { + if isPinnedToBottom { + isPinnedToBottom = false + } + view.holdScrollOnOutput = true + } + } + + private func settleScroll() { + updatePinState() + if isPinnedToBottom { + terminalView?.resumeAutoScroll() + } + } + + // MARK: - Pinch zoom + + @objc private func handlePinch(_ gesture: UIPinchGestureRecognizer) { + switch gesture.state { + case .began: + pinchBaseFontSize = fontSize + case .changed: + let next = min(max(pinchBaseFontSize * gesture.scale, Self.minFontSize), Self.maxFontSize) + if abs(next - fontSize) >= 0.5 { + fontSize = next + // Font change recomputes cell metrics + cols/rows inside SwiftTerm, + // which fires sizeChanged → sendTerminalResize. + terminalView?.font = UIFont.monospacedSystemFont(ofSize: next, weight: .regular) + } + default: + break + } + } + + fileprivate func handleBell() { + UIImpactFeedbackGenerator(style: .rigid).impactOccurred() + bellPulse += 1 + } +} + +// MARK: - TerminalViewDelegate + +extension TerminalSessionController: TerminalViewDelegate { + nonisolated func send(source: TerminalView, data: ArraySlice) { + let text = String(bytes: data, encoding: .utf8) ?? String(decoding: data, as: UTF8.self) + MainActor.assumeIsolated { + enqueueInput(text) + } + } + + nonisolated func sizeChanged(source: TerminalView, newCols: Int, newRows: Int) { + MainActor.assumeIsolated { + sendResizeIfNeeded(cols: newCols, rows: newRows) + } + } + + nonisolated func requestOpenLink(source: TerminalView, link: String, params: [String: String]) { + guard let url = URL(string: link), url.scheme?.lowercased().hasPrefix("http") == true else { return } + MainActor.assumeIsolated { + UIApplication.shared.open(url) + } + } + + nonisolated func bell(source: TerminalView) { + MainActor.assumeIsolated { + handleBell() + } + } + + nonisolated func clipboardCopy(source: TerminalView, content: Data) { + let text = String(bytes: content, encoding: .utf8) ?? String(decoding: content, as: UTF8.self) + MainActor.assumeIsolated { + UIPasteboard.general.string = text + } + } + + nonisolated func setTerminalTitle(source: TerminalView, title: String) {} + nonisolated func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) {} + nonisolated func scrolled(source: TerminalView, position: Double) {} + nonisolated func iTermContent(source: TerminalView, content: ArraySlice) {} + nonisolated func rangeChanged(source: TerminalView, startY: Int, endY: Int) {} +} + +// MARK: - UIScrollViewDelegate (TerminalView is a UIScrollView) + +extension TerminalSessionController: UIScrollViewDelegate { + func scrollViewDidScroll(_ scrollView: UIScrollView) { + guard scrollView === terminalView else { return } + // Ignore programmatic offset changes (SwiftTerm's own bottom snaps). + guard scrollView.isTracking || scrollView.isDecelerating else { return } + updatePinState() + if scrollView.contentOffset.y < 160 { + loadOlderHistoryIfNeeded() + } + } + + func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { + guard scrollView === terminalView, !decelerate else { return } + settleScroll() + } + + func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { + guard scrollView === terminalView else { return } + settleScroll() + } +} + +// MARK: - SwiftUI wrapper + +struct SwiftTermSessionView: UIViewRepresentable { + @ObservedObject var controller: TerminalSessionController + + func makeUIView(context: Context) -> ADESwiftTermView { + controller.makeTerminalView() + } + + func updateUIView(_ uiView: ADESwiftTermView, context: Context) {} +} diff --git a/apps/ios/ADE/Views/Work/TerminalSessionScreen.swift b/apps/ios/ADE/Views/Work/TerminalSessionScreen.swift new file mode 100644 index 000000000..86a5b0b8e --- /dev/null +++ b/apps/ios/ADE/Views/Work/TerminalSessionScreen.swift @@ -0,0 +1,461 @@ +import SwiftUI +import UIKit + +/// Full-bleed native terminal for a CLI session: SwiftTerm emulation, offset +/// streamed PTY output, tap-to-focus keyboard input, on-demand scrollback +/// paging, and phone-driven PTY resize. +struct TerminalSessionScreen: View { + @EnvironmentObject var syncService: SyncService + @Environment(\.dismiss) private var dismiss + + let session: TerminalSessionSummary + + @StateObject private var controller = TerminalSessionController() + @State private var keyboardVisible = false + @State private var pasteboardHasStrings = false + @State private var showReconnectingCaption = false + @State private var statusDotFlash = false + + private var bottomIgnoredEdges: Edge.Set { + // The resume bar renders without the keyboard; it needs the bottom safe + // area so it doesn't sit under the home indicator. + (keyboardVisible || controller.hasExited) ? [] : [.bottom] + } + + private let keyboardShowPublisher: NotificationCenter.Publisher = + NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification) + private let keyboardHidePublisher: NotificationCenter.Publisher = + NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification) + private let pasteboardPublisher: NotificationCenter.Publisher = + NotificationCenter.default.publisher(for: UIPasteboard.changedNotification) + + var body: some View { + lifecycleDecorated + .onReceive(keyboardShowPublisher) { _ in handleKeyboardWillShow() } + .onReceive(keyboardHidePublisher) { _ in keyboardVisible = false } + .onReceive(pasteboardPublisher) { _ in refreshPasteboardState() } + } + + private var lifecycleDecorated: some View { + chromeDecorated + .task(id: session.id) { handleAppear() } + .onDisappear { controller.deactivate() } + .onChange(of: syncService.connectionState) { (_, state: RemoteConnectionState) in + handleConnectionState(state) + } + .onChange(of: controller.bellPulse) { (_, _: Int) in flashStatusDot() } + .onChange(of: controller.blockedInputPulse) { (_, _: Int) in showReconnectingCaption = true } + .task(id: showReconnectingCaption) { await hideReconnectingCaptionSoon() } + } + + private var chromeDecorated: some View { + terminalStack + .background(Color.black.ignoresSafeArea()) + .safeAreaInset(edge: .bottom, spacing: 0) { + if controller.hasExited { + resumeBar + } else if keyboardVisible { + keyBarStack + } + } + .ignoresSafeArea(.container, edges: bottomIgnoredEdges) + .navigationTitle("") + .toolbar(.hidden, for: .navigationBar) + .toolbar(.hidden, for: .tabBar) + .adeRootTabBarHidden() + } + + private var terminalStack: some View { + VStack(spacing: 0) { + topBar + SwiftTermSessionView(controller: controller) + .overlay(alignment: .top) { + if controller.isLoadingHistory { + TerminalHistoryShimmer() + } + } + .overlay(alignment: .bottomTrailing) { + if !controller.isPinnedToBottom { + livePill + .padding(.trailing, 14) + .padding(.bottom, 14) + } + } + } + } + + private func handleAppear() { + controller.activate(syncService: syncService, sessionId: session.id, sessionStatus: session.status) + pasteboardHasStrings = controller.pasteboardHasStrings + } + + private func handleConnectionState(_ state: RemoteConnectionState) { + controller.handleConnectionChange(isConnected: state == .connected || state == .syncing) + } + + private func handleKeyboardWillShow() { + keyboardVisible = true + pasteboardHasStrings = controller.pasteboardHasStrings + } + + private func refreshPasteboardState() { + pasteboardHasStrings = controller.pasteboardHasStrings + } + + private func hideReconnectingCaptionSoon() async { + guard showReconnectingCaption else { return } + try? await Task.sleep(nanoseconds: 1_600_000_000) + guard !Task.isCancelled else { return } + showReconnectingCaption = false + } + + private func flashStatusDot() { + statusDotFlash = true + Task { @MainActor in + try? await Task.sleep(nanoseconds: 140_000_000) + withAnimation(.easeOut(duration: 0.4)) { + statusDotFlash = false + } + } + } + + // MARK: - Top bar + + private var topBar: some View { + HStack(spacing: 10) { + Button { + dismiss() + } label: { + Image(systemName: "chevron.left") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(ADEColor.textPrimary) + .frame(width: 36, height: 36) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Back") + + Circle() + .fill(statusColor) + .frame(width: 7, height: 7) + .scaleEffect(statusDotFlash ? 1.6 : 1) + + Text(titleText) + .font(.system(size: 13, weight: .semibold, design: .monospaced)) + .foregroundStyle(ADEColor.textPrimary) + .lineLimit(1) + .truncationMode(.middle) + + Spacer(minLength: 0) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background { + ADEColor.pageBackground + .opacity(0.98) + .ignoresSafeArea(edges: .top) + } + } + + private var titleText: String { + "\(runtimeLabel) · \(session.laneName)" + } + + private var runtimeLabel: String { + let raw = session.toolType?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "" + return raw.isEmpty ? "shell" : raw + } + + private var statusColor: Color { + if controller.hasExited { + return ADEColor.textMuted + } + switch syncService.connectionState { + case .connected, .syncing: + return controller.isSubscribed ? ADEColor.success : ADEColor.warning + case .connecting: + return ADEColor.warning + case .disconnected, .error: + return ADEColor.textMuted + } + } + + // MARK: - Live pill + + private var livePill: some View { + Button { + controller.scrollToLive() + } label: { + HStack(spacing: 5) { + Image(systemName: "arrow.down") + .font(.system(size: 10, weight: .bold)) + Text(controller.liveChunksWhileScrolledUp > 0 ? "Live \(controller.liveChunksWhileScrolledUp)" : "Live") + .font(.system(size: 12, weight: .semibold, design: .monospaced)) + } + .foregroundStyle(ADEColor.textPrimary) + .padding(.horizontal, 12) + .padding(.vertical, 7) + .background(.ultraThinMaterial, in: Capsule()) + .overlay(Capsule().stroke(ADEColor.glassBorder, lineWidth: 0.6)) + } + .buttonStyle(.plain) + } + + // MARK: - Resume bar + + /// Whether the host can relaunch this session's runtime: agent CLI sessions + /// carry resume metadata; plain shells do not. + private var sessionIsResumable: Bool { + terminalSessionHasResumeTarget(session) + } + + private var resumeBar: some View { + VStack(spacing: 6) { + if let resumeError = controller.resumeError { + Text(resumeError) + .font(.caption2.weight(.medium)) + .foregroundStyle(ADEColor.warning) + .lineLimit(2) + .padding(.horizontal, 12) + .frame(maxWidth: .infinity, alignment: .leading) + } + HStack(spacing: 10) { + Text("Session ended") + .font(.system(size: 13, weight: .medium, design: .monospaced)) + .foregroundStyle(ADEColor.textMuted) + Spacer(minLength: 0) + if sessionIsResumable { + Button { + controller.resume() + } label: { + HStack(spacing: 6) { + if controller.isResuming { + ProgressView() + .controlSize(.small) + .tint(.white) + } else { + Image(systemName: "arrow.counterclockwise") + .font(.system(size: 11, weight: .bold)) + } + Text(controller.isResuming ? "Resuming…" : "Resume") + .font(.system(size: 13, weight: .semibold)) + } + .foregroundStyle(.white) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(ADEColor.accent, in: Capsule()) + } + .buttonStyle(.plain) + .disabled(controller.isResuming) + } + } + .padding(.horizontal, 12) + } + .padding(.top, 8) + .padding(.bottom, 8) + .background(ADEColor.recessedBackground.opacity(0.92)) + .overlay(alignment: .top) { + Rectangle().fill(ADEColor.glassBorder).frame(height: 0.5) + } + } + + // MARK: - Key bar + + private var keyBarStack: some View { + VStack(spacing: 6) { + if showReconnectingCaption || pasteboardHasStrings { + HStack { + if showReconnectingCaption { + Text("reconnecting…") + .font(.caption2.weight(.medium)) + .foregroundStyle(ADEColor.warning) + } + Spacer(minLength: 0) + if pasteboardHasStrings { + pasteChip + } + } + .padding(.horizontal, 12) + } + keyBar + .modifier(ADEShakeEffect(animatableData: CGFloat(controller.blockedInputPulse))) + .animation(.linear(duration: 0.3), value: controller.blockedInputPulse) + } + .padding(.top, 6) + .padding(.bottom, 6) + .background(ADEColor.recessedBackground.opacity(0.92)) + .overlay(alignment: .top) { + Rectangle().fill(ADEColor.glassBorder).frame(height: 0.5) + } + } + + private var pasteChip: some View { + Button { + controller.pasteFromClipboard() + } label: { + HStack(spacing: 5) { + Image(systemName: "doc.on.clipboard") + .font(.system(size: 10, weight: .semibold)) + Text("Paste") + .font(.system(size: 12, weight: .semibold)) + } + .foregroundStyle(ADEColor.accent) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(ADEColor.accent.opacity(0.12), in: Capsule()) + } + .buttonStyle(.plain) + } + + private var keyBar: some View { + HStack(spacing: 4) { + keyButton("esc", sends: "\u{1B}") + keyButton("⇥", sends: "\t") + // Back-tab (ESC[Z) — cycles permission modes in Claude Code. + keyButton("⇧⇥", sends: "\u{1B}[Z") + ctrlButton + keyButton("↑", sends: "\u{1B}[A") + keyButton("↓", sends: "\u{1B}[B") + keyButton("←", sends: "\u{1B}[D") + keyButton("→", sends: "\u{1B}[C") + keyButton("⏎", sends: "\r") + // Backslash + return: newline in agent TUIs (Claude Code treats `\` + // + Enter as soft return) and line continuation in shells. + keyButton("⇧⏎", sends: "\\\r") + overflowMenu + dismissKeyboardButton + } + .padding(.horizontal, 6) + .disabled(controller.hasExited) + .opacity(controller.hasExited ? 0.4 : 1) + } + + private func keyButton(_ label: String, sends data: String) -> some View { + Button { + controller.sendKeySequence(data) + } label: { + Text(label) + .font(.system(size: label.count > 1 && label != "esc" ? 11 : 13, weight: .semibold, design: .monospaced)) + .foregroundStyle(ADEColor.textPrimary) + .frame(maxWidth: .infinity, minHeight: 32) + .background(ADEColor.textPrimary.opacity(0.06), in: RoundedRectangle(cornerRadius: 7, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 7, style: .continuous) + .stroke(ADEColor.border.opacity(0.28), lineWidth: 0.6) + ) + } + .buttonStyle(.plain) + } + + private var dismissKeyboardButton: some View { + Button { + controller.dismissKeyboard() + } label: { + Image(systemName: "keyboard.chevron.compact.down") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(ADEColor.textPrimary) + .frame(maxWidth: .infinity, minHeight: 32) + .background(ADEColor.textPrimary.opacity(0.06), in: RoundedRectangle(cornerRadius: 7, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 7, style: .continuous) + .stroke(ADEColor.border.opacity(0.28), lineWidth: 0.6) + ) + } + .buttonStyle(.plain) + .accessibilityLabel("Hide keyboard") + } + + private var ctrlButton: some View { + Button { + controller.toggleCtrl() + } label: { + Text("⌃") + .font(.system(size: 14, weight: .bold, design: .monospaced)) + .foregroundStyle(controller.ctrlArmed ? Color.white : ADEColor.textPrimary) + .frame(maxWidth: .infinity, minHeight: 32) + .background( + controller.ctrlArmed ? AnyShapeStyle(ADEColor.accent) : AnyShapeStyle(ADEColor.textPrimary.opacity(0.06)), + in: RoundedRectangle(cornerRadius: 7, style: .continuous) + ) + .overlay( + RoundedRectangle(cornerRadius: 7, style: .continuous) + .stroke(controller.ctrlArmed ? ADEColor.accent : ADEColor.border.opacity(0.28), lineWidth: 0.6) + ) + } + .buttonStyle(.plain) + .accessibilityLabel(controller.ctrlArmed ? "Control armed" : "Control") + } + + private var overflowMenu: some View { + Menu { + overflowKey("^C", sends: "\u{03}") + overflowKey("^D", sends: "\u{04}") + overflowKey("^Z", sends: "\u{1A}") + overflowKey("^L", sends: "\u{0C}") + overflowKey("^R", sends: "\u{12}") + overflowKey("^U", sends: "\u{15}") + overflowKey("^A", sends: "\u{01}") + overflowKey("^E", sends: "\u{05}") + overflowKey("^K", sends: "\u{0B}") + Divider() + overflowKey("|", sends: "|") + overflowKey("~", sends: "~") + overflowKey("/", sends: "/") + overflowKey("-", sends: "-") + } label: { + Text("⋯") + .font(.system(size: 14, weight: .bold, design: .monospaced)) + .foregroundStyle(ADEColor.textPrimary) + .frame(maxWidth: .infinity, minHeight: 32) + .background(ADEColor.textPrimary.opacity(0.06), in: RoundedRectangle(cornerRadius: 7, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 7, style: .continuous) + .stroke(ADEColor.border.opacity(0.28), lineWidth: 0.6) + ) + } + .buttonStyle(.plain) + } + + private func overflowKey(_ label: String, sends data: String) -> some View { + Button(label) { + controller.sendKeySequence(data) + } + } +} + +/// Horizontal shake for the key bar when typed input has nowhere to go. +private struct ADEShakeEffect: GeometryEffect { + var animatableData: CGFloat + + func effectValue(size: CGSize) -> ProjectionTransform { + ProjectionTransform(CGAffineTransform(translationX: 7 * sin(animatableData * .pi * 3), y: 0)) + } +} + +/// Thin animated bar shown while older scrollback pages in. +private struct TerminalHistoryShimmer: View { + @State private var phase: CGFloat = -1 + + var body: some View { + GeometryReader { proxy in + Rectangle() + .fill( + LinearGradient( + colors: [.clear, ADEColor.accent.opacity(0.85), .clear], + startPoint: .leading, + endPoint: .trailing + ) + ) + .frame(width: proxy.size.width * 0.45) + .offset(x: phase * proxy.size.width) + } + .frame(height: 2.5) + .clipped() + .onAppear { + phase = -0.45 + withAnimation(.linear(duration: 0.9).repeatForever(autoreverses: false)) { + phase = 1 + } + } + } +} diff --git a/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift b/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift index 938c8cb50..9136d1345 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift @@ -242,10 +242,18 @@ struct WorkSessionDestinationView: View { transitionNamespace == nil ? nil : "work-container-\(sessionId)" } + /// Terminal sessions render `TerminalSessionScreen`, which brings its own + /// slim full-bleed top bar — the shared pushed-detail chrome would stack a + /// second header on top of it. + private var isFullScreenTerminalSession: Bool { + guard let current = session ?? initialSession else { return false } + return !isChatSession(current) + } + var body: some View { sessionDestinationRoot .workSessionNavigationChrome( - mode: navigationChrome, + mode: isFullScreenTerminalSession ? .embedded : navigationChrome, title: sessionDestinationNavigationTitle, trailingControls: { sessionHeaderTrailingControls } ) @@ -346,12 +354,8 @@ struct WorkSessionDestinationView: View { lanes: lanes ) } else { - WorkTerminalSessionView( - session: session, - transitionNamespace: transitionNamespace, - onOpenLane: showsLaneActions ? openSessionLane : nil - ) - .environmentObject(syncService) + TerminalSessionScreen(session: session) + .environmentObject(syncService) } } else { ADEEmptyStateView( @@ -456,7 +460,11 @@ struct WorkSessionDestinationView: View { fallbackTranscript = makeWorkChatTranscript(from: response.entries, sessionId: sessionId) } - if forceRemote && !preferLightweight { + // Chat-only fallback: parses chat envelopes out of the raw terminal buffer. + // Terminal sessions own their subscription via TerminalSessionScreen's + // offset stream; a preview-budget subscribe here would race a second + // replace-snapshot into that stream. + if forceRemote && !preferLightweight, let currentSession = session ?? initialSession, isChatSession(currentSession) { try? await syncService.subscribeTerminal(sessionId: sessionId) let raw = syncService.terminalBuffers[sessionId] ?? "" let parsed = parseWorkChatTranscript(raw) diff --git a/docs/features/sync-and-multi-device/README.md b/docs/features/sync-and-multi-device/README.md index 8cf1e8eae..219d546d0 100644 --- a/docs/features/sync-and-multi-device/README.md +++ b/docs/features/sync-and-multi-device/README.md @@ -160,10 +160,13 @@ Canonical files (`apps/ade-cli/src/services/sync/`): `budget_usage_records`, `automation_runs`, `automation_action_results` — are filtered from phone changesets while ack watermarks still advance), the per-session chat-event seq - + replay buffer, terminal/chat subscription bridging, mobile - terminal input/resize forwarding into subscribed PTYs, lane presence - decoration, project catalog/switch envelopes, per-IP pairing rate - limiter, and the Tailscale Serve / mDNS publication paths. Runtime + + replay buffer, terminal/chat subscription bridging, offset-stamped + mobile terminal streams, `sinceOffset` delta snapshots, scrollback + paging via `terminal_history`, mobile terminal input/resize forwarding + into subscribed PTYs, desktop-size restore after the last phone + detaches, lane presence decoration, project catalog/switch envelopes, + per-IP pairing rate limiter, and the Tailscale Serve / mDNS + publication paths. Runtime kind is one of `desktop-embedded`, `headless`, `remote-stdio`, `desktop`, `daemon`, or `remote`. - `sharedSyncListener.ts` — the brain-level WebSocket listener shared @@ -509,7 +512,7 @@ Envelopes are JSON with fields: "heartbeat" | "file_request" | "file_response" | "terminal_subscribe" | "terminal_unsubscribe" | "terminal_snapshot" | "terminal_data" | "terminal_exit" | - "terminal_input" | "terminal_resize" | + "terminal_input" | "terminal_resize" | "terminal_history" | "chat_subscribe" | "chat_unsubscribe" | "chat_event" | "brain_status" | "project_catalog_request" | "project_catalog" | diff --git a/docs/features/sync-and-multi-device/ios-companion.md b/docs/features/sync-and-multi-device/ios-companion.md index e79f2687a..fa52165f4 100644 --- a/docs/features/sync-and-multi-device/ios-companion.md +++ b/docs/features/sync-and-multi-device/ios-companion.md @@ -114,8 +114,10 @@ apps/ios/ │ │ ├── Work/ # WorkRootScreen, WorkChatSessionView, │ │ │ # Work*Helpers, WorkNewChatScreen (chat/CLI │ │ │ # segmented launcher), WorkArtifactTerminalViews, -│ │ │ # WorkTerminalEmulatorView (UIKit-backed monospaced -│ │ │ # terminal screen + viewport reporter), +│ │ │ # TerminalSessionScreen + SwiftTermSessionView +│ │ │ # (full-screen SwiftTerm terminal, +│ │ │ # offset resume/history paging + +│ │ │ # viewport reporter), │ │ │ # WorkSessionDestination*, │ │ │ # WorkRootScreen+Selection (multi-select state + │ │ │ # bulk close/archive/restore/delete/export), @@ -361,8 +363,9 @@ Implemented envelope types on iOS: | `command_ack` | Runtime → phone | Command receipt | | `command_result` | Runtime → phone | Execution result or error | | `file_request` / `file_response` | Bidirectional | On-demand file access | -| `terminal_subscribe` / `terminal_unsubscribe` / `terminal_data` | Phone ↔ runtime | Terminal streaming; `unsubscribe` is sent when a Work terminal screen disappears so the phone stops accumulating buffer for off-screen sessions | -| `terminal_input` / `terminal_resize` | Phone → runtime | Raw input bytes and viewport size changes for a subscribed live PTY | +| `terminal_subscribe` / `terminal_unsubscribe` / `terminal_data` | Phone ↔ runtime | Terminal streaming; `unsubscribe` is sent when a Work terminal screen disappears so the phone stops accumulating buffer for off-screen sessions. `terminal_data` carries `offset` — the transcript's end byte offset after the chunk (null when the session has no transcript or hit the size cap) — so the phone can detect dropped chunks. `terminal_subscribe` accepts `sinceOffset`; when the runtime can serve exactly `sinceOffset → end` within the byte budget it replies with a `delta: true` snapshot (append, don't replace), giving exact back-fill after reconnects/gaps. Snapshots also report `startOffset`/`endOffset`, plus `live: false` when no PTY backs the session (ended, or orphaned by a brain restart while status still says running) so the phone shows a resume bar instead of silently accepting keystrokes | +| `terminal_history` | Phone → runtime | On-demand scrollback paging: `{ sessionId, beforeOffset, maxBytes? }` returns transcript bytes `[startOffset, endOffset)` ending at/before `beforeOffset` (page start scanned forward to a newline/ESC boundary; `atStart: true` at beginning of transcript). Requires an active `terminal_subscribe` | +| `terminal_input` / `terminal_resize` | Phone → runtime | Raw input bytes and viewport size changes for a subscribed live PTY. Mobile resizes are non-authoritative: the runtime records the last desktop-originated size and restores it when the last subscribed phone detaches | | `chat_subscribe` / `chat_event` | Phone → runtime / runtime → phone | Agent chat transcript streaming; `chat_subscribe` carries `sinceSeq` so the runtime can replay exactly the missed events from its per-session buffer instead of re-sending a snapshot | | `envelope_chunk` | Runtime → phone | Slice of an oversized encoded envelope (>720 KB); the phone reassembles by `chunkId`/`index` before normal decode | | `heartbeat` | Bidirectional | Connection health (30s) | @@ -746,7 +749,7 @@ duplicate. Project list dedup runs as a final pass |---|---|---|---| | **Lanes** | `square.stack.3d.up` | `/lanes` | Full lane surface: search/filter chips, open/create/attach/manage, multi-attach for unregistered worktrees, stack canvas, git/diff/rebase/conflicts, template-backed environment setup progress, lane-scoped sessions and AI chats. `devicesOpen` presence chips show which other devices currently have the lane open. The lane gear opens `LaneAdvancedScreen`, a single page that groups Manage / Switch branch / Stash and the destructive git escape hatches (rebase lane, rebase descendants, rebase + push, force push) with an inline description per row and an offline disabled banner. The commit sheet (`LaneCommitSheet`) renders staged + unstaged file lists with per-file stage / unstage / discard / restore / open-diff / open-files actions, a "Suggest" AI button gated by runtime capability, and a setup-hint card surfaced when the runtime returns "AI commit messages are off". | | **Files** | `doc.text` | `/files` | Lane-backed workspace picker, live file tree/search/read, protected-workspace read-only parity. `mobileReadOnly` on the workspace payload gates mutating file actions on the phone via `ensureMobileFileMutationsAllowed`; quick-open and text-search result lists cap visible rows at 40 and ask the user to refine when more matches exist. | -| **Work** | `terminal` | `/work` | Terminal + chat session list, cached history with persisted lane names, output streaming, character-by-character terminal input (Termius-style: each typed glyph forwards a single `terminal_input` byte and the field clears so PTY echo is the only source of truth), Ctrl-C forwarding for subscribed live PTYs, in-app CLI session launcher (Claude / Codex / Cursor / OpenCode / Droid / shell), message-to-continue on ended agent CLI rows, session pinning, live chat-event push from the runtime (no polling lag once subscribed). The new-session screen (`WorkNewChatScreen`) toggles between **ADE chat** and **CLI session** via a segmented picker; in CLI mode a `workCliProviderOptions` row picker exposes each supported provider explicitly. CLI mode submits `work.startCliSession` with the chosen provider, permission mode (Claude additionally supports `auto`), an optional `reasoningEffort`, and an optional opening message. For most providers the runtime types the opening message into the spawned PTY; for Codex the opening message is forwarded as the final argv positional through `buildTrackedCliLaunchCommand`, so the prompt is treated as a real first turn instead of a typed shell line. The terminal viewer (`WorkTerminalEmulatorView`) is a UIKit-backed monospaced screen that drives a `WorkTerminalScreen` model, computes its viewport in (cols, rows) from the rendered glyph cell, forwards each viewport change as `terminal_resize`, and unsubscribes via `terminal_unsubscribe` when the screen disappears. The earlier "activity feed" section was retired — running chats are surfaced through the session list and a Work tab badge bound to `SyncService.runningChatSessionCount`. | +| **Work** | `terminal` | `/work` | Terminal + chat session list, cached history with persisted lane names, output streaming, native key-passthrough terminal input (keystrokes from the iOS keyboard flow straight into the PTY as `terminal_input`, coalesced ~16 ms; PTY echo is the only source of truth), Ctrl-C forwarding for subscribed live PTYs, in-app CLI session launcher (Claude / Codex / Cursor / OpenCode / Droid / shell), message-to-continue on ended agent CLI rows, session pinning, live chat-event push from the runtime (no polling lag once subscribed). The new-session screen (`WorkNewChatScreen`) toggles between **ADE chat** and **CLI session** via a segmented picker; in CLI mode a `workCliProviderOptions` row picker exposes each supported provider explicitly. CLI mode submits `work.startCliSession` with the chosen provider, permission mode (Claude additionally supports `auto`), an optional `reasoningEffort`, and an optional opening message. For most providers the runtime types the opening message into the spawned PTY; for Codex the opening message is forwarded as the final argv positional through `buildTrackedCliLaunchCommand`, so the prompt is treated as a real first turn instead of a typed shell line. The terminal viewer (`TerminalSessionScreen` + `SwiftTermSessionView`) is a full-bleed SwiftTerm (real VT100/xterm) emulator: tap-to-focus raises the iOS keyboard for direct passthrough, a single-row key bar provides esc/tab/latching-Ctrl/arrows/return plus an overflow menu, pinch adjusts font size, and the phone owns the PTY's cols×rows while the screen is open (sent as `terminal_resize`; the runtime restores the desktop size on detach). Live output streams via offset-stamped `terminal_data` with gap detection + `sinceOffset` delta resume (no snapshot polling); scrolling near the top auto-pages older transcript via `terminal_history`, and a floating "↓ Live N" pill snaps back to the live tail. When the hosted program enables mouse reporting (Claude Code, htop), vertical pans are translated into SGR wheel events so the TUI scrolls itself; mouse-off sessions scroll native scrollback. Against pre-offset hosts (older brains, whose PTY→sync bridge never pushed terminal output) the screen detects the missing offsets and falls back to a 2s tail-refresh poll until offsets appear. The screen unsubscribes via `terminal_unsubscribe` on disappear. The legacy `WorkTerminalEmulatorView`/`WorkTerminalScreen` mini-parser remains only for inline preview cards. The earlier "activity feed" section was retired — running chats are surfaced through the session list and a Work tab badge bound to `SyncService.runningChatSessionCount`. | | **PRs** | `arrow.triangle.pull` | `/prs` | PR list/detail driven by `prs.getMobileSnapshot`: stack visibility (`PrStackSheet`), create-PR wizard (`CreatePrWizardView`) gated by per-lane eligibility, workflow cards (queue / integration / rebase) rendered from `PrWorkflowCard`, per-PR action capabilities. | | **CTO** | `brain.head.profile` | `/cto` | CTO snapshot: Chat / Team / Workflows segments, with the mobile workflows screen mirroring the desktop workflow policy/dashboard and preserving the shared glass navigation chrome. Drills into per-worker chat sessions via `CtoSessionDestinationView`. | | **Settings** | `gearshape` | `/settings` (sync subset) | PIN pairing (`SettingsPinSheet`), notification preferences (`NotificationsCenterView`), quiet hours, per-session overrides, appearance, diagnostics, connection header with QR payload and address candidates, reconnect, forget. `ConnectionSettingsView` binds to `SettingsConnectionPresentationModel`, which feeds plain `SettingsConnectionSnapshot` / `SettingsPairingSnapshot` / `SettingsDiagnosticsSnapshot` DTOs into the section views (`SettingsConnectionHeader`, `SettingsPairingSection`, `SettingsDiagnosticsSection`) instead of having them reach into `SyncService` directly. `sendTestPush` is now `async` and returns a `SyncSendTestPushResult` (`ok`, `message`); the Notifications section renders that message verbatim so APNs-not-configured / in-app-only / wire failure cases all surface to the user. | @@ -1055,17 +1058,18 @@ reflected in the phone's UI on the next descriptor read. message, and sends it with the durable `sessionId`. The runtime writes to a live PTY when present, or starts the provider continuation internally and attaches the new PTY to the same session row. -- **`WorkTerminalEmulatorView` drives a monospaced grid, not a free - text view.** The viewport reported back to the runtime is in (cols, - rows) inferred from the rendered glyph cell, not pixel dimensions. - The emulator unsubscribes the runtime stream on `onDisappear` so a - user paging through the session list does not accumulate buffer - bytes for off-screen sessions; `restoreTerminalSubscriptions` - re-subscribes on reconnect for any session id still tracked in - `subscribedTerminalSessionIds`. Terminal snapshots request up to - 240 KB and local buffers trim at roughly 240,000 characters, keeping - recent CLI output available without letting an off-screen PTY grow - the mobile buffer indefinitely. +- **`TerminalSessionScreen` + `SwiftTermSessionView` drive a real + SwiftTerm grid, not a free text view.** The viewport reported back to + the runtime is in (cols, rows) inferred from the rendered glyph cell, + not pixel dimensions. The terminal unsubscribes the runtime stream on + `onDisappear` so a user paging through the session list does not keep + a phone-owned viewport attached; `restoreTerminalSubscriptions` + re-subscribes on reconnect with the last known transcript end offset + for any session id still tracked in `subscribedTerminalSessionIds`. + Terminal snapshots request up to 240 KB for legacy hosts; offset-aware + hosts use `sinceOffset` delta snapshots and `terminal_history` pages + so the phone can keep older scrollback without reloading the whole + tail. - **Lane presence is best-effort with a TTL.** The phone re-announces on a 30 s cadence; the runtime prunes stale entries at 60 s. A phone that crashes without sending `lanes.presence.release` diff --git a/docs/features/terminals-and-sessions/README.md b/docs/features/terminals-and-sessions/README.md index 260d1781f..2a03229ff 100644 --- a/docs/features/terminals-and-sessions/README.md +++ b/docs/features/terminals-and-sessions/README.md @@ -40,14 +40,17 @@ desktop in-process path used before a binding exists, in diagnostics, and in tests. - `apps/desktop/src/main/services/pty/ptyService.ts` — PTY lifecycle, - transcript capture (capped at `MAX_TRANSCRIPT_BYTES = 64 MB`), runtime + transcript capture (capped at `MAX_TRANSCRIPT_BYTES = 16 MB`), runtime state, AI auto-titles, tool-type routing, continuation-target backfill, session-id based write/resize entry points used by mobile sync terminal control, `readTranscriptTail({ sessionId, ... })` which merges the on-disk transcript tail with the live PTY output tail so Work/TUI terminal hydration can replay output that is still buffered - in the transcript write stream, agent CLI input protocol (bracketed - paste, chunked writes, provider-specific submit delays), process tree + in the transcript write stream, `readTranscriptRange({ sessionId, + startOffset, endOffset })` for mobile scrollback/delta resume, + offset-stamped PTY data batches, desktop-size restore after + mobile-driven resizes, agent CLI input protocol (bracketed paste, + chunked writes, provider-specific submit delays), process tree termination (`terminatePtyProcessTree` walks descendant PIDs via `pgrep` and escalates to `SIGKILL` after a grace timer), live session row resync (re-opens rows that drifted to `ended` while the PTY is @@ -111,6 +114,7 @@ Shared types and IPC: - `apps/desktop/src/shared/types/sessions.ts` — `TerminalSessionSummary`, `TerminalSessionStatus`, `TerminalToolType`, `TerminalRuntimeState`, `TerminalResumeMetadata`, `PtyCreateArgs`, `SessionDeltaSummary`, + offset-stamped `PtyDataEvent`, `PtySendToSessionArgs` / `PtySendToSessionResult` (the send-or-continue surface), `PtyResumeSessionArgs` / `PtyResumeSessionResult` (prompt-free tracked CLI relaunch), the rich `ChatTerminalSession` / @@ -131,8 +135,11 @@ Shared types and IPC: `ade.localhost.probePort`. - `apps/desktop/src/shared/types/sync.ts` — terminal stream/control envelopes (`terminal_subscribe`, `terminal_unsubscribe`, - `terminal_data`, `terminal_exit`, `terminal_input`, `terminal_resize`) - for iOS Work surfaces, plus the mobile CLI launcher payload + `terminal_snapshot`, `terminal_data`, `terminal_history`, + `terminal_exit`, `terminal_input`, `terminal_resize`) for iOS Work + surfaces, including transcript offsets, `sinceOffset` delta resume, + `live` backing-PTY status, and pull-to-load-older history pages, plus + the mobile CLI launcher payload (`SyncCliLaunchProvider`, `SyncStartCliSessionArgs`, `SyncStartCliSessionResult`) consumed by the `work.startCliSession` remote command. @@ -496,15 +503,15 @@ iOS Work surfaces: row action. The earlier in-list activity feed is gone — running chats surface through the session list and the live-count chip. +- `apps/ios/ADE/Views/Work/TerminalSessionScreen.swift` and + `SwiftTermSessionView.swift` — full-screen SwiftTerm-backed terminal + surface for CLI sessions. It subscribes with `sinceOffset`, applies + offset-stamped `terminal_data`, pages older transcript bytes via + `terminal_history`, sends raw `terminal_input`, reports viewport + changes as `terminal_resize`, and unsubscribes on disappear. - `apps/ios/ADE/Views/Work/WorkArtifactTerminalViews.swift` — - terminal artifact/output views and the compact input bar that sends - `terminal_input` bytes and Ctrl-C to the subscribed host PTY. Hosts - the new emulator surface and unsubscribes via - `SyncService.unsubscribeTerminal` on view disappear. -- `apps/ios/ADE/Views/Work/WorkTerminalEmulatorView.swift` — - UIKit-backed monospaced terminal screen + `WorkTerminalScreen` - model that reports its viewport in (cols, rows) so the host can - resize the PTY to the phone's actual rendered grid. + terminal artifact/output views and inline preview cards; the older + lightweight terminal emulator remains here only for compact previews. - `apps/ios/ADE/Views/Work/WorkChatSessionView.swift`, `WorkChatComposerAndInputViews.swift`, `WorkChatRichCardViews.swift`, `WorkReasoningCard.swift`, `WorkNewChatScreen.swift` — mobile chat, @@ -574,9 +581,10 @@ See `apps/desktop/src/shared/types/sessions.ts` for the full shape. `initialInputDelayMs` delay. 2. **Stream** — PTY `data` events are written to the transcript - (capped at `MAX_TRANSCRIPT_BYTES = 64 MB`), throttled into a - `lastOutputPreview`, forwarded to `broadcastData`, and scanned for - runtime state signals (OSC 133 prompt markers). + (capped at `MAX_TRANSCRIPT_BYTES = 16 MB`), throttled into a + `lastOutputPreview`, forwarded to `broadcastData` with the transcript + end offset when available, and scanned for runtime state signals + (OSC 133 prompt markers). 3. **Tag** — the tool type is inferred or passed by the renderer. Claude/Codex sessions also get a best-effort `--session-id` extraction @@ -757,9 +765,10 @@ Processes (managed): falls back to `defaultResumeCommandForTool(toolType)`. Editing it directly is only allowed through `sessionService.setResumeCommand` or `updateMeta`, both of which re-derive the metadata. -- Transcript writes are capped at 64 MB; after the cap a notice line is - written once and further output is dropped. The runtime counter - `transcriptBytesWritten` is not persisted. +- Transcript writes are capped at 16 MB; after the cap a notice line is + written once and further output is dropped. The runtime seeds + `transcriptBytesWritten` from the file size on attach, so the cap + survives resume. - Preview updates are throttled (~900 ms) and the string is capped at 220 chars via `derivePreviewFromChunk`. - Reconcile and dispose paths gate on `processRegistryService` live and diff --git a/docs/features/terminals-and-sessions/pty-and-processes.md b/docs/features/terminals-and-sessions/pty-and-processes.md index 889fcab16..92cba9b15 100644 --- a/docs/features/terminals-and-sessions/pty-and-processes.md +++ b/docs/features/terminals-and-sessions/pty-and-processes.md @@ -136,7 +136,7 @@ Each live PTY has an entry in the `ptys` map keyed by `ptyId` with: - `pty` (node-pty handle), `laneId`, `laneWorktreePath`, `boundCwd`, `sessionId`, `tracked` - transcript: `transcriptPath`, `transcriptStream`, - `transcriptBytesWritten`, `transcriptLimitReached` (64 MB cap from + `transcriptBytesWritten`, `transcriptLimitReached` (16 MB cap from `MAX_TRANSCRIPT_BYTES`) - preview: `lastPreviewWriteAt`, `previewCurrentLine`, `latestPreviewLine`, `lastPreviewWritten` @@ -259,9 +259,32 @@ OpenCode rendering inside Work tabs. ### Data, preview, and runtime state `writeTranscript(entry, data)` writes to the append-mode write stream. -Once the 64 MB cap is hit it writes a single notice line and drops -further output. Bytes written are not persisted, so the cap resets on -reattach. +Once the 16 MB cap (`MAX_TRANSCRIPT_BYTES`) is hit it writes a single +notice line and drops further output. `transcriptBytesWritten` is +seeded from the file size on (re)attach, so the cap survives resume. + +`transcriptBytesWritten` doubles as the transcript byte cursor for +mobile streaming: each batched PTY data emission carries `offset` — +the transcript's end byte offset after the batch (null once the cap is +reached, the write stream fails, or the session is untracked). The +transcript write and the data-batch enqueue run in the same `onData` +handler, so the cursor at flush time is exact. Note the fs.WriteStream +buffers, so disk can lag the cursor by a few ms; range reads clamp to +the flushed file size and report achieved offsets. + +`readTranscriptRange({ sessionId, startOffset, endOffset })` reads a +byte range from the transcript for scrollback paging (the sync host's +`terminal_history`), scanning the page start forward to a newline/ESC +boundary (and past UTF-8 continuation bytes) so a page never begins +mid-escape-sequence. + +Resize ownership: the ptyId-based `resize(...)` path (desktop +renderer) records `lastDesktopCols/Rows` on the entry; +`resizeBySessionId(..., { source: "mobile" })` does not. +`restoreDesktopSizeBySessionId(sessionId)` puts the PTY back to the +recorded desktop size — the sync host calls it when the last +subscribed phone detaches, so a phone-fitted 45-column reflow doesn't +linger on desktop. `updatePreviewThrottled` uses `derivePreviewFromChunk` to track the last non-empty line, capped at 220 chars. Preview is flushed to