@kidsinai/kids-client 0.0.9 → 0.0.11

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.11",
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",
@@ -13,10 +13,17 @@ export type OpencodeClient = ReturnType<typeof createOpencodeClient>
13
13
  export interface ConnectionOptions {
14
14
  baseUrl: string
15
15
  serverPassword: string
16
+ /**
17
+ * Must match upstream's `OPENCODE_SERVER_USERNAME` (default "opencode").
18
+ * Sending an empty username here yields a 401 against opencode ≥1.x —
19
+ * see opencode-kernel server/auth.ts `authorized()` which requires
20
+ * `credentials.username === config.username`.
21
+ */
22
+ serverUsername: string
16
23
  }
17
24
 
18
25
  export function createKidsClient(opts: ConnectionOptions): OpencodeClient {
19
- const authHeader = "Basic " + btoa(`:${opts.serverPassword}`)
26
+ const authHeader = buildAuthHeader(opts.serverUsername, opts.serverPassword)
20
27
  return createOpencodeClient({
21
28
  baseUrl: opts.baseUrl,
22
29
  headers: {
@@ -24,3 +31,13 @@ export function createKidsClient(opts: ConnectionOptions): OpencodeClient {
24
31
  },
25
32
  } as Parameters<typeof createOpencodeClient>[0])
26
33
  }
34
+
35
+ /**
36
+ * Construct the HTTP Basic-Auth header opencode serve expects.
37
+ * Exposed so serve-manager's probe + tests can share the exact format
38
+ * upstream `authorized()` checks against (username MUST match config,
39
+ * default "opencode" — empty username produces 401 on opencode ≥1.x).
40
+ */
41
+ export function buildAuthHeader(username: string, password: string): string {
42
+ return "Basic " + btoa(`${username}:${password}`)
43
+ }
package/src/core/env.ts CHANGED
@@ -14,6 +14,13 @@ export interface KidsClientEnv {
14
14
  opencodeBaseUrl: string
15
15
  /** HTTP Basic Auth password for serve. Mandatory. */
16
16
  opencodeServerPassword: string
17
+ /**
18
+ * HTTP Basic Auth *username* for serve. Defaults to "opencode" to match
19
+ * upstream's `OPENCODE_SERVER_USERNAME` default (opencode-kernel
20
+ * packages/opencode/src/server/auth.ts) — sending an empty username here
21
+ * causes a 401 against opencode ≥1.x even when the password is correct.
22
+ */
23
+ opencodeServerUsername: string
17
24
  /** DeepRouter tenant key. May be empty when using BYOK bypass. */
18
25
  deeprouterApiKey: string
19
26
  /** True if the wrapper set KIDS_LLM_BYPASS_GATEWAY=1 (BYOK dogfood mode). */
@@ -39,6 +46,7 @@ export function readEnv(): KidsClientEnv {
39
46
  return {
40
47
  opencodeBaseUrl: process.env.OPENCODE_BASE_URL ?? "http://127.0.0.1:4096",
41
48
  opencodeServerPassword: password,
49
+ opencodeServerUsername: process.env.OPENCODE_SERVER_USERNAME || "opencode",
42
50
  deeprouterApiKey: process.env.DEEPROUTER_API_KEY ?? "",
43
51
  bypassGateway: process.env.KIDS_LLM_BYPASS_GATEWAY === "1",
44
52
  coursePack: process.env.KIDS_COURSE_PACK || null,
@@ -10,15 +10,36 @@
10
10
  */
11
11
 
12
12
  import { spawn, type Subprocess } from "bun"
13
+ import { buildAuthHeader } from "./connection.ts"
13
14
 
14
15
  export type ServeReadiness =
15
16
  | { kind: "already_running" }
16
17
  | { kind: "spawned"; pid: number }
18
+ // Someone else is holding the port (TCP accepts) but our password doesn't
19
+ // unlock /app. Usually a stale `opencode serve` from a previous run with a
20
+ // different OPENCODE_SERVER_PASSWORD. Telling the kid to retry won't help —
21
+ // they need to free the port (`kids-opencode --shutdown`).
22
+ | { kind: "port_taken_auth_mismatch"; port: string }
23
+ // We spawned a child but it exited before becoming ready (e.g. EADDRINUSE,
24
+ // missing config). exitCode/stderr surface the real cause instead of
25
+ // making the kid wait 10s for a generic timeout.
26
+ | { kind: "spawn_failed"; exitCode: number | null; stderrTail: string }
17
27
  | { kind: "timeout"; lastError: string }
18
28
 
29
+ /** Tri-state probe result; lets ensureReady() distinguish "nobody home"
30
+ * from "someone's home but won't let me in". */
31
+ export type ProbeResult = "ok" | "auth_mismatch" | "offline"
32
+
19
33
  export interface ServeManagerOptions {
20
34
  baseUrl: string
21
35
  serverPassword: string
36
+ /**
37
+ * Must match upstream's `OPENCODE_SERVER_USERNAME` (default "opencode").
38
+ * The probe sends this as the Basic-auth username — upstream's
39
+ * authorized() requires an exact match, so an empty username produces
40
+ * a 401 even when the password is correct.
41
+ */
42
+ serverUsername: string
22
43
  opencodeBin: string
23
44
  /** Max wait for readiness probe in ms. Default 10s. */
24
45
  readyTimeoutMs?: number
@@ -31,17 +52,30 @@ export interface ServeManagerOptions {
31
52
  export class ServeManager {
32
53
  private child: Subprocess | null = null
33
54
  private opts: ServeManagerOptions
55
+ // Recent stderr lines from the spawned child, used for spawn_failed
56
+ // diagnostics. Bounded so it doesn't grow unbounded over a long session.
57
+ private stderrTail: string[] = []
58
+ private static STDERR_TAIL_MAX = 20
34
59
 
35
60
  constructor(opts: ServeManagerOptions) {
36
61
  this.opts = opts
37
62
  }
38
63
 
39
64
  /**
40
- * Probe baseUrl. If already up, no-op. Otherwise spawn `opencode serve`
41
- * as a child, hook stderr parsing, poll until /app responds 200.
65
+ * Probe baseUrl. If already up, no-op. If something else holds the port
66
+ * with a different password, return port_taken_auth_mismatch (don't try
67
+ * to spawn — bind would just fail with EADDRINUSE). Otherwise spawn
68
+ * `opencode serve`, hook stderr parsing, and race the readiness poll
69
+ * against the child's exit so port conflicts surface in <1s instead of
70
+ * timing out after 10s.
42
71
  */
43
72
  async ensureReady(): Promise<ServeReadiness> {
44
- if (await this.probe()) return { kind: "already_running" }
73
+ const initial = await this.probe()
74
+ if (initial === "ok") return { kind: "already_running" }
75
+ if (initial === "auth_mismatch") {
76
+ const url = new URL(this.opts.baseUrl)
77
+ return { kind: "port_taken_auth_mismatch", port: url.port || "4096" }
78
+ }
45
79
 
46
80
  const url = new URL(this.opts.baseUrl)
47
81
  const proc = spawn({
@@ -49,6 +83,7 @@ export class ServeManager {
49
83
  env: {
50
84
  ...process.env,
51
85
  OPENCODE_SERVER_PASSWORD: this.opts.serverPassword,
86
+ OPENCODE_SERVER_USERNAME: this.opts.serverUsername,
52
87
  },
53
88
  stdout: "pipe",
54
89
  stderr: "pipe",
@@ -61,9 +96,19 @@ export class ServeManager {
61
96
  const start = Date.now()
62
97
  let lastError = "no response"
63
98
  while (Date.now() - start < timeout) {
64
- if (await this.probe()) return { kind: "spawned", pid: proc.pid ?? -1 }
99
+ // If the child died on its own (bind failure, bad args), don't keep
100
+ // polling — surface the exit immediately with whatever stderr we got.
101
+ if (proc.exitCode !== null) {
102
+ return {
103
+ kind: "spawn_failed",
104
+ exitCode: proc.exitCode,
105
+ stderrTail: this.stderrTail.join("\n"),
106
+ }
107
+ }
108
+ const status = await this.probe()
109
+ if (status === "ok") return { kind: "spawned", pid: proc.pid ?? -1 }
65
110
  await new Promise((r) => setTimeout(r, 200))
66
- lastError = "still booting"
111
+ lastError = status === "auth_mismatch" ? "auth mismatch on /app" : "still booting"
67
112
  }
68
113
  return { kind: "timeout", lastError }
69
114
  }
@@ -77,17 +122,20 @@ export class ServeManager {
77
122
  this.child = null
78
123
  }
79
124
 
80
- /** GET /app with Basic Auth. Returns true on 200. */
81
- private async probe(): Promise<boolean> {
125
+ /** GET /app with Basic Auth.
126
+ * 200 "ok"; 401/403 → "auth_mismatch" (port owned by another instance
127
+ * whose password differs); anything else (network refused, 5xx, timeout)
128
+ * → "offline". */
129
+ private async probe(): Promise<ProbeResult> {
82
130
  try {
83
131
  const res = await fetch(`${this.opts.baseUrl}/app`, {
84
132
  headers: {
85
- authorization: "Basic " + btoa(`:${this.opts.serverPassword}`),
133
+ authorization: buildAuthHeader(this.opts.serverUsername, this.opts.serverPassword),
86
134
  },
87
135
  })
88
- return res.ok
136
+ return classifyProbeStatus(res.status)
89
137
  } catch {
90
- return false
138
+ return "offline"
91
139
  }
92
140
  }
93
141
 
@@ -112,11 +160,24 @@ export class ServeManager {
112
160
  private handleLine(line: string): void {
113
161
  if (!line) return
114
162
  const audit = parseAuditLine(line)
115
- if (audit) this.opts.onAuditLine?.(audit)
116
- else this.opts.onDebugLine?.(line)
163
+ if (audit) {
164
+ this.opts.onAuditLine?.(audit)
165
+ return
166
+ }
167
+ this.stderrTail.push(line)
168
+ if (this.stderrTail.length > ServeManager.STDERR_TAIL_MAX) {
169
+ this.stderrTail.shift()
170
+ }
171
+ this.opts.onDebugLine?.(line)
117
172
  }
118
173
  }
119
174
 
175
+ export function classifyProbeStatus(status: number): ProbeResult {
176
+ if (status >= 200 && status < 300) return "ok"
177
+ if (status === 401 || status === 403) return "auth_mismatch"
178
+ return "offline"
179
+ }
180
+
120
181
  export function parseAuditLine(line: string): unknown | null {
121
182
  const prefixes = ["[kids-audit] ", "[kids-tui-audit] "]
122
183
  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
@@ -64,6 +64,7 @@ interface AppHandlers {
64
64
  onPermissionReply: (decision: "allow" | "deny" | "edit") => void
65
65
  onDangerousAcknowledge: () => void
66
66
  onErrorRetry: () => void | Promise<void>
67
+ onReconfigure: () => void
67
68
  onQuit: () => void | Promise<void>
68
69
  onAbort: () => void
69
70
  onHelpBack: () => void
@@ -189,6 +190,12 @@ function makeHandlers(
189
190
  // Pre-boot error retry: re-run main isn't trivial; just exit.
190
191
  process.exit(1)
191
192
  },
193
+ onReconfigure: () => {
194
+ // From an error screen, jump into the setup wizard so the parent can
195
+ // change provider / paste a new API key. onSetupContinue knows whether
196
+ // we're in first-run (resolve gate) or post-boot (reload env + retry).
197
+ store.update({ screen: { kind: "setup" } })
198
+ },
192
199
  onQuit: async () => {
193
200
  const s = servicesHolder.current
194
201
  if (s) return s.quit()
@@ -211,6 +218,16 @@ function makeHandlers(
211
218
  onSetupContinue: async () => {
212
219
  const r = getResolveSetup()
213
220
  if (r) r()
221
+ // Post-boot reconfigure path: services are already up but the env they
222
+ // were booted with is stale. Re-source the env file (the wizard wrote
223
+ // it) and replay the same recovery as [Enter] Retry on the error
224
+ // screen — push the loading screen and re-run readiness probe.
225
+ const s = servicesHolder.current
226
+ if (s) {
227
+ reloadEnvFile(env.configDir)
228
+ Object.assign(env, readEnv())
229
+ await s.handlers.onErrorRetry()
230
+ }
214
231
  },
215
232
  onSetupSkip: () => {
216
233
  const r = getResolveSetup()
@@ -245,6 +262,7 @@ async function bootServices(env: KidsClientEnv, store: Store): Promise<ServiceSe
245
262
  const serve = new ServeManager({
246
263
  baseUrl: env.opencodeBaseUrl,
247
264
  serverPassword: env.opencodeServerPassword,
265
+ serverUsername: env.opencodeServerUsername,
248
266
  opencodeBin: env.opencodeBin,
249
267
  onAuditLine: (event) => {
250
268
  audit.push(event)
@@ -254,6 +272,26 @@ async function bootServices(env: KidsClientEnv, store: Store): Promise<ServiceSe
254
272
  })
255
273
 
256
274
  const readiness = await serve.ensureReady()
275
+ if (readiness.kind === "port_taken_auth_mismatch") {
276
+ store.update({
277
+ screen: {
278
+ kind: "error",
279
+ variant: "port_taken",
280
+ detail: `port ${readiness.port} held by another opencode serve`,
281
+ },
282
+ })
283
+ return null
284
+ }
285
+ if (readiness.kind === "spawn_failed") {
286
+ store.update({
287
+ screen: {
288
+ kind: "error",
289
+ variant: "serve_unreachable",
290
+ detail: `opencode serve exited (${readiness.exitCode}): ${readiness.stderrTail || "no stderr"}`,
291
+ },
292
+ })
293
+ return null
294
+ }
257
295
  if (readiness.kind === "timeout") {
258
296
  store.update({ screen: { kind: "error", variant: "serve_unreachable", detail: readiness.lastError } })
259
297
  return null
@@ -262,6 +300,7 @@ async function bootServices(env: KidsClientEnv, store: Store): Promise<ServiceSe
262
300
  const client = createKidsClient({
263
301
  baseUrl: env.opencodeBaseUrl,
264
302
  serverPassword: env.opencodeServerPassword,
303
+ serverUsername: env.opencodeServerUsername,
265
304
  })
266
305
  const session = new SessionManager(client)
267
306
 
@@ -491,7 +530,23 @@ function makeFullHandlers(
491
530
  },
492
531
  })
493
532
  const again = await serve.ensureReady()
494
- if (again.kind === "timeout") {
533
+ if (again.kind === "port_taken_auth_mismatch") {
534
+ store.update({
535
+ screen: {
536
+ kind: "error",
537
+ variant: "port_taken",
538
+ detail: `port ${again.port} held by another opencode serve`,
539
+ },
540
+ })
541
+ } else if (again.kind === "spawn_failed") {
542
+ store.update({
543
+ screen: {
544
+ kind: "error",
545
+ variant: "serve_unreachable",
546
+ detail: `opencode serve exited (${again.exitCode}): ${again.stderrTail || "no stderr"}`,
547
+ },
548
+ })
549
+ } else if (again.kind === "timeout") {
495
550
  store.update({ screen: { kind: "error", variant: "serve_unreachable", detail: again.lastError } })
496
551
  } else {
497
552
  store.update({ screen: { kind: "startup" } })
@@ -11,7 +11,7 @@
11
11
 
12
12
  import React, { useSyncExternalStore } from "react"
13
13
  import type { InstalledPack } from "../../core/course-pack.ts"
14
- import type { Store } from "../../core/store.ts"
14
+ import type { ErrorVariant, Store } from "../../core/store.ts"
15
15
  import { StartupScreen } from "./screens/StartupScreen.tsx"
16
16
  import { MissionScreen } from "./screens/MissionScreen.tsx"
17
17
  import { PermissionModal } from "./screens/PermissionModal.tsx"
@@ -25,6 +25,17 @@ import { SetupScreen } from "./screens/SetupScreen.tsx"
25
25
  import { TourScreen } from "./screens/TourScreen.tsx"
26
26
  import type { ProviderId } from "../../core/setup.ts"
27
27
 
28
+ // Variants where the root cause may be a stale / wrong API key or a missing
29
+ // provider config — showing a [c] Change settings option that opens the setup
30
+ // wizard actually helps. Pure runtime/network problems (network_down,
31
+ // stars_exhausted, ai_hung) are excluded; they need a different recovery.
32
+ const RECONFIGURABLE_VARIANTS: ReadonlySet<ErrorVariant> = new Set([
33
+ "serve_unreachable",
34
+ "port_taken",
35
+ "auth_failed",
36
+ "config_missing",
37
+ ])
38
+
28
39
  export interface AppDeps {
29
40
  store: Store
30
41
  locale: "zh-Hans" | "en"
@@ -34,6 +45,11 @@ export interface AppDeps {
34
45
  onPermissionReply: (decision: "allow" | "deny" | "edit") => void
35
46
  onDangerousAcknowledge: () => void
36
47
  onErrorRetry: () => void
48
+ /**
49
+ * Jump from the error screen into the setup wizard. Only wired for
50
+ * config-related variants — see RECONFIGURABLE_VARIANTS below.
51
+ */
52
+ onReconfigure: () => void
37
53
  onQuit: () => void
38
54
  onAbort: () => void
39
55
  onHelpBack: () => void
@@ -117,6 +133,7 @@ export function App(deps: AppDeps): React.ReactElement {
117
133
  detail={state.screen.detail}
118
134
  locale={deps.locale}
119
135
  onRetry={deps.onErrorRetry}
136
+ onReconfigure={RECONFIGURABLE_VARIANTS.has(state.screen.variant) ? deps.onReconfigure : undefined}
120
137
  onQuit={deps.onQuit}
121
138
  />
122
139
  )
@@ -21,12 +21,20 @@ interface ErrorScreenProps {
21
21
  detail?: string
22
22
  onRetry?: () => void
23
23
  onQuit?: () => void
24
+ /**
25
+ * Open the setup wizard so the parent can change provider / paste a new
26
+ * API key. Wired by AppDeps only for config-related variants
27
+ * (serve_unreachable / port_taken / auth_failed / config_missing) — retry
28
+ * alone won't fix a wrong key.
29
+ */
30
+ onReconfigure?: () => void
24
31
  }
25
32
 
26
- export function ErrorScreen({ variant, locale, detail, onRetry, onQuit }: ErrorScreenProps): React.ReactElement {
33
+ export function ErrorScreen({ variant, locale, detail, onRetry, onQuit, onReconfigure }: ErrorScreenProps): React.ReactElement {
27
34
  const theme = getTheme()
28
35
  useInput((input, key) => {
29
36
  if (key.return && onRetry) onRetry()
37
+ else if ((input === "c" || input === "C") && onReconfigure) onReconfigure()
30
38
  else if (input === "q" && onQuit) onQuit()
31
39
  })
32
40
  const t = STRINGS[locale][variant]
@@ -52,6 +60,12 @@ export function ErrorScreen({ variant, locale, detail, onRetry, onQuit }: ErrorS
52
60
  <Text color={theme.fg}> {t.retry}</Text>
53
61
  </Box>
54
62
  )}
63
+ {onReconfigure && (
64
+ <Box marginRight={2}>
65
+ <Text color={theme.accent}>[c]</Text>
66
+ <Text color={theme.fg}> {STRINGS[locale].reconfigure}</Text>
67
+ </Box>
68
+ )}
55
69
  {onQuit && (
56
70
  <Box>
57
71
  <Text color={theme.accent}>[q]</Text>
@@ -66,11 +80,17 @@ export function ErrorScreen({ variant, locale, detail, onRetry, onQuit }: ErrorS
66
80
  const STRINGS = {
67
81
  "zh-Hans": {
68
82
  quit: "退出",
83
+ reconfigure: "改设置(换 key / 换 provider)",
69
84
  serve_unreachable: {
70
85
  title: "AI 老师还没起来",
71
86
  body: "后台 AI 服务好像没启动。要不要再试一次?",
72
87
  retry: "重试",
73
88
  },
89
+ port_taken: {
90
+ title: "另一个 AI 老师还在占着位子",
91
+ body: "请家长打开终端,跑一下:\n\n kids-opencode --shutdown\n\n然后按 Enter 再试。",
92
+ retry: "已经关掉了,再试",
93
+ },
74
94
  network_down: {
75
95
  title: "网络有点问题",
76
96
  body: "我没办法连上 AI。等会儿再来,或者问家长检查网络。",
@@ -99,11 +119,17 @@ const STRINGS = {
99
119
  },
100
120
  en: {
101
121
  quit: "Quit",
122
+ reconfigure: "Change settings (switch key / provider)",
102
123
  serve_unreachable: {
103
124
  title: "AI teacher didn't start",
104
125
  body: "The background AI service isn't running. Try again?",
105
126
  retry: "Retry",
106
127
  },
128
+ port_taken: {
129
+ title: "Another AI teacher is still holding the seat",
130
+ body: "Ask a parent to open a terminal and run:\n\n kids-opencode --shutdown\n\nThen press Enter to try again.",
131
+ retry: "Done — try again",
132
+ },
107
133
  network_down: {
108
134
  title: "Network trouble",
109
135
  body: "I can't reach the AI. Try later, or ask an adult to check the connection.",