typeclaw 0.3.1 → 0.4.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 (89) 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/session-meta.ts +1 -1
  6. package/src/agent/session-origin.ts +3 -2
  7. package/src/bundled-plugins/security/index.ts +3 -2
  8. package/src/channels/adapters/github/auth-app.ts +120 -0
  9. package/src/channels/adapters/github/auth-pat.ts +50 -0
  10. package/src/channels/adapters/github/auth.ts +33 -0
  11. package/src/channels/adapters/github/channel-resolver.ts +30 -0
  12. package/src/channels/adapters/github/dedup.ts +26 -0
  13. package/src/channels/adapters/github/event-allowlist.ts +8 -0
  14. package/src/channels/adapters/github/fetch-attachment.ts +5 -0
  15. package/src/channels/adapters/github/history.ts +63 -0
  16. package/src/channels/adapters/github/inbound.ts +286 -0
  17. package/src/channels/adapters/github/index.ts +286 -0
  18. package/src/channels/adapters/github/managed-path.ts +54 -0
  19. package/src/channels/adapters/github/membership.ts +35 -0
  20. package/src/channels/adapters/github/outbound.ts +145 -0
  21. package/src/channels/adapters/github/webhook-register.ts +349 -0
  22. package/src/channels/manager.ts +94 -9
  23. package/src/channels/schema.ts +31 -1
  24. package/src/channels/tunnel-bridge.ts +51 -0
  25. package/src/cli/builtins.ts +28 -0
  26. package/src/cli/channel.ts +511 -25
  27. package/src/cli/container-command-client.ts +244 -0
  28. package/src/cli/cron.ts +173 -0
  29. package/src/cli/host-command-runner.ts +150 -0
  30. package/src/cli/index.ts +42 -1
  31. package/src/cli/init.ts +256 -27
  32. package/src/cli/model.ts +4 -2
  33. package/src/cli/plugin-command-help.ts +49 -0
  34. package/src/cli/plugin-commands-dispatch.ts +112 -0
  35. package/src/cli/plugin-commands.ts +118 -0
  36. package/src/cli/tui.ts +10 -2
  37. package/src/cli/tunnel.ts +533 -0
  38. package/src/cli/ui.ts +8 -3
  39. package/src/config/config.ts +75 -0
  40. package/src/container/start.ts +30 -3
  41. package/src/cron/bridge.ts +136 -0
  42. package/src/cron/consumer.ts +45 -5
  43. package/src/cron/index.ts +19 -2
  44. package/src/cron/list.ts +105 -0
  45. package/src/cron/scheduler.ts +12 -3
  46. package/src/cron/schema.ts +11 -3
  47. package/src/doctor/checks.ts +0 -50
  48. package/src/init/dockerfile.ts +59 -13
  49. package/src/init/ensure-deps.ts +15 -4
  50. package/src/init/github-webhook-install.ts +109 -0
  51. package/src/init/index.ts +505 -9
  52. package/src/init/run-bun-install.ts +17 -3
  53. package/src/init/run-owner-claim.ts +11 -2
  54. package/src/permissions/builtins.ts +6 -1
  55. package/src/permissions/match-rule.ts +24 -2
  56. package/src/permissions/resolve.ts +1 -0
  57. package/src/plugin/define.ts +42 -1
  58. package/src/plugin/index.ts +18 -3
  59. package/src/plugin/manager.ts +2 -0
  60. package/src/plugin/registry.ts +85 -3
  61. package/src/plugin/types.ts +138 -1
  62. package/src/plugin/zod-introspect.ts +100 -0
  63. package/src/role-claim/match-rule.ts +2 -1
  64. package/src/run/index.ts +110 -3
  65. package/src/secrets/index.ts +1 -1
  66. package/src/secrets/schema.ts +21 -0
  67. package/src/server/command-runner.ts +476 -0
  68. package/src/server/index.ts +388 -5
  69. package/src/shared/index.ts +8 -0
  70. package/src/shared/protocol.ts +80 -1
  71. package/src/skills/typeclaw-channel-github/SKILL.md +24 -0
  72. package/src/skills/typeclaw-config/SKILL.md +27 -26
  73. package/src/skills/typeclaw-cron/SKILL.md +234 -3
  74. package/src/skills/typeclaw-monorepo/SKILL.md +2 -2
  75. package/src/skills/typeclaw-permissions/SKILL.md +5 -4
  76. package/src/skills/typeclaw-plugins/SKILL.md +251 -5
  77. package/src/skills/typeclaw-tunnels/SKILL.md +111 -0
  78. package/src/test-helpers/wait-for.ts +50 -0
  79. package/src/tui/index.ts +35 -4
  80. package/src/tunnels/__fixtures__/cloudflared-quick-stderr.txt +11 -0
  81. package/src/tunnels/events.ts +14 -0
  82. package/src/tunnels/index.ts +12 -0
  83. package/src/tunnels/log-ring.ts +54 -0
  84. package/src/tunnels/manager.ts +139 -0
  85. package/src/tunnels/providers/cloudflare-quick.ts +189 -0
  86. package/src/tunnels/providers/external.ts +53 -0
  87. package/src/tunnels/quick-url-parser.ts +5 -0
  88. package/src/tunnels/types.ts +43 -0
  89. package/typeclaw.schema.json +254 -1
