typeclaw 0.4.0 → 0.5.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 (56) hide show
  1. package/package.json +1 -1
  2. package/src/agent/auth.ts +4 -2
  3. package/src/agent/index.ts +16 -28
  4. package/src/agent/model-fallback.ts +127 -0
  5. package/src/agent/tools/curl-impersonate.ts +300 -0
  6. package/src/agent/tools/ddg.ts +13 -88
  7. package/src/agent/tools/webfetch/fetch.ts +105 -2
  8. package/src/agent/tools/webfetch/tool.ts +4 -0
  9. package/src/bundled-plugins/agent-browser/shim.ts +47 -0
  10. package/src/bundled-plugins/backup/subagents.ts +2 -0
  11. package/src/bundled-plugins/memory/README.md +49 -12
  12. package/src/bundled-plugins/memory/citation-superset.ts +63 -0
  13. package/src/bundled-plugins/memory/dreaming.ts +105 -17
  14. package/src/bundled-plugins/memory/index.ts +2 -2
  15. package/src/bundled-plugins/memory/memory-logger.ts +45 -26
  16. package/src/bundled-plugins/memory/strength.ts +127 -0
  17. package/src/bundled-plugins/memory/topics.ts +75 -0
  18. package/src/bundled-plugins/security/index.ts +87 -43
  19. package/src/bundled-plugins/security/permissions.ts +36 -0
  20. package/src/bundled-plugins/security/policies/git-exfil.ts +20 -0
  21. package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +12 -0
  22. package/src/bundled-plugins/security/policies/prompt-injection.ts +23 -3
  23. package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +7 -0
  24. package/src/bundled-plugins/security/policies/secret-exfil-read.ts +6 -0
  25. package/src/bundled-plugins/security/policies/session-search-secrets.ts +9 -0
  26. package/src/bundled-plugins/security/policies/ssrf.ts +6 -0
  27. package/src/bundled-plugins/security/policies/system-prompt-leak.ts +7 -0
  28. package/src/channels/adapters/github/index.ts +87 -3
  29. package/src/channels/router.ts +194 -28
  30. package/src/channels/types.ts +3 -1
  31. package/src/cli/channel.ts +2 -45
  32. package/src/cli/init.ts +148 -87
  33. package/src/cli/model.ts +12 -3
  34. package/src/cli/oauth-callbacks.ts +49 -0
  35. package/src/cli/provider.ts +3 -20
  36. package/src/cli/ui.ts +95 -0
  37. package/src/config/config.ts +59 -24
  38. package/src/config/models-mutation.ts +42 -8
  39. package/src/config/providers-mutation.ts +12 -8
  40. package/src/container/start.ts +18 -1
  41. package/src/cron/consumer.ts +129 -43
  42. package/src/init/dockerfile.ts +221 -3
  43. package/src/init/hatching.ts +2 -2
  44. package/src/init/index.ts +47 -3
  45. package/src/init/oauth-login.ts +17 -3
  46. package/src/permissions/builtins.ts +29 -7
  47. package/src/permissions/permissions.ts +24 -7
  48. package/src/plugin/define.ts +2 -0
  49. package/src/plugin/manager.ts +14 -0
  50. package/src/plugin/types.ts +6 -0
  51. package/src/run/index.ts +2 -1
  52. package/src/skills/typeclaw-memory/SKILL.md +25 -15
  53. package/src/skills/typeclaw-permissions/SKILL.md +35 -17
  54. package/src/tui/index.ts +35 -3
  55. package/src/usage/report.ts +15 -12
  56. package/typeclaw.schema.json +57 -25
@@ -103,6 +103,19 @@ const dockerfileObjectSchema = z.object({
103
103
  // edit. Opt-out with `cloudflared: false` to skip the ~35MB binary on
104
104
  // agents that don't use tunnels.
105
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),
106
119
  append: z.array(dockerfileLineSchema).default([]),
107
120
  })
108
121
 
@@ -278,32 +291,50 @@ const tunnelsArraySchema = z
278
291
  }
279
292
  })
280
293
 
281
- // `models` is a map from profile name to a single curated model ref. The
294
+ // `models` maps a profile name to one or more curated model refs. The
282
295
  // `default` profile is mandatory; every other profile is optional and falls
283
296
  // back to `default` at resolution time (see `resolveProfile`).
284
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
+ //
285
305
  // Profile names are open strings; the runtime recognizes a handful of
286
306
  // well-known names by convention (`default`, `fast`, `deep`, `vision`) but
287
- // any string is valid. Subagents may declare a static profile preference;
288
- // callers may override per-spawn. Unknown profile names resolve to `default`
289
- // 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.
290
309
  //
