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.
- package/README.md +20 -15
- package/auth.schema.json +113 -0
- package/package.json +1 -1
- package/secrets.schema.json +113 -0
- 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/session-meta.ts +1 -1
- package/src/agent/session-origin.ts +3 -2
- 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 +88 -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/auth-app.ts +120 -0
- package/src/channels/adapters/github/auth-pat.ts +50 -0
- package/src/channels/adapters/github/auth.ts +33 -0
- package/src/channels/adapters/github/channel-resolver.ts +30 -0
- package/src/channels/adapters/github/dedup.ts +26 -0
- package/src/channels/adapters/github/event-allowlist.ts +8 -0
- package/src/channels/adapters/github/fetch-attachment.ts +5 -0
- package/src/channels/adapters/github/history.ts +63 -0
- package/src/channels/adapters/github/inbound.ts +286 -0
- package/src/channels/adapters/github/index.ts +370 -0
- package/src/channels/adapters/github/managed-path.ts +54 -0
- package/src/channels/adapters/github/membership.ts +35 -0
- package/src/channels/adapters/github/outbound.ts +145 -0
- package/src/channels/adapters/github/webhook-register.ts +349 -0
- package/src/channels/manager.ts +94 -9
- package/src/channels/router.ts +194 -28
- package/src/channels/schema.ts +31 -1
- package/src/channels/tunnel-bridge.ts +51 -0
- package/src/channels/types.ts +3 -1
- package/src/cli/builtins.ts +28 -0
- package/src/cli/channel.ts +511 -25
- package/src/cli/container-command-client.ts +244 -0
- package/src/cli/cron.ts +173 -0
- package/src/cli/host-command-runner.ts +150 -0
- package/src/cli/index.ts +42 -1
- package/src/cli/init.ts +400 -67
- package/src/cli/model.ts +14 -4
- package/src/cli/oauth-callbacks.ts +49 -0
- package/src/cli/plugin-command-help.ts +49 -0
- package/src/cli/plugin-commands-dispatch.ts +112 -0
- package/src/cli/plugin-commands.ts +118 -0
- package/src/cli/provider.ts +3 -20
- package/src/cli/tui.ts +10 -2
- package/src/cli/tunnel.ts +533 -0
- package/src/cli/ui.ts +8 -3
- package/src/config/config.ts +134 -24
- package/src/config/models-mutation.ts +42 -8
- package/src/config/providers-mutation.ts +12 -8
- package/src/container/start.ts +48 -4
- package/src/cron/bridge.ts +136 -0
- package/src/cron/consumer.ts +174 -48
- package/src/cron/index.ts +19 -2
- package/src/cron/list.ts +105 -0
- package/src/cron/scheduler.ts +12 -3
- package/src/cron/schema.ts +11 -3
- package/src/doctor/checks.ts +0 -50
- package/src/init/dockerfile.ts +165 -13
- package/src/init/ensure-deps.ts +15 -4
- package/src/init/github-webhook-install.ts +109 -0
- package/src/init/hatching.ts +2 -2
- package/src/init/index.ts +519 -12
- package/src/init/oauth-login.ts +17 -3
- package/src/init/run-bun-install.ts +17 -3
- package/src/init/run-owner-claim.ts +11 -2
- package/src/permissions/builtins.ts +29 -2
- package/src/permissions/match-rule.ts +24 -2
- package/src/permissions/permissions.ts +24 -7
- package/src/permissions/resolve.ts +1 -0
- package/src/plugin/define.ts +44 -1
- package/src/plugin/index.ts +18 -3
- package/src/plugin/manager.ts +16 -0
- package/src/plugin/registry.ts +85 -3
- package/src/plugin/types.ts +144 -1
- package/src/plugin/zod-introspect.ts +100 -0
- package/src/role-claim/match-rule.ts +2 -1
- package/src/run/index.ts +112 -4
- package/src/secrets/index.ts +1 -1
- package/src/secrets/schema.ts +21 -0
- package/src/server/command-runner.ts +476 -0
- package/src/server/index.ts +388 -5
- package/src/shared/index.ts +8 -0
- package/src/shared/protocol.ts +80 -1
- package/src/skills/typeclaw-channel-github/SKILL.md +24 -0
- package/src/skills/typeclaw-config/SKILL.md +27 -26
- package/src/skills/typeclaw-cron/SKILL.md +234 -3
- package/src/skills/typeclaw-memory/SKILL.md +25 -15
- package/src/skills/typeclaw-monorepo/SKILL.md +2 -2
- package/src/skills/typeclaw-permissions/SKILL.md +35 -16
- package/src/skills/typeclaw-plugins/SKILL.md +251 -5
- package/src/skills/typeclaw-tunnels/SKILL.md +111 -0
- package/src/test-helpers/wait-for.ts +50 -0
- package/src/tui/index.ts +70 -7
- package/src/tunnels/__fixtures__/cloudflared-quick-stderr.txt +11 -0
- package/src/tunnels/events.ts +14 -0
- package/src/tunnels/index.ts +12 -0
- package/src/tunnels/log-ring.ts +54 -0
- package/src/tunnels/manager.ts +139 -0
- package/src/tunnels/providers/cloudflare-quick.ts +189 -0
- package/src/tunnels/providers/external.ts +53 -0
- package/src/tunnels/quick-url-parser.ts +5 -0
- package/src/tunnels/types.ts +43 -0
- package/src/usage/report.ts +15 -12
- package/typeclaw.schema.json +311 -26
package/src/cron/consumer.ts
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
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
|
|
|
7
|
-
import type { CronJob, ExecJob, PromptJob } from './schema'
|
|
9
|
+
import type { CronJob, ExecJob, HandlerJob, PromptJob } from './schema'
|
|
10
|
+
|
|
11
|
+
export type CronHandlerInvoker = (job: HandlerJob) => Promise<void>
|
|
8
12
|
|
|
9
13
|
// `hooks`, `sessionId`, `agentDir`, and `getTranscriptPath` are optional so
|
|
10
14
|
// test fakes can stay one-liners. When present, the consumer fires
|
|
@@ -39,7 +43,19 @@ export type CronConsumerLogger = {
|
|
|
39
43
|
export type CreateCronConsumerOptions = {
|
|
40
44
|
stream: Stream
|
|
41
45
|
cwd: string
|
|
42
|
-
|
|
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>
|
|
52
|
+
// Builds the `CronHandlerContext` for the job and awaits its `handler`.
|
|
53
|
+
// Wired by `src/run/index.ts` to reuse `runPromptForCommand` /
|
|
54
|
+
// `runExecForCommand` from the command runner so plugin cron handlers and
|
|
55
|
+
// container plugin commands share one implementation of `ctx.prompt` /
|
|
56
|
+
// `ctx.exec`. Optional so unit-test fakes that never schedule handler jobs
|
|
57
|
+
// stay one-liners.
|
|
58
|
+
invokeHandler?: CronHandlerInvoker
|
|
43
59
|
logger?: CronConsumerLogger
|
|
44
60
|
}
|
|
45
61
|
|
|
@@ -59,6 +75,7 @@ export function createCronConsumer({
|
|
|
59
75
|
stream,
|
|
60
76
|
cwd,
|
|
61
77
|
createSessionForCron,
|
|
78
|
+
invokeHandler,
|
|
62
79
|
logger = consoleLogger,
|
|
63
80
|
}: CreateCronConsumerOptions): CronConsumer {
|
|
64
81
|
const inFlight = new Set<string>()
|
|
@@ -81,8 +98,15 @@ export function createCronConsumer({
|
|
|
81
98
|
try {
|
|
82
99
|
if (job.kind === 'prompt') {
|
|
83
100
|
await runPrompt(job, createSessionForCron, stream, logger)
|
|
84
|
-
} else {
|
|
101
|
+
} else if (job.kind === 'exec') {
|
|
85
102
|
await runExec(job, cwd)
|
|
103
|
+
} else {
|
|
104
|
+
if (invokeHandler === undefined) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
`handler job dispatched but no invokeHandler wired into the consumer (likely a misconfigured test or boot path)`,
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
await invokeHandler(job)
|
|
86
110
|
}
|
|
87
111
|
} catch (err) {
|
|
88
112
|
const message = err instanceof Error ? err.message : String(err)
|
|
@@ -104,7 +128,7 @@ export function createCronConsumer({
|
|
|
104
128
|
|
|
105
129
|
async function runPrompt(
|
|
106
130
|
job: PromptJob,
|
|
107
|
-
createSessionForCron: (job: PromptJob) => Promise<CronSession>,
|
|
131
|
+
createSessionForCron: (job: PromptJob, refOverride?: KnownModelRef) => Promise<CronSession>,
|
|
108
132
|
stream: Stream,
|
|
109
133
|
logger: CronConsumerLogger,
|
|
110
134
|
): Promise<void> {
|
|
@@ -131,56 +155,157 @@ async function runPrompt(
|
|
|
131
155
|
})
|
|
132
156
|
return
|
|
133
157
|
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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 } : {}),
|
|
139
270
|
})
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
session.hooks && session.sessionId !== undefined && session.agentDir !== undefined
|
|
143
|
-
? {
|
|
144
|
-
sessionId: session.sessionId,
|
|
145
|
-
agentDir: session.agentDir,
|
|
146
|
-
...(session.origin !== undefined ? { origin: session.origin } : {}),
|
|
147
|
-
}
|
|
148
|
-
: undefined
|
|
149
|
-
try {
|
|
150
|
-
if (session.hooks && turnEvent !== undefined) {
|
|
151
|
-
await session.hooks.runSessionTurnStart(turnEvent)
|
|
152
|
-
}
|
|
153
|
-
try {
|
|
154
|
-
await session.prompt(job.prompt)
|
|
155
|
-
} finally {
|
|
156
|
-
if (session.hooks && turnEvent !== undefined) {
|
|
157
|
-
await session.hooks.runSessionTurnEnd(turnEvent)
|
|
271
|
+
} catch (e) {
|
|
272
|
+
logger.warn(`[cron] ${job.id}: session-idle hook threw: ${describe(e)}`)
|
|
158
273
|
}
|
|
159
274
|
}
|
|
160
|
-
|
|
161
|
-
await session.hooks.runSessionIdle({
|
|
162
|
-
sessionId: session.sessionId,
|
|
163
|
-
parentTranscriptPath: session.getTranscriptPath?.(),
|
|
164
|
-
idleMs: 0,
|
|
165
|
-
...(session.origin !== undefined ? { origin: session.origin } : {}),
|
|
166
|
-
})
|
|
167
|
-
}
|
|
168
|
-
} finally {
|
|
169
|
-
unsubProviderErrors?.()
|
|
170
|
-
if (session.hooks && session.sessionId !== undefined) {
|
|
171
|
-
await session.hooks.runSessionEnd({
|
|
172
|
-
sessionId: session.sessionId,
|
|
173
|
-
...(session.origin !== undefined ? { origin: session.origin } : {}),
|
|
174
|
-
})
|
|
175
|
-
}
|
|
176
|
-
session.dispose?.()
|
|
275
|
+
await result.dispose()
|
|
177
276
|
}
|
|
178
277
|
}
|
|
179
278
|
|
|
279
|
+
function describe(err: unknown): string {
|
|
280
|
+
return err instanceof Error ? err.message : String(err)
|
|
281
|
+
}
|
|
282
|
+
|
|
180
283
|
async function runExec(job: ExecJob, cwd: string): Promise<void> {
|
|
181
284
|
const [cmd, ...args] = job.command
|
|
182
285
|
if (!cmd) throw new Error(`exec job ${job.id}: empty command`)
|
|
183
|
-
|
|
286
|
+
// Inject TYPECLAW_PARENT_ORIGIN_JSON so a child that proxies into the
|
|
287
|
+
// agent (typically a `typeclaw <container-cmd>` invocation through the
|
|
288
|
+
// host CLI's container-command-client) can stamp its session's
|
|
289
|
+
// spawnedByOrigin with the cron job's provenance. Without this the
|
|
290
|
+
// proxy would default to a synthetic owner origin and silently elevate
|
|
291
|
+
// a guest- or member-scheduled cron job to owner.
|
|
292
|
+
const parentOrigin = {
|
|
293
|
+
kind: 'cron',
|
|
294
|
+
jobId: job.id,
|
|
295
|
+
jobKind: 'exec',
|
|
296
|
+
...(job.scheduledByRole !== undefined ? { scheduledByRole: job.scheduledByRole } : {}),
|
|
297
|
+
...(job.scheduledByOrigin !== undefined ? { scheduledByOrigin: job.scheduledByOrigin } : {}),
|
|
298
|
+
}
|
|
299
|
+
const proc = Bun.spawn({
|
|
300
|
+
cmd: [cmd, ...args],
|
|
301
|
+
cwd,
|
|
302
|
+
stdout: 'pipe',
|
|
303
|
+
stderr: 'pipe',
|
|
304
|
+
env: {
|
|
305
|
+
...process.env,
|
|
306
|
+
TYPECLAW_PARENT_ORIGIN_JSON: JSON.stringify(parentOrigin),
|
|
307
|
+
},
|
|
308
|
+
})
|
|
184
309
|
const code = await proc.exited
|
|
185
310
|
if (code !== 0) {
|
|
186
311
|
const stderr = await new Response(proc.stderr).text()
|
|
@@ -190,7 +315,8 @@ async function runExec(job: ExecJob, cwd: string): Promise<void> {
|
|
|
190
315
|
|
|
191
316
|
function isCronJob(value: unknown): value is CronJob {
|
|
192
317
|
if (typeof value !== 'object' || value === null) return false
|
|
193
|
-
const v = value as { id?: unknown; kind?: unknown }
|
|
318
|
+
const v = value as { id?: unknown; kind?: unknown; handler?: unknown }
|
|
194
319
|
if (typeof v.id !== 'string') return false
|
|
195
|
-
|
|
320
|
+
if (v.kind === 'prompt' || v.kind === 'exec') return true
|
|
321
|
+
return v.kind === 'handler' && typeof v.handler === 'function'
|
|
196
322
|
}
|
package/src/cron/index.ts
CHANGED
|
@@ -21,7 +21,15 @@ export {
|
|
|
21
21
|
type CronConsumerLogger,
|
|
22
22
|
type CronSession,
|
|
23
23
|
} from './consumer'
|
|
24
|
-
export {
|
|
24
|
+
export {
|
|
25
|
+
type ComputeNextFireResult,
|
|
26
|
+
computeNextFire,
|
|
27
|
+
createScheduler,
|
|
28
|
+
type JobDiff,
|
|
29
|
+
type Scheduler,
|
|
30
|
+
type SchedulerLogger,
|
|
31
|
+
} from './scheduler'
|
|
32
|
+
export { aggregateCronList, type CronListEntry, type CronListSource } from './list'
|
|
25
33
|
export {
|
|
26
34
|
buildCronMigrationCommitMessage,
|
|
27
35
|
cronFileSchema,
|
|
@@ -31,7 +39,9 @@ export {
|
|
|
31
39
|
type CronMigrationResult,
|
|
32
40
|
type CronMigrationStep,
|
|
33
41
|
type ExecJob,
|
|
42
|
+
type HandlerJob,
|
|
34
43
|
migrateLegacyCronShape,
|
|
44
|
+
type ParsedCronJob,
|
|
35
45
|
type PromptJob,
|
|
36
46
|
} from './schema'
|
|
37
47
|
|
|
@@ -41,6 +51,12 @@ export type LoadCronResult = { ok: true; file: CronFile | null } | { ok: false;
|
|
|
41
51
|
|
|
42
52
|
export type LoadCronOptions = {
|
|
43
53
|
subagents?: SubagentRegistry
|
|
54
|
+
// When true (the default), legacy-shape migrations are written back
|
|
55
|
+
// to cron.json on disk and committed by the system-commit helper.
|
|
56
|
+
// Read-only inspection callers must pass `false` so an unaware
|
|
57
|
+
// `typeclaw cron list` against a legacy file does not produce a
|
|
58
|
+
// commit on whatever branch the user happens to be on.
|
|
59
|
+
persistMigrations?: boolean
|
|
44
60
|
}
|
|
45
61
|
|
|
46
62
|
export async function loadCron(agentDir: string, options: LoadCronOptions = {}): Promise<LoadCronResult> {
|
|
@@ -62,7 +78,8 @@ export async function loadCron(agentDir: string, options: LoadCronOptions = {}):
|
|
|
62
78
|
}
|
|
63
79
|
|
|
64
80
|
const migrated = migrateLegacyCronShape(parsed)
|
|
65
|
-
|
|
81
|
+
const persistMigrations = options.persistMigrations ?? true
|
|
82
|
+
if (migrated.changed && persistMigrations) {
|
|
66
83
|
await persistMigratedCron(path, migrated.json, agentDir, migrated.applied)
|
|
67
84
|
}
|
|
68
85
|
|
package/src/cron/list.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { RegisteredCronJob } from '@/plugin'
|
|
2
|
+
|
|
3
|
+
import { computeNextFire } from './scheduler'
|
|
4
|
+
import type { CronJob } from './schema'
|
|
5
|
+
|
|
6
|
+
// `plugin` carries `localId` (the original key on `definePlugin({ cronJobs })`)
|
|
7
|
+
// so callers can render "memory.dreaming" rather than the synthetic
|
|
8
|
+
// `__plugin_memory_dreaming` global id the scheduler uses internally.
|
|
9
|
+
export type CronListSource = { kind: 'user' } | { kind: 'plugin'; pluginName: string; localId: string }
|
|
10
|
+
|
|
11
|
+
// Display-oriented snapshot of a CronJob, separated from CronJob itself
|
|
12
|
+
// so the WS wire shape stays stable as CronJob accretes runtime-only
|
|
13
|
+
// fields (scheduledByOrigin, future description, etc.).
|
|
14
|
+
export type CronListEntry = {
|
|
15
|
+
id: string
|
|
16
|
+
source: CronListSource
|
|
17
|
+
kind: 'prompt' | 'exec' | 'handler'
|
|
18
|
+
schedule: string
|
|
19
|
+
timezone: string | undefined
|
|
20
|
+
enabled: boolean
|
|
21
|
+
scheduledByRole: string | undefined
|
|
22
|
+
// null when cron-parser rejects `schedule` — keeps such rows visible
|
|
23
|
+
// in the list with the original error preserved in `scheduleError`,
|
|
24
|
+
// rather than dropping them silently as the scheduler would.
|
|
25
|
+
nextFireMs: number | null
|
|
26
|
+
scheduleError: string | undefined
|
|
27
|
+
prompt: string | undefined
|
|
28
|
+
subagent: string | undefined
|
|
29
|
+
command: readonly string[] | undefined
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type AggregateCronListOptions = {
|
|
33
|
+
userJobs: readonly CronJob[]
|
|
34
|
+
// Registered entries (not flat CronJob[]) so each row can be attributed
|
|
35
|
+
// to its plugin + localId without re-parsing the global id.
|
|
36
|
+
pluginJobs: readonly RegisteredCronJob[]
|
|
37
|
+
now: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function aggregateCronList(opts: AggregateCronListOptions): CronListEntry[] {
|
|
41
|
+
const entries: CronListEntry[] = []
|
|
42
|
+
for (const job of opts.userJobs) {
|
|
43
|
+
entries.push(toEntry(job, { kind: 'user' }, opts.now))
|
|
44
|
+
}
|
|
45
|
+
for (const reg of opts.pluginJobs) {
|
|
46
|
+
entries.push(toEntry(reg.job, { kind: 'plugin', pluginName: reg.pluginName, localId: reg.localId }, opts.now))
|
|
47
|
+
}
|
|
48
|
+
// Sort by next-fire time ascending so the soonest-firing job is at the
|
|
49
|
+
// top. Jobs with a null nextFireMs (parse errors) sort to the bottom
|
|
50
|
+
// so the human-readable list keeps the actionable rows first. Disabled
|
|
51
|
+
// jobs still get a nextFireMs computed — they appear in the list with
|
|
52
|
+
// an "(disabled)" badge but their position reflects when they WOULD
|
|
53
|
+
// have fired had they been enabled.
|
|
54
|
+
entries.sort(compareByNextFire)
|
|
55
|
+
return entries
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function toEntry(job: CronJob, source: CronListSource, now: number): CronListEntry {
|
|
59
|
+
const fire = computeNextFire(job, now)
|
|
60
|
+
const base = {
|
|
61
|
+
id: job.id,
|
|
62
|
+
source,
|
|
63
|
+
schedule: job.schedule,
|
|
64
|
+
timezone: job.timezone,
|
|
65
|
+
enabled: job.enabled,
|
|
66
|
+
scheduledByRole: job.scheduledByRole,
|
|
67
|
+
nextFireMs: fire.ok ? fire.nextFire : null,
|
|
68
|
+
scheduleError: fire.ok ? undefined : fire.reason,
|
|
69
|
+
} as const
|
|
70
|
+
if (job.kind === 'prompt') {
|
|
71
|
+
return {
|
|
72
|
+
...base,
|
|
73
|
+
kind: 'prompt',
|
|
74
|
+
prompt: job.prompt,
|
|
75
|
+
subagent: job.subagent,
|
|
76
|
+
command: undefined,
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (job.kind === 'exec') {
|
|
80
|
+
return {
|
|
81
|
+
...base,
|
|
82
|
+
kind: 'exec',
|
|
83
|
+
prompt: undefined,
|
|
84
|
+
subagent: undefined,
|
|
85
|
+
command: job.command,
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// Handler jobs carry a function reference, not a serializable payload.
|
|
89
|
+
// Surface the row so the list stays complete; leave action fields undefined.
|
|
90
|
+
return {
|
|
91
|
+
...base,
|
|
92
|
+
kind: 'handler',
|
|
93
|
+
prompt: undefined,
|
|
94
|
+
subagent: undefined,
|
|
95
|
+
command: undefined,
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function compareByNextFire(a: CronListEntry, b: CronListEntry): number {
|
|
100
|
+
if (a.nextFireMs === null && b.nextFireMs === null) return a.id.localeCompare(b.id)
|
|
101
|
+
if (a.nextFireMs === null) return 1
|
|
102
|
+
if (b.nextFireMs === null) return -1
|
|
103
|
+
if (a.nextFireMs !== b.nextFireMs) return a.nextFireMs - b.nextFireMs
|
|
104
|
+
return a.id.localeCompare(b.id)
|
|
105
|
+
}
|
package/src/cron/scheduler.ts
CHANGED
|
@@ -177,12 +177,21 @@ function jobFingerprint(job: CronJob): string {
|
|
|
177
177
|
|
|
178
178
|
function jobPayload(job: CronJob): unknown {
|
|
179
179
|
if (job.kind === 'prompt') return { prompt: job.prompt, subagent: job.subagent ?? null, payload: job.payload ?? null }
|
|
180
|
-
return job.command
|
|
180
|
+
if (job.kind === 'exec') return job.command
|
|
181
|
+
// Use the handler's source as the discriminator. A constant placeholder
|
|
182
|
+
// would make every handler fingerprint identically, so a plugin reload
|
|
183
|
+
// that replaces the handler with a new implementation would be classified
|
|
184
|
+
// as `unchanged` by `diff()` — the old function reference would keep
|
|
185
|
+
// firing forever. `Function.prototype.toString()` returns the function's
|
|
186
|
+
// declared source (deterministic per declaration site, changes when the
|
|
187
|
+
// plugin module is re-imported with edits), which is the cheapest stable
|
|
188
|
+
// discriminator without keeping a separate identity Map. JSON-safe.
|
|
189
|
+
return { handler: String(job.handler) }
|
|
181
190
|
}
|
|
182
191
|
|
|
183
|
-
type ComputeNextFireResult = { ok: true; nextFire: number } | { ok: false; reason: string }
|
|
192
|
+
export type ComputeNextFireResult = { ok: true; nextFire: number } | { ok: false; reason: string }
|
|
184
193
|
|
|
185
|
-
function computeNextFire(job: CronJob, now: number): ComputeNextFireResult {
|
|
194
|
+
export function computeNextFire(job: CronJob, now: number): ComputeNextFireResult {
|
|
186
195
|
try {
|
|
187
196
|
const expr = CronExpressionParser.parse(job.schedule, {
|
|
188
197
|
currentDate: new Date(now),
|
package/src/cron/schema.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { z } from 'zod'
|
|
|
3
3
|
|
|
4
4
|
import type { SubagentRegistry } from '@/agent/subagents'
|
|
5
5
|
import { validateSubagentPayload } from '@/agent/subagents'
|
|
6
|
+
import type { CronHandlerContext } from '@/plugin/types'
|
|
6
7
|
|
|
7
8
|
const idPattern = /^[a-zA-Z0-9_-]+$/
|
|
8
9
|
|
|
@@ -42,9 +43,16 @@ export const cronFileSchema = z.object({
|
|
|
42
43
|
jobs: z.array(cronJobSchema).default([]),
|
|
43
44
|
})
|
|
44
45
|
|
|
45
|
-
export type
|
|
46
|
-
export type PromptJob = Extract<
|
|
47
|
-
export type ExecJob = Extract<
|
|
46
|
+
export type ParsedCronJob = z.infer<typeof cronJobSchema>
|
|
47
|
+
export type PromptJob = Extract<ParsedCronJob, { kind: 'prompt' }>
|
|
48
|
+
export type ExecJob = Extract<ParsedCronJob, { kind: 'exec' }>
|
|
49
|
+
|
|
50
|
+
export type HandlerJob = z.infer<typeof baseJob> & {
|
|
51
|
+
kind: 'handler'
|
|
52
|
+
handler: (ctx: CronHandlerContext) => Promise<void>
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type CronJob = ParsedCronJob | HandlerJob
|
|
48
56
|
export type CronFile = z.infer<typeof cronFileSchema>
|
|
49
57
|
|
|
50
58
|
export type ParseCronResult = { ok: true; file: CronFile } | { ok: false; reason: string }
|
package/src/doctor/checks.ts
CHANGED
|
@@ -35,7 +35,6 @@ export function buildStaticChecks(opts: { dockerExec?: DockerExec } = {}): Docto
|
|
|
35
35
|
agentFolderNodeModules(),
|
|
36
36
|
agentFolderGitRepo(),
|
|
37
37
|
configValid(),
|
|
38
|
-
configBundledProfiles(),
|
|
39
38
|
hostdHomeWritable(),
|
|
40
39
|
hostdReachable(),
|
|
41
40
|
hostdRegistration(),
|
|
@@ -215,55 +214,6 @@ function configValid(): DoctorCheck {
|
|
|
215
214
|
}
|
|
216
215
|
}
|
|
217
216
|
|
|
218
|
-
// Warns (not errors) when a model profile that a bundled subagent prefers is
|
|
219
|
-
// absent from `models`. Bundled subagents fall back to `default` silently
|
|
220
|
-
// today, but the operator likely declared a `fast`/`deep`/`vision` model in
|
|
221
|
-
// the design discussion's tier scheme expecting the bundled subagents to
|
|
222
|
-
// pick them up. This check surfaces the gap once at `typeclaw doctor` time
|
|
223
|
-
// instead of leaving it buried in container logs (where the rate-limited
|
|
224
|
-
// fallback warning lives).
|
|
225
|
-
//
|
|
226
|
-
// We deliberately limit this to known bundled profiles (memory-logger=fast,
|
|
227
|
-
// dreaming=deep, multimodal-looker=vision). Plugin-contributed subagents
|
|
228
|
-
// would require loading the plugin registry — a heavyweight async path
|
|
229
|
-
// that doesn't belong in doctor's static check surface.
|
|
230
|
-
const BUNDLED_PROFILES: ReadonlyArray<{ profile: string; subagent: string }> = [
|
|
231
|
-
{ profile: 'fast', subagent: 'memory-logger' },
|
|
232
|
-
{ profile: 'deep', subagent: 'dreaming' },
|
|
233
|
-
{ profile: 'vision', subagent: 'multimodal-looker (via look_at tool)' },
|
|
234
|
-
]
|
|
235
|
-
|
|
236
|
-
function configBundledProfiles(): DoctorCheck {
|
|
237
|
-
return {
|
|
238
|
-
name: 'config.bundled-profiles',
|
|
239
|
-
category: 'config',
|
|
240
|
-
description: 'bundled subagent profiles (`fast`, `deep`, `vision`) declared in models',
|
|
241
|
-
applies: (ctx) => ctx.hasAgentFolder,
|
|
242
|
-
async run(ctx) {
|
|
243
|
-
const validation = validateConfig(ctx.cwd)
|
|
244
|
-
if (!validation.ok) {
|
|
245
|
-
return { status: 'ok', message: 'skipped (config.valid will report the underlying error)' }
|
|
246
|
-
}
|
|
247
|
-
const config = loadConfigSync(ctx.cwd)
|
|
248
|
-
const declared = new Set(Object.keys(config.models))
|
|
249
|
-
const missing = BUNDLED_PROFILES.filter((p) => !declared.has(p.profile))
|
|
250
|
-
if (missing.length === 0) {
|
|
251
|
-
return { status: 'ok', message: 'all bundled subagent profiles declared' }
|
|
252
|
-
}
|
|
253
|
-
return {
|
|
254
|
-
status: 'warning',
|
|
255
|
-
message: `${missing.length} bundled profile(s) missing; will fall back to \`default\``,
|
|
256
|
-
details: missing.map(
|
|
257
|
-
(m) => `${m.profile}: used by ${m.subagent}; declare \`models.${m.profile}\` in typeclaw.json to override`,
|
|
258
|
-
),
|
|
259
|
-
fix: {
|
|
260
|
-
description: 'Add the missing profile(s) under `models` in typeclaw.json. See the typeclaw-config skill.',
|
|
261
|
-
},
|
|
262
|
-
}
|
|
263
|
-
},
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
|
|
267
217
|
function hostdHomeWritable(): DoctorCheck {
|
|
268
218
|
return {
|
|
269
219
|
name: 'hostd.home-writable',
|