typeclaw 0.36.7 → 0.37.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 (112) hide show
  1. package/README.md +2 -2
  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 +11 -2
  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 +143 -12
  56. package/src/cli/inspect.ts +11 -5
  57. package/src/cli/model.ts +112 -34
  58. package/src/cli/restart.ts +24 -0
  59. package/src/cli/start.ts +24 -0
  60. package/src/cli/tunnel.ts +53 -8
  61. package/src/config/config.ts +110 -19
  62. package/src/config/index.ts +5 -1
  63. package/src/config/models-mutation.ts +29 -11
  64. package/src/config/providers-mutation.ts +2 -2
  65. package/src/config/providers.ts +146 -12
  66. package/src/container/shared.ts +9 -0
  67. package/src/container/start.ts +87 -4
  68. package/src/cron/consumer.ts +13 -7
  69. package/src/hostd/models.ts +64 -0
  70. package/src/hostd/paths.ts +6 -0
  71. package/src/hostd/portbroker-manager.ts +2 -2
  72. package/src/init/checkpoint.ts +201 -0
  73. package/src/init/dockerfile.ts +164 -51
  74. package/src/init/gitignore.ts +7 -7
  75. package/src/init/index.ts +41 -9
  76. package/src/init/line-auth.ts +50 -21
  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 +41 -7
  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
