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/init/index.ts CHANGED
@@ -10,13 +10,15 @@ import {
10
10
  GWS_MULTI_ACCOUNT_PLUGIN_VERSION,
11
11
  migrateLegacyConfigShape,
12
12
  type Config,
13
+ type CustomModelMeta,
13
14
  } from '@/config'
14
15
  import {
15
16
  DEFAULT_MODEL_REF,
16
17
  KNOWN_PROVIDERS,
18
+ isKnownModelRef,
17
19
  providerForModelRef,
18
- type KnownModelRef,
19
20
  type KnownProviderId,
21
+ type ModelRef,
20
22
  } from '@/config/providers'
21
23
  import { checkDockerAvailable, type DockerAvailability, type DockerExec, start } from '@/container'
22
24
  import { commitSystemFile } from '@/git/system-commit'
@@ -171,7 +173,8 @@ export type InitOptions = {
171
173
  cwd: string
172
174
  // Selected `provider/model` ref written into typeclaw.json. Defaults to
173
175
  // DEFAULT_MODEL_REF when callers (or older test fixtures) omit it.
174
- model?: KnownModelRef
176
+ model?: ModelRef | string
177
+ modelMeta?: CustomModelMeta
175
178
  // How the agent will authenticate to the LLM provider. When omitted,
176
179
  // defaults to the api-key path with `apiKey` (legacy field, still
177
180
  // supported for backwards compat with the old `runInit` signature).
@@ -181,7 +184,8 @@ export type InitOptions = {
181
184
  // when both refer to the same provider; the wizard enforces this
182
185
  // pairing rule, so by the time we get here `visionAuth` is either
183
186
  // (a) absent, or (b) the right auth for `visionModel`'s provider.
184
- visionModel?: KnownModelRef
187
+ visionModel?: ModelRef | string
188
+ visionModelMeta?: CustomModelMeta
185
189
  visionAuth?: LLMAuth
186
190
  apiKey?: string
187
191
  discordBotToken?: string
@@ -224,7 +228,9 @@ export async function runInit({
224
228
  apiKey,
225
229
  llmAuth,
226
230
  model = DEFAULT_MODEL_REF,
231
+ modelMeta,
227
232
  visionModel,
233
+ visionModelMeta,
228
234
  visionAuth,
229
235
  discordBotToken,
230
236
  slackBotToken,
@@ -304,7 +310,9 @@ export async function runInit({
304
310
  emit({ step: 'scaffold', phase: 'start' })
305
311
  await scaffold(cwd, {
306
312
  model,
313
+ ...(modelMeta !== undefined ? { modelMeta } : {}),
307
314
  ...(visionModel !== undefined ? { visionModel } : {}),
315
+ ...(visionModelMeta !== undefined ? { visionModelMeta } : {}),
308
316
  withDiscord: wantsDiscord,
309
317
  withSlack: wantsSlack,
310
318
  withTelegram: wantsTelegram,
@@ -520,8 +528,10 @@ export async function isHatched(dir: string): Promise<boolean> {
520
528
  }
521
529
 
522
530
  export type ScaffoldOptions = {
523
- model?: KnownModelRef
524
- visionModel?: KnownModelRef
531
+ model?: ModelRef | string
532
+ modelMeta?: CustomModelMeta
533
+ visionModel?: ModelRef | string
534
+ visionModelMeta?: CustomModelMeta
525
535
  withDiscord?: boolean
526
536
  withSlack?: boolean
527
537
  withTelegram?: boolean
@@ -545,12 +555,14 @@ export async function scaffold(root: string, options: ScaffoldOptions = {}): Pro
545
555
  // `memory.*`) is omitted to keep the scaffold minimal — duplicating defaults
546
556
  // here would mean every schema change has to be mirrored in two places, and
547
557
  // users would feel obligated to maintain values they never set.
548
- const models: Record<string, KnownModelRef> = { default: options.model ?? DEFAULT_MODEL_REF }
558
+ const models: Record<string, string> = { default: options.model ?? DEFAULT_MODEL_REF }
549
559
  if (options.visionModel !== undefined) models.vision = options.visionModel
550
560
  const config: Record<string, unknown> = {
551
561
  $schema: './node_modules/typeclaw/typeclaw.schema.json',
552
562
  models,
553
563
  }
564
+ const customModels = collectCustomModels(options)
565
+ if (Object.keys(customModels).length > 0) config.customModels = customModels
554
566
  const channels: Record<string, Record<string, never>> = {}
555
567
  if (options.withDiscord) channels['discord-bot'] = {}
556
568
  if (options.withSlack) channels['slack-bot'] = {}
@@ -578,12 +590,32 @@ export async function scaffold(root: string, options: ScaffoldOptions = {}): Pro
578
590
  await writeFile(join(root, GITIGNORE_FILE), buildGitignore(), { flag: 'wx' }).catch(ignoreExists)
579
591
  }
580
592
 
593
+ function collectCustomModels(options: ScaffoldOptions): Record<string, CustomModelMeta> {
594
+ const customModels: Record<string, CustomModelMeta> = {}
595
+ addCustomModel(customModels, options.model ?? DEFAULT_MODEL_REF, options.modelMeta)
596
+ if (options.visionModel !== undefined) addCustomModel(customModels, options.visionModel, options.visionModelMeta)
597
+ return customModels
598
+ }
599
+
600
+ function addCustomModel(
601
+ customModels: Record<string, CustomModelMeta>,
602
+ ref: string,
603
+ meta: CustomModelMeta | undefined,
604
+ ): void {
605
+ if (isKnownModelRef(ref)) return
606
+ customModels[ref] = meta ?? {}
607
+ }
608
+
581
609
  // agent-browser ships in every agent: the bundled SKILL.md (src/skills/
582
610
  // agent-browser/SKILL.md) is a discovery stub that calls `agent-browser
583
611
  // skills get core` at runtime, so the CLI must be installed for the skill
584
612
  // to function. The Dockerfile pre-downloads Chromium too, so the agent
585
613
  // can drive a browser without any first-run setup.
586
- const AGENT_BROWSER_VERSION = '^0.26.0'
614
+ //
615
+ // Must match the Dockerfile Layer 4 global install (dockerfile.ts); they are
616
+ // two installs of the same CLI and a skew is silent. Enforced by a guard test
617
+ // in packagejson.test.ts.
618
+ export const AGENT_BROWSER_VERSION = '^0.27.0'
587
619
  function buildPackageJson(root: string, name: string): Record<string, unknown> {
588
620
  return {
589
621
  name,
@@ -738,10 +770,10 @@ export async function writeSecrets(
738
770
  slackAppToken,
739
771
  telegramBotToken,
740
772
  }: {
741
- model?: KnownModelRef
773
+ model?: ModelRef | string
742
774
  // Omitted on the OAuth path — credentials live in secrets.json via the OAuth runner.
743
775
  apiKey?: string
744
- visionModel?: KnownModelRef
776
+ visionModel?: ModelRef | string
745
777
  visionApiKey?: string
746
778
  discordBotToken?: string
747
779
  slackBotToken?: string
@@ -50,30 +50,49 @@ export function lineSecretsPath(agentDir: string): string {
50
50
  return join(agentDir, 'secrets.json')
51
51
  }
52
52
 
53
+ // The SDK persists E2EE (Letter-Sealing) key material under
54
+ // `<AGENT_MESSENGER_CONFIG_DIR>/line-storage/`. The container sets that env to
55
+ // the agent workspace (src/init/dockerfile.ts), but a host-stage login (init /
56
+ // `channel reauth line`) would otherwise fall back to `~/.config/agent-messenger`
57
+ // — so the E2EE key gets written somewhere the container never reads, and inbound
58
+ // Letter-Sealing messages stay undecryptable. Point the host login at the same
59
+ // per-agent dir the container uses so the key lands where the runtime reads it.
60
+ export function lineConfigDir(agentDir: string): string {
61
+ return join(agentDir, 'workspace', '.agent-messenger')
62
+ }
63
+
53
64
  export async function runLineBootstrap(input: LineLoginInput): Promise<LineBootstrapStatus> {
54
65
  try {
55
66
  const store = new SecretsLineCredentialStore({ mode: 'host', secretsPath: lineSecretsPath(input.agentDir) })
56
- // The LINE SDK persists the minted auth_token + certificate by calling
57
- // setAccount() on whatever credential manager the client was built with.
58
- // Wiring our secrets.json-backed store in here means a successful login
59
- // writes straight to secrets.json#channels.line no second copy in
60
- // ~/.config/agent-messenger to keep in sync.
61
- const client = input.client ?? buildLineClient(store)
62
-
63
- const result = await suppressLineTokenInfoDump(() =>
64
- input.method === 'qr'
65
- ? client.loginWithQR({
66
- onQRUrl: async (url) => {
67
- await input.callbacks.onQRUrl?.(url)
68
- },
69
- onPincode: input.callbacks.onPincode,
70
- })
71
- : client.loginWithEmail({
72
- email: input.email,
73
- password: input.password,
74
- onPincode: input.callbacks.onPincode,
75
- }),
76
- )
67
+
68
+ // The env is set only for the duration of client construction + login (when
69
+ // the SDK reads it to locate line-storage) and restored after, so a second
70
+ // bootstrap for a different agent in the same process can't inherit the
71
+ // first agent's path. An already-set value (the container's Dockerfile env)
72
+ // is left untouched.
73
+ const result = await withLineConfigDir(lineConfigDir(input.agentDir), () => {
74
+ // The LINE SDK persists the minted auth_token + certificate by calling
75
+ // setAccount() on whatever credential manager the client was built with.
76
+ // Wiring our secrets.json-backed store in here means a successful login
77
+ // writes straight to secrets.json#channels.line — no second copy in
78
+ // ~/.config/agent-messenger to keep in sync.
79
+ const client = input.client ?? buildLineClient(store)
80
+
81
+ return suppressLineTokenInfoDump(() =>
82
+ input.method === 'qr'
83
+ ? client.loginWithQR({
84
+ onQRUrl: async (url) => {
85
+ await input.callbacks.onQRUrl?.(url)
86
+ },
87
+ onPincode: input.callbacks.onPincode,
88
+ })
89
+ : client.loginWithEmail({
90
+ email: input.email,
91
+ password: input.password,
92
+ onPincode: input.callbacks.onPincode,
93
+ }),
94
+ )
95
+ })
77
96
 
78
97
  if (!result.authenticated || result.account_id === undefined) {
79
98
  const reason = result.message ?? result.error ?? 'LINE login did not authenticate'
@@ -105,6 +124,16 @@ function buildLineClient(store: SecretsLineCredentialStore): LineLoginClient {
105
124
  return new RealLineClient(credManager) as unknown as LineLoginClient
106
125
  }
107
126
 
127
+ async function withLineConfigDir<T>(dir: string, fn: () => Promise<T>): Promise<T> {
128
+ const previous = process.env.AGENT_MESSENGER_CONFIG_DIR
129
+ if (previous === undefined) process.env.AGENT_MESSENGER_CONFIG_DIR = dir
130
+ try {
131
+ return await fn()
132
+ } finally {
133
+ if (previous === undefined) delete process.env.AGENT_MESSENGER_CONFIG_DIR
134
+ }
135
+ }
136
+
108
137
  async function suppressLineTokenInfoDump<T>(fn: () => Promise<T>): Promise<T> {
109
138
  const previous = lineTokenInfoSuppressionQueue
110
139
  let release: () => void = () => {}
@@ -1,4 +1,13 @@
1
- import { KNOWN_PROVIDERS, type KnownModelRef, type KnownProviderId, listKnownModelRefs } from '@/config/providers'
1
+ import type { CustomModelMeta } from '@/config'
2
+ import {
3
+ KNOWN_PROVIDERS,
4
+ isKnownModelRef,
5
+ isModelRef,
6
+ listKnownModelRefs,
7
+ providerForModelRef,
8
+ type KnownProviderId,
9
+ type ModelRef,
10
+ } from '@/config/providers'
2
11
 
3
12
  const MODELS_DEV_URL = 'https://models.dev/api.json'
4
13
  const REQUEST_TIMEOUT_MS = 10_000
@@ -23,16 +32,24 @@ const PROVIDER_TO_MODELS_DEV: Record<KnownProviderId, string> = {
23
32
  xai: 'xai',
24
33
  minimax: 'minimax',
25
34
  deepseek: 'deepseek',
35
+ moonshot: 'moonshot',
36
+ // moonshot-coding (Kimi Code subscription) is a billing surface, not a
37
+ // separate model catalog. models.dev tracks the underlying Kimi model
38
+ // metadata under `moonshot`, so we route lookups there; the curated
39
+ // `kimi-for-coding` alias is surfaced regardless of upstream membership.
40
+ 'moonshot-coding': 'moonshot',
26
41
  }
27
42
 
28
43
  export type ModelOption = {
29
- ref: KnownModelRef
44
+ ref: ModelRef | string
30
45
  providerId: KnownProviderId
31
46
  providerName: string
32
47
  modelId: string
33
48
  modelName: string
34
49
  reasoning: boolean
35
50
  contextWindow: number | null
51
+ maxTokens?: number | null
52
+ cost?: ModelOptionCost | null
36
53
  curated: boolean
37
54
  // True iff the model accepts image input. Sourced from the curated
38
55
  // `Model.input` array (which is the source of truth — pi-ai consumes it
@@ -43,6 +60,13 @@ export type ModelOption = {
43
60
  supportsVision: boolean
44
61
  }
45
62
 
63
+ export type ModelOptionCost = {
64
+ input: number
65
+ output: number
66
+ cacheRead: number
67
+ cacheWrite: number
68
+ }
69
+
46
70
  type ModelsDevModel = {
47
71
  id?: string
48
72
  name?: string
@@ -51,7 +75,15 @@ type ModelsDevModel = {
51
75
  status?: string
52
76
  release_date?: string
53
77
  modalities?: { input?: string[]; output?: string[] }
54
- limit?: { context?: number }
78
+ limit?: { context?: number; output?: number }
79
+ cost?: {
80
+ input?: number
81
+ output?: number
82
+ cacheRead?: number
83
+ cacheWrite?: number
84
+ cache_read?: number
85
+ cache_write?: number
86
+ }
55
87
  }
56
88
 
57
89
  type ModelsDevProvider = {
@@ -99,22 +131,47 @@ export function curatedOptions(): ModelOption[] {
99
131
  return refs.map((ref) => buildOption(ref, { curated: true }))
100
132
  }
101
133
 
102
- // `data` is the parsed models.dev JSON. We walk only the providers we care
103
- // about (openai, fireworks-ai) and only emit options for models that are
104
- // also in our curated allowlist — anything outside the allowlist would fail
105
- // schema validation when written to typeclaw.json. Curated entries that
106
- // models.dev doesn't list (e.g. kimi-k2p6-turbo) are still surfaced so the
107
- // user can pick them.
134
+ export function customModelMetaFromOption(option: ModelOption): CustomModelMeta | undefined {
135
+ if (isKnownModelRef(option.ref)) return undefined
136
+ if (!isModelRef(option.ref)) return undefined
137
+ return {
138
+ name: option.modelName,
139
+ reasoning: option.reasoning,
140
+ input: option.supportsVision ? ['text', 'image'] : ['text'],
141
+ ...(option.contextWindow !== null ? { contextWindow: option.contextWindow } : {}),
142
+ ...(option.maxTokens !== undefined && option.maxTokens !== null ? { maxTokens: option.maxTokens } : {}),
143
+ ...(option.cost !== undefined && option.cost !== null ? { cost: option.cost } : {}),
144
+ }
145
+ }
146
+
147
+ // `data` is the parsed models.dev JSON. We keep every curated entry first
148
+ // (including provider-specific aliases models.dev does not list), then append
149
+ // live upstream models whose refs validate against a known TypeClaw provider.
108
150
  function mergeWithCurated(data: Record<string, ModelsDevProvider>): ModelOption[] {
109
151
  const out: ModelOption[] = []
152
+ const seen = new Set<string>()
110
153
  for (const providerId of Object.keys(KNOWN_PROVIDERS) as KnownProviderId[]) {
111
154
  const known = KNOWN_PROVIDERS[providerId]
112
155
  const upstream = data[PROVIDER_TO_MODELS_DEV[providerId]]
113
156
  const upstreamModels = upstream?.models ?? {}
114
157
  for (const modelId of Object.keys(known.models)) {
115
158
  const upstreamModel = upstreamModels[modelId]
116
- const ref = `${providerId}/${modelId}` as KnownModelRef
159
+ const ref = `${providerId}/${modelId}`
117
160
  out.push(buildOption(ref, { curated: true, upstream: upstreamModel }))
161
+ seen.add(ref)
162
+ }
163
+ }
164
+
165
+ for (const providerId of Object.keys(KNOWN_PROVIDERS) as KnownProviderId[]) {
166
+ const upstream = data[PROVIDER_TO_MODELS_DEV[providerId]]
167
+ const upstreamModels = upstream?.models ?? {}
168
+ for (const [fallbackModelId, upstreamModel] of Object.entries(upstreamModels)) {
169
+ const modelId = upstreamModel.id ?? fallbackModelId
170
+ if (modelId.trim().length === 0) continue
171
+ const ref = `${providerId}/${modelId}`
172
+ if (seen.has(ref) || !isModelRef(ref)) continue
173
+ out.push(buildOption(ref, { curated: isKnownModelRef(ref), upstream: upstreamModel }))
174
+ seen.add(ref)
118
175
  }
119
176
  }
120
177
  return out
@@ -125,17 +182,23 @@ type BuildOptionOpts = {
125
182
  upstream?: ModelsDevModel
126
183
  }
127
184
 
128
- function buildOption(ref: KnownModelRef, opts: BuildOptionOpts): ModelOption {
129
- const slash = ref.indexOf('/')
130
- const providerId = ref.slice(0, slash) as KnownProviderId
131
- const modelId = ref.slice(slash + 1)
185
+ function buildOption(ref: ModelRef | string, opts: BuildOptionOpts): ModelOption {
186
+ const providerId = providerForModelRef(ref)
187
+ const modelId = ref.slice(providerId.length + 1)
132
188
  const provider = KNOWN_PROVIDERS[providerId]
133
189
  const curatedModel = (
134
190
  provider.models as Record<
135
191
  string,
136
- { name: string; contextWindow?: number; reasoning?: boolean; input?: ReadonlyArray<string> }
192
+ {
193
+ name: string
194
+ contextWindow?: number
195
+ maxTokens?: number
196
+ reasoning?: boolean
197
+ input?: ReadonlyArray<string>
198
+ }
137
199
  >
138
200
  )[modelId]
201
+ const input = resolveInput(curatedModel?.input, opts.upstream?.modalities?.input)
139
202
  return {
140
203
  ref,
141
204
  providerId,
@@ -144,16 +207,28 @@ function buildOption(ref: KnownModelRef, opts: BuildOptionOpts): ModelOption {
144
207
  modelName: opts.upstream?.name ?? curatedModel?.name ?? modelId,
145
208
  reasoning: opts.upstream?.reasoning ?? curatedModel?.reasoning ?? false,
146
209
  contextWindow: opts.upstream?.limit?.context ?? curatedModel?.contextWindow ?? null,
210
+ maxTokens: opts.upstream?.limit?.output ?? curatedModel?.maxTokens ?? null,
211
+ cost: resolveCost(opts.upstream?.cost),
147
212
  curated: opts.curated,
148
- supportsVision: resolveSupportsVision(curatedModel?.input, opts.upstream?.modalities?.input),
213
+ supportsVision: input.includes('image'),
149
214
  }
150
215
  }
151
216
 
152
- function resolveSupportsVision(
217
+ function resolveInput(
153
218
  curatedInput: ReadonlyArray<string> | undefined,
154
219
  upstreamInput: ReadonlyArray<string> | undefined,
155
- ): boolean {
156
- if (curatedInput !== undefined) return curatedInput.includes('image')
157
- if (upstreamInput !== undefined) return upstreamInput.includes('image')
158
- return false
220
+ ): string[] {
221
+ if (curatedInput !== undefined) return [...curatedInput]
222
+ if (upstreamInput !== undefined && upstreamInput.length > 0) return [...upstreamInput]
223
+ return ['text']
224
+ }
225
+
226
+ function resolveCost(cost: ModelsDevModel['cost']): ModelOptionCost | null {
227
+ if (cost === undefined) return null
228
+ return {
229
+ input: cost.input ?? 0,
230
+ output: cost.output ?? 0,
231
+ cacheRead: cost.cacheRead ?? cost.cache_read ?? 0,
232
+ cacheWrite: cost.cacheWrite ?? cost.cache_write ?? 0,
233
+ }
159
234
  }
@@ -4,14 +4,14 @@ import {
4
4
  KNOWN_PROVIDERS,
5
5
  providerForModelRef,
6
6
  supportsOAuth,
7
- type KnownModelRef,
8
7
  type KnownProviderId,
8
+ type ModelRef,
9
9
  } from '@/config/providers'
10
10
  import { createSecretsStoreForAgent } from '@/secrets'
11
11
 
12
12
  export type OAuthLoginResult = { ok: true } | { ok: false; reason: string }
13
13
 
14
- export type OAuthLoginRunner = (options: { cwd: string; model: KnownModelRef }) => Promise<OAuthLoginResult>
14
+ export type OAuthLoginRunner = (options: { cwd: string; model: ModelRef | string }) => Promise<OAuthLoginResult>
15
15
 
16
16
  // Wrap pi-ai's OAuth callbacks so the CLI doesn't have to know about the
17
17
  // upstream callback shape. The CLI sees four lifecycle events:
@@ -76,7 +76,7 @@ export function makeOAuthLoginRunner(callbacks: OAuthCallbacks): OAuthLoginRunne
76
76
  // params" without spinning up a real secrets store / browser callback server.
77
77
  export type FakeOAuthLoginRunnerOptions = {
78
78
  result?: OAuthLoginResult
79
- onCalled?: (options: { cwd: string; model: KnownModelRef; providerId: KnownProviderId }) => void
79
+ onCalled?: (options: { cwd: string; model: ModelRef | string; providerId: KnownProviderId }) => void
80
80
  }
81
81
 
82
82
  export function makeFakeOAuthLoginRunner(options: FakeOAuthLoginRunnerOptions = {}): OAuthLoginRunner {
@@ -0,0 +1,29 @@
1
+ import type { WizardAnswerCheckpointV1, WizardCheckpointStore } from './checkpoint'
2
+ import { isHatched } from './index'
3
+
4
+ export type InitProgressStatus =
5
+ | { kind: 'none' }
6
+ | { kind: 'incomplete'; checkpoint: WizardAnswerCheckpointV1 }
7
+ | { kind: 'complete-stale-checkpoint'; checkpoint: WizardAnswerCheckpointV1 }
8
+
9
+ export interface DetectInitProgressOptions {
10
+ cwd: string
11
+ checkpointStore: WizardCheckpointStore
12
+ isHatched?: (dir: string) => Promise<boolean>
13
+ }
14
+
15
+ // Single shared predicate for "is this init incomplete?", consumed by both the
16
+ // init resume-prompt and the start/restart launchers so the two never drift.
17
+ //
18
+ // `isHatched` is the completion authority — NOT the presence of node_modules,
19
+ // Dockerfile, or typeclaw.json, which are intermediate artifacts that start can
20
+ // regenerate. A checkpoint that outlives a hatched agent (clear failed after a
21
+ // successful run) is reported as `complete-stale-checkpoint` so callers can
22
+ // opportunistically clean it up instead of falsely blocking a working agent.
23
+ export async function detectInitProgress(options: DetectInitProgressOptions): Promise<InitProgressStatus> {
24
+ const hatchedCheck = options.isHatched ?? isHatched
25
+ const checkpoint = await options.checkpointStore.load(options.cwd)
26
+ if (checkpoint === undefined) return { kind: 'none' }
27
+ if (await hatchedCheck(options.cwd)) return { kind: 'complete-stale-checkpoint', checkpoint }
28
+ return { kind: 'incomplete', checkpoint }
29
+ }
@@ -10,6 +10,8 @@ const PROVIDER_PROBE: Partial<Record<KnownProviderId, { url: string; authHeader:
10
10
  xai: { url: 'https://api.x.ai/v1/models', authHeader: 'bearer' },
11
11
  minimax: { url: 'https://api.minimax.io/v1/models', authHeader: 'bearer' },
12
12
  deepseek: { url: 'https://api.deepseek.com/models', authHeader: 'bearer' },
13
+ moonshot: { url: 'https://api.moonshot.ai/v1/models', authHeader: 'bearer' },
14
+ 'moonshot-coding': { url: 'https://api.kimi.com/coding/v1/models', authHeader: 'bearer' },
13
15
  }
14
16
 
15
17
  // When a base-URL override (ANTHROPIC_BASE_URL / OPENAI_BASE_URL) points at a
@@ -165,6 +167,8 @@ export const API_KEY_DASHBOARD_URL: Partial<Record<KnownProviderId, string>> = {
165
167
  xai: 'https://console.x.ai',
166
168
  minimax: 'https://platform.minimax.io/user-center/basic-information/interface-key',
167
169
  deepseek: 'https://platform.deepseek.com/api_keys',
170
+ moonshot: 'https://platform.moonshot.ai/console/api-keys',
171
+ 'moonshot-coding': 'https://www.kimi.com/code/console',
168
172
  }
169
173
 
170
174
  // MiniMax sells the same `minimax` provider under two billing surfaces that
@@ -14,6 +14,8 @@ export { originLabel, shortSessionId } from './label'
14
14
  export { renderEvent } from './render'
15
15
  export { replayJsonl } from './replay'
16
16
  export { streamLive } from './live'
17
+ export { fetchLiveSessions } from './live-list'
18
+ export type { FetchLiveSessionsOptions } from './live-list'
17
19
  export { parseDuration, parseFilter } from './types'
18
20
  export type { InspectCategory, InspectEvent, InspectFilter } from './types'
19
21
  export { runInspectLoop, runViewerLoop } from './loop'
@@ -219,12 +221,17 @@ export async function streamSessionEvents(opts: StreamSessionEventsOptions): Pro
219
221
  opts.onEvent(event)
220
222
  }
221
223
 
222
- for await (const event of replayJsonl(
223
- opts.summary.sessionFile,
224
- opts.onWarn !== undefined ? { onWarn: opts.onWarn } : {},
225
- )) {
226
- if (aborted()) return { escToPicker: true }
227
- deliver(event)
224
+ // A live-only session (registry-derived, no .jsonl yet) has an empty
225
+ // sessionFile: skip replay and go straight to the live tail. Replaying ''
226
+ // would just emit a spurious "file does not exist" warning.
227
+ if (opts.summary.sessionFile !== '') {
228
+ for await (const event of replayJsonl(
229
+ opts.summary.sessionFile,
230
+ opts.onWarn !== undefined ? { onWarn: opts.onWarn } : {},
231
+ )) {
232
+ if (aborted()) return { escToPicker: true }
233
+ deliver(event)
234
+ }
228
235
  }
229
236
  opts.onPhase?.({ phase: 'replay-end' })
230
237
 
@@ -1,5 +1,7 @@
1
+ import type { LiveSessionPayload } from '@/shared'
2
+
1
3
  import type { ViewerItem } from './item'
2
- import { listSessions, type ListSessionsOptions, type SessionSummary } from './session-list'
4
+ import { listSessions, type ListSessionsOptions, mergeLiveSessions, type SessionSummary } from './session-list'
3
5
 
4
6
  export type ListViewerItemsOptions = ListSessionsOptions & {
5
7
  containerRunning: boolean
@@ -9,6 +11,9 @@ export type ListViewerItemsOptions = ListSessionsOptions & {
9
11
  // (most-recent) tui transcript — and any older tui transcript the heuristic
10
12
  // would otherwise promote — must NOT be offered as a writable live row.
11
13
  allowWritable?: boolean
14
+ // Registry sessions not yet flushed to disk, fetched by the CLI over the
15
+ // /inspect WS. The lib layer stays I/O-free; the caller owns the connection.
16
+ liveSessions?: LiveSessionPayload[]
12
17
  }
13
18
 
14
19
  export type ViewerList = {
@@ -23,7 +28,11 @@ export type ViewerList = {
23
28
  // sessions are read-only. The `logs` row is appended last (container stdout,
24
29
  // available offline) so it sits below the divider in the picker.
25
30
  export async function listViewerItems(opts: ListViewerItemsOptions): Promise<ViewerList> {
26
- const sessions = await listSessions(opts)
31
+ const diskSessions = await listSessions(opts)
32
+ const sessions =
33
+ opts.liveSessions !== undefined && opts.liveSessions.length > 0
34
+ ? mergeLiveSessions(diskSessions, opts.liveSessions)
35
+ : diskSessions
27
36
  const allowWritable = opts.allowWritable !== false
28
37
  const writableSessionId = opts.containerRunning && allowWritable ? pickWritableSession(sessions) : null
29
38
 
@@ -0,0 +1,65 @@
1
+ import type { InspectClientMessage, InspectServerMessage, LiveSessionPayload } from '@/shared'
2
+
3
+ export type FetchLiveSessionsOptions = {
4
+ url: string
5
+ signal?: AbortSignal
6
+ WebSocketImpl?: typeof WebSocket
7
+ timeoutMs?: number
8
+ }
9
+
10
+ const DEFAULT_TIMEOUT_MS = 5_000
11
+
12
+ // One-shot query of the container's in-memory session registry over the
13
+ // /inspect WS: open, send list_live, read the single reply, close. Failure
14
+ // (container down, timeout, abort) resolves to [] so the picker degrades to the
15
+ // disk-only listing rather than erroring — the live overlay is best-effort.
16
+ export async function fetchLiveSessions(opts: FetchLiveSessionsOptions): Promise<LiveSessionPayload[]> {
17
+ const WS = opts.WebSocketImpl ?? WebSocket
18
+ if (opts.signal?.aborted === true) return []
19
+
20
+ return new Promise<LiveSessionPayload[]>((resolve) => {
21
+ let settled = false
22
+ const ws = new WS(opts.url)
23
+
24
+ const finish = (result: LiveSessionPayload[]): void => {
25
+ if (settled) return
26
+ settled = true
27
+ clearTimeout(timer)
28
+ try {
29
+ ws.close()
30
+ } catch {
31
+ /* ignore */
32
+ }
33
+ resolve(result)
34
+ }
35
+
36
+ const timer = setTimeout(() => finish([]), opts.timeoutMs ?? DEFAULT_TIMEOUT_MS)
37
+
38
+ if (opts.signal !== undefined) {
39
+ opts.signal.addEventListener('abort', () => finish([]), { once: true })
40
+ }
41
+
42
+ ws.addEventListener('open', () => {
43
+ const req: InspectClientMessage = { type: 'list_live' }
44
+ try {
45
+ ws.send(JSON.stringify(req))
46
+ } catch {
47
+ finish([])
48
+ }
49
+ })
50
+
51
+ ws.addEventListener('message', (e) => {
52
+ let msg: InspectServerMessage
53
+ try {
54
+ msg = JSON.parse(String((e as MessageEvent).data)) as InspectServerMessage
55
+ } catch {
56
+ return
57
+ }
58
+ if (msg.type === 'live_sessions') finish(msg.sessions)
59
+ else if (msg.type === 'error') finish([])
60
+ })
61
+
62
+ ws.addEventListener('error', () => finish([]))
63
+ ws.addEventListener('close', () => finish([]))
64
+ })
65
+ }
@@ -1,8 +1,11 @@
1
+ import { join } from 'node:path'
2
+
1
3
  import type { LiveSourceFactory, RunInspectResult } from './index'
2
4
  import { createTranscriptView, streamInspectTarget } from './index'
3
5
  import type { ViewerItem } from './item'
4
6
  import { streamLogs } from './logs-item'
5
7
  import type { OpenItemContext, OpenItemResult, TailController } from './loop'
8
+ import { resolveSession } from './session-list'
6
9
  import { runTuiViewer } from './tui-item'
7
10
  import type { InspectFilter } from './types'
8
11
 
@@ -28,7 +31,14 @@ export type OpenViewerDeps = {
28
31
  // would corrupt input). The line/JSON session path and logs run UNDER the tail
29
32
  // scope, which owns the raw-mode esc/q/ctrl-c handling.
30
33
  export function openViewerItem(deps: OpenViewerDeps) {
31
- return async (item: ViewerItem, ctx: OpenItemContext): Promise<OpenItemResult> => {
34
+ return async (rawItem: ViewerItem, ctx: OpenItemContext): Promise<OpenItemResult> => {
35
+ // A live-only row captured `sessionFile: ''` when it was listed. By the time
36
+ // the user opens it the reply may have flushed to disk AND the registry
37
+ // entry may be gone — leaving the live tail broadcast-only and skipping the
38
+ // now-existing transcript. Re-resolve against the sessions dir so a
39
+ // flushed session opens as its real disk summary (replay + live tail).
40
+ const item = await reresolveLiveItem(rawItem, deps.cwd, deps.stderr)
41
+
32
42
  if (item.kind === 'tui') {
33
43
  const result = await runTuiViewer({
34
44
  resolveUrl: deps.resolveTuiUrl,
@@ -93,6 +103,17 @@ export function openViewerItem(deps: OpenViewerDeps) {
93
103
  }
94
104
  }
95
105
 
106
+ export async function reresolveLiveItem(
107
+ item: ViewerItem,
108
+ cwd: string,
109
+ onWarn: (line: string) => void,
110
+ ): Promise<ViewerItem> {
111
+ if (item.kind === 'logs' || item.summary.live !== true) return item
112
+ const resolved = await resolveSession(join(cwd, 'sessions'), item.summary.sessionId, onWarn)
113
+ if (!resolved.ok) return item
114
+ return { kind: 'session', summary: resolved.summary, writable: false }
115
+ }
116
+
96
117
  function toResult(escToPicker: boolean, scope: TailController): RunInspectResult {
97
118
  if (scope.intent() === 'exit') return { ok: true, exitCode: 0 }
98
119
  if (escToPicker) return { ok: true, exitCode: 0, escToPicker: true }