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
@@ -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 }
@@ -2,6 +2,7 @@ import { readdir, stat } from 'node:fs/promises'
2
2
  import { join } from 'node:path'
3
3
 
4
4
  import type { MinimalSessionOrigin } from '@/agent/session-meta'
5
+ import type { LiveSessionPayload } from '@/shared'
5
6
 
6
7
  import { previewForHint } from './preview'
7
8
  import { replayJsonl } from './replay'
@@ -13,6 +14,11 @@ export type SessionSummary = {
13
14
  mtimeMs: number
14
15
  origin: MinimalSessionOrigin | null
15
16
  firstPrompt: string | null
17
+ // True only for a registry-derived session with no .jsonl on disk yet (a
18
+ // reply is in flight). Disk sessions leave this undefined. Selecting one tails
19
+ // live-only: streamSessionEvents replays an empty file, then the WS delivers
20
+ // events as they happen.
21
+ live?: boolean
16
22
  }
17
23
 
18
24
  export type ListSessionsOptions = {
@@ -65,6 +71,29 @@ export async function listSessions(opts: ListSessionsOptions): Promise<SessionSu
65
71
  )
66
72
  }
67
73
 
74
+ // Overlay container-registry sessions onto the disk listing. A live session
75
+ // already flushed to disk (post-reply) is dropped from the overlay — the disk
76
+ // summary wins, carrying its real mtime and prompt preview. Only sessions with
77
+ // no .jsonl yet become synthetic live rows, sorted to the top by registration
78
+ // time so an in-flight reply surfaces above settled history.
79
+ export function mergeLiveSessions(disk: SessionSummary[], live: LiveSessionPayload[]): SessionSummary[] {
80
+ const onDisk = new Set(disk.map((s) => s.sessionId))
81
+ const liveOnly = live
82
+ .filter((l) => !onDisk.has(l.sessionId))
83
+ .map(
84
+ (l): SessionSummary => ({
85
+ sessionId: l.sessionId,
86
+ sessionFile: '',
87
+ basename: '',
88
+ mtimeMs: l.registeredAtMs,
89
+ origin: l.origin,
90
+ firstPrompt: null,
91
+ live: true,
92
+ }),
93
+ )
94
+ return [...liveOnly, ...disk].sort((a, b) => b.mtimeMs - a.mtimeMs)
95
+ }
96
+
68
97
  export type ResolveResult =
69
98
  | { ok: true; summary: SessionSummary }
70
99
  | { ok: false; reason: 'not-found' | 'ambiguous'; matches: SessionSummary[] }
