@kidsinai/kids-client 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,114 @@
1
+ # @kidsinai/kids-client
2
+
3
+ > **Status:** Phase 2.5 MVP scaffold (2026-05-16). Not yet npm-published; consumed via workspace + `bun link` for dogfood.
4
+
5
+ The own-client TUI for Kids OpenCode. Talks to a local `opencode serve` process over `@opencode-ai/sdk/v2`. Replaces the upstream Solid.js TUI with a kid-warm Ink (React+Node) experience: branded welcome screen, Mission progress + Stars balance, permission dialogs the kid actually understands, friendly error screens, Kids Helpline overlay for crisis terms.
6
+
7
+ ## Why this exists
8
+
9
+ Per [`kids-opencode-client-prd.md`](../../../airbotix/docs/product/prd/kids-opencode-client-prd.md) §2 **C route**, the terminal-end UX must not look like an engineer tool. Upstream `opencode` is a great agent runtime but a hostile first impression for a 12-year-old. This package owns the rendering layer.
10
+
11
+ Architecture (see PRD §2.3):
12
+
13
+ ```
14
+ kids-opencode wrapper → kids-client (this package, Ink)
15
+
16
+ spawns + supervises
17
+
18
+ opencode serve (upstream kernel + @kidsinai/kids-opencode-plugin)
19
+
20
+ routes LLM via
21
+
22
+ DeepRouter
23
+ ```
24
+
25
+ ## How it runs
26
+
27
+ ```
28
+ $ kids-opencode --course portfolio-site --mission mission-1
29
+ ```
30
+
31
+ The wrapper:
32
+ 1. Loads `OPENCODE_SERVER_PASSWORD` from `~/.config/kids-opencode/server-password`
33
+ 2. Translates `--course` / `--mission` to env vars
34
+ 3. Exec's `kids-client`
35
+
36
+ The client:
37
+ 1. Probes `http://127.0.0.1:4096/app` with Basic Auth
38
+ 2. If down, spawns `opencode serve` as its child and pipes stderr
39
+ 3. Parses `[kids-audit] {...}` lines into the audit pipeline (local jsonl buffer; remote ingest plumbed but disabled)
40
+ 4. Subscribes to `client.global.event()` SSE
41
+ 5. Renders the kid-warm Ink TUI
42
+
43
+ ## Architecture inside this package
44
+
45
+ - **`src/core/`** — pure TS, no Ink imports. State machine, SDK client, SSE dispatcher, serve subprocess manager, audit pipeline. **V1 Tauri reuses this verbatim.**
46
+ - **`src/render/ink/`** — Ink components and screens. Replaceable with a WebView render layer for V1.
47
+
48
+ ### Files
49
+
50
+ ```
51
+ src/index.tsx Composition root; main()
52
+ src/core/env.ts Reads KIDS_*/OPENCODE_* env, validates
53
+ src/core/serve-manager.ts Spawns + tails opencode serve
54
+ src/core/connection.ts createOpencodeClient with Basic Auth
55
+ src/core/session.ts session.create / prompt / abort
56
+ src/core/events.ts SSE subscribe + dispatch
57
+ src/core/store.ts useSyncExternalStore-compatible pub/sub
58
+ src/core/audit-pipeline.ts stderr → jsonl buffer (+ future remote POST)
59
+ src/dangerous-topic-bridge.ts Crisis-term patterns (mirrors kids-tui-plugin)
60
+
61
+ src/render/ink/App.tsx Router
62
+ src/render/ink/theme.ts Kid-warm color tokens
63
+ src/render/ink/screens/StartupScreen.tsx
64
+ src/render/ink/screens/MissionScreen.tsx
65
+ src/render/ink/screens/PermissionModal.tsx
66
+ src/render/ink/screens/DangerousTopicModal.tsx
67
+ src/render/ink/screens/ErrorScreen.tsx
68
+ src/render/ink/components/Header.tsx
69
+ src/render/ink/components/ChatStream.tsx
70
+ src/render/ink/components/Input.tsx
71
+ src/render/ink/components/Thinking.tsx
72
+ src/render/ink/components/KeyHints.tsx
73
+ ```
74
+
75
+ ## Dogfood (current path)
76
+
77
+ From a clone of `kidsinai/kids-opencode`:
78
+
79
+ ```
80
+ bun install
81
+ bun link --cwd packages/kids-client
82
+ KIDS_LLM_BYPASS_GATEWAY=1 ANTHROPIC_API_KEY=sk-ant-... \
83
+ kids-opencode --course portfolio-site --mission mission-1
84
+ ```
85
+
86
+ The startup screen should render within ~3 seconds. The wrapper's `--shutdown` subcommand kills any lingering serve on `:4096`.
87
+
88
+ ## V0 MVP scope cuts
89
+
90
+ Items in the PRD that we deliberately deferred to keep Phase 2.5 shippable for Workshop #2:
91
+
92
+ - **Session resume across client crashes** (PRD §5.3) — client kills serve on exit; deferred to V1
93
+ - **Sound pack** — deferred to V1 Tauri
94
+ - **Embedded browser preview** — V1 Tauri
95
+ - **Locale runtime switching** — V0 reads `$LANG` once at startup
96
+ - **Multi-mission parallel** — single active session only
97
+ - **Project sharing** — handled in `airbotix-app` web side
98
+
99
+ ## Tests
100
+
101
+ ```
102
+ bun test
103
+ ```
104
+
105
+ 31 tests across env validation, store mutation, audit pipeline jsonl write,
106
+ dangerous-topic pattern detection, and Ink snapshot of StartupScreen/ErrorScreen
107
+ variants. Wired into CI via `.github/workflows/ci.yml`.
108
+
109
+ ## Related
110
+
111
+ - Plan: `~/.claude/plans/resilient-sleeping-pancake.md`
112
+ - Q3 spike result: `../../docs/v2-api-verification.md` §Q3
113
+ - Plugin (server-side kid-safety): `../kids-plugin/`
114
+ - TUI plugin (A-route theme/keymap, sibling): `../kids-tui-plugin/`
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env bun
2
+ // Entry shim for the kids-client TUI. The actual app lives in src/index.tsx.
3
+ // The wrapper bin/kids-opencode exec's this binary after preparing env.
4
+ import "../src/index.tsx"
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/package.json",
3
+ "name": "@kidsinai/kids-client",
4
+ "version": "0.0.1",
5
+ "type": "module",
6
+ "description": "Own-client TUI for Kids OpenCode — talks to local `opencode serve` via @opencode-ai/sdk v2 with kid-warm rendering, mission progress, permission dialog, and stderr-tail audit pipeline.",
7
+ "license": "MIT",
8
+ "homepage": "https://github.com/kidsinai/kids-opencode",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "https://github.com/kidsinai/kids-opencode.git",
12
+ "directory": "packages/kids-client"
13
+ },
14
+ "keywords": ["opencode", "kids", "tui", "ink", "education", "k-12", "agentic"],
15
+ "bin": {
16
+ "kids-client": "./bin/kids-client"
17
+ },
18
+ "exports": {
19
+ ".": {
20
+ "import": "./src/index.tsx",
21
+ "types": "./src/index.tsx"
22
+ }
23
+ },
24
+ "files": ["src", "bin", "README.md", "LICENSE"],
25
+ "scripts": {
26
+ "typecheck": "tsc --noEmit",
27
+ "test": "bun test"
28
+ },
29
+ "peerDependencies": {
30
+ "@opencode-ai/sdk": ">=1.14.0"
31
+ },
32
+ "dependencies": {
33
+ "ink": "^5.0.1",
34
+ "ink-spinner": "^5.0.0",
35
+ "ink-text-input": "^6.0.0",
36
+ "react": "^18.3.1",
37
+ "@kidsinai/kids-opencode-plugin": "workspace:*"
38
+ },
39
+ "devDependencies": {
40
+ "@opencode-ai/sdk": "^1.14.51",
41
+ "@types/react": "^18.3.12",
42
+ "ink-testing-library": "^4.0.0",
43
+ "typescript": "^5.7.0"
44
+ }
45
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Audit pipeline: stderr-tail input → batch → local jsonl buffer
3
+ * → (later) POST to platform-backend /api/audit.
4
+ *
5
+ * V0 MVP scope: write only to local file at
6
+ * ~/.config/kids-opencode/audit-buffer.jsonl
7
+ * Remote ingest is plumbed but disabled until platform-backend ships
8
+ * the endpoint and `@airbotix/audit-schema` is published (see PRD
9
+ * audit-event-schema-prd.md §8).
10
+ *
11
+ * Format on disk: one JSON envelope per line. Each line is the event
12
+ * exactly as parsed from `[kids-audit] {...}` stderr output.
13
+ */
14
+
15
+ import { appendFile, mkdir } from "node:fs/promises"
16
+ import { dirname } from "node:path"
17
+
18
+ export interface AuditPipelineOptions {
19
+ bufferPath: string
20
+ /** Optional remote endpoint. When unset, only the local buffer is written. */
21
+ remoteUrl?: string
22
+ remoteAuthHeader?: string
23
+ /** Max items held in memory before flushing to disk. */
24
+ batchSize?: number
25
+ /** Max ms between flushes regardless of batch size. */
26
+ flushIntervalMs?: number
27
+ }
28
+
29
+ export class AuditPipeline {
30
+ private opts: AuditPipelineOptions
31
+ private pending: unknown[] = []
32
+ private flushTimer: ReturnType<typeof setInterval> | null = null
33
+ private writing = false
34
+
35
+ constructor(opts: AuditPipelineOptions) {
36
+ this.opts = { batchSize: 20, flushIntervalMs: 2000, ...opts }
37
+ }
38
+
39
+ start(): void {
40
+ if (this.flushTimer) return
41
+ this.flushTimer = setInterval(() => {
42
+ void this.flush().catch(() => {})
43
+ }, this.opts.flushIntervalMs!)
44
+ }
45
+
46
+ push(event: unknown): void {
47
+ this.pending.push(event)
48
+ if (this.pending.length >= (this.opts.batchSize ?? 20)) {
49
+ void this.flush().catch(() => {})
50
+ }
51
+ }
52
+
53
+ async flush(): Promise<void> {
54
+ if (this.writing) return
55
+ if (this.pending.length === 0) return
56
+ this.writing = true
57
+ const batch = this.pending
58
+ this.pending = []
59
+ try {
60
+ await mkdir(dirname(this.opts.bufferPath), { recursive: true })
61
+ const lines = batch.map((e) => JSON.stringify(e)).join("\n") + "\n"
62
+ await appendFile(this.opts.bufferPath, lines, "utf8")
63
+ if (this.opts.remoteUrl) await this.postRemote(batch)
64
+ } catch {
65
+ // Restore on failure so we retry next tick.
66
+ this.pending = [...batch, ...this.pending]
67
+ } finally {
68
+ this.writing = false
69
+ }
70
+ }
71
+
72
+ async stop(): Promise<void> {
73
+ if (this.flushTimer) clearInterval(this.flushTimer)
74
+ this.flushTimer = null
75
+ await this.flush()
76
+ }
77
+
78
+ private async postRemote(batch: unknown[]): Promise<void> {
79
+ if (!this.opts.remoteUrl) return
80
+ try {
81
+ await fetch(this.opts.remoteUrl, {
82
+ method: "POST",
83
+ headers: {
84
+ "content-type": "application/json",
85
+ ...(this.opts.remoteAuthHeader ? { authorization: this.opts.remoteAuthHeader } : {}),
86
+ },
87
+ body: JSON.stringify({ events: batch }),
88
+ })
89
+ } catch {
90
+ // Swallow; local jsonl is the source of truth.
91
+ }
92
+ }
93
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * In-TUI mission acceptance check. Calls runMissionChecks from
3
+ * @kidsinai/kids-opencode-plugin against the kid's current project
4
+ * directory.
5
+ *
6
+ * Triggered by the kid typing "/check" or a vernacular completion phrase
7
+ * (e.g. "我做完了" / "I'm done") in the chat input. index.tsx intercepts
8
+ * those strings before they reach the LLM.
9
+ */
10
+
11
+ import { runMissionChecks, type MissionResult } from "@kidsinai/kids-opencode-plugin"
12
+
13
+ export interface CheckOutcome {
14
+ kind: "pass" | "fail" | "error"
15
+ missionId: string
16
+ /** Friendly summary for the kid. Composed from MissionResult.completion_message + per-check labels. */
17
+ message: string
18
+ /** Detailed per-check labels for the chat trail. */
19
+ details: string[]
20
+ result?: MissionResult
21
+ }
22
+
23
+ export const COMPLETION_PHRASES_ZH = ["/check", "我做完了", "做完了", "完成了", "我完成了", "/done"]
24
+ export const COMPLETION_PHRASES_EN = ["/check", "i'm done", "im done", "done!", "i am done", "/done", "all done"]
25
+
26
+ export function isCompletionTrigger(text: string, locale: "zh-Hans" | "en"): boolean {
27
+ const t = text.trim().toLowerCase()
28
+ if (!t) return false
29
+ const candidates = locale === "zh-Hans" ? COMPLETION_PHRASES_ZH : COMPLETION_PHRASES_EN
30
+ return candidates.some((p) => t === p.toLowerCase() || (t.length < 25 && t.includes(p.toLowerCase())))
31
+ }
32
+
33
+ export interface RunCheckOptions {
34
+ missionId: string
35
+ packId: string
36
+ projectDir?: string
37
+ locale: "zh-Hans" | "en"
38
+ }
39
+
40
+ export function runCheck(opts: RunCheckOptions): CheckOutcome {
41
+ if (!opts.missionId) {
42
+ return {
43
+ kind: "error",
44
+ missionId: opts.missionId ?? "",
45
+ message: opts.locale === "zh-Hans" ? "没设当前 Mission,没法验收。" : "No active Mission to check.",
46
+ details: [],
47
+ }
48
+ }
49
+ const result = runMissionChecks(opts.missionId, {
50
+ packId: opts.packId,
51
+ projectDir: opts.projectDir,
52
+ })
53
+ if ("error" in result) {
54
+ return {
55
+ kind: "error",
56
+ missionId: opts.missionId,
57
+ message: result.error,
58
+ details: [],
59
+ }
60
+ }
61
+ const details = (result.results ?? []).map((r) => {
62
+ const tick = r.status === "pass" ? "✅" : r.status === "skip" ? "⏭" : "❌"
63
+ return `${tick} ${r.description || r.id || "check"}`
64
+ })
65
+ const ok = result.ok
66
+ if (ok) {
67
+ const msg = result.completion_message
68
+ ?? (opts.locale === "zh-Hans"
69
+ ? `Mission ${result.mission_id} 全部通过!${result.passed}/${result.total} 项检查 ✓`
70
+ : `Mission ${result.mission_id} passed all checks. ${result.passed}/${result.total} ✓`)
71
+ return { kind: "pass", missionId: opts.missionId, message: msg, details, result }
72
+ }
73
+ const msg = opts.locale === "zh-Hans"
74
+ ? `还差一点:${result.passed}/${result.total} 通过,${result.failed} 个需要再修一下。`
75
+ : `Almost: ${result.passed}/${result.total} passed, ${result.failed} still need work.`
76
+ return { kind: "fail", missionId: opts.missionId, message: msg, details, result }
77
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * SDK v2 client factory + cheap auth probe.
3
+ *
4
+ * The SDK ships at the @opencode-ai/sdk/v2 subpath (see Q1 verification).
5
+ * We instantiate once per client process; reconnection on SSE drop is
6
+ * handled by events.ts (it re-creates the stream, not the client).
7
+ */
8
+
9
+ import { createOpencodeClient } from "@opencode-ai/sdk/v2"
10
+
11
+ export type OpencodeClient = ReturnType<typeof createOpencodeClient>
12
+
13
+ export interface ConnectionOptions {
14
+ baseUrl: string
15
+ serverPassword: string
16
+ }
17
+
18
+ export function createKidsClient(opts: ConnectionOptions): OpencodeClient {
19
+ const authHeader = "Basic " + btoa(`:${opts.serverPassword}`)
20
+ return createOpencodeClient({
21
+ baseUrl: opts.baseUrl,
22
+ headers: {
23
+ authorization: authHeader,
24
+ },
25
+ } as Parameters<typeof createOpencodeClient>[0])
26
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Course Pack loader bridge. Wraps @kidsinai/kids-opencode-plugin's pack
3
+ * loader so the client can render real pack/mission metadata (title,
4
+ * mission index, Stars budget) instead of just echoing env vars.
5
+ *
6
+ * Also lists installed packs by scanning the bundled course-packs directory
7
+ * — used by CoursePackPicker.
8
+ */
9
+
10
+ import { readdirSync, statSync } from "node:fs"
11
+ import { join } from "node:path"
12
+ import {
13
+ bundledCoursePacksDir,
14
+ findMission,
15
+ loadCoursePack,
16
+ type CoursePack,
17
+ type CoursePackMission,
18
+ } from "@kidsinai/kids-opencode-plugin"
19
+
20
+ export interface ResolvedMissionContext {
21
+ pack: CoursePack
22
+ packTitle: string
23
+ mission: CoursePackMission | null
24
+ missionTitle: string | null
25
+ /** 1-based index of the current mission within pack.missions. null if free-play. */
26
+ missionIndex: number | null
27
+ missionTotal: number
28
+ starsBudget: number
29
+ }
30
+
31
+ export function resolveContext(packId: string | null, missionId: string | null): ResolvedMissionContext | null {
32
+ if (!packId) return null
33
+ const pack = loadCoursePack(packId)
34
+ if (!pack) return null
35
+ const missions = pack.missions ?? []
36
+ const mission = missionId ? findMission(pack, missionId) : null
37
+ const missionIndex = mission ? missions.findIndex((m) => m.id === mission.id) + 1 : null
38
+ return {
39
+ pack,
40
+ packTitle: pack.title,
41
+ mission,
42
+ missionTitle: mission?.title ?? null,
43
+ missionIndex: missionIndex && missionIndex > 0 ? missionIndex : null,
44
+ missionTotal: missions.length,
45
+ starsBudget: pack.estimated_stars_budget ?? 0,
46
+ }
47
+ }
48
+
49
+ export interface InstalledPack {
50
+ id: string
51
+ title: string
52
+ shortDescription: string | null
53
+ missionCount: number
54
+ starsBudget: number
55
+ }
56
+
57
+ /**
58
+ * Enumerate packs available in the bundled course-packs/ directory. Used
59
+ * by the picker screen. Tolerant of malformed packs (skips, doesn't
60
+ * throw).
61
+ */
62
+ export function listInstalledPacks(): InstalledPack[] {
63
+ const dir = bundledCoursePacksDir()
64
+ let entries: string[]
65
+ try {
66
+ entries = readdirSync(dir)
67
+ } catch {
68
+ return []
69
+ }
70
+ const out: InstalledPack[] = []
71
+ for (const id of entries) {
72
+ try {
73
+ const full = join(dir, id)
74
+ if (!statSync(full).isDirectory()) continue
75
+ const pack = loadCoursePack(id)
76
+ if (!pack) continue
77
+ out.push({
78
+ id: pack.id,
79
+ title: pack.title,
80
+ shortDescription: pack.short_description ?? null,
81
+ missionCount: pack.missions?.length ?? 0,
82
+ starsBudget: pack.estimated_stars_budget ?? 0,
83
+ })
84
+ } catch {
85
+ // skip malformed entry
86
+ }
87
+ }
88
+ return out
89
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Env contract between the wrapper (`bin/kids-opencode`) and this client.
3
+ * Wrapper resolves auth + flag → env vars → exec kids-client.
4
+ *
5
+ * Required keys cause hard-fail at startup with a friendly ErrorScreen
6
+ * (config_missing / auth_failed variant).
7
+ */
8
+
9
+ import { homedir } from "node:os"
10
+ import { join } from "node:path"
11
+
12
+ export interface KidsClientEnv {
13
+ /** Local opencode serve endpoint. Default http://127.0.0.1:4096. */
14
+ opencodeBaseUrl: string
15
+ /** HTTP Basic Auth password for serve. Mandatory. */
16
+ opencodeServerPassword: string
17
+ /** DeepRouter tenant key. May be empty when using BYOK bypass. */
18
+ deeprouterApiKey: string
19
+ /** True if the wrapper set KIDS_LLM_BYPASS_GATEWAY=1 (BYOK dogfood mode). */
20
+ bypassGateway: boolean
21
+ /** Optional course pack id (e.g. "portfolio-site"). */
22
+ coursePack: string | null
23
+ /** Optional mission id (e.g. "mission-1"). */
24
+ mission: string | null
25
+ /** Locale hint ("zh-Hans" / "en"). Picked from KIDS_LOCALE or $LANG. */
26
+ locale: "zh-Hans" | "en"
27
+ /** Path to opencode binary so client can spawn `opencode serve`. */
28
+ opencodeBin: string
29
+ /** Path to ~/.config/kids-opencode/ for audit buffer + future state. */
30
+ configDir: string
31
+ /** When true, the client renders a "Tony banner" / suppresses interactive prompts (CI). */
32
+ noBanner: boolean
33
+ }
34
+
35
+ export function readEnv(): KidsClientEnv {
36
+ const password = process.env.OPENCODE_SERVER_PASSWORD ?? ""
37
+ const lang = process.env.KIDS_LOCALE ?? process.env.LANG ?? "en"
38
+ const locale: "zh-Hans" | "en" = lang.toLowerCase().startsWith("zh") ? "zh-Hans" : "en"
39
+ return {
40
+ opencodeBaseUrl: process.env.OPENCODE_BASE_URL ?? "http://127.0.0.1:4096",
41
+ opencodeServerPassword: password,
42
+ deeprouterApiKey: process.env.DEEPROUTER_API_KEY ?? "",
43
+ bypassGateway: process.env.KIDS_LLM_BYPASS_GATEWAY === "1",
44
+ coursePack: process.env.KIDS_COURSE_PACK || null,
45
+ mission: process.env.KIDS_MISSION || null,
46
+ locale,
47
+ opencodeBin: process.env.OPENCODE_BIN ?? "opencode",
48
+ configDir: process.env.KIDS_OPENCODE_CONFIG_DIR ?? join(homedir(), ".config", "kids-opencode"),
49
+ noBanner: process.env.KIDS_OPENCODE_NO_BANNER === "1",
50
+ }
51
+ }
52
+
53
+ export function validateEnv(env: KidsClientEnv): { ok: true } | { ok: false; reason: string; variant: "config_missing" | "auth_failed" } {
54
+ if (!env.opencodeServerPassword) {
55
+ return {
56
+ ok: false,
57
+ reason: "OPENCODE_SERVER_PASSWORD is empty. The wrapper should have loaded it from " + join(env.configDir, "server-password"),
58
+ variant: "config_missing",
59
+ }
60
+ }
61
+ if (!env.bypassGateway && !env.deeprouterApiKey) {
62
+ return {
63
+ ok: false,
64
+ reason: "DEEPROUTER_API_KEY is empty. Run `kids-opencode register` first, or set KIDS_LLM_BYPASS_GATEWAY=1 with a provider key for dogfood.",
65
+ variant: "auth_failed",
66
+ }
67
+ }
68
+ return { ok: true }
69
+ }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * SSE subscriber over `client.global.event()`.
3
+ *
4
+ * Handles:
5
+ * - automatic reconnect on disconnect (5s back-off, max ten retries before
6
+ * surfacing serve_unreachable to the store)
7
+ * - dispatch of the discriminated union GlobalEvent.payload to per-type
8
+ * handlers
9
+ * - graceful shutdown via AbortSignal
10
+ *
11
+ * The actual `client.global.event()` return shape is an async iterable
12
+ * of GlobalEvent. We narrow on `payload.type`. See
13
+ * `~/Documents/sites/kidsinai/opencode-kernel/packages/sdk/js/src/v2/types.gen.ts`
14
+ * for the full union.
15
+ */
16
+
17
+ import type { OpencodeClient } from "./connection.ts"
18
+
19
+ export type EventHandlers = {
20
+ onSessionCreated?: (e: { sessionID: string }) => void
21
+ onMessagePartDelta?: (e: { sessionID: string; messageID: string; partID: string; delta: string }) => void
22
+ onTextEnded?: (e: { sessionID: string; messageID: string }) => void
23
+ onPermissionAsked?: (e: { requestID: string; sessionID: string; tool?: string; metadata?: Record<string, unknown> }) => void
24
+ onLlmError?: (e: { message: string }) => void
25
+ onCompactionEnded?: () => void
26
+ onUnknown?: (type: string, payload: unknown) => void
27
+ /** Fires when the SSE loop fails too many times in a row. */
28
+ onDisconnected?: (reason: string) => void
29
+ /** Fires once when reconnected after at least one failure. */
30
+ onReconnected?: () => void
31
+ }
32
+
33
+ export class EventSubscriber {
34
+ private client: OpencodeClient
35
+ private handlers: EventHandlers
36
+ private abort: AbortController
37
+ private retries = 0
38
+ private readonly MAX_RETRIES = 10
39
+
40
+ constructor(client: OpencodeClient, handlers: EventHandlers) {
41
+ this.client = client
42
+ this.handlers = handlers
43
+ this.abort = new AbortController()
44
+ }
45
+
46
+ /** Start the subscription loop. Resolves only when the loop exits. */
47
+ async run(): Promise<void> {
48
+ while (!this.abort.signal.aborted) {
49
+ try {
50
+ await this.consume()
51
+ // SSE stream ended cleanly (server-side); loop reconnects.
52
+ await this.sleep(1000)
53
+ } catch (err) {
54
+ if (this.abort.signal.aborted) return
55
+ this.retries++
56
+ if (this.retries > this.MAX_RETRIES) {
57
+ this.handlers.onDisconnected?.(`event stream failed ${this.retries} times: ${stringifyErr(err)}`)
58
+ return
59
+ }
60
+ await this.sleep(5000)
61
+ }
62
+ }
63
+ }
64
+
65
+ stop(): void {
66
+ this.abort.abort()
67
+ }
68
+
69
+ private async consume(): Promise<void> {
70
+ // The SDK exposes the SSE stream via client.global.event(). Shape varies
71
+ // between SDK minor versions (some return async iterable, some a stream
72
+ // helper). We use the duck-typed iterable path.
73
+ const eventApi = (this.client as unknown as { global?: { event: () => AsyncIterable<unknown> } }).global
74
+ if (!eventApi || typeof eventApi.event !== "function") {
75
+ throw new Error("@opencode-ai/sdk/v2: client.global.event() not available — SDK version drift")
76
+ }
77
+ const stream = eventApi.event()
78
+ for await (const raw of stream) {
79
+ if (this.abort.signal.aborted) return
80
+ if (this.retries > 0) {
81
+ this.retries = 0
82
+ this.handlers.onReconnected?.()
83
+ }
84
+ this.dispatch(raw)
85
+ }
86
+ }
87
+
88
+ private dispatch(raw: unknown): void {
89
+ const env = raw as { payload?: { type?: string } & Record<string, unknown> }
90
+ const payload = env?.payload
91
+ if (!payload || typeof payload.type !== "string") return
92
+ const t = payload.type
93
+ switch (t) {
94
+ case "session.created":
95
+ case "session.next.session.created":
96
+ this.handlers.onSessionCreated?.({ sessionID: String(payload.sessionID ?? "") })
97
+ return
98
+ case "message.part.delta": {
99
+ const messageID = String(payload.messageID ?? "")
100
+ const partID = String(payload.partID ?? "")
101
+ const sessionID = String(payload.sessionID ?? "")
102
+ const delta = String((payload.delta as { text?: string } | undefined)?.text ?? payload.delta ?? "")
103
+ if (delta) this.handlers.onMessagePartDelta?.({ sessionID, messageID, partID, delta })
104
+ return
105
+ }
106
+ case "session.next.text.delta": {
107
+ const messageID = String(payload.messageID ?? "")
108
+ const partID = String(payload.partID ?? "stream")
109
+ const sessionID = String(payload.sessionID ?? "")
110
+ const delta = String(payload.delta ?? "")
111
+ if (delta) this.handlers.onMessagePartDelta?.({ sessionID, messageID, partID, delta })
112
+ return
113
+ }
114
+ case "session.next.text.ended": {
115
+ const messageID = String(payload.messageID ?? "")
116
+ const sessionID = String(payload.sessionID ?? "")
117
+ this.handlers.onTextEnded?.({ sessionID, messageID })
118
+ return
119
+ }
120
+ case "permission.asked":
121
+ case "session.next.permission.asked": {
122
+ const requestID = String(payload.requestID ?? payload.id ?? "")
123
+ const sessionID = String(payload.sessionID ?? "")
124
+ this.handlers.onPermissionAsked?.({
125
+ requestID,
126
+ sessionID,
127
+ tool: payload.tool as string | undefined,
128
+ metadata: payload.metadata as Record<string, unknown> | undefined,
129
+ })
130
+ return
131
+ }
132
+ case "session.error":
133
+ case "llm.error": {
134
+ const message = String((payload.error as { message?: string } | undefined)?.message ?? payload.message ?? "unknown LLM error")
135
+ this.handlers.onLlmError?.({ message })
136
+ return
137
+ }
138
+ case "session.next.compaction.ended":
139
+ this.handlers.onCompactionEnded?.()
140
+ return
141
+ default:
142
+ this.handlers.onUnknown?.(t, payload)
143
+ }
144
+ }
145
+
146
+ private sleep(ms: number): Promise<void> {
147
+ return new Promise((resolve) => {
148
+ const timer = setTimeout(resolve, ms)
149
+ this.abort.signal.addEventListener(
150
+ "abort",
151
+ () => {
152
+ clearTimeout(timer)
153
+ resolve()
154
+ },
155
+ { once: true },
156
+ )
157
+ })
158
+ }
159
+ }
160
+
161
+ function stringifyErr(err: unknown): string {
162
+ if (err instanceof Error) return err.message
163
+ try {
164
+ return JSON.stringify(err)
165
+ } catch {
166
+ return String(err)
167
+ }
168
+ }