@@ -86,6 +86,23 @@ const dockerfileObjectSchema = z.object({
86
86
  gh: dockerfileFeatureSchema.default(true),
87
87
  python: z.boolean().default(true),
88
88
  tmux: dockerfileFeatureSchema.default(true),
89
+ // `fonts-noto-cjk` is a ~56MB metapackage that makes Chromium render
90
+ // Korean/Japanese/Chinese glyphs correctly in screenshots, `page.pdf()`,
91
+ // and any other raster output the agent-browser plugin produces. Default
92
+ // `true` because the alternative — silent tofu boxes (□□□) in CJK
93
+ // screenshots — is a confusing failure mode that an agent cannot self-
94
+ // diagnose from a screenshot it took itself. Opt-out with `cjkFonts:
95
+ // false` to save the ~56MB on agents that never touch CJK content.
96
+ // Boolean-only (no version pin) because the package is a metapackage
97
+ // tracking the upstream Noto release and version pinning offers no
98
+ // practical value.
99
+ cjkFonts: z.boolean().default(true),
100
+ // Opt into the cloudflared layer for `cloudflare-quick` tunnels. Default
101
+ // `true` so `tunnel add` / `channel add github` with the default Cloudflare
102
+ // Quick provider works on the next `start` without a separate Dockerfile
103
+ // edit. Opt-out with `cloudflared: false` to skip the ~35MB binary on
104
+ // agents that don't use tunnels.
105
+ cloudflared: z.boolean().default(true),
89
106
  append: z.array(dockerfileLineSchema).default([]),
90
107
  })
91
108
 
@@ -205,6 +222,62 @@ export const networkSchema = z
205
222
 
206
223
  export type NetworkConfig = z.infer<typeof networkSchema>
207
224
 
225
+ // Reverse-proxy tunnels expose a container-private port to the public internet
226
+ // via a managed subprocess (cloudflared) or a user-supplied external URL.
227
+ // See AGENTS.md `## Tunnels`. PR 2 ships `cloudflare-quick`; `cloudflare-named`
228
+ // remains deferred to PR 3. Keeping the enum scoped to what's implemented means
229
+ // validateConfig() rejects unsupported providers at `typeclaw start` time,
230
+ // before the container is torn down and rebuilt. `restart-required` because
231
+ // the tunnel manager reads this list once at boot.
232
+ const tunnelForSchema = z.discriminatedUnion('kind', [
233
+ z.object({ kind: z.literal('channel'), name: z.string().trim().min(1) }),
234
+ z.object({ kind: z.literal('manual') }),
235
+ ])
236
+
237
+ const tunnelEntrySchema = z
238
+ .object({
239
+ name: z
240
+ .string()
241
+ .min(1)
242
+ .regex(/^[a-z0-9][a-z0-9-_]*$/, {
243
+ message: 'tunnel name must match /^[a-z0-9][a-z0-9-_]*$/ (lowercase, digits, dashes, underscores)',
244
+ }),
245
+ provider: z.enum(['external', 'cloudflare-quick']),
246
+ for: tunnelForSchema,
247
+ externalUrl: z
248
+ .string()
249
+ .url()
250
+ .refine((u) => u.startsWith('https://'), { message: 'externalUrl must use https://' })
251
+ .optional(),
252
+ upstreamPort: z.number().int().min(1).max(65535).optional(),
253
+ })
254
+ .refine((v) => v.provider !== 'external' || (v.externalUrl !== undefined && v.externalUrl.trim() !== ''), {
255
+ message: "tunnels[].externalUrl is required when provider is 'external'",
256
+ })
257
+ .refine((v) => v.for.kind !== 'manual' || v.upstreamPort !== undefined, {
258
+ message: "tunnels[].upstreamPort is required when for.kind is 'manual'",
259
+ })
260
+
261
+ const tunnelsArraySchema = z
262
+ .array(tunnelEntrySchema)
263
+ .default([])
264
+ .superRefine((entries, ctx) => {
265
+ const seen = new Map<string, number>()
266
+ for (let i = 0; i < entries.length; i++) {
267
+ const name = entries[i]!.name
268
+ const prev = seen.get(name)
269
+ if (prev !== undefined) {
270
+ ctx.addIssue({
271
+ code: 'custom',
272
+ path: [i, 'name'],
273
+ message: `tunnels[${i}].name duplicates tunnels[${prev}].name ('${name}')`,
274
+ })
275
+ } else {
276
+ seen.set(name, i)
277
+ }
278
+ }
279
+ })
280
+
208
281
  // `models` is a map from profile name to a single curated model ref. The
