typeclaw 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/README.md +20 -15
  2. package/auth.schema.json +113 -0
  3. package/package.json +1 -1
  4. package/secrets.schema.json +113 -0
  5. package/src/agent/auth.ts +4 -2
  6. package/src/agent/index.ts +16 -28
  7. package/src/agent/model-fallback.ts +127 -0
  8. package/src/agent/session-meta.ts +1 -1
  9. package/src/agent/session-origin.ts +3 -2
  10. package/src/agent/tools/curl-impersonate.ts +300 -0
  11. package/src/agent/tools/ddg.ts +13 -88
  12. package/src/agent/tools/webfetch/fetch.ts +105 -2
  13. package/src/agent/tools/webfetch/tool.ts +4 -0
  14. package/src/bundled-plugins/agent-browser/shim.ts +47 -0
  15. package/src/bundled-plugins/backup/subagents.ts +2 -0
  16. package/src/bundled-plugins/memory/README.md +49 -12
  17. package/src/bundled-plugins/memory/citation-superset.ts +63 -0
  18. package/src/bundled-plugins/memory/dreaming.ts +105 -17
  19. package/src/bundled-plugins/memory/index.ts +2 -2
  20. package/src/bundled-plugins/memory/memory-logger.ts +45 -26
  21. package/src/bundled-plugins/memory/strength.ts +127 -0
  22. package/src/bundled-plugins/memory/topics.ts +75 -0
  23. package/src/bundled-plugins/security/index.ts +88 -43
  24. package/src/bundled-plugins/security/permissions.ts +36 -0
  25. package/src/bundled-plugins/security/policies/git-exfil.ts +20 -0
  26. package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +12 -0
  27. package/src/bundled-plugins/security/policies/prompt-injection.ts +23 -3
  28. package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +7 -0
  29. package/src/bundled-plugins/security/policies/secret-exfil-read.ts +6 -0
  30. package/src/bundled-plugins/security/policies/session-search-secrets.ts +9 -0
  31. package/src/bundled-plugins/security/policies/ssrf.ts +6 -0
  32. package/src/bundled-plugins/security/policies/system-prompt-leak.ts +7 -0
  33. package/src/channels/adapters/github/auth-app.ts +120 -0
  34. package/src/channels/adapters/github/auth-pat.ts +50 -0
  35. package/src/channels/adapters/github/auth.ts +33 -0
  36. package/src/channels/adapters/github/channel-resolver.ts +30 -0
  37. package/src/channels/adapters/github/dedup.ts +26 -0
  38. package/src/channels/adapters/github/event-allowlist.ts +8 -0
  39. package/src/channels/adapters/github/fetch-attachment.ts +5 -0
  40. package/src/channels/adapters/github/history.ts +63 -0
  41. package/src/channels/adapters/github/inbound.ts +286 -0
  42. package/src/channels/adapters/github/index.ts +370 -0
  43. package/src/channels/adapters/github/managed-path.ts +54 -0
  44. package/src/channels/adapters/github/membership.ts +35 -0
  45. package/src/channels/adapters/github/outbound.ts +145 -0
  46. package/src/channels/adapters/github/webhook-register.ts +349 -0
  47. package/src/channels/manager.ts +94 -9
  48. package/src/channels/router.ts +194 -28
  49. package/src/channels/schema.ts +31 -1
  50. package/src/channels/tunnel-bridge.ts +51 -0
  51. package/src/channels/types.ts +3 -1
  52. package/src/cli/builtins.ts +28 -0
  53. package/src/cli/channel.ts +511 -25
  54. package/src/cli/container-command-client.ts +244 -0
  55. package/src/cli/cron.ts +173 -0
  56. package/src/cli/host-command-runner.ts +150 -0
  57. package/src/cli/index.ts +42 -1
  58. package/src/cli/init.ts +400 -67
  59. package/src/cli/model.ts +14 -4
  60. package/src/cli/oauth-callbacks.ts +49 -0
  61. package/src/cli/plugin-command-help.ts +49 -0
  62. package/src/cli/plugin-commands-dispatch.ts +112 -0
  63. package/src/cli/plugin-commands.ts +118 -0
  64. package/src/cli/provider.ts +3 -20
  65. package/src/cli/tui.ts +10 -2
  66. package/src/cli/tunnel.ts +533 -0
  67. package/src/cli/ui.ts +8 -3
  68. package/src/config/config.ts +134 -24
  69. package/src/config/models-mutation.ts +42 -8
  70. package/src/config/providers-mutation.ts +12 -8
  71. package/src/container/start.ts +48 -4
  72. package/src/cron/bridge.ts +136 -0
  73. package/src/cron/consumer.ts +174 -48
  74. package/src/cron/index.ts +19 -2
  75. package/src/cron/list.ts +105 -0
  76. package/src/cron/scheduler.ts +12 -3
  77. package/src/cron/schema.ts +11 -3
  78. package/src/doctor/checks.ts +0 -50
  79. package/src/init/dockerfile.ts +165 -13
  80. package/src/init/ensure-deps.ts +15 -4
  81. package/src/init/github-webhook-install.ts +109 -0
  82. package/src/init/hatching.ts +2 -2
  83. package/src/init/index.ts +519 -12
  84. package/src/init/oauth-login.ts +17 -3
  85. package/src/init/run-bun-install.ts +17 -3
  86. package/src/init/run-owner-claim.ts +11 -2
  87. package/src/permissions/builtins.ts +29 -2
  88. package/src/permissions/match-rule.ts +24 -2
  89. package/src/permissions/permissions.ts +24 -7
  90. package/src/permissions/resolve.ts +1 -0
  91. package/src/plugin/define.ts +44 -1
  92. package/src/plugin/index.ts +18 -3
  93. package/src/plugin/manager.ts +16 -0
  94. package/src/plugin/registry.ts +85 -3
  95. package/src/plugin/types.ts +144 -1
  96. package/src/plugin/zod-introspect.ts +100 -0
  97. package/src/role-claim/match-rule.ts +2 -1
  98. package/src/run/index.ts +112 -4
  99. package/src/secrets/index.ts +1 -1
  100. package/src/secrets/schema.ts +21 -0
  101. package/src/server/command-runner.ts +476 -0
  102. package/src/server/index.ts +388 -5
  103. package/src/shared/index.ts +8 -0
  104. package/src/shared/protocol.ts +80 -1
  105. package/src/skills/typeclaw-channel-github/SKILL.md +24 -0
  106. package/src/skills/typeclaw-config/SKILL.md +27 -26
  107. package/src/skills/typeclaw-cron/SKILL.md +234 -3
  108. package/src/skills/typeclaw-memory/SKILL.md +25 -15
  109. package/src/skills/typeclaw-monorepo/SKILL.md +2 -2
  110. package/src/skills/typeclaw-permissions/SKILL.md +35 -16
  111. package/src/skills/typeclaw-plugins/SKILL.md +251 -5
  112. package/src/skills/typeclaw-tunnels/SKILL.md +111 -0
  113. package/src/test-helpers/wait-for.ts +50 -0
  114. package/src/tui/index.ts +70 -7
  115. package/src/tunnels/__fixtures__/cloudflared-quick-stderr.txt +11 -0
  116. package/src/tunnels/events.ts +14 -0
  117. package/src/tunnels/index.ts +12 -0
  118. package/src/tunnels/log-ring.ts +54 -0
  119. package/src/tunnels/manager.ts +139 -0
  120. package/src/tunnels/providers/cloudflare-quick.ts +189 -0
  121. package/src/tunnels/providers/external.ts +53 -0
  122. package/src/tunnels/quick-url-parser.ts +5 -0
  123. package/src/tunnels/types.ts +43 -0
  124. package/src/usage/report.ts +15 -12
  125. package/typeclaw.schema.json +311 -26
