typeclaw 0.36.8 → 0.37.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.
Files changed (112) hide show
  1. package/README.md +3 -3
  2. package/package.json +3 -2
  3. package/src/agent/index.ts +31 -11
  4. package/src/agent/live-sessions.ts +12 -0
  5. package/src/agent/model-fallback.ts +17 -15
  6. package/src/agent/model-overrides.ts +2 -2
  7. package/src/agent/session-meta.ts +10 -0
  8. package/src/agent/subagents.ts +30 -3
  9. package/src/agent/system-prompt.ts +9 -3
  10. package/src/agent/todo/continuation-policy.ts +6 -3
  11. package/src/agent/todo/continuation-wiring.ts +4 -2
  12. package/src/agent/todo/continuation.ts +3 -3
  13. package/src/agent/tools/todo/index.ts +27 -4
  14. package/src/bundled-plugins/agent-browser/index.ts +33 -108
  15. package/src/bundled-plugins/agent-browser/shim.ts +3 -94
  16. package/src/bundled-plugins/agent-browser/skills/agent-browser/SKILL.md +8 -33
  17. package/src/bundled-plugins/doc-render/skills/typeclaw-render-pdf/SKILL.md +2 -2
  18. package/src/bundled-plugins/guard/policies/memory-retrieval-cache-write.ts +7 -1
  19. package/src/bundled-plugins/memory/README.md +80 -23
  20. package/src/bundled-plugins/memory/append-tool.ts +74 -53
  21. package/src/bundled-plugins/memory/citation-superset.ts +4 -0
  22. package/src/bundled-plugins/memory/citations.ts +54 -0
  23. package/src/bundled-plugins/memory/dreaming-metrics.ts +30 -0
  24. package/src/bundled-plugins/memory/dreaming.ts +444 -21
  25. package/src/bundled-plugins/memory/index.ts +544 -400
  26. package/src/bundled-plugins/memory/load-memory.ts +87 -10
  27. package/src/bundled-plugins/memory/load-shards.ts +48 -22
  28. package/src/bundled-plugins/memory/memory-logger.ts +95 -106
  29. package/src/bundled-plugins/memory/memory-retrieval.ts +3 -3
  30. package/src/bundled-plugins/memory/parent-link.ts +33 -0
  31. package/src/bundled-plugins/memory/paths.ts +12 -0
  32. package/src/bundled-plugins/memory/references/frontmatter.ts +197 -0
  33. package/src/bundled-plugins/memory/references/load-references.ts +212 -0
  34. package/src/bundled-plugins/memory/references/store-reference-tool.ts +59 -0
  35. package/src/bundled-plugins/memory/search-tool.ts +282 -45
  36. package/src/bundled-plugins/memory/stream-events.ts +1 -0
  37. package/src/bundled-plugins/memory/stream-io.ts +28 -3
  38. package/src/bundled-plugins/memory/turn-dedup.ts +40 -0
  39. package/src/bundled-plugins/memory/vector/cache-write.ts +19 -0
  40. package/src/bundled-plugins/memory/vector/config.ts +28 -0
  41. package/src/bundled-plugins/memory/vector/doctor.ts +124 -0
  42. package/src/bundled-plugins/memory/vector/embedder.ts +246 -0
  43. package/src/bundled-plugins/memory/vector/hybrid.ts +439 -0
  44. package/src/bundled-plugins/memory/vector/index-on-write.ts +34 -0
  45. package/src/bundled-plugins/memory/vector/inspect.ts +111 -0
  46. package/src/bundled-plugins/memory/vector/passages.ts +125 -0
  47. package/src/bundled-plugins/memory/vector/reference-index-on-write.ts +50 -0
  48. package/src/bundled-plugins/memory/vector/relevance-gate.ts +93 -0
  49. package/src/bundled-plugins/memory/vector/startup.ts +71 -0
  50. package/src/bundled-plugins/memory/vector/store.ts +203 -0
  51. package/src/bundled-plugins/memory/vector/truncation.ts +124 -0
  52. package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +2 -0
  53. package/src/channels/router.ts +239 -40
  54. package/src/cli/incomplete-init.ts +57 -0
  55. package/src/cli/init.ts +166 -18
  56. package/src/cli/inspect.ts +11 -5
  57. package/src/cli/model.ts +115 -36
  58. package/src/cli/provider.ts +5 -3
  59. package/src/cli/restart.ts +24 -0
  60. package/src/cli/start.ts +24 -0
  61. package/src/cli/tunnel.ts +53 -8
  62. package/src/config/config.ts +110 -19
  63. package/src/config/index.ts +5 -1
  64. package/src/config/models-mutation.ts +29 -11
  65. package/src/config/providers-mutation.ts +2 -2
  66. package/src/config/providers.ts +146 -12
  67. package/src/container/shared.ts +9 -0
  68. package/src/container/start.ts +87 -4
  69. package/src/cron/consumer.ts +13 -7
  70. package/src/hostd/models.ts +64 -0
  71. package/src/hostd/paths.ts +6 -0
  72. package/src/hostd/portbroker-manager.ts +2 -2
  73. package/src/init/checkpoint.ts +201 -0
  74. package/src/init/dockerfile.ts +121 -34
  75. package/src/init/gitignore.ts +7 -7
  76. package/src/init/index.ts +41 -9
  77. package/src/init/models-dev.ts +96 -21
  78. package/src/init/oauth-login.ts +3 -3
  79. package/src/init/progress.ts +29 -0
  80. package/src/init/validate-api-key.ts +4 -0
  81. package/src/inspect/index.ts +13 -6
  82. package/src/inspect/item-list.ts +11 -2
  83. package/src/inspect/live-list.ts +65 -0
  84. package/src/inspect/open-item.ts +22 -1
  85. package/src/inspect/session-list.ts +29 -0
  86. package/src/models/embedding-model.ts +114 -0
  87. package/src/models/transformers-version.ts +55 -0
  88. package/src/plugin/types.ts +3 -0
  89. package/src/portbroker/container-server.ts +23 -0
  90. package/src/portbroker/forward-request-bus.ts +35 -0
  91. package/src/portbroker/forward-result-bus.ts +2 -3
  92. package/src/portbroker/hostd-client.ts +182 -36
  93. package/src/portbroker/index.ts +6 -1
  94. package/src/portbroker/protocol.ts +9 -2
  95. package/src/run/channel-session-factory.ts +11 -1
  96. package/src/run/index.ts +65 -8
  97. package/src/server/command-runner.ts +24 -1
  98. package/src/server/index.ts +42 -8
  99. package/src/shared/index.ts +2 -0
  100. package/src/shared/protocol.ts +31 -0
  101. package/src/skills/typeclaw-channels/SKILL.md +4 -4
  102. package/src/skills/typeclaw-config/SKILL.md +2 -2
  103. package/src/skills/typeclaw-memory/SKILL.md +3 -1
  104. package/src/skills/typeclaw-permissions/SKILL.md +3 -3
  105. package/src/skills/typeclaw-skills/SKILL.md +1 -1
  106. package/src/skills/typeclaw-tunnels/SKILL.md +22 -1
  107. package/src/tunnels/providers/cloudflare-quick.ts +65 -7
  108. package/src/tunnels/upstream-probe.ts +25 -0
  109. package/typeclaw.schema.json +156 -67
  110. package/src/bundled-plugins/agent-browser/dashboard-discovery.ts +0 -170
  111. package/src/bundled-plugins/agent-browser/dashboard-proxy.ts +0 -421
  112. package/src/portbroker/bind-with-forward.ts +0 -102
