@kidsinai/kids-client 0.0.22 → 0.0.23

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.22",
4
+ "version": "0.0.23",
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",
@@ -17,8 +17,13 @@ export async function listModels(client: OpencodeClient): Promise<ModelChoice[]>
17
17
  }
18
18
  let raw: unknown
19
19
  try {
20
- if (typeof api.provider?.list === "function") raw = await api.provider.list()
21
- else if (typeof api.config?.providers === "function") raw = await api.config.providers()
20
+ // Prefer config.providers: it returns the configured/usable providers as
21
+ // { providers:[{id,models:[…]}], default } which flattenModels understands.
22
+ // provider.list returns the full models.dev catalog in a different shape
23
+ // ({ all, connected, default }) that flattens to 0 — that mismatch was why
24
+ // the /model picker showed "No models available". Verified vs serve 1.15.x.
25
+ if (typeof api.config?.providers === "function") raw = await api.config.providers()
26
+ else if (typeof api.provider?.list === "function") raw = await api.provider.list()
22
27
  else return []
23
28
  } catch {
24
29
  return []
@@ -12,6 +12,21 @@
12
12
  import { spawn, type Subprocess } from "bun"
13
13
  import { buildAuthHeader } from "./connection.ts"
14
14
 
15
+ /**
16
+ * Overall budget for the readiness poll. First boot in a large repo (file
17
+ * watcher + git + plugin load) can take >10s, so give it room before we
18
+ * surface a serve_unreachable error.
19
+ */
20
+ const DEFAULT_READY_TIMEOUT_MS = 30_000
21
+ /**
22
+ * Per-probe ceiling. CRITICAL: Bun's `fetch` has no default timeout, so a
23
+ * serve that ACCEPTS the connection but stalls mid-bootstrap (holds `/app`
24
+ * open without sending headers) would hang the probe — and therefore
25
+ * `ensureReady()` — forever, freezing the kid on "Starting AI engine…". An
26
+ * AbortSignal bounds every probe so a stuck serve becomes a retry, not a hang.
27
+ */
28
+ const DEFAULT_PROBE_TIMEOUT_MS = 2_000
29
+
15
30
  export type ServeReadiness =
16
31
  | { kind: "already_running" }
17
32
  | { kind: "spawned"; pid: number }
@@ -41,8 +56,10 @@ export interface ServeManagerOptions {
41
56
  */
42
57
  serverUsername: string
43
58
  opencodeBin: string
44
- /** Max wait for readiness probe in ms. Default 10s. */
59
+ /** Max total wait for readiness in ms. Default {@link DEFAULT_READY_TIMEOUT_MS}. */
45
60
  readyTimeoutMs?: number
61
+ /** Per-probe abort ceiling in ms. Default {@link DEFAULT_PROBE_TIMEOUT_MS}. */
62
+ probeTimeoutMs?: number
46
63
  /** Called for every parsed `[kids-audit]` JSON line on stderr. */
47
64
  onAuditLine?: (event: unknown) => void
48
65
  /** Called for every other (non-audit) stderr line. Useful for debug log. */
@@ -92,7 +109,7 @@ export class ServeManager {
92
109
 
93
110
  if (proc.stderr) void this.pipeStderr(proc.stderr)
94
111
 
95
- const timeout = this.opts.readyTimeoutMs ?? 10_000
112
+ const timeout = this.opts.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS
96
113
  const start = Date.now()
97
114
  let lastError = "no response"
98
115
  while (Date.now() - start < timeout) {
@@ -132,9 +149,14 @@ export class ServeManager {
132
149
  headers: {
133
150
  authorization: buildAuthHeader(this.opts.serverUsername, this.opts.serverPassword),
134
151
  },
152
+ // Without this, a serve that accepts the socket but stalls mid-boot
153
+ // hangs the probe forever (Bun fetch has no default timeout).
154
+ signal: AbortSignal.timeout(this.opts.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS),
135
155
  })
136
156
  return classifyProbeStatus(res.status)
137
157
  } catch {
158
+ // AbortError (timeout) and connection-refused both land here → treat as
159
+ // "nobody answering yet" so ensureReady() retries instead of freezing.
138
160
  return "offline"
139
161
  }
140
162
  }
@@ -67,11 +67,13 @@ export class SessionManager {
67
67
  */
68
68
  async loadMessages(sessionID: string): Promise<ChatMessage[]> {
69
69
  this.currentSessionId = sessionID
70
- const api = (this.client as unknown as { session?: { messages?: (p: unknown) => Promise<unknown> } }).session
70
+ const api = (this.client as unknown as { session?: { messages?: (p: unknown, o?: unknown) => Promise<unknown> } }).session
71
71
  if (typeof api?.messages !== "function") return []
72
72
  let raw: unknown
73
73
  try {
74
- raw = await api.messages({ sessionID, order: "asc", limit: 200 })
74
+ // SDK v2 flat params: `sessionID` path, `limit` query. There is no
75
+ // `order` param — the route already returns messages chronologically.
76
+ raw = await api.messages({ sessionID, limit: 200 }, SDK_THROW)
75
77
  } catch {
76
78
  return []
77
79
  }
@@ -104,15 +106,18 @@ export class SessionManager {
104
106
  const sessionID = this.currentSessionId!
105
107
  const api = (this.client as unknown as { session?: { prompt: (parameters: unknown, options?: unknown) => Promise<unknown> } }).session
106
108
  if (!api?.prompt) throw new Error("SDK v2: client.session.prompt unavailable")
107
- // SDK 1.14.51 signature: single parameters object, kid's text under .prompt.text.
108
- // Pass SDK_THROW so 4xx/5xx surface as exceptions instead of getting
109
- // silently swallowed (the bug behind the "thinking…" hang).
109
+ // SDK v2 prompt takes ONE flat params object; buildClientParams routes
110
+ // `sessionID` URL path and `parts`/`model`/`agent` body. A flat
111
+ // { sessionID, prompt:{text} } leaves body undefined — serve 1.15.x rejects
112
+ // it ("Expected object" / "Missing key parts"), which surfaced as endless
113
+ // "thinking…" then a Network-trouble error. Verified against 1.15.x: this
114
+ // shape returns 200. Pass SDK_THROW so 4xx/5xx surface as exceptions.
110
115
  // `model` (from the /model picker) is a "providerID/modelID" string; the SDK
111
116
  // wants it split into { providerID, modelID }.
112
117
  const model = splitModelId(opts?.model)
113
118
  const payload = {
114
119
  sessionID,
115
- prompt: { text },
120
+ parts: [{ type: "text", text }],
116
121
  ...(model ? { model } : {}),
117
122
  ...(opts?.agent ? { agent: opts.agent } : {}),
118
123
  }
@@ -163,38 +168,43 @@ function splitModelId(id: string | undefined): { providerID: string; modelID: st
163
168
 
164
169
  /** session.messages returns `{ items }` or `{ data: { items } }`. */
165
170
  function unwrapItems(result: unknown): unknown[] {
171
+ if (Array.isArray(result)) return result
166
172
  if (result && typeof result === "object") {
167
- const r = result as { items?: unknown; data?: { items?: unknown } }
173
+ const r = result as { items?: unknown; data?: unknown }
168
174
  if (Array.isArray(r.items)) return r.items
169
- if (Array.isArray(r.data?.items)) return r.data!.items as unknown[]
175
+ // SDK v2 with throwOnError returns { data: T[], request, response }.
176
+ if (Array.isArray(r.data)) return r.data
177
+ const di = (r.data as { items?: unknown })?.items
178
+ if (Array.isArray(di)) return di
170
179
  }
171
180
  return []
172
181
  }
173
182
 
174
- /** Map a server SessionMessage to our ChatMessage; null = skip (tool/control). */
183
+ /**
184
+ * Map a server SessionMessage to our ChatMessage; null = skip (tool/control).
185
+ *
186
+ * SDK v2 list items are `{ info: Message, parts: Part[] }`: the role/id/time
187
+ * live on `info`, and the visible text is the concatenation of the `text`
188
+ * parts. (The pre-v2 flat `{ type, text, content }` shape no longer applies.)
189
+ */
175
190
  export function mapServerMessage(m: unknown): ChatMessage | null {
176
191
  if (!m || typeof m !== "object") return null
177
192
  const o = m as {
178
- id?: string
179
- type?: string
180
- text?: string
181
- content?: Array<{ type?: string; text?: string }>
182
- time?: { created?: number }
183
- }
184
- const id = o.id ?? `srv-${o.time?.created ?? 0}`
185
- const ts = typeof o.time?.created === "number" ? o.time.created : 0
186
- if (o.type === "user" && typeof o.text === "string") {
187
- return { id, actor: "kid", text: o.text, streaming: false, ts }
188
- }
189
- if (o.type === "assistant" && Array.isArray(o.content)) {
190
- const text = o.content
191
- .filter((p) => p?.type === "text" && typeof p.text === "string")
192
- .map((p) => p.text)
193
- .join("")
194
- .trim()
195
- if (!text) return null
196
- return { id, actor: "agent", text, streaming: false, ts }
197
- }
193
+ info?: { id?: string; role?: string; time?: { created?: number } }
194
+ parts?: Array<{ type?: string; text?: string }>
195
+ }
196
+ const info = o.info
197
+ if (!info || typeof info !== "object") return null
198
+ const id = info.id ?? `srv-${info.time?.created ?? 0}`
199
+ const ts = typeof info.time?.created === "number" ? info.time.created : 0
200
+ const text = (Array.isArray(o.parts) ? o.parts : [])
201
+ .filter((p) => p?.type === "text" && typeof p.text === "string")
202
+ .map((p) => p.text)
203
+ .join("")
204
+ .trim()
205
+ if (!text) return null
206
+ if (info.role === "user") return { id, actor: "kid", text, streaming: false, ts }
207
+ if (info.role === "assistant") return { id, actor: "agent", text, streaming: false, ts }
198
208
  return null
199
209
  }
200
210
 
package/src/index.tsx CHANGED
@@ -54,7 +54,7 @@ interface ServiceSet {
54
54
  }
55
55
 
56
56
  interface FullHandlers {
57
- onStart: (mode: "free" | "course" | "resume" | "help") => void
57
+ onStart: (mode: "free" | "course" | "resume" | "help" | "settings") => void
58
58
  onPrompt: (text: string) => Promise<void>
59
59
  onPermissionReply: (decision: "allow" | "deny" | "edit") => Promise<void>
60
60
  onAbort: () => Promise<void>
@@ -66,7 +66,7 @@ interface FullHandlers {
66
66
  }
67
67
 
68
68
  interface AppHandlers {
69
- onStart: (mode: "free" | "course" | "resume" | "help") => void
69
+ onStart: (mode: "free" | "course" | "resume" | "help" | "settings") => void
70
70
  onPrompt: (text: string) => void
71
71
  onPermissionReply: (decision: "allow" | "deny" | "edit") => void
72
72
  onDangerousAcknowledge: () => void
@@ -212,7 +212,7 @@ function makeHandlers(
212
212
  }
213
213
 
214
214
  return {
215
- onStart: ifBooted((s, mode: "free" | "course" | "resume" | "help") => s.handlers.onStart(mode)),
215
+ onStart: ifBooted((s, mode: "free" | "course" | "resume" | "help" | "settings") => s.handlers.onStart(mode)),
216
216
  onPrompt: ifBooted((s, text: string) => s.handlers.onPrompt(text)),
217
217
  onPermissionReply: ifBooted((s, d: "allow" | "deny" | "edit") => s.handlers.onPermissionReply(d)),
218
218
  onDangerousAcknowledge: () => store.update({ dangerousTopic: null }),
@@ -571,6 +571,11 @@ function makeFullHandlers(
571
571
  store.update({ screen: { kind: "help" } })
572
572
  return
573
573
  }
574
+ if (mode === "settings") {
575
+ // Re-open the setup wizard to change provider / API key / model.
576
+ store.update({ screen: { kind: "setup" } })
577
+ return
578
+ }
574
579
  if (mode === "course") {
575
580
  store.update({ screen: { kind: "course_picker" } })
576
581
  return
@@ -650,7 +655,8 @@ function makeFullHandlers(
650
655
  try {
651
656
  await session.prompt(text, { model: snap.selectedModel ?? undefined })
652
657
  } catch (err) {
653
- store.update({ thinking: false, screen: { kind: "error", variant: "network_down", detail: errMessage(err) } })
658
+ const detail = errMessage(err)
659
+ store.update({ thinking: false, screen: { kind: "error", variant: classifyLlmError(detail), detail } })
654
660
  }
655
661
  },
656
662
  onPermissionReply: async (decision) => {
@@ -840,7 +846,7 @@ function handlePluginAudit(event: unknown, store: Store): void {
840
846
  * codes like WALLET_INSUFFICIENT / FAMILY_PAUSED (platform-backend §7) or
841
847
  * plain English ("insufficient credits", "rate limit", "402").
842
848
  */
843
- function classifyLlmError(msg: string): "stars_exhausted" | "network_down" {
849
+ function classifyLlmError(msg: string): "stars_exhausted" | "auth_failed" | "network_down" {
844
850
  const m = msg.toLowerCase()
845
851
  if (
846
852
  m.includes("wallet_insufficient")
@@ -853,6 +859,21 @@ function classifyLlmError(msg: string): "stars_exhausted" | "network_down" {
853
859
  ) {
854
860
  return "stars_exhausted"
855
861
  }
862
+ // Auth/sign-in failures (e.g. ChatGPT OAuth token invalidated, 401) are NOT
863
+ // network problems — the fix is to re-authenticate, so route to the
864
+ // reconfigurable error screen instead of the dead-end "can't reach AI".
865
+ if (
866
+ m.includes("token_invalidated")
867
+ || m.includes("authentication token")
868
+ || m.includes("sign in again")
869
+ || m.includes("signing in")
870
+ || m.includes("invalidated")
871
+ || m.includes("unauthorized")
872
+ || m.includes("invalid_api_key")
873
+ || m.includes("401")
874
+ ) {
875
+ return "auth_failed"
876
+ }
856
877
  return "network_down"
857
878
  }
858
879
 
@@ -42,7 +42,7 @@ export interface AppDeps {
42
42
  store: Store
43
43
  locale: "zh-Hans" | "en"
44
44
  installedPacks: InstalledPack[]
45
- onStart: (mode: "free" | "course" | "resume" | "help") => void
45
+ onStart: (mode: "free" | "course" | "resume" | "help" | "settings") => void
46
46
  onPrompt: (text: string) => void
47
47
  onPermissionReply: (decision: "allow" | "deny" | "edit") => void
48
48
  onDangerousAcknowledge: () => void
@@ -106,7 +106,10 @@ export function App(deps: AppDeps): React.ReactElement {
106
106
 
107
107
  const screen = renderScreen(state, deps)
108
108
  return (
109
- <Box width={width} height={height} flexDirection="column">
109
+ // paddingTop gives every screen a row of breathing room instead of being
110
+ // glued to the terminal's top edge. It's constant, so the App's footprint
111
+ // still never changes between renders (see the height-lock note above).
112
+ <Box width={width} height={height} flexDirection="column" paddingTop={1}>
110
113
  {screen}
111
114
  </Box>
112
115
  )
@@ -23,7 +23,7 @@ interface StartupScreenProps {
23
23
  locale: "zh-Hans" | "en"
24
24
  coursePack: string | null
25
25
  toast: ToastState | null
26
- onStart: (mode: "free" | "course" | "resume" | "help") => void
26
+ onStart: (mode: "free" | "course" | "resume" | "help" | "settings") => void
27
27
  onOpenWallet: () => void
28
28
  onQuit: () => void
29
29
  }
@@ -36,6 +36,7 @@ export function StartupScreen({ locale, coursePack, toast, onStart, onOpenWallet
36
36
  else if (input === "f") onStart("free")
37
37
  else if (input === "r") onStart("resume")
38
38
  else if (input === "w" || input === "W") onOpenWallet()
39
+ else if (input === "s" || input === "S") onStart("settings")
39
40
  else if (input === "h") onStart("help")
40
41
  else if (input === "q" || input === "Q") onQuit()
41
42
  })
@@ -64,6 +65,7 @@ export function StartupScreen({ locale, coursePack, toast, onStart, onOpenWallet
64
65
  { key: "f", label: t.startFree },
65
66
  { key: "r", label: t.resume },
66
67
  { key: "w", label: t.wallet },
68
+ { key: "s", label: t.settings },
67
69
  { key: "h", label: t.help },
68
70
  { key: "q", label: t.quit },
69
71
  ]} />
@@ -90,6 +92,7 @@ const STRINGS = {
90
92
  pickCourse: "选 Course Pack",
91
93
  resume: "继续上次",
92
94
  wallet: "钱包 / 充值(开浏览器)",
95
+ settings: "设置 / 换模型",
93
96
  help: "帮助",
94
97
  quit: "退出",
95
98
  },
@@ -105,6 +108,7 @@ const STRINGS = {
105
108
  pickCourse: "Pick a Course Pack",
106
109
  resume: "Resume last session",
107
110
  wallet: "Wallet / Top up (opens browser)",
111
+ settings: "Settings / change model",
108
112
  help: "Help",
109
113
  quit: "Quit",
110
114
  },