typeclaw 0.2.0 → 0.3.1

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/src/cli/model.ts CHANGED
@@ -1,7 +1,13 @@
1
- import { cancel, intro, isCancel, select } from '@clack/prompts'
1
+ import { cancel, intro, isCancel, log, select } from '@clack/prompts'
2
2
  import { defineCommand } from 'citty'
3
3
 
4
- import { addProfile, listModelProfiles, removeProfile, setProfile } from '@/config/models-mutation'
4
+ import {
5
+ addProfile,
6
+ listModelProfiles,
7
+ listRegisteredModelRefs,
8
+ removeProfile,
9
+ setProfile,
10
+ } from '@/config/models-mutation'
5
11
  import {
6
12
  KNOWN_PROVIDERS,
7
13
  listKnownModelRefs,
@@ -11,8 +17,11 @@ import {
11
17
  } from '@/config/providers'
12
18
  import { findAgentDir, isInitialized } from '@/init'
13
19
 
20
+ import { runProviderAddFlow } from './provider'
14
21
  import { c, done, errorLine } from './ui'
15
22
 
23
+ const ADD_PROVIDER_SENTINEL = '__add-provider__'
24
+
16
25
  const setSub = defineCommand({
17
26
  meta: {
18
27
  name: 'set',
@@ -38,7 +47,7 @@ const setSub = defineCommand({
38
47
  async run({ args }) {
39
48
  const cwd = ensureAgentDir()
40
49
  const profile = args.profile ?? (await pickProfileName())
41
- const ref = args.ref ?? (await pickModelRef())
50
+ const ref = args.ref ?? (await pickModelRef(cwd))
42
51
 
43
52
  intro(`Setting model profile: ${profile} → ${ref}`)
44
53
 
@@ -78,7 +87,7 @@ const addSub = defineCommand({
78
87
  },
79
88
  async run({ args }) {
80
89
  const cwd = ensureAgentDir()
81
- const ref = args.ref ?? (await pickModelRef())
90
+ const ref = args.ref ?? (await pickModelRef(cwd))
82
91
 
83
92
  intro(`Adding model profile: ${args.profile} → ${ref}`)
84
93
 
@@ -198,22 +207,45 @@ async function pickProfileName(): Promise<string> {
198
207
  return choice
199
208
  }
200
209
 
201
- async function pickModelRef(): Promise<string> {
202
- const refs = listKnownModelRefs()
203
- const choice = await select<KnownModelRef>({
204
- message: 'Pick a model',
205
- options: refs.map((ref) => ({
206
- value: ref,
207
- label: describeRef(ref),
208
- hint: ref,
209
- })),
210
- initialValue: refs[0],
211
- })
212
- if (isCancel(choice)) {
213
- cancel('Aborted.')
214
- process.exit(0)
210
+ async function pickModelRef(cwd: string): Promise<string> {
211
+ while (true) {
212
+ const refs = listRegisteredModelRefs(cwd)
213
+ if (refs.length === 0) {
214
+ log.info("No provider credentials found. Let's add one first.")
215
+ const added = await runProviderAddFlow(cwd, {})
216
+ if (!added.ok) {
217
+ console.error(errorLine(added.reason))
218
+ process.exit(1)
219
+ }
220
+ continue
221
+ }
222
+ const choice = await select<KnownModelRef | typeof ADD_PROVIDER_SENTINEL>({
223
+ message: 'Pick a model',
224
+ options: [
225
+ ...refs.map((ref) => ({
226
+ value: ref,
227
+ label: describeRef(ref),
228
+ hint: ref,
229
+ })),
230
+ {
231
+ value: ADD_PROVIDER_SENTINEL,
232
+ label: c.cyan('+ add provider'),
233
+ hint: 'configure a new provider',
234
+ },
235
+ ],
236
+ initialValue: refs[0],
237
+ })
238
+ if (isCancel(choice)) {
239
+ cancel('Aborted.')
240
+ process.exit(0)
241
+ }
242
+ if (choice !== ADD_PROVIDER_SENTINEL) return choice
243
+ const added = await runProviderAddFlow(cwd, {})
244
+ if (!added.ok) {
245
+ console.error(errorLine(added.reason))
246
+ process.exit(1)
247
+ }
215
248
  }
216
- return choice
217
249
  }
218
250
 
219
251
  function describeRef(ref: KnownModelRef): string {
@@ -48,37 +48,51 @@ const addSub = defineCommand({
48
48
  },
49
49
  async run({ args }) {
50
50
  const cwd = ensureAgentDir()
51
- const providerId = await resolveProviderForAdd(args.provider)
52
- const provider = KNOWN_PROVIDERS[providerId]
53
-
54
- intro(`Adding provider: ${provider.name}`)
55
-
56
- const method = await resolveAuthMethod(provider, args)
57
- if (method === 'oauth') {
58
- const result = await runOAuthLogin(cwd, providerId)
59
- if (!result.ok) {
60
- console.error(errorLine(`OAuth login failed: ${result.reason}`))
61
- process.exit(1)
62
- }
63
- done({
64
- title: c.green(`Logged in to ${provider.name}.`),
65
- hints: nextStepHints({ credentialChanged: true }),
66
- })
67
- return
68
- }
69
-
70
- const credential = await resolveApiKeyInputs(provider, args)
71
- const result = addProvider(cwd, providerId, credential)
51
+ const result = await runProviderAddFlow(cwd, args)
72
52
  if (!result.ok) {
73
53
  console.error(errorLine(result.reason))
74
54
  process.exit(1)
75
55
  }
56
+ },
57
+ })
58
+
59
+ export type ProviderAddFlowArgs = {
60
+ provider?: string | undefined
61
+ key?: string | undefined
62
+ env?: string | undefined
63
+ oauth?: boolean | undefined
64
+ }
65
+
66
+ export type ProviderAddFlowResult =
67
+ | { ok: true; providerId: KnownProviderId; method: 'api-key' | 'oauth' }
68
+ | { ok: false; reason: string }
69
+
70
+ export async function runProviderAddFlow(cwd: string, args: ProviderAddFlowArgs): Promise<ProviderAddFlowResult> {
71
+ const providerId = await resolveProviderForAdd(args.provider)
72
+ const provider = KNOWN_PROVIDERS[providerId]
73
+
74
+ intro(`Adding provider: ${provider.name}`)
75
+
76
+ const method = await resolveAuthMethod(provider, args)
77
+ if (method === 'oauth') {
78
+ const result = await runOAuthLogin(cwd, providerId)
79
+ if (!result.ok) return { ok: false, reason: `OAuth login failed: ${result.reason}` }
76
80
  done({
77
- title: c.green(`Added ${provider.name} credentials to secrets.json.`),
81
+ title: c.green(`Logged in to ${provider.name}.`),
78
82
  hints: nextStepHints({ credentialChanged: true }),
79
83
  })
80
- },
81
- })
84
+ return { ok: true, providerId, method: 'oauth' }
85
+ }
86
+
87
+ const credential = await resolveApiKeyInputs(provider, args)
88
+ const result = addProvider(cwd, providerId, credential)
89
+ if (!result.ok) return { ok: false, reason: result.reason }
90
+ done({
91
+ title: c.green(`Added ${provider.name} credentials to secrets.json.`),
92
+ hints: nextStepHints({ credentialChanged: true }),
93
+ })
94
+ return { ok: true, providerId, method: 'api-key' }
95
+ }
82
96
 
83
97
  const setSub = defineCommand({
84
98
  meta: {
package/src/cli/usage.ts CHANGED
@@ -6,7 +6,7 @@ import { formatJson, formatReport } from '@/usage/report'
6
6
 
7
7
  import { parseSince, parseUntil, USAGE_COMMON_ARGS } from './usage-args'
8
8
 
9
- const SUBCOMMANDS = ['daily', 'session', 'models'] as const
9
+ const SUBCOMMANDS = ['daily', 'session', 'models', 'origin'] as const
10
10
  type Subcommand = (typeof SUBCOMMANDS)[number]
11
11
  type View = 'summary' | Subcommand
12
12
 
@@ -18,6 +18,13 @@ const COMMON_ARGS = {
18
18
  },
19
19
  }
20
20
 
21
+ // Captured by the parent's `setup` hook (which citty runs BEFORE the matched
22
+ // subcommand's `run`, with the full parent-level argv parsed). Subcommands
23
+ // read this in their own `run` to recover global options like `--since` that
24
+ // appeared before the subcommand name. Single-instance CLI processes only —
25
+ // no concurrency.
26
+ let parentRunArgs: Record<string, unknown> | undefined
27
+
21
28
  const subcommand = (view: View, description: string) =>
22
29
  defineCommand({
23
30
  meta: { name: view, description },
@@ -26,7 +33,7 @@ const subcommand = (view: View, description: string) =>
26
33
  ...(view === 'session' ? { limit: { type: 'string' as const, description: 'max sessions (default 20)' } } : {}),
27
34
  },
28
35
  async run({ args }) {
29
- await emit(view, args)
36
+ await emit(view, mergeParentArgs(args))
30
37
  },
31
38
  })
32
39
 
@@ -36,10 +43,14 @@ export const usageCommand = defineCommand({
36
43
  description: 'report LLM token usage and cost for this agent folder',
37
44
  },
38
45
  args: COMMON_ARGS,
46
+ setup({ args }) {
47
+ parentRunArgs = args as unknown as Record<string, unknown>
48
+ },
39
49
  subCommands: {
40
50
  daily: subcommand('daily', 'one row per calendar day'),
41
51
  session: subcommand('session', 'top sessions by cost'),
42
52
  models: subcommand('models', 'one row per provider/model'),
53
+ origin: subcommand('origin', 'one row per session origin (tui/cron/channel/subagent)'),
43
54
  },
44
55
  async run({ args }) {
45
56
  // citty invokes both the matched subcommand's `run` and the parent's
@@ -50,6 +61,23 @@ export const usageCommand = defineCommand({
50
61
  },
51
62
  })
52
63
 
64
+ // citty's subcommand `run` only sees args that came AFTER the subcommand
65
+ // name (the child's rawArgs is pre-sliced), so `usage --since=X origin` would
66
+ // silently drop `--since` despite the help text advertising it as a global
67
+ // option. The parent's `setup` runs first with the full parent-level parse
68
+ // (which includes everything: global options + subcommand options merged),
69
+ // so we capture it there and merge it as a fallback under any explicitly-set
70
+ // child arg. Child-wins so `usage --since=A origin --since=B` still honours B.
71
+ function mergeParentArgs(childArgs: Record<string, unknown>): Record<string, unknown> {
72
+ if (parentRunArgs === undefined) return childArgs
73
+ const merged: Record<string, unknown> = { ...parentRunArgs }
74
+ for (const key of Object.keys(childArgs)) {
75
+ const v = childArgs[key]
76
+ if (v !== undefined && v !== '' && v !== false) merged[key] = v
77
+ }
78
+ return merged
79
+ }
80
+
53
81
  async function emit(view: View, args: Record<string, unknown>): Promise<void> {
54
82
  const cwdArg = typeof args.cwd === 'string' && args.cwd.length > 0 ? args.cwd : process.cwd()
55
83
  const agentDir = findAgentDir(cwdArg) ?? cwdArg
@@ -881,7 +881,16 @@ export type ValidateConfigResult = { ok: true } | { ok: false; reason: string }
881
881
  // confusing path-sharing error (or, on some Linux setups, silently bind-mount
882
882
  // an empty auto-created directory). First-failure reporting matches the
883
883
  // schema-error path's shape; users fix one and re-run.
884
- export function validateConfig(cwd: string): ValidateConfigResult {
884
+ export type ValidateConfigOptions = {
885
+ // Skip the mount-path accessibility check. Host-side callers leave this
886
+ // false (the default) so missing mount directories surface as a precise
887
+ // pre-`docker run` error. Container-side callers (the reload registry)
888
+ // set it true because mount paths in typeclaw.json are host paths and
889
+ // don't resolve inside the container's filesystem.
890
+ skipMounts?: boolean
891
+ }
892
+
893
+ export function validateConfig(cwd: string, options: ValidateConfigOptions = {}): ValidateConfigResult {
885
894
  let raw: string
886
895
  try {
887
896
  raw = readFileSync(join(cwd, CONFIG_FILE), 'utf8')
@@ -907,9 +916,11 @@ export function validateConfig(cwd: string): ValidateConfigResult {
907
916
  return { ok: false, reason: `${CONFIG_FILE} is invalid: ${formatZodError(result.error)}` }
908
917
  }
909
918
 
910
- for (const mount of result.data.mounts) {
911
- const check = validateMount(mount, cwd)
912
- if (!check.ok) return check
919
+ if (!options.skipMounts) {
920
+ for (const mount of result.data.mounts) {
921
+ const check = validateMount(mount, cwd)
922
+ if (!check.ok) return check
923
+ }
913
924
  }
914
925
 
915
926
  return { ok: true }
@@ -11,7 +11,7 @@ import {
11
11
  type KnownModelRef,
12
12
  type KnownProviderId,
13
13
  } from './providers'
14
- import { isProviderConfigured } from './providers-mutation'
14
+ import { isProviderConfigured, listConfiguredProviders } from './providers-mutation'
15
15
 
16
16
  const CONFIG_FILE = 'typeclaw.json'
17
17
 
@@ -51,6 +51,25 @@ export function listAvailableModelRefs(): KnownModelRef[] {
51
51
  return listKnownModelRefs()
52
52
  }
53
53
 
54
+ // Subset of `listAvailableModelRefs()` filtered to providers with a usable
55
+ // credential in this agent folder — either a `secrets.json#providers.<id>`
56
+ // entry (api-key OR oauth) or a credential resolvable from the process env
57
+ // via the provider's canonical env-var name. Used by `typeclaw model set`'s
58
+ // interactive picker so users only see models they can actually run; the
59
+ // CLI surfaces an explicit "add provider" sentinel when the result is empty
60
+ // or when the user wants to wire a new one.
61
+ //
62
+ // Ordering preserves `listKnownModelRefs()` (provider-table declaration
63
+ // order, then per-provider model order) so the picker reads stably across
64
+ // invocations.
65
+ export function listRegisteredModelRefs(cwd: string, env: NodeJS.ProcessEnv = process.env): KnownModelRef[] {
66
+ const registered = new Set<KnownProviderId>()
67
+ for (const entry of listConfiguredProviders(cwd, env)) {
68
+ if (entry.known) registered.add(entry.id as KnownProviderId)
69
+ }
70
+ return listKnownModelRefs().filter((ref) => registered.has(providerForModelRef(ref)))
71
+ }
72
+
54
73
  export function isKnownModelRef(value: string): value is KnownModelRef {
55
74
  return (listKnownModelRefs() as ReadonlyArray<string>).includes(value)
56
75
  }
@@ -11,24 +11,42 @@ export type CreateConfigReloadableOptions = {
11
11
  // hand-edits) take effect without a container restart. `roles.<name>.permissions`
12
12
  // changes still require a restart — see FIELD_EFFECTS in config.ts.
13
13
  permissions?: PermissionService
14
+ // Skip the mount-path accessibility check inside validateConfig. Mount paths
15
+ // in typeclaw.json are host paths — they don't resolve inside the container,
16
+ // so the check would always fail on any agent that declares mounts. `mounts`
17
+ // is `restart-required` anyway, so reload never applies mount changes. Set
18
+ // this when wiring the reloadable from a container-stage context.
19
+ skipMountValidation?: boolean
14
20
  }
15
21
 
16
- export function createConfigReloadable({ cwd, permissions }: CreateConfigReloadableOptions): Reloadable {
22
+ export function createConfigReloadable({
23
+ cwd,
24
+ permissions,
25
+ skipMountValidation = false,
26
+ }: CreateConfigReloadableOptions): Reloadable {
17
27
  return {
18
28
  scope: 'config',
19
29
  description: 'typeclaw.json runtime config',
20
- reload: async () => doReload(cwd, permissions),
30
+ reload: async () => doReload(cwd, permissions, skipMountValidation),
21
31
  }
22
32
  }
23
33
 
24
- async function doReload(cwd: string, permissions: PermissionService | undefined): Promise<ReloadResult> {
34
+ async function doReload(
35
+ cwd: string,
36
+ permissions: PermissionService | undefined,
37
+ skipMountValidation: boolean,
38
+ ): Promise<ReloadResult> {
25
39
  // Mount accessibility belongs to the validation surface, not loadConfigSync —
26
40
  // validateConfig is the single gate that every host-side caller goes through.
27
41
  // Run it before swapping the live config pointer so a mount that vanished
28
42
  // between starts surfaces as a reload failure (`mounts` is restart-required
29
43
  // anyway, so the user has to restart to pick up changes; better to flag the
30
44
  // problem now than to let restart fail later).
31
- const validated = validateConfig(cwd)
45
+ //
46
+ // Container-side reload skips mount validation: mounts are host paths and
47
+ // statSync against them inside the container always fails. The host-side
48
+ // `start` / `restart` / doctor paths still gate on the full validateConfig.
49
+ const validated = validateConfig(cwd, { skipMounts: skipMountValidation })
32
50
  if (!validated.ok) {
33
51
  return { scope: 'config', ok: false, reason: validated.reason }
34
52
  }
@@ -1,3 +1,5 @@
1
+ import type { AgentSession } from '@/agent'
2
+ import { subscribeProviderErrors } from '@/agent/provider-error'
1
3
  import type { SessionOrigin } from '@/agent/session-origin'
2
4
  import type { HookBus } from '@/plugin'
3
5
  import type { Stream, Unsubscribe } from '@/stream'
@@ -20,6 +22,12 @@ export type CronSession = {
20
22
  agentDir?: string
21
23
  getTranscriptPath?: () => string | undefined
22
24
  origin?: SessionOrigin
25
+ // Underlying agent session, exposed so the consumer can subscribe to
26
+ // `message_end` events and surface soft provider errors (billing, rate
27
+ // limit, network — pi-coding-agent encodes these in the assistant message
28
+ // instead of throwing, so the outer try/catch never sees them). Optional
29
+ // so existing test fakes that only need `prompt` keep working.
30
+ session?: AgentSession
23
31
  }
24
32
 
25
33
  export type CronConsumerLogger = {
@@ -72,7 +80,7 @@ export function createCronConsumer({
72
80
  inFlight.add(job.id)
73
81
  try {
74
82
  if (job.kind === 'prompt') {
75
- await runPrompt(job, createSessionForCron, stream)
83
+ await runPrompt(job, createSessionForCron, stream, logger)
76
84
  } else {
77
85
  await runExec(job, cwd)
78
86
  }
@@ -98,6 +106,7 @@ async function runPrompt(
98
106
  job: PromptJob,
99
107
  createSessionForCron: (job: PromptJob) => Promise<CronSession>,
100
108
  stream: Stream,
109
+ logger: CronConsumerLogger,
101
110
  ): Promise<void> {
102
111
  if (job.subagent !== undefined) {
103
112
  // Propagate the cron job's role and origin into the spawned subagent.
@@ -123,6 +132,12 @@ async function runPrompt(
123
132
  return
124
133
  }
125
134
  const session = await createSessionForCron(job)
135
+ const unsubProviderErrors =
136
+ session.session !== undefined
137
+ ? subscribeProviderErrors(session.session, (err) => {
138
+ logger.error(`[cron] ${job.id}: LLM call failed: ${err.message}`)
139
+ })
140
+ : null
126
141
  const turnEvent =
127
142
  session.hooks && session.sessionId !== undefined && session.agentDir !== undefined
128
143
  ? {
@@ -151,6 +166,7 @@ async function runPrompt(
151
166
  })
152
167
  }
153
168
  } finally {
169
+ unsubProviderErrors?.()
154
170
  if (session.hooks && session.sessionId !== undefined) {
155
171
  await session.hooks.runSessionEnd({
156
172
  sessionId: session.sessionId,
@@ -33,6 +33,7 @@ export type BuildChannelSessionFactoryDeps = {
33
33
  // their inbound messages came from.
34
34
  getChannelRouter: () => ChannelRouter
35
35
  containerName?: string
36
+ runtimeVersion?: string
36
37
  // When set, rehydrating a session JSONL caps oversized tool results in the
37
38
  // file before pi-coding-agent reads it. `null` disables the load-time pass
38
39
  // (tool-result-cap.enabled=false in config, or no plugin block at all).
@@ -105,6 +106,7 @@ export function buildChannelSessionFactory(deps: BuildChannelSessionFactoryDeps)
105
106
  }
106
107
  : {}),
107
108
  ...(deps.containerName !== undefined ? { containerName: deps.containerName } : {}),
109
+ ...(deps.runtimeVersion !== undefined ? { runtimeVersion: deps.runtimeVersion } : {}),
108
110
  ...(deps.permissions !== undefined ? { permissions: deps.permissions } : {}),
109
111
  })
110
112
 
package/src/run/index.ts CHANGED
@@ -24,6 +24,7 @@ import {
24
24
  loadCron as loadCronDefault,
25
25
  type Scheduler,
26
26
  } from '@/cron'
27
+ import { CLI_VERSION } from '@/init/cli-version'
27
28
  import { loadPlugins, type LoadPluginsResult, pluginCronJobs, type PluginRegistry, summarizeLoaded } from '@/plugin'
28
29
  import { createContainerBroker, publishForwardResult } from '@/portbroker'
29
30
  import { ReloadRegistry } from '@/reload'
@@ -90,6 +91,7 @@ export async function startAgent({
90
91
  // which is what we want, since there is no host daemon to honor it anyway.
91
92
  const containerName = process.env.TYPECLAW_CONTAINER_NAME
92
93
  const containerNameOpt = containerName !== undefined ? { containerName } : {}
94
+ const runtimeVersionOpt = { runtimeVersion: CLI_VERSION }
93
95
  const tuiToken = process.env.TYPECLAW_TUI_TOKEN
94
96
  const tuiTokenOpt = tuiToken !== undefined && tuiToken !== '' ? { tuiToken } : {}
95
97
 
@@ -103,7 +105,13 @@ export async function startAgent({
103
105
  ...(cwdConfig.roles !== undefined ? { roles: cwdConfig.roles } : {}),
104
106
  })
105
107
 
106
- reloadRegistry.register(createConfigReloadable({ cwd, permissions: pluginsLoaded.permissions }))
108
+ reloadRegistry.register(
109
+ createConfigReloadable({
110
+ cwd,
111
+ permissions: pluginsLoaded.permissions,
112
+ skipMountValidation: containerName !== undefined,
113
+ }),
114
+ )
107
115
  const pluginRegistry = pluginsLoaded.registry
108
116
  const pluginHooks = pluginsLoaded.hooks
109
117
 
@@ -155,6 +163,7 @@ export async function startAgent({
155
163
  rehydrateCapOptions: resolveCapOptionsFromConfig(pluginConfigsByName['tool-result-cap']),
156
164
  permissions: pluginsLoaded.permissions,
157
165
  ...containerNameOpt,
166
+ ...runtimeVersionOpt,
158
167
  }),
159
168
  permissions: pluginsLoaded.permissions,
160
169
  claimHandler: claimController.claimHandler,
@@ -198,6 +207,7 @@ export async function startAgent({
198
207
  ...(entry.pluginSubagent.toolResultBudget !== undefined
199
208
  ? { toolResultBudget: entry.pluginSubagent.toolResultBudget }
200
209
  : {}),
210
+ ...runtimeVersionOpt,
201
211
  })
202
212
  return {
203
213
  ...created,
@@ -267,6 +277,7 @@ export async function startAgent({
267
277
  }
268
278
  : {}),
269
279
  ...containerNameOpt,
280
+ ...runtimeVersionOpt,
270
281
  })
271
282
  return {
272
283
  prompt: (text) => session.prompt(text),
@@ -274,6 +285,7 @@ export async function startAgent({
274
285
  sessionId,
275
286
  agentDir: cwd,
276
287
  origin: cronOrigin,
288
+ session,
277
289
  ...(snap.hasAnyPluginContent ? { hooks: snap.hooks } : {}),
278
290
  getTranscriptPath: () => sessionManager.getSessionFile(),
279
291
  }
@@ -316,6 +328,7 @@ export async function startAgent({
316
328
  agentDir: cwd,
317
329
  userPrompt: '',
318
330
  payload,
331
+ onProviderError: (message) => console.error(`[subagent] ${name}: LLM call failed: ${message}`),
319
332
  ...(options?.parentSessionId !== undefined ? { parentSessionId: options.parentSessionId } : {}),
320
333
  ...(spawnedByRole !== undefined ? { spawnedByRole } : {}),
321
334
  ...(options?.spawnedByOrigin !== undefined ? { spawnedByOrigin: options.spawnedByOrigin } : {}),
@@ -363,6 +376,7 @@ export async function startAgent({
363
376
  pluginRuntime,
364
377
  claimController,
365
378
  ...containerNameOpt,
379
+ ...runtimeVersionOpt,
366
380
  ...tuiTokenOpt,
367
381
  ...containerBrokerOpt,
368
382
  }).start()
@@ -7,6 +7,7 @@ import {
7
7
  type CreateSessionResult,
8
8
  } from '@/agent'
9
9
  import { runPluginDoctorChecks, runPluginDoctorFix } from '@/agent/doctor'
10
+ import { detectProviderError } from '@/agent/provider-error'
10
11
  import type { SessionOrigin } from '@/agent/session-origin'
11
12
  import type { ChannelRouter } from '@/channels/router'
12
13
  import type { HookBus } from '@/plugin'
@@ -38,6 +39,7 @@ export type ServerOptions = {
38
39
  agentDir?: string
39
40
  pluginRuntime?: PluginRuntime
40
41
  containerName?: string
42
+ runtimeVersion?: string
41
43
  tuiToken?: string
42
44
  // Optional in-process portbroker handler. When provided, requests to the
43
45
  // /portbroker WS path are routed to it instead of being treated as TUI
@@ -108,6 +110,7 @@ export function createServer({
108
110
  agentDir,
109
111
  pluginRuntime,
110
112
  containerName,
113
+ runtimeVersion,
111
114
  tuiToken,
112
115
  containerBroker,
113
116
  logger = consoleLogger,
@@ -167,6 +170,7 @@ export function createServer({
167
170
  ...(channelRouter ? { channelRouter } : {}),
168
171
  ...(pluginsWiring ? { plugins: pluginsWiring } : {}),
169
172
  ...(containerName !== undefined ? { containerName } : {}),
173
+ ...(runtimeVersion !== undefined ? { runtimeVersion } : {}),
170
174
  })
171
175
  const session = 'session' in result ? result.session : result
172
176
  const dispose = 'session' in result && result.dispose ? result.dispose : async () => {}
@@ -455,16 +459,10 @@ function forwardSessionEvents(ws: Ws, session: AgentSession, logger: ServerLogge
455
459
  }
456
460
 
457
461
  function forwardAssistantError(ws: Ws, message: unknown, logger: ServerLogger, sessionFileId: string): void {
458
- if (typeof message !== 'object' || message === null) return
459
- const m = message as { role?: string; stopReason?: string; errorMessage?: string }
460
- if (m.role !== 'assistant') return
461
- if (m.stopReason !== 'error' && m.stopReason !== 'aborted') return
462
- // 'aborted' is fired when the user hits Escape — don't surface it as an
463
- // error message because the TUI already shows abort feedback elsewhere.
464
- if (m.stopReason === 'aborted') return
465
- const text = typeof m.errorMessage === 'string' && m.errorMessage.length > 0 ? m.errorMessage : 'LLM call failed'
466
- logger.error(`[server] ${sessionFileId}: LLM call failed: ${text}`)
467
- send(ws, { type: 'error', message: text })
462
+ const detected = detectProviderError(message)
463
+ if (detected === null) return
464
+ logger.error(`[server] ${sessionFileId}: LLM call failed: ${detected.message}`)
465
+ send(ws, { type: 'error', message: detected.message })
468
466
  }
469
467
 
470
468
  function enqueuePrompt(