Skip to content

Commit 061e994

Browse files
senamakelclaude
andauthored
feat(cli): add opencode as a first-class interactive TUI harness (#253)
* refactor(cli): extract SessionEnvelopeEmitter + add opencode SSE bus mapper Factor the v1/v2/status emit machinery out of HarnessSessionTailer into a reusable SessionEnvelopeEmitter (parameterized by EmitIdentity) so a non-file session source can share envelope framing/publish/status. Add opencodeEventsFromBusEvent + createOpenCodeBusMapper mapping opencode's SSE `/event` bus frames (message.part.updated / message.updated / session.*) into the shared HarnessSemanticEvent model, with tool/text dedup across snapshots. No behavior change to the file tailer (existing v2/resume/envelope tests green). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011nJsktzaVmQ1VnsYjzditQ * feat(cli): opencode SSE session source + wire into the harness wrapper Add OpenCodeEventSource + startOpenCodeServer: start a headless `opencode serve` on a free port, subscribe to its `/event` SSE bus, filter to the session whose directory matches cwd, and emit the shared v1/v2 envelopes via SessionEnvelopeEmitter. runHarnessCommand launches `opencode attach <url>` in the PTY so the human's session and the bridge share one server; teardown kills serve in a finally. Non-TTY/opencode falls back to a plain spawn with a note. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011nJsktzaVmQ1VnsYjzditQ * feat(cli): add opencode as a first-class interactive TUI harness Extend the TUI (TinyVerseAgentKind, home menu, buildAgentProfile with a serverMode marker) so `tinyplace opencode` runs like codex/claude. In serverMode the TUI boots `opencode serve`, prepends `attach <url>` to the launch, bridges the live session via OpenCodeEventSource (not the file tailer), and tears the server down on exit. Add runOpencodeCommand + the opencode dispatch/help entry. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011nJsktzaVmQ1VnsYjzditQ * test(cli): cover the opencode bus mapper, SSE source, server, and dispatch Unit tests for createOpenCodeBusMapper (tool/text dedup, roles, flush), OpenCodeEventSource (v1/v2 emit, session filtering, foreign-session drop), startOpenCodeServer (port/readiness/teardown/retry via injected spawn+connect), and the opencode CLI dispatch/parse. Add an opt-in live smoke (TINYPLACE_OPENCODE_SMOKE) that drives a real `opencode serve` turn end-to-end and asserts an agent_message envelope — verified locally against opencode 1.17.18 (text parts carry time.end, as the fixtures assume). Also harden the server's readiness wait to surface spawn errors (ENOENT). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011nJsktzaVmQ1VnsYjzditQ * style(cli): drop incidental reformatting of pre-existing SDK lines The earlier commits ran the website prettier over edited SDK files, which rewrapped unrelated pre-existing lines (the SDK is hand-wrapped and is not prettier-managed — CI only formats the website). Restore those lines to their original form so the diff contains only the opencode changes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011nJsktzaVmQ1VnsYjzditQ --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 39ab493 commit 061e994

12 files changed

Lines changed: 2028 additions & 235 deletions

sdk/typescript/src/cli/codex.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,19 @@ export async function runClaudeCommand(
7878
* caller wants to wrap a specific session, so route to the wrapper and honor
7979
* them; the TUI is reserved for the argument-free onboarding entry.
8080
*/
81+
/**
82+
* `tinyplace opencode` mirrors `tinyplace codex`/`claude` — the same three modes.
83+
* opencode has no per-session transcript files, so its bridge observes the live
84+
* session over the local server's SSE bus (`opencode serve` + `opencode attach`)
85+
* rather than tailing files; the mode dispatch is identical.
86+
*/
87+
export async function runOpencodeCommand(
88+
argv: Array<string>,
89+
options: TinyPlaceCliOptions = {},
90+
): Promise<TinyPlaceCliResult> {
91+
return runHarnessAgentCommand("opencode", argv, options);
92+
}
93+
8194
async function runHarnessAgentCommand(
8295
harness: TinyVerseAgentKind,
8396
argv: Array<string>,

sdk/typescript/src/cli/commands.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,14 @@ export const HARNESS_CLI_COMMANDS: Array<TinyPlaceCliCommand> = [
198198
"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).",
199199
usage: "[--raw | --agent [--wallet <name>] [--autorespond]] [--tinyplace-dm-to <recipient>] [--tinyplace-out <dir>] [--tinyplace-scope folder|session] [--tinyplace-bucket minute|hour|day] [--] <claude-args...>",
200200
},
201+
{
202+
name: "opencode",
203+
// Parity sibling of codex/claude — groups with them in help.
204+
capability: "maintenance",
205+
description:
206+
"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.",
207+
usage: "[--raw | --agent [--wallet <name>] [--autorespond]] [--tinyplace-dm-to <recipient>] [--tinyplace-out <dir>] [--tinyplace-scope folder|session] [--tinyplace-bucket minute|hour|day] [--] <opencode-args...>",
208+
},
201209
{
202210
name: "tui",
203211
capability: "workflow",

sdk/typescript/src/cli/harness-events.ts

Lines changed: 334 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -606,6 +606,340 @@ function openCodeOutputText(output: unknown): string {
606606
return safeStringify(output);
607607
}
608608

609+
// ── OpenCode server bus (SSE `/event`) ───────────────────────────────────────
610+
//
611+
// The daemon path (`opencode run --format json`, above) emits a FLAT line shape
612+
// (`{type:"text"|"tool"|"error", part:{…}}`). The interactive wrapper instead
613+
// attaches to `opencode serve` and reads its `GET /event` SSE bus, whose frames
614+
// are NESTED (`{type:"message.part.updated", properties:{sessionID, part}}`) and
615+
// carry session identity out of band (no per-session file to key on). This
616+
// mapper folds those bus frames into the same `HarnessSemanticEvent` model,
617+
// returning the routing keys (`sessionID`/`directory`) alongside the events so
618+
// the event source can filter the global stream down to its own session.
619+
//
620+
// Verified against a live `opencode serve` OpenAPI schema (2026-07-13):
621+
// message.part.updated properties:{ sessionID, part:<Part>, time }
622+
// Part union on part.type: text | reasoning | tool | step-* | file | …
623+
// ToolPart: { callID, tool, state:{ status: pending|running|completed|error,
624+
// input, output } }; text/reasoning carry a growing `text` and a
625+
// { start, end? } `time`.
626+
// message.updated properties:{ sessionID, info:<UserMessage|Assistant> }
627+
// session.created|updated properties:{ info:{ id, directory } }
628+
// session.error properties:{ error? }
629+
630+
/** Routing keys + typed events folded from one SSE bus frame. */
631+
export interface OpenCodeBusMapping {
632+
/** The session a `message.*` frame belongs to (filter key). */
633+
sessionID?: string;
634+
/** A `session.*` frame's working directory (matched against the wrapper cwd). */
635+
directory?: string;
636+
events: Array<HarnessSemanticEvent>;
637+
}
638+
639+
/** A `Part` as it rides on the SSE bus (superset of the flat `OpenCodePart`). */
640+
interface OpenCodeBusPart extends OpenCodePart {
641+
id?: string;
642+
messageID?: string;
643+
time?: { start?: number; end?: number };
644+
}
645+
646+
/**
647+
* Stateful bus mapper. Parts arrive as repeated snapshots of the same id/callID
648+
* (a tool progresses pending→running→completed; a text part's `text` grows), so
649+
* a stateless fold would emit a storm of partial duplicates. This mapper dedupes:
650+
* • tool parts → exactly one `tool_call` (first pending/running snapshot) and
651+
* one `tool_result` (first terminal snapshot; a synthetic call is emitted
652+
* first if the terminal snapshot is the first we ever saw for that callID);
653+
* • text/reasoning parts → one event when the part is terminal (`time.end`)
654+
* or when its message's `message.updated` frame flushes it, whichever first.
655+
* `flush()` drains any still-buffered text at stream end. Create ONE per session
656+
* stream — the dedupe state must not leak across streams.
657+
*/
658+
export interface OpenCodeBusMapper {
659+
next(raw: string, line: number): OpenCodeBusMapping;
660+
flush(line: number): Array<HarnessSemanticEvent>;
661+
}
662+
663+
interface BufferedText {
664+
kind: "text" | "reasoning";
665+
messageID?: string;
666+
text: string;
667+
line: number;
668+
timestamp: Date;
669+
}
670+
671+
export function createOpenCodeBusMapper(): OpenCodeBusMapper {
672+
// callID → whether we've emitted its `tool_call` ("call") or `tool_result`
673+
// ("result"). Absent = never seen.
674+
const toolState = new Map<string, "call" | "result">();
675+
// messageID → author role, learned from `message.updated`, so a buffered text
676+
// part is surfaced as an owner prompt vs an agent message.
677+
const messageRole = new Map<string, "user" | "assistant">();
678+
// partId → latest buffered text/reasoning awaiting a terminal/flush signal.
679+
const textBuffers = new Map<string, BufferedText>();
680+
681+
function emitBuffered(
682+
partId: string,
683+
line: number,
684+
): Array<HarnessSemanticEvent> {
685+
const buffered = textBuffers.get(partId);
686+
if (!buffered) {
687+
return [];
688+
}
689+
textBuffers.delete(partId);
690+
const text = buffered.text.trim();
691+
if (!text) {
692+
return [];
693+
}
694+
if (buffered.kind === "reasoning") {
695+
return [
696+
{
697+
line,
698+
timestamp: buffered.timestamp,
699+
recordType: "opencode:reasoning",
700+
event: {
701+
kind: "agent_thinking",
702+
role: "agent",
703+
payload: { text },
704+
},
705+
},
706+
];
707+
}
708+
const role = buffered.messageID
709+
? messageRole.get(buffered.messageID)
710+
: undefined;
711+
if (role === "user") {
712+
return [userPromptEvent(line, buffered.timestamp, text)];
713+
}
714+
return [
715+
{
716+
line,
717+
timestamp: buffered.timestamp,
718+
recordType: "opencode:text",
719+
event: {
720+
kind: "agent_message",
721+
role: "agent",
722+
payload: { text },
723+
},
724+
},
725+
];
726+
}
727+
728+
function mapToolPart(
729+
part: OpenCodeBusPart,
730+
line: number,
731+
timestamp: Date,
732+
): Array<HarnessSemanticEvent> {
733+
const toolName = String(part.tool);
734+
const callId = asString(part.callID) ?? asString(part.id) ?? "";
735+
const status = (part.state?.status ?? "").toLowerCase();
736+
const output = part.state?.output;
737+
const terminal =
738+
OPENCODE_TERMINAL_STATES.has(status) ||
739+
(output !== undefined && output !== null && output !== "");
740+
const prev = toolState.get(callId);
741+
742+
const callEvent = (): HarnessSemanticEvent => ({
743+
line,
744+
timestamp,
745+
recordType: "opencode:tool_call",
746+
event: {
747+
kind: "tool_call",
748+
role: "agent",
749+
payload: {
750+
call_id: callId,
751+
tool_name: toolName,
752+
tool_kind: normalizeToolKind(toolName),
753+
display: toolDisplay(toolName, part.state?.input),
754+
input: boundToolInput(part.state?.input),
755+
},
756+
},
757+
});
758+
759+
if (terminal) {
760+
if (prev === "result") {
761+
return [];
762+
}
763+
const outputText = openCodeOutputText(output);
764+
const isError = status === "error";
765+
const events: Array<HarnessSemanticEvent> = [];
766+
if (prev !== "call") {
767+
events.push(callEvent());
768+
}
769+
events.push({
770+
line,
771+
timestamp,
772+
recordType: "opencode:tool_result",
773+
event: {
774+
kind: "tool_result",
775+
role: "agent",
776+
payload: {
777+
call_id: callId,
778+
ok: !isError,
779+
is_error: isError,
780+
output: truncate(outputText),
781+
output_bytes: byteLength(outputText),
782+
},
783+
},
784+
});
785+
toolState.set(callId, "result");
786+
return events;
787+
}
788+
789+
if (prev) {
790+
return [];
791+
}
792+
toolState.set(callId, "call");
793+
return [callEvent()];
794+
}
795+
796+
function mapPart(
797+
part: OpenCodeBusPart,
798+
line: number,
799+
frameTime: Date,
800+
): Array<HarnessSemanticEvent> {
801+
const type = asString(part.type);
802+
if (type === "tool" && part.tool) {
803+
return mapToolPart(part, line, frameTime);
804+
}
805+
if (type === "text" || type === "reasoning") {
806+
const partId = asString(part.id) ?? "";
807+
if (!partId || typeof part.text !== "string") {
808+
return [];
809+
}
810+
const timestamp = part.time?.start
811+
? new Date(part.time.start)
812+
: frameTime;
813+
textBuffers.set(partId, {
814+
kind: type,
815+
...(asString(part.messageID) ? { messageID: part.messageID } : {}),
816+
text: part.text,
817+
line,
818+
timestamp,
819+
});
820+
// A part with an end time is final — surface it now.
821+
if (part.time?.end !== undefined) {
822+
return emitBuffered(partId, line);
823+
}
824+
return [];
825+
}
826+
return [];
827+
}
828+
829+
return {
830+
next(raw: string, line: number): OpenCodeBusMapping {
831+
const record = parseJsonObject(raw);
832+
if (!record) {
833+
return { events: [] };
834+
}
835+
const type = asString(record.type);
836+
const properties = asObject(record.properties) ?? {};
837+
const frameTime = opencodeBusTimestamp(properties.time);
838+
839+
if (type === "session.created" || type === "session.updated") {
840+
const info = asObject(properties.info);
841+
return {
842+
...(asString(info?.id) ? { sessionID: asString(info?.id) } : {}),
843+
...(asString(info?.directory)
844+
? { directory: asString(info?.directory) }
845+
: {}),
846+
events: [],
847+
};
848+
}
849+
850+
if (type === "session.error") {
851+
const message =
852+
describeOpenCodeError(properties.error) ??
853+
safeStringify(properties.error ?? record);
854+
return {
855+
...(asString(properties.sessionID)
856+
? { sessionID: asString(properties.sessionID) }
857+
: {}),
858+
events: [
859+
{
860+
line,
861+
timestamp: frameTime,
862+
recordType: "opencode:session.error",
863+
event: {
864+
kind: "error",
865+
role: "agent",
866+
payload: { message: truncate(message), fatal: false },
867+
},
868+
},
869+
],
870+
};
871+
}
872+
873+
if (type === "message.updated") {
874+
const info = asObject(properties.info);
875+
const messageID = asString(info?.id);
876+
const role = asString(info?.role);
877+
if (messageID && (role === "user" || role === "assistant")) {
878+
messageRole.set(messageID, role);
879+
}
880+
// Flush any buffered text for this message now that its role is known.
881+
const events: Array<HarnessSemanticEvent> = [];
882+
if (messageID) {
883+
for (const [partId, buffered] of [...textBuffers.entries()]) {
884+
if (buffered.messageID === messageID) {
885+
events.push(...emitBuffered(partId, line));
886+
}
887+
}
888+
}
889+
return {
890+
...(asString(properties.sessionID)
891+
? { sessionID: asString(properties.sessionID) }
892+
: {}),
893+
events,
894+
};
895+
}
896+
897+
if (type === "message.part.updated") {
898+
const part = asObject(properties.part) as OpenCodeBusPart | undefined;
899+
return {
900+
...(asString(properties.sessionID)
901+
? { sessionID: asString(properties.sessionID) }
902+
: {}),
903+
events: part ? mapPart(part, line, frameTime) : [],
904+
};
905+
}
906+
907+
// server.connected, session.next.*, and everything else: routing no-ops.
908+
return { events: [] };
909+
},
910+
911+
flush(line: number): Array<HarnessSemanticEvent> {
912+
const events: Array<HarnessSemanticEvent> = [];
913+
for (const partId of [...textBuffers.keys()]) {
914+
events.push(...emitBuffered(partId, line));
915+
}
916+
return events;
917+
},
918+
};
919+
}
920+
921+
/**
922+
* Stateless single-frame fold — routing keys + a naive event view of one bus
923+
* frame (no cross-frame dedup/buffering). Handy for tests and callers that only
924+
* need routing; the live wrapper uses `createOpenCodeBusMapper` for de-duped
925+
* streaming. A fresh mapper per call means a text/reasoning part only surfaces
926+
* when the frame is already terminal (`time.end`).
927+
*/
928+
export function opencodeEventsFromBusEvent(
929+
raw: string,
930+
line: number,
931+
): OpenCodeBusMapping {
932+
return createOpenCodeBusMapper().next(raw, line);
933+
}
934+
935+
/** Bus frames stamp `time` as epoch ms; fall back to receive time. */
936+
function opencodeBusTimestamp(value: unknown): Date {
937+
if (typeof value === "number" && Number.isFinite(value)) {
938+
return new Date(value);
939+
}
940+
return parseTimestamp(value);
941+
}
942+
609943
// ── shared helpers ───────────────────────────────────────────────────────────
610944

611945
function userPromptEvent(

0 commit comments

Comments
 (0)