Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 141 additions & 0 deletions docs/superpowers/specs/2026-07-06-remote-semantic-rendering-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
# Remote Semantic Rendering — the desktop feed on the phone

**Date:** 2026-07-06
**Status:** Approved direction (user delegated approach); implementation in progress
**Parent:** 2026-07-06-remote-mobile-companion-design.md · issue #420

## Problem

The shipped phone client renders the provider's raw TUI text. Correct, but it
discards the product's defining feature: the custom React transcript
rendering. The phone must render sessions with the SAME feed the desktop
shows — prose markdown, tool cards, diffs, todos, thinking blocks, streaming
turns, subagent cards — with no second renderer to drift.

## Decision: import the desktop feed, don't reimplement it

Three candidate architectures were evaluated against a full dependency audit
of the feed subtree (four parallel deep-reads; findings below):

1. **Reimplement mobile rows** sharing only parsers — permanent double
rendering tax, guaranteed drift. Rejected: it is exactly the failure mode
the MANIFESTO calls out.
2. **Vendor a copy of the feed** (what testing/rendering does) — same drift
problem one directory over. Rejected.
3. **Import the REAL `@renderer` feed subtree into the phone bundle, with
Vite-alias substitution for the (exactly four) Electron/desktop-coupled
modules.** Chosen.

Why 3 is safe — the audit's load-bearing facts:

- `Feed.tsx` reads NOTHING from Electron or the app store itself; it renders
from plain props (entries, semanticTurn/History, tool indices, subAgents,
streamPhase…). TileLeaf.tsx:475-573 is the complete runtime→props map.
- `renderModel.ts` / `renderUnits.ts` (ordering + ownership/suppression — the
hardest correctness logic) are pure.
- Every non-portable edge funnels through four modules, each cleanly
substitutable at BUILD level via vite aliases:
| desktop module | coupling | phone substitute |
|---|---|---|
| `@renderer/lib/code/CodeBlock` | monaco (dynamic import), LSP via window.api | static-only CodeBlock: same props, hljs `highlight`/`highlightAuto`, same `hljs` class markup (byte-identical tokens to the desktop's static engine) |
| `@renderer/app-state/hooks` | zustand app store (ONE read: `settings.customRendering`, Block.tsx:55) | stub `useAppStore(selector)` over a static defaults object |
| `@renderer/performance/client` | perf IPC | no-op metrics |
| `SafeMarkdownLink` / `SafeInlineCode` | global editor, toast, window.api external-open | plain `<a target="_blank">` / `<code>` |
- Styling: the components are Tailwind-4 classed against the app theme
tokens. The phone bundle adopts `@tailwindcss/vite` + imports
`@renderer/styles.css` (the rendering harness proves this works outside the
main app), keeping the existing mobile chrome CSS for the shell around the
feed.
- Monaco is NOT dragged in: its import is dynamic inside the desktop
CodeBlock, and that file is aliased away entirely. The desktop's own
markdown fences/diffs/git output already use the static hljs engine, so
phone output matches the majority of desktop surfaces exactly.

**The alias-substitution list is the phone's compatibility contract.** Every
alias is a declared, documented divergence; anything NOT aliased renders with
literally the same component the desktop uses. Adding an alias = a conscious
decision recorded in the vite config's comments.

## Client state model (the minimal SessionRuntime)

The phone maintains, per session (audit: workspaceState.ts:285-671 with the
feed-consumed field analysis):

- `entries: Entry[]` — mapper-produced, seen-uuid deduped
- `semantic: { currentTurn, history }` via the REAL `foldSemanticEvent`
(pure; React-coupled only through the provider registry, fine in a React
bundle)
- `streamPhase` + pending tool name/id + `turnStartedAt` — reduced OUTSIDE
the fold, mirroring useIpcSubscriptions.ts:939-1028 (stream_phase events +
turn_started/turn_completed bridge)
- `toolUseIndex` / `toolResultIndex` / `toolIndexVersion` via
`indexEntryIntoMaps`
- `subAgents`, `conditions` (already on the wire)
- `lastJsonlEntryAt`, `historyOldestMarker`, `hasOlderHistory`, `totalEntries`
- per-session codex mapper turn cursor (the stateful mapper's cursor must
survive across WS bursts, mirroring `codexCurrentTurnIdBySession`)

Deliberately SKIPPED subsystems (each returns identity/no-op when absent):
- **ghosts** — crash-recovery/JSONL-stall fallback only; with an empty Map,
`selectMergedEntries` returns `entries` by identity. Revisit only if
stalled-stream complaints show up on phone.
- screen buffers (feed no longer reads them), feed-debug, ghosts journal,
optimistic user echo (phone has no local composer echo yet), provider
quarantine (single-consumer stream).

Ingest discipline (must mirror the desktop exactly — audit §5/6):
- ONE seen-uuid Set per session gates BOTH backfill and live entries.
- History PREPENDS, live APPENDS.
- Pagination anchor = first kept entry's historyMarker; `totalEntries`
authoritative only from the initial chunk.

## History over the wire

New read-only inbound message (scope widening, scope.test.ts updated in the
same diff):

```
{ type: 'get-history', sessionId, beforeMarker?, limit? }
→ reply result: { entries: RawRecord[], hasMore, totalEntries? }
```

Main-side key resolution: SessionManager cannot produce
`{cwd, providerSessionId}` for a live session (cwd is constructor-private;
providerSessionId only exists inside the jsonl). But the transcript FILE
path rides every `jsonl-entry` event. So:

- SessionManager gains a `lastTranscriptFile` cache (same pattern + lifetime
as the screen/conditions snapshot caches added for late-joiner priming;
fed at the jsonl-entry relay, cleared in cleanupSessionState) + getter.
Because SessionManager lives from app boot, this covers sessions started
long before remote was enabled.
- historyLoader gains path-based variants
(`loadInitialHistoryChunkFromFile` / `loadOlderHistoryChunkFromFile`) —
a refactor extracting the existing post-path-resolution body; the
{kind,cwd,providerSessionId} entrypoints become thin wrappers. No behavior
change for the desktop callers.
- RemoteServer's `get-history` handler = getTranscriptFile + kind → chunk.
Raw records go over the wire in the live `{entry, file}` shape so the
phone's ONE mapper path serves both backfill and live.

## Composer / conditions (unchanged scope)

The v1 control scope is untouched: prompts via deliverPromptToAgent,
interrupt, condition replies. The phone's condition tap-targets stay (they
already fail closed via the server-side live-menu check); adopting the
desktop's ProviderConditionOutlet is a possible later refinement, not part
of this slice.

