feat(tui): run on Node 26 (one runtime), finalize copy UX, rename to ui-opentui

Ports the engine off the second JS runtime onto Node 26.3 (node:ffi) so the
repo ships a single JavaScript runtime: child_process for the gateway, vitest
for tests, an esbuild + Solid build step. Mouse selection copies the rendered
text you highlight, and the clipboard path is crash-proofed (a broken copy
pipe no longer quits the UI). Renames the engine dir ui-tui-opentui-v2/ ->
ui-opentui/ and updates the launcher/installer/Docker references.
This commit is contained in:
alt-glitch
2026-06-09 16:16:48 +00:00
parent 25567919ea
commit ae11a636dc
85 changed files with 5613 additions and 900 deletions
+126
View File
@@ -0,0 +1,126 @@
/**
* Clipboard (item 1) — copy via OSC 52 (works over SSH/tmux) + a native platform
* command, and read a clipboard IMAGE for paste-to-attach. Ported/trimmed from
* opencode `clipboard.ts`. A boundary concern (spawns processes / writes stdout);
* everything is best-effort and never throws into the view.
*/
import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import { platform } from 'node:os'
import { join } from 'node:path'
/** Whether `cmd` resolves on PATH (cached). We DON'T spawn missing tools: a failed
* spawn + writing to its dead stdin pipe raises EPIPE/SIGPIPE, and OpenTUI used to
* treat SIGPIPE as a shutdown signal — i.e. a clipboard miss would quit the TUI.
* Skipped on Windows (the built-in `clip` is always present; PATHEXT complicates
* a filename probe). */
const commandCache = new Map<string, boolean>()
function commandExists(cmd: string): boolean {
if (platform() === 'win32') return true
const cached = commandCache.get(cmd)
if (cached !== undefined) return cached
const dirs = (process.env.PATH ?? '').split(':').filter(Boolean)
const found = dirs.some(dir => existsSync(join(dir, cmd)))
commandCache.set(cmd, found)
return found
}
/** Run a command, optionally piping `input` to stdin; resolve its stdout bytes.
* Best-effort and crash-proof: every stream error (incl. EPIPE → SIGPIPE on a
* clipboard tool that exits early) is swallowed so a failed copy never throws out
* of the boundary or signals the process. */
function run(cmd: string, args: string[] = [], input?: string): Promise<Buffer> {
return new Promise((resolve, reject) => {
let child
try {
child = spawn(cmd, args, { stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'ignore'] })
} catch (cause) {
reject(cause instanceof Error ? cause : new Error(String(cause)))
return
}
const out: Buffer[] = []
child.on('error', reject)
child.stdout?.on('error', () => {}) // a closed stdout pipe must not throw
child.stdout?.on('data', (c: Buffer) => out.push(c))
child.on('close', code => (code === 0 ? resolve(Buffer.concat(out)) : reject(new Error(`${cmd} exit ${code}`))))
if (input !== undefined && child.stdin) {
// Writing to a tool that died/closed early raises EPIPE (→ SIGPIPE). Swallow it.
child.stdin.on('error', () => {})
try {
child.stdin.end(input)
} catch {
// pipe already gone — nothing to flush
}
}
})
}
/** OSC 52 copy — the terminal puts `text` on the system clipboard (SSH/tmux-safe). */
function writeOsc52(text: string): void {
if (!process.stdout.isTTY) return
const seq = `\x1b]52;c;${Buffer.from(text).toString('base64')}\x07`
// tmux/screen need the sequence wrapped in their passthrough escape.
process.stdout.write(process.env.TMUX || process.env.STY ? `\x1bPtmux;\x1b${seq}\x1b\\` : seq)
}
/** Native copy commands to try, in order, for the current platform. */
function copyCandidates(): Array<[string, string[]]> {
const os = platform()
if (os === 'darwin') return [['pbcopy', []]]
if (os === 'win32') return [['clip', []]]
// linux: prefer Wayland, then X11 tools
const list: Array<[string, string[]]> = []
if (process.env.WAYLAND_DISPLAY) list.push(['wl-copy', []])
list.push(['xclip', ['-selection', 'clipboard']], ['xsel', ['--clipboard', '--input']])
return list
}
/** Copy `text` to the clipboard: OSC 52 (always) + the first native command that works. */
export async function writeClipboard(text: string): Promise<void> {
writeOsc52(text) // primary path — SSH/tmux-safe, no subprocess
for (const [cmd, args] of copyCandidates()) {
if (!commandExists(cmd)) continue // never spawn a missing tool (avoids EPIPE/SIGPIPE)
try {
await run(cmd, args, text)
return
} catch {
// try the next candidate
}
}
}
/** Read a clipboard IMAGE as base64 PNG (for paste-to-attach); undefined if none. */
export async function readClipboardImage(): Promise<{ data: string; mime: string } | undefined> {
const os = platform()
const tries: Array<[string, string[]]> = []
if (os === 'linux') {
if (process.env.WAYLAND_DISPLAY) tries.push(['wl-paste', ['-t', 'image/png']])
tries.push(['xclip', ['-selection', 'clipboard', '-t', 'image/png', '-o']])
} else if (os === 'darwin') {
tries.push(['pngpaste', ['-']]) // brew install pngpaste
} else if (os === 'win32') {
tries.push([
'powershell.exe',
[
'-NonInteractive',
'-NoProfile',
'-Command',
'Add-Type -AssemblyName System.Windows.Forms; $img=[System.Windows.Forms.Clipboard]::GetImage(); if($img){$ms=New-Object System.IO.MemoryStream; $img.Save($ms,[System.Drawing.Imaging.ImageFormat]::Png); [Console]::Out.Write([System.Convert]::ToBase64String($ms.ToArray()))}'
]
])
}
for (const [cmd, args] of tries) {
if (!commandExists(cmd)) continue // skip missing tools (no pointless failing spawns)
try {
const buf = await run(cmd, args)
if (buf.length) {
// powershell already returns base64 text; the others return raw PNG bytes.
const data = os === 'win32' ? buf.toString('utf8').trim() : buf.toString('base64')
if (data) return { data, mime: 'image/png' }
}
} catch {
// try the next candidate
}
}
return undefined
}
+29
View File
@@ -0,0 +1,29 @@
/**
* Typed errors at the gateway boundary.
*
* Per spec v4 §3.4: internal errors use `Data.TaggedError`; wire/serializable
* errors use Schema-based tagged errors (added in Phase 1 alongside the
* GatewayEvent schema). Phase 0 ships the internal set the renderer/transport
* boundary needs.
*
* Boundary code yields these directly (`return yield* new FooError(...)`) — no
* throw / try-catch / Promise.catch / orDie.
*/
import { Data } from 'effect'
/** The renderer (createCliRenderer) failed to acquire. */
export class RendererError extends Data.TaggedError('RendererError')<{
readonly cause: unknown
}> {}
/** Could not resolve a usable Python interpreter for the gateway. */
export class PythonResolutionError extends Data.TaggedError('PythonResolutionError')<{
readonly tried: ReadonlyArray<string>
}> {}
/** A JSON-RPC request to the gateway failed (timeout, transport down, rpc error). */
export class GatewayError extends Data.TaggedError('GatewayError')<{
readonly method: string
readonly reason: 'timeout' | 'transport-down' | 'rpc-error'
readonly message: string
}> {}
@@ -0,0 +1,29 @@
/**
* GatewayService — the Effect-side transport boundary.
*
* Phase 0: the SHAPE only. The live layer (spawning the Python `tui_gateway`,
* JSON-RPC framing, Schema-decoding the wire union) lands in Phase 1
* (`boundary/gateway/liveGateway.ts`). For now the only implementation is
* `FakeGateway.layer` (entry/fakeGateway.ts), which the render/test harness uses.
*
* This is one of exactly two Effect<->Solid contact points: the Solid store
* subscribes via `subscribe(handler)` and the boundary pushes DECODED events in.
* Per spec v4 §1, the store/reducer themselves are plain Solid, never Effect.
*/
import { Context, type Effect } from 'effect'
import type { GatewayError } from '../errors.ts'
import type { GatewayEvent } from '../schema/GatewayEvent.ts'
export interface GatewayServiceShape {
/** Push decoded gateway events into the Solid store. Returns an unsubscribe fn. */
readonly subscribe: (handler: (event: GatewayEvent) => void) => Effect.Effect<() => void>
/** Typed JSON-RPC request to the Python gateway. Fails with a typed GatewayError, never throws. */
readonly request: <A>(method: string, params: unknown) => Effect.Effect<A, GatewayError>
/** The active session id (for `approval.respond {session_id}`); undefined before a session exists. */
readonly sessionId: () => string | undefined
}
export class GatewayService extends Context.Service<GatewayService, GatewayServiceShape>()(
'@hermes-tui/GatewayService'
) {}
+255
View File
@@ -0,0 +1,255 @@
/**
* Low-level JSON-RPC-over-stdio client for the Python `tui_gateway` (spec v4 §4).
* Re-authored minimal (NOT the Ink client's 740-LOC attach-mode/buffering) but
* the WIRE CONTRACT is identical (verified against ui-tui/src/gatewayClient.ts +
* tui_gateway/server.py + entry.py + transport.py):
*
* - spawn: `python -m tui_gateway.entry`, cwd=srcRoot, env={...process.env,
* PYTHONPATH=srcRoot:…, HERMES_PYTHON_SRC_ROOT=srcRoot}, stdio piped.
* - framing: newline-delimited compact JSON, BOTH directions, on ONE stdout.
* - request: {id:"r<n>", jsonrpc:"2.0", method, params} + "\n".
* - response: {jsonrpc, id, result} | {jsonrpc, id, error:{code,message}} — match by id.
* - event: {jsonrpc, method:"event", params:{type, session_id?, payload?}} (NO id).
* - handshake: child emits {event, params:{type:"gateway.ready", payload:{skin}}}
* UNSOLICITED first; no subscribe RPC. Then client drives session.create /
* session.resume / prompt.submit / *.respond.
* - GOTCHA: session.resume/prompt.submit/slash.exec are LONG handlers — their
* {id,result} arrives async, interleaved with events. Keep the pending map
* authoritative; never assume in-order response delivery.
*
* Raw events are surfaced as `unknown` (the params object). The liveGateway
* layer Schema-decodes them once at the boundary (spec v4 §3.3); this client
* stays decode-agnostic so the transport and the schema evolve independently.
*/
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
import type { Log } from '../log.ts'
import { resolvePython, resolveSrcRoot } from './python.ts'
interface Pending {
resolve: (result: unknown) => void
reject: (error: Error) => void
method: string
}
export interface RawClientOptions {
readonly log: Log
/** Called with each server-pushed event's `params` object (still unknown — decoded upstream). */
readonly onEvent: (params: unknown) => void
/** Called when the child exits / errors (so the layer can reject pending + reconnect). */
readonly onExit?: (reason: string) => void
}
const REQUEST_TIMEOUT_MS = (() => {
const raw = Number.parseInt(process.env.HERMES_TUI_RPC_TIMEOUT_MS ?? '', 10)
return Number.isFinite(raw) && raw > 0 ? Math.max(5000, raw) : 120_000
})()
const STARTUP_TIMEOUT_MS = (() => {
const raw = Number.parseInt(process.env.HERMES_TUI_STARTUP_TIMEOUT_MS ?? '', 10)
return Number.isFinite(raw) && raw > 0 ? Math.max(2000, raw) : 20_000
})()
export class RawGatewayClient {
private proc: ChildProcessWithoutNullStreams | null = null
private pending = new Map<string, Pending>()
private reqId = 0
private stdinBuffer = ''
private startupTimer: ReturnType<typeof setTimeout> | undefined
private readonly log: Log
private readonly onEvent: (params: unknown) => void
private readonly onExit?: (reason: string) => void
constructor(options: RawClientOptions) {
this.log = options.log
this.onEvent = options.onEvent
if (options.onExit) this.onExit = options.onExit
}
/** Spawn the gateway child and begin reading frames. Idempotent. */
start(): void {
if (this.proc) return
const srcRoot = resolveSrcRoot()
const python = resolvePython(srcRoot)
const cwd = process.env.HERMES_CWD?.trim() || srcRoot
const env: Record<string, string> = { ...(process.env as Record<string, string>) }
env.PYTHONPATH = env.PYTHONPATH ? `${srcRoot}:${env.PYTHONPATH}` : srcRoot
env.HERMES_PYTHON_SRC_ROOT = srcRoot
this.log.info('gateway', 'spawning tui_gateway', { python, cwd, srcRoot })
const proc = spawn(python, ['-m', 'tui_gateway.entry'], {
cwd,
env,
stdio: ['pipe', 'pipe', 'pipe']
})
// Identity guard: a stale child's late exit/error must not act after a restart
// has already installed a new `this.proc` (else it'd null the live child).
// Nulling `this.proc` here makes a subsequent finish() a no-op (idempotent),
// covering the ENOENT case where 'error' fires and 'exit' does not.
const finish = (reason: string) => {
if (this.proc !== proc) return
this.log.warn('gateway', reason)
this.rejectAll(reason)
this.proc = null
this.onExit?.(reason)
}
proc.on('exit', (code, signal) => finish(`gateway exited (code=${code ?? 'null'} signal=${signal ?? 'null'})`))
proc.on('error', err => finish(`gateway spawn error: ${err instanceof Error ? err.message : String(err)}`))
this.proc = proc
this.readStdout(proc)
this.readStderr(proc)
// Startup-readiness watchdog: a child that hangs on import (wrong python /
// missing dep) never emits the unsolicited `gateway.ready` handshake, leaving
// a silent blank UI. Emit `gateway.start_timeout` so the store can surface a
// failure line + the captured stderr tail. Cleared on ready (dispatch) / stop.
// A recovery-respawn re-enters start(), so this re-arms per respawn — desired.
this.startupTimer = setTimeout(() => {
this.startupTimer = undefined
this.onEvent({
type: 'gateway.start_timeout',
payload: { message: `no gateway.ready within ${STARTUP_TIMEOUT_MS}ms` }
})
}, STARTUP_TIMEOUT_MS)
}
private readStdout(proc: ChildProcessWithoutNullStreams): void {
proc.stdout.setEncoding('utf8')
proc.stdout.on('data', (chunk: string) => {
this.stdinBuffer += chunk
let nl: number
while ((nl = this.stdinBuffer.indexOf('\n')) >= 0) {
const line = this.stdinBuffer.slice(0, nl)
this.stdinBuffer = this.stdinBuffer.slice(nl + 1)
if (line.trim()) this.dispatch(line)
}
})
proc.stdout.on('error', cause => this.log.error('gateway', 'stdout read loop failed', { cause: String(cause) }))
}
private readStderr(proc: ChildProcessWithoutNullStreams): void {
let buf = ''
proc.stderr.setEncoding('utf8')
proc.stderr.on('data', (chunk: string) => {
buf += chunk
let nl: number
while ((nl = buf.indexOf('\n')) >= 0) {
const line = buf.slice(0, nl)
buf = buf.slice(nl + 1)
if (line.trim()) {
this.log.debug('gateway.stderr', line)
// Surface as a synthetic gateway.stderr event (matches Ink).
this.onEvent({ type: 'gateway.stderr', payload: { line } })
}
}
})
// stderr pipe closing on exit is expected; ignore errors.
proc.stderr.on('error', () => {})
}
private dispatch(line: string): void {
let msg: unknown
try {
msg = JSON.parse(line)
} catch {
this.log.warn('gateway', 'unparseable frame', { preview: line.slice(0, 120) })
this.onEvent({ type: 'gateway.protocol_error', payload: { preview: line.slice(0, 120) } })
return
}
if (!msg || typeof msg !== 'object') return
const frame = msg as { id?: unknown; method?: unknown; params?: unknown; result?: unknown; error?: unknown }
// Response: has an id matching a pending request.
const pending = typeof frame.id === 'string' ? this.pending.get(frame.id) : undefined
if (typeof frame.id === 'string' && pending) {
const p = pending
this.pending.delete(frame.id)
if (frame.error) {
const err = frame.error as { code?: number; message?: string }
p.reject(new Error(err.message ?? `rpc error (${err.code ?? '?'})`))
} else {
p.resolve(frame.result)
}
return
}
// Event push: method === "event", no id. Surface params (decoded upstream).
if (frame.method === 'event' && frame.params && typeof frame.params === 'object') {
// Handshake arrived: cancel the startup-readiness watchdog. Narrow without
// `as` via `'type' in obj` + property access (the params record is loose).
if ('type' in frame.params && frame.params.type === 'gateway.ready') {
if (this.startupTimer) clearTimeout(this.startupTimer)
this.startupTimer = undefined
}
this.onEvent(frame.params)
return
}
this.log.warn('gateway', 'unroutable frame', { preview: line.slice(0, 120) })
}
/** Send a JSON-RPC request; resolves with `result` (long handlers reply async). */
request<A = unknown>(method: string, params: unknown): Promise<A> {
// Do NOT auto-start here: during the recovery backoff window `this.proc` is
// null, and a respawn here would BYPASS the backoff (the first spawn always
// comes from subscribe() → client.start()). A null proc rejects below.
const proc = this.proc
const stdin = proc?.stdin
if (!stdin) return Promise.reject(new Error('gateway not running'))
const id = `r${++this.reqId}`
const frame = JSON.stringify({ id, jsonrpc: '2.0', method, params: params ?? {} }) + '\n'
return new Promise<A>((resolve, reject) => {
const timer = setTimeout(() => {
if (this.pending.delete(id)) reject(new Error(`timeout: ${method}`))
}, REQUEST_TIMEOUT_MS)
this.pending.set(id, {
method,
resolve: result => {
clearTimeout(timer)
resolve(result as A)
},
reject: error => {
clearTimeout(timer)
reject(error)
}
})
try {
// Newline-delimited JSON to the child's stdin. Fire-and-forget: the write
// returns a backpressure boolean we intentionally ignore (frames are tiny
// and ordered; Node flushes the pipe itself).
stdin.write(frame)
} catch (cause) {
this.pending.delete(id)
clearTimeout(timer)
reject(cause instanceof Error ? cause : new Error(String(cause)))
}
})
}
private rejectAll(reason: string): void {
for (const p of this.pending.values()) p.reject(new Error(reason))
this.pending.clear()
}
/** Close stdin (EOF → child exits) and stop. */
stop(): void {
if (this.startupTimer) clearTimeout(this.startupTimer)
this.startupTimer = undefined
this.rejectAll('gateway stopping')
const stdin = this.proc?.stdin
if (stdin) {
try {
// Close stdin → child sees EOF and exits.
stdin.end()
} catch {
// already gone
}
}
this.proc = null
}
}
@@ -0,0 +1,175 @@
/**
* liveGateway — the GatewayService layer backed by the real Python `tui_gateway`
* (spec v4 §2/§3.2). Adapts RawGatewayClient to GatewayServiceShape:
* - decodes each raw event ONCE with the GatewayEvent Schema
* (decodeUnknownOption → unrecognized/malformed events skipped, never crash),
* - coalesces decoded events on a 16ms debounce flushed inside Solid `batch()`
* so a burst of deltas is ONE repaint (opencode sdk.tsx:54-80),
* - tracks the session id (set from session.create/resume result) for
* approval.respond {session_id},
* - maps request failures to a typed GatewayError (never throws).
*
* The 16ms batch + `batch()` call is the boundary handing decoded events to
* Solid — one of the two approved Effect<->Solid contact points (spec v4 §1).
*/
import { Effect, Layer, Option, Schema } from 'effect'
import { batch } from 'solid-js'
import { backoffMs, planGatewayRecovery } from '../../logic/gatewayRecovery.ts'
import { GatewayError } from '../errors.ts'
import { getLog } from '../log.ts'
import { GatewayEventSchema, type GatewayEvent } from '../schema/GatewayEvent.ts'
import { GatewayService, type GatewayServiceShape } from './GatewayService.ts'
import { RawGatewayClient } from './client.ts'
const COALESCE_MS = 16
const decodeEvent = Schema.decodeUnknownOption(GatewayEventSchema)
function makeLiveGateway(): { service: GatewayServiceShape; stop: () => void } {
const log = getLog()
const handlers = new Set<(event: GatewayEvent) => void>()
let sessionId: string | undefined
// Auto-heal recovery state (driver below). `recoverSid` is the resume target
// carried across a respawn that died before gateway.ready; `recoveryAttempts`
// is the sliding crash-loop budget window; `restartTimer` is the pending
// backoff respawn (cleared on teardown so it can't fire post-stop).
let recoverSid: string | undefined
let recoveryAttempts: number[] = []
let restartTimer: ReturnType<typeof setTimeout> | undefined
// 16ms event coalescing → one batched repaint (opencode sdk.tsx model).
let queue: GatewayEvent[] = []
let timer: ReturnType<typeof setTimeout> | undefined
let last = 0
const flush = () => {
timer = undefined
if (queue.length === 0) return
const events = queue
queue = []
last = Date.now()
batch(() => {
for (const event of events) {
for (const handler of handlers) handler(event)
}
})
}
const enqueue = (event: GatewayEvent) => {
queue.push(event)
if (timer) return
// If we flushed recently (<16ms ago) batch with near-future events; else flush now.
if (Date.now() - last < COALESCE_MS) {
timer = setTimeout(flush, COALESCE_MS)
} else {
flush()
}
}
const onRawEvent = (params: unknown) => {
const decoded = decodeEvent(params)
if (Option.isNone(decoded)) {
const t = (params as { type?: unknown } | null)?.type
log.debug('gateway', 'skipped undecodable event', { type: typeof t === 'string' ? t : '(none)' })
return
}
enqueue(decoded.value)
}
// Recovery driver: on a child exit, clear the frozen spinner (via the store's
// gateway.exited case), then — under the crash-loop budget — respawn the child
// on exponential backoff. The post-respawn gateway.ready triggers the re-resume
// (driven from entry's subscribe callback). Hoisted so it can be passed to
// `new RawGatewayClient` below while itself referencing the `client` const —
// `client` is assigned by the time onExit ever fires at runtime.
function onExit(reason: string): void {
log.warn('gateway', 'transport exited', { reason })
// Clears the frozen spinner + shows status (store handles gateway.exited).
enqueue({ type: 'gateway.exited', payload: { reason } })
const plan = planGatewayRecovery(sessionId ?? null, recoverSid ?? null, recoveryAttempts, Date.now())
recoveryAttempts = plan.attempts
if (!plan.recover || plan.sid === null) {
enqueue({ type: 'error', payload: { message: 'gateway exited repeatedly — type /resume to retry' } })
return
}
recoverSid = plan.sid
const attempt = recoveryAttempts.length
const delay = backoffMs(attempt)
enqueue({ type: 'gateway.recovering', payload: { attempt, delay_ms: delay } })
if (restartTimer) clearTimeout(restartTimer)
restartTimer = setTimeout(() => {
restartTimer = undefined
client.start()
}, delay)
}
const client = new RawGatewayClient({
log,
onEvent: onRawEvent,
onExit
})
const service: GatewayServiceShape = {
subscribe: handler =>
Effect.sync(() => {
handlers.add(handler)
// Lazily spawn on first subscription so the child + its gateway.ready land.
client.start()
return () => {
handlers.delete(handler)
}
}),
request: <A>(method: string, params: unknown) =>
Effect.tryPromise({
try: () => client.request<A>(method, params),
catch: cause => {
const message = cause instanceof Error ? cause.message : String(cause)
const reason = message.startsWith('timeout:')
? ('timeout' as const)
: message.includes('not running') || message.includes('stopping')
? ('transport-down' as const)
: ('rpc-error' as const)
return new GatewayError({ method, reason, message })
}
}).pipe(
// Capture session id from create/resume results so approval.respond works.
Effect.tap(result =>
Effect.sync(() => {
if ((method === 'session.create' || method === 'session.resume') && result && typeof result === 'object') {
const sid = (result as { session_id?: unknown }).session_id
if (typeof sid === 'string') sessionId = sid
}
})
)
),
sessionId: () => sessionId
}
// Clear a pending coalesce timer on teardown so a queued flush() can't fire
// batch()/handlers into a torn-down store after the layer scope releases.
const stop = () => {
if (timer) clearTimeout(timer)
timer = undefined
// Also kill any pending backoff respawn so it can't fire after teardown.
if (restartTimer) clearTimeout(restartTimer)
restartTimer = undefined
client.stop()
}
return { service, stop }
}
/**
* The live GatewayService layer (spawns + talks to the real Python tui_gateway).
* Scoped so the child process is stopped (stdin EOF → exit) on scope teardown —
* no orphaned gateway children when the renderer is destroyed.
*/
export const liveGatewayLayer: Layer.Layer<GatewayService> = Layer.effect(
GatewayService,
Effect.acquireRelease(Effect.sync(makeLiveGateway), ({ stop }) => Effect.sync(stop)).pipe(
Effect.map(({ service }) => service)
)
)
+49
View File
@@ -0,0 +1,49 @@
/**
* Python resolution for spawning the `tui_gateway` — mirrors Ink's
* `resolvePython` (ui-tui/src/gatewayClient.ts:45-64) EXACTLY so behavior is
* identical across engines (spec v4 §4). NEVER "probe any python".
*
* Order: HERMES_PYTHON / PYTHON env → $VIRTUAL_ENV (bin/python or
* Scripts/python.exe) → <root>/.venv → <root>/venv → bare `python3` (`python`
* on win32) on PATH. The source root is HERMES_PYTHON_SRC_ROOT (the launcher
* sets it) so the child resolves modules against the right checkout.
*/
import { existsSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
export function resolvePython(root: string): string {
const configured = process.env.HERMES_PYTHON?.trim() || process.env.PYTHON?.trim()
if (configured) return configured
const venv = process.env.VIRTUAL_ENV?.trim()
const hit = [
venv && resolve(venv, 'bin/python'),
venv && resolve(venv, 'Scripts/python.exe'),
resolve(root, '.venv/bin/python'),
resolve(root, '.venv/bin/python3'),
resolve(root, 'venv/bin/python'),
resolve(root, 'venv/bin/python3')
].find(p => p && existsSync(p))
return hit || (process.platform === 'win32' ? 'python' : 'python3')
}
/** The Hermes checkout root used as PYTHONPATH / HERMES_PYTHON_SRC_ROOT for the child. */
export function resolveSrcRoot(): string {
const configured = process.env.HERMES_PYTHON_SRC_ROOT?.trim()
if (configured) return configured
// Fallback (no launcher env): walk up from this module to the Hermes checkout
// root — the dir holding the `hermes_cli` package / `pyproject.toml`. Bundle-
// agnostic, so it works whether running the source tree (.../src/boundary/gateway)
// or the built `dist/main.js`. (Under the real launcher this never runs — the
// launcher always sets HERMES_PYTHON_SRC_ROOT.)
let dir = import.meta.dirname
for (let i = 0; i < 8; i++) {
if (existsSync(resolve(dir, 'hermes_cli')) || existsSync(resolve(dir, 'pyproject.toml'))) return dir
const parent = dirname(dir)
if (parent === dir) break
dir = parent
}
return resolve(import.meta.dirname, '../../../../')
}
+248
View File
@@ -0,0 +1,248 @@
/**
* Log — TUI diagnostics sink (glitch: "v. important … hook into logs to figure
* out TUI state"). Design mirrors opencode's `util/log.ts` (levels + priority
* filter, scoped/child loggers, a `.time()` span helper) but adds a dual sink:
*
* 1. an in-memory RING BUFFER (queryable at runtime — a `/logs` overlay or a
* test asserting TUI state transitions can read it live), AND
* 2. an append-only NDJSON FILE (default `~/.hermes/logs/opentui-v2.log`,
* override via HERMES_TUI_LOG_FILE) so a live session is `tail -f`-able.
*
* The ring buffer is the key advantage over opencode's file-only logger: it lets
* us inspect engine state from inside the running TUI without leaving it.
*
* CRITICAL: OpenTUI HIJACKS `console.*` and stdout (opentui skill / gotcha) —
* logging to the terminal corrupts the rendered frame. So this NEVER touches
* console/stdout/stderr; file + ring only. It's the single approved logging path
* for the whole engine. Level filter via HERMES_TUI_LOG_LEVEL (default INFO).
*/
import { appendFileSync, mkdirSync, renameSync, statSync, unlinkSync } from 'node:fs'
import { homedir } from 'node:os'
import { dirname, join } from 'node:path'
import { Schema } from 'effect'
// LogLevel is modeled schema-first (the schema-inferred-types idiom, mirroring
// `boundary/schema/GatewayEvent.ts`): declare the literal union once and INFER
// the TS type from it, so the two can never drift.
export const LogLevelSchema = Schema.Literals(['debug', 'info', 'warn', 'error'])
export type LogLevel = typeof LogLevelSchema.Type
const PRIORITY: Record<LogLevel, number> = { debug: 0, info: 1, warn: 2, error: 3 }
/**
* Serialize a value to JSON that NEVER throws. A caller-supplied `data` can hold
* a circular reference or a BigInt — plain `JSON.stringify` throws on both, which
* (in the file-write `catch` below) would flip `fileBroken` and kill ALL file
* logging for the session. Instead we degrade a bad payload to a placeholder:
* - circular refs (tracked via a per-call `WeakSet` of seen objects) → '[Circular]'
* - BigInt → `\`${n}n\`` (JSON has no bigint; keep it readable + reversible-ish)
* and wrap the whole thing so any other throw (e.g. a hostile `toJSON`) falls back
* to `String(value)`, then to '[unserializable]' if even that throws.
*/
export function safeStringify(value: unknown): string {
try {
const seen = new WeakSet<object>()
return JSON.stringify(value, (_key, val: unknown) => {
if (typeof val === 'bigint') return `${val}n`
if (typeof val === 'object' && val !== null) {
if (seen.has(val)) return '[Circular]'
seen.add(val)
}
return val
})
} catch {
try {
return String(value)
} catch {
return '[unserializable]'
}
}
}
export interface LogEntry {
readonly t: number // epoch ms
readonly level: LogLevel
readonly scope: string
readonly msg: string
readonly data?: unknown
}
const RING_LIMIT = 2000
// Size-based rotation for the append-only NDJSON file (mirrors opencode's
// keep-N model, but size- rather than time-keyed since we write one growing
// file). When the live file crosses LOG_MAX_BYTES we shift
// `.log` → `.log.1` → … → `.log.${LOG_KEEP}` (dropping the oldest) and resume on
// a fresh empty `.log`. Rotation is best-effort: any failure leaves us writing
// to the existing file (logging must never crash the engine).
const LOG_MAX_BYTES = 5 * 1024 * 1024
const LOG_KEEP = 5
function defaultLogFile(): string {
const explicit = process.env.HERMES_TUI_LOG_FILE?.trim()
if (explicit) return explicit
return join(homedir(), '.hermes', 'logs', 'opentui-v2.log')
}
function defaultLevel(): LogLevel {
const raw = process.env.HERMES_TUI_LOG_LEVEL?.trim().toLowerCase()
return raw === 'debug' || raw === 'info' || raw === 'warn' || raw === 'error' ? raw : 'info'
}
/** A timing span — call `.stop()` (or `using` it) to log completion + duration. */
export interface TimeSpan {
stop: () => void
[Symbol.dispose]: () => void
}
export class Log {
private ring: LogEntry[] = []
private file: string | null
private fileBroken = false
private minPriority: number
// Bytes in the live log file. Seeded from statSync on open (counter approach —
// we avoid a statSync on EVERY write); incremented by each line's byte length
// and reset to 0 after a rotation. Rotation triggers when this would cross
// LOG_MAX_BYTES, so the live file stays bounded without per-write fs stats.
private fileBytes = 0
constructor(file: string | null = defaultLogFile(), level: LogLevel = defaultLevel()) {
this.file = file
this.minPriority = PRIORITY[level]
if (this.file) {
try {
mkdirSync(dirname(this.file), { recursive: true })
} catch {
this.fileBroken = true
}
try {
this.fileBytes = statSync(this.file).size
} catch {
this.fileBytes = 0 // no existing file (or unreadable) → start the counter at 0
}
}
}
setLevel(level: LogLevel): void {
this.minPriority = PRIORITY[level]
}
/**
* Best-effort size-based rotation: `.log.${LOG_KEEP}` is dropped, every other
* `.log.N` shifts up, the live `.log` becomes `.log.1`, and the counter resets
* so writing continues on a fresh file. Any fs failure is swallowed and we keep
* writing to the existing file — rotation must never crash logging.
*/
private rotate(file: string): void {
try {
try {
unlinkSync(`${file}.${LOG_KEEP}`)
} catch {
// oldest slot may not exist yet — fine
}
for (let i = LOG_KEEP - 1; i >= 1; i--) {
try {
renameSync(`${file}.${i}`, `${file}.${i + 1}`)
} catch {
// that slot may not exist yet — fine
}
}
renameSync(file, `${file}.1`)
this.fileBytes = 0
} catch {
// rotation failed (e.g. live file vanished) — leave the counter alone and
// keep appending to the existing path; better an oversized log than none.
}
}
private write(level: LogLevel, scope: string, msg: string, data?: unknown): void {
if (PRIORITY[level] < this.minPriority) return
const entry: LogEntry =
data === undefined ? { t: Date.now(), level, scope, msg } : { t: Date.now(), level, scope, msg, data }
this.ring.push(entry)
if (this.ring.length > RING_LIMIT) this.ring.shift()
if (this.file && !this.fileBroken) {
try {
const line = safeStringify(entry) + '\n'
if (this.fileBytes > 0 && this.fileBytes + Buffer.byteLength(line) > LOG_MAX_BYTES) this.rotate(this.file)
appendFileSync(this.file, line)
this.fileBytes += Buffer.byteLength(line)
} catch {
this.fileBroken = true // stop hammering a broken path; the ring keeps working
}
}
}
debug(scope: string, msg: string, data?: unknown): void {
this.write('debug', scope, msg, data)
}
info(scope: string, msg: string, data?: unknown): void {
this.write('info', scope, msg, data)
}
warn(scope: string, msg: string, data?: unknown): void {
this.write('warn', scope, msg, data)
}
error(scope: string, msg: string, data?: unknown): void {
this.write('error', scope, msg, data)
}
/** A logger bound to a fixed scope (opencode's tagged-logger ergonomics). */
child(scope: string): ScopedLog {
return new ScopedLog(this, scope)
}
/** Time an operation: logs `<msg> started` now and `<msg> completed` + duration on stop. */
time(scope: string, msg: string, data?: Record<string, unknown>): TimeSpan {
const started = Date.now()
this.info(scope, `${msg} started`, data)
const stop = () => this.info(scope, `${msg} completed`, { ...data, duration_ms: Date.now() - started })
return { stop, [Symbol.dispose]: stop }
}
/** Snapshot of the in-memory ring (newest last). For a `/logs` overlay or tests. */
tail(n = RING_LIMIT): LogEntry[] {
return n >= this.ring.length ? [...this.ring] : this.ring.slice(this.ring.length - n)
}
/** Where the file log is written (for surfacing in the UI / `/logs`). */
get filePath(): string | null {
return this.fileBroken ? null : this.file
}
clear(): void {
this.ring = []
}
}
/** A logger with a fixed scope — forwards to the parent Log. */
export class ScopedLog {
constructor(
private readonly parent: Log,
private readonly scope: string
) {}
debug(msg: string, data?: unknown): void {
this.parent.debug(this.scope, msg, data)
}
info(msg: string, data?: unknown): void {
this.parent.info(this.scope, msg, data)
}
warn(msg: string, data?: unknown): void {
this.parent.warn(this.scope, msg, data)
}
error(msg: string, data?: unknown): void {
this.parent.error(this.scope, msg, data)
}
time(msg: string, data?: Record<string, unknown>): TimeSpan {
return this.parent.time(this.scope, msg, data)
}
}
let _singleton: Log | null = null
/** Module-singleton logger for the live engine. Tests construct their own `new Log(null)`. */
export function getLog(): Log {
_singleton ??= new Log()
return _singleton
}
+138
View File
@@ -0,0 +1,138 @@
/**
* Renderer lifecycle — the Effect-side resource boundary (spec v4 §3.1).
*
* `acquireRelease(createCliRenderer)` so the renderer is always destroyed on
* scope exit; a `Deferred` resolved on the renderer's "destroy" event lets the
* entry block until the user quits. Mirrors opencode `app.tsx:177` /
* `:185-225`.
*
* No throw / try-catch here: acquisition failure surfaces as a typed
* `RendererError` via `Effect.tryPromise`'s `catch`.
*/
import { createCliRenderer, type CliRenderer, type KeyEvent, type Selection } from '@opentui/core'
import { Deferred, Effect } from 'effect'
import { RendererError } from './errors.ts'
import { getLog } from './log.ts'
/**
* The text a finished selection copies: the RENDERED text the user highlighted,
* verbatim (`getSelectedText()` does correct same-line merging). Markdown markers
* are concealed in the pretty render, so a partial selection cannot recover source —
* this copies exactly what was highlighted (the `/copy` command gives full source).
* Total by construction — a copy must NEVER throw out of an input/event handler
* (that would tear down the render loop).
*/
function selectionCopyText(selection: Selection): string {
try {
return selection.getSelectedText()
} catch (cause) {
getLog().warn('copy', 'getSelectedText failed', { cause: String(cause) })
return ''
}
}
export interface RendererOptions {
/** Mouse tracking on/off (from decoded display config). */
readonly mouse: boolean
/** When true, a blocking prompt owns Ctrl+C (cancel) — the global quit is suppressed (gotcha §8 #6). */
readonly isBlocked?: () => boolean
/**
* Ctrl+C handler (item 11). When set, it OWNS Ctrl+C while not blocked — the
* entry's state machine decides interrupt-the-turn vs quit. When omitted, the
* default is an immediate `renderer.destroy()` (quit).
*/
readonly onCtrlC?: () => void
/**
* Copy a mouse selection (item 1). When there's a live selection, Ctrl+C copies
* it (this callback) instead of interrupting/quitting — opencode's selection
* key precedence (`app.tsx:388`). Receives the rendered text the user highlighted.
*/
readonly onCopySelection?: (text: string) => void
}
/**
* Acquire a CliRenderer inside the current scope and register its release.
* Returns the renderer plus a Deferred that resolves when the renderer is
* destroyed (user quit) — `await` it to keep the entry alive.
*/
export const acquireRenderer = Effect.fn('Renderer.acquire')(function* (options: RendererOptions) {
const renderer = yield* Effect.acquireRelease(
Effect.tryPromise({
try: () =>
createCliRenderer({
// scrollbox clips growing output → no terminal-scrollback corruption (gotcha §8 #2).
externalOutputMode: 'passthrough',
targetFps: 60,
// prompts own Ctrl+C → deny/cancel (gotcha §8 #6); the global quit is gated on !blocked.
exitOnCtrlC: false,
// OpenTUI's default exitSignals include SIGPIPE + SIGBUS, and its handler
// calls renderer.destroy() — so a broken clipboard pipe (writeClipboard
// spawning xclip/wl-copy that dies) raises SIGPIPE and QUITS THE TUI on
// copy. SIGPIPE/SIGBUS are not shutdown intents; restrict to the genuine
// termination signals so a stray pipe error can never tear down the UI.
exitSignals: ['SIGINT', 'SIGTERM', 'SIGQUIT', 'SIGHUP'],
useKittyKeyboard: {},
useMouse: options.mouse
}),
catch: cause => new RendererError({ cause })
}),
renderer => Effect.sync(() => destroyRenderer(renderer))
)
const shutdown = yield* Deferred.make<void>()
renderer.once('destroy', () => {
Deferred.doneUnsafe(shutdown, Effect.void)
})
// Global quit on Ctrl+C. `exitOnCtrlC:false` hands Ctrl+C to us as a key event
// (not SIGINT), so destroying here fires 'destroy' → resolves `shutdown` → the
// entry scope closes → finalizers run: renderer teardown + the gateway layer's
// `client.stop()` EOFs the Python child's stdin so it exits (no orphan). When a
// blocking prompt is up, it owns Ctrl+C (→ deny/cancel) so we suppress the quit
// (gotcha §8 #6) — the prompt's own handler sends the cancel reply.
const isBlocked = options.isBlocked ?? (() => false)
renderer.keyInput.on('keypress', (key: KeyEvent) => {
if (!(key.ctrl && key.name === 'c') || renderer.isDestroyed) return
// Copy a live mouse selection first (item 1) — takes precedence over the
// interrupt/quit machine and over a blocking prompt's cancel.
if (options.onCopySelection) {
const selection = renderer.getSelection()
const text = selection ? selectionCopyText(selection) : ''
if (text) {
options.onCopySelection(text)
renderer.clearSelection()
return
}
}
if (isBlocked()) return // a blocking prompt owns Ctrl+C (→ deny/cancel)
if (options.onCtrlC) options.onCtrlC()
else renderer.destroy()
})
// Copy-on-select (item 1 parity with free-code/Ink): the renderer's "selection"
// event fires ONCE when a free-form mouse selection COMPLETES (drag finish);
// auto-copy the spanned selectable text. Unlike the Ctrl+C path above we do NOT
// clearSelection() — the highlight persists so the user sees what was copied and
// Ctrl+C still works on it. `writeClipboard` is idempotent, so both paths writing
// the same text is harmless (no double-write bug). `CliRenderer extends
// EventEmitter`, so `on('selection', …)` is untyped → annotate `selection`.
const onCopy = options.onCopySelection
if (onCopy) {
renderer.on('selection', (selection: Selection) => {
const text = selectionCopyText(selection)
if (text) onCopy(text)
})
}
return { renderer, shutdown } as const
})
/** Best-effort renderer teardown; never throws out of the finalizer. */
function destroyRenderer(renderer: CliRenderer): void {
try {
if (!renderer.isDestroyed) renderer.destroy()
} catch {
// teardown is best-effort; a failed destroy must not mask the real exit cause.
}
}
+17
View File
@@ -0,0 +1,17 @@
/**
* Runtime composition — the single edge where layers are provided and the
* program is run (spec v4 §3.1). Layers are provided HERE by the caller
* (the launcher entry), never inside components. Mirrors opencode
* `cli/tui/layer.ts:6` + `cli/cmd/tui.ts` runMain.
*/
import { Layer } from 'effect'
import type { GatewayService } from './gateway/GatewayService.ts'
/**
* The application layer. Phase 0 takes the GatewayService layer as a parameter
* so the entry can choose Fake (dev/test) or — from Phase 1 — the live
* `tui_gateway`-spawning layer. Compose additional boundary services
* (Config, Theme-with-IO) here as they land.
*/
export const makeAppLayer = (gateway: Layer.Layer<GatewayService>) => Layer.mergeAll(gateway)
@@ -0,0 +1,254 @@
/**
* GatewayEvent — the wire event union, modeled as an Effect Schema and decoded
* ONCE at the transport boundary (spec v4 §3.3). Mirrors Ink's
* `ui-tui/src/gatewayTypes.ts:509-587` (discriminant = `type`).
*
* beta.78 API (verified vs .d.ts): variants are `Schema.Struct` with a
* `Schema.Literal` `type`, combined with `Schema.Union([...]).pipe(
* Schema.toTaggedUnion("type"))`. Optional fields use `Schema.optionalKey`
* (exact-optional under exactOptionalPropertyTypes). Decode unknown wire JSON
* with `Schema.decodeUnknownOption` so an UNRECOGNIZED `type` yields `Option.none`
* and is skipped — a stray event never tears down the stream.
*
* Types are INFERRED from the schema (`typeof X["Type"]`), never hand-declared.
*/
import { Schema } from 'effect'
const Str = Schema.String
const opt = Schema.optionalKey
// ── Skin (mirror GatewaySkin in ui-tui/src/gatewayTypes.ts) ───────────
export const GatewaySkinSchema = Schema.Struct({
banner_hero: opt(Str),
banner_logo: opt(Str),
branding: opt(Schema.Record(Str, Str)),
colors: opt(Schema.Record(Str, Str)),
help_header: opt(Str),
tool_prefix: opt(Str)
})
export type GatewaySkinDecoded = typeof GatewaySkinSchema.Type
// ── Variant schemas (one per wire `type`) ─────────────────────────────
// lifecycle
const GatewayReady = Schema.Struct({
type: Schema.Literal('gateway.ready'),
session_id: opt(Str),
payload: opt(Schema.Struct({ skin: opt(GatewaySkinSchema) }))
})
const SkinChanged = Schema.Struct({
type: Schema.Literal('skin.changed'),
session_id: opt(Str),
payload: opt(GatewaySkinSchema)
})
const SessionInfoEvent = Schema.Struct({
type: Schema.Literal('session.info'),
session_id: opt(Str),
// SessionInfo is large + evolving; keep it loose at the boundary (Record),
// the chrome phase narrows the fields it actually reads.
payload: Schema.Record(Str, Schema.Unknown)
})
// streaming text
const MessageStart = Schema.Struct({ type: Schema.Literal('message.start'), session_id: opt(Str) })
const MessageDelta = Schema.Struct({
type: Schema.Literal('message.delta'),
session_id: opt(Str),
payload: opt(Schema.Struct({ text: opt(Str), rendered: opt(Str) }))
})
const MessageComplete = Schema.Struct({
type: Schema.Literal('message.complete'),
session_id: opt(Str),
// `usage` carries the post-turn token/context totals → refreshes the status bar
// (item 14). Kept loose (Record) — the chrome reader narrows what it needs.
payload: opt(Schema.Struct({ text: opt(Str), rendered: opt(Str), usage: opt(Schema.Record(Str, Schema.Unknown)) }))
})
// reasoning / thinking — toTaggedUnion needs ONE literal per member, so the
// reasoning.delta/reasoning.available pair is two structs sharing a shape.
const ReasoningShape = {
session_id: opt(Str),
payload: opt(Schema.Struct({ text: opt(Str), verbose: opt(Schema.Boolean) }))
}
const ReasoningDelta = Schema.Struct({ type: Schema.Literal('reasoning.delta'), ...ReasoningShape })
const ReasoningAvailable = Schema.Struct({ type: Schema.Literal('reasoning.available'), ...ReasoningShape })
const ThinkingDelta = Schema.Struct({
type: Schema.Literal('thinking.delta'),
session_id: opt(Str),
payload: opt(Schema.Struct({ text: opt(Str) }))
})
// tools
const ToolStart = Schema.Struct({
type: Schema.Literal('tool.start'),
session_id: opt(Str),
payload: Schema.Record(Str, Schema.Unknown)
})
const ToolComplete = Schema.Struct({
type: Schema.Literal('tool.complete'),
session_id: opt(Str),
payload: Schema.Record(Str, Schema.Unknown)
})
const ToolProgress = Schema.Struct({
type: Schema.Literal('tool.progress'),
session_id: opt(Str),
payload: Schema.Struct({ name: opt(Str), preview: opt(Str) })
})
const ToolGenerating = Schema.Struct({
type: Schema.Literal('tool.generating'),
session_id: opt(Str),
payload: Schema.Struct({ name: opt(Str) })
})
// blocking prompts (deadlock-critical — Phase 3 renders these)
const ClarifyRequest = Schema.Struct({
type: Schema.Literal('clarify.request'),
session_id: opt(Str),
payload: Schema.Struct({
choices: opt(Schema.NullOr(Schema.Array(Str))),
question: opt(Str),
request_id: Str
})
})
const ApprovalRequest = Schema.Struct({
type: Schema.Literal('approval.request'),
session_id: opt(Str),
payload: Schema.Struct({ command: Str, description: Str })
})
const SudoRequest = Schema.Struct({
type: Schema.Literal('sudo.request'),
session_id: opt(Str),
payload: Schema.Struct({ request_id: Str })
})
const SecretRequest = Schema.Struct({
type: Schema.Literal('secret.request'),
session_id: opt(Str),
payload: Schema.Struct({ env_var: Str, prompt: Str, request_id: Str })
})
// chrome / agent
const StatusUpdate = Schema.Struct({
type: Schema.Literal('status.update'),
session_id: opt(Str),
payload: opt(Schema.Struct({ kind: opt(Str), text: opt(Str) }))
})
const NotificationShow = Schema.Struct({
type: Schema.Literal('notification.show'),
session_id: opt(Str),
payload: Schema.Record(Str, Schema.Unknown)
})
const NotificationClear = Schema.Struct({
type: Schema.Literal('notification.clear'),
session_id: opt(Str),
payload: opt(Schema.Struct({ key: opt(Str) }))
})
const VoiceStatus = Schema.Struct({
type: Schema.Literal('voice.status'),
session_id: opt(Str),
payload: opt(Schema.Struct({ state: opt(Schema.Literals(['idle', 'listening', 'transcribing'])) }))
})
const VoiceTranscript = Schema.Struct({
type: Schema.Literal('voice.transcript'),
session_id: opt(Str),
payload: opt(Schema.Struct({ no_speech_limit: opt(Schema.Boolean), text: opt(Str) }))
})
const BrowserProgress = Schema.Struct({
type: Schema.Literal('browser.progress'),
session_id: opt(Str),
payload: Schema.Record(Str, Schema.Unknown)
})
const BackgroundComplete = Schema.Struct({
type: Schema.Literal('background.complete'),
session_id: opt(Str),
payload: Schema.Struct({ task_id: Str, text: Str })
})
const ReviewSummary = Schema.Struct({
type: Schema.Literal('review.summary'),
session_id: opt(Str),
payload: opt(Schema.Struct({ text: opt(Str) }))
})
const SubagentShape = { session_id: opt(Str), payload: Schema.Record(Str, Schema.Unknown) }
const SubagentSpawnRequested = Schema.Struct({ type: Schema.Literal('subagent.spawn_requested'), ...SubagentShape })
const SubagentStart = Schema.Struct({ type: Schema.Literal('subagent.start'), ...SubagentShape })
const SubagentThinking = Schema.Struct({ type: Schema.Literal('subagent.thinking'), ...SubagentShape })
const SubagentTool = Schema.Struct({ type: Schema.Literal('subagent.tool'), ...SubagentShape })
const SubagentProgress = Schema.Struct({ type: Schema.Literal('subagent.progress'), ...SubagentShape })
const SubagentComplete = Schema.Struct({ type: Schema.Literal('subagent.complete'), ...SubagentShape })
// transport errors
const ErrorEvent = Schema.Struct({
type: Schema.Literal('error'),
session_id: opt(Str),
payload: opt(Schema.Struct({ message: opt(Str) }))
})
const GatewayStderr = Schema.Struct({
type: Schema.Literal('gateway.stderr'),
session_id: opt(Str),
payload: Schema.Struct({ line: Str })
})
const GatewayStartTimeout = Schema.Struct({
type: Schema.Literal('gateway.start_timeout'),
session_id: opt(Str),
payload: Schema.Record(Str, Schema.Unknown)
})
const GatewayProtocolError = Schema.Struct({
type: Schema.Literal('gateway.protocol_error'),
session_id: opt(Str),
payload: opt(Schema.Struct({ preview: opt(Str) }))
})
// gateway lifecycle recovery (auto-heal): the child exited (crash/kill) and the
// transport is respawning+resuming the session. Surfaced so the frozen spinner
// clears and the user sees the in-flight reply was lost (see store cases).
const GatewayExited = Schema.Struct({
type: Schema.Literal('gateway.exited'),
session_id: opt(Str),
payload: opt(Schema.Struct({ reason: opt(Str), code: opt(Schema.Number), signal: opt(Str) }))
})
const GatewayRecovering = Schema.Struct({
type: Schema.Literal('gateway.recovering'),
session_id: opt(Str),
payload: opt(Schema.Struct({ attempt: opt(Schema.Number), delay_ms: opt(Schema.Number) }))
})
// ── The union ─────────────────────────────────────────────────────────
export const GatewayEventSchema = Schema.Union([
GatewayReady,
SkinChanged,
SessionInfoEvent,
MessageStart,
MessageDelta,
MessageComplete,
ReasoningDelta,
ReasoningAvailable,
ThinkingDelta,
ToolStart,
ToolComplete,
ToolProgress,
ToolGenerating,
ClarifyRequest,
ApprovalRequest,
SudoRequest,
SecretRequest,
StatusUpdate,
NotificationShow,
NotificationClear,
VoiceStatus,
VoiceTranscript,
BrowserProgress,
BackgroundComplete,
ReviewSummary,
SubagentSpawnRequested,
SubagentStart,
SubagentThinking,
SubagentTool,
SubagentProgress,
SubagentComplete,
ErrorEvent,
GatewayStderr,
GatewayStartTimeout,
GatewayProtocolError,
GatewayExited,
GatewayRecovering
]).pipe(Schema.toTaggedUnion('type'))
/** The decoded, typed event. Inferred from the schema — never hand-declared. */
export type GatewayEvent = typeof GatewayEventSchema.Type
@@ -0,0 +1,99 @@
/**
* SessionInfo + Catalog decoders — the decode-at-boundary idiom (spec v4 §3.3),
* mirroring GatewayEvent.ts. These two payloads are UNTRUSTED loose JSON from the
* Python `tui_gateway` (`session.info` event / `session.create`/`resume` result
* `info`, and the `startup.catalog` RPC result), so they are decoded ONCE with an
* Effect Schema instead of hand-rolled `as`-cast readers.
*
* Decode with `Schema.decodeUnknownOption`: a malformed/partial payload yields
* `Option.none` and the caller falls back to an empty patch / leaves the catalog
* unset — a stray shape never crashes the reducer.
*
* Wire field names are verified against `tui_gateway/server.py`:
* - session.info → `_session_info()` (server.py:~1830): top-level `model`,
* `reasoning_effort`, `fast`, `cwd`, `branch`, `running`, plus a nested
* `usage` (`_get_usage()`, server.py:~1698) carrying `context_used`,
* `context_max`, `context_percent`, `compressions` (context_* only present
* when the compressor knows a context length).
* - startup.catalog → `@method("startup.catalog")` (server.py:~8521):
* `{ tools:{total, toolsets:[{name,count,enabled,tools}]},
* skills:{total, categories:[{name,count}]}, mcp:{servers:[]} }`.
*
* These schemas are used PURELY as decoders; they do NOT Effect-ify the store's
* reactivity or control flow (Solid stays the runtime — spec v4 §1).
*/
import { Schema } from 'effect'
const Str = Schema.String
const Num = Schema.Number
const Bool = Schema.Boolean
const opt = Schema.optionalKey
// ── session.info / session.create.info ────────────────────────────────
// Context/usage numbers arrive nested under `usage`; the same names may also
// appear at the top level depending on the RPC vs event path (the reader prefers
// `usage.context_*`, then the top-level fallback). All keys are optional — a
// `session.info` patch only carries the fields that actually changed.
const UsageSchema = Schema.Struct({
context_used: opt(Num),
context_max: opt(Num),
context_percent: opt(Num),
compressions: opt(Num)
})
export const SessionInfoPatchSchema = Schema.Struct({
model: opt(Str),
reasoning_effort: opt(Str),
fast: opt(Bool),
cwd: opt(Str),
branch: opt(Str),
running: opt(Bool),
// top-level context fallback (used when there's no nested `usage`)
context_used: opt(Num),
context_max: opt(Num),
context_percent: opt(Num),
compressions: opt(Num),
usage: opt(UsageSchema)
})
export type SessionInfoPatchDecoded = typeof SessionInfoPatchSchema.Type
/** Decode a loose session.info payload → `Option<SessionInfoPatchDecoded>`. */
export const decodeSessionInfoPatch = Schema.decodeUnknownOption(SessionInfoPatchSchema)
// ── startup.catalog ───────────────────────────────────────────────────
// Mirrors the `Catalog` interface in store.ts. `enabled` defaults to true at the
// reader (an absent flag means on), so it stays optional here.
const ToolsetSchema = Schema.Struct({
name: opt(Str),
count: opt(Num),
enabled: opt(Bool),
tools: opt(Schema.Array(Schema.Unknown))
})
const CategorySchema = Schema.Struct({
name: opt(Str),
count: opt(Num)
})
export const CatalogSchema = Schema.Struct({
tools: opt(
Schema.Struct({
total: opt(Num),
toolsets: opt(Schema.Array(ToolsetSchema))
})
),
skills: opt(
Schema.Struct({
total: opt(Num),
categories: opt(Schema.Array(CategorySchema))
})
),
mcp: opt(
Schema.Struct({
servers: opt(Schema.Array(Schema.Unknown))
})
)
})
export type CatalogDecoded = typeof CatalogSchema.Type
/** Decode a loose startup.catalog result → `Option<CatalogDecoded>`. */
export const decodeCatalog = Schema.decodeUnknownOption(CatalogSchema)