@kidsinai/kids-client 0.0.24 → 0.0.26

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.24",
4
+ "version": "0.0.26",
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",
@@ -69,3 +69,28 @@ function pickModels(models: unknown): unknown[] {
69
69
  if (models && typeof models === "object") return Object.values(models)
70
70
  return []
71
71
  }
72
+
73
+ /** True when an LLM error is about the *model itself* (not auth/network) — e.g.
74
+ * a model the current ChatGPT-account auth isn't allowed to use. The fix is to
75
+ * switch models, so callers should re-pick rather than retry or re-auth. */
76
+ export function isModelUnavailable(msg: string): boolean {
77
+ const m = msg.toLowerCase()
78
+ return m.includes("not supported")
79
+ || m.includes("model_not_found")
80
+ || m.includes("does not have access")
81
+ || m.includes("not available")
82
+ || m.includes("unsupported model")
83
+ }
84
+
85
+ /** Pick a kid-safe default model: prefer the small/standard tiers that work
86
+ * with ChatGPT-account auth; skip the `-pro` tiers (API-key only, rejected by
87
+ * the Codex/OAuth path). Returns null only if the server reports no models. */
88
+ export function pickDefaultModel(models: ModelChoice[]): ModelChoice | null {
89
+ const usable = models.filter((m) => !/-pro\b/i.test(m.id))
90
+ const prefer = ["gpt-5.4-mini", "gpt-5.4", "claude-3-5-sonnet", "sonnet", "gpt-5.5"]
91
+ for (const p of prefer) {
92
+ const hit = usable.find((m) => m.id.toLowerCase().includes(p))
93
+ if (hit) return hit
94
+ }
95
+ return usable[0] ?? models[0] ?? null
96
+ }
@@ -56,6 +56,16 @@ export interface ServeManagerOptions {
56
56
  */
57
57
  serverUsername: string
58
58
  opencodeBin: string
59
+ /**
60
+ * Inline opencode config (JSON string) passed to the spawned serve via
61
+ * OPENCODE_CONFIG_CONTENT. opencode otherwise reads the empty global config
62
+ * and falls back to a default model the ChatGPT-account auth can't use
63
+ * (gpt-5.5-pro) with permissions unset. Passing a curated `{ model,
64
+ * permission }` here pins a usable default model AND enforces the kid-safety
65
+ * "ask before write/edit" gate at the source. (OPENCODE_CONFIG as a file
66
+ * path is unreliable; the inline content var is what opencode honors.)
67
+ */
68
+ opencodeConfigContent?: string
59
69
  /** Max total wait for readiness in ms. Default {@link DEFAULT_READY_TIMEOUT_MS}. */
60
70
  readyTimeoutMs?: number
61
71
  /** Per-probe abort ceiling in ms. Default {@link DEFAULT_PROBE_TIMEOUT_MS}. */
@@ -101,6 +111,9 @@ export class ServeManager {
101
111
  ...process.env,
102
112
  OPENCODE_SERVER_PASSWORD: this.opts.serverPassword,
103
113
  OPENCODE_SERVER_USERNAME: this.opts.serverUsername,
114
+ ...(this.opts.opencodeConfigContent
115
+ ? { OPENCODE_CONFIG_CONTENT: this.opts.opencodeConfigContent }
116
+ : {}),
104
117
  },
105
118
  stdout: "pipe",
106
119
  stderr: "pipe",
package/src/index.tsx CHANGED
@@ -20,6 +20,7 @@
20
20
  import React from "react"
21
21
  import { render } from "ink"
22
22
  import { join } from "node:path"
23
+ import { readFileSync } from "node:fs"
23
24
  import { readEnv, validateEnv, type KidsClientEnv } from "./core/env.ts"
24
25
  import { ServeManager } from "./core/serve-manager.ts"
25
26
  import { createKidsClient, type OpencodeClient } from "./core/connection.ts"
@@ -31,7 +32,7 @@ import { listInstalledPacks, resolveContext } from "./core/course-pack.ts"
31
32
  import { readLastSession, writeLastSession } from "./core/last-session.ts"
32
33
  import { isCompletionTrigger, runCheck } from "./core/check-runner.ts"
33
34
  import { parseSlash, matchCommand } from "./core/commands.ts"
34
- import { listModels } from "./core/models.ts"
35
+ import { listModels, isModelUnavailable, pickDefaultModel } from "./core/models.ts"
35
36
  import { findFiles } from "./core/files.ts"
36
37
  import { App } from "./render/ink/App.tsx"
37
38
  import { FREE_PLAY_PACK_ID } from "./render/ink/screens/CoursePackPicker.tsx"
@@ -344,6 +345,7 @@ async function bootServices(env: KidsClientEnv, store: Store): Promise<ServiceSe
344
345
  serverPassword: env.opencodeServerPassword,
345
346
  serverUsername: env.opencodeServerUsername,
346
347
  opencodeBin: env.opencodeBin,
348
+ opencodeConfigContent: buildServeConfig(env.configDir),
347
349
  onAuditLine: (event) => {
348
350
  audit.push(event)
349
351
  store.pushAudit(event)
@@ -652,10 +654,32 @@ function makeFullHandlers(
652
654
 
653
655
  store.update({ thinking: true })
654
656
  updateLastSession()
657
+ // Resolve a usable model up front. With no explicit pick, the server
658
+ // falls back to whatever model it last used — which may be one the
659
+ // current auth can't use (a ChatGPT-account login can't use gpt-5.5-pro).
660
+ // Pinning a known-good default makes the kid's first message just work.
661
+ let model = snap.selectedModel
662
+ if (!model) {
663
+ const def = pickDefaultModel(await listModels(client))
664
+ if (def) {
665
+ model = def.id
666
+ store.update({ selectedModel: def.id, selectedModelLabel: def.label })
667
+ }
668
+ }
655
669
  try {
656
- await session.prompt(text, { model: snap.selectedModel ?? undefined })
670
+ await session.prompt(text, { model: model ?? undefined })
657
671
  } catch (err) {
658
672
  const detail = errMessage(err)
673
+ if (isModelUnavailable(detail)) {
674
+ // The model isn't usable on this account (e.g. a -pro model under a
675
+ // ChatGPT login). Clear it so the next message auto-picks a good
676
+ // default, and guide the kid to /model instead of a scary error.
677
+ store.update({ thinking: false, selectedModel: null, selectedModelLabel: null })
678
+ sysMessage(env.locale === "zh-Hans"
679
+ ? "这个 AI 模型在你的账号下用不了。直接再发一条消息会自动换成可用模型,或打 /model 自己选(推荐 gpt-5.4-mini)。"
680
+ : "That AI model isn't available on your account. Just send again to auto-switch to a usable one, or type /model to choose (try gpt-5.4-mini).")
681
+ return
682
+ }
659
683
  store.update({ thinking: false, screen: { kind: "error", variant: classifyLlmError(detail), detail } })
660
684
  }
661
685
  },
@@ -893,6 +917,34 @@ function errMessage(err: unknown): string {
893
917
  return String(err)
894
918
  }
895
919
 
920
+ /**
921
+ * Build the inline opencode config passed to the spawned serve. opencode's
922
+ * own global config is effectively empty, so without this it defaults the
923
+ * openai provider to gpt-5.5-pro (rejected by ChatGPT-account auth) with no
924
+ * permission gate. We forward a curated, schema-clean subset of the kids
925
+ * preset: a usable default model + the kid-safety "ask before acting" gate.
926
+ *
927
+ * Deliberately NOT forwarded: the preset's `agent.tools` / `_comment` keys —
928
+ * they fail opencode's config schema (which silently drops the whole config).
929
+ * The tool whitelist is enforced by the kids plugin regardless, so nothing is
930
+ * lost. Returns undefined if the preset is missing/unreadable (serve then
931
+ * falls back to its own config — same as before this change).
932
+ */
933
+ function buildServeConfig(configDir: string): string | undefined {
934
+ let model = "openai/gpt-5.4-mini"
935
+ try {
936
+ const preset = JSON.parse(readFileSync(join(configDir, "opencode.json"), "utf8")) as { model?: string }
937
+ if (typeof preset.model === "string" && preset.model.includes("/")) model = preset.model
938
+ } catch {
939
+ return undefined
940
+ }
941
+ return JSON.stringify({
942
+ $schema: "https://opencode.ai/config.json",
943
+ model,
944
+ permission: { edit: "ask", write: "ask", bash: "ask", webfetch: "ask" },
945
+ })
946
+ }
947
+
896
948
  void main().catch((err) => {
897
949
  console.error("kids-client: fatal startup error:", err)
898
950
  process.exit(1)