@@ -0,0 +1,114 @@
1
+ import { readFile, rename, writeFile } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+
4
+ export const EMBEDDING_MODEL_NAME = 'Xenova/multilingual-e5-base'
5
+ export const EMBEDDING_MODEL_DTYPE = 'q8'
6
+ export const EMBEDDING_DIMS = 768
7
+
8
+ // The embedding recipe that makes two vectors comparable: E5 query/passage
9
+ // prefixing + mean pooling + L2 normalize. Stamped in the sentinel (not folded
10
+ // into EMBEDDING_MODEL_ID, which is a stored-row filter — changing the ID would
11
+ // invalidate every existing vector row). A future pooling/normalize change
12
+ // bumps this string so a stale cache fails the sentinel loudly.
13
+ export const EMBEDDING_RECIPE = 'e5-prefix:mean-pool:l2-normalize'
14
+
15
+ // Stored-row identity = name@dtype. Used by the vector store to filter rows
16
+ // from an incompatible model/dtype variant out of cosine scans.
17
+ export const EMBEDDING_MODEL_ID = `${EMBEDDING_MODEL_NAME}@${EMBEDDING_MODEL_DTYPE}`
18
+
19
+ const SENTINEL_FILE = '.typeclaw-model.json'
20
+
21
+ export type ModelSentinel = {
22
+ schemaVersion: 1
23
+ model: string
24
+ dtype: string
25
+ dims: number
26
+ recipe: string
27
+ transformers: string
28
+ }
29
+
30
+ function sentinelPath(dir: string): string {
31
+ return join(dir, SENTINEL_FILE)
32
+ }
33
+
34
+ function expectedSentinel(transformers: string): Omit<ModelSentinel, 'transformers'> & { transformers: string } {
35
+ return {
36
+ schemaVersion: 1,
37
+ model: EMBEDDING_MODEL_NAME,
38
+ dtype: EMBEDDING_MODEL_DTYPE,
39
+ dims: EMBEDDING_DIMS,
40
+ recipe: EMBEDDING_RECIPE,
41
+ transformers,
42
+ }
43
+ }
44
+
45
+ // Atomic write-then-rename so a container reader can never observe a partial
46
+ // JSON file mid-write. Called host-side after a successful model download,
47
+ // inside the proper-lockfile critical section.
48
+ export async function writeModelSentinel(dir: string, input: { transformers: string }): Promise<void> {
49
+ const sentinel = expectedSentinel(input.transformers)
50
+ const tmp = `${sentinelPath(dir)}.${process.pid}.tmp`
51
+ await writeFile(tmp, `${JSON.stringify(sentinel, null, 2)}\n`, 'utf8')
52
+ await rename(tmp, sentinelPath(dir))
53
+ }
54
+
55
+ export async function readModelSentinel(dir: string): Promise<ModelSentinel | null> {
56
+ let raw: string
57
+ try {
58
+ raw = await readFile(sentinelPath(dir), 'utf8')
59
+ } catch {
60
+ return null
61
+ }
62
+ try {
63
+ const parsed = JSON.parse(raw) as Partial<ModelSentinel>
64
+ if (
65
+ parsed.schemaVersion !== 1 ||
66
+ typeof parsed.model !== 'string' ||
67
+ typeof parsed.dtype !== 'string' ||
68
+ typeof parsed.dims !== 'number' ||
69
+ typeof parsed.recipe !== 'string' ||
70
+ typeof parsed.transformers !== 'string'
71
+ ) {
72
+ return null
73
+ }
74
+ return parsed as ModelSentinel
75
+ } catch {
76
+ return null
77
+ }
78
+ }
79
+
80
+ // Throws a TypeClaw-authored error (naming observed vs expected identity, with
81
+ // the fix) BEFORE the container's `local_files_only` pipeline load — so a
82
+ // host/container drift surfaces as a clear "refresh the cache" message instead
83
+ // of a cryptic missing-file miss, OR worse, a stale file that loads against a
84
+ // different producer's layout and silently returns garbage vectors. Absent
85
+ // sentinel is a hard failure: host ensureModels() writes it before `docker
86
+ // run` in the same `typeclaw start`, so a missing one means the mount is wrong
87
+ // or the cache was hand-copied — exactly the case we must not paper over.
88
+ export async function assertModelCacheCompatible(dir: string, expected: { transformers: string }): Promise<void> {
89
+ const sentinel = await readModelSentinel(dir)
90
+ const want = expectedSentinel(expected.transformers)
91
+ if (sentinel === null) {
92
+ throw new Error(
93
+ `TypeClaw model cache at ${dir} is missing or has an unreadable ${SENTINEL_FILE}, so compatibility with ` +
94
+ `this container cannot be verified. Re-run \`typeclaw start\` to refresh the model cache; if it was copied ` +
95
+ `manually, delete it and start again.`,
96
+ )
97
+ }
98
+ const mismatches = describeMismatches(sentinel, want)
99
+ if (mismatches.length > 0) {
100
+ throw new Error(
101
+ `TypeClaw model cache at ${dir} is incompatible with this container (${mismatches.join('; ')}). ` +
102
+ `Re-run \`typeclaw start\` to refresh the model cache.`,
103
+ )
104
+ }
105
+ }
106
+
107
+ function describeMismatches(got: ModelSentinel, want: ModelSentinel): string[] {
108
+ const fields: Array<keyof ModelSentinel> = ['model', 'dtype', 'dims', 'recipe', 'transformers']
109
+ return fields
110
+ .filter((field) => got[field] !== want[field])
111
+ .map(
112
+ (field) => `${field}: cache has ${JSON.stringify(got[field])}, container expects ${JSON.stringify(want[field])}`,
113
+ )
114
+ }
@@ -0,0 +1,55 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { createRequire } from 'node:module'
3
+ import { dirname, join, parse as parsePath } from 'node:path'
4
+
5
+ // The ACTUALLY-INSTALLED @huggingface/transformers version in the current
6
+ // runtime, read from the resolved package's own package.json — NOT from
7
+ // typeclaw's dependency spec (which is the intended version, not what is on
8
+ // disk). The model-cache sentinel compares this across stages: the host
9
+ // stamps the version that produced the download, the container checks the
10
+ // version that will consume it. Comparing two intended constants would miss
11
+ // exactly the drift this guards — "the installed runtime isn't what the build
12
+ // said it should be" (e.g. a lockfile-free `bun add` resolving a newer
13
+ // release). Resolution is isolated here so the package-internals access lives
14
+ // in one place.
15
+ //
16
+ // We resolve the package's EXPORTED entry and walk up to its package.json,
17
+ // rather than `require('@huggingface/transformers/package.json')`: that subpath
18
+ // is not in the package's `exports` map (only `node`/`default`), so a strict
19
+ // Node-exports resolver throws ERR_PACKAGE_PATH_NOT_EXPORTED. The main entry IS
20
+ // exported, and its package.json is the nearest one above the resolved file.
21
+ export function getResolvedTransformersVersion(): string {
22
+ const require = createRequire(import.meta.url)
23
+ const entry = require.resolve('@huggingface/transformers')
24
+ const version = readNearestPackageVersion(dirname(entry))
25
+ if (version === null) {
26
+ throw new Error('could not resolve @huggingface/transformers version from its package.json')
27
+ }
28
+ return version
29
+ }
30
+
31
+ function readNearestPackageVersion(startDir: string): string | null {
32
+ const root = parsePath(startDir).root
33
+ let dir = startDir
34
+ for (;;) {
35
+ const version = readPackageNameVersion(join(dir, 'package.json'))
36
+ if (version !== null) return version
37
+ if (dir === root) return null
38
+ dir = dirname(dir)
39
+ }
40
+ }
41
+
42
+ // Only accept the @huggingface/transformers package.json, never a nested
43
+ // dependency's: the resolved entry can sit under dist/, and an intermediate
44
+ // dir could in theory carry an unrelated package.json. Match on name.
45
+ function readPackageNameVersion(pkgPath: string): string | null {
46
+ let parsed: { name?: unknown; version?: unknown }
47
+ try {
48
+ parsed = JSON.parse(readFileSync(pkgPath, 'utf8')) as { name?: unknown; version?: unknown }
49
+ } catch {
50
+ return null
51
+ }
52
+ if (parsed.name !== '@huggingface/transformers') return null
53
+ if (typeof parsed.version !== 'string' || parsed.version.length === 0) return null
54
+ return parsed.version
55
+ }
@@ -182,6 +182,9 @@ export type SessionTurnStartEvent = {
182
182
  agentDir: string
183
183
  userPrompt: string
184
184
  origin?: SessionOrigin
185
+ // Mutable ref: plugin writes retrieval results here; server/router reads after hook returns.
186
+ // Only populated when vector.enabled and injection plan is index mode.
187
+ retrievalContext?: { results: string }
185
188
  }
186
189
 
187
190
  export type SessionTurnEndEvent = {