291
310
  // The pre-multi-model schema had a single `model: KnownModelRef` at the top
292
311
  // level. `migrateLegacyConfigShape` rewrites that to `models: { default: ... }`
293
312
  // on first load (and writes the result back to disk + commits via
294
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]))
295
330
  export const modelsSchema = z
296
- .record(z.string().min(1), z.enum(knownModelRefs))
331
+ .record(z.string().min(1), modelRefOrChainSchema)
297
332
  .refine((m) => 'default' in m, { message: 'models.default is required' })
298
333
 
299
- // Zod's `z.record(..., refine)` doesn't refine the inferred type — the inferred
300
- // shape is `Record<string, KnownModelRef>` where every access is `T | undefined`.
301
- // The runtime guarantee (the `refine` above) is that `default` is present, so
302
- // we narrow the type here. Every consumer (auth.ts, agent/index.ts,
303
- // resolveProfile) reads `models.default` on the hot path; without this
304
- // narrowing they all have to assert or `?? throw`, which is noise around an
305
- // invariant the schema already enforces.
306
- 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[] }
307
338
 
308
339
  export const configSchema = z
309
340
  .object({
@@ -311,8 +342,10 @@ export const configSchema = z
311
342
  port: z.number().int().min(1).max(65535).default(DEFAULT_PORT),
312
343
  // `default(() => ...)` ensures every parsed config has at least
313
344
  // `models.default`. Direct `.default({ default: ... })` would short-circuit
314
- // the refinement, so we lean on the lazy thunk form.
315
- 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>,
316
349
  // Defaults to `[]` so the field can be omitted from `typeclaw.json` (no
317
350
  // host paths exposed) without failing the whole config load. `typeclaw
318
351
  // init` omits this field so users don't see noise for the empty case.
@@ -345,26 +378,28 @@ export function resolveModel(ref: KnownModelRef): Model<'openai-completions'> |
345
378
  return KNOWN_PROVIDERS[providerId].models[modelId as never]
346
379
  }
347
380
 
348
- // Resolves a profile name (e.g. `fast`, `deep`, `vision`) to a concrete model
349
- // 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
350
383
  // arbitrary subagent-declared or user-overridden strings without crashing.
351
- // Returns the resolved ref plus whether it came from the requested profile or
352
- // from the `default` fallback, so the caller can warn once per session
353
- // 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`.
354
388
  export type ResolvedProfile = {
355
389
  ref: KnownModelRef
390
+ refs: KnownModelRef[]
356
391
  profile: string
357
392
  fellBackToDefault: boolean
358
393
  }
359
394
 
360
395
  export function resolveProfile(models: Models, name: string | undefined): ResolvedProfile {
361
396
  const requested = name ?? 'default'
362
- const ref = models[requested]
363
- if (ref !== undefined) {
364
- 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 }
365
400
  }
366
401
  const fallback = models.default
367
- return { ref: fallback, profile: 'default', fellBackToDefault: true }
402
+ return { ref: fallback[0]!, refs: fallback, profile: 'default', fellBackToDefault: true }
368
403
  }
369
404
 
370
405
  // Resolves a mount's `path` field to an absolute host path, mirroring shell
@@ -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
  }
