@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.8",
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" } })
@@ -31,11 +31,13 @@ const SPARKLE_ROW_BOTTOM = " ⭐ ✦ ⭐ ✦ "
31
31
 
32
32
  export function KidsLogo(): React.ReactElement {
33
33
  const theme = getTheme()
34
- // Colors approximate the brand logo. Use "Bright" variants so dark terminals pop.
35
- const cK = "cyanBright"
36
- const cI = "yellow"
37
- const cD = "greenBright"
38
- const cS = "magentaBright"
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.",
@@ -1,10 +1,17 @@
1
1
  /**
2
- * Kid-warm color tokens. Hand-tuned to satisfy WCAG AA on most terminals
3
- * with dark backgrounds. High-contrast variant available via $KIDS_HC=1.
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
- * Sourced conceptually from kids-tui-plugin/themes/kids-warm.json but
6
- * inlined here because Ink consumes raw ANSI/chalk color names, not
7
- * opentui theme JSON.
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
- const DEFAULT: Theme = {
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
- const HIGH_CONTRAST: Theme = {
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
- if (process.env.KIDS_HC === "1") return HIGH_CONTRAST
57
- return DEFAULT
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
  }