@@ -86,6 +86,36 @@ const dockerfileObjectSchema = z.object({
86
86
  gh: dockerfileFeatureSchema.default(true),
87
87
  python: z.boolean().default(true),
88
88
  tmux: dockerfileFeatureSchema.default(true),
89
+ // `fonts-noto-cjk` is a ~56MB metapackage that makes Chromium render
90
+ // Korean/Japanese/Chinese glyphs correctly in screenshots, `page.pdf()`,
91
+ // and any other raster output the agent-browser plugin produces. Default
92
+ // `true` because the alternative — silent tofu boxes (□□□) in CJK
93
+ // screenshots — is a confusing failure mode that an agent cannot self-
94
+ // diagnose from a screenshot it took itself. Opt-out with `cjkFonts:
95
+ // false` to save the ~56MB on agents that never touch CJK content.
96
+ // Boolean-only (no version pin) because the package is a metapackage
97
+ // tracking the upstream Noto release and version pinning offers no
98
+ // practical value.
99
+ cjkFonts: z.boolean().default(true),
100
+ // Opt into the cloudflared layer for `cloudflare-quick` tunnels. Default
101
+ // `true` so `tunnel add` / `channel add github` with the default Cloudflare
102
+ // Quick provider works on the next `start` without a separate Dockerfile
103
+ // edit. Opt-out with `cloudflared: false` to skip the ~35MB binary on
104
+ // agents that don't use tunnels.
105
+ cloudflared: z.boolean().default(true),
106
+ // Install xvfb so the entrypoint shim can spawn an Xvfb virtual X
107
+ // server and export DISPLAY, giving headed Chrome (agent-browser
108
+ // --headed, Playwright headful) a real X11 display to connect to.
109
+ // Default `true` because modern bot detection (Akamai/Cloudflare Bot
110
+ // Manager) fingerprints `--headless` and `--headless=new` regardless
111
+ // of UA spoof, and headed-via-Xvfb is the cheapest path to a passing
112
+ // fingerprint from a container. Opt-out with `xvfb: false` to save
113
+ // ~5MB image + ~10MB RAM/idle on agents that never touch a browser.
114
+ // The shim self-heals — when Xvfb isn't on PATH it execs the agent
115
+ // directly, no other Dockerfile or shim change needed. Boolean-only
116
+ // because the package has no API-stable versioning that matters
117
+ // here; xvfb tracks the upstream X server release.
118
+ xvfb: z.boolean().default(true),
89
119
  append: z.array(dockerfileLineSchema).default([]),
90
120
  })
