typeclaw 0.4.0 → 0.5.0

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.
Files changed (54) hide show
  1. package/package.json +1 -1
  2. package/src/agent/auth.ts +4 -2
  3. package/src/agent/index.ts +16 -28
  4. package/src/agent/model-fallback.ts +127 -0
  5. package/src/agent/tools/curl-impersonate.ts +300 -0
  6. package/src/agent/tools/ddg.ts +13 -88
  7. package/src/agent/tools/webfetch/fetch.ts +105 -2
  8. package/src/agent/tools/webfetch/tool.ts +4 -0
  9. package/src/bundled-plugins/agent-browser/shim.ts +47 -0
  10. package/src/bundled-plugins/backup/subagents.ts +2 -0
  11. package/src/bundled-plugins/memory/README.md +49 -12
  12. package/src/bundled-plugins/memory/citation-superset.ts +63 -0
  13. package/src/bundled-plugins/memory/dreaming.ts +105 -17
  14. package/src/bundled-plugins/memory/index.ts +2 -2
  15. package/src/bundled-plugins/memory/memory-logger.ts +45 -26
  16. package/src/bundled-plugins/memory/strength.ts +127 -0
  17. package/src/bundled-plugins/memory/topics.ts +75 -0
  18. package/src/bundled-plugins/security/index.ts +87 -43
  19. package/src/bundled-plugins/security/permissions.ts +36 -0
  20. package/src/bundled-plugins/security/policies/git-exfil.ts +20 -0
  21. package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +12 -0
  22. package/src/bundled-plugins/security/policies/prompt-injection.ts +23 -3
  23. package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +7 -0
  24. package/src/bundled-plugins/security/policies/secret-exfil-read.ts +6 -0
  25. package/src/bundled-plugins/security/policies/session-search-secrets.ts +9 -0
  26. package/src/bundled-plugins/security/policies/ssrf.ts +6 -0
  27. package/src/bundled-plugins/security/policies/system-prompt-leak.ts +7 -0
  28. package/src/channels/adapters/github/index.ts +87 -3
  29. package/src/channels/router.ts +194 -28
  30. package/src/channels/types.ts +3 -1
  31. package/src/cli/init.ts +146 -42
  32. package/src/cli/model.ts +10 -2
  33. package/src/cli/oauth-callbacks.ts +49 -0
  34. package/src/cli/provider.ts +3 -20
  35. package/src/config/config.ts +59 -24
  36. package/src/config/models-mutation.ts +42 -8
  37. package/src/config/providers-mutation.ts +12 -8
  38. package/src/container/start.ts +18 -1
  39. package/src/cron/consumer.ts +129 -43
  40. package/src/init/dockerfile.ts +109 -3
  41. package/src/init/hatching.ts +2 -2
  42. package/src/init/index.ts +14 -3
  43. package/src/init/oauth-login.ts +17 -3
  44. package/src/permissions/builtins.ts +29 -7
  45. package/src/permissions/permissions.ts +24 -7
  46. package/src/plugin/define.ts +2 -0
  47. package/src/plugin/manager.ts +14 -0
  48. package/src/plugin/types.ts +6 -0
  49. package/src/run/index.ts +2 -1
  50. package/src/skills/typeclaw-memory/SKILL.md +25 -15
  51. package/src/skills/typeclaw-permissions/SKILL.md +35 -17
  52. package/src/tui/index.ts +35 -3
  53. package/src/usage/report.ts +15 -12
  54. package/typeclaw.schema.json +57 -25
package/src/cli/init.ts CHANGED
@@ -11,7 +11,7 @@ import {
11
11
  type KnownModelRef,
12
12
  type KnownProviderId,
13
13
  } from '@/config/providers'