package/src/cli/init.ts CHANGED
@@ -1,11 +1,24 @@
1
1
  import { randomBytes } from 'node:crypto'
2
2
 
3
- import { cancel, confirm, intro, isCancel, log, note, password, select, spinner, text } from '@clack/prompts'
3
+ import {
4
+ autocomplete,
5
+ cancel,
6
+ confirm,
7
+ intro,
8
+ isCancel,
9
+ log,
10
+ note,
11
+ password,
12
+ select,
13
+ spinner,
14
+ text,
15
+ } from '@clack/prompts'
4
16
  import { defineCommand } from 'citty'
5
17
 
6
18
  import {
7
19
  KNOWN_PROVIDER_VENDORS,
8
20
  KNOWN_PROVIDERS,
21
+ isKnownModelRef,
9
22
  listKnownProviderVendorIds,
10
23
  providerIdsForVendor,
11
24
  supportsApiKey as providerSupportsApiKey,
@@ -35,9 +48,17 @@ import {
35
48
  type KakaotalkAuthResult,
36
49
  type LLMAuth,
37
50
  } from '@/init'
51
+ import {
52
+ checkpointFromSelections,
53
+ createLocalWizardCheckpointStore,
54
+ sanitizeCheckpointAgainstCatalog,
55
+ type WizardAnswerCheckpointV1,
56
+ type WizardCheckpointStore,
57
+ } from '@/init/checkpoint'
38
58
  import { runKakaotalkBootstrap } from '@/init/kakaotalk-auth'
39
- import { fetchModelOptions, type ModelOption } from '@/init/models-dev'
59
+ import { customModelMetaFromOption, fetchModelOptions, type ModelOption } from '@/init/models-dev'
40
60
  import { makeOAuthLoginRunner, type OAuthLoginResult } from '@/init/oauth-login'
61
+ import { detectInitProgress } from '@/init/progress'
41
62
  import {
42
63
  API_KEY_DASHBOARD_URL,
43
64
  MINIMAX_TOKEN_PLAN_DASHBOARD_URL,
@@ -159,9 +180,10 @@ export const init = defineCommand({
159
180
  }
160
181
  preflightSpinner.stop('Docker is reachable.')
161
182
 
183
+ const checkpointStore = createLocalWizardCheckpointStore()
162
184
  let collected: CollectedInputs
163
185
  try {
164
- collected = await collectWizardInputs(cwd, defaultWizardPrompts, { reset })
186
+ collected = await collectWizardInputs(cwd, defaultWizardPrompts, { reset, checkpointStore })
165
187
  } catch (error) {
166
188
  if (error instanceof WizardAbortedError) {
167
189
  if (error.oauthCredentialsSaved) {
@@ -189,6 +211,8 @@ export const init = defineCommand({
189
211
  kakaotalkPassword,
190
212
  github: githubCredentials,
191
213
  } = channelSecrets
214
+ const modelMeta = customModelMetaFromOption(model)
215
+ const visionModelMeta = vision !== undefined ? customModelMetaFromOption(vision.model) : undefined
192
216
 
193
217
  // TODO: add remaining wizard steps from TypeClaw.md once their runtime lands:
194
218
  // - git backup (url + PAT) — Phase 10
@@ -213,7 +237,14 @@ export const init = defineCommand({
213
237
  cwd,
214
238
  llmAuth,
215
239
  model: model.ref,
216
- ...(vision !== undefined ? { visionModel: vision.model.ref, visionAuth: vision.llmAuth } : {}),
240
+ ...(modelMeta !== undefined ? { modelMeta } : {}),
241
+ ...(vision !== undefined
242
+ ? {
243
+ visionModel: vision.model.ref,
244
+ ...(visionModelMeta !== undefined ? { visionModelMeta } : {}),
245
+ visionAuth: vision.llmAuth,
246
+ }
247
+ : {}),
217
248
  cliEntry: process.argv[1],
218
249
  ...(discordBotToken !== undefined ? { discordBotToken } : {}),
219
250
  ...(slackBotToken !== undefined ? { slackBotToken, slackAppToken } : {}),
@@ -271,6 +302,10 @@ export const init = defineCommand({
271
302
  }
272
303
 
273
304
  if (hatchingOk) {
305
+ // Clear the resume checkpoint only on full success (hatching ok). A
306
+ // failed install/dockerfile/git/hatching leaves it in place so the next
307
+ // `typeclaw init` — or `typeclaw start` (PR2) — can resume.
308
+ await checkpointStore.clear(cwd).catch(() => {})
274
309
  const claimableChannel =
275
310
  channelChoice !== 'none' && channelChoice !== 'github' ? channelDisplayName(channelChoice) : null
276
311
  const hints: Array<{ label: string; command: string }> = []
@@ -380,7 +415,7 @@ export interface WizardPrompts {
380
415
  pickModel: (
381
416
  options: ModelOption[],
382
417
  providerId: KnownProviderId,
383
- initial: KnownModelRef | undefined,
418
+ initial: string | undefined,
384
419
  ) => Promise<StepResult<ModelOption>>
385
420
  pickAuthMethod: (
386
421
  provider: (typeof KNOWN_PROVIDERS)[KnownProviderId],
@@ -400,7 +435,7 @@ export interface WizardPrompts {
400
435
  pickVisionModel: (
401
436
  options: ModelOption[],
402
437
  providerId: KnownProviderId,
403
- initial: KnownModelRef | undefined,
438
+ initial: string | undefined,
404
439
  ) => Promise<StepResult<ModelOption>>
405
440
  pickChannel: (initial: ChannelChoice | undefined) => Promise<StepResult<ChannelChoice>>
406
441
  hasExistingChannelSecrets: (cwd: string, channel: Exclude<ChannelChoice, 'none'>) => Promise<boolean>
@@ -408,17 +443,19 @@ export interface WizardPrompts {
408
443
  runOAuthLogin: (
409
444
  provider: (typeof KNOWN_PROVIDERS)[KnownProviderId],
410
445
  cwd: string,
411
- model: KnownModelRef,
446
+ model: string,
412
447
  ) => Promise<OAuthLoginResult>
413
448
  askOAuthFailureRecovery: (
414
449
  provider: (typeof KNOWN_PROVIDERS)[KnownProviderId],
415
450
  reason: string,
416
451
  apiKeyAvailable: boolean,
417
452
  ) => Promise<OAuthFailureRecovery>
453
+ confirmResumeCheckpoint: (checkpoint: WizardAnswerCheckpointV1) => Promise<'resume' | 'start-over'>
418
454
  }
419
455
 
420
456
  export type CollectWizardInputsOptions = {
421
457
  reset?: boolean
458
+ checkpointStore?: WizardCheckpointStore
422
459
  }
423
460
 
424
461
  export type OAuthFailureRecovery = 'retry' | 'api-key' | 'abort'
@@ -448,6 +485,7 @@ export const defaultWizardPrompts: WizardPrompts = {
448
485
  }
449
486
  },
450
487
  askOAuthFailureRecovery,
488
+ confirmResumeCheckpoint,
451
489
  }
452
490
 
453
491
  export async function collectWizardInputs(
@@ -456,12 +494,46 @@ export async function collectWizardInputs(
456
494
  options: CollectWizardInputsOptions = {},
457
495
  ): Promise<CollectedInputs> {
458
496
  const reset = options.reset === true
497
+ const checkpointStore = options.checkpointStore
459
498
  const catalog = await prompts.loadCatalog()
460
499
  const state: WizardState = { catalog }
461
500
  let step: StepId = 'pick-vendor'
462
501
  let pendingBackOrigin: StepId | null = null
463
502
  let oauthCredentialsSaved = false
464
503
 
504
+ // Resume saved wizard answers from a prior unfinished init. `--reset` skips
505
+ // this entirely (the user asked to re-answer everything). Route through the
506
+ // shared detectInitProgress() predicate so init/start/restart classify a
507
+ // checkpoint identically: only an `incomplete` init (checkpoint + not
508
+ // hatched) offers resume; a `complete-stale-checkpoint` (checkpoint that
509
+ // outlived a hatched agent) is cleared and ignored, matching the contract.
510
+ if (!reset && checkpointStore !== undefined) {
511
+ const progress = await detectInitProgress({ cwd, checkpointStore })
512
+ if (progress.kind === 'complete-stale-checkpoint') {
513
+ await checkpointStore.clear(cwd).catch(() => {})
514
+ } else if (progress.kind === 'incomplete') {
515
+ // Sanitize against the freshly-loaded catalog BEFORE showing the resume
516
+ // prompt. `load` only validates version/cwd/updatedAt, so a checkpoint
517
+ // with a since-removed provider/model id reaches here intact; describing
518
+ // or seeding it raw would crash on `KNOWN_PROVIDERS[providerId]`. Pruning
519
+ // first means the prompt, the seed, and any partial answers all see only
520
+ // catalog-valid fields.
521
+ const catalogRefs = new Set(catalog.options.map((option) => option.ref))
522
+ const sanitized = sanitizeCheckpointAgainstCatalog(progress.checkpoint, catalogRefs)
523
+ const decision = await prompts.confirmResumeCheckpoint(sanitized)
524
+ if (decision === 'resume') {
525
+ seedWizardState(state, sanitized)
526
+ } else {
527
+ await checkpointStore.clear(cwd)
528
+ }
529
+ }
530
+ }
531
+
532
+ const persistCheckpoint = async (): Promise<void> => {
533
+ if (reset || checkpointStore === undefined) return
534
+ await checkpointStore.save(cwd, projectCheckpoint(cwd, state)).catch(() => {})
535
+ }
536
+
465
537
  const abort = (): never => {
466
538
  throw new WizardAbortedError({ oauthCredentialsSaved })
467
539
  }
@@ -492,6 +564,11 @@ export async function collectWizardInputs(
492
564
  }
493
565
 
494
566
  while (true) {
567
+ // Persist the cumulative selections at the top of every iteration so a
568
+ // mid-wizard abort keeps everything answered so far. Running it here (not
569
+ // per-case) means back-navigation — which clears downstream fields before
570
+ // looping — overwrites the projection too, so stale answers never linger.
571
+ await persistCheckpoint()
495
572
  switch (step) {
496
573
  case 'pick-vendor': {
497
574
  const result = onResult(step, await prompts.pickVendor(catalog.options, state.vendorId))
@@ -849,7 +926,7 @@ async function runOAuthLoginSafely(
849
926
  prompts: WizardPrompts,
850
927
  provider: (typeof KNOWN_PROVIDERS)[KnownProviderId],
851
928
  cwd: string,
852
- model: KnownModelRef,
929
+ model: string,
853
930
  ): Promise<OAuthLoginResult> {
854
931
  try {
855
932
  return await prompts.runOAuthLogin(provider, cwd, model)
@@ -871,6 +948,72 @@ function finalize(state: WizardState, channelSecrets: CollectedInputs['channelSe
871
948
  }
872
949
  }
873
950
 
951
+ async function confirmResumeCheckpoint(checkpoint: WizardAnswerCheckpointV1): Promise<'resume' | 'start-over'> {
952
+ const summary = describeCheckpoint(checkpoint)
953
+ const choice = await select({
954
+ message: 'Found answers from a previous, unfinished init. Resume from them?',
955
+ options: [
956
+ { value: 'resume' as const, label: 'Resume', hint: summary },
957
+ { value: 'start-over' as const, label: 'Start over', hint: 'discard saved answers and pick everything again' },
958
+ ],
959
+ initialValue: 'resume' as const,
960
+ })
961
+ if (isCancel(choice)) return 'start-over'
962
+ return choice
963
+ }
964
+
965
+ function describeCheckpoint(checkpoint: WizardAnswerCheckpointV1): string {
966
+ const parts: string[] = []
967
+ // Defense-in-depth: callers sanitize before describing, but a raw provider id
968
+ // missing from KNOWN_PROVIDERS must degrade to the id string, never throw.
969
+ if (checkpoint.modelRef !== undefined) parts.push(checkpoint.modelRef)
970
+ else if (checkpoint.providerId !== undefined) {
971
+ parts.push(KNOWN_PROVIDERS[checkpoint.providerId]?.name ?? checkpoint.providerId)
972
+ }
973
+ if (checkpoint.visionModelRef !== undefined) parts.push(`vision: ${checkpoint.visionModelRef}`)
974
+ if (checkpoint.channelChoice !== undefined && checkpoint.channelChoice !== 'none') {
975
+ parts.push(`channel: ${checkpoint.channelChoice}`)
976
+ }
977
+ return parts.length > 0 ? parts.join(', ') : 'partial selections'
978
+ }
979
+
980
+ // Pre-populate wizard state from a sanitized checkpoint so each prompt's
981
+ // `initial` argument fast-forwards the user to their prior choice. Only seeds
982
+ // fields whose upstream selections are all present and catalog-valid; the
983
+ // sanitize pass already dropped stale refs, so a missing field here just means
984
+ // that step prompts fresh.
985
+ function seedWizardState(state: WizardState, checkpoint: WizardAnswerCheckpointV1): void {
986
+ state.vendorId = checkpoint.vendorId
987
+ state.providerId = checkpoint.providerId
988
+ state.model = resolveModelOption(state.catalog, checkpoint.modelRef)
989
+ state.authMethod = checkpoint.authMethod
990
+ state.visionVendorId = checkpoint.visionVendorId
991
+ state.visionProviderId = checkpoint.visionProviderId
992
+ state.visionModel = resolveModelOption(state.catalog, checkpoint.visionModelRef)
993
+ state.visionAuthMethod = checkpoint.visionAuthMethod
994
+ state.channelChoice = checkpoint.channelChoice
995
+ }
996
+
997
+ function resolveModelOption(catalog: WizardState['catalog'], ref: string | undefined): ModelOption | undefined {
998
+ if (catalog === undefined || ref === undefined) return undefined
999
+ return catalog.options.find((option) => option.ref === ref)
1000
+ }
1001
+
1002
+ function projectCheckpoint(cwd: string, state: WizardState): WizardAnswerCheckpointV1 {
1003
+ return checkpointFromSelections({
1004
+ cwd,
1005
+ ...(state.vendorId !== undefined ? { vendorId: state.vendorId } : {}),
1006
+ ...(state.providerId !== undefined ? { providerId: state.providerId } : {}),
1007
+ ...(state.model?.ref !== undefined ? { modelRef: state.model.ref } : {}),
1008
+ ...(state.authMethod !== undefined ? { authMethod: state.authMethod } : {}),
1009
+ ...(state.visionVendorId !== undefined ? { visionVendorId: state.visionVendorId } : {}),
1010
+ ...(state.visionProviderId !== undefined ? { visionProviderId: state.visionProviderId } : {}),
1011
+ ...(state.visionModel?.ref !== undefined ? { visionModelRef: state.visionModel.ref } : {}),
1012
+ ...(state.visionAuthMethod !== undefined ? { visionAuthMethod: state.visionAuthMethod } : {}),
1013
+ ...(state.channelChoice !== undefined ? { channelChoice: state.channelChoice } : {}),
1014
+ })
1015
+ }
1016
+
874
1017
  function channelDisplayName(choice: Exclude<ChannelChoice, 'none'>): string {
875
1018
  switch (choice) {
876
1019
  case 'slack':
@@ -952,8 +1095,9 @@ async function pickVendor(
952
1095
  initial: KnownProviderVendorId | undefined,
953
1096
  ): Promise<StepResult<KnownProviderVendorId>> {
954
1097
  const vendors = uniqueVendors(options)
955
- const choice = await select({
1098
+ const choice = await autocomplete({
956
1099
  message: 'Pick an LLM provider',
1100
+ placeholder: 'Type to search…',
957
1101
  options: vendors.map((id) => ({
958
1102
  value: id,
959
1103
  label: KNOWN_PROVIDER_VENDORS[id].name,
@@ -973,8 +1117,9 @@ async function pickProviderVariant(
973
1117
  const variants = providersForVendorInCatalog(vendorId, options)
974
1118
  if (variants.length === 0) throw new Error(`Internal error: vendor ${vendorId} has no providers in the catalog`)
975
1119
  if (variants.length === 1) return autoValue(variants[0]!)
976
- const choice = await select<KnownProviderId>({
1120
+ const choice = await autocomplete<KnownProviderId>({
977
1121
  message: `Pick a ${KNOWN_PROVIDER_VENDORS[vendorId].name} option`,
1122
+ placeholder: 'Type to search…',
978
1123
  options: variants.map((id) => {
979
1124
  const hint = variantHint(vendorId, id)
980
1125
  return hint !== undefined
@@ -990,15 +1135,16 @@ async function pickProviderVariant(
990
1135
  async function pickModelForProvider(
991
1136
  options: ModelOption[],
992
1137
  providerId: KnownProviderId,
993
- initial: KnownModelRef | undefined,
1138
+ initial: string | undefined,
994
1139
  ): Promise<StepResult<ModelOption>> {
995
1140
  const candidates = sortRecommendedFirst(options.filter((o) => o.providerId === providerId))
996
1141
  // select<string>, not select<KnownModelRef>: clack's Option<Value> is a
997
1142
  // distributive conditional type, so a large KnownModelRef union explodes into
998
1143
  // a per-literal option union that no longer accepts `value: ref`. The runtime
999
1144
  // value is the ref string and is re-narrowed via `candidates.find` below.
1000
- const choice = await select<string>({
1145
+ const choice = await autocomplete<string>({
1001
1146
  message: `Pick a ${KNOWN_PROVIDERS[providerId].name} model`,
1147
+ placeholder: 'Type to search…',
1002
1148
  options: candidates.map((o) => ({
1003
1149
  value: o.ref,
1004
1150
  label: formatModelLabel(o),
@@ -1042,8 +1188,9 @@ async function pickVisionVendor(
1042
1188
  log.warn('No vision-capable models available; skipping vision profile.')
1043
1189
  return autoValue('skip')
1044
1190
  }
1045
- const choice = await select<KnownProviderVendorId | 'skip'>({
1191
+ const choice = await autocomplete<KnownProviderVendorId | 'skip'>({
1046
1192
  message: 'Your model is text-only. Pick a provider for the `vision` profile (used for image input)',
1193
+ placeholder: 'Type to search…',
1047
1194
  options: [
1048
1195
  ...vendors.map((id) => ({
1049
1196
  value: id as KnownProviderVendorId | 'skip',
@@ -1069,12 +1216,13 @@ async function pickVisionProviderVariant(
1069
1216
  async function pickVisionModel(
1070
1217
  options: ModelOption[],
1071
1218
  providerId: KnownProviderId,
1072
- initial: KnownModelRef | undefined,
1219
+ initial: string | undefined,
1073
1220
  ): Promise<StepResult<ModelOption>> {
1074
1221
  const candidates = sortRecommendedFirst(options.filter((o) => o.providerId === providerId))
1075
1222
  // select<string> for the same distributive-Option reason as pickModelForProvider.
1076
- const choice = await select<string>({
1223
+ const choice = await autocomplete<string>({
1077
1224
  message: `Pick a vision-capable ${KNOWN_PROVIDERS[providerId].name} model`,
1225
+ placeholder: 'Type to search…',
1078
1226
  options: candidates.map((o) => ({
1079
1227
  value: o.ref,
1080
1228
  label: formatModelLabel(o),
@@ -1697,12 +1845,12 @@ const RECOMMENDED_MODEL_REFS: ReadonlySet<KnownModelRef> = new Set<KnownModelRef
1697
1845
  ])
1698
1846
 
1699
1847
  export function formatModelLabel(o: ModelOption): string {
1700
- return RECOMMENDED_MODEL_REFS.has(o.ref) ? `${o.modelName} (Recommended)` : o.modelName
1848
+ return isKnownModelRef(o.ref) && RECOMMENDED_MODEL_REFS.has(o.ref) ? `${o.modelName} (Recommended)` : o.modelName
1701
1849
  }
1702
1850
 
1703
1851
  export function sortRecommendedFirst(options: ModelOption[]): ModelOption[] {
1704
- const recommended = options.filter((o) => RECOMMENDED_MODEL_REFS.has(o.ref))
1705
- const rest = options.filter((o) => !RECOMMENDED_MODEL_REFS.has(o.ref))
1852
+ const recommended = options.filter((o) => isKnownModelRef(o.ref) && RECOMMENDED_MODEL_REFS.has(o.ref))
1853
+ const rest = options.filter((o) => !isKnownModelRef(o.ref) || !RECOMMENDED_MODEL_REFS.has(o.ref))
1706
1854
  return [...recommended, ...rest]
1707
1855
  }
1708
1856
 
@@ -3,6 +3,7 @@ import { defineCommand } from 'citty'
3
3
  import { requireContainerRunning, resolveHostPort, resolveTuiToken } from '@/container'
4
4
  import { findAgentDir } from '@/init'
5
5
  import {
6
+ fetchLiveSessions,
6
7
  listViewerItems,
7
8
  openViewerItem,
8
9
  parseDuration,
@@ -150,7 +151,8 @@ export async function runInspectViewer(opts: RunInspectViewerOptions): Promise<n
150
151
 
151
152
  const interactive = Boolean(process.stdin.isTTY)
152
153
  const liveHint = interactive ? escHintLine(color) : undefined
153
- const liveSource = containerRunning ? await buildLiveSource(cwd) : undefined
154
+ const inspectUrl = containerRunning ? await resolveInspectUrl(cwd) : undefined
155
+ const liveSource = inspectUrl !== undefined ? buildLiveSource(inspectUrl) : undefined
154
156
 
155
157
  const stdout = (line: string): void => {
156
158
  process.stdout.write(`${line}\n`)
@@ -186,6 +188,7 @@ export async function runInspectViewer(opts: RunInspectViewerOptions): Promise<n
186
188
  onWarn: stderr,
187
189
  }
188
190
  if (sinceMs !== undefined) listOpts.sinceMs = sinceMs
191
+ if (inspectUrl !== undefined) listOpts.liveSessions = await fetchLiveSessions({ url: inspectUrl })
189
192
  return (await listViewerItems(listOpts)).items
190
193
  },
191
194
  keyOf: (item) => (item.kind === 'logs' ? 'logs' : item.summary.sessionId),
@@ -223,12 +226,15 @@ async function resolveTuiUrl(cwd: string): Promise<string> {
223
226
  return url.toString()
224
227
  }
225
228
 
226
- async function buildLiveSource(cwd: string): Promise<LiveSourceFactory | undefined> {
229
+ async function resolveInspectUrl(cwd: string): Promise<string> {
227
230
  const port = await resolveHostPort({ cwd })
228
231
  const token = await resolveTuiToken({ cwd })
229
232
  const baseUrl = new URL(`ws://127.0.0.1:${port}/inspect`)
230
233
  if (token !== null) baseUrl.searchParams.set('token', token)
231
- const url = baseUrl.toString()
234
+ return baseUrl.toString()
235
+ }
236
+
237
+ function buildLiveSource(url: string): LiveSourceFactory {
232
238
  return ({ sessionId, sinceMs, signal, onSubscribed }) =>
233
239
  streamLive({
234
240
  url,
@@ -325,8 +331,8 @@ function itemHint(item: ViewerItem): { hint: string } {
325
331
  function sessionRowLabel(s: SessionSummary): string {
326
332
  const id = shortSessionId(s.sessionId)
327
333
  const label = s.origin === null ? '(unknown origin)' : originLabel(s.origin)
328
- const when = formatRelative(s.mtimeMs)
329
- return `${c.cyan(id)} ${label} ${c.dim(when)}`
334
+ const when = s.live === true ? c.green('live · replying') : c.dim(formatRelative(s.mtimeMs))
335
+ return `${c.cyan(id)} ${label} ${when}`
330
336
  }
331
337
 
332
338
  function formatRelative(ms: number): string {
package/src/cli/model.ts CHANGED
@@ -1,6 +1,7 @@
1
- import { cancel, intro, isCancel, log, select } from '@clack/prompts'
1
+ import { autocomplete, cancel, intro, isCancel, log, select } from '@clack/prompts'
2
2
  import { defineCommand } from 'citty'
3
3
 
4
+ import type { CustomModelMeta } from '@/config'
4
5
  import {
5
6
  addProfile,
6
7
  listModelProfiles,
@@ -9,19 +10,25 @@ import {
9
10
  setProfile,
10
11
  } from '@/config/models-mutation'
11
12
  import {
13
+ isKnownModelRef,
12
14
  KNOWN_PROVIDERS,
13
- listKnownModelRefs,
14
15
  providerForModelRef,
15
16
  type KnownModelRef,
16
17
  type KnownProviderId,
17
18
  } from '@/config/providers'
18
19
  import { findAgentDir, isInitialized } from '@/init'
20
+ import { customModelMetaFromOption, fetchModelOptions, type ModelOption } from '@/init/models-dev'
19
21
 
20
22
  import { runProviderAddFlow } from './provider'
21
23
  import { c, done, errorLine } from './ui'
22
24
 
23
25
  const ADD_PROVIDER_SENTINEL = '__add-provider__'
24
26
 
27
+ type PickedModelRef = {
28
+ ref: string
29
+ meta?: CustomModelMeta
30
+ }
31
+
25
32
  const setSub = defineCommand({
26
33
  meta: {
27
34
  name: 'set',
@@ -47,18 +54,21 @@ const setSub = defineCommand({
47
54
  async run({ args }) {
48
55
  const cwd = ensureAgentDir()
49
56
  const profile = args.profile ?? (await pickProfileName())
50
- const ref = args.ref ?? (await pickModelRef(cwd))
57
+ const picked = args.ref !== undefined ? await resolveExplicitRef(args.ref) : await pickModelRef(cwd)
51
58
 
52
- intro(`Setting model profile: ${profile} → ${ref}`)
59
+ intro(`Setting model profile: ${profile} → ${picked.ref}`)
53
60
 
54
- const result = setProfile(cwd, profile, ref, { force: args.force === true })
61
+ const result = setProfile(cwd, profile, picked.ref, {
62
+ force: args.force === true,
63
+ ...(picked.meta !== undefined ? { meta: picked.meta } : {}),
64
+ })
55
65
  if (!result.ok) {
56
66
  console.error(errorLine(result.reason))
57
67
  process.exit(1)
58
68
  }
59
69
  done({
60
70
  title: c.green(`Profile "${profile}" set.`),
61
- details: `${profile} → ${ref}`,
71
+ details: `${profile} → ${picked.ref}`,
62
72
  hints: [{ label: 'If the agent is running:', command: 'typeclaw reload' }],
63
73
  })
64
74
  },
@@ -88,18 +98,21 @@ const addSub = defineCommand({
88
98
  },
89
99
  async run({ args }) {
90
100
  const cwd = ensureAgentDir()
91
- const ref = args.ref ?? (await pickModelRef(cwd))
101
+ const picked = args.ref !== undefined ? await resolveExplicitRef(args.ref) : await pickModelRef(cwd)
92
102
 
93
- intro(`Adding model profile: ${args.profile} → ${ref}`)
103
+ intro(`Adding model profile: ${args.profile} → ${picked.ref}`)
94
104
 
95
- const result = addProfile(cwd, args.profile, ref, { force: args.force === true })
105
+ const result = addProfile(cwd, args.profile, picked.ref, {
106
+ force: args.force === true,
107
+ ...(picked.meta !== undefined ? { meta: picked.meta } : {}),
108
+ })
96
109
  if (!result.ok) {
97
110
  console.error(errorLine(result.reason))
98
111
  process.exit(1)
99
112
  }
100
113
  done({
101
114
  title: c.green(`Profile "${args.profile}" added.`),
102
- details: `${args.profile} → ${ref}`,
115
+ details: `${args.profile} → ${picked.ref}`,
103
116
  hints: [{ label: 'If the agent is running:', command: 'typeclaw reload' }],
104
117
  })
105
118
  },
@@ -146,7 +159,7 @@ const listSub = defineCommand({
146
159
  },
147
160
  async run({ args }) {
148
161
  if (args.available === true) {
149
- printAvailableRefs()
162
+ await printAvailableRefs()
150
163
  return
151
164
  }
152
165
  const cwd = ensureAgentDir()
@@ -218,7 +231,7 @@ async function pickProfileName(): Promise<string> {
218
231
  return choice
219
232
  }
220
233
 
221
- async function pickModelRef(cwd: string): Promise<string> {
234
+ async function pickModelRef(cwd: string): Promise<PickedModelRef> {
222
235
  while (true) {
223
236
  const refs = listRegisteredModelRefs(cwd)
224
237
  if (refs.length === 0) {
@@ -234,13 +247,15 @@ async function pickModelRef(cwd: string): Promise<string> {
234
247
  // distributive conditional type and a large ref union breaks `value: ref`
235
248
  // assignability. Values are ref strings (+ the sentinel) and stay correct
236
249
  // at runtime — the sentinel check and `return choice` below are unaffected.
237
- const choice = await select<string>({
250
+ const modelOptions = await listCredentialedModelOptions(refs)
251
+ const choice = await autocomplete<string>({
238
252
  message: 'Pick a model',
253
+ placeholder: 'Type to search…',
239
254
  options: [
240
- ...refs.map((ref) => ({
241
- value: ref,
242
- label: describeRef(ref),
243
- hint: ref,
255
+ ...modelOptions.map((option) => ({
256
+ value: option.ref,
257
+ label: describeRef(option.ref),
258
+ hint: option.ref,
244
259
  })),
245
260
  {
246
261
  value: ADD_PROVIDER_SENTINEL,
@@ -248,13 +263,18 @@ async function pickModelRef(cwd: string): Promise<string> {
248
263
  hint: 'configure a new provider',
249
264
  },
250
265
  ],
251
- initialValue: refs[0],
266
+ initialValue: modelOptions[0]?.ref ?? refs[0],
252
267
  })
253
268
  if (isCancel(choice)) {
254
269
  cancel('Aborted.')
255
270
  process.exit(0)
256
271
  }
257
- if (choice !== ADD_PROVIDER_SENTINEL) return choice
272
+ if (choice !== ADD_PROVIDER_SENTINEL) {
273
+ const option = modelOptions.find((candidate) => candidate.ref === choice)
274
+ if (option === undefined) return { ref: choice }
275
+ const meta = customModelMetaFromOption(option)
276
+ return { ref: option.ref, ...(meta !== undefined ? { meta } : {}) }
277
+ }
258
278
  const added = await runProviderAddFlow(cwd, {})
259
279
  if (!added.ok) {
260
280
  console.error(errorLine(added.reason))
@@ -263,29 +283,88 @@ async function pickModelRef(cwd: string): Promise<string> {
263
283
  }
264
284
  }
265
285
 
266
- function describeRef(ref: KnownModelRef): string {
267
- const providerId = providerForModelRef(ref)
268
- const modelId = ref.slice(providerId.length + 1)
269
- const provider = KNOWN_PROVIDERS[providerId]
270
- const model = (provider.models as Record<string, { name: string }>)[modelId]
271
- return model ? `${provider.name} · ${model.name}` : ref
286
+ // Non-interactive `<ref>` path. Curated refs resolve from KNOWN_PROVIDERS, so
287
+ // they need no metadata. Non-curated refs are looked up in the live catalog so
288
+ // `customModels[ref]` carries the same metadata the interactive picker would
289
+ // persist; without it `resolveModel` silently falls back to defaults. A
290
+ // catalog miss (offline / unknown id) still writes the ref, but warns first.
291
+ export async function resolveExplicitRef(
292
+ ref: string,
293
+ loadCatalog: () => Promise<{ options: ModelOption[] }> = fetchModelOptions,
294
+ ): Promise<PickedModelRef> {
295
+ if (isKnownModelRef(ref)) return { ref }
296
+ const { options } = await loadCatalog()
297
+ const option = options.find((candidate) => candidate.ref === ref)
298
+ if (option === undefined) {
299
+ log.warn(
300
+ `"${ref}" isn't in the live catalog; saving the ref without metadata. ` +
301
+ `The agent will use fallback defaults (reasoning off, text-only input, zero cost, provider-default context).`,
302
+ )
303
+ return { ref }
304
+ }
305
+ const meta = customModelMetaFromOption(option)
306
+ return { ref, ...(meta !== undefined ? { meta } : {}) }
307
+ }
308
+
309
+ export type { PickedModelRef }
310
+
311
+ async function listCredentialedModelOptions(refs: KnownModelRef[]): Promise<ModelOption[]> {
312
+ const credentialedProviders = new Set<KnownProviderId>(refs.map((ref) => providerForModelRef(ref)))
313
+ const catalog = await fetchModelOptions()
314
+ const options = catalog.options.filter((option) => credentialedProviders.has(option.providerId))
315
+ if (options.length > 0) return options
316
+ return refs.map((ref) => {
317
+ const providerId = providerForModelRef(ref)
318
+ const modelId = ref.slice(providerId.length + 1)
319
+ const model = (
320
+ KNOWN_PROVIDERS[providerId].models as Record<
321
+ string,
322
+ { name: string; reasoning?: boolean; contextWindow?: number; input?: ReadonlyArray<string> }
323
+ >
324
+ )[modelId]
325
+ return {
326
+ ref,
327
+ providerId,
328
+ providerName: KNOWN_PROVIDERS[providerId].name,
329
+ modelId,
330
+ modelName: model?.name ?? modelId,
331
+ reasoning: model?.reasoning ?? false,
332
+ contextWindow: model?.contextWindow ?? null,
333
+ curated: true,
334
+ supportsVision: model?.input?.includes('image') ?? false,
335
+ }
336
+ })
337
+ }
338
+
339
+ function describeRef(ref: string): string {
340
+ try {
341
+ const providerId = providerForModelRef(ref)
342
+ const modelId = ref.slice(providerId.length + 1)
343
+ const provider = KNOWN_PROVIDERS[providerId]
344
+ const model = (provider.models as Record<string, { name: string }>)[modelId]
345
+ return `${provider.name} · ${model?.name ?? modelId}`
346
+ } catch {
347
+ return ref
348
+ }
272
349
  }
273
350
 
274
- function printAvailableRefs(): void {
275
- const refs = listKnownModelRefs()
276
- if (refs.length === 0) {
351
+ async function printAvailableRefs(): Promise<void> {
352
+ const { options, source, warning } = await fetchModelOptions()
353
+ if (options.length === 0) {
277
354
  console.log(c.dim('No models registered.'))
278
355
  return
279
356
  }
280
357
  console.log(c.dim('Use `typeclaw model set <profile> <ref>` to apply.'))
281
- let lastProvider: KnownProviderId | null = null
282
- for (const ref of refs) {
283
- const providerId = providerForModelRef(ref)
284
- if (providerId !== lastProvider) {
285
- console.log('')
286
- console.log(c.cyan(KNOWN_PROVIDERS[providerId].name))
287
- lastProvider = providerId
358
+ if (source === 'curated' && warning !== undefined) {
359
+ console.log(c.dim(`Using built-in catalog (models.dev unavailable: ${warning}).`))
360
+ }
361
+ for (const providerId of Object.keys(KNOWN_PROVIDERS) as KnownProviderId[]) {
362
+ const providerOptions = options.filter((option) => option.providerId === providerId)
363
+ if (providerOptions.length === 0) continue
364
+ console.log('')
365
+ console.log(c.cyan(KNOWN_PROVIDERS[providerId].name))
366
+ for (const option of providerOptions) {
367
+ console.log(` ${option.ref}`)
288
368
  }
289
- console.log(` ${ref}`)
290
369
  }
291
370
  }