91
121
 
@@ -205,32 +235,106 @@ export const networkSchema = z
205
235
 
206
236
  export type NetworkConfig = z.infer<typeof networkSchema>
207
237
 
208
- // `models` is a map from profile name to a single curated model ref. The
238
+ // Reverse-proxy tunnels expose a container-private port to the public internet
239
+ // via a managed subprocess (cloudflared) or a user-supplied external URL.
240
+ // See AGENTS.md `## Tunnels`. PR 2 ships `cloudflare-quick`; `cloudflare-named`
241
+ // remains deferred to PR 3. Keeping the enum scoped to what's implemented means
242
+ // validateConfig() rejects unsupported providers at `typeclaw start` time,
243
+ // before the container is torn down and rebuilt. `restart-required` because
244
+ // the tunnel manager reads this list once at boot.
245
+ const tunnelForSchema = z.discriminatedUnion('kind', [
246
+ z.object({ kind: z.literal('channel'), name: z.string().trim().min(1) }),
247
+ z.object({ kind: z.literal('manual') }),
248
+ ])
249
+
250
+ const tunnelEntrySchema = z
251
+ .object({
252
+ name: z
253
+ .string()
254
+ .min(1)
255
+ .regex(/^[a-z0-9][a-z0-9-_]*$/, {
256
+ message: 'tunnel name must match /^[a-z0-9][a-z0-9-_]*$/ (lowercase, digits, dashes, underscores)',
257
+ }),
258
+ provider: z.enum(['external', 'cloudflare-quick']),
259
+ for: tunnelForSchema,
260
+ externalUrl: z
261
+ .string()
262
+ .url()
263
+ .refine((u) => u.startsWith('https://'), { message: 'externalUrl must use https://' })
264
+ .optional(),
265
+ upstreamPort: z.number().int().min(1).max(65535).optional(),
266
+ })
267
+ .refine((v) => v.provider !== 'external' || (v.externalUrl !== undefined && v.externalUrl.trim() !== ''), {
268
+ message: "tunnels[].externalUrl is required when provider is 'external'",
269
+ })
270
+ .refine((v) => v.for.kind !== 'manual' || v.upstreamPort !== undefined, {
271
+ message: "tunnels[].upstreamPort is required when for.kind is 'manual'",
272
+ })
273
+
274
+ const tunnelsArraySchema = z
275
+ .array(tunnelEntrySchema)
276
+ .default([])
277
+ .superRefine((entries, ctx) => {
278
+ const seen = new Map<string, number>()
279
+ for (let i = 0; i < entries.length; i++) {
280
+ const name = entries[i]!.name
281
+ const prev = seen.get(name)
282
+ if (prev !== undefined) {
283
+ ctx.addIssue({
284
+ code: 'custom',
285
+ path: [i, 'name'],
286
+ message: `tunnels[${i}].name duplicates tunnels[${prev}].name ('${name}')`,
287
+ })
288
+ } else {
289
+ seen.set(name, i)
290
+ }
291
+ }
292
+ })
293
+
294
+ // `models` maps a profile name to one or more curated model refs. The
209
295
  // `default` profile is mandatory; every other profile is optional and falls
210
296
  // back to `default` at resolution time (see `resolveProfile`).
211
297
  //
298
+ // Each value is either a single `KnownModelRef` or a non-empty array of refs
299
+ // forming a fallback chain: when a turn against the first ref fails (hard
300
+ // throw or a soft provider error), the runtime disposes the failed session
301
+ // and replays the same prompt against the next ref. Schema accepts both
302
+ // shapes for ergonomics; the parsed value is always normalised to a
303
+ // non-empty array so downstream consumers read a uniform `KnownModelRef[]`.
304
+ //
212
305
  // Profile names are open strings; the runtime recognizes a handful of
213
306
  // well-known names by convention (`default`, `fast`, `deep`, `vision`) but
214
- // any string is valid. Subagents may declare a static profile preference;
215
- // callers may override per-spawn. Unknown profile names resolve to `default`
216
- // with a one-time warning at session construction.
307
+ // any string is valid. Unknown profile names resolve to `default` with a
308
+ // one-time warning at session construction.
217
309
  //
218
310
  // The pre-multi-model schema had a single `model: KnownModelRef` at the top
219
311
  // level. `migrateLegacyConfigShape` rewrites that to `models: { default: ... }`
