@kidsinai/kids-client 0.0.19 → 0.0.20

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.19",
4
+ "version": "0.0.20",
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",
@@ -35,7 +35,7 @@
35
35
  "ink-spinner": "^5.0.0",
36
36
  "ink-text-input": "^6.0.0",
37
37
  "react": "^18.3.1",
38
- "@kidsinai/kids-opencode-plugin": "^0.0.19"
38
+ "@kidsinai/kids-opencode-plugin": "^0.0.20"
39
39
  },
40
40
  "devDependencies": {
41
41
  "@opencode-ai/sdk": "^1.14.51",
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Tiny file-based debug logger.
3
+ *
4
+ * Always-on writes to ~/.config/kids-opencode/debug.log so we can see what
5
+ * actually happened in a TUI session after the fact (Ink owns the terminal,
6
+ * so console.log/error doesn't show on screen and would also break the
7
+ * alt-screen-buffer canvas).
8
+ *
9
+ * Set KIDS_DEBUG=0 to silence it. Otherwise it's on by default while we're
10
+ * still chasing the "thinking…" hang in dogfood.
11
+ */
12
+
13
+ import { appendFileSync, mkdirSync } from "node:fs"
14
+ import { dirname, join } from "node:path"
15
+ import { homedir } from "node:os"
16
+
17
+ const PATH = process.env.KIDS_DEBUG_LOG
18
+ ?? join(process.env.KIDS_OPENCODE_CONFIG_DIR ?? join(homedir(), ".config", "kids-opencode"), "debug.log")
19
+
20
+ const ENABLED = process.env.KIDS_DEBUG !== "0"
21
+
22
+ let initialised = false
23
+
24
+ function init(): void {
25
+ if (initialised) return
26
+ try {
27
+ mkdirSync(dirname(PATH), { recursive: true })
28
+ appendFileSync(PATH, `\n===== kids-client debug log opened at ${new Date().toISOString()} (pid ${process.pid}) =====\n`, "utf8")
29
+ } catch {
30
+ // can't init — disable to avoid further failures
31
+ initialised = true
32
+ return
33
+ }
34
+ initialised = true
35
+ }
36
+
37
+ export function debug(message: string, fields?: Record<string, unknown>): void {
38
+ if (!ENABLED) return
39
+ init()
40
+ try {
41
+ const stamp = new Date().toISOString()
42
+ const tail = fields ? " " + JSON.stringify(fields) : ""
43
+ appendFileSync(PATH, `${stamp} ${message}${tail}\n`, "utf8")
44
+ } catch {
45
+ // best-effort, never throw from the logger
46
+ }
47
+ }
48
+
49
+ export function debugLogPath(): string {
50
+ return PATH
51
+ }
@@ -66,6 +66,14 @@ export class EventSubscriber {
66
66
  this.abort.abort()
67
67
  }
68
68
 
69
+ private dbg(message: string, fields?: Record<string, unknown>): void {
70
+ // Lazy require to avoid a hard dep cycle if debug.ts grows imports.
71
+ try {
72
+ const { debug } = require("./debug.ts") as { debug: (m: string, f?: Record<string, unknown>) => void }
73
+ debug(`[events] ${message}`, fields)
74
+ } catch { /* never let logging block events */ }
75
+ }
76
+
69
77
  private async consume(): Promise<void> {
70
78
  // The SDK exposes the SSE stream via client.global.event(). The shape
71
79
  // has changed across SDK versions:
@@ -78,19 +86,24 @@ export class EventSubscriber {
78
86
  if (!eventApi || typeof eventApi.event !== "function") {
79
87
  throw new Error("@opencode-ai/sdk/v2: client.global.event() not available — SDK version drift")
80
88
  }
89
+ this.dbg("consume: calling event()")
81
90
  const result = await Promise.resolve(eventApi.event())
91
+ this.dbg("consume: event() returned", { shape: describeShape(result) })
82
92
  const iterable = pickAsyncIterable(result)
83
93
  if (!iterable) {
84
94
  throw new Error(`@opencode-ai/sdk/v2: client.global.event() returned an unrecognised shape: ${describeShape(result)}`)
85
95
  }
96
+ this.dbg("consume: got iterable, awaiting SSE events…")
86
97
  for await (const raw of iterable) {
87
98
  if (this.abort.signal.aborted) return
88
99
  if (this.retries > 0) {
89
100
  this.retries = 0
90
101
  this.handlers.onReconnected?.()
91
102
  }
103
+ this.dbg("consume: raw event", { preview: previewRaw(raw) })
92
104
  this.dispatch(raw)
93
105
  }
106
+ this.dbg("consume: iterable ended")
94
107
  }
95
108
 
96
109
  private dispatch(raw: unknown): void {
@@ -239,3 +252,12 @@ function describeShape(value: unknown): string {
239
252
  if (typeof value !== "object") return typeof value
240
253
  return `object keys=[${Object.keys(value as object).join(",")}]`
241
254
  }
255
+
256
+ function previewRaw(raw: unknown): string {
257
+ try {
258
+ const s = JSON.stringify(raw)
259
+ return s.length > 220 ? s.slice(0, 220) + "…" : s
260
+ } catch {
261
+ return describeShape(raw)
262
+ }
263
+ }
@@ -11,6 +11,16 @@
11
11
 
12
12
  import type { OpencodeClient } from "./connection.ts"
13
13
  import type { SessionSummary } from "./store.ts"
14
+ import { debug } from "./debug.ts"
15
+
16
+ /**
17
+ * SDK 1.14.x default is `ThrowOnError=false` which returns the discriminated
18
+ * union { data, error }. If we don't inspect `.error`, auth / 4xx / 5xx
19
+ * responses are silently swallowed and the kid sees "thinking…" forever.
20
+ * Pass this options object to every call so the SDK throws and the
21
+ * orchestrator's existing try/catch can surface the error on ErrorScreen.
22
+ */
23
+ const SDK_THROW: { throwOnError: true } = { throwOnError: true }
14
24
 
15
25
  export class SessionManager {
16
26
  private client: OpencodeClient
@@ -50,35 +60,59 @@ export class SessionManager {
50
60
  }
51
61
 
52
62
  async create(): Promise<string> {
53
- const api = (this.client as unknown as { session?: { create: (input?: unknown) => Promise<{ data?: { id?: string } } | { id?: string } | string> } }).session
63
+ const api = (this.client as unknown as { session?: { create: (input?: unknown, options?: unknown) => Promise<{ data?: { id?: string } } | { id?: string } | string> } }).session
54
64
  if (!api?.create) throw new Error("SDK v2: client.session.create unavailable")
55
- const result = await api.create({})
65
+ debug("session.create: calling SDK")
66
+ const result = await api.create({}, SDK_THROW)
67
+ debug("session.create: result shape", { keys: result && typeof result === "object" ? Object.keys(result) : typeof result })
56
68
  const id = extractId(result)
57
69
  if (!id) throw new Error("SDK v2 session.create returned no id")
58
70
  this.currentSessionId = id
71
+ debug("session.create: id", { id })
59
72
  return id
60
73
  }
61
74
 
62
75
  async prompt(text: string, opts?: { model?: string; agent?: string }): Promise<void> {
63
76
  if (!this.currentSessionId) await this.create()
64
77
  const sessionID = this.currentSessionId!
65
- const api = (this.client as unknown as { session?: { prompt: (sessionID: string, body: unknown) => Promise<unknown> } }).session
78
+ const api = (this.client as unknown as { session?: { prompt: (parameters: unknown, options?: unknown) => Promise<unknown> } }).session
66
79
  if (!api?.prompt) throw new Error("SDK v2: client.session.prompt unavailable")
67
- await api.prompt(sessionID, {
68
- parts: [{ type: "text", text }],
69
- model: opts?.model,
70
- agent: opts?.agent,
71
- })
80
+ // SDK 1.14.51 signature: single parameters object, kid's text under .prompt.text.
81
+ // Pass SDK_THROW so 4xx/5xx surface as exceptions instead of getting
82
+ // silently swallowed (the bug behind the "thinking…" hang).
83
+ // `model` (from the /model picker) is a "providerID/modelID" string; the SDK
84
+ // wants it split into { providerID, modelID }.
85
+ const model = splitModelId(opts?.model)
86
+ const payload = {
87
+ sessionID,
88
+ prompt: { text },
89
+ ...(model ? { model } : {}),
90
+ ...(opts?.agent ? { agent: opts.agent } : {}),
91
+ }
92
+ debug("session.prompt: sending", { sessionID, textLen: text.length })
93
+ try {
94
+ const result = await api.prompt(payload, SDK_THROW)
95
+ debug("session.prompt: SDK returned", { keys: result && typeof result === "object" ? Object.keys(result) : typeof result })
96
+ } catch (err) {
97
+ debug("session.prompt: SDK threw", { error: errMsg(err) })
98
+ throw err
99
+ }
72
100
  }
73
101
 
74
102
  async abort(): Promise<void> {
75
103
  if (!this.currentSessionId) return
76
- const api = (this.client as unknown as { session?: { abort: (sessionID: string) => Promise<unknown> } }).session
104
+ const api = (this.client as unknown as { session?: { abort: (parameters: unknown, options?: unknown) => Promise<unknown> } }).session
77
105
  if (!api?.abort) return
78
- await api.abort(this.currentSessionId)
106
+ debug("session.abort", { sessionID: this.currentSessionId })
107
+ await api.abort({ sessionID: this.currentSessionId }, SDK_THROW)
79
108
  }
80
109
  }
81
110
 
111
+ function errMsg(err: unknown): string {
112
+ if (err instanceof Error) return `${err.name}: ${err.message}`
113
+ try { return JSON.stringify(err) } catch { return String(err) }
114
+ }
115
+
82
116
  function extractId(result: unknown): string | null {
83
117
  if (typeof result === "string") return result
84
118
  if (result && typeof result === "object") {
@@ -88,6 +122,18 @@ function extractId(result: unknown): string | null {
88
122
  return null
89
123
  }
90
124
 
125
+ /**
126
+ * Split a "providerID/modelID" id (as built by the /model picker) into the
127
+ * shape the SDK prompt body wants. Splits on the FIRST slash so model ids that
128
+ * themselves contain "/" survive. Returns undefined for empty/no input.
129
+ */
130
+ function splitModelId(id: string | undefined): { providerID: string; modelID: string } | undefined {
131
+ if (!id) return undefined
132
+ const slash = id.indexOf("/")
133
+ if (slash <= 0 || slash === id.length - 1) return undefined
134
+ return { providerID: id.slice(0, slash), modelID: id.slice(slash + 1) }
135
+ }
136
+
91
137
  /** SDK list responses come back as `T[]` or `{ data: T[] }` across versions. */
92
138
  function unwrapArray(result: unknown): unknown[] {
93
139
  if (Array.isArray(result)) return result