diff --git a/sdk/typescript/src/cli/codex.ts b/sdk/typescript/src/cli/codex.ts index bf325df1..4f2b93dd 100644 --- a/sdk/typescript/src/cli/codex.ts +++ b/sdk/typescript/src/cli/codex.ts @@ -78,6 +78,19 @@ export async function runClaudeCommand( * caller wants to wrap a specific session, so route to the wrapper and honor * them; the TUI is reserved for the argument-free onboarding entry. */ +/** + * `tinyplace opencode` mirrors `tinyplace codex`/`claude` — the same three modes. + * opencode has no per-session transcript files, so its bridge observes the live + * session over the local server's SSE bus (`opencode serve` + `opencode attach`) + * rather than tailing files; the mode dispatch is identical. + */ +export async function runOpencodeCommand( + argv: Array, + options: TinyPlaceCliOptions = {}, +): Promise { + return runHarnessAgentCommand("opencode", argv, options); +} + async function runHarnessAgentCommand( harness: TinyVerseAgentKind, argv: Array, diff --git a/sdk/typescript/src/cli/commands.ts b/sdk/typescript/src/cli/commands.ts index f6a08293..8e3041ab 100644 --- a/sdk/typescript/src/cli/commands.ts +++ b/sdk/typescript/src/cli/commands.ts @@ -198,6 +198,14 @@ export const HARNESS_CLI_COMMANDS: Array = [ "Launch the tiny.place TUI wrapping Claude Code: shows the active session + OpenHuman connection and runs the bidirectional bridge (publish keys, stream turns, inject inbound DMs). Use --raw for the headless transparent wrapper (no UI), or --agent to boot a first-class agent session via the unified plugin (own wallet + MCP tools, auto-reply off by default).", usage: "[--raw | --agent [--wallet ] [--autorespond]] [--tinyplace-dm-to ] [--tinyplace-out ] [--tinyplace-scope folder|session] [--tinyplace-bucket minute|hour|day] [--] ", }, + { + name: "opencode", + // Parity sibling of codex/claude — groups with them in help. + capability: "maintenance", + description: + "Launch the tiny.place TUI wrapping OpenCode: shows the active session + OpenHuman connection and runs the bidirectional bridge. opencode has no per-session files, so the bridge observes the live session over a local `opencode serve` SSE bus (it launches the server and `opencode attach`es to it). Use --raw for the headless transparent wrapper (no UI), or --agent to boot a first-class agent session via the unified plugin.", + usage: "[--raw | --agent [--wallet ] [--autorespond]] [--tinyplace-dm-to ] [--tinyplace-out ] [--tinyplace-scope folder|session] [--tinyplace-bucket minute|hour|day] [--] ", + }, { name: "tui", capability: "workflow", diff --git a/sdk/typescript/src/cli/harness-events.ts b/sdk/typescript/src/cli/harness-events.ts index f0883bc6..061e72d8 100644 --- a/sdk/typescript/src/cli/harness-events.ts +++ b/sdk/typescript/src/cli/harness-events.ts @@ -606,6 +606,340 @@ function openCodeOutputText(output: unknown): string { return safeStringify(output); } +// ── OpenCode server bus (SSE `/event`) ─────────────────────────────────────── +// +// The daemon path (`opencode run --format json`, above) emits a FLAT line shape +// (`{type:"text"|"tool"|"error", part:{…}}`). The interactive wrapper instead +// attaches to `opencode serve` and reads its `GET /event` SSE bus, whose frames +// are NESTED (`{type:"message.part.updated", properties:{sessionID, part}}`) and +// carry session identity out of band (no per-session file to key on). This +// mapper folds those bus frames into the same `HarnessSemanticEvent` model, +// returning the routing keys (`sessionID`/`directory`) alongside the events so +// the event source can filter the global stream down to its own session. +// +// Verified against a live `opencode serve` OpenAPI schema (2026-07-13): +// message.part.updated properties:{ sessionID, part:, time } +// Part union on part.type: text | reasoning | tool | step-* | file | … +// ToolPart: { callID, tool, state:{ status: pending|running|completed|error, +// input, output } }; text/reasoning carry a growing `text` and a +// { start, end? } `time`. +// message.updated properties:{ sessionID, info: } +// session.created|updated properties:{ info:{ id, directory } } +// session.error properties:{ error? } + +/** Routing keys + typed events folded from one SSE bus frame. */ +export interface OpenCodeBusMapping { + /** The session a `message.*` frame belongs to (filter key). */ + sessionID?: string; + /** A `session.*` frame's working directory (matched against the wrapper cwd). */ + directory?: string; + events: Array; +} + +/** A `Part` as it rides on the SSE bus (superset of the flat `OpenCodePart`). */ +interface OpenCodeBusPart extends OpenCodePart { + id?: string; + messageID?: string; + time?: { start?: number; end?: number }; +} + +/** + * Stateful bus mapper. Parts arrive as repeated snapshots of the same id/callID + * (a tool progresses pending→running→completed; a text part's `text` grows), so + * a stateless fold would emit a storm of partial duplicates. This mapper dedupes: + * • tool parts → exactly one `tool_call` (first pending/running snapshot) and + * one `tool_result` (first terminal snapshot; a synthetic call is emitted + * first if the terminal snapshot is the first we ever saw for that callID); + * • text/reasoning parts → one event when the part is terminal (`time.end`) + * or when its message's `message.updated` frame flushes it, whichever first. + * `flush()` drains any still-buffered text at stream end. Create ONE per session + * stream — the dedupe state must not leak across streams. + */ +export interface OpenCodeBusMapper { + next(raw: string, line: number): OpenCodeBusMapping; + flush(line: number): Array; +} + +interface BufferedText { + kind: "text" | "reasoning"; + messageID?: string; + text: string; + line: number; + timestamp: Date; +} + +export function createOpenCodeBusMapper(): OpenCodeBusMapper { + // callID → whether we've emitted its `tool_call` ("call") or `tool_result` + // ("result"). Absent = never seen. + const toolState = new Map(); + // messageID → author role, learned from `message.updated`, so a buffered text + // part is surfaced as an owner prompt vs an agent message. + const messageRole = new Map(); + // partId → latest buffered text/reasoning awaiting a terminal/flush signal. + const textBuffers = new Map(); + + function emitBuffered( + partId: string, + line: number, + ): Array { + const buffered = textBuffers.get(partId); + if (!buffered) { + return []; + } + textBuffers.delete(partId); + const text = buffered.text.trim(); + if (!text) { + return []; + } + if (buffered.kind === "reasoning") { + return [ + { + line, + timestamp: buffered.timestamp, + recordType: "opencode:reasoning", + event: { + kind: "agent_thinking", + role: "agent", + payload: { text }, + }, + }, + ]; + } + const role = buffered.messageID + ? messageRole.get(buffered.messageID) + : undefined; + if (role === "user") { + return [userPromptEvent(line, buffered.timestamp, text)]; + } + return [ + { + line, + timestamp: buffered.timestamp, + recordType: "opencode:text", + event: { + kind: "agent_message", + role: "agent", + payload: { text }, + }, + }, + ]; + } + + function mapToolPart( + part: OpenCodeBusPart, + line: number, + timestamp: Date, + ): Array { + const toolName = String(part.tool); + const callId = asString(part.callID) ?? asString(part.id) ?? ""; + const status = (part.state?.status ?? "").toLowerCase(); + const output = part.state?.output; + const terminal = + OPENCODE_TERMINAL_STATES.has(status) || + (output !== undefined && output !== null && output !== ""); + const prev = toolState.get(callId); + + const callEvent = (): HarnessSemanticEvent => ({ + line, + timestamp, + recordType: "opencode:tool_call", + event: { + kind: "tool_call", + role: "agent", + payload: { + call_id: callId, + tool_name: toolName, + tool_kind: normalizeToolKind(toolName), + display: toolDisplay(toolName, part.state?.input), + input: boundToolInput(part.state?.input), + }, + }, + }); + + if (terminal) { + if (prev === "result") { + return []; + } + const outputText = openCodeOutputText(output); + const isError = status === "error"; + const events: Array = []; + if (prev !== "call") { + events.push(callEvent()); + } + events.push({ + line, + timestamp, + recordType: "opencode:tool_result", + event: { + kind: "tool_result", + role: "agent", + payload: { + call_id: callId, + ok: !isError, + is_error: isError, + output: truncate(outputText), + output_bytes: byteLength(outputText), + }, + }, + }); + toolState.set(callId, "result"); + return events; + } + + if (prev) { + return []; + } + toolState.set(callId, "call"); + return [callEvent()]; + } + + function mapPart( + part: OpenCodeBusPart, + line: number, + frameTime: Date, + ): Array { + const type = asString(part.type); + if (type === "tool" && part.tool) { + return mapToolPart(part, line, frameTime); + } + if (type === "text" || type === "reasoning") { + const partId = asString(part.id) ?? ""; + if (!partId || typeof part.text !== "string") { + return []; + } + const timestamp = part.time?.start + ? new Date(part.time.start) + : frameTime; + textBuffers.set(partId, { + kind: type, + ...(asString(part.messageID) ? { messageID: part.messageID } : {}), + text: part.text, + line, + timestamp, + }); + // A part with an end time is final — surface it now. + if (part.time?.end !== undefined) { + return emitBuffered(partId, line); + } + return []; + } + return []; + } + + return { + next(raw: string, line: number): OpenCodeBusMapping { + const record = parseJsonObject(raw); + if (!record) { + return { events: [] }; + } + const type = asString(record.type); + const properties = asObject(record.properties) ?? {}; + const frameTime = opencodeBusTimestamp(properties.time); + + if (type === "session.created" || type === "session.updated") { + const info = asObject(properties.info); + return { + ...(asString(info?.id) ? { sessionID: asString(info?.id) } : {}), + ...(asString(info?.directory) + ? { directory: asString(info?.directory) } + : {}), + events: [], + }; + } + + if (type === "session.error") { + const message = + describeOpenCodeError(properties.error) ?? + safeStringify(properties.error ?? record); + return { + ...(asString(properties.sessionID) + ? { sessionID: asString(properties.sessionID) } + : {}), + events: [ + { + line, + timestamp: frameTime, + recordType: "opencode:session.error", + event: { + kind: "error", + role: "agent", + payload: { message: truncate(message), fatal: false }, + }, + }, + ], + }; + } + + if (type === "message.updated") { + const info = asObject(properties.info); + const messageID = asString(info?.id); + const role = asString(info?.role); + if (messageID && (role === "user" || role === "assistant")) { + messageRole.set(messageID, role); + } + // Flush any buffered text for this message now that its role is known. + const events: Array = []; + if (messageID) { + for (const [partId, buffered] of [...textBuffers.entries()]) { + if (buffered.messageID === messageID) { + events.push(...emitBuffered(partId, line)); + } + } + } + return { + ...(asString(properties.sessionID) + ? { sessionID: asString(properties.sessionID) } + : {}), + events, + }; + } + + if (type === "message.part.updated") { + const part = asObject(properties.part) as OpenCodeBusPart | undefined; + return { + ...(asString(properties.sessionID) + ? { sessionID: asString(properties.sessionID) } + : {}), + events: part ? mapPart(part, line, frameTime) : [], + }; + } + + // server.connected, session.next.*, and everything else: routing no-ops. + return { events: [] }; + }, + + flush(line: number): Array { + const events: Array = []; + for (const partId of [...textBuffers.keys()]) { + events.push(...emitBuffered(partId, line)); + } + return events; + }, + }; +} + +/** + * Stateless single-frame fold — routing keys + a naive event view of one bus + * frame (no cross-frame dedup/buffering). Handy for tests and callers that only + * need routing; the live wrapper uses `createOpenCodeBusMapper` for de-duped + * streaming. A fresh mapper per call means a text/reasoning part only surfaces + * when the frame is already terminal (`time.end`). + */ +export function opencodeEventsFromBusEvent( + raw: string, + line: number, +): OpenCodeBusMapping { + return createOpenCodeBusMapper().next(raw, line); +} + +/** Bus frames stamp `time` as epoch ms; fall back to receive time. */ +function opencodeBusTimestamp(value: unknown): Date { + if (typeof value === "number" && Number.isFinite(value)) { + return new Date(value); + } + return parseTimestamp(value); +} + // ── shared helpers ─────────────────────────────────────────────────────────── function userPromptEvent( diff --git a/sdk/typescript/src/cli/harness-wrapper.ts b/sdk/typescript/src/cli/harness-wrapper.ts index 43133dc2..7f76645d 100644 --- a/sdk/typescript/src/cli/harness-wrapper.ts +++ b/sdk/typescript/src/cli/harness-wrapper.ts @@ -51,6 +51,12 @@ import { type SessionStatusState, } from "./harness-status.js"; import { makeContext } from "./context.js"; +import { makePathLookup } from "./daemon/providers.js"; +import { + OpenCodeEventSource, + startOpenCodeServer, + type OpenCodeServerHandle, +} from "./opencode-source.js"; import type { TinyPlaceCliOptions, TinyPlaceCliResult } from "./types.js"; import type { Writable } from "node:stream"; @@ -162,7 +168,7 @@ interface SessionMeta { sessionId: string; } -interface SemanticMessage { +export interface SemanticMessage { line: number; phase?: string; recordType: string; @@ -229,15 +235,67 @@ export async function runHarnessCommand( }; const writer = new TerminalEnvelopeWriter(config, cwd, stdio.stderr); const publisher = new SessionEnvelopePublisher(config, options, stdio.stderr); - const sessionTailer = config.captureSession - ? new HarnessSessionTailer(config, cwd, stdio.stderr, publisher) - : undefined; const receiver = config.receiveEnabled ? new InboundMessageReceiver(config, publisher, stdio.stderr) : undefined; - const launch = buildAgentLaunch(config); const usePty = config.usePty && options.spawn === undefined; + // opencode has no per-session transcript files — it is observed over its + // HTTP server's SSE bus instead of the file tailer, and only when a real TTY + // is present (its bridge attaches to an interactive `opencode attach` TUI). + const opencodeBridge = + provider === "opencode" && config.captureSession && usePty; + + const sessionTailer = + config.captureSession && provider !== "opencode" + ? new HarnessSessionTailer(config, cwd, stdio.stderr, publisher) + : undefined; + + let opencodeServer: OpenCodeServerHandle | undefined; + let opencodeSource: OpenCodeEventSource | undefined; + let launch = buildAgentLaunch(config); + + if (opencodeBridge) { + if (!makePathLookup(env)(config.agentBin)) { + return { + code: 1, + stdout: "", + stderr: `${JSON.stringify( + { + error: + `opencode not found on PATH as \`${config.agentBin}\`. Install it ` + + "(https://opencode.ai) or set TINYPLACE_OPENCODE_BIN to its path.", + }, + null, + 2, + )}\n`, + }; + } + opencodeServer = await startOpenCodeServer({ + bin: config.agentBin, + cwd, + env, + }); + opencodeSource = new OpenCodeEventSource( + config, + cwd, + stdio.stderr, + publisher, + ); + opencodeSource.start(`${opencodeServer.url}/event`); + // Attach the interactive TUI to the server we observe, so the human's + // session and our SSE bridge share one process. + launch = { + command: config.agentBin, + args: ["attach", opencodeServer.url, ...config.agentArgs], + }; + } else if (provider === "opencode" && config.captureSession && !usePty) { + stdio.stderr.write( + "[tinyplace] opencode's session bridge needs a TTY (it attaches to the " + + "interactive TUI); running opencode without the OpenHuman bridge.\n", + ); + } + writer.pty = usePty; writer.write( "lifecycle", @@ -254,22 +312,33 @@ export async function runHarnessCommand( const onInputSink = receiver ? (write: (text: string) => void): void => receiver.setSink(write) : undefined; - const exitCode = usePty - ? await runPtyAgent(launch, config, cwd, env, writer, stdio, onInputSink) - : await runPipeAgent( - launch, - config, - cwd, - env, - writer, - stdio, - spawnFn, - onInputSink, - ); - const dmFailures = (await sessionTailer?.stop()) ?? 0; - if (receiver) { - await receiver.stop(); + let exitCode = 1; + let dmFailures = 0; + try { + exitCode = usePty + ? await runPtyAgent(launch, config, cwd, env, writer, stdio, onInputSink) + : await runPipeAgent( + launch, + config, + cwd, + env, + writer, + stdio, + spawnFn, + onInputSink, + ); + } finally { + // Teardown order: stop the session observer (flushes the publisher) → stop + // inbound → kill the opencode server. In a `finally` so a spawn failure + // still tears the server down instead of leaking a `serve` process. + dmFailures = opencodeSource + ? await opencodeSource.stop().catch(() => 0) + : ((await sessionTailer?.stop()) ?? 0); + if (receiver) { + await receiver.stop(); + } + await opencodeServer?.stop().catch(() => undefined); } return { @@ -655,6 +724,214 @@ function buildAgentLaunch(config: HarnessWrapperConfig): AgentLaunch { return { command: config.agentBin, args: config.agentArgs }; } +/** + * The session identity a set of envelopes is framed against. The file tailer + * derives it from a located transcript file (`sessionMeta.sessionId` + path); + * the opencode SSE source derives it from a `session.created` bus event + + * a synthetic `opencode-sse://…` path. Threaded per-emit so one emitter can + * serve either source without baking in a file identity. + */ +export interface EmitIdentity { + sessionId: string; + sourcePath: string; +} + +/** + * The shared v1-message + v2-typed-event + status emit machinery, factored out + * of `HarnessSessionTailer` so a non-file session source (opencode's SSE bus) + * can reuse the exact envelope framing, output-file writing, dedup-free publish, + * and status derivation. Owns only the emit-side stream state (status, v2 seq, + * heartbeat) — the source owns discovery/identity and hands an `EmitIdentity` + * to every call. Behavior is byte-identical to the pre-extraction tailer. + */ +export class SessionEnvelopeEmitter { + private status: SessionStatusState = initialStatus(); + private v2Seq = 0; + private lastHeartbeatMs = 0; + // Highest source line seen — used as the `line` on derived status events. + private lastLine = 0; + + public constructor( + private readonly config: HarnessWrapperConfig, + private readonly cwd: string, + private readonly dryRunOutput: Writable, + private readonly publisher: SessionEnvelopePublisher, + ) {} + + /** Emit one v1 session-message envelope. */ + public emitV1Message(message: SemanticMessage, id: EmitIdentity): void { + this.lastLine = Math.max(this.lastLine, message.line); + const bucketStart = floorTimestamp(message.timestamp, this.config.bucket); + const bucketEnd = addBucket(bucketStart, this.config.bucket); + const envelope: SessionEnvelope = { + envelope_version: SESSION_ENVELOPE_VERSION_V1, + version: 1, + bucket: { + unit: this.config.bucket, + start: formatTimestamp(bucketStart), + end: formatTimestamp(bucketEnd), + }, + scope: { + type: this.config.scope, + key: this.scopeKey(), + cwd: this.cwd, + wrapper_session_id: this.config.wrapperSessionId, + harness_session_id: id.sessionId, + }, + harness: { + provider: this.config.provider, + command: this.config.agentBin, + argv: this.config.agentArgs, + }, + message: { + id: stableEventId( + id.sessionId, + message.role, + message.line, + message.text, + ), + line: message.line, + ...(message.phase ? { phase: message.phase } : {}), + role: message.role, + text: message.text, + timestamp: formatTimestamp(message.timestamp), + }, + source: { + path: id.sourcePath, + record_type: message.recordType, + ...(message.sourceRole ? { source_role: message.sourceRole } : {}), + }, + }; + bridgeLog("emit.message", { + id: envelope.message.id, + role: message.role, + line: message.line, + recordType: message.recordType, + textPreview: message.text, + }); + this.writeEnvelope(envelope); + this.publisher.publish(envelope); + } + + /** Emit one typed v2 event, then the status transition it implies. */ + public emitV2Event(semantic: HarnessSemanticEvent, id: EmitIdentity): void { + this.lastLine = Math.max(this.lastLine, semantic.line); + const ctx = this.v2Context(id); + const envelope = buildEventEnvelopeV2(ctx, semantic, this.nextV2Seq()); + bridgeLog("emit.v2.event", { + id: envelope.event.id, + seq: envelope.event.seq, + kind: envelope.event.kind, + line: semantic.line, + }); + this.writeEnvelope(envelope); + this.publisher.publish(envelope); + + const step = reduceStatus(this.status, semantic); + this.status = step.next; + if (step.emit) { + this.publishStatus(step.emit, Date.now(), id); + } + } + + /** Age a silent session toward idle and emit a periodic heartbeat status. */ + public tick(id: EmitIdentity): void { + const now = Date.now(); + const heartbeat = + now - this.lastHeartbeatMs >= this.config.statusHeartbeatMs; + const step = tickStatus(this.status, now, { + idleAfterMs: this.config.statusIdleMs, + heartbeat, + }); + this.status = step.next; + if (step.emit) { + this.lastHeartbeatMs = now; + this.publishStatus(step.emit, now, id); + } + } + + private publishStatus( + payload: StatusPayload, + nowMs: number, + id: EmitIdentity, + ): void { + const semantic: HarnessSemanticEvent = { + line: this.lastLine, + timestamp: new Date(nowMs), + recordType: "derived:status", + event: { kind: "status", role: "agent", payload }, + }; + const ctx = this.v2Context(id); + const envelope = buildEventEnvelopeV2(ctx, semantic, this.nextV2Seq()); + this.writeEnvelope(envelope); + this.publisher.publish(envelope); + } + + private v2Context(id: EmitIdentity): EnvelopeContext { + return { + provider: this.config.provider, + command: this.config.agentBin, + argv: this.config.agentArgs, + scopeType: this.config.scope, + scopeKey: this.scopeKey(), + cwd: this.cwd, + wrapperSessionId: this.config.wrapperSessionId, + harnessSessionId: id.sessionId, + bucketUnit: this.config.bucket, + sourcePath: id.sourcePath, + }; + } + + private nextV2Seq(): number { + const seq = this.v2Seq; + this.v2Seq += 1; + return seq; + } + + private writeEnvelope(envelope: AnySessionEnvelope): void { + const encoded = `${JSON.stringify(envelope)}\n`; + if (this.config.dryRun) { + this.dryRunOutput.write(encoded); + return; + } + const target = this.outputPath(envelope); + mkdirSync(resolve(target, ".."), { recursive: true }); + writeFileSync(target, encoded, { encoding: "utf8", flag: "a" }); + } + + private outputPath(envelope: AnySessionEnvelope): string { + const bucketStart = new Date(envelope.bucket.start); + const fileName = bucketFileName(bucketStart, this.config.bucket); + if (this.config.scope === "session") { + return join( + this.config.outDir, + "messages", + "sessions", + safeSlug(this.config.wrapperSessionId), + fileName, + ); + } + return join( + this.config.outDir, + "messages", + "folders", + safeSlug(this.scopeKey()), + fileName, + ); + } + + private scopeKey(): string { + if (this.config.scope === "session") { + return this.config.wrapperSessionId; + } + const digest = createHash("sha256") + .update(this.cwd) + .digest("hex") + .slice(0, 12); + return `${basename(this.cwd) || "root"}-${digest}`; + } +} + export class HarnessSessionTailer { private ignoredSessionFiles = new Set(); private lineOffset = 0; @@ -668,10 +945,8 @@ export class HarnessSessionTailer { private sessionFile: string | undefined; private sessionMeta: SessionMeta | undefined; private timer: ReturnType | undefined; - // v2 typed-event stream state (only used when config.emitV2 is on). - private status: SessionStatusState = initialStatus(); - private v2Seq = 0; - private lastHeartbeatMs = 0; + // Shared v1/v2/status emit machinery (envelope framing + publish + output). + private readonly emitter: SessionEnvelopeEmitter; // Stateful per-stream mapper: dedupes codex's double-recorded assistant // message (event_msg + response_item for the same turn). private readonly mapEventsFromLine: HarnessLineMapper; @@ -683,6 +958,23 @@ export class HarnessSessionTailer { private readonly publisher: SessionEnvelopePublisher, ) { this.mapEventsFromLine = createHarnessLineMapper(config.provider); + this.emitter = new SessionEnvelopeEmitter( + config, + cwd, + dryRunOutput, + publisher, + ); + } + + /** The located file's identity, for handing to the shared emitter. */ + private identity(): EmitIdentity | undefined { + if (!this.sessionFile || !this.sessionMeta) { + return undefined; + } + return { + sessionId: this.sessionMeta.sessionId, + sourcePath: this.sessionFile, + }; } public start(startedAt: Date): void { @@ -758,6 +1050,10 @@ export class HarnessSessionTailer { }); } + const id = this.identity(); + if (!id) { + return; + } const lines = readNewLines(this.sessionFile, this.lineOffset); this.lineOffset += lines.length; if (lines.length > 0) { @@ -769,11 +1065,11 @@ export class HarnessSessionTailer { line, )) { semanticCount += 1; - this.write(message); + this.emitter.emitV1Message(message, id); } if (this.config.emitV2) { for (const event of this.mapEventsFromLine(raw, line)) { - this.writeV2(event); + this.emitter.emitV2Event(event, id); } } } @@ -785,7 +1081,7 @@ export class HarnessSessionTailer { } // Even with no new lines, age a silent session toward idle and heartbeat. if (this.config.emitV2) { - this.tickV2Status(); + this.emitter.tick(id); } } @@ -839,184 +1135,6 @@ export class HarnessSessionTailer { } return candidates[0]; } - - private write(message: SemanticMessage): void { - if (!this.sessionFile || !this.sessionMeta) { - return; - } - const bucketStart = floorTimestamp(message.timestamp, this.config.bucket); - const bucketEnd = addBucket(bucketStart, this.config.bucket); - const envelope: SessionEnvelope = { - envelope_version: SESSION_ENVELOPE_VERSION_V1, - version: 1, - bucket: { - unit: this.config.bucket, - start: formatTimestamp(bucketStart), - end: formatTimestamp(bucketEnd), - }, - scope: { - type: this.config.scope, - key: this.scopeKey(), - cwd: this.cwd, - wrapper_session_id: this.config.wrapperSessionId, - harness_session_id: this.sessionMeta.sessionId, - }, - harness: { - provider: this.config.provider, - command: this.config.agentBin, - argv: this.config.agentArgs, - }, - message: { - id: stableEventId( - this.sessionMeta.sessionId, - message.role, - message.line, - message.text, - ), - line: message.line, - ...(message.phase ? { phase: message.phase } : {}), - role: message.role, - text: message.text, - timestamp: formatTimestamp(message.timestamp), - }, - source: { - path: this.sessionFile, - record_type: message.recordType, - ...(message.sourceRole ? { source_role: message.sourceRole } : {}), - }, - }; - bridgeLog("tailer.message", { - id: envelope.message.id, - role: message.role, - line: message.line, - recordType: message.recordType, - textPreview: message.text, - }); - this.writeEnvelope(envelope); - this.publisher.publish(envelope); - } - - /** Emit one typed v2 event, then the status transition it implies. */ - private writeV2(semantic: HarnessSemanticEvent): void { - if (!this.sessionFile || !this.sessionMeta) { - return; - } - const ctx = this.v2Context(this.sessionFile, this.sessionMeta); - const envelope = buildEventEnvelopeV2(ctx, semantic, this.nextV2Seq()); - bridgeLog("tailer.v2.event", { - id: envelope.event.id, - seq: envelope.event.seq, - kind: envelope.event.kind, - line: semantic.line, - }); - this.writeEnvelope(envelope); - this.publisher.publish(envelope); - - const step = reduceStatus(this.status, semantic); - this.status = step.next; - if (step.emit) { - this.publishStatus(step.emit, Date.now()); - } - } - - /** Age a silent session toward idle and emit a periodic heartbeat status. */ - private tickV2Status(): void { - if (!this.sessionFile || !this.sessionMeta) { - return; - } - const now = Date.now(); - const heartbeat = - now - this.lastHeartbeatMs >= this.config.statusHeartbeatMs; - const step = tickStatus(this.status, now, { - idleAfterMs: this.config.statusIdleMs, - heartbeat, - }); - this.status = step.next; - if (step.emit) { - this.lastHeartbeatMs = now; - this.publishStatus(step.emit, now); - } - } - - private publishStatus(payload: StatusPayload, nowMs: number): void { - if (!this.sessionFile || !this.sessionMeta) { - return; - } - const semantic: HarnessSemanticEvent = { - line: this.lineOffset, - timestamp: new Date(nowMs), - recordType: "derived:status", - event: { kind: "status", role: "agent", payload }, - }; - const ctx = this.v2Context(this.sessionFile, this.sessionMeta); - const envelope = buildEventEnvelopeV2(ctx, semantic, this.nextV2Seq()); - this.writeEnvelope(envelope); - this.publisher.publish(envelope); - } - - private v2Context(sessionFile: string, meta: SessionMeta): EnvelopeContext { - return { - provider: this.config.provider, - command: this.config.agentBin, - argv: this.config.agentArgs, - scopeType: this.config.scope, - scopeKey: this.scopeKey(), - cwd: this.cwd, - wrapperSessionId: this.config.wrapperSessionId, - harnessSessionId: meta.sessionId, - bucketUnit: this.config.bucket, - sourcePath: sessionFile, - }; - } - - private nextV2Seq(): number { - const seq = this.v2Seq; - this.v2Seq += 1; - return seq; - } - - private writeEnvelope(envelope: AnySessionEnvelope): void { - const encoded = `${JSON.stringify(envelope)}\n`; - if (this.config.dryRun) { - this.dryRunOutput.write(encoded); - return; - } - const target = this.outputPath(envelope); - mkdirSync(resolve(target, ".."), { recursive: true }); - writeFileSync(target, encoded, { encoding: "utf8", flag: "a" }); - } - - private outputPath(envelope: AnySessionEnvelope): string { - const bucketStart = new Date(envelope.bucket.start); - const fileName = bucketFileName(bucketStart, this.config.bucket); - if (this.config.scope === "session") { - return join( - this.config.outDir, - "messages", - "sessions", - safeSlug(this.config.wrapperSessionId), - fileName, - ); - } - return join( - this.config.outDir, - "messages", - "folders", - safeSlug(this.scopeKey()), - fileName, - ); - } - - private scopeKey(): string { - if (this.config.scope === "session") { - return this.config.wrapperSessionId; - } - const digest = createHash("sha256") - .update(this.cwd) - .digest("hex") - .slice(0, 12); - return `${basename(this.cwd) || "root"}-${digest}`; - } } export class SessionEnvelopePublisher { diff --git a/sdk/typescript/src/cli/index.ts b/sdk/typescript/src/cli/index.ts index a15b9339..26e5f37f 100644 --- a/sdk/typescript/src/cli/index.ts +++ b/sdk/typescript/src/cli/index.ts @@ -11,7 +11,11 @@ import { buildHelp, rawCommands, } from "./commands.js"; -import { runClaudeCommand, runCodexCommand } from "./codex.js"; +import { + runClaudeCommand, + runCodexCommand, + runOpencodeCommand, +} from "./codex.js"; import { runDaemon } from "./daemon.js"; import { makeContext } from "./context.js"; import { formatResult, redactSecrets, resolveFormat } from "./format.js"; @@ -82,6 +86,7 @@ const NOTICE_SKIP_COMMANDS = new Set([ "help", "codex", "claude", + "opencode", "daemon", "tui", "--help", @@ -177,6 +182,23 @@ async function dispatchCli( }; } } + if (parsed.command === "opencode") { + try { + return await runOpencodeCommand(argv.slice(1), options); + } catch (error) { + return { + code: 1, + stdout: "", + stderr: `${JSON.stringify( + { + error: error instanceof Error ? error.message : String(error), + }, + null, + 2, + )}\n`, + }; + } + } try { const ctx = await makeContext(options); diff --git a/sdk/typescript/src/cli/opencode-source.ts b/sdk/typescript/src/cli/opencode-source.ts new file mode 100644 index 00000000..25910b5d --- /dev/null +++ b/sdk/typescript/src/cli/opencode-source.ts @@ -0,0 +1,434 @@ +// opencode session source — the non-file "tailer" for the interactive wrapper. +// +// opencode keeps sessions in a single SQLite DB, not per-session JSONL files, so +// the file-polling `HarnessSessionTailer` cannot observe a live session. But +// running `opencode` starts an HTTP server with an SSE bus (`GET /event`), and +// `opencode attach ` launches the interactive TUI against an existing +// server. This module owns that seam: it starts a headless `opencode serve`, +// subscribes to its `/event` bus, folds bus frames through the shared +// `createOpenCodeBusMapper`, and emits the same v1/v2 envelopes as the file +// tailer via `SessionEnvelopeEmitter`. The wrapper spawns `opencode attach +// ` in the PTY so the human's session and this observer share one server. +import { spawn as spawnChild, type ChildProcess } from "node:child_process"; +import { createServer } from "node:net"; +import { resolve as resolvePath } from "node:path"; +import type { Writable } from "node:stream"; + +import { bridgeLog } from "./bridge-debug.js"; +import { + createOpenCodeBusMapper, + type HarnessSemanticEvent, + type OpenCodeBusMapper, +} from "./harness-events.js"; +import { + SessionEnvelopeEmitter, + type EmitIdentity, + type HarnessWrapperConfig, + type SemanticMessage, + type SessionEnvelopePublisher, +} from "./harness-wrapper.js"; + +/** + * Opens the opencode `/event` SSE stream and yields one raw JSON payload per + * bus frame (the `data:` lines, `data:` prefix stripped). Injectable so tests + * can feed a synthetic frame sequence without a real server. + */ +export type SseConnect = ( + url: string, + signal: AbortSignal, +) => AsyncIterable; + +const READY_TIMEOUT_MS = 10_000; +const STOP_GRACE_MS = 2_000; +// Pre-latch buffer bound — before we know our session id, incoming events are +// held; a runaway (never-latched) stream must not grow this without limit. +const PRE_LATCH_CAP = 500; + +// ── server lifecycle ───────────────────────────────────────────────────────── + +export interface OpenCodeServerHandle { + url: string; + port: number; + stop(): Promise; +} + +export interface StartOpenCodeServerOptions { + bin: string; + cwd: string; + env: Record; + spawn?: typeof spawnChild; + connect?: SseConnect; + readyTimeoutMs?: number; +} + +/** + * Start a headless `opencode serve` on a free ephemeral port and resolve only + * once it is accepting connections (the SSE `server.connected` frame, or a + * "listening on " line on the child's output — whichever comes first). A + * child that exits before ready (e.g. a lost port race) is retried once. + */ +export async function startOpenCodeServer( + options: StartOpenCodeServerOptions, +): Promise { + for (let attempt = 0; ; attempt += 1) { + try { + return await startOnce(options); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (attempt >= 1 || !/EADDRINUSE|exited before ready/i.test(message)) { + throw error; + } + bridgeLog("opencode.serve.retry", { message }); + } + } +} + +async function startOnce( + options: StartOpenCodeServerOptions, +): Promise { + const spawn = options.spawn ?? spawnChild; + const connect = options.connect ?? defaultSseConnect; + const port = await freePort(); + const url = `http://127.0.0.1:${port}`; + const child = spawn( + options.bin, + ["serve", "--hostname", "127.0.0.1", "--port", String(port)], + { cwd: options.cwd, env: options.env }, + ); + bridgeLog("opencode.serve.spawn", { bin: options.bin, port }); + + try { + await waitForReady( + connect, + `${url}/event`, + child, + options.readyTimeoutMs ?? READY_TIMEOUT_MS, + ); + } catch (error) { + await killChild(child); + throw error; + } + + return { url, port, stop: () => killChild(child) }; +} + +/** Wait until the server accepts SSE connections, or the child dies / times out. */ +function waitForReady( + connect: SseConnect, + eventUrl: string, + child: ChildProcess, + timeoutMs: number, +): Promise { + const probe = new AbortController(); + return new Promise((resolvePromise, reject) => { + let settled = false; + const done = (error?: Error): void => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + probe.abort(); + child.stdout?.off("data", onData); + child.stderr?.off("data", onData); + child.off("exit", onExit); + child.off("error", onError); + if (error) { + reject(error); + } else { + resolvePromise(); + } + }; + + const timer = setTimeout( + () => done(new Error(`opencode serve not ready after ${timeoutMs}ms`)), + timeoutMs, + ); + const onExit = (code: number | null): void => + done(new Error(`opencode serve exited before ready (code ${code})`)); + const onError = (error: Error): void => + done(new Error(`opencode serve failed to spawn: ${error.message}`)); + const onData = (chunk: Buffer | string): void => { + if (/listening on\s+http/i.test(String(chunk))) { + done(); + } + }; + + child.on("exit", onExit); + child.on("error", onError); + child.stdout?.on("data", onData); + child.stderr?.on("data", onData); + + // Primary readiness signal: the first `/event` frame (server.connected). + void (async (): Promise => { + try { + for await (const raw of connect(eventUrl, probe.signal)) { + void raw; + done(); + return; + } + } catch { + // Aborted (we won via stdout) or transient — the timeout/exit guards + // still resolve or reject this wait. + } + })(); + }); +} + +async function killChild(child: ChildProcess): Promise { + if (child.exitCode !== null || child.signalCode !== null) { + return; + } + await new Promise((resolvePromise) => { + const grace = setTimeout(() => { + child.kill("SIGKILL"); + }, STOP_GRACE_MS); + child.once("exit", () => { + clearTimeout(grace); + resolvePromise(); + }); + child.kill("SIGTERM"); + }); +} + +function freePort(): Promise { + return new Promise((resolvePromise, reject) => { + const server = createServer(); + server.on("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + server.close(() => { + if (port) { + resolvePromise(port); + } else { + reject(new Error("could not acquire a free port")); + } + }); + }); + }); +} + +// ── event source ───────────────────────────────────────────────────────────── + +/** + * Subscribes to an opencode server's `/event` SSE bus and publishes the tiny.place + * session envelopes for the ONE session whose working directory matches `cwd`. + * The global stream can carry other sessions; this filters them out. + */ +export class OpenCodeEventSource { + private readonly emitter: SessionEnvelopeEmitter; + private readonly mapper: OpenCodeBusMapper = createOpenCodeBusMapper(); + private readonly connect: SseConnect; + private readonly controller = new AbortController(); + private targetSessionId: string | undefined; + private pending: Array<{ sessionID?: string; event: HarnessSemanticEvent }> = + []; + private line = 0; + private statusTimer: ReturnType | undefined; + private readerDone: Promise | undefined; + + public constructor( + private readonly config: HarnessWrapperConfig, + private readonly cwd: string, + dryRunOutput: Writable, + private readonly publisher: SessionEnvelopePublisher, + options: { connect?: SseConnect } = {}, + ) { + this.emitter = new SessionEnvelopeEmitter( + config, + cwd, + dryRunOutput, + publisher, + ); + this.connect = options.connect ?? defaultSseConnect; + } + + /** Begin reading the SSE stream and ticking status; resolves immediately. */ + public start(eventUrl: string): void { + this.readerDone = this.readLoop(eventUrl); + if (this.config.emitV2) { + this.statusTimer = setInterval(() => { + const id = this.identity(); + if (id) { + this.emitter.tick(id); + } + }, this.config.sessionPollMs); + } + } + + /** Stop the stream, flush any buffered text, and flush the publisher. */ + public async stop(): Promise { + if (this.statusTimer) { + clearInterval(this.statusTimer); + this.statusTimer = undefined; + } + this.controller.abort(); + await this.readerDone?.catch(() => undefined); + for (const event of this.mapper.flush(this.line)) { + this.emit(event); + } + return this.publisher.flush(); + } + + private async readLoop(eventUrl: string): Promise { + try { + for await (const raw of this.connect(eventUrl, this.controller.signal)) { + this.line += 1; + this.handleFrame(raw, this.line); + } + } catch (error) { + if (!this.controller.signal.aborted) { + bridgeLog("opencode.source.read.error", { + message: error instanceof Error ? error.message : String(error), + }); + } + } + } + + private handleFrame(raw: string, line: number): void { + const mapping = this.mapper.next(raw, line); + + // Latch our session id the first time a `session.*` frame reports our cwd. + if ( + !this.targetSessionId && + mapping.sessionID && + mapping.directory !== undefined && + this.sameCwd(mapping.directory) + ) { + this.targetSessionId = mapping.sessionID; + bridgeLog("opencode.source.latched", { + sessionId: this.targetSessionId, + directory: mapping.directory, + }); + const buffered = this.pending; + this.pending = []; + for (const held of buffered) { + if (!held.sessionID || held.sessionID === this.targetSessionId) { + this.emit(held.event); + } + } + } + + for (const event of mapping.events) { + if (this.targetSessionId) { + // Drop events tagged for a different session on the shared bus. + if (mapping.sessionID && mapping.sessionID !== this.targetSessionId) { + continue; + } + this.emit(event); + } else { + this.bufferPreLatch(mapping.sessionID, event); + } + } + } + + private bufferPreLatch( + sessionID: string | undefined, + event: HarnessSemanticEvent, + ): void { + if (this.pending.length >= PRE_LATCH_CAP) { + this.pending.shift(); + bridgeLog("opencode.source.preLatchOverflow", { cap: PRE_LATCH_CAP }); + } + this.pending.push({ ...(sessionID ? { sessionID } : {}), event }); + } + + private emit(event: HarnessSemanticEvent): void { + const id = this.identity(); + if (!id) { + return; + } + const message = v1FromSemantic(event); + if (message) { + this.emitter.emitV1Message(message, id); + } + if (this.config.emitV2) { + this.emitter.emitV2Event(event, id); + } + } + + private identity(): EmitIdentity | undefined { + if (!this.targetSessionId) { + return undefined; + } + return { + sessionId: this.targetSessionId, + sourcePath: `opencode-sse://${this.targetSessionId}`, + }; + } + + private sameCwd(directory: string): boolean { + return resolvePath(directory) === resolvePath(this.cwd); + } +} + +/** + * Project a typed semantic event onto a v1 session message. Only owner prompts + * and agent messages become v1 lines — tool calls, thinking, and status are + * v2-only, exactly as the claude/codex v1 mappers already drop them. + */ +function v1FromSemantic( + event: HarnessSemanticEvent, +): SemanticMessage | undefined { + const { line, timestamp, recordType } = event; + if (event.event.kind === "user_prompt") { + return { + line, + timestamp, + recordType, + role: "user", + text: event.event.payload.text, + }; + } + if (event.event.kind === "agent_message") { + return { + line, + timestamp, + recordType, + role: "agent", + sourceRole: "assistant", + text: event.event.payload.text, + }; + } + return undefined; +} + +// ── default SSE transport (Node 22 global fetch) ───────────────────────────── + +async function* defaultSseConnect( + url: string, + signal: AbortSignal, +): AsyncIterable { + const response = await fetch(url, { + signal, + headers: { accept: "text/event-stream" }, + }); + if (!response.ok || !response.body) { + throw new Error(`opencode /event returned HTTP ${response.status}`); + } + const decoder = new TextDecoder(); + let buffer = ""; + for await (const chunk of response.body as AsyncIterable) { + buffer += decoder.decode(chunk, { stream: true }); + let boundary = buffer.indexOf("\n\n"); + while (boundary !== -1) { + const frame = buffer.slice(0, boundary); + buffer = buffer.slice(boundary + 2); + const data = frameData(frame); + if (data !== undefined) { + yield data; + } + boundary = buffer.indexOf("\n\n"); + } + } +} + +/** Extract the joined `data:` payload from one SSE frame (ignore comments/ids). */ +function frameData(frame: string): string | undefined { + const dataLines = frame + .split("\n") + .filter((raw) => raw.startsWith("data:")) + .map((raw) => raw.slice("data:".length).replace(/^ /, "")); + return dataLines.length > 0 ? dataLines.join("\n") : undefined; +} diff --git a/sdk/typescript/src/cli/tui.ts b/sdk/typescript/src/cli/tui.ts index c5140224..64673c08 100644 --- a/sdk/typescript/src/cli/tui.ts +++ b/sdk/typescript/src/cli/tui.ts @@ -27,13 +27,18 @@ import { SessionEnvelopePublisher, parseHarnessWrapperArgs, } from "./harness-wrapper.js"; +import { + OpenCodeEventSource, + startOpenCodeServer, + type OpenCodeServerHandle, +} from "./opencode-source.js"; import type { CliContext, TinyPlaceCliOptions, TinyPlaceCliResult, } from "./types.js"; -export type TinyVerseAgentKind = "claude" | "codex"; +export type TinyVerseAgentKind = "claude" | "codex" | "opencode"; type TuiView = "welcome" | "settings" | "agent"; @@ -74,6 +79,10 @@ interface AgentProfile { // Set when this profile resumes a specific session (home resume pane): drives // the `--resume`/`resume` launch arg and a stable per-session OpenHuman scope. resumeSessionId?: string; + // opencode: this harness is observed over a local server's SSE bus rather than + // per-session files, so the TUI boots `opencode serve` before launch and + // bridges via `OpenCodeEventSource` instead of the file tailer. + serverMode?: boolean; } interface AgentSessionMeta { @@ -93,6 +102,7 @@ type TuiAction = | "launch" | "launch-codex" | "launch-claude" + | "launch-opencode" | "connect" | "settings" | "quit"; @@ -106,6 +116,7 @@ const FIXED_ACTIONS: ReadonlyArray = [ const HOME_ACTIONS: ReadonlyArray = [ "launch-codex", "launch-claude", + "launch-opencode", "connect", "settings", "quit", @@ -149,8 +160,11 @@ export function parseTinyVerseAgentKind( if (value === "claude") { return "claude"; } + if (value === "opencode") { + return "opencode"; + } throw new Error( - `unknown tinyverse agent "${value}" (expected codex or claude)`, + `unknown tinyverse agent "${value}" (expected codex, claude, or opencode)`, ); } @@ -201,6 +215,10 @@ class BlessedTinyPlaceTui { // Real OpenHuman bridge (replaces the mock): publisher + outbound tailer + // inbound receiver, reused from the harness wrapper. private bridgeTailer?: HarnessSessionTailer; + // opencode's non-file session observer (SSE bus) + the `opencode serve` we + // launch and attach to; both replace the file tailer for serverMode profiles. + private bridgeSource?: OpenCodeEventSource; + private opencodeServer?: OpenCodeServerHandle; private bridgeReceiver?: InboundMessageReceiver; // Home-screen resume pane: the recent sessions across both agents, loaded once // at start. Empty in fixed-agent mode. @@ -502,6 +520,10 @@ class BlessedTinyPlaceTui { this.setProfile("claude"); void this.startAgent(); return; + case "launch-opencode": + this.setProfile("opencode"); + void this.startAgent(); + return; case "connect": this.connectOpenHuman(); return; @@ -715,7 +737,7 @@ class BlessedTinyPlaceTui { * keys, stream the agent's turns out (tailer), and inject inbound DMs into the * live agent (receiver → writeAgentInput). No-op without a configured owner. */ private startBridge(): void { - if (this.bridgeTailer || this.bridgeReceiver) { + if (this.bridgeTailer || this.bridgeSource || this.bridgeReceiver) { return; } const owner = this.resolveOwner(); @@ -756,9 +778,15 @@ class BlessedTinyPlaceTui { // file instead so bridge failures are visible while debugging. const sink = createBridgeDiagSink(); const publisher = new SessionEnvelopePublisher(config, this.options, sink); - this.bridgeTailer = config.captureSession - ? new HarnessSessionTailer(config, cwd, sink, publisher) - : undefined; + if (this.profile.serverMode && this.opencodeServer) { + // opencode: observe the live session over the server's SSE bus. + this.bridgeSource = new OpenCodeEventSource(config, cwd, sink, publisher); + this.bridgeSource.start(`${this.opencodeServer.url}/event`); + } else { + this.bridgeTailer = config.captureSession + ? new HarnessSessionTailer(config, cwd, sink, publisher) + : undefined; + } this.bridgeReceiver = config.receiveEnabled ? new InboundMessageReceiver(config, publisher, sink) : undefined; @@ -793,8 +821,10 @@ class BlessedTinyPlaceTui { private stopBridge(): void { void this.bridgeTailer?.stop(); + void this.bridgeSource?.stop(); void this.bridgeReceiver?.stop(); this.bridgeTailer = undefined; + this.bridgeSource = undefined; this.bridgeReceiver = undefined; this.state = { ...this.state, bridgeLive: false }; } @@ -865,6 +895,31 @@ class BlessedTinyPlaceTui { if (this.child || this.pty) { return; } + // opencode: boot the server we observe + attach to BEFORE anything spawns, + // then prepend `attach ` to its launch. A start failure (e.g. opencode + // not installed) surfaces as an agent-exit notice instead of a silent hang. + if (this.profile.serverMode && !this.opencodeServer) { + try { + this.opencodeServer = await startOpenCodeServer({ + bin: this.profile.launch.command, + cwd: this.effectiveCwd(), + env: childEnv(this.ctx.env), + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.finishAgent(`opencode server failed to start: ${message}`); + return; + } + const attachArgs = [ + "attach", + this.opencodeServer.url, + ...this.profile.launch.args, + ]; + this.profile = { + ...this.profile, + launch: buildLaunch(this.profile.launch.command, attachArgs), + }; + } const { launch } = this.profile; this.state = { ...this.state, @@ -873,25 +928,29 @@ class BlessedTinyPlaceTui { notice: undefined, view: "agent", }; - this.agentSessionMonitor = new AgentSessionMonitor( - this.ctx, - // Locate the resumed/fresh session against the folder it actually runs in. - { ...this.options, cwd: this.effectiveCwd() }, - this.profile, - (meta) => { - this.state = { - ...this.state, - activeSessionId: meta.sessionId, - }; - if (this.nativeRelayActive) { - this.updateNativeTerminalTitle(); - } else { - this.renderFooter(); - this.queueScreenRender(); - } - }, - ); - this.agentSessionMonitor.start(new Date()); + // opencode has no per-session files to monitor; its live session id arrives + // over the SSE bus, so the file-based AgentSessionMonitor is skipped. + if (!this.profile.serverMode) { + this.agentSessionMonitor = new AgentSessionMonitor( + this.ctx, + // Locate the resumed/fresh session against the folder it runs in. + { ...this.options, cwd: this.effectiveCwd() }, + this.profile, + (meta) => { + this.state = { + ...this.state, + activeSessionId: meta.sessionId, + }; + if (this.nativeRelayActive) { + this.updateNativeTerminalTitle(); + } else { + this.renderFooter(); + this.queueScreenRender(); + } + }, + ); + this.agentSessionMonitor.start(new Date()); + } // Start the real OpenHuman bridge; the receiver's sink reads this.pty/this.child // lazily, so starting before spawn is safe (first inbound poll is ~1.5s out). this.startBridge(); @@ -1092,10 +1151,12 @@ class BlessedTinyPlaceTui { */ private usesNativeRelay(): boolean { const kindMode = - this.profile.kind === "claude" - ? (this.ctx.env.TINYVERSE_CLAUDE_TERMINAL_MODE ?? - this.ctx.env.TINYPLACE_CLAUDE_TERMINAL_MODE) - : this.ctx.env.TINYPLACE_CODEX_TERMINAL_MODE; + this.ctx.env[ + `TINYPLACE_${this.profile.kind.toUpperCase()}_TERMINAL_MODE` + ] ?? + (this.profile.kind === "claude" + ? this.ctx.env.TINYVERSE_CLAUDE_TERMINAL_MODE + : undefined); const mode = kindMode ?? this.ctx.env.TINYPLACE_TERMINAL_MODE; return mode !== "blessed"; } @@ -1237,6 +1298,12 @@ class BlessedTinyPlaceTui { this.agentSessionMonitor?.stop(); this.agentSessionMonitor = undefined; this.stopBridge(); + // Tear down the opencode server we launched (best-effort) after the bridge + // stops reading its SSE stream. + if (this.opencodeServer) { + void this.opencodeServer.stop().catch(() => undefined); + this.opencodeServer = undefined; + } this.releaseSessionLockIfHeld(); this.cleanupNativeRelay(); this.pty = undefined; @@ -1596,6 +1663,8 @@ function actionRow( return ["[ Start Codex session ]", "{green-fg}"]; case "launch-claude": return ["[ Start Claude session ]", "{green-fg}"]; + case "launch-opencode": + return ["[ Start OpenCode session ]", "{green-fg}"]; case "connect": return [ state.openHumanConnected @@ -1681,6 +1750,27 @@ function buildAgentProfile( ...(resumeSessionId ? { resumeSessionId } : {}), }; } + if (kind === "opencode") { + const command = env.TINYPLACE_OPENCODE_BIN ?? "opencode"; + const args = splitShellWords(env.TINYPLACE_OPENCODE_ARGS ?? ""); + // opencode has no per-session files and no resume-by-id in the TUI (its + // bridge attaches to a fresh server session); resume is a documented + // follow-up, so a resumeSessionId is ignored here. The launch args are the + // user's extra flags; `attach ` is prepended at spawn once the server + // is up (startAgent), so `serverMode` is the marker that triggers that. + return { + disabledPtyEnv: "TINYPLACE_OPENCODE_NO_PTY", + displayName: "OpenCode", + kind, + launch: buildLaunch(command, args), + pendingSessionId: "opencode:pending", + sessionPollEnv: "TINYPLACE_OPENCODE_SESSION_POLL_MS", + sessionsDir: + env.TINYPLACE_OPENCODE_SESSIONS_DIR ?? + join(homedir(), ".local", "share", "opencode", "sessions"), + serverMode: true, + }; + } const command = env.TINYPLACE_CODEX_BIN ?? "codex"; const args = splitShellWords(env.TINYPLACE_CODEX_ARGS ?? ""); // `codex resume ` is a subcommand, so it must lead the arg list. @@ -1725,6 +1815,9 @@ function profileOverrideNames(profile: AgentProfile): Array { "TINYPLACE_CLAUDE_SESSIONS_DIR", ]; } + if (profile.kind === "opencode") { + return ["TINYPLACE_OPENCODE_BIN", "TINYPLACE_OPENCODE_ARGS"]; + } return [ "TINYPLACE_CODEX_BIN", "TINYPLACE_CODEX_ARGS", diff --git a/sdk/typescript/tests/harness-events.test.ts b/sdk/typescript/tests/harness-events.test.ts index 2d0b2268..95d6f0de 100644 --- a/sdk/typescript/tests/harness-events.test.ts +++ b/sdk/typescript/tests/harness-events.test.ts @@ -3,8 +3,10 @@ import { claudeEventsFromLine, codexEventsFromLine, createHarnessLineMapper, + createOpenCodeBusMapper, harnessEventsFromLine, normalizeToolKind, + opencodeEventsFromBusEvent, toolDisplay, } from "../src/cli/harness-events.js"; import type { HarnessSemanticEvent } from "../src/cli/harness-events.js"; @@ -475,3 +477,249 @@ describe("toolDisplay", () => { expect(toolDisplay("Bash", { command: "line1\nline2" })).toBe("line1"); }); }); + +// ── opencode SSE bus mapper ─────────────────────────────────────────────────── + +function busFrame(record: unknown): string { + return JSON.stringify(record); +} + +function partUpdated(part: unknown, sessionID = "ses_1"): string { + return busFrame({ + type: "message.part.updated", + properties: { sessionID, part, time: 1_700_000_000_000 }, + }); +} + +describe("opencodeEventsFromBusEvent (stateless routing)", () => { + it("returns routing keys for a session.created frame with no events", () => { + const mapping = opencodeEventsFromBusEvent( + busFrame({ + type: "session.created", + properties: { info: { id: "ses_9", directory: "/work/proj" } }, + }), + 1, + ); + expect(mapping.sessionID).toBe("ses_9"); + expect(mapping.directory).toBe("/work/proj"); + expect(mapping.events).toHaveLength(0); + }); + + it("maps a session.error frame to an error event", () => { + const mapping = opencodeEventsFromBusEvent( + busFrame({ + type: "session.error", + properties: { + sessionID: "ses_1", + error: { name: "ProviderError", data: { message: "no credentials" } }, + }, + }), + 1, + ); + expect(kinds(mapping.events)).toEqual(["error"]); + expect(mapping.events[0].event).toMatchObject({ + kind: "error", + role: "agent", + payload: { fatal: false }, + }); + }); + + it("tags a message.part.updated with its sessionID (source filters)", () => { + const mapping = opencodeEventsFromBusEvent( + partUpdated( + { + type: "text", + id: "prt_1", + messageID: "msg_1", + text: "hi", + time: { start: 1, end: 2 }, + }, + "ses_other", + ), + 1, + ); + expect(mapping.sessionID).toBe("ses_other"); + expect(kinds(mapping.events)).toEqual(["agent_message"]); + }); + + it("ignores unknown/next.* frames", () => { + const mapping = opencodeEventsFromBusEvent( + busFrame({ type: "session.next.tool.progress", properties: {} }), + 1, + ); + expect(mapping.events).toHaveLength(0); + }); +}); + +describe("createOpenCodeBusMapper (stateful dedup)", () => { + it("emits exactly one tool_call and one tool_result across snapshots", () => { + const map = createOpenCodeBusMapper(); + const pending = map.next( + partUpdated({ + type: "tool", + id: "p1", + callID: "c1", + tool: "bash", + state: { status: "pending" }, + }), + 1, + ); + const running = map.next( + partUpdated({ + type: "tool", + id: "p1", + callID: "c1", + tool: "bash", + state: { status: "running", input: { command: "ls" } }, + }), + 2, + ); + const completed = map.next( + partUpdated({ + type: "tool", + id: "p1", + callID: "c1", + tool: "bash", + state: { + status: "completed", + input: { command: "ls" }, + output: "a\nb", + }, + }), + 3, + ); + const extra = map.next( + partUpdated({ + type: "tool", + id: "p1", + callID: "c1", + tool: "bash", + state: { status: "completed", output: "a\nb" }, + }), + 4, + ); + expect(kinds(pending.events)).toEqual(["tool_call"]); + expect(kinds(running.events)).toEqual([]); + expect(kinds(completed.events)).toEqual(["tool_result"]); + expect(kinds(extra.events)).toEqual([]); + expect(completed.events[0].event).toMatchObject({ + kind: "tool_result", + payload: { call_id: "c1", ok: true, is_error: false }, + }); + }); + + it("synthesizes a tool_call when the first snapshot is already terminal", () => { + const map = createOpenCodeBusMapper(); + const done = map.next( + partUpdated({ + type: "tool", + id: "p2", + callID: "c2", + tool: "read", + state: { status: "error", output: "boom" }, + }), + 1, + ); + expect(kinds(done.events)).toEqual(["tool_call", "tool_result"]); + expect(done.events[1].event).toMatchObject({ + kind: "tool_result", + payload: { ok: false, is_error: true }, + }); + }); + + it("emits a text part once, on its terminal (time.end) snapshot", () => { + const map = createOpenCodeBusMapper(); + const partial = map.next( + partUpdated({ + type: "text", + id: "t1", + messageID: "m1", + text: "hel", + time: { start: 1 }, + }), + 1, + ); + const done = map.next( + partUpdated({ + type: "text", + id: "t1", + messageID: "m1", + text: "hello", + time: { start: 1, end: 2 }, + }), + 2, + ); + expect(kinds(partial.events)).toEqual([]); + expect(kinds(done.events)).toEqual(["agent_message"]); + expect(done.events[0].event).toMatchObject({ + kind: "agent_message", + payload: { text: "hello" }, + }); + }); + + it("surfaces a user-role text part as an owner prompt", () => { + const map = createOpenCodeBusMapper(); + map.next( + busFrame({ + type: "message.updated", + properties: { sessionID: "ses_1", info: { id: "mu", role: "user" } }, + }), + 1, + ); + const done = map.next( + partUpdated({ + type: "text", + id: "tu", + messageID: "mu", + text: "do the thing", + time: { start: 1, end: 2 }, + }), + 2, + ); + expect(done.events[0].event).toMatchObject({ + kind: "user_prompt", + role: "owner", + payload: { text: "do the thing", source: "human" }, + }); + }); + + it("flushes a still-buffered text part via message.updated then flush()", () => { + const viaMessage = createOpenCodeBusMapper(); + viaMessage.next( + partUpdated({ + type: "text", + id: "t2", + messageID: "m2", + text: "buffered", + time: { start: 1 }, + }), + 1, + ); + const flushed = viaMessage.next( + busFrame({ + type: "message.updated", + properties: { + sessionID: "ses_1", + info: { id: "m2", role: "assistant" }, + }, + }), + 2, + ); + expect(kinds(flushed.events)).toEqual(["agent_message"]); + + const viaFlush = createOpenCodeBusMapper(); + viaFlush.next( + partUpdated({ + type: "text", + id: "t3", + messageID: "m3", + text: "tail", + time: { start: 1 }, + }), + 1, + ); + const drained = viaFlush.flush(9); + expect(kinds(drained)).toEqual(["agent_message"]); + expect(drained[0].event).toMatchObject({ payload: { text: "tail" } }); + }); +}); diff --git a/sdk/typescript/tests/opencode-cli.test.ts b/sdk/typescript/tests/opencode-cli.test.ts new file mode 100644 index 00000000..59e359e5 --- /dev/null +++ b/sdk/typescript/tests/opencode-cli.test.ts @@ -0,0 +1,66 @@ +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; +import type { ChildProcessWithoutNullStreams } from "node:child_process"; +import { describe, expect, it } from "vitest"; + +import { runTinyPlaceCli } from "../src/cli.js"; +import { parseTinyVerseAgentKind } from "../src/cli/tui.js"; + +describe("tinyplace opencode dispatch", () => { + it("parses the opencode agent kind and rejects unknown kinds", () => { + expect(parseTinyVerseAgentKind("opencode")).toBe("opencode"); + expect(parseTinyVerseAgentKind("codex")).toBe("codex"); + expect(() => parseTinyVerseAgentKind("gemini")).toThrow(/opencode/); + }); + + it("defaults bare `opencode` to the tiny.place TUI (static snapshot in a non-TTY)", async () => { + const result = await runTinyPlaceCli(["opencode"], { + env: { TINYPLACE_ENDPOINT: "https://relay.test" }, + stdin: new PassThrough(), + stdout: new PassThrough(), + stderr: new PassThrough(), + }); + expect(result.code).toBe(0); + expect(result.stdout).toContain("welcome to tiny.place"); + // The opencode profile drives the snapshot. + expect(result.stdout).toContain("opencode: opencode"); + }); + + it("wraps `opencode ` (non-PTY) with the forwarded args and no SSE bridge", async () => { + // With an injected spawn (usePty=false) the SSE/attach bridge is skipped — + // opencode runs as a plain child with the user's args forwarded verbatim. + let spawned: { args: Array; command: string } | undefined; + const result = await runTinyPlaceCli( + [ + "opencode", + "--tinyplace-no-pty", + "--tinyplace-no-session-tail", + "--model", + "grok", + ], + { + cwd: "/tmp/project", + env: { TINYPLACE_OPENCODE_BIN: "fake-opencode" }, + stdin: new PassThrough(), + stdout: new PassThrough(), + stderr: new PassThrough(), + spawn: (command, args) => { + spawned = { args, command }; + const child = new EventEmitter() as ChildProcessWithoutNullStreams; + child.stdin = new PassThrough(); + child.stdout = new PassThrough(); + child.stderr = new PassThrough(); + child.pid = 8642; + queueMicrotask(() => child.emit("exit", 0, null)); + return child; + }, + }, + ); + expect(result.code).toBe(0); + // Not `attach ` — the SSE bridge only engages under a real TTY. + expect(spawned).toEqual({ + command: "fake-opencode", + args: ["--model", "grok"], + }); + }); +}); diff --git a/sdk/typescript/tests/opencode-server.test.ts b/sdk/typescript/tests/opencode-server.test.ts new file mode 100644 index 00000000..3315a6b1 --- /dev/null +++ b/sdk/typescript/tests/opencode-server.test.ts @@ -0,0 +1,146 @@ +import { EventEmitter } from "node:events"; +import { describe, expect, it } from "vitest"; + +import { + startOpenCodeServer, + type SseConnect, +} from "../src/cli/opencode-source.js"; + +// Unit-tests startOpenCodeServer with an injected spawn (a fake child) and an +// injected SSE connect — no real `opencode` binary, no network. + +class FakeChild extends EventEmitter { + public stdout = new EventEmitter(); + public stderr = new EventEmitter(); + public exitCode: number | null = null; + public signalCode: string | null = null; + public readonly signals: Array = []; + public kill(signal?: string): boolean { + this.signals.push(signal ?? "SIGTERM"); + // A real SIGTERM ends the process; emit exit so killChild resolves. + this.exitCode = 0; + queueMicrotask(() => this.emit("exit", 0, signal ?? "SIGTERM")); + return true; + } +} + +interface Spawned { + child: FakeChild; + bin: string; + args: Array; +} + +function fakeSpawn(): { + spawn: (bin: string, args: Array) => FakeChild; + last: () => Spawned | undefined; +} { + let last: Spawned | undefined; + return { + spawn: (bin: string, args: Array): FakeChild => { + const child = new FakeChild(); + last = { child, bin, args }; + return child; + }, + last: () => last, + }; +} + +/** Connect that yields the server.connected frame immediately, then idles. */ +const connectReady: SseConnect = async function* (_url, signal) { + yield JSON.stringify({ type: "server.connected" }); + await new Promise((resolve) => { + if (signal.aborted) return resolve(); + signal.addEventListener("abort", () => resolve(), { once: true }); + }); +}; + +/** Connect that never yields — readiness must come from the stdout line. */ +const connectNever: SseConnect = (_url, signal) => ({ + async *[Symbol.asyncIterator]() { + await new Promise((resolve) => { + if (signal.aborted) return resolve(); + signal.addEventListener("abort", () => resolve(), { once: true }); + }); + }, +}); + +describe("startOpenCodeServer", () => { + it("spawns `opencode serve` on a free port and resolves on server.connected", async () => { + const s = fakeSpawn(); + const handle = await startOpenCodeServer({ + bin: "opencode", + cwd: "/work/proj", + env: {}, + spawn: s.spawn as never, + connect: connectReady, + }); + const spawned = s.last(); + expect(spawned?.bin).toBe("opencode"); + expect(spawned?.args[0]).toBe("serve"); + expect(spawned?.args).toContain("--port"); + expect(spawned?.args).toContain("--hostname"); + // The resolved url embeds the chosen port and is loopback-only. + expect(handle.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); + expect(handle.url.endsWith(String(handle.port))).toBe(true); + await handle.stop(); + }); + + it("falls back to the stdout 'listening on' line for readiness", async () => { + const s = fakeSpawn(); + const pending = startOpenCodeServer({ + bin: "opencode", + cwd: "/work/proj", + env: {}, + spawn: s.spawn as never, + connect: connectNever, + }); + // Emit the listening banner once the child exists. + await new Promise((resolve) => setTimeout(resolve, 10)); + s.last()?.child.stdout.emit( + "data", + "opencode server listening on http://127.0.0.1:9", + ); + const handle = await pending; + expect(handle.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); + await handle.stop(); + }); + + it("stop() terminates the child", async () => { + const s = fakeSpawn(); + const handle = await startOpenCodeServer({ + bin: "opencode", + cwd: "/work/proj", + env: {}, + spawn: s.spawn as never, + connect: connectReady, + }); + await handle.stop(); + expect(s.last()?.child.signals).toContain("SIGTERM"); + }); + + it("rejects when the child keeps exiting before readiness", async () => { + // Every spawned child exits immediately — the port-race retry fires once, + // then the second failure propagates. + let spawns = 0; + const autoExitSpawn = (): FakeChild => { + spawns += 1; + const child = new FakeChild(); + queueMicrotask(() => { + child.exitCode = 1; + child.emit("exit", 1, null); + }); + return child; + }; + await expect( + startOpenCodeServer({ + bin: "opencode", + cwd: "/work/proj", + env: {}, + spawn: autoExitSpawn as never, + connect: connectNever, + readyTimeoutMs: 1_000, + }), + ).rejects.toThrow(/exited before ready/i); + expect(spawns).toBe(2); // one retry + }); +}); diff --git a/sdk/typescript/tests/opencode-smoke.test.ts b/sdk/typescript/tests/opencode-smoke.test.ts new file mode 100644 index 00000000..c7369749 --- /dev/null +++ b/sdk/typescript/tests/opencode-smoke.test.ts @@ -0,0 +1,118 @@ +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { Writable } from "node:stream"; +import { describe, expect, it } from "vitest"; + +import { + SessionEnvelopePublisher, + type HarnessWrapperConfig, +} from "../src/cli/harness-wrapper.js"; +import { + OpenCodeEventSource, + startOpenCodeServer, +} from "../src/cli/opencode-source.js"; +import type { SessionEnvelopeV1 } from "../src/index.js"; +import type { TinyPlaceCliOptions } from "../src/cli/types.js"; + +// Opt-in live smoke against a REAL `opencode serve`. Gated on the env flag so it +// never runs in CI: it needs opencode installed and an authed provider. It is +// the end-to-end check that OpenCodeEventSource, driven by real bus frames, +// publishes an agent message — and it exercises the exact text-completion signal +// (`time.end`) that the unit fixtures assume. +// +// TINYPLACE_OPENCODE_SMOKE=1 pnpm --filter @tinyhumansai/tinyplace \ +// exec vitest run tests/opencode-smoke.test.ts + +const RUN = process.env.TINYPLACE_OPENCODE_SMOKE === "1"; +const BIN = process.env.TINYPLACE_OPENCODE_BIN ?? "opencode"; + +function config(cwd: string): HarnessWrapperConfig { + return { + agentArgs: [], + agentBin: BIN, + bucket: "hour", + captureError: true, + captureInput: true, + captureOutput: true, + captureSession: true, + dryRun: true, + emitV2: true, + outDir: join(tmpdir(), "tp-oc-smoke"), + provider: "opencode", + receiveEnabled: false, + receivePollMs: 1500, + sessionPollMs: 500, + sessionsDir: join(tmpdir(), "tp-oc-smoke-sessions"), + sessionTailGraceMs: 0, + scope: "session", + statusHeartbeatMs: 15_000, + statusIdleMs: 30_000, + usePty: false, + wrapperSessionId: "wrap-smoke", + }; +} + +describe.skipIf(!RUN)("opencode live smoke", () => { + it("publishes an agent message for a real turn over the SSE bus", async () => { + const cwd = process.cwd(); + const server = await startOpenCodeServer({ + bin: BIN, + cwd, + env: process.env, + }); + const chunks: Array = []; + const out = new Writable({ + write(chunk, _enc, cb): void { + chunks.push(chunk.toString()); + cb(); + }, + }); + const options = { env: process.env } as unknown as TinyPlaceCliOptions; + const publisher = new SessionEnvelopePublisher(config(cwd), options, out); + const source = new OpenCodeEventSource( + config(cwd), + cwd, + out, + publisher, + {}, + ); + source.start(`${server.url}/event`); + + try { + const session = (await ( + await fetch(`${server.url}/session`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + }) + ).json()) as { id: string }; + await fetch(`${server.url}/session/${session.id}/message`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + parts: [{ type: "text", text: "Reply with exactly: PONG" }], + }), + }); + // Poll for an agent message envelope (LLM latency). + const deadline = Date.now() + 45_000; + let messages: Array = []; + while (Date.now() < deadline) { + messages = chunks + .join("") + .split("\n") + .filter((line) => line.trim().length > 0) + .map((line) => JSON.parse(line) as SessionEnvelopeV1) + .filter((env) => env.version === 1 && env.message?.role === "agent"); + if (messages.length > 0) break; + await new Promise((resolve) => setTimeout(resolve, 1_000)); + } + expect(messages.length).toBeGreaterThan(0); + expect(messages.map((env) => env.message.text).join(" ")).toContain( + "PONG", + ); + } finally { + await source.stop(); + await server.stop(); + } + }, 90_000); +}); diff --git a/sdk/typescript/tests/opencode-source.test.ts b/sdk/typescript/tests/opencode-source.test.ts new file mode 100644 index 00000000..29f08252 --- /dev/null +++ b/sdk/typescript/tests/opencode-source.test.ts @@ -0,0 +1,193 @@ +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { Writable } from "node:stream"; +import { afterEach, describe, expect, it } from "vitest"; + +import { + SessionEnvelopePublisher, + type HarnessWrapperConfig, +} from "../src/cli/harness-wrapper.js"; +import { + OpenCodeEventSource, + type SseConnect, +} from "../src/cli/opencode-source.js"; +import type { + AnySessionEnvelope, + SessionEnvelopeV1, + SessionEnvelopeV2, +} from "../src/index.js"; +import type { TinyPlaceCliOptions } from "../src/cli/types.js"; + +// Drives the real OpenCodeEventSource against a synthetic SSE frame sequence +// (injected SseConnect — no server, no network). Dry-run routes every envelope +// to a Writable so we can assert the v1 messages and v2 typed events the source +// publishes, plus session filtering and flush-on-stop. + +const CWD = "/work/proj"; + +function baseConfig( + over: Partial = {}, +): HarnessWrapperConfig { + return { + agentArgs: [], + agentBin: "opencode", + bucket: "hour", + captureError: true, + captureInput: true, + captureOutput: true, + captureSession: true, + dryRun: true, + emitV2: false, + outDir: join(tmpdir(), "tp-oc-out"), + provider: "opencode", + receiveEnabled: false, + receivePollMs: 1500, + sessionPollMs: 500, + sessionsDir: join(tmpdir(), "tp-oc-sessions"), + sessionTailGraceMs: 0, + scope: "session", + statusHeartbeatMs: 15_000, + statusIdleMs: 30_000, + usePty: false, + wrapperSessionId: "wrap-oc", + ...over, + }; +} + +function frame(record: unknown): string { + return JSON.stringify(record); +} + +/** An injected SSE transport that replays a fixed list of JSON frame strings. */ +function replay(frames: Array): SseConnect { + return async function* (): AsyncIterable { + for (const raw of frames) { + yield raw; + } + // Hold open like a real stream would until aborted, so stop() controls exit. + await new Promise((resolve) => setTimeout(resolve, 50)); + }; +} + +function collector(): { + out: Writable; + envelopes: () => Array; +} { + const chunks: Array = []; + const out = new Writable({ + write(chunk, _enc, cb): void { + chunks.push(chunk.toString()); + cb(); + }, + }); + return { + out, + envelopes: (): Array => + chunks + .join("") + .split("\n") + .filter((line) => line.trim().length > 0) + .map((line) => JSON.parse(line) as AnySessionEnvelope), + }; +} + +async function drive( + config: HarnessWrapperConfig, + frames: Array, + out: Writable, +): Promise { + const options = { env: {} } as unknown as TinyPlaceCliOptions; + // dryRun sends envelopes straight to `out`, so the publisher never touches + // the network. + const publisher = new SessionEnvelopePublisher(config, options, out); + const source = new OpenCodeEventSource(config, CWD, out, publisher, { + connect: replay(frames), + }); + source.start("http://127.0.0.1:1/event"); + // Let the async reader drain the frames before we stop + flush. + await new Promise((resolve) => setTimeout(resolve, 20)); + await source.stop(); +} + +const created = (directory = CWD, id = "ses_1"): string => + frame({ type: "session.created", properties: { info: { id, directory } } }); + +const textPart = (text: string, sessionID = "ses_1"): string => + frame({ + type: "message.part.updated", + properties: { + sessionID, + part: { + type: "text", + id: `t_${text}`, + messageID: "m1", + text, + time: { start: 1, end: 2 }, + }, + }, + }); + +describe("OpenCodeEventSource", () => { + const cleanup: Array<() => void> = []; + afterEach(() => { + for (const fn of cleanup.splice(0)) fn(); + }); + + it("publishes a v1 agent message for our session", async () => { + const { out, envelopes } = collector(); + await drive( + baseConfig(), + [frame({ type: "server.connected" }), created(), textPart("all done")], + out, + ); + const v1 = envelopes().filter( + (env): env is SessionEnvelopeV1 => env.version === 1, + ); + expect(v1.length).toBeGreaterThanOrEqual(1); + expect(v1[0].message).toMatchObject({ role: "agent", text: "all done" }); + expect(v1[0].scope.harness_session_id).toBe("ses_1"); + expect(v1[0].source.path).toBe("opencode-sse://ses_1"); + }); + + it("emits v2 typed events when emitV2 is on", async () => { + const { out, envelopes } = collector(); + await drive( + baseConfig({ emitV2: true }), + [frame({ type: "server.connected" }), created(), textPart("hi")], + out, + ); + const v2 = envelopes().filter( + (env): env is SessionEnvelopeV2 => env.version === 2, + ); + const kinds = v2.map((env) => env.event.kind); + expect(kinds).toContain("agent_message"); + }); + + it("drops events for a different session on the shared bus", async () => { + const { out, envelopes } = collector(); + await drive( + baseConfig(), + [ + created(CWD, "ses_mine"), + textPart("mine", "ses_mine"), + textPart("theirs", "ses_other"), + ], + out, + ); + const texts = envelopes() + .filter((env): env is SessionEnvelopeV1 => env.version === 1) + .map((env) => env.message.text); + expect(texts).toContain("mine"); + expect(texts).not.toContain("theirs"); + }); + + it("never latches onto a session in a different directory", async () => { + const { out, envelopes } = collector(); + await drive( + baseConfig(), + [created("/some/other/dir", "ses_x"), textPart("nope", "ses_x")], + out, + ); + expect(envelopes().filter((env) => env.version === 1)).toHaveLength(0); + }); +});