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
@@ -1,10 +1,14 @@
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
 
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
- 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>
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
- const session = await createSessionForCron(job)
135
- const unsubProviderErrors =
136
- session.session !== undefined
137
- ? subscribeProviderErrors(session.session, (err) => {
138
- 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 } : {}),
139
270
  })
140
- : null
141
- const turnEvent =
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
- if (session.hooks && session.sessionId !== undefined) {
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
- const proc = Bun.spawn({ cmd: [cmd, ...args], cwd, stdout: 'pipe', stderr: 'pipe' })
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
- return v.kind === 'prompt' || v.kind === 'exec'
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 { createScheduler, type JobDiff, type Scheduler, type SchedulerLogger } from './scheduler'
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
- if (migrated.changed) {
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
 
@@ -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
+ }
@@ -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),
@@ -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 CronJob = z.infer<typeof cronJobSchema>
46
- export type PromptJob = Extract<CronJob, { kind: 'prompt' }>
47
- export type ExecJob = Extract<CronJob, { kind: 'exec' }>
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 }
@@ -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',