220
312
  // on first load (and writes the result back to disk + commits via
221
313
  // `persistMigratedConfig`), so every downstream consumer sees the new shape.
314
+ const modelRefOrChainSchema = z
315
+ .union([
316
+ z.enum(knownModelRefs),
317
+ z
318
+ .array(z.enum(knownModelRefs))
319
+ .min(1)
320
+ // Reject exact duplicates in a chain — retrying the same ref after the
321
+ // same class of failure is almost certainly a config typo, and silently
322
+ // deduping would mask user intent. Different models from the same
323
+ // provider (e.g. `["openai/gpt-5.4-nano", "openai/gpt-5.4-mini"]`) are
324
+ // still valid because they hit distinct upstream endpoints.
325
+ .refine((arr) => new Set(arr).size === arr.length, {
326
+ message: 'models chain must not contain duplicate refs',
327
+ }),
328
+ ])
329
+ .transform((value) => (Array.isArray(value) ? value : [value]))
222
330
  export const modelsSchema = z
223
- .record(z.string().min(1), z.enum(knownModelRefs))
331
+ .record(z.string().min(1), modelRefOrChainSchema)
224
332
  .refine((m) => 'default' in m, { message: 'models.default is required' })
225
333
 
226
- // Zod's `z.record(..., refine)` doesn't refine the inferred type — the inferred
227
- // shape is `Record<string, KnownModelRef>` where every access is `T | undefined`.
228
- // The runtime guarantee (the `refine` above) is that `default` is present, so
229
- // we narrow the type here. Every consumer (auth.ts, agent/index.ts,
230
- // resolveProfile) reads `models.default` on the hot path; without this
231
- // narrowing they all have to assert or `?? throw`, which is noise around an
232
- // invariant the schema already enforces.
233
- export type Models = Record<string, KnownModelRef> & { default: KnownModelRef }
334
+ // Zod's `z.record(..., refine)` doesn't refine the inferred type. The
335
+ // `default` key is schema-enforced, so we narrow it here to spare every
336
+ // consumer the `T | undefined` assertion noise.
337
+ export type Models = Record<string, KnownModelRef[]> & { default: KnownModelRef[] }
234
338
 
235
339
  export const configSchema = z
236
340
  .object({
@@ -238,8 +342,10 @@ export const configSchema = z
238
342
  port: z.number().int().min(1).max(65535).default(DEFAULT_PORT),
239
343
  // `default(() => ...)` ensures every parsed config has at least
240
344
  // `models.default`. Direct `.default({ default: ... })` would short-circuit
241
- // the refinement, so we lean on the lazy thunk form.
242
- models: modelsSchema.default(() => ({ default: DEFAULT_MODEL_REF })) as unknown as z.ZodType<Models>,
345
+ // the refinement, so we lean on the lazy thunk form. The default value is
346
+ // shaped to match the post-transform output (always `KnownModelRef[]`),
347
+ // not the user-facing input shape.
348
+ models: modelsSchema.default(() => ({ default: [DEFAULT_MODEL_REF] })) as unknown as z.ZodType<Models>,
243
349
  // Defaults to `[]` so the field can be omitted from `typeclaw.json` (no
244
350
  // host paths exposed) without failing the whole config load. `typeclaw
245
351
  // init` omits this field so users don't see noise for the empty case.
@@ -258,6 +364,7 @@ export const configSchema = z
258
364
  docker: dockerSchema,
259
365
  git: gitSchema,
260
366
  roles: rolesConfigSchema.optional(),
367
+ tunnels: tunnelsArraySchema,
261
368
  })
262
369
  .catchall(z.unknown())
263
370
 
@@ -271,26 +378,28 @@ export function resolveModel(ref: KnownModelRef): Model<'openai-completions'> |
271
378
  return KNOWN_PROVIDERS[providerId].models[modelId as never]
272
379
  }
273
380
 
274
- // Resolves a profile name (e.g. `fast`, `deep`, `vision`) to a concrete model
275
- // ref. Unknown profiles fall back to `default` so callers can pass through
381
+ // Resolves a profile name (e.g. `fast`, `deep`, `vision`) to its fallback
382
+ // chain. Unknown profiles fall back to `default` so callers can pass through
276
383
  // arbitrary subagent-declared or user-overridden strings without crashing.
277
- // Returns the resolved ref plus whether it came from the requested profile or
278
- // from the `default` fallback, so the caller can warn once per session
279
- // instead of every prompt.
384
+ // `refs` is non-empty (the schema guarantees `default` exists and every value
385
+ // is at least one ref). `ref` is the head of the chain the model the
386
+ // session is created with first. Callers that don't implement fallback can
387
+ // keep reading `ref`; fallback-aware callers iterate `refs`.
280
388
  export type ResolvedProfile = {
281
389
  ref: KnownModelRef
390
+ refs: KnownModelRef[]
282
391
  profile: string
283
392
  fellBackToDefault: boolean
284
393
  }