14
- import type { DockerAvailability } from '@/container'
14
+ import { checkDockerAvailable, type DockerAvailability } from '@/container'
15
15
  import {
16
16
  findAgentDir,
17
17
  formatEagerGithubWebhookInstallResult,
@@ -29,8 +29,9 @@ import {
29
29
  } from '@/init'
30
30
  import { runKakaotalkBootstrap } from '@/init/kakaotalk-auth'
31
31
  import { fetchModelOptions, type ModelOption } from '@/init/models-dev'
32
- import { makeOAuthLoginRunner } from '@/init/oauth-login'
32
+ import { makeOAuthLoginRunner, type OAuthLoginResult } from '@/init/oauth-login'
33
33
 
34
+ import { buildOAuthCallbacks } from './oauth-callbacks'
34
35
  import { c, done, errorLine } from './ui'
35
36
 
36
37
  // ESC and Ctrl+C both produce clack's cancel symbol (the keypress layer
@@ -54,9 +55,15 @@ import { c, done, errorLine } from './ui'
54
55
  // a clean exit. Inside an active clack prompt Ctrl+C is still aliased to
55
56
  // cancel, so the abort hotkey is "cancel twice in a row".
56
57
  export class WizardAbortedError extends Error {
57
- constructor() {
58
+ // When the wizard ran a successful eager OAuth login before aborting, the
59
+ // resulting credentials are already on disk at `<cwd>/secrets.json`. The
60
+ // CLI surfaces this on abort so the user knows to either re-run init in
61
+ // the same directory (the credentials will be reused) or delete the file.
62
+ readonly oauthCredentialsSaved: boolean
63
+ constructor(options: { oauthCredentialsSaved?: boolean } = {}) {
58
64
  super('Wizard aborted by user')
59
65
  this.name = 'WizardAbortedError'
66
+ this.oauthCredentialsSaved = options.oauthCredentialsSaved === true
60
67
  }
61
68
  }
62
69
 
@@ -102,11 +109,37 @@ export const init = defineCommand({
102
109
  intro('Initializing TypeClaw...')
103
110
  log.info('Press ESC at any prompt to go back. Press ESC twice in a row to abort.')
104
111
 
112
+ // Docker preflight runs BEFORE the wizard so an OAuth login (which the
113
+ // wizard fires the moment the user picks "OAuth (browser login)") doesn't
114
+ // burn a real browser flow on an agent folder we can't actually start.
115
+ // `runInit` re-runs the preflight as a defense-in-depth gate, but
116
+ // surfacing the failure here lets the user fix Docker without re-doing
117
+ // every wizard step.
118
+ const preflightSpinner = spinner()
119
+ preflightSpinner.start('Checking Docker...')
120
+ const preflight = await checkDockerAvailable()
121
+ if (!preflight.ok) {
122
+ preflightSpinner.error(preflightFailureSummary(preflight))
123
+ note(preflightFailureGuidance(preflight).join('\n'), 'Docker check failed')
124
+ process.exit(1)
125
+ }
126
+ preflightSpinner.stop('Docker is reachable.')
127
+
105
128
  let collected: CollectedInputs
106
129
  try {
107
130
  collected = await collectWizardInputs(cwd, defaultWizardPrompts)
108
131
  } catch (error) {
109
132
  if (error instanceof WizardAbortedError) {
133
+ if (error.oauthCredentialsSaved) {
134
+ note(
135
+ [
136
+ 'OAuth credentials were saved to `secrets.json` before you aborted.',
137
+ 'Re-run `typeclaw init` here to pick up where you left off (the credentials',
138
+ 'will be reused), or delete `secrets.json` if you want a clean restart.',
139
+ ].join('\n'),
140
+ 'Saved OAuth credentials',
141
+ )
142
+ }
110
143
  cancel('Aborted.')
111
144
  process.exit(0)
112
145
  }
@@ -309,9 +342,24 @@ export interface WizardPrompts {
309
342
  hasExistingChannelSecrets: (cwd: string, channel: Exclude<ChannelChoice, 'none'>) => Promise<boolean>
310
343
  askReuseExistingChannel: (channel: Exclude<ChannelChoice, 'none'>) => Promise<StepResult<'reuse' | 'prompt'>>
311
344
  runChannelFlow: (choice: ChannelChoice) => Promise<StepResult<CollectedInputs['channelSecrets']>>
312
- buildOAuthAuth: (provider: (typeof KNOWN_PROVIDERS)[KnownProviderId]) => LLMAuth
345
+ runOAuthLogin: (
346
+ provider: (typeof KNOWN_PROVIDERS)[KnownProviderId],
347
+ cwd: string,
348
+ model: KnownModelRef,
349
+ ) => Promise<OAuthLoginResult>
350
+ // Asked after a failed OAuth login. `apiKeyAvailable` is true when the
351
+ // provider also supports api-key auth (so the wizard can offer a fallback
352
+ // path); false for OAuth-only providers like openai-codex, where the only
353
+ // options are retry or abort.
354
+ askOAuthFailureRecovery: (
355
+ provider: (typeof KNOWN_PROVIDERS)[KnownProviderId],
356
+ reason: string,
357
+ apiKeyAvailable: boolean,
358
+ ) => Promise<OAuthFailureRecovery>
313
359
  }
314
360
 
361
+ export type OAuthFailureRecovery = 'retry' | 'api-key' | 'abort'
362
+
315
363
  export const defaultWizardPrompts: WizardPrompts = {
316
364
  loadCatalog,
317
365
  readExistingApiKey: readExistingProviderApiKey,
@@ -326,10 +374,8 @@ export const defaultWizardPrompts: WizardPrompts = {
326
374
  hasExistingChannelSecrets,
327
375
  askReuseExistingChannel,
328
376
  runChannelFlow,
329
- buildOAuthAuth: (provider) => ({
330
- kind: 'oauth',
331
- runLogin: makeOAuthLoginRunner(buildOAuthCallbacks(provider.name)),
332
- }),
377
+ runOAuthLogin: (provider, cwd, model) => makeOAuthLoginRunner(buildOAuthCallbacks(provider.name))({ cwd, model }),
378
+ askOAuthFailureRecovery,
333
379
  }
334
380
 
335
381
  export async function collectWizardInputs(cwd: string, prompts: WizardPrompts): Promise<CollectedInputs> {
@@ -337,10 +383,15 @@ export async function collectWizardInputs(cwd: string, prompts: WizardPrompts):
337
383
  const state: WizardState = { catalog }
338
384
  let step: StepId = 'pick-provider'
339
385
  let pendingBackOrigin: StepId | null = null
386
+ let oauthCredentialsSaved = false
387
+
388
+ const abort = (): never => {
389
+ throw new WizardAbortedError({ oauthCredentialsSaved })
390
+ }
340
391
 
341
392
  const onResult = <T>(currentStep: StepId, result: StepResult<T>): StepResult<T> => {
342
393
  if (result.kind === 'back') {
343
- if (pendingBackOrigin === currentStep) throw new WizardAbortedError()
394
+ if (pendingBackOrigin === currentStep) abort()
344
395
  pendingBackOrigin = currentStep
345
396
  } else if (!result.auto) {
346
397
  pendingBackOrigin = null
@@ -410,7 +461,32 @@ export async function collectWizardInputs(cwd: string, prompts: WizardPrompts):
410
461
  }
411
462
  state.authMethod = result.value
412
463
  if (result.value === 'oauth') {
413
- state.llmAuth = prompts.buildOAuthAuth(provider)
464
+ // Run the browser login eagerly so the user sees the OAuth URL the
465
+ // moment they pick "OAuth (browser login)" — not at the end of the
466
+ // wizard. On failure we ask the user how to recover (retry / fall
467
+ // back to API key / abort) instead of dumping them back into the
468
+ // auth method picker with no guidance.
469
+ const login = await runOAuthLoginSafely(prompts, provider, cwd, state.model!.ref)
470
+ if (!login.ok) {
471
+ const recovery = await prompts.askOAuthFailureRecovery(
472
+ provider,
473
+ login.reason,
474
+ providerSupportsApiKey(provider),
475
+ )
476
+ // The recovery prompt is a fresh user decision, so it must clear
477
+ // any back-token left over from an earlier step. Without this, a
478
+ // sequence like `enter-api-key → back → autoValue('oauth') →
479
+ // OAuth fails → recovery=api-key → enter-api-key` would treat the
480
+ // user's NEXT back press as a double-back and abort the wizard.
481
+ pendingBackOrigin = null
482
+ if (recovery === 'abort') abort()
483
+ state.authMethod = recovery === 'api-key' ? 'api-key' : undefined
484
+ state.llmAuth = undefined
485
+ step = recovery === 'api-key' ? 'enter-api-key' : 'pick-auth-method'
486
+ break
487
+ }
488
+ oauthCredentialsSaved = true
489
+ state.llmAuth = { kind: 'oauth-completed' }
414
490
  step = stepAfterDefaultAuth(state)
415
491
  } else {
416
492
  step = 'enter-api-key'
@@ -498,7 +574,25 @@ export async function collectWizardInputs(cwd: string, prompts: WizardPrompts):
498
574
  }
499
575
  state.visionAuthMethod = result.value
500
576
  if (result.value === 'oauth') {
501
- state.visionLlmAuth = prompts.buildOAuthAuth(provider)
577
+ // Same eager-login + recovery-prompt rationale as the default-provider branch above.
578
+ const login = await runOAuthLoginSafely(prompts, provider, cwd, state.visionModel!.ref)
579
+ if (!login.ok) {
580
+ const recovery = await prompts.askOAuthFailureRecovery(
581
+ provider,
582
+ login.reason,
583
+ providerSupportsApiKey(provider),
584
+ )
585
+ // See the matching pendingBackOrigin reset in the default-provider
586
+ // branch above — same reasoning applies to vision auth recovery.
587
+ pendingBackOrigin = null
588
+ if (recovery === 'abort') abort()
589
+ state.visionAuthMethod = recovery === 'api-key' ? 'api-key' : undefined
590
+ state.visionLlmAuth = undefined
591
+ step = recovery === 'api-key' ? 'enter-vision-api-key' : 'pick-vision-auth-method'
592
+ break
593
+ }
594
+ oauthCredentialsSaved = true
595
+ state.visionLlmAuth = { kind: 'oauth-completed' }
502
596
  step = 'pick-channel'
503
597
  } else {
504
598
  step = 'enter-vision-api-key'
@@ -570,6 +664,25 @@ export async function collectWizardInputs(cwd: string, prompts: WizardPrompts):
570
664
  }
571
665
  }
572
666
 
667
+ // Belt-and-suspenders wrapper: `makeOAuthLoginRunner` already catches the
668
+ // upstream pi-ai login flow and returns `{ ok: false, reason }`, but the
669
+ // wizard cannot afford ANY uncaught throw from a custom runner (test seam,
670
+ // future plugin-contributed runner) — it would bubble out of
671
+ // `collectWizardInputs` and exit the whole init. Coerce unexpected throws to
672
+ // the normal failure path so the recovery prompt always fires.
673
+ async function runOAuthLoginSafely(
674
+ prompts: WizardPrompts,
675
+ provider: (typeof KNOWN_PROVIDERS)[KnownProviderId],
676
+ cwd: string,
677
+ model: KnownModelRef,
678
+ ): Promise<OAuthLoginResult> {
679
+ try {
680
+ return await prompts.runOAuthLogin(provider, cwd, model)
681
+ } catch (error) {
682
+ return { ok: false, reason: error instanceof Error ? error.message : String(error) }
683
+ }
684
+ }
685
+
573
686
  function finalize(state: WizardState, channelSecrets: CollectedInputs['channelSecrets']): CollectedInputs {
574
687
  return {
575
688
  model: state.model!,
@@ -768,6 +881,28 @@ async function askApiKey(provider: (typeof KNOWN_PROVIDERS)[KnownProviderId]): P
768
881
  return value(apiKey)
769
882
  }
770
883
 
884
+ async function askOAuthFailureRecovery(
885
+ provider: (typeof KNOWN_PROVIDERS)[KnownProviderId],
886
+ reason: string,
887
+ apiKeyAvailable: boolean,
888
+ ): Promise<OAuthFailureRecovery> {
889
+ note(reason, `${provider.name} OAuth login failed`)
890
+ const options: Array<{ value: OAuthFailureRecovery; label: string; hint?: string }> = [
891
+ { value: 'retry', label: 'Retry OAuth login' },
892
+ ]
893
+ if (apiKeyAvailable) {
894
+ options.push({ value: 'api-key', label: `Use a ${provider.name} API key instead` })
895
+ }
896
+ options.push({ value: 'abort', label: 'Abort init', hint: 'you can re-run `typeclaw init` later' })
897
+ const choice = await select<OAuthFailureRecovery>({
898
+ message: 'What next?',
899
+ options,
900
+ initialValue: 'retry',
901
+ })
902
+ if (isCancel(choice)) return 'abort'
903
+ return choice
904
+ }
905
+
771
906
  async function pickChannel(initial: ChannelChoice | undefined): Promise<StepResult<ChannelChoice>> {
772
907
  const choice = await select<ChannelChoice>({
773
908
  message: 'Pick a channel to wire (you can add more later by editing typeclaw.json + secrets.json)',
@@ -1261,37 +1396,6 @@ export async function decideExistingApiKeyReuse(
1261
1396
  return reuse === true ? 'reuse' : 'prompt'
1262
1397
  }
1263
1398
 
1264
- // Wraps the OAuth lifecycle into the same clack idiom the rest of the wizard
1265
- // uses: a spinner over the "waiting for login" period, with onAuth printing
1266
- // the URL the user needs to open and onPrompt falling back to a `text`
1267
- // prompt for the manual code path. The spinner is started by onAuth and
1268
- // stopped by the caller (runInit) — we don't try to manage it here because
1269
- // the spinner lifecycle has to span emit('start') -> emit('done').
1270
- function buildOAuthCallbacks(providerName: string) {
1271
- return {
1272
- onAuth: (url: string, instructions?: string) => {
1273
- // Don't put the URL inside note(): clack wraps long lines with the box
1274
- // border `│` on each wrapped segment, which corrupts the URL when the
1275
- // user copy-pastes it. Keep instructional text in the box, but print
1276
- // the URL itself as a bare console.log line that any terminal will
1277
- // hyperlink intact.
1278
- const preamble = [`Open this URL in your browser to authorize ${providerName}.`]
1279
- if (instructions) preamble.push('', instructions)
1280
- note(preamble.join('\n'), 'Browser login')
1281
- console.log(url)
1282
- console.log('')
1283
- },
1284
- onProgress: (message: string) => {
1285
- log.info(message)
1286
- },
1287
- onPrompt: async (message: string, placeholder?: string): Promise<string | null> => {
1288
- const value = await text({ message, ...(placeholder !== undefined ? { placeholder } : {}) })
1289
- if (isCancel(value)) return null
1290
- return value
1291
- },
1292
- }
1293
- }
1294
-
1295
1399
  function uniqueProviders(options: ModelOption[]): KnownProviderId[] {
1296
1400
  const seen = new Set<KnownProviderId>()
1297
1401
  const out: KnownProviderId[] = []
package/src/cli/model.ts CHANGED
@@ -157,15 +157,23 @@ const listSub = defineCommand({
157
157
  }
158
158
 
159
159
  const profileWidth = Math.max(7, ...entries.map((e) => e.profile.length))
160
- const refWidth = Math.max(3, ...entries.map((e) => e.ref.length))
160
+ const refDisplay = (e: (typeof entries)[number]): string =>
161
+ e.refs.length > 1 ? `${e.ref} ${c.dim(`(+${e.refs.length - 1} fallback)`)}` : e.ref
162
+ const refWidth = Math.max(3, ...entries.map((e) => e.ref.length + (e.refs.length > 1 ? 14 : 0)))
161
163
 
162
164
  const header = `${'PROFILE'.padEnd(profileWidth)} ${'REF'.padEnd(refWidth)} PROVIDER STATUS`
163
165
  console.log(c.dim(header))
164
166
  for (const e of entries) {
165
167
  const star = e.isDefault ? c.cyan('*') : ' '
166
168
  const status = e.credentialStatus === 'available' ? c.green('ok') : c.yellow('missing-credentials')
167
- const line = `${star}${e.profile.padEnd(profileWidth - 1)} ${e.ref.padEnd(refWidth)} ${e.providerId.padEnd(12)} ${status}`
169
+ const line = `${star}${e.profile.padEnd(profileWidth - 1)} ${refDisplay(e).padEnd(refWidth)} ${e.providerId.padEnd(12)} ${status}`
168
170
  console.log(line)
171
+ if (e.refs.length > 1) {
172
+ for (let i = 1; i < e.refs.length; i++) {
173
+ const fb = e.refs[i]!
174
+ console.log(`${' '.padEnd(profileWidth + 2)}↳ ${c.dim(fb)}`)
175
+ }
176
+ }
169
177
  }
170
178
  },
171
179
  })
@@ -0,0 +1,49 @@
1
+ import { isCancel, log, note, text } from '@clack/prompts'
2
+
3
+ import type { OAuthCallbacks } from '@/init/oauth-login'
4
+
5
+ // Shared between `typeclaw init` (src/cli/init.ts) and `typeclaw provider
6
+ // add/set` (src/cli/provider.ts). Both call into the same OAuth runner, so
7
+ // they need to render the same UX: a note() box with the URL + cross-device
8
+ // guidance, a `text()` prompt for the post-callback manual fallback, and a
9
+ // concurrent `onManualCodeInput` prompt for users whose browser is on a
10
+ // different host than the CLI. See src/init/oauth-login.ts for the contract
11
+ // on each callback and why onManualCodeInput is required for cross-device.
12
+ export function buildOAuthCallbacks(providerName: string): OAuthCallbacks {
13
+ return {
14
+ onAuth: (url, instructions) => {
15
+ // Don't put the URL inside note(): clack wraps long lines with the box
16
+ // border `│` on each wrapped segment, which corrupts the URL when the
17
+ // user copy-pastes it. Keep instructional text in the box, but print
18
+ // the URL itself as a bare console.log line that any terminal will
19
+ // hyperlink intact.
20
+ const preamble = [
21
+ `Open this URL in your browser to sign in to ${providerName}.`,
22
+ '',
23
+ 'If your browser shows "this site can\'t be reached" after you sign in,',
24
+ 'copy the full address from the top of the browser and paste it below.',
25
+ ]
26
+ if (instructions) preamble.push('', instructions)
27
+ note(preamble.join('\n'), 'Browser login')
28
+ console.log(url)
29
+ console.log('')
30
+ },
31
+ onProgress: (message) => {
32
+ log.info(message)
33
+ },
34
+ onPrompt: async (message, placeholder) => {
35
+ const value = await text({ message, ...(placeholder !== undefined ? { placeholder } : {}) })
36
+ if (isCancel(value)) return null
37
+ return value
38
+ },
39
+ onManualCodeInput: async () => {
40
+ const value = await text({
41
+ message:
42
+ 'If your browser shows "this site can\'t be reached" after you sign in, copy the full address from the top of the browser and paste it here:',
43
+ placeholder: 'http://localhost:1455/auth/callback?code=...&state=...',
44
+ })
45
+ if (isCancel(value)) throw new Error('Login cancelled by user')
46
+ return value
47
+ },
48
+ }
49
+ }
@@ -1,4 +1,4 @@
1
- import { cancel, intro, isCancel, log, note, password, select, text } from '@clack/prompts'
1
+ import { cancel, intro, isCancel, log, password, select } from '@clack/prompts'
2
2
  import { defineCommand } from 'citty'
3
3
 
4
4
  import {
@@ -17,6 +17,7 @@ import {
17
17
  import { findAgentDir, isInitialized } from '@/init'
18
18
  import { makeOAuthLoginRunner } from '@/init/oauth-login'
19
19
 
20
+ import { buildOAuthCallbacks } from './oauth-callbacks'
20
21
  import { c, done, errorLine } from './ui'
21
22
 
22
23
  const addSub = defineCommand({
@@ -366,25 +367,7 @@ async function runOAuthLogin(cwd: string, providerId: KnownProviderId): Promise<
366
367
  }
367
368
  const modelRef = `${providerId}/${ref}` as const
368
369
 
369
- const callbacks = {
370
- onAuth: (url: string, instructions?: string) => {
371
- const preamble = [`Open this URL in your browser to authorize ${provider.name}.`]
372
- if (instructions) preamble.push('', instructions)
373
- note(preamble.join('\n'), 'Browser login')
374
- console.log(url)
375
- console.log('')
376
- },
377
- onProgress: (message: string) => {
378
- log.info(message)
379
- },
380
- onPrompt: async (message: string, placeholder?: string): Promise<string | null> => {
381
- const value = await text({ message, ...(placeholder !== undefined ? { placeholder } : {}) })
382
- if (isCancel(value)) return null
383
- return value
384
- },
385
- }
386
-
387
- const runner = makeOAuthLoginRunner(callbacks)
370
+ const runner = makeOAuthLoginRunner(buildOAuthCallbacks(provider.name))
388
371
  const result = await runner({ cwd, model: modelRef as Parameters<typeof runner>[0]['model'] })
389
372
  if (!result.ok) return { ok: false, reason: result.reason }
390
373
  return { ok: true }
@@ -103,6 +103,19 @@ const dockerfileObjectSchema = z.object({
103
103
  // edit. Opt-out with `cloudflared: false` to skip the ~35MB binary on
104
104
  // agents that don't use tunnels.
105
105
  cloudflared: z.boolean().default(true),
106
+ // Install xvfb so the entrypoint shim can spawn an Xvfb virtual X
107
+ // server and export DISPLAY, giving headed Chrome (agent-browser
108
+ // --headed, Playwright headful) a real X11 display to connect to.
109
+ // Default `true` because modern bot detection (Akamai/Cloudflare Bot
110
+ // Manager) fingerprints `--headless` and `--headless=new` regardless
111
+ // of UA spoof, and headed-via-Xvfb is the cheapest path to a passing
112
+ // fingerprint from a container. Opt-out with `xvfb: false` to save
113
+ // ~5MB image + ~10MB RAM/idle on agents that never touch a browser.
114
+ // The shim self-heals — when Xvfb isn't on PATH it execs the agent
115
+ // directly, no other Dockerfile or shim change needed. Boolean-only
116
+ // because the package has no API-stable versioning that matters
117
+ // here; xvfb tracks the upstream X server release.
118
+ xvfb: z.boolean().default(true),
106
119
  append: z.array(dockerfileLineSchema).default([]),
107
120
  })
108
121
 
@@ -278,32 +291,50 @@ const tunnelsArraySchema = z
278
291
  }
279
292
  })
280
293
 
281
- // `models` is a map from profile name to a single curated model ref. The
294
+ // `models` maps a profile name to one or more curated model refs. The
282
295
  // `default` profile is mandatory; every other profile is optional and falls
283
296
  // back to `default` at resolution time (see `resolveProfile`).
284
297
  //
298
+ // Each value is either a single `KnownModelRef` or a non-empty array of refs
299
+ // forming a fallback chain: when a turn against the first ref fails (hard
300
+ // throw or a soft provider error), the runtime disposes the failed session
301
+ // and replays the same prompt against the next ref. Schema accepts both
302
+ // shapes for ergonomics; the parsed value is always normalised to a
303
+ // non-empty array so downstream consumers read a uniform `KnownModelRef[]`.
304
+ //
285
305
  // Profile names are open strings; the runtime recognizes a handful of
286
306
  // well-known names by convention (`default`, `fast`, `deep`, `vision`) but
287
- // any string is valid. Subagents may declare a static profile preference;
288
- // callers may override per-spawn. Unknown profile names resolve to `default`
289
- // with a one-time warning at session construction.
307
+ // any string is valid. Unknown profile names resolve to `default` with a
308
+ // one-time warning at session construction.
290
309
  //
291
310
  // The pre-multi-model schema had a single `model: KnownModelRef` at the top
292
311
  // level. `migrateLegacyConfigShape` rewrites that to `models: { default: ... }`
293
312
  // on first load (and writes the result back to disk + commits via
294
313
  // `persistMigratedConfig`), so every downstream consumer sees the new shape.
314
+ const modelRefOrChainSchema = z
315
+ .union([
316
+ z.enum(knownModelRefs),
317
+ z
318
+ .array(z.enum(knownModelRefs))
319
+ .min(1)
320
+ // Reject exact duplicates in a chain — retrying the same ref after the
321
+ // same class of failure is almost certainly a config typo, and silently
322
+ // deduping would mask user intent. Different models from the same
323
+ // provider (e.g. `["openai/gpt-5.4-nano", "openai/gpt-5.4-mini"]`) are
324
+ // still valid because they hit distinct upstream endpoints.
325
+ .refine((arr) => new Set(arr).size === arr.length, {
326
+ message: 'models chain must not contain duplicate refs',
327
+ }),
328
+ ])
329
+ .transform((value) => (Array.isArray(value) ? value : [value]))
295
330
  export const modelsSchema = z
296
- .record(z.string().min(1), z.enum(knownModelRefs))
331
+ .record(z.string().min(1), modelRefOrChainSchema)
297
332
  .refine((m) => 'default' in m, { message: 'models.default is required' })
298
333
 
299
- // Zod's `z.record(..., refine)` doesn't refine the inferred type — the inferred
300
- // shape is `Record<string, KnownModelRef>` where every access is `T | undefined`.
301
- // The runtime guarantee (the `refine` above) is that `default` is present, so
302
- // we narrow the type here. Every consumer (auth.ts, agent/index.ts,
303
- // resolveProfile) reads `models.default` on the hot path; without this
304
- // narrowing they all have to assert or `?? throw`, which is noise around an
305
- // invariant the schema already enforces.
306
- export type Models = Record<string, KnownModelRef> & { default: KnownModelRef }
334
+ // Zod's `z.record(..., refine)` doesn't refine the inferred type. The
335
+ // `default` key is schema-enforced, so we narrow it here to spare every
336
+ // consumer the `T | undefined` assertion noise.
337
+ export type Models = Record<string, KnownModelRef[]> & { default: KnownModelRef[] }
307
338
 
308
339
  export const configSchema = z
309
340
  .object({
@@ -311,8 +342,10 @@ export const configSchema = z
311
342
  port: z.number().int().min(1).max(65535).default(DEFAULT_PORT),
312
343
  // `default(() => ...)` ensures every parsed config has at least
313
344
  // `models.default`. Direct `.default({ default: ... })` would short-circuit
314
- // the refinement, so we lean on the lazy thunk form.
315
- models: modelsSchema.default(() => ({ default: DEFAULT_MODEL_REF })) as unknown as z.ZodType<Models>,
345
+ // the refinement, so we lean on the lazy thunk form. The default value is
346
+ // shaped to match the post-transform output (always `KnownModelRef[]`),
347
+ // not the user-facing input shape.
348
+ models: modelsSchema.default(() => ({ default: [DEFAULT_MODEL_REF] })) as unknown as z.ZodType<Models>,
316
349
  // Defaults to `[]` so the field can be omitted from `typeclaw.json` (no
317
350
  // host paths exposed) without failing the whole config load. `typeclaw
318
351
  // init` omits this field so users don't see noise for the empty case.
@@ -345,26 +378,28 @@ export function resolveModel(ref: KnownModelRef): Model<'openai-completions'> |
345
378
  return KNOWN_PROVIDERS[providerId].models[modelId as never]
346
379
  }
347
380
 
348
- // Resolves a profile name (e.g. `fast`, `deep`, `vision`) to a concrete model
349
- // ref. Unknown profiles fall back to `default` so callers can pass through
381
+ // Resolves a profile name (e.g. `fast`, `deep`, `vision`) to its fallback
382
+ // chain. Unknown profiles fall back to `default` so callers can pass through
350
383
  // arbitrary subagent-declared or user-overridden strings without crashing.
351
- // Returns the resolved ref plus whether it came from the requested profile or
352
- // from the `default` fallback, so the caller can warn once per session
353
- // instead of every prompt.
384
+ // `refs` is non-empty (the schema guarantees `default` exists and every value
385
+ // is at least one ref). `ref` is the head of the chain the model the
386
+ // session is created with first. Callers that don't implement fallback can
387
+ // keep reading `ref`; fallback-aware callers iterate `refs`.
354
388
  export type ResolvedProfile = {
355
389
  ref: KnownModelRef
390
+ refs: KnownModelRef[]
356
391
  profile: string
357
392
  fellBackToDefault: boolean
358
393
  }
359
394
 
360
395
  export function resolveProfile(models: Models, name: string | undefined): ResolvedProfile {
361
396
  const requested = name ?? 'default'
362
- const ref = models[requested]
363
- if (ref !== undefined) {
364
- return { ref, profile: requested, fellBackToDefault: false }
397
+ const refs = models[requested]
398
+ if (refs !== undefined) {
399
+ return { ref: refs[0]!, refs, profile: requested, fellBackToDefault: false }
365
400
  }
366
401
  const fallback = models.default
367
- return { ref: fallback, profile: 'default', fellBackToDefault: true }
402
+ return { ref: fallback[0]!, refs: fallback, profile: 'default', fellBackToDefault: true }
368
403
  }
369
404
 
370
405
  // Resolves a mount's `path` field to an absolute host path, mirroring shell
@@ -17,8 +17,16 @@ const CONFIG_FILE = 'typeclaw.json'
17
17
 
18
18
  export type ModelProfileEntry = {
19
19
  profile: string
20
+ // Head of the fallback chain. Kept under the legacy `ref` name so callers
21
+ // that only care about the active model (the common case) don't need to
22
+ // dereference `refs[0]`. The chain itself is exposed as `refs`.
20
23
  ref: KnownModelRef
24
+ refs: KnownModelRef[]
21
25
  providerId: KnownProviderId
26
+ // Credential status for every provider referenced by the chain. The chain's
27
+ // overall status is `available` only when every entry resolves; otherwise
28
+ // it is `missing-credentials`, and `missingProviders` names which.
29
+ missingProviders: KnownProviderId[]
22
30
  isDefault: boolean
23
31
  credentialStatus: 'available' | 'missing-credentials'
24
32
  }
@@ -28,14 +36,18 @@ export type ModelMutationResult = { ok: true } | { ok: false; reason: string }
28
36
  export function listModelProfiles(cwd: string, env: NodeJS.ProcessEnv = process.env): ModelProfileEntry[] {
29
37
  const models = loadConfigSync(cwd).models
30
38
  const out: ModelProfileEntry[] = []
31
- for (const [profile, ref] of Object.entries(models)) {
32
- const providerId = providerForModelRef(ref)
39
+ for (const [profile, refs] of Object.entries(models)) {
40
+ const headRef = refs[0]!
41
+ const providerId = providerForModelRef(headRef)
42
+ const missingProviders = uniqueProviders(refs).filter((p) => !hasUsableCredential(cwd, p, env))
33
43
  out.push({
34
44
  profile,
35
- ref,
45
+ ref: headRef,
46
+ refs,
36
47
  providerId,
48
+ missingProviders,
37
49
  isDefault: profile === 'default',
38
- credentialStatus: hasUsableCredential(cwd, providerId, env) ? 'available' : 'missing-credentials',
50
+ credentialStatus: missingProviders.length === 0 ? 'available' : 'missing-credentials',
39
51
  })
40
52
  }
41
53
  // `default` always first; remaining profiles alphabetical so output is stable.
@@ -47,6 +59,19 @@ export function listModelProfiles(cwd: string, env: NodeJS.ProcessEnv = process.
47
59
  return out
48
60
  }
49
61
 
62
+ function uniqueProviders(refs: ReadonlyArray<KnownModelRef>): KnownProviderId[] {
63
+ const seen = new Set<KnownProviderId>()
64
+ const out: KnownProviderId[] = []
65
+ for (const r of refs) {
66
+ const p = providerForModelRef(r)
67
+ if (!seen.has(p)) {
68
+ seen.add(p)
69
+ out.push(p)
70
+ }
71
+ }
72
+ return out
73
+ }
74
+
50
75
  export function listAvailableModelRefs(): KnownModelRef[] {
51
76
  return listKnownModelRefs()
52
77
  }
@@ -158,14 +183,18 @@ export function removeProfile(cwd: string, profile: string): ModelMutationResult
158
183
 
159
184
  function writeProfile(cwd: string, profile: string, ref: KnownModelRef, message: string): ModelMutationResult {
160
185
  const existing = readModelsRaw(cwd)
161
- const next = existing === null ? { default: ref } : { ...existing, [profile]: ref }
186
+ const next: Record<string, string | string[]> = existing === null ? { default: ref } : { ...existing, [profile]: ref }
162
187
  if (existing === null && profile !== 'default') {
163
188
  next.default = ref
164
189
  }
165
190
  return writeModels(cwd, next, message)
166
191
  }
167
192
 
168
- function writeModels(cwd: string, models: Record<string, string>, commitMessage: string): ModelMutationResult {
193
+ function writeModels(
194
+ cwd: string,
195
+ models: Record<string, string | string[]>,
196
+ commitMessage: string,
197
+ ): ModelMutationResult {
169
198
  const path = join(cwd, CONFIG_FILE)
170
199
  let parsed: Record<string, unknown>
171
200
  try {
@@ -207,10 +236,15 @@ function writeModels(cwd: string, models: Record<string, string>, commitMessage:
207
236
  return { ok: true }
208
237
  }
209
238
 
210
- function readModelsRaw(cwd: string): Record<string, string> | null {
239
+ // Returns the raw `models` block from disk in its on-disk shape: each value
240
+ // is `string | string[]` (the user-facing schema). Writers preserve whichever
241
+ // shape was already present for profiles they don't touch — converting a
242
+ // hand-authored fallback chain back to a single string would silently drop
243
+ // the fallback.
244
+ function readModelsRaw(cwd: string): Record<string, string | string[]> | null {
211
245
  try {
212
246
  const raw = readFileSync(join(cwd, CONFIG_FILE), 'utf8')
213
- const parsed = JSON.parse(raw) as { models?: Record<string, string> }
247
+ const parsed = JSON.parse(raw) as { models?: Record<string, string | string[]> }
214
248
  return parsed.models ?? null
215
249
  } catch (error) {
216
250
  if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null