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:
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
) {}
|
||||
@@ -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)
|
||||
)
|
||||
)
|
||||
@@ -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, '../../../../')
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user