@kidsinai/kids-client 0.0.8 → 0.0.10
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/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package.json",
|
|
3
3
|
"name": "@kidsinai/kids-client",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.10",
|
|
5
5
|
"type": "module",
|
|
6
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
7
|
"license": "MIT",
|
|
@@ -14,8 +14,21 @@ import { spawn, type Subprocess } from "bun"
|
|
|
14
14
|
export type ServeReadiness =
|
|
15
15
|
| { kind: "already_running" }
|
|
16
16
|
| { kind: "spawned"; pid: number }
|
|
17
|
+
// Someone else is holding the port (TCP accepts) but our password doesn't
|
|
18
|
+
// unlock /app. Usually a stale `opencode serve` from a previous run with a
|
|
19
|
+
// different OPENCODE_SERVER_PASSWORD. Telling the kid to retry won't help —
|
|
20
|
+
// they need to free the port (`kids-opencode --shutdown`).
|
|
21
|
+
| { kind: "port_taken_auth_mismatch"; port: string }
|
|
22
|
+
// We spawned a child but it exited before becoming ready (e.g. EADDRINUSE,
|
|
23
|
+
// missing config). exitCode/stderr surface the real cause instead of
|
|
24
|
+
// making the kid wait 10s for a generic timeout.
|
|
25
|
+
| { kind: "spawn_failed"; exitCode: number | null; stderrTail: string }
|
|
17
26
|
| { kind: "timeout"; lastError: string }
|
|
18
27
|
|
|
28
|
+
/** Tri-state probe result; lets ensureReady() distinguish "nobody home"
|
|
29
|
+
* from "someone's home but won't let me in". */
|
|
30
|
+
export type ProbeResult = "ok" | "auth_mismatch" | "offline"
|
|
31
|
+
|
|
19
32
|
export interface ServeManagerOptions {
|
|
20
33
|
baseUrl: string
|
|
21
34
|
serverPassword: string
|
|
@@ -31,17 +44,30 @@ export interface ServeManagerOptions {
|
|
|
31
44
|
export class ServeManager {
|
|
32
45
|
private child: Subprocess | null = null
|
|
33
46
|
private opts: ServeManagerOptions
|
|
47
|
+
// Recent stderr lines from the spawned child, used for spawn_failed
|
|
48
|
+
// diagnostics. Bounded so it doesn't grow unbounded over a long session.
|
|
49
|
+
private stderrTail: string[] = []
|
|
50
|
+
private static STDERR_TAIL_MAX = 20
|
|
34
51
|
|
|
35
52
|
constructor(opts: ServeManagerOptions) {
|
|
36
53
|
this.opts = opts
|
|
37
54
|
}
|
|
38
55
|
|
|
39
56
|
/**
|
|
40
|
-
* Probe baseUrl. If already up, no-op.
|
|
41
|
-
*
|
|
57
|
+
* Probe baseUrl. If already up, no-op. If something else holds the port
|
|
58
|
+
* with a different password, return port_taken_auth_mismatch (don't try
|
|
59
|
+
* to spawn — bind would just fail with EADDRINUSE). Otherwise spawn
|
|
60
|
+
* `opencode serve`, hook stderr parsing, and race the readiness poll
|
|
61
|
+
* against the child's exit so port conflicts surface in <1s instead of
|
|
62
|
+
* timing out after 10s.
|
|
42
63
|
*/
|
|
43
64
|
async ensureReady(): Promise<ServeReadiness> {
|
|
44
|
-
|
|
65
|
+
const initial = await this.probe()
|
|
66
|
+
if (initial === "ok") return { kind: "already_running" }
|
|
67
|
+
if (initial === "auth_mismatch") {
|
|
68
|
+
const url = new URL(this.opts.baseUrl)
|
|
69
|
+
return { kind: "port_taken_auth_mismatch", port: url.port || "4096" }
|
|
70
|
+
}
|
|
45
71
|
|
|
46
72
|
const url = new URL(this.opts.baseUrl)
|
|
47
73
|
const proc = spawn({
|
|
@@ -61,9 +87,19 @@ export class ServeManager {
|
|
|
61
87
|
const start = Date.now()
|
|
62
88
|
let lastError = "no response"
|
|
63
89
|
while (Date.now() - start < timeout) {
|
|
64
|
-
|
|
90
|
+
// If the child died on its own (bind failure, bad args), don't keep
|
|
91
|
+
// polling — surface the exit immediately with whatever stderr we got.
|
|
92
|
+
if (proc.exitCode !== null) {
|
|
93
|
+
return {
|
|
94
|
+
kind: "spawn_failed",
|
|
95
|
+
exitCode: proc.exitCode,
|
|
96
|
+
stderrTail: this.stderrTail.join("\n"),
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const status = await this.probe()
|
|
100
|
+
if (status === "ok") return { kind: "spawned", pid: proc.pid ?? -1 }
|
|
65
101
|
await new Promise((r) => setTimeout(r, 200))
|
|
66
|
-
lastError = "still booting"
|
|
102
|
+
lastError = status === "auth_mismatch" ? "auth mismatch on /app" : "still booting"
|
|
67
103
|
}
|
|
68
104
|
return { kind: "timeout", lastError }
|
|
69
105
|
}
|
|
@@ -77,17 +113,20 @@ export class ServeManager {
|
|
|
77
113
|
this.child = null
|
|
78
114
|
}
|
|
79
115
|
|
|
80
|
-
/** GET /app with Basic Auth.
|
|
81
|
-
|
|
116
|
+
/** GET /app with Basic Auth.
|
|
117
|
+
* 200 → "ok"; 401/403 → "auth_mismatch" (port owned by another instance
|
|
118
|
+
* whose password differs); anything else (network refused, 5xx, timeout)
|
|
119
|
+
* → "offline". */
|
|
120
|
+
private async probe(): Promise<ProbeResult> {
|
|
82
121
|
try {
|
|
83
122
|
const res = await fetch(`${this.opts.baseUrl}/app`, {
|
|
84
123
|
headers: {
|
|
85
124
|
authorization: "Basic " + btoa(`:${this.opts.serverPassword}`),
|
|
86
125
|
},
|
|
87
126
|
})
|
|
88
|
-
return res.
|
|
127
|
+
return classifyProbeStatus(res.status)
|
|
89
128
|
} catch {
|
|
90
|
-
return
|
|
129
|
+
return "offline"
|
|
91
130
|
}
|
|
92
131
|
}
|
|
93
132
|
|
|
@@ -112,11 +151,24 @@ export class ServeManager {
|
|
|
112
151
|
private handleLine(line: string): void {
|
|
113
152
|
if (!line) return
|
|
114
153
|
const audit = parseAuditLine(line)
|
|
115
|
-
if (audit)
|
|
116
|
-
|
|
154
|
+
if (audit) {
|
|
155
|
+
this.opts.onAuditLine?.(audit)
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
this.stderrTail.push(line)
|
|
159
|
+
if (this.stderrTail.length > ServeManager.STDERR_TAIL_MAX) {
|
|
160
|
+
this.stderrTail.shift()
|
|
161
|
+
}
|
|
162
|
+
this.opts.onDebugLine?.(line)
|
|
117
163
|
}
|
|
118
164
|
}
|
|
119
165
|
|
|
166
|
+
export function classifyProbeStatus(status: number): ProbeResult {
|
|
167
|
+
if (status >= 200 && status < 300) return "ok"
|
|
168
|
+
if (status === 401 || status === 403) return "auth_mismatch"
|
|
169
|
+
return "offline"
|
|
170
|
+
}
|
|
171
|
+
|
|
120
172
|
export function parseAuditLine(line: string): unknown | null {
|
|
121
173
|
const prefixes = ["[kids-audit] ", "[kids-tui-audit] "]
|
|
122
174
|
for (const prefix of prefixes) {
|
package/src/core/store.ts
CHANGED
package/src/index.tsx
CHANGED
|
@@ -254,6 +254,26 @@ async function bootServices(env: KidsClientEnv, store: Store): Promise<ServiceSe
|
|
|
254
254
|
})
|
|
255
255
|
|
|
256
256
|
const readiness = await serve.ensureReady()
|
|
257
|
+
if (readiness.kind === "port_taken_auth_mismatch") {
|
|
258
|
+
store.update({
|
|
259
|
+
screen: {
|
|
260
|
+
kind: "error",
|
|
261
|
+
variant: "port_taken",
|
|
262
|
+
detail: `port ${readiness.port} held by another opencode serve`,
|
|
263
|
+
},
|
|
264
|
+
})
|
|
265
|
+
return null
|
|
266
|
+
}
|
|
267
|
+
if (readiness.kind === "spawn_failed") {
|
|
268
|
+
store.update({
|
|
269
|
+
screen: {
|
|
270
|
+
kind: "error",
|
|
271
|
+
variant: "serve_unreachable",
|
|
272
|
+
detail: `opencode serve exited (${readiness.exitCode}): ${readiness.stderrTail || "no stderr"}`,
|
|
273
|
+
},
|
|
274
|
+
})
|
|
275
|
+
return null
|
|
276
|
+
}
|
|
257
277
|
if (readiness.kind === "timeout") {
|
|
258
278
|
store.update({ screen: { kind: "error", variant: "serve_unreachable", detail: readiness.lastError } })
|
|
259
279
|
return null
|
|
@@ -491,7 +511,23 @@ function makeFullHandlers(
|
|
|
491
511
|
},
|
|
492
512
|
})
|
|
493
513
|
const again = await serve.ensureReady()
|
|
494
|
-
if (again.kind === "
|
|
514
|
+
if (again.kind === "port_taken_auth_mismatch") {
|
|
515
|
+
store.update({
|
|
516
|
+
screen: {
|
|
517
|
+
kind: "error",
|
|
518
|
+
variant: "port_taken",
|
|
519
|
+
detail: `port ${again.port} held by another opencode serve`,
|
|
520
|
+
},
|
|
521
|
+
})
|
|
522
|
+
} else if (again.kind === "spawn_failed") {
|
|
523
|
+
store.update({
|
|
524
|
+
screen: {
|
|
525
|
+
kind: "error",
|
|
526
|
+
variant: "serve_unreachable",
|
|
527
|
+
detail: `opencode serve exited (${again.exitCode}): ${again.stderrTail || "no stderr"}`,
|
|
528
|
+
},
|
|
529
|
+
})
|
|
530
|
+
} else if (again.kind === "timeout") {
|
|
495
531
|
store.update({ screen: { kind: "error", variant: "serve_unreachable", detail: again.lastError } })
|
|
496
532
|
} else {
|
|
497
533
|
store.update({ screen: { kind: "startup" } })
|
|
@@ -31,11 +31,13 @@ const SPARKLE_ROW_BOTTOM = " ⭐ ✦ ⭐ ✦ "
|
|
|
31
31
|
|
|
32
32
|
export function KidsLogo(): React.ReactElement {
|
|
33
33
|
const theme = getTheme()
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
const
|
|
34
|
+
// Theme-driven — DARK uses cyanBright/yellow/greenBright/magentaBright,
|
|
35
|
+
// LIGHT swaps to non-Bright variants (blue/yellow/green/magenta) that
|
|
36
|
+
// hold contrast on white macOS Terminal default.
|
|
37
|
+
const cK = theme.logoK
|
|
38
|
+
const cI = theme.logoI
|
|
39
|
+
const cD = theme.logoD
|
|
40
|
+
const cS = theme.logoS
|
|
39
41
|
const gap = " " // 2-col gap between letters
|
|
40
42
|
return (
|
|
41
43
|
<Box flexDirection="column" alignItems="center">
|
|
@@ -71,6 +71,11 @@ const STRINGS = {
|
|
|
71
71
|
body: "后台 AI 服务好像没启动。要不要再试一次?",
|
|
72
72
|
retry: "重试",
|
|
73
73
|
},
|
|
74
|
+
port_taken: {
|
|
75
|
+
title: "另一个 AI 老师还在占着位子",
|
|
76
|
+
body: "请家长打开终端,跑一下:\n\n kids-opencode --shutdown\n\n然后按 Enter 再试。",
|
|
77
|
+
retry: "已经关掉了,再试",
|
|
78
|
+
},
|
|
74
79
|
network_down: {
|
|
75
80
|
title: "网络有点问题",
|
|
76
81
|
body: "我没办法连上 AI。等会儿再来,或者问家长检查网络。",
|
|
@@ -104,6 +109,11 @@ const STRINGS = {
|
|
|
104
109
|
body: "The background AI service isn't running. Try again?",
|
|
105
110
|
retry: "Retry",
|
|
106
111
|
},
|
|
112
|
+
port_taken: {
|
|
113
|
+
title: "Another AI teacher is still holding the seat",
|
|
114
|
+
body: "Ask a parent to open a terminal and run:\n\n kids-opencode --shutdown\n\nThen press Enter to try again.",
|
|
115
|
+
retry: "Done — try again",
|
|
116
|
+
},
|
|
107
117
|
network_down: {
|
|
108
118
|
title: "Network trouble",
|
|
109
119
|
body: "I can't reach the AI. Try later, or ask an adult to check the connection.",
|
package/src/render/ink/theme.ts
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Kid-warm color tokens.
|
|
3
|
-
*
|
|
2
|
+
* Kid-warm color tokens. Three themes, picked automatically based on the
|
|
3
|
+
* terminal's background color (white-bg macOS Terminal vs dark-bg iTerm2
|
|
4
|
+
* vs anything we can't infer).
|
|
4
5
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
* Pick order:
|
|
7
|
+
* 1. KIDS_THEME=dark|light|hc (explicit override; wins)
|
|
8
|
+
* 2. KIDS_HC=1 (legacy flag, maps to "hc")
|
|
9
|
+
* 3. COLORFGBG env var (terminal hint; "fg;bg" — macOS Terminal,
|
|
10
|
+
* iTerm2, VS Code, gnome-terminal all set this)
|
|
11
|
+
* 4. fallback : "dark"
|
|
12
|
+
*
|
|
13
|
+
* Logo colors (logoK/I/D/S) are part of the theme so KidsLogo doesn't
|
|
14
|
+
* have to know which terminal it's on.
|
|
8
15
|
*/
|
|
9
16
|
|
|
10
17
|
export interface Theme {
|
|
@@ -20,9 +27,15 @@ export interface Theme {
|
|
|
20
27
|
system: string
|
|
21
28
|
border: string
|
|
22
29
|
stars: string
|
|
30
|
+
// Kids OpenCode brand mark, per-letter
|
|
31
|
+
logoK: string
|
|
32
|
+
logoI: string
|
|
33
|
+
logoD: string
|
|
34
|
+
logoS: string
|
|
23
35
|
}
|
|
24
36
|
|
|
25
|
-
|
|
37
|
+
/** Default — vibrant on a dark terminal. */
|
|
38
|
+
const DARK: Theme = {
|
|
26
39
|
fg: "white",
|
|
27
40
|
bg: "black",
|
|
28
41
|
fgDim: "gray",
|
|
@@ -35,9 +48,39 @@ const DEFAULT: Theme = {
|
|
|
35
48
|
system: "blueBright",
|
|
36
49
|
border: "yellow",
|
|
37
50
|
stars: "yellowBright",
|
|
51
|
+
logoK: "cyanBright",
|
|
52
|
+
logoI: "yellow",
|
|
53
|
+
logoD: "greenBright",
|
|
54
|
+
logoS: "magentaBright",
|
|
38
55
|
}
|
|
39
56
|
|
|
40
|
-
|
|
57
|
+
/**
|
|
58
|
+
* Light terminal (macOS Terminal.app default, light VS Code, etc.).
|
|
59
|
+
* Avoids Bright variants — they wash out to near-invisible on white.
|
|
60
|
+
* Uses non-Bright ANSI codes (33/32/36/35) which most terminals render
|
|
61
|
+
* as darker shades that hold contrast against white.
|
|
62
|
+
*/
|
|
63
|
+
const LIGHT: Theme = {
|
|
64
|
+
fg: "black",
|
|
65
|
+
bg: "white",
|
|
66
|
+
fgDim: "blackBright", // dark gray — still readable on white
|
|
67
|
+
accent: "magenta", // not yellow/cyan, those wash out
|
|
68
|
+
warn: "red", // yellow text is unreadable on white
|
|
69
|
+
danger: "red",
|
|
70
|
+
success: "green",
|
|
71
|
+
agent: "blue", // not cyan
|
|
72
|
+
kid: "magenta",
|
|
73
|
+
system: "blue",
|
|
74
|
+
border: "blackBright", // medium gray frame
|
|
75
|
+
stars: "yellow", // ANSI 33 = olive/brown on white = "gold-ish"
|
|
76
|
+
logoK: "blue", // brand K — blue instead of teal
|
|
77
|
+
logoI: "yellow", // olive on white, readable
|
|
78
|
+
logoD: "green", // dark green
|
|
79
|
+
logoS: "magenta", // dark magenta
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** High-contrast on dark — for poor-vision / low-light. */
|
|
83
|
+
const HC: Theme = {
|
|
41
84
|
fg: "whiteBright",
|
|
42
85
|
bg: "black",
|
|
43
86
|
fgDim: "white",
|
|
@@ -50,9 +93,56 @@ const HIGH_CONTRAST: Theme = {
|
|
|
50
93
|
system: "blueBright",
|
|
51
94
|
border: "whiteBright",
|
|
52
95
|
stars: "yellowBright",
|
|
96
|
+
logoK: "cyanBright",
|
|
97
|
+
logoI: "yellowBright",
|
|
98
|
+
logoD: "greenBright",
|
|
99
|
+
logoS: "magentaBright",
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export type ThemeKind = "dark" | "light" | "hc"
|
|
103
|
+
|
|
104
|
+
export function resolveThemeKind(): ThemeKind {
|
|
105
|
+
// 1. Explicit env var
|
|
106
|
+
const explicit = (process.env.KIDS_THEME ?? "").toLowerCase()
|
|
107
|
+
if (explicit === "dark" || explicit === "light" || explicit === "hc") return explicit
|
|
108
|
+
// 2. Legacy flag
|
|
109
|
+
if (process.env.KIDS_HC === "1") return "hc"
|
|
110
|
+
// 3. COLORFGBG terminal hint
|
|
111
|
+
const detected = detectFromColorFgBg(process.env.COLORFGBG)
|
|
112
|
+
if (detected) return detected
|
|
113
|
+
// 4. Fallback
|
|
114
|
+
return "dark"
|
|
53
115
|
}
|
|
54
116
|
|
|
55
117
|
export function getTheme(): Theme {
|
|
56
|
-
|
|
57
|
-
return
|
|
118
|
+
const kind = resolveThemeKind()
|
|
119
|
+
if (kind === "light") return LIGHT
|
|
120
|
+
if (kind === "hc") return HC
|
|
121
|
+
return DARK
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Parse the COLORFGBG env var. Format: "fg;bg" or "fg;default;bg" — a
|
|
126
|
+
* semicolon-separated list where the LAST field is the background color
|
|
127
|
+
* code (ANSI 0-15). 0-6 are dark; 7 is light gray; 8 is "bright black"
|
|
128
|
+
* (dark gray); 9-14 are bright dark-family; 15 is bright white.
|
|
129
|
+
*
|
|
130
|
+
* Empirically: macOS Terminal.app sets COLORFGBG=0;15 in light mode and
|
|
131
|
+
* 15;0 in Pro/Homebrew dark themes. iTerm2 mirrors. VS Code's integrated
|
|
132
|
+
* terminal also sets it.
|
|
133
|
+
*
|
|
134
|
+
* We treat 7 (light gray) and 15 (white) as "light"; everything else
|
|
135
|
+
* (incl. 8 dark-gray) as dark. Some terminals send "default" instead of
|
|
136
|
+
* a digit — we return null and let the caller fall through.
|
|
137
|
+
*/
|
|
138
|
+
export function detectFromColorFgBg(raw: string | undefined): ThemeKind | null {
|
|
139
|
+
if (!raw) return null
|
|
140
|
+
const parts = raw.split(";").map((s) => s.trim()).filter(Boolean)
|
|
141
|
+
if (parts.length === 0) return null
|
|
142
|
+
const bgStr = parts[parts.length - 1]!
|
|
143
|
+
if (bgStr === "default") return null
|
|
144
|
+
const bg = Number.parseInt(bgStr, 10)
|
|
145
|
+
if (!Number.isFinite(bg)) return null
|
|
146
|
+
if (bg === 7 || bg === 15) return "light"
|
|
147
|
+
return "dark"
|
|
58
148
|
}
|