@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 +114 -0
- package/bin/kids-client +4 -0
- package/package.json +45 -0
- package/src/core/audit-pipeline.ts +93 -0
- package/src/core/check-runner.ts +77 -0
- package/src/core/connection.ts +26 -0
- package/src/core/course-pack.ts +89 -0
- package/src/core/env.ts +69 -0
- package/src/core/events.ts +168 -0
- package/src/core/last-session.ts +48 -0
- package/src/core/serve-manager.ts +132 -0
- package/src/core/session.ts +63 -0
- package/src/core/store.ts +165 -0
- package/src/dangerous-topic-bridge.ts +50 -0
- package/src/index.tsx +513 -0
- package/src/render/ink/App.tsx +112 -0
- package/src/render/ink/components/ChatStream.tsx +62 -0
- package/src/render/ink/components/Header.tsx +35 -0
- package/src/render/ink/components/Input.tsx +28 -0
- package/src/render/ink/components/KeyHints.tsx +21 -0
- package/src/render/ink/components/Thinking.tsx +21 -0
- package/src/render/ink/components/Toast.tsx +29 -0
- package/src/render/ink/screens/CoursePackPicker.tsx +90 -0
- package/src/render/ink/screens/DangerousTopicModal.tsx +63 -0
- package/src/render/ink/screens/ErrorScreen.tsx +133 -0
- package/src/render/ink/screens/HelpScreen.tsx +96 -0
- package/src/render/ink/screens/LoadingScreen.tsx +33 -0
- package/src/render/ink/screens/MissionCompleteScreen.tsx +112 -0
- package/src/render/ink/screens/MissionScreen.tsx +85 -0
- package/src/render/ink/screens/PermissionModal.tsx +77 -0
- package/src/render/ink/screens/StartupScreen.tsx +83 -0
- package/src/render/ink/theme.ts +58 -0
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/`
|
package/bin/kids-client
ADDED
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
|
+
}
|
package/src/core/env.ts
ADDED
|
@@ -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
|
+
}
|