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.
- 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/session-meta.ts +1 -1
- package/src/agent/session-origin.ts +3 -2
- package/src/bundled-plugins/security/index.ts +3 -2
- 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 +286 -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/schema.ts +31 -1
- package/src/channels/tunnel-bridge.ts +51 -0
- 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 +256 -27
- package/src/cli/model.ts +4 -2
- 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/tui.ts +10 -2
- package/src/cli/tunnel.ts +533 -0
- package/src/cli/ui.ts +8 -3
- package/src/config/config.ts +75 -0
- package/src/container/start.ts +30 -3
- package/src/cron/bridge.ts +136 -0
- package/src/cron/consumer.ts +45 -5
- 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 +59 -13
- package/src/init/ensure-deps.ts +15 -4
- package/src/init/github-webhook-install.ts +109 -0
- package/src/init/index.ts +505 -9
- package/src/init/run-bun-install.ts +17 -3
- package/src/init/run-owner-claim.ts +11 -2
- package/src/permissions/builtins.ts +6 -1
- package/src/permissions/match-rule.ts +24 -2
- package/src/permissions/resolve.ts +1 -0
- package/src/plugin/define.ts +42 -1
- package/src/plugin/index.ts +18 -3
- package/src/plugin/manager.ts +2 -0
- package/src/plugin/registry.ts +85 -3
- package/src/plugin/types.ts +138 -1
- package/src/plugin/zod-introspect.ts +100 -0
- package/src/role-claim/match-rule.ts +2 -1
- package/src/run/index.ts +110 -3
- 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-monorepo/SKILL.md +2 -2
- package/src/skills/typeclaw-permissions/SKILL.md +5 -4
- 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 +35 -4
- 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/typeclaw.schema.json +254 -1
package/src/config/config.ts
CHANGED
|
@@ -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
|
package/src/container/start.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/src/cron/consumer.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 {
|
|
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',
|