@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 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.9",
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. Otherwise spawn `opencode serve`
41
- * as a child, hook stderr parsing, poll until /app responds 200.
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
- if (await this.probe()) return { kind: "already_running" }
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
- if (await this.probe()) return { kind: "spawned", pid: proc.pid ?? -1 }
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. Returns true on 200. */
81
- private async probe(): Promise<boolean> {
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.ok
127
+ return classifyProbeStatus(res.status)
89
128
  } catch {
90
- return false
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) this.opts.onAuditLine?.(audit)
116
- else this.opts.onDebugLine?.(line)
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
@@ -28,6 +28,7 @@ export type Screen =
28
28
 
29
29
  export type ErrorVariant =
30
30
  | "serve_unreachable"
31
+ | "port_taken"
31
32
  | "network_down"
32
33
  | "stars_exhausted"
33
34
  | "auth_failed"
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 === "timeout") {
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.",