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.
- package/package.json +1 -1
- package/src/agent/auth.ts +4 -2
- package/src/agent/index.ts +16 -28
- package/src/agent/model-fallback.ts +127 -0
- package/src/agent/tools/curl-impersonate.ts +300 -0
- package/src/agent/tools/ddg.ts +13 -88
- package/src/agent/tools/webfetch/fetch.ts +105 -2
- package/src/agent/tools/webfetch/tool.ts +4 -0
- package/src/bundled-plugins/agent-browser/shim.ts +47 -0
- package/src/bundled-plugins/backup/subagents.ts +2 -0
- package/src/bundled-plugins/memory/README.md +49 -12
- package/src/bundled-plugins/memory/citation-superset.ts +63 -0
- package/src/bundled-plugins/memory/dreaming.ts +105 -17
- package/src/bundled-plugins/memory/index.ts +2 -2
- package/src/bundled-plugins/memory/memory-logger.ts +45 -26
- package/src/bundled-plugins/memory/strength.ts +127 -0
- package/src/bundled-plugins/memory/topics.ts +75 -0
- package/src/bundled-plugins/security/index.ts +87 -43
- package/src/bundled-plugins/security/permissions.ts +36 -0
- package/src/bundled-plugins/security/policies/git-exfil.ts +20 -0
- package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +12 -0
- package/src/bundled-plugins/security/policies/prompt-injection.ts +23 -3
- package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +7 -0
- package/src/bundled-plugins/security/policies/secret-exfil-read.ts +6 -0
- package/src/bundled-plugins/security/policies/session-search-secrets.ts +9 -0
- package/src/bundled-plugins/security/policies/ssrf.ts +6 -0
- package/src/bundled-plugins/security/policies/system-prompt-leak.ts +7 -0
- package/src/channels/adapters/github/index.ts +87 -3
- package/src/channels/router.ts +194 -28
- package/src/channels/types.ts +3 -1
- package/src/cli/channel.ts +2 -45
- package/src/cli/init.ts +148 -87
- package/src/cli/model.ts +12 -3
- package/src/cli/oauth-callbacks.ts +49 -0
- package/src/cli/provider.ts +3 -20
- package/src/cli/ui.ts +95 -0
- package/src/config/config.ts +59 -24
- package/src/config/models-mutation.ts +42 -8
- package/src/config/providers-mutation.ts +12 -8
- package/src/container/start.ts +18 -1
- package/src/cron/consumer.ts +129 -43
- package/src/init/dockerfile.ts +221 -3
- package/src/init/hatching.ts +2 -2
- package/src/init/index.ts +47 -3
- package/src/init/oauth-login.ts +17 -3
- package/src/permissions/builtins.ts +29 -7
- package/src/permissions/permissions.ts +24 -7
- package/src/plugin/define.ts +2 -0
- package/src/plugin/manager.ts +14 -0
- package/src/plugin/types.ts +6 -0
- package/src/run/index.ts +2 -1
- package/src/skills/typeclaw-memory/SKILL.md +25 -15
- package/src/skills/typeclaw-permissions/SKILL.md +35 -17
- package/src/tui/index.ts +35 -3
- package/src/usage/report.ts +15 -12
- package/typeclaw.schema.json +57 -25
package/src/config/config.ts
CHANGED
|
@@ -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`
|
|
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.
|
|
288
|
-
//
|
|
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),
|
|
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
|
|
300
|
-
//
|
|
301
|
-
//
|
|
302
|
-
|
|
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
|
-
|
|
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
|
|
349
|
-
//
|
|
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
|
-
//
|
|
352
|
-
//
|
|
353
|
-
//
|
|
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
|
|
363
|
-
if (
|
|
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,
|
|
32
|
-
const
|
|
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:
|
|
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(
|
|
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
|
-
|
|
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,
|
|
140
|
-
if (refTargetsProvider(
|
|
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,
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
}
|
package/src/container/start.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
package/src/cron/consumer.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { AgentSession } from '@/agent'
|
|
2
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
158
|
-
|
|
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
|
-
|
|
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`)
|