typeclaw 0.3.0 → 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 +2 -1
- package/scripts/dump-system-prompt.ts +401 -0
- package/secrets.schema.json +113 -0
- package/src/agent/index.ts +149 -30
- package/src/agent/provider-error.ts +44 -0
- package/src/agent/session-meta.ts +43 -0
- package/src/agent/session-origin.ts +3 -2
- package/src/agent/subagents.ts +8 -0
- package/src/agent/system-prompt.ts +70 -35
- 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/router.ts +28 -2
- 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/cli/usage.ts +30 -2
- package/src/config/config.ts +90 -4
- package/src/config/reloadable.ts +22 -4
- package/src/container/start.ts +30 -3
- package/src/cron/bridge.ts +136 -0
- package/src/cron/consumer.ts +62 -6
- 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 +119 -4
- package/src/secrets/index.ts +1 -1
- package/src/secrets/schema.ts +21 -0
- package/src/server/command-runner.ts +476 -0
- package/src/server/index.ts +393 -15
- 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/src/usage/aggregate.ts +30 -1
- package/src/usage/index.ts +3 -2
- package/src/usage/report.ts +103 -3
- package/src/usage/scan.ts +59 -4
- package/typeclaw.schema.json +254 -1
package/src/cli/usage.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { formatJson, formatReport } from '@/usage/report'
|
|
|
6
6
|
|
|
7
7
|
import { parseSince, parseUntil, USAGE_COMMON_ARGS } from './usage-args'
|
|
8
8
|
|
|
9
|
-
const SUBCOMMANDS = ['daily', 'session', 'models'] as const
|
|
9
|
+
const SUBCOMMANDS = ['daily', 'session', 'models', 'origin'] as const
|
|
10
10
|
type Subcommand = (typeof SUBCOMMANDS)[number]
|
|
11
11
|
type View = 'summary' | Subcommand
|
|
12
12
|
|
|
@@ -18,6 +18,13 @@ const COMMON_ARGS = {
|
|
|
18
18
|
},
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
// Captured by the parent's `setup` hook (which citty runs BEFORE the matched
|
|
22
|
+
// subcommand's `run`, with the full parent-level argv parsed). Subcommands
|
|
23
|
+
// read this in their own `run` to recover global options like `--since` that
|
|
24
|
+
// appeared before the subcommand name. Single-instance CLI processes only —
|
|
25
|
+
// no concurrency.
|
|
26
|
+
let parentRunArgs: Record<string, unknown> | undefined
|
|
27
|
+
|
|
21
28
|
const subcommand = (view: View, description: string) =>
|
|
22
29
|
defineCommand({
|
|
23
30
|
meta: { name: view, description },
|
|
@@ -26,7 +33,7 @@ const subcommand = (view: View, description: string) =>
|
|
|
26
33
|
...(view === 'session' ? { limit: { type: 'string' as const, description: 'max sessions (default 20)' } } : {}),
|
|
27
34
|
},
|
|
28
35
|
async run({ args }) {
|
|
29
|
-
await emit(view, args)
|
|
36
|
+
await emit(view, mergeParentArgs(args))
|
|
30
37
|
},
|
|
31
38
|
})
|
|
32
39
|
|
|
@@ -36,10 +43,14 @@ export const usageCommand = defineCommand({
|
|
|
36
43
|
description: 'report LLM token usage and cost for this agent folder',
|
|
37
44
|
},
|
|
38
45
|
args: COMMON_ARGS,
|
|
46
|
+
setup({ args }) {
|
|
47
|
+
parentRunArgs = args as unknown as Record<string, unknown>
|
|
48
|
+
},
|
|
39
49
|
subCommands: {
|
|
40
50
|
daily: subcommand('daily', 'one row per calendar day'),
|
|
41
51
|
session: subcommand('session', 'top sessions by cost'),
|
|
42
52
|
models: subcommand('models', 'one row per provider/model'),
|
|
53
|
+
origin: subcommand('origin', 'one row per session origin (tui/cron/channel/subagent)'),
|
|
43
54
|
},
|
|
44
55
|
async run({ args }) {
|
|
45
56
|
// citty invokes both the matched subcommand's `run` and the parent's
|
|
@@ -50,6 +61,23 @@ export const usageCommand = defineCommand({
|
|
|
50
61
|
},
|
|
51
62
|
})
|
|
52
63
|
|
|
64
|
+
// citty's subcommand `run` only sees args that came AFTER the subcommand
|
|
65
|
+
// name (the child's rawArgs is pre-sliced), so `usage --since=X origin` would
|
|
66
|
+
// silently drop `--since` despite the help text advertising it as a global
|
|
67
|
+
// option. The parent's `setup` runs first with the full parent-level parse
|
|
68
|
+
// (which includes everything: global options + subcommand options merged),
|
|
69
|
+
// so we capture it there and merge it as a fallback under any explicitly-set
|
|
70
|
+
// child arg. Child-wins so `usage --since=A origin --since=B` still honours B.
|
|
71
|
+
function mergeParentArgs(childArgs: Record<string, unknown>): Record<string, unknown> {
|
|
72
|
+
if (parentRunArgs === undefined) return childArgs
|
|
73
|
+
const merged: Record<string, unknown> = { ...parentRunArgs }
|
|
74
|
+
for (const key of Object.keys(childArgs)) {
|
|
75
|
+
const v = childArgs[key]
|
|
76
|
+
if (v !== undefined && v !== '' && v !== false) merged[key] = v
|
|
77
|
+
}
|
|
78
|
+
return merged
|
|
79
|
+
}
|
|
80
|
+
|
|
53
81
|
async function emit(view: View, args: Record<string, unknown>): Promise<void> {
|
|
54
82
|
const cwdArg = typeof args.cwd === 'string' && args.cwd.length > 0 ? args.cwd : process.cwd()
|
|
55
83
|
const agentDir = findAgentDir(cwdArg) ?? cwdArg
|
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
|
|
@@ -881,7 +956,16 @@ export type ValidateConfigResult = { ok: true } | { ok: false; reason: string }
|
|
|
881
956
|
// confusing path-sharing error (or, on some Linux setups, silently bind-mount
|
|
882
957
|
// an empty auto-created directory). First-failure reporting matches the
|
|
883
958
|
// schema-error path's shape; users fix one and re-run.
|
|
884
|
-
export
|
|
959
|
+
export type ValidateConfigOptions = {
|
|
960
|
+
// Skip the mount-path accessibility check. Host-side callers leave this
|
|
961
|
+
// false (the default) so missing mount directories surface as a precise
|
|
962
|
+
// pre-`docker run` error. Container-side callers (the reload registry)
|
|
963
|
+
// set it true because mount paths in typeclaw.json are host paths and
|
|
964
|
+
// don't resolve inside the container's filesystem.
|
|
965
|
+
skipMounts?: boolean
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
export function validateConfig(cwd: string, options: ValidateConfigOptions = {}): ValidateConfigResult {
|
|
885
969
|
let raw: string
|
|
886
970
|
try {
|
|
887
971
|
raw = readFileSync(join(cwd, CONFIG_FILE), 'utf8')
|
|
@@ -907,9 +991,11 @@ export function validateConfig(cwd: string): ValidateConfigResult {
|
|
|
907
991
|
return { ok: false, reason: `${CONFIG_FILE} is invalid: ${formatZodError(result.error)}` }
|
|
908
992
|
}
|
|
909
993
|
|
|
910
|
-
|
|
911
|
-
const
|
|
912
|
-
|
|
994
|
+
if (!options.skipMounts) {
|
|
995
|
+
for (const mount of result.data.mounts) {
|
|
996
|
+
const check = validateMount(mount, cwd)
|
|
997
|
+
if (!check.ok) return check
|
|
998
|
+
}
|
|
913
999
|
}
|
|
914
1000
|
|
|
915
1001
|
return { ok: true }
|
package/src/config/reloadable.ts
CHANGED
|
@@ -11,24 +11,42 @@ export type CreateConfigReloadableOptions = {
|
|
|
11
11
|
// hand-edits) take effect without a container restart. `roles.<name>.permissions`
|
|
12
12
|
// changes still require a restart — see FIELD_EFFECTS in config.ts.
|
|
13
13
|
permissions?: PermissionService
|
|
14
|
+
// Skip the mount-path accessibility check inside validateConfig. Mount paths
|
|
15
|
+
// in typeclaw.json are host paths — they don't resolve inside the container,
|
|
16
|
+
// so the check would always fail on any agent that declares mounts. `mounts`
|
|
17
|
+
// is `restart-required` anyway, so reload never applies mount changes. Set
|
|
18
|
+
// this when wiring the reloadable from a container-stage context.
|
|
19
|
+
skipMountValidation?: boolean
|
|
14
20
|
}
|
|
15
21
|
|
|
16
|
-
export function createConfigReloadable({
|
|
22
|
+
export function createConfigReloadable({
|
|
23
|
+
cwd,
|
|
24
|
+
permissions,
|
|
25
|
+
skipMountValidation = false,
|
|
26
|
+
}: CreateConfigReloadableOptions): Reloadable {
|
|
17
27
|
return {
|
|
18
28
|
scope: 'config',
|
|
19
29
|
description: 'typeclaw.json runtime config',
|
|
20
|
-
reload: async () => doReload(cwd, permissions),
|
|
30
|
+
reload: async () => doReload(cwd, permissions, skipMountValidation),
|
|
21
31
|
}
|
|
22
32
|
}
|
|
23
33
|
|
|
24
|
-
async function doReload(
|
|
34
|
+
async function doReload(
|
|
35
|
+
cwd: string,
|
|
36
|
+
permissions: PermissionService | undefined,
|
|
37
|
+
skipMountValidation: boolean,
|
|
38
|
+
): Promise<ReloadResult> {
|
|
25
39
|
// Mount accessibility belongs to the validation surface, not loadConfigSync —
|
|
26
40
|
// validateConfig is the single gate that every host-side caller goes through.
|
|
27
41
|
// Run it before swapping the live config pointer so a mount that vanished
|
|
28
42
|
// between starts surfaces as a reload failure (`mounts` is restart-required
|
|
29
43
|
// anyway, so the user has to restart to pick up changes; better to flag the
|
|
30
44
|
// problem now than to let restart fail later).
|
|
31
|
-
|
|
45
|
+
//
|
|
46
|
+
// Container-side reload skips mount validation: mounts are host paths and
|
|
47
|
+
// statSync against them inside the container always fails. The host-side
|
|
48
|
+
// `start` / `restart` / doctor paths still gate on the full validateConfig.
|
|
49
|
+
const validated = validateConfig(cwd, { skipMounts: skipMountValidation })
|
|
32
50
|
if (!validated.ok) {
|
|
33
51
|
return { scope: 'config', ok: false, reason: validated.reason }
|
|
34
52
|
}
|
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
|
@@ -1,8 +1,12 @@
|
|
|
1
|
+
import type { AgentSession } from '@/agent'
|
|
2
|
+
import { subscribeProviderErrors } from '@/agent/provider-error'
|
|
1
3
|
import type { SessionOrigin } from '@/agent/session-origin'
|
|
2
4
|
import type { HookBus } from '@/plugin'
|
|
3
5
|
import type { Stream, Unsubscribe } from '@/stream'
|
|
4
6
|
|
|
5
|
-
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>
|
|
6
10
|
|
|
7
11
|
// `hooks`, `sessionId`, `agentDir`, and `getTranscriptPath` are optional so
|
|
8
12
|
// test fakes can stay one-liners. When present, the consumer fires
|
|
@@ -20,6 +24,12 @@ export type CronSession = {
|
|
|
20
24
|
agentDir?: string
|
|
21
25
|
getTranscriptPath?: () => string | undefined
|
|
22
26
|
origin?: SessionOrigin
|
|
27
|
+
// Underlying agent session, exposed so the consumer can subscribe to
|
|
28
|
+
// `message_end` events and surface soft provider errors (billing, rate
|
|
29
|
+
// limit, network — pi-coding-agent encodes these in the assistant message
|
|
30
|
+
// instead of throwing, so the outer try/catch never sees them). Optional
|
|
31
|
+
// so existing test fakes that only need `prompt` keep working.
|
|
32
|
+
session?: AgentSession
|
|
23
33
|
}
|
|
24
34
|
|
|
25
35
|
export type CronConsumerLogger = {
|
|
@@ -32,6 +42,13 @@ export type CreateCronConsumerOptions = {
|
|
|
32
42
|
stream: Stream
|
|
33
43
|
cwd: string
|
|
34
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
|
|
35
52
|
logger?: CronConsumerLogger
|
|
36
53
|
}
|
|
37
54
|
|
|
@@ -51,6 +68,7 @@ export function createCronConsumer({
|
|
|
51
68
|
stream,
|
|
52
69
|
cwd,
|
|
53
70
|
createSessionForCron,
|
|
71
|
+
invokeHandler,
|
|
54
72
|
logger = consoleLogger,
|
|
55
73
|
}: CreateCronConsumerOptions): CronConsumer {
|
|
56
74
|
const inFlight = new Set<string>()
|
|
@@ -72,9 +90,16 @@ export function createCronConsumer({
|
|
|
72
90
|
inFlight.add(job.id)
|
|
73
91
|
try {
|
|
74
92
|
if (job.kind === 'prompt') {
|
|
75
|
-
await runPrompt(job, createSessionForCron, stream)
|
|
76
|
-
} else {
|
|
93
|
+
await runPrompt(job, createSessionForCron, stream, logger)
|
|
94
|
+
} else if (job.kind === 'exec') {
|
|
77
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)
|
|
78
103
|
}
|
|
79
104
|
} catch (err) {
|
|
80
105
|
const message = err instanceof Error ? err.message : String(err)
|
|
@@ -98,6 +123,7 @@ async function runPrompt(
|
|
|
98
123
|
job: PromptJob,
|
|
99
124
|
createSessionForCron: (job: PromptJob) => Promise<CronSession>,
|
|
100
125
|
stream: Stream,
|
|
126
|
+
logger: CronConsumerLogger,
|
|
101
127
|
): Promise<void> {
|
|
102
128
|
if (job.subagent !== undefined) {
|
|
103
129
|
// Propagate the cron job's role and origin into the spawned subagent.
|
|
@@ -123,6 +149,12 @@ async function runPrompt(
|
|
|
123
149
|
return
|
|
124
150
|
}
|
|
125
151
|
const session = await createSessionForCron(job)
|
|
152
|
+
const unsubProviderErrors =
|
|
153
|
+
session.session !== undefined
|
|
154
|
+
? subscribeProviderErrors(session.session, (err) => {
|
|
155
|
+
logger.error(`[cron] ${job.id}: LLM call failed: ${err.message}`)
|
|
156
|
+
})
|
|
157
|
+
: null
|
|
126
158
|
const turnEvent =
|
|
127
159
|
session.hooks && session.sessionId !== undefined && session.agentDir !== undefined
|
|
128
160
|
? {
|
|
@@ -151,6 +183,7 @@ async function runPrompt(
|
|
|
151
183
|
})
|
|
152
184
|
}
|
|
153
185
|
} finally {
|
|
186
|
+
unsubProviderErrors?.()
|
|
154
187
|
if (session.hooks && session.sessionId !== undefined) {
|
|
155
188
|
await session.hooks.runSessionEnd({
|
|
156
189
|
sessionId: session.sessionId,
|
|
@@ -164,7 +197,29 @@ async function runPrompt(
|
|
|
164
197
|
async function runExec(job: ExecJob, cwd: string): Promise<void> {
|
|
165
198
|
const [cmd, ...args] = job.command
|
|
166
199
|
if (!cmd) throw new Error(`exec job ${job.id}: empty command`)
|
|
167
|
-
|
|
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
|
+
})
|
|
168
223
|
const code = await proc.exited
|
|
169
224
|
if (code !== 0) {
|
|
170
225
|
const stderr = await new Response(proc.stderr).text()
|
|
@@ -174,7 +229,8 @@ async function runExec(job: ExecJob, cwd: string): Promise<void> {
|
|
|
174
229
|
|
|
175
230
|
function isCronJob(value: unknown): value is CronJob {
|
|
176
231
|
if (typeof value !== 'object' || value === null) return false
|
|
177
|
-
const v = value as { id?: unknown; kind?: unknown }
|
|
232
|
+
const v = value as { id?: unknown; kind?: unknown; handler?: unknown }
|
|
178
233
|
if (typeof v.id !== 'string') return false
|
|
179
|
-
|
|
234
|
+
if (v.kind === 'prompt' || v.kind === 'exec') return true
|
|
235
|
+
return v.kind === 'handler' && typeof v.handler === 'function'
|
|
180
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
|
|