285
394
 
286
395
  export function resolveProfile(models: Models, name: string | undefined): ResolvedProfile {
287
396
  const requested = name ?? 'default'
288
- const ref = models[requested]
289
- if (ref !== undefined) {
290
- return { ref, profile: requested, fellBackToDefault: false }
397
+ const refs = models[requested]
398
+ if (refs !== undefined) {
399
+ return { ref: refs[0]!, refs, profile: requested, fellBackToDefault: false }
291
400
  }
292
401
  const fallback = models.default
293
- return { ref: fallback, profile: 'default', fellBackToDefault: true }
402
+ return { ref: fallback[0]!, refs: fallback, profile: 'default', fellBackToDefault: true }
294
403
  }
295
404
 
296
405
  // Resolves a mount's `path` field to an absolute host path, mirroring shell
@@ -369,6 +478,7 @@ export const FIELD_EFFECTS: Record<string, FieldEffect> = {
369
478
  channels: 'applied',
370
479
  portForward: 'restart-required',
371
480
  network: 'restart-required',
481
+ tunnels: 'restart-required',
372
482
  'docker.file': 'restart-required',
373
483
  'git.ignore': 'restart-required',
374
484
  // Split: `match` lists are reload-safe (typeclaw role claim, hand-edits
@@ -17,8 +17,16 @@ const CONFIG_FILE = 'typeclaw.json'
17
17
 
18
18
  export type ModelProfileEntry = {
19
19
  profile: string
20
+ // Head of the fallback chain. Kept under the legacy `ref` name so callers
21
+ // that only care about the active model (the common case) don't need to
22
+ // dereference `refs[0]`. The chain itself is exposed as `refs`.
20
23
  ref: KnownModelRef
24
+ refs: KnownModelRef[]
21
25
  providerId: KnownProviderId
26
+ // Credential status for every provider referenced by the chain. The chain's
27
+ // overall status is `available` only when every entry resolves; otherwise
28
+ // it is `missing-credentials`, and `missingProviders` names which.
29
+ missingProviders: KnownProviderId[]
22
30
  isDefault: boolean
23
31
  credentialStatus: 'available' | 'missing-credentials'
24
32
  }
@@ -28,14 +36,18 @@ export type ModelMutationResult = { ok: true } | { ok: false; reason: string }
28
36
  export function listModelProfiles(cwd: string, env: NodeJS.ProcessEnv = process.env): ModelProfileEntry[] {
29
37
  const models = loadConfigSync(cwd).models
30
38
  const out: ModelProfileEntry[] = []
31
- for (const [profile, ref] of Object.entries(models)) {
32
- const providerId = providerForModelRef(ref)
39
+ for (const [profile, refs] of Object.entries(models)) {
40
+ const headRef = refs[0]!
41
+ const providerId = providerForModelRef(headRef)
42
+ const missingProviders = uniqueProviders(refs).filter((p) => !hasUsableCredential(cwd, p, env))
33
43
  out.push({
34
44
  profile,
35
- ref,
45
+ ref: headRef,
46
+ refs,
36
47
  providerId,
48
+ missingProviders,
37
49
  isDefault: profile === 'default',
38
- credentialStatus: hasUsableCredential(cwd, providerId, env) ? 'available' : 'missing-credentials',
50
+ credentialStatus: missingProviders.length === 0 ? 'available' : 'missing-credentials',
39
51
  })
40
52
  }
41
53
  // `default` always first; remaining profiles alphabetical so output is stable.
@@ -47,6 +59,19 @@ export function listModelProfiles(cwd: string, env: NodeJS.ProcessEnv = process.
47
59
  return out
48
60
  }
49
61
 
62
+ function uniqueProviders(refs: ReadonlyArray<KnownModelRef>): KnownProviderId[] {
63
+ const seen = new Set<KnownProviderId>()
64
+ const out: KnownProviderId[] = []
65
+ for (const r of refs) {
66
+ const p = providerForModelRef(r)
67
+ if (!seen.has(p)) {
68
+ seen.add(p)
69
+ out.push(p)
70
+ }
71
+ }
72
+ return out
73
+ }
74
+
50
75
  export function listAvailableModelRefs(): KnownModelRef[] {
51
76
  return listKnownModelRefs()
52
77
  }
@@ -158,14 +183,18 @@ export function removeProfile(cwd: string, profile: string): ModelMutationResult
158
183
 
159
184
  function writeProfile(cwd: string, profile: string, ref: KnownModelRef, message: string): ModelMutationResult {
160
185
  const existing = readModelsRaw(cwd)
161
- const next = existing === null ? { default: ref } : { ...existing, [profile]: ref }
186
+ const next: Record<string, string | string[]> = existing === null ? { default: ref } : { ...existing, [profile]: ref }
162
187
  if (existing === null && profile !== 'default') {
163
188
  next.default = ref
164
189
  }
165
190
  return writeModels(cwd, next, message)
166
191
  }
167
192
 
168
- function writeModels(cwd: string, models: Record<string, string>, commitMessage: string): ModelMutationResult {
193
+ function writeModels(
194
+ cwd: string,
195
+ models: Record<string, string | string[]>,
196
+ commitMessage: string,
197
+ ): ModelMutationResult {
169
198
  const path = join(cwd, CONFIG_FILE)
170
199
  let parsed: Record<string, unknown>
171
200
  try {
@@ -207,10 +236,15 @@ function writeModels(cwd: string, models: Record<string, string>, commitMessage:
207
236
  return { ok: true }
208
237
  }
209
238
 
210
- function readModelsRaw(cwd: string): Record<string, string> | null {
239
+ // Returns the raw `models` block from disk in its on-disk shape: each value
240
+ // is `string | string[]` (the user-facing schema). Writers preserve whichever
241
+ // shape was already present for profiles they don't touch — converting a
242
+ // hand-authored fallback chain back to a single string would silently drop
243
+ // the fallback.
244
+ function readModelsRaw(cwd: string): Record<string, string | string[]> | null {
211
245
  try {
212
246
  const raw = readFileSync(join(cwd, CONFIG_FILE), 'utf8')
213
- const parsed = JSON.parse(raw) as { models?: Record<string, string> }
247
+ const parsed = JSON.parse(raw) as { models?: Record<string, string | string[]> }
214
248
  return parsed.models ?? null
215
249
  } catch (error) {
216
250
  if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null
@@ -136,8 +136,8 @@ export function findModelsReferencingProvider(cwd: string, providerId: string):
136
136
  const models = readModelsOrNull(cwd)
137
137
  if (models === null) return []
138
138
  const out: string[] = []
139
- for (const [profile, ref] of Object.entries(models)) {
140
- if (refTargetsProvider(ref, providerId)) out.push(profile)
139
+ for (const [profile, refs] of Object.entries(models)) {
140
+ if (refs.some((r) => refTargetsProvider(r, providerId))) out.push(profile)
141
141
  }
142
142
  return out
143
143
  }
@@ -212,12 +212,16 @@ function readEnvKey(env: NodeJS.ProcessEnv, key: string): string | undefined {
212
212
  function buildProviderReferenceMap(models: Models | null): Map<string, string[]> {
213
213
  const out = new Map<string, string[]>()
214
214
  if (models === null) return out
215
- for (const [profile, ref] of Object.entries(models)) {
216
- const providerId = safeProviderForRef(ref)
217
- if (providerId === null) continue
218
- const existing = out.get(providerId) ?? []
219
- existing.push(profile)
220
- out.set(providerId, existing)
215
+ for (const [profile, refs] of Object.entries(models)) {
216
+ for (const ref of refs) {
217
+ const providerId = safeProviderForRef(ref)
218
+ if (providerId === null) continue
219
+ const existing = out.get(providerId) ?? []
220
+ if (!existing.includes(profile)) {
221
+ existing.push(profile)
222
+ out.set(providerId, existing)
223
+ }
224
+ }
221
225
  }
222
226
  return out
223
227
  }
@@ -87,7 +87,7 @@ export type StartOptions = {
87
87
  // Hostd's supervisor restart callback already runs inside the daemon process.
88
88
  // Reusing that daemon avoids a self-shutdown when disk source has drifted.
89
89
  reuseCurrentHostDaemon?: boolean
90
- ensureDeps?: (cwd: string) => Promise<EnsureDepsResult>
90
+ ensureDeps?: (cwd: string, opts?: { force?: boolean }) => Promise<EnsureDepsResult>
91
91
  // Test seam for the typeclaw-version auto-upgrade. Production callers omit
92
92
  // this and get the real autoUpgradeTypeclawDep (which reads the CLI's own
93
93
  // package.json). Tests inject a stub to simulate `bun -g update typeclaw`
@@ -149,7 +149,7 @@ export async function start({
149
149
  allocatePort = findFreePort,
150
150
  cliEntry,
151
151
  reuseCurrentHostDaemon = false,
152
- ensureDeps = (dir) => ensureDepsInstalled({ cwd: dir }),
152
+ ensureDeps = (dir, opts) => ensureDepsInstalled({ cwd: dir, ...opts }),
153
153
  autoUpgrade = (dir) => autoUpgradeTypeclawDep({ cwd: dir }),
154
154
  forceBunUpdate = runBunUpdate,
155
155
  readInstalledVersion = readInstalledTypeclawVersionFromAgent,
@@ -235,7 +235,14 @@ export async function start({
235
235
  // node_modules/ partially populated. The container then crashes with
236
236
  // `Cannot find package 'x'` because the agent folder is bind-mounted into
237
237
  // /agent and the container has no node_modules of its own.
238
- const deps = await ensureDeps(cwd)
238
+ //
239
+ // Force-reinstall ONLY when --build is set AND typeclaw is declared via
240
+ // a local link. Bun's file-dep cache otherwise serves stale source on
241
+ // subsequent installs (PR #243 dogfooding wasted three rebuilds + a
242
+ // manual version bump before this gate existed). Registry-spec users
243
+ // skip the force path because their install is already cache-correct.
244
+ const forceDepsReinstall = forceBuild && (await hasLocallyLinkedTypeclawDep(cwd))
245
+ const deps = await ensureDeps(cwd, { force: forceDepsReinstall })
239
246
  if (!deps.ok) {
240
247
  return { ok: false, reason: `dependency install failed: ${deps.reason}` }
241
248
  }
@@ -448,7 +455,24 @@ export async function planStart({
448
455
  // the start() preflight force-removes any lingering corpse before the next
449
456
  // launch — so the only state Docker ever sees in `docker ps -a` is either
450
457
  // a running container or one the user has not started again yet.
451
- const runArgs = ['run', '-d', '--name', containerName, '-p', `${publishHost}:${hostPort}:${CONTAINER_PORT}`]
458
+ //
459
+ // `--shm-size=2g` is mandatory for the bundled Chrome (agent-browser) to
460
+ // survive heavy pages. Docker's default /dev/shm is 64MB; Chrome uses
461
+ // shared memory for the renderer process and silently crashes mid-load
462
+ // on any site with a large DOM or non-trivial WebGL. The crash surfaces
463
+ // as a blank page or "target closed" with no clear cause — easy to
464
+ // misattribute to bot detection. 2g matches the Playwright/Puppeteer
465
+ // canonical recommendation and is a memory cap, not an allocation (only
466
+ // used pages count against the host).
467
+ const runArgs = [
468
+ 'run',
469
+ '-d',
470
+ '--name',
471
+ containerName,
472
+ '--shm-size=2g',
473
+ '-p',
474
+ `${publishHost}:${hostPort}:${CONTAINER_PORT}`,
475
+ ]
452
476
 
453
477
  // Network egress filter: when `typeclaw.json#network.blockInternal` is true,
454
478
  // grant the container CAP_NET_ADMIN at boot so the entrypoint shim can
@@ -710,6 +734,26 @@ async function detectDevSource(cwd: string): Promise<string | null> {
710
734
  }
711
735
  }
712
736
 
737
+ // True when the agent's package.json declares typeclaw via `file:` or
738
+ // `link:` — i.e. a developer is iterating on the typeclaw source via a
739
+ // locally-linked checkout. `bun install` keys its file-dep cache on
740
+ // name+version, so it treats a stale cached copy as a cache hit even
741
+ // after the source on disk has changed. `typeclaw start --build` uses
742
+ // this gate to force `bun install --force` only in dev: registry-spec
743
+ // users (`^X.Y.Z`, `~X.Y.Z`, exact pins) pay nothing because their
744
+ // install path is already cache-correct.
745
+ async function hasLocallyLinkedTypeclawDep(cwd: string): Promise<boolean> {
746
+ try {
747
+ const raw = await readFile(join(cwd, PACKAGE_FILE), 'utf8')
748
+ const pkg = JSON.parse(raw) as { dependencies?: Record<string, string> }
749
+ const spec = pkg.dependencies?.typeclaw
750
+ if (typeof spec !== 'string') return false
751
+ return spec.startsWith('file:') || spec.startsWith('link:')
752
+ } catch {
753
+ return false
754
+ }
755
+ }
756
+
713
757
  // A missing typeclaw.json is tolerated (e.g. test fixtures, freshly-cloned
714
758
  // folder mid-init). Anything else — malformed JSON, schema-invalid config,
715
759
  // invalid mount entry — must surface so the user sees they configured a mount
@@ -0,0 +1,136 @@
1
+ import { resolveHostPort, resolveTuiToken } from '@/container'
2
+ import type { ClientMessage, CronListEntryPayload, ServerMessage } from '@/shared'
3
+
4
+ export type CronListBridgeOptions = {
5
+ cwd: string
6
+ url?: string
7
+ timeoutMs?: number
8
+ }
9
+
10
+ export type CronListBridgeResult =
11
+ | { kind: 'ok'; jobs: CronListEntryPayload[]; nowMs: number }
12
+ | { kind: 'unreachable'; reason: string }
13
+ | { kind: 'timeout' }
14
+ | { kind: 'error'; reason: string }
15
+
16
+ const DEFAULT_TIMEOUT_MS = 15_000
17
+
18
+ export async function fetchCronList(opts: CronListBridgeOptions): Promise<CronListBridgeResult> {
19
+ const reach = await dial(opts)
20
+ if (reach.kind !== 'ok') return reach
21
+ const { ws, timeoutMs } = reach
22
+ const requestId = randomId()
23
+ try {
24
+ return await awaitReply(ws, timeoutMs, requestId)
25
+ } finally {
26
+ ws.close()
27
+ }
28
+ }
29
+
30
+ type DialResult = { kind: 'ok'; ws: WebSocket; timeoutMs: number } | { kind: 'unreachable'; reason: string }
31
+
32
+ async function dial(opts: CronListBridgeOptions): Promise<DialResult> {
33
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS
34
+ let url = opts.url
35
+ if (url === undefined) {
36
+ try {
37
+ const port = await resolveHostPort({ cwd: opts.cwd })
38
+ const token = await resolveTuiToken({ cwd: opts.cwd })
39
+ url = buildBridgeUrl(port, token)
40
+ } catch (err) {
41
+ return { kind: 'unreachable', reason: err instanceof Error ? err.message : String(err) }
42
+ }
43
+ }
44
+ const ws = new WebSocket(url)
45
+ const displayUrl = redactUrl(url)
46
+ try {
47
+ await new Promise<void>((resolve, reject) => {
48
+ // Mirrors the wedged-handshake timeout from src/doctor/plugin-bridge.ts.
49
+ // Bun's WebSocket has no built-in connect timeout; a stuck Upgrade
50
+ // never fires 'open' nor 'error', so `typeclaw cron list` would hang.
51
+ let timer: ReturnType<typeof setTimeout> | undefined
52
+ const cleanup = () => {
53
+ if (timer !== undefined) clearTimeout(timer)
54
+ ws.removeEventListener('open', onOpen)
55
+ ws.removeEventListener('error', onError)
56
+ ws.removeEventListener('close', onClose)
57
+ }
58
+ const onOpen = () => {
59
+ cleanup()
60
+ resolve()
61
+ }
62
+ const onError = (err: unknown) => {
63
+ cleanup()
64
+ reject(new Error(`failed to connect to ${displayUrl}: ${err instanceof Error ? err.message : String(err)}`))
65
+ }
66
+ const onClose = () => {
67
+ cleanup()
68
+ reject(new Error(`connection to ${displayUrl} closed before opening`))
69
+ }
70
+ timer = setTimeout(() => {
71
+ cleanup()
72
+ try {
73
+ ws.close()
74
+ } catch {}
75
+ reject(new Error(`timed out connecting to ${displayUrl} after ${timeoutMs}ms`))
76
+ }, timeoutMs)
77
+ ws.addEventListener('open', onOpen, { once: true })
78
+ ws.addEventListener('error', onError, { once: true })
79
+ ws.addEventListener('close', onClose, { once: true })
80
+ })
81
+ } catch (err) {
82
+ return { kind: 'unreachable', reason: err instanceof Error ? err.message : String(err) }
83
+ }
84
+ return { kind: 'ok', ws, timeoutMs }
85
+ }
86
+
87
+ async function awaitReply(ws: WebSocket, timeoutMs: number, requestId: string): Promise<CronListBridgeResult> {
88
+ const outgoing: ClientMessage = { type: 'cron_list', requestId }
89
+ ws.send(JSON.stringify(outgoing))
90
+ return new Promise((resolve) => {
91
+ const timer = setTimeout(() => {
92
+ ws.removeEventListener('message', onMessage)
93
+ resolve({ kind: 'timeout' })
94
+ }, timeoutMs)
95
+ const onMessage = (event: MessageEvent) => {
96
+ let msg: ServerMessage
97
+ try {
98
+ msg = JSON.parse(String(event.data)) as ServerMessage
99
+ } catch (err) {
100
+ clearTimeout(timer)
101
+ ws.removeEventListener('message', onMessage)
102
+ resolve({ kind: 'error', reason: err instanceof Error ? err.message : String(err) })
103
+ return
104
+ }
105
+ if (msg.type !== 'cron_list_result' || msg.requestId !== requestId) return
106
+ clearTimeout(timer)
107
+ ws.removeEventListener('message', onMessage)
108
+ if (msg.result.ok) {
109
+ resolve({ kind: 'ok', jobs: msg.result.jobs, nowMs: msg.result.nowMs })
110
+ } else {
111
+ resolve({ kind: 'error', reason: msg.result.reason })
112
+ }
113
+ }
114
+ ws.addEventListener('message', onMessage)
115
+ })
116
+ }
117
+
118
+ function buildBridgeUrl(port: number, token: string | null): string {
119
+ const url = new URL(`ws://127.0.0.1:${port}`)
120
+ if (token !== null) url.searchParams.set('token', token)
121
+ return url.toString()
122
+ }
123
+
124
+ function redactUrl(url: string): string {
125
+ try {
126
+ const parsed = new URL(url)
127
+ if (parsed.searchParams.has('token')) parsed.searchParams.set('token', '<redacted>')
128
+ return parsed.toString()
129
+ } catch {
130
+ return url
131
+ }
132
+ }
133
+
134
+ function randomId(): string {
135
+ return `cron-${crypto.randomUUID()}`
136
+ }