@@ -455,7 +455,24 @@ export async function planStart({
455
455
  // the start() preflight force-removes any lingering corpse before the next
456
456
  // launch — so the only state Docker ever sees in `docker ps -a` is either
457
457
  // a running container or one the user has not started again yet.
458
- 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
+ ]
459
476
 
460
477
  // Network egress filter: when `typeclaw.json#network.blockInternal` is true,
461
478
  // grant the container CAP_NET_ADMIN at boot so the entrypoint shim can
@@ -1,6 +1,8 @@
1
1
  import type { AgentSession } from '@/agent'
2
- import { subscribeProviderErrors } from '@/agent/provider-error'
2
+ import { promptWithFallback, resolveFallbackChain } from '@/agent/model-fallback'
3
3
  import type { SessionOrigin } from '@/agent/session-origin'
4
+ import { getConfig } from '@/config'
5
+ import type { KnownModelRef } from '@/config/providers'
4
6
  import type { HookBus } from '@/plugin'
5
7
  import type { Stream, Unsubscribe } from '@/stream'
6
8
 
@@ -41,7 +43,12 @@ export type CronConsumerLogger = {
41
43
  export type CreateCronConsumerOptions = {
42
44
  stream: Stream
43
45
  cwd: string
44
- createSessionForCron: (job: PromptJob) => Promise<CronSession>
46
+ // The optional `refOverride` argument is consumed by the fallback loop: the
47
+ // consumer calls this factory once per ref in the profile's chain, pinning
48
+ // each attempt to the specified model. Factories that don't honor the
49
+ // override silently lose fallback semantics, so production wiring threads
50
+ // it through to `createSession({ refOverride })`.
51
+ createSessionForCron: (job: PromptJob, refOverride?: KnownModelRef) => Promise<CronSession>
45
52
  // Builds the `CronHandlerContext` for the job and awaits its `handler`.
46
53
  // Wired by `src/run/index.ts` to reuse `runPromptForCommand` /
47
54
  // `runExecForCommand` from the command runner so plugin cron handlers and
@@ -121,7 +128,7 @@ export function createCronConsumer({
121
128
 
122
129
  async function runPrompt(
123
130
  job: PromptJob,
124
- createSessionForCron: (job: PromptJob) => Promise<CronSession>,
131
+ createSessionForCron: (job: PromptJob, refOverride?: KnownModelRef) => Promise<CronSession>,
125
132
  stream: Stream,
126
133
  logger: CronConsumerLogger,
127
134
  ): Promise<void> {
@@ -148,52 +155,131 @@ async function runPrompt(
148
155
  })
149
156
  return
150
157
  }
151
- const session = await createSessionForCron(job)
152
- const unsubProviderErrors =
153
- session.session !== undefined
154
- ? subscribeProviderErrors(session.session, (err) => {
155
- logger.error(`[cron] ${job.id}: LLM call failed: ${err.message}`)
158
+ // Resolve the model fallback chain for the cron profile (cron jobs run
159
+ // under the `default` profile today). Single-ref configs produce a length-1
160
+ // chain; multi-ref configs (e.g. `"default": ["openai/...", "fireworks/..."]`)
161
+ // drive the retry-on-failure loop inside `runPromptOnce`.
162
+ const refs = resolveFallbackChain(getConfig().models, undefined)
163
+ await runPromptOnce(job, refs, createSessionForCron, logger)
164
+ }
165
+
166
+ async function runPromptOnce(
167
+ job: PromptJob,
168
+ refs: KnownModelRef[],
169
+ createSessionForCron: (job: PromptJob, refOverride?: KnownModelRef) => Promise<CronSession>,
170
+ logger: CronConsumerLogger,
171
+ ): Promise<void> {
172
+ // Per-attempt lifecycle: every session we create gets full
173
+ // turn-start → turn-end → session-end → dispose bracketing, regardless of
174
+ // whether the helper chose it as the final session or disposed it as a
175
+ // failed earlier attempt. Without per-attempt session.end, plugin state
176
+ // keyed by sessionId (security plugin's remote-taint map, memory plugin's
177
+ // debounce timer) would orphan for every failed attempt. We track the
178
+ // last session separately so we can fire session.idle exactly once on
179
+ // success (matching pre-fallback cron behavior — see the pre-fallback
180
+ // try/finally structure: idle inside the prompt try-block, end in the
181
+ // outer finally).
182
+ let lastSession: CronSession | null = null
183
+ const result = await promptWithFallback({
184
+ refs,
185
+ text: job.prompt,
186
+ createSessionForRef: async (ref) => {
187
+ const created = await createSessionForCron(job, ref)
188
+ lastSession = created
189
+ const turnEvent =
190
+ created.hooks && created.sessionId !== undefined && created.agentDir !== undefined
191
+ ? {
192
+ sessionId: created.sessionId,
193
+ agentDir: created.agentDir,
194
+ ...(created.origin !== undefined ? { origin: created.origin } : {}),
195
+ }
196
+ : undefined
197
+ if (created.hooks && turnEvent !== undefined) {
198
+ await created.hooks.runSessionTurnStart(turnEvent)
199
+ }
200
+ // Bridge the CronSession wrapper into the AgentSession surface the
201
+ // fallback helper expects:
202
+ // prompt → CronSession.prompt (wrapper that calls AgentSession.prompt
203
+ // in production, or a hand-rolled test fake)
204
+ // subscribe → CronSession.session.subscribe when an underlying agent
205
+ // session is supplied, else a no-op (soft-error detection
206
+ // degrades to "off" in that mode; only hard throws drive
207
+ // fallback). Test fakes that omit `.session` lose
208
+ // soft-error fallback — production code always provides it.
209
+ // .bind(created.session) is load-bearing: AgentSession.subscribe is a
210
+ // regular method that reads `this._eventListeners`. Destructuring drops
211
+ // the receiver.
212
+ const sessionForHelper: AgentSession = {
213
+ prompt: (text: string) => created.prompt(text),
214
+ subscribe: created.session?.subscribe.bind(created.session) ?? (() => () => {}),
215
+ } as unknown as AgentSession
216
+ return {
217
+ session: sessionForHelper,
218
+ // Per-attempt teardown. Fires turn.end and session.end for every
219
+ // session created (success or failure), then disposes the underlying
220
+ // resources. Hooks that throw are logged but don't prevent disposal.
221
+ dispose: async () => {
222
+ if (created.hooks && turnEvent !== undefined) {
223
+ try {
224
+ await created.hooks.runSessionTurnEnd(turnEvent)
225
+ } catch (e) {
226
+ logger.warn(`[cron] ${job.id}: turn-end hook threw: ${describe(e)}`)
227
+ }
228
+ }
229
+ if (created.hooks && created.sessionId !== undefined) {
230
+ try {
231
+ await created.hooks.runSessionEnd({
232
+ sessionId: created.sessionId,
233
+ ...(created.origin !== undefined ? { origin: created.origin } : {}),
234
+ })
235
+ } catch (e) {
236
+ logger.warn(`[cron] ${job.id}: session-end hook threw: ${describe(e)}`)
237
+ }
238
+ }
239
+ created.dispose?.()
240
+ },
241
+ }
242
+ },
243
+ onAttemptFailed: (attempt) => {
244
+ logger.warn(
245
+ `[cron] ${job.id}: ${attempt.outcome} failure on ${attempt.ref}: ${attempt.errorMessage ?? 'unknown'}; falling back`,
246
+ )
247
+ },
248
+ })
249
+
250
+ if (!result.success) {
251
+ logger.error(
252
+ `[cron] ${job.id}: all ${result.attempts.length} model(s) failed; last error: ${result.lastError?.message ?? 'unknown'}`,
253
+ )
254
+ }
255
+
256
+ // session.idle fires once, only on success, and only against the session
257
+ // that handled the turn. Then dispose the successful session (the helper
258
+ // returns the session+dispose so we can run post-prompt hooks against a
259
+ // live session before tearing it down). Failed-chain disposal is already
260
+ // handled by the helper's per-attempt dispose calls.
261
+ if (result.success && lastSession !== null) {
262
+ const finalSession: CronSession = lastSession
263
+ if (finalSession.hooks && finalSession.sessionId !== undefined) {
264
+ try {
265
+ await finalSession.hooks.runSessionIdle({
266
+ sessionId: finalSession.sessionId,
267
+ parentTranscriptPath: finalSession.getTranscriptPath?.(),
268
+ idleMs: 0,
269
+ ...(finalSession.origin !== undefined ? { origin: finalSession.origin } : {}),
156
270
  })
157
- : null
158
- const turnEvent =
159
- session.hooks && session.sessionId !== undefined && session.agentDir !== undefined
160
- ? {
161
- sessionId: session.sessionId,
162
- agentDir: session.agentDir,
163
- ...(session.origin !== undefined ? { origin: session.origin } : {}),
164
- }
165
- : undefined
166
- try {
167
- if (session.hooks && turnEvent !== undefined) {
168
- await session.hooks.runSessionTurnStart(turnEvent)
169
- }
170
- try {
171
- await session.prompt(job.prompt)
172
- } finally {
173
- if (session.hooks && turnEvent !== undefined) {
174
- await session.hooks.runSessionTurnEnd(turnEvent)
271
+ } catch (e) {
272
+ logger.warn(`[cron] ${job.id}: session-idle hook threw: ${describe(e)}`)
175
273
  }
176
274
  }
177
- if (session.hooks && session.sessionId !== undefined) {
178
- await session.hooks.runSessionIdle({
179
- sessionId: session.sessionId,
180
- parentTranscriptPath: session.getTranscriptPath?.(),
181
- idleMs: 0,
182
- ...(session.origin !== undefined ? { origin: session.origin } : {}),
183
- })
184
- }
185
- } finally {
186
- unsubProviderErrors?.()
187
- if (session.hooks && session.sessionId !== undefined) {
188
- await session.hooks.runSessionEnd({
189
- sessionId: session.sessionId,
190
- ...(session.origin !== undefined ? { origin: session.origin } : {}),
191
- })
192
- }
193
- session.dispose?.()
275
+ await result.dispose()
194
276
  }
195
277
  }
196
278
 
279
+ function describe(err: unknown): string {
280
+ return err instanceof Error ? err.message : String(err)
281
+ }
282
+
197
283
  async function runExec(job: ExecJob, cwd: string): Promise<void> {
198
284
  const [cmd, ...args] = job.command
199
285
  if (!cmd) throw new Error(`exec job ${job.id}: empty command`)