@kidsinai/kids-client 0.0.9 → 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 +1 -1
- package/src/core/serve-manager.ts +63 -11
- package/src/core/store.ts +1 -0
- package/src/index.tsx +37 -1
- package/src/render/ink/screens/ErrorScreen.tsx +10 -0
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" } })
|
|
@@ -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.",
|