## Risks / accepted costs

- **Bundle size**: react-markdown + remark + highlight.js + the feed tree.
Estimated ~350-450 KB gzip (no monaco). Acceptable for a LAN/tunnel tool;
measure at build and record.
- **Alias fragility**: a desktop refactor that moves one of the four aliased
module paths breaks the phone build LOUDLY (vite resolve error) — which is
the correct failure mode; the fix is updating one alias.
- **tsconfig coverage**: remote-client is type-checked under tsconfig.web
with the REAL desktop modules (aliases are build-time only), so tsc keeps
checking the true desktop types; the stubs are checked as standalone
modules. The stub-vs-real interface contract is pinned by a test comparing
the stub CodeBlock's props type to the desktop's.
1 change: 1 addition & 0 deletions src/main/remote/RemoteController.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ function makeManager(): FakeManager {
emitter.list = vi.fn(() => [])
emitter.getScreenSnapshot = vi.fn(() => null)
emitter.getConditionsSnapshot = vi.fn(() => null)
emitter.getTranscriptFile = vi.fn(() => null)
emitter.write = vi.fn(() => true)
emitter.resolveCondition = vi.fn(async () => ({ ok: true as const }))
emitter.deliverPromptToAgent = vi.fn(async () => ({ ok: true as const }))
Expand Down
62 changes: 61 additions & 1 deletion src/main/remote/RemoteServer.integration.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { EventEmitter } from 'node:events'
import { randomBytes } from 'node:crypto'
import { mkdtemp, rm } from 'node:fs/promises'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { WebSocket } from 'ws'
Expand All @@ -24,6 +24,7 @@ function makeManager(): FakeManager {
emitter.list = vi.fn(() => [])
emitter.getScreenSnapshot = vi.fn(() => null)
emitter.getConditionsSnapshot = vi.fn(() => null)
emitter.getTranscriptFile = vi.fn(() => null)
emitter.write = vi.fn(() => true)
emitter.resolveCondition = vi.fn(async () => ({ ok: true as const }))
emitter.deliverPromptToAgent = vi.fn(async () => ({ ok: true as const }))
Expand Down Expand Up @@ -318,6 +319,65 @@ describe('inbound scope enforcement on a live socket', () => {
ws.close()
})

it('get-history serves the newest chunk from the cached transcript file', async () => {
// Write a real jsonl transcript and point the manager's file cache at
// it — the exact shape RemoteServer reads for a live session.
const transcript = join(dir, 'session.jsonl')
const lines = Array.from({ length: 5 }, (_, i) =>
JSON.stringify({
type: 'user',
uuid: `u-${i}`,
message: { role: 'user', content: `prompt ${i}` },
}),
)
await writeFile(transcript, lines.join('\n') + '\n', 'utf8')
;(manager.getTranscriptFile as ReturnType<typeof vi.fn>).mockReturnValue(transcript)

const { ws, frames, token } = await openAuthed()
ws.send(JSON.stringify({
token, id: 'h1',
message: { type: 'get-history', sessionId: 's1', limit: 3 },
}))
await waitFor(frames, f => framesOfType(f, 'reply').length > 0)
const reply = framesOfType(frames, 'reply')[0] as {
ok: boolean
result: { entries: Array<{ uuid: string }>; hasMore: boolean; totalEntries: number }
}
expect(reply.ok).toBe(true)
// Newest `limit` records, in file order.
expect(reply.result.entries.map(e => e.uuid)).toEqual(['u-2', 'u-3', 'u-4'])
expect(reply.result.hasMore).toBe(true)
expect(reply.result.totalEntries).toBe(5)

// Older page anchored on the first entry of the previous chunk.
ws.send(JSON.stringify({
token, id: 'h2',
message: { type: 'get-history', sessionId: 's1', beforeMarker: 'u-2', limit: 3 },
}))
await waitFor(frames, f => framesOfType(f, 'reply').length >= 2)
const older = framesOfType(frames, 'reply')[1] as {
ok: boolean
result: { entries: Array<{ uuid: string }>; hasMore: boolean }
}
expect(older.ok).toBe(true)
expect(older.result.entries.map(e => e.uuid)).toEqual(['u-0', 'u-1'])
expect(older.result.hasMore).toBe(false)
ws.close()
})

it('get-history fails cleanly before any transcript exists', async () => {
const { ws, frames, token } = await openAuthed()
ws.send(JSON.stringify({
token, id: 'h1',
message: { type: 'get-history', sessionId: 's1' },
}))
await waitFor(frames, f => framesOfType(f, 'reply').length > 0)
expect(framesOfType(frames, 'reply')[0]).toMatchObject({
ok: false, error: expect.stringContaining('no transcript'),
})
ws.close()
})

it('a revoked device is cut off mid-session', async () => {
const { ws, frames, token } = await openAuthed()
const verdict = pairing.verifyToken(token)
Expand Down
49 changes: 48 additions & 1 deletion src/main/remote/RemoteServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ import type { DeviceRegistry } from '@main/remote/auth/deviceRegistry.js'
import { parseInboundFrame } from '@main/remote/protocol/scope.js'
import type { InboundFrame, OutboundFrame } from '@main/remote/protocol/messages.js'
import type { FeedChannel, SessionFeedSource } from '@main/remote/SessionFeedSource.js'
import {
loadInitialHistoryChunkFromFile,
loadOlderHistoryChunkFromFile,
} from '@main/sessions/historyLoader.js'
import type { RemoteTransport } from '@main/remote/transport/RemoteTransport.js'

// RemoteServer — the HTTP+WS host that makes Agent Code controllable from a
Expand Down Expand Up @@ -54,6 +58,7 @@ export type RemoteSessionControl = {
list(): string[]
getScreenSnapshot(sessionId: string): unknown
getConditionsSnapshot(sessionId: string): unknown
getTranscriptFile(sessionId: string): string | null
write(sessionId: string, data: string): boolean
resolveCondition(
sessionId: string,
Expand Down Expand Up @@ -417,7 +422,17 @@ export class RemoteServer extends EventEmitter {
}
void this.deps.registry.touch(verdict.deviceId)

const result = await this.apply(frame)
// apply() reaches disk (get-history) and provider runtimes; a thrown
// error must become a structured reply, not an unhandled rejection that
// leaves the phone's request waiting out its 10s timeout (review
// finding). Never echo raw error internals to an untrusted socket.
let result: { ok: boolean; error?: string; result?: unknown }
try {
result = await this.apply(frame)
} catch (err) {
this.deps.journal?.recordError('remote_apply.error', err)
result = { ok: false, error: 'internal error applying message' }
}
this.send(ws, { type: 'reply', id: frame.id, ...result })
}

Expand Down Expand Up @@ -453,6 +468,38 @@ export class RemoteServer extends EventEmitter {

case 'permission-reply':
return this.applyPermissionReply(msg.sessionId, msg.action)

case 'get-history': {
// Read-only backfill. The transcript file is the one key main can
// actually resolve for a live session (cached off the jsonl-entry
// relay — see SessionManager.getTranscriptFile). No file yet means
// the session has not written a durable line this run; the phone
// shows live-only and may retry after the first entry lands.
const file = this.deps.manager.getTranscriptFile(msg.sessionId)
if (!file) {
return { ok: false, error: 'no transcript on disk yet for this session' }
}
const kind = this.deps.manager.getSessionKind(msg.sessionId)
if (!kind || kind === 'terminal') {
return { ok: false, error: 'not an agent session' }
}
const chunk = msg.beforeMarker
? await loadOlderHistoryChunkFromFile(file, {
kind,
beforeMarker: msg.beforeMarker,
limit: msg.limit ?? 200,
})
: await loadInitialHistoryChunkFromFile(file, msg.limit ?? 120)
// Raw records, same shape as the live jsonl frames' `entry` halves,
// so the phone runs ONE mapper path for backfill and live. `file`
// rides along so the client can detect a transcript ROLL: after
// /clear (or a resume that starts a new provider session) the
// manager's file cache is stale until the new conversation's first
// durable line, and a backfill served from the OLD file must be
// discardable client-side by comparing against the file the live
// frames carry.
return { ok: true, result: { ...chunk, file } }
}
}
}

Expand Down
17 changes: 17 additions & 0 deletions src/main/remote/protocol/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,22 @@ export const conditionActionSchema = z.union([
}),
])

/**
* Read-only transcript backfill. The phone connects mid-session and needs
* scrollback; live jsonl frames only cover what happened after connect.
* beforeMarker absent → the newest `limit` records (initial chunk);
* present → the `limit` records immediately before it (older page). This
* widens the v1 scope with a READ — it exposes transcript content the
* feed channels already stream, adds no new write capability, and its
* acceptance is pinned in scope.test.ts like every other message.
*/
export const getHistoryMessageSchema = z.object({
type: z.literal('get-history'),
sessionId: z.string().min(1),
beforeMarker: z.string().min(1).optional(),
limit: z.number().int().min(1).max(500).optional(),
})

export const permissionReplyMessageSchema = z.object({
type: z.literal('permission-reply'),
sessionId: z.string().min(1),
Expand All @@ -91,6 +107,7 @@ export const inboundMessageSchema = z.discriminatedUnion('type', [
submitMessageSchema,
interruptMessageSchema,
permissionReplyMessageSchema,
getHistoryMessageSchema,
])

/**
Expand Down
7 changes: 7 additions & 0 deletions src/main/remote/protocol/scope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ describe('allowed inbound messages', () => {
action: { kind: 'pty', id: 'yes', label: 'Yes', data: '\r' },
},
],
['get-history (initial)', { type: 'get-history', sessionId: 's1' }],
[
'get-history (older page)',
{ type: 'get-history', sessionId: 's1', beforeMarker: 'uuid-123', limit: 200 },
],
[
'permission-reply (custom action)',
{
Expand Down Expand Up @@ -70,6 +75,8 @@ describe('out-of-scope and malformed frames', () => {
type: 'permission-reply', sessionId: 's1', action: '\x03',
}],
['no type at all', { sessionId: 's1' }],
['get-history without sessionId', { type: 'get-history' }],
['get-history with absurd limit', { type: 'get-history', sessionId: 's1', limit: 100000 }],
])('%s is rejected', (_label, message) => {
const result = parseInboundFrame(frame(message))
expect(result.ok).toBe(false)
Expand Down
Loading