209
282
  // `default` profile is mandatory; every other profile is optional and falls
210
283
  // back to `default` at resolution time (see `resolveProfile`).
@@ -258,6 +331,7 @@ export const configSchema = z
258
331
  docker: dockerSchema,
259
332
  git: gitSchema,
260
333
  roles: rolesConfigSchema.optional(),
334
+ tunnels: tunnelsArraySchema,
261
335
  })
262
336
  .catchall(z.unknown())
263
337
 
@@ -369,6 +443,7 @@ export const FIELD_EFFECTS: Record<string, FieldEffect> = {
369
443
  channels: 'applied',
370
444
  portForward: 'restart-required',
371
445
  network: 'restart-required',
446
+ tunnels: 'restart-required',
372
447
  'docker.file': 'restart-required',
373
448
  'git.ignore': 'restart-required',
374
449
  // Split: `match` lists are reload-safe (typeclaw role claim, hand-edits
@@ -87,7 +87,7 @@ export type StartOptions = {
87
87
  // Hostd's supervisor restart callback already runs inside the daemon process.
88
88
  // Reusing that daemon avoids a self-shutdown when disk source has drifted.
89
89
  reuseCurrentHostDaemon?: boolean
90
- ensureDeps?: (cwd: string) => Promise<EnsureDepsResult>
90
+ ensureDeps?: (cwd: string, opts?: { force?: boolean }) => Promise<EnsureDepsResult>
91
91
  // Test seam for the typeclaw-version auto-upgrade. Production callers omit
92
92
  // this and get the real autoUpgradeTypeclawDep (which reads the CLI's own
93
93
  // package.json). Tests inject a stub to simulate `bun -g update typeclaw`
@@ -149,7 +149,7 @@ export async function start({
149
149
  allocatePort = findFreePort,
150
150
  cliEntry,
151
151
  reuseCurrentHostDaemon = false,
152
- ensureDeps = (dir) => ensureDepsInstalled({ cwd: dir }),
152
+ ensureDeps = (dir, opts) => ensureDepsInstalled({ cwd: dir, ...opts }),
153
153
  autoUpgrade = (dir) => autoUpgradeTypeclawDep({ cwd: dir }),
154
154
  forceBunUpdate = runBunUpdate,
155
155
  readInstalledVersion = readInstalledTypeclawVersionFromAgent,
@@ -235,7 +235,14 @@ export async function start({
235
235
  // node_modules/ partially populated. The container then crashes with
236
236
  // `Cannot find package 'x'` because the agent folder is bind-mounted into
237
237
  // /agent and the container has no node_modules of its own.
238
- const deps = await ensureDeps(cwd)
238
+ //
239
+ // Force-reinstall ONLY when --build is set AND typeclaw is declared via
240
+ // a local link. Bun's file-dep cache otherwise serves stale source on
241
+ // subsequent installs (PR #243 dogfooding wasted three rebuilds + a
242
+ // manual version bump before this gate existed). Registry-spec users
243
+ // skip the force path because their install is already cache-correct.
244
+ const forceDepsReinstall = forceBuild && (await hasLocallyLinkedTypeclawDep(cwd))
245
+ const deps = await ensureDeps(cwd, { force: forceDepsReinstall })
239
246
  if (!deps.ok) {
240
247
  return { ok: false, reason: `dependency install failed: ${deps.reason}` }
241
248
  }
@@ -710,6 +717,26 @@ async function detectDevSource(cwd: string): Promise<string | null> {
710
717
  }
711
718
  }
712
719
 
720
+ // True when the agent's package.json declares typeclaw via `file:` or
721
+ // `link:` — i.e. a developer is iterating on the typeclaw source via a
722
+ // locally-linked checkout. `bun install` keys its file-dep cache on
723
+ // name+version, so it treats a stale cached copy as a cache hit even
724
+ // after the source on disk has changed. `typeclaw start --build` uses
725
+ // this gate to force `bun install --force` only in dev: registry-spec
726
+ // users (`^X.Y.Z`, `~X.Y.Z`, exact pins) pay nothing because their
727
+ // install path is already cache-correct.
728
+ async function hasLocallyLinkedTypeclawDep(cwd: string): Promise<boolean> {
729
+ try {
730
+ const raw = await readFile(join(cwd, PACKAGE_FILE), 'utf8')
731
+ const pkg = JSON.parse(raw) as { dependencies?: Record<string, string> }
732
+ const spec = pkg.dependencies?.typeclaw
733
+ if (typeof spec !== 'string') return false
734
+ return spec.startsWith('file:') || spec.startsWith('link:')
735
+ } catch {
736
+ return false
737
+ }
738
+ }
739
+
713
740
  // A missing typeclaw.json is tolerated (e.g. test fixtures, freshly-cloned
714
741
  // folder mid-init). Anything else — malformed JSON, schema-invalid config,
715
742
  // invalid mount entry — must surface so the user sees they configured a mount
@@ -0,0 +1,136 @@
1
+ import { resolveHostPort, resolveTuiToken } from '@/container'
2
+ import type { ClientMessage, CronListEntryPayload, ServerMessage } from '@/shared'
3
+
4
+ export type CronListBridgeOptions = {
5
+ cwd: string
6
+ url?: string
7
+ timeoutMs?: number
8
+ }
9
+
10
+ export type CronListBridgeResult =
11
+ | { kind: 'ok'; jobs: CronListEntryPayload[]; nowMs: number }
12
+ | { kind: 'unreachable'; reason: string }
13
+ | { kind: 'timeout' }
14
+ | { kind: 'error'; reason: string }
15
+
16
+ const DEFAULT_TIMEOUT_MS = 15_000
17
+
18
+ export async function fetchCronList(opts: CronListBridgeOptions): Promise<CronListBridgeResult> {
19
+ const reach = await dial(opts)
20
+ if (reach.kind !== 'ok') return reach
21
+ const { ws, timeoutMs } = reach
22
+ const requestId = randomId()
23
+ try {
24
+ return await awaitReply(ws, timeoutMs, requestId)
25
+ } finally {
26
+ ws.close()
27
+ }
28
+ }
29
+
30
+ type DialResult = { kind: 'ok'; ws: WebSocket; timeoutMs: number } | { kind: 'unreachable'; reason: string }
31
+
32
+ async function dial(opts: CronListBridgeOptions): Promise<DialResult> {
33
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS
34
+ let url = opts.url
35
+ if (url === undefined) {
36
+ try {
37
+ const port = await resolveHostPort({ cwd: opts.cwd })
38
+ const token = await resolveTuiToken({ cwd: opts.cwd })
39
+ url = buildBridgeUrl(port, token)
40
+ } catch (err) {
41
+ return { kind: 'unreachable', reason: err instanceof Error ? err.message : String(err) }
42
+ }
43
+ }
44
+ const ws = new WebSocket(url)
45
+ const displayUrl = redactUrl(url)
46
+ try {
47
+ await new Promise<void>((resolve, reject) => {
48
+ // Mirrors the wedged-handshake timeout from src/doctor/plugin-bridge.ts.
49
+ // Bun's WebSocket has no built-in connect timeout; a stuck Upgrade
50
+ // never fires 'open' nor 'error', so `typeclaw cron list` would hang.
51
+ let timer: ReturnType<typeof setTimeout> | undefined
52
+ const cleanup = () => {
53
+ if (timer !== undefined) clearTimeout(timer)
54
+ ws.removeEventListener('open', onOpen)
55
+ ws.removeEventListener('error', onError)
56
+ ws.removeEventListener('close', onClose)
57
+ }
58
+ const onOpen = () => {
59
+ cleanup()
60
+ resolve()
61
+ }
62
+ const onError = (err: unknown) => {
63
+ cleanup()
64
+ reject(new Error(`failed to connect to ${displayUrl}: ${err instanceof Error ? err.message : String(err)}`))
65
+ }
66
+ const onClose = () => {
67
+ cleanup()
68
+ reject(new Error(`connection to ${displayUrl} closed before opening`))
69
+ }
70
+ timer = setTimeout(() => {
71
+ cleanup()
72
+ try {
73
+ ws.close()
74
+ } catch {}
75
+ reject(new Error(`timed out connecting to ${displayUrl} after ${timeoutMs}ms`))
76
+ }, timeoutMs)
77
+ ws.addEventListener('open', onOpen, { once: true })
78
+ ws.addEventListener('error', onError, { once: true })
79
+ ws.addEventListener('close', onClose, { once: true })
80
+ })
81
+ } catch (err) {
82
+ return { kind: 'unreachable', reason: err instanceof Error ? err.message : String(err) }
83
+ }
84
+ return { kind: 'ok', ws, timeoutMs }
85
+ }
86
+
87
+ async function awaitReply(ws: WebSocket, timeoutMs: number, requestId: string): Promise<CronListBridgeResult> {
88
+ const outgoing: ClientMessage = { type: 'cron_list', requestId }
89
+ ws.send(JSON.stringify(outgoing))
90
+ return new Promise((resolve) => {
91
+ const timer = setTimeout(() => {
92
+ ws.removeEventListener('message', onMessage)
93
+ resolve({ kind: 'timeout' })
94
+ }, timeoutMs)
95
+ const onMessage = (event: MessageEvent) => {
96
+ let msg: ServerMessage
97
+ try {
98
+ msg = JSON.parse(String(event.data)) as ServerMessage
99
+ } catch (err) {
100
+ clearTimeout(timer)
101
+ ws.removeEventListener('message', onMessage)
102
+ resolve({ kind: 'error', reason: err instanceof Error ? err.message : String(err) })
103
+ return
104
+ }
105
+ if (msg.type !== 'cron_list_result' || msg.requestId !== requestId) return
106
+ clearTimeout(timer)
107
+ ws.removeEventListener('message', onMessage)
108
+ if (msg.result.ok) {
109
+ resolve({ kind: 'ok', jobs: msg.result.jobs, nowMs: msg.result.nowMs })
110
+ } else {
111
+ resolve({ kind: 'error', reason: msg.result.reason })
112
+ }
113
+ }
114
+ ws.addEventListener('message', onMessage)
115
+ })
116
+ }
117
+
118
+ function buildBridgeUrl(port: number, token: string | null): string {
119
+ const url = new URL(`ws://127.0.0.1:${port}`)
120
+ if (token !== null) url.searchParams.set('token', token)
121
+ return url.toString()
122
+ }
123
+
124
+ function redactUrl(url: string): string {
125
+ try {
126
+ const parsed = new URL(url)
127
+ if (parsed.searchParams.has('token')) parsed.searchParams.set('token', '<redacted>')
128
+ return parsed.toString()
129
+ } catch {
130
+ return url
131
+ }
132
+ }
133
+
134
+ function randomId(): string {
135
+ return `cron-${crypto.randomUUID()}`
136
+ }
@@ -4,7 +4,9 @@ import type { SessionOrigin } from '@/agent/session-origin'
4
4
  import type { HookBus } from '@/plugin'
5
5
  import type { Stream, Unsubscribe } from '@/stream'
6
6
 
7
- import type { CronJob, ExecJob, PromptJob } from './schema'
7
+ import type { CronJob, ExecJob, HandlerJob, PromptJob } from './schema'
8
+
9
+ export type CronHandlerInvoker = (job: HandlerJob) => Promise<void>
8
10
 
9
11
  // `hooks`, `sessionId`, `agentDir`, and `getTranscriptPath` are optional so
10
12
  // test fakes can stay one-liners. When present, the consumer fires
@@ -40,6 +42,13 @@ export type CreateCronConsumerOptions = {
40
42
  stream: Stream
41
43
  cwd: string
42
44
  createSessionForCron: (job: PromptJob) => Promise<CronSession>
45
+ // Builds the `CronHandlerContext` for the job and awaits its `handler`.
46
+ // Wired by `src/run/index.ts` to reuse `runPromptForCommand` /
47
+ // `runExecForCommand` from the command runner so plugin cron handlers and
48
+ // container plugin commands share one implementation of `ctx.prompt` /
49
+ // `ctx.exec`. Optional so unit-test fakes that never schedule handler jobs
50
+ // stay one-liners.
51
+ invokeHandler?: CronHandlerInvoker
43
52
  logger?: CronConsumerLogger
44
53
  }
45
54
 
@@ -59,6 +68,7 @@ export function createCronConsumer({
59
68
  stream,
60
69
  cwd,
61
70
  createSessionForCron,
71
+ invokeHandler,
62
72
  logger = consoleLogger,
63
73
  }: CreateCronConsumerOptions): CronConsumer {
64
74
  const inFlight = new Set<string>()
@@ -81,8 +91,15 @@ export function createCronConsumer({
81
91
  try {
82
92
  if (job.kind === 'prompt') {
83
93
  await runPrompt(job, createSessionForCron, stream, logger)
84
- } else {
94
+ } else if (job.kind === 'exec') {
85
95
  await runExec(job, cwd)
96
+ } else {
97
+ if (invokeHandler === undefined) {
98
+ throw new Error(
99
+ `handler job dispatched but no invokeHandler wired into the consumer (likely a misconfigured test or boot path)`,
100
+ )
101
+ }
102
+ await invokeHandler(job)
86
103
  }
87
104
  } catch (err) {
88
105
  const message = err instanceof Error ? err.message : String(err)
@@ -180,7 +197,29 @@ async function runPrompt(
180
197
  async function runExec(job: ExecJob, cwd: string): Promise<void> {
181
198
  const [cmd, ...args] = job.command
182
199
  if (!cmd) throw new Error(`exec job ${job.id}: empty command`)
183
- const proc = Bun.spawn({ cmd: [cmd, ...args], cwd, stdout: 'pipe', stderr: 'pipe' })
200
+ // Inject TYPECLAW_PARENT_ORIGIN_JSON so a child that proxies into the
201
+ // agent (typically a `typeclaw <container-cmd>` invocation through the
202
+ // host CLI's container-command-client) can stamp its session's
203
+ // spawnedByOrigin with the cron job's provenance. Without this the
204
+ // proxy would default to a synthetic owner origin and silently elevate
205
+ // a guest- or member-scheduled cron job to owner.
206
+ const parentOrigin = {
207
+ kind: 'cron',
208
+ jobId: job.id,
209
+ jobKind: 'exec',
210
+ ...(job.scheduledByRole !== undefined ? { scheduledByRole: job.scheduledByRole } : {}),
211
+ ...(job.scheduledByOrigin !== undefined ? { scheduledByOrigin: job.scheduledByOrigin } : {}),
212
+ }
213
+ const proc = Bun.spawn({
214
+ cmd: [cmd, ...args],
215
+ cwd,
216
+ stdout: 'pipe',
217
+ stderr: 'pipe',
218
+ env: {
219
+ ...process.env,
220
+ TYPECLAW_PARENT_ORIGIN_JSON: JSON.stringify(parentOrigin),
221
+ },
222
+ })
184
223
  const code = await proc.exited
185
224
  if (code !== 0) {
186
225
  const stderr = await new Response(proc.stderr).text()
@@ -190,7 +229,8 @@ async function runExec(job: ExecJob, cwd: string): Promise<void> {
190
229
 
191
230
  function isCronJob(value: unknown): value is CronJob {
192
231
  if (typeof value !== 'object' || value === null) return false
193
- const v = value as { id?: unknown; kind?: unknown }
232
+ const v = value as { id?: unknown; kind?: unknown; handler?: unknown }
194
233
  if (typeof v.id !== 'string') return false
195
- return v.kind === 'prompt' || v.kind === 'exec'
234
+ if (v.kind === 'prompt' || v.kind === 'exec') return true
235
+ return v.kind === 'handler' && typeof v.handler === 'function'
196
236
  }
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',