@@ -6,6 +6,7 @@ import { defineCommand } from 'citty'
6
6
  import {
7
7
  KNOWN_PROVIDER_VENDORS,
8
8
  KNOWN_PROVIDERS,
9
+ isKnownModelRef,
9
10
  listKnownProviderVendorIds,
10
11
  providerIdsForVendor,
11
12
  supportsApiKey as providerSupportsApiKey,
@@ -35,9 +36,17 @@ import {
35
36
  type KakaotalkAuthResult,
36
37
  type LLMAuth,
37
38
  } from '@/init'
39
+ import {
40
+ checkpointFromSelections,
41
+ createLocalWizardCheckpointStore,
42
+ sanitizeCheckpointAgainstCatalog,
43
+ type WizardAnswerCheckpointV1,
44
+ type WizardCheckpointStore,
45
+ } from '@/init/checkpoint'
38
46
  import { runKakaotalkBootstrap } from '@/init/kakaotalk-auth'
39
- import { fetchModelOptions, type ModelOption } from '@/init/models-dev'
47
+ import { customModelMetaFromOption, fetchModelOptions, type ModelOption } from '@/init/models-dev'
40
48
  import { makeOAuthLoginRunner, type OAuthLoginResult } from '@/init/oauth-login'
49
+ import { detectInitProgress } from '@/init/progress'
41
50
  import {
42
51
  API_KEY_DASHBOARD_URL,
43
52
  MINIMAX_TOKEN_PLAN_DASHBOARD_URL,
@@ -159,9 +168,10 @@ export const init = defineCommand({
159
168
  }
160
169
  preflightSpinner.stop('Docker is reachable.')
161
170
 
171
+ const checkpointStore = createLocalWizardCheckpointStore()
162
172
  let collected: CollectedInputs
163
173
  try {
164
- collected = await collectWizardInputs(cwd, defaultWizardPrompts, { reset })
174
+ collected = await collectWizardInputs(cwd, defaultWizardPrompts, { reset, checkpointStore })
165
175
  } catch (error) {
166
176
  if (error instanceof WizardAbortedError) {
167
177
  if (error.oauthCredentialsSaved) {
@@ -189,6 +199,8 @@ export const init = defineCommand({
189
199
  kakaotalkPassword,
190
200
  github: githubCredentials,
191
201
  } = channelSecrets
202
+ const modelMeta = customModelMetaFromOption(model)
203
+ const visionModelMeta = vision !== undefined ? customModelMetaFromOption(vision.model) : undefined
192
204
 
193
205
  // TODO: add remaining wizard steps from TypeClaw.md once their runtime lands:
194
206
  // - git backup (url + PAT) — Phase 10
@@ -213,7 +225,14 @@ export const init = defineCommand({
213
225
  cwd,
214
226
  llmAuth,
215
227
  model: model.ref,
216
- ...(vision !== undefined ? { visionModel: vision.model.ref, visionAuth: vision.llmAuth } : {}),
228
+ ...(modelMeta !== undefined ? { modelMeta } : {}),
229
+ ...(vision !== undefined
230
+ ? {
231
+ visionModel: vision.model.ref,
232
+ ...(visionModelMeta !== undefined ? { visionModelMeta } : {}),
233
+ visionAuth: vision.llmAuth,
234
+ }
235
+ : {}),
217
236
  cliEntry: process.argv[1],
218
237
  ...(discordBotToken !== undefined ? { discordBotToken } : {}),
219
238
  ...(slackBotToken !== undefined ? { slackBotToken, slackAppToken } : {}),
@@ -271,6 +290,10 @@ export const init = defineCommand({
271
290
  }
272
291
 
273
292
  if (hatchingOk) {
293
+ // Clear the resume checkpoint only on full success (hatching ok). A
294
+ // failed install/dockerfile/git/hatching leaves it in place so the next
295
+ // `typeclaw init` — or `typeclaw start` (PR2) — can resume.
296
+ await checkpointStore.clear(cwd).catch(() => {})
274
297
  const claimableChannel =
275
298
  channelChoice !== 'none' && channelChoice !== 'github' ? channelDisplayName(channelChoice) : null
276
299
  const hints: Array<{ label: string; command: string }> = []
@@ -380,7 +403,7 @@ export interface WizardPrompts {
380
403
  pickModel: (
381
404
  options: ModelOption[],
382
405
  providerId: KnownProviderId,
383
- initial: KnownModelRef | undefined,
406
+ initial: string | undefined,
384
407
  ) => Promise<StepResult<ModelOption>>
385
408
  pickAuthMethod: (
386
409
  provider: (typeof KNOWN_PROVIDERS)[KnownProviderId],
@@ -400,7 +423,7 @@ export interface WizardPrompts {
400
423
  pickVisionModel: (
401
424
  options: ModelOption[],
402
425
  providerId: KnownProviderId,
403
- initial: KnownModelRef | undefined,
426
+ initial: string | undefined,
404
427
  ) => Promise<StepResult<ModelOption>>
405
428
  pickChannel: (initial: ChannelChoice | undefined) => Promise<StepResult<ChannelChoice>>
406
429
  hasExistingChannelSecrets: (cwd: string, channel: Exclude<ChannelChoice, 'none'>) => Promise<boolean>
@@ -408,17 +431,19 @@ export interface WizardPrompts {
408
431
  runOAuthLogin: (
409
432
  provider: (typeof KNOWN_PROVIDERS)[KnownProviderId],
410
433
  cwd: string,
411
- model: KnownModelRef,
434
+ model: string,
412
435
  ) => Promise<OAuthLoginResult>
413
436
  askOAuthFailureRecovery: (
414
437
  provider: (typeof KNOWN_PROVIDERS)[KnownProviderId],
415
438
  reason: string,
416
439
  apiKeyAvailable: boolean,
417
440
  ) => Promise<OAuthFailureRecovery>
441
+ confirmResumeCheckpoint: (checkpoint: WizardAnswerCheckpointV1) => Promise<'resume' | 'start-over'>
418
442
  }
419
443
 
420
444
  export type CollectWizardInputsOptions = {
421
445
  reset?: boolean
446
+ checkpointStore?: WizardCheckpointStore
422
447
  }
423
448
 
424
449
  export type OAuthFailureRecovery = 'retry' | 'api-key' | 'abort'
@@ -448,6 +473,7 @@ export const defaultWizardPrompts: WizardPrompts = {
448
473
  }
449
474
  },
450
475
  askOAuthFailureRecovery,
476
+ confirmResumeCheckpoint,
451
477
  }
452
478
 
453
479
  export async function collectWizardInputs(
@@ -456,12 +482,46 @@ export async function collectWizardInputs(
456
482
  options: CollectWizardInputsOptions = {},
457
483
  ): Promise<CollectedInputs> {
458
484
  const reset = options.reset === true
485
+ const checkpointStore = options.checkpointStore
459
486
  const catalog = await prompts.loadCatalog()
460
487
  const state: WizardState = { catalog }
461
488
  let step: StepId = 'pick-vendor'
462
489
  let pendingBackOrigin: StepId | null = null
463
490
  let oauthCredentialsSaved = false
464
491
 
492
+ // Resume saved wizard answers from a prior unfinished init. `--reset` skips
493
+ // this entirely (the user asked to re-answer everything). Route through the
494
+ // shared detectInitProgress() predicate so init/start/restart classify a
495
+ // checkpoint identically: only an `incomplete` init (checkpoint + not
496
+ // hatched) offers resume; a `complete-stale-checkpoint` (checkpoint that
497
+ // outlived a hatched agent) is cleared and ignored, matching the contract.
498
+ if (!reset && checkpointStore !== undefined) {
499
+ const progress = await detectInitProgress({ cwd, checkpointStore })
500
+ if (progress.kind === 'complete-stale-checkpoint') {
501
+ await checkpointStore.clear(cwd).catch(() => {})
502
+ } else if (progress.kind === 'incomplete') {
503
+ // Sanitize against the freshly-loaded catalog BEFORE showing the resume
504
+ // prompt. `load` only validates version/cwd/updatedAt, so a checkpoint
505
+ // with a since-removed provider/model id reaches here intact; describing
506
+ // or seeding it raw would crash on `KNOWN_PROVIDERS[providerId]`. Pruning
507
+ // first means the prompt, the seed, and any partial answers all see only
508
+ // catalog-valid fields.
509
+ const catalogRefs = new Set(catalog.options.map((option) => option.ref))
510
+ const sanitized = sanitizeCheckpointAgainstCatalog(progress.checkpoint, catalogRefs)
511
+ const decision = await prompts.confirmResumeCheckpoint(sanitized)
512
+ if (decision === 'resume') {
513
+ seedWizardState(state, sanitized)
514
+ } else {
515
+ await checkpointStore.clear(cwd)
516
+ }
517
+ }
518
+ }
519
+
520
+ const persistCheckpoint = async (): Promise<void> => {
521
+ if (reset || checkpointStore === undefined) return
522
+ await checkpointStore.save(cwd, projectCheckpoint(cwd, state)).catch(() => {})
523
+ }
524
+
465
525
  const abort = (): never => {
466
526
  throw new WizardAbortedError({ oauthCredentialsSaved })
467
527
  }
@@ -492,6 +552,11 @@ export async function collectWizardInputs(
492
552
  }
493
553
 
494
554
  while (true) {
555
+ // Persist the cumulative selections at the top of every iteration so a
556
+ // mid-wizard abort keeps everything answered so far. Running it here (not
557
+ // per-case) means back-navigation — which clears downstream fields before
558
+ // looping — overwrites the projection too, so stale answers never linger.
559
+ await persistCheckpoint()
495
560
  switch (step) {
496
561
  case 'pick-vendor': {
497
562
  const result = onResult(step, await prompts.pickVendor(catalog.options, state.vendorId))
@@ -849,7 +914,7 @@ async function runOAuthLoginSafely(
849
914
  prompts: WizardPrompts,
850
915
  provider: (typeof KNOWN_PROVIDERS)[KnownProviderId],
851
916
  cwd: string,
852
- model: KnownModelRef,
917
+ model: string,
853
918
  ): Promise<OAuthLoginResult> {
854
919
  try {
855
920
  return await prompts.runOAuthLogin(provider, cwd, model)
@@ -871,6 +936,72 @@ function finalize(state: WizardState, channelSecrets: CollectedInputs['channelSe
871
936
  }
872
937
  }
873
938
 
939
+ async function confirmResumeCheckpoint(checkpoint: WizardAnswerCheckpointV1): Promise<'resume' | 'start-over'> {
940
+ const summary = describeCheckpoint(checkpoint)
941
+ const choice = await select({
942
+ message: 'Found answers from a previous, unfinished init. Resume from them?',
943
+ options: [
944
+ { value: 'resume' as const, label: 'Resume', hint: summary },
945
+ { value: 'start-over' as const, label: 'Start over', hint: 'discard saved answers and pick everything again' },
946
+ ],
947
+ initialValue: 'resume' as const,
948
+ })
949
+ if (isCancel(choice)) return 'start-over'
950
+ return choice
951
+ }
952
+
953
+ function describeCheckpoint(checkpoint: WizardAnswerCheckpointV1): string {
954
+ const parts: string[] = []
955
+ // Defense-in-depth: callers sanitize before describing, but a raw provider id
956
+ // missing from KNOWN_PROVIDERS must degrade to the id string, never throw.
957
+ if (checkpoint.modelRef !== undefined) parts.push(checkpoint.modelRef)
958
+ else if (checkpoint.providerId !== undefined) {
959
+ parts.push(KNOWN_PROVIDERS[checkpoint.providerId]?.name ?? checkpoint.providerId)
960
+ }
961
+ if (checkpoint.visionModelRef !== undefined) parts.push(`vision: ${checkpoint.visionModelRef}`)
962
+ if (checkpoint.channelChoice !== undefined && checkpoint.channelChoice !== 'none') {
963
+ parts.push(`channel: ${checkpoint.channelChoice}`)
964
+ }
965
+ return parts.length > 0 ? parts.join(', ') : 'partial selections'
966
+ }
967
+
968
+ // Pre-populate wizard state from a sanitized checkpoint so each prompt's
969
+ // `initial` argument fast-forwards the user to their prior choice. Only seeds
970
+ // fields whose upstream selections are all present and catalog-valid; the
971
+ // sanitize pass already dropped stale refs, so a missing field here just means
972
+ // that step prompts fresh.
973
+ function seedWizardState(state: WizardState, checkpoint: WizardAnswerCheckpointV1): void {
974
+ state.vendorId = checkpoint.vendorId
975
+ state.providerId = checkpoint.providerId
976
+ state.model = resolveModelOption(state.catalog, checkpoint.modelRef)
977
+ state.authMethod = checkpoint.authMethod
978
+ state.visionVendorId = checkpoint.visionVendorId
979
+ state.visionProviderId = checkpoint.visionProviderId
980
+ state.visionModel = resolveModelOption(state.catalog, checkpoint.visionModelRef)
981
+ state.visionAuthMethod = checkpoint.visionAuthMethod
982
+ state.channelChoice = checkpoint.channelChoice
983
+ }
984
+
985
+ function resolveModelOption(catalog: WizardState['catalog'], ref: string | undefined): ModelOption | undefined {
986
+ if (catalog === undefined || ref === undefined) return undefined
987
+ return catalog.options.find((option) => option.ref === ref)
988
+ }
989
+
990
+ function projectCheckpoint(cwd: string, state: WizardState): WizardAnswerCheckpointV1 {
991
+ return checkpointFromSelections({
992
+ cwd,
993
+ ...(state.vendorId !== undefined ? { vendorId: state.vendorId } : {}),
994
+ ...(state.providerId !== undefined ? { providerId: state.providerId } : {}),
995
+ ...(state.model?.ref !== undefined ? { modelRef: state.model.ref } : {}),
996
+ ...(state.authMethod !== undefined ? { authMethod: state.authMethod } : {}),
997
+ ...(state.visionVendorId !== undefined ? { visionVendorId: state.visionVendorId } : {}),
998
+ ...(state.visionProviderId !== undefined ? { visionProviderId: state.visionProviderId } : {}),
999
+ ...(state.visionModel?.ref !== undefined ? { visionModelRef: state.visionModel.ref } : {}),
1000
+ ...(state.visionAuthMethod !== undefined ? { visionAuthMethod: state.visionAuthMethod } : {}),
1001
+ ...(state.channelChoice !== undefined ? { channelChoice: state.channelChoice } : {}),
1002
+ })
1003
+ }
1004
+
874
1005
  function channelDisplayName(choice: Exclude<ChannelChoice, 'none'>): string {
875
1006
  switch (choice) {
876
1007
  case 'slack':
@@ -990,7 +1121,7 @@ async function pickProviderVariant(
990
1121
  async function pickModelForProvider(
991
1122
  options: ModelOption[],
992
1123
  providerId: KnownProviderId,
993
- initial: KnownModelRef | undefined,
1124
+ initial: string | undefined,
994
1125
  ): Promise<StepResult<ModelOption>> {
995
1126
  const candidates = sortRecommendedFirst(options.filter((o) => o.providerId === providerId))
996
1127
  // select<string>, not select<KnownModelRef>: clack's Option<Value> is a
@@ -1069,7 +1200,7 @@ async function pickVisionProviderVariant(
1069
1200
  async function pickVisionModel(
1070
1201
  options: ModelOption[],
1071
1202
  providerId: KnownProviderId,
1072
- initial: KnownModelRef | undefined,
1203
+ initial: string | undefined,
1073
1204
  ): Promise<StepResult<ModelOption>> {
1074
1205
  const candidates = sortRecommendedFirst(options.filter((o) => o.providerId === providerId))
1075
1206
  // select<string> for the same distributive-Option reason as pickModelForProvider.
@@ -1697,12 +1828,12 @@ const RECOMMENDED_MODEL_REFS: ReadonlySet<KnownModelRef> = new Set<KnownModelRef
1697
1828
  ])
1698
1829
 
1699
1830
  export function formatModelLabel(o: ModelOption): string {
1700
- return RECOMMENDED_MODEL_REFS.has(o.ref) ? `${o.modelName} (Recommended)` : o.modelName
1831
+ return isKnownModelRef(o.ref) && RECOMMENDED_MODEL_REFS.has(o.ref) ? `${o.modelName} (Recommended)` : o.modelName
1701
1832
  }
1702
1833
 
1703
1834
  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))
1835
+ const recommended = options.filter((o) => isKnownModelRef(o.ref) && RECOMMENDED_MODEL_REFS.has(o.ref))
1836
+ const rest = options.filter((o) => !isKnownModelRef(o.ref) || !RECOMMENDED_MODEL_REFS.has(o.ref))
1706
1837
  return [...recommended, ...rest]
1707
1838
  }
1708
1839
 
@@ -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
1
  import { 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,14 @@ 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.
250
+ const modelOptions = await listCredentialedModelOptions(refs)
237
251
  const choice = await select<string>({
238
252
  message: 'Pick a model',
239
253
  options: [
240
- ...refs.map((ref) => ({
241
- value: ref,
242
- label: describeRef(ref),
243
- hint: ref,
254
+ ...modelOptions.map((option) => ({
255
+ value: option.ref,
256
+ label: describeRef(option.ref),
257
+ hint: option.ref,
244
258
  })),
245
259
  {
246
260
  value: ADD_PROVIDER_SENTINEL,
@@ -248,13 +262,18 @@ async function pickModelRef(cwd: string): Promise<string> {
248
262
  hint: 'configure a new provider',
249
263
  },
250
264
  ],
251
- initialValue: refs[0],
265
+ initialValue: modelOptions[0]?.ref ?? refs[0],
252
266
  })
253
267
  if (isCancel(choice)) {
254
268
  cancel('Aborted.')
255
269
  process.exit(0)
256
270
  }
257
- if (choice !== ADD_PROVIDER_SENTINEL) return choice
271
+ if (choice !== ADD_PROVIDER_SENTINEL) {
272
+ const option = modelOptions.find((candidate) => candidate.ref === choice)
273
+ if (option === undefined) return { ref: choice }
274
+ const meta = customModelMetaFromOption(option)
275
+ return { ref: option.ref, ...(meta !== undefined ? { meta } : {}) }
276
+ }
258
277
  const added = await runProviderAddFlow(cwd, {})
259
278
  if (!added.ok) {
260
279
  console.error(errorLine(added.reason))
@@ -263,29 +282,88 @@ async function pickModelRef(cwd: string): Promise<string> {
263
282
  }
264
283
  }
265
284
 
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
285
+ // Non-interactive `<ref>` path. Curated refs resolve from KNOWN_PROVIDERS, so
286
+ // they need no metadata. Non-curated refs are looked up in the live catalog so
287
+ // `customModels[ref]` carries the same metadata the interactive picker would
288
+ // persist; without it `resolveModel` silently falls back to defaults. A
289
+ // catalog miss (offline / unknown id) still writes the ref, but warns first.
290
+ export async function resolveExplicitRef(
291
+ ref: string,
292
+ loadCatalog: () => Promise<{ options: ModelOption[] }> = fetchModelOptions,
293
+ ): Promise<PickedModelRef> {
294
+ if (isKnownModelRef(ref)) return { ref }
295
+ const { options } = await loadCatalog()
296
+ const option = options.find((candidate) => candidate.ref === ref)
297
+ if (option === undefined) {
298
+ log.warn(
299
+ `"${ref}" isn't in the live catalog; saving the ref without metadata. ` +
300
+ `The agent will use fallback defaults (reasoning off, text-only input, zero cost, provider-default context).`,
301
+ )
302
+ return { ref }
303
+ }
304
+ const meta = customModelMetaFromOption(option)
305
+ return { ref, ...(meta !== undefined ? { meta } : {}) }
306
+ }
307
+
308
+ export type { PickedModelRef }
309
+
310
+ async function listCredentialedModelOptions(refs: KnownModelRef[]): Promise<ModelOption[]> {
311
+ const credentialedProviders = new Set<KnownProviderId>(refs.map((ref) => providerForModelRef(ref)))
312
+ const catalog = await fetchModelOptions()
313
+ const options = catalog.options.filter((option) => credentialedProviders.has(option.providerId))
314
+ if (options.length > 0) return options
315
+ return refs.map((ref) => {
316
+ const providerId = providerForModelRef(ref)
317
+ const modelId = ref.slice(providerId.length + 1)
318
+ const model = (
319
+ KNOWN_PROVIDERS[providerId].models as Record<
320
+ string,
321
+ { name: string; reasoning?: boolean; contextWindow?: number; input?: ReadonlyArray<string> }
322
+ >
323
+ )[modelId]
324
+ return {
325
+ ref,
326
+ providerId,
327
+ providerName: KNOWN_PROVIDERS[providerId].name,
328
+ modelId,
329
+ modelName: model?.name ?? modelId,
330
+ reasoning: model?.reasoning ?? false,
331
+ contextWindow: model?.contextWindow ?? null,
332
+ curated: true,
333
+ supportsVision: model?.input?.includes('image') ?? false,
334
+ }
335
+ })
336
+ }
337
+
338
+ function describeRef(ref: string): string {
339
+ try {
340
+ const providerId = providerForModelRef(ref)
341
+ const modelId = ref.slice(providerId.length + 1)
342
+ const provider = KNOWN_PROVIDERS[providerId]
343
+ const model = (provider.models as Record<string, { name: string }>)[modelId]
344
+ return `${provider.name} · ${model?.name ?? modelId}`
345
+ } catch {
346
+ return ref
347
+ }
272
348
  }
273
349
 
274
- function printAvailableRefs(): void {
275
- const refs = listKnownModelRefs()
276
- if (refs.length === 0) {
350
+ async function printAvailableRefs(): Promise<void> {
351
+ const { options, source, warning } = await fetchModelOptions()
352
+ if (options.length === 0) {
277
353
  console.log(c.dim('No models registered.'))
278
354
  return
279
355
  }
280
356
  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
357
+ if (source === 'curated' && warning !== undefined) {
358
+ console.log(c.dim(`Using built-in catalog (models.dev unavailable: ${warning}).`))
359
+ }
360
+ for (const providerId of Object.keys(KNOWN_PROVIDERS) as KnownProviderId[]) {
361
+ const providerOptions = options.filter((option) => option.providerId === providerId)
362
+ if (providerOptions.length === 0) continue
363
+ console.log('')
364
+ console.log(c.cyan(KNOWN_PROVIDERS[providerId].name))
365
+ for (const option of providerOptions) {
366
+ console.log(` ${option.ref}`)
288
367
  }
289
- console.log(` ${ref}`)
290
368
  }
291
369
  }
@@ -1,9 +1,11 @@
1
+ import { confirm, isCancel } from '@clack/prompts'
1
2
  import { defineCommand } from 'citty'
2
3
 
3
4
  import { config, validateConfig } from '@/config'
4
5
  import { start, stop } from '@/container'
5
6
  import { findAgentDir, isInitialized } from '@/init'
6
7
 
8
+ import { guardIncompleteInit } from './incomplete-init'
7
9
  import { c, errorLine, renderStartSuccess, spinner } from './ui'
8
10
 
9
11
  export const restartCommand = defineCommand({
@@ -27,6 +29,28 @@ export const restartCommand = defineCommand({
27
29
  async run({ args }) {
28
30
  const cwd = findAgentDir(process.cwd()) ?? process.cwd()
29
31
 
32
+ // Runs before BOTH isInitialized and stop. A wizard abort persists a
33
+ // checkpoint before scaffold writes typeclaw.json, so a checkpoint-but-no-
34
+ // config dir is an incomplete init that should get resume guidance, not the
35
+ // generic config-missing error — and a half-init agent usually has no
36
+ // container to stop. A `continue` falls through to isInitialized, which
37
+ // still catches a truly uninitialized dir.
38
+ const guard = await guardIncompleteInit({
39
+ cwd,
40
+ interactive: Boolean(process.stdout.isTTY),
41
+ confirmContinue: async () => {
42
+ const proceed = await confirm({ message: 'Try restarting anyway?', initialValue: false })
43
+ return !isCancel(proceed) && proceed === true
44
+ },
45
+ })
46
+ if (guard.action === 'block') {
47
+ console.error(errorLine(guard.message))
48
+ process.exit(1)
49
+ }
50
+ if (guard.action === 'abort') {
51
+ process.exit(0)
52
+ }
53
+
30
54
  if (!isInitialized(cwd)) {
31
55
  console.error(errorLine('TypeClaw config file not found. Run `typeclaw init` first.'))
32
56
  process.exit(1)
package/src/cli/start.ts CHANGED
@@ -1,9 +1,11 @@
1
+ import { confirm, isCancel } from '@clack/prompts'
1
2
  import { defineCommand } from 'citty'
2
3
 
3
4
  import { config, validateConfig } from '@/config'
4
5
  import { start } from '@/container'
5
6
  import { findAgentDir, isInitialized } from '@/init'
6
7
 
8
+ import { guardIncompleteInit } from './incomplete-init'
7
9
  import { errorLine, renderStartSuccess, spinner } from './ui'
8
10
 
9
11
  export const startCommand = defineCommand({
@@ -27,6 +29,28 @@ export const startCommand = defineCommand({
27
29
  async run({ args }) {
28
30
  const cwd = findAgentDir(process.cwd()) ?? process.cwd()
29
31
 
32
+ // Runs BEFORE the isInitialized check: a wizard abort persists a checkpoint
33
+ // before scaffold writes typeclaw.json, so a checkpoint-but-no-config dir is
34
+ // an incomplete init, not a "never initialized" one. Guarding first means
35
+ // that case gets the resume guidance instead of the generic config-missing
36
+ // error. A `continue` (no incomplete checkpoint, or "try anyway") falls
37
+ // through to isInitialized, which still catches a truly uninitialized dir.
38
+ const guard = await guardIncompleteInit({
39
+ cwd,
40
+ interactive: Boolean(process.stdout.isTTY),
41
+ confirmContinue: async () => {
42
+ const proceed = await confirm({ message: 'Try starting anyway?', initialValue: false })
43
+ return !isCancel(proceed) && proceed === true
44
+ },
45
+ })
46
+ if (guard.action === 'block') {
47
+ console.error(errorLine(guard.message))
48
+ process.exit(1)
49
+ }
50
+ if (guard.action === 'abort') {
51
+ process.exit(0)
52
+ }
53
+
30
54
  if (!isInitialized(cwd)) {
31
55
  console.error(errorLine('TypeClaw config file not found. Run `typeclaw init` first.'))
32
56
  process.exit(1)