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.
Files changed (101) hide show
  1. package/README.md +20 -15
  2. package/auth.schema.json +113 -0
  3. package/package.json +2 -1
  4. package/scripts/dump-system-prompt.ts +401 -0
  5. package/secrets.schema.json +113 -0
  6. package/src/agent/index.ts +149 -30
  7. package/src/agent/provider-error.ts +44 -0
  8. package/src/agent/session-meta.ts +43 -0
  9. package/src/agent/session-origin.ts +3 -2
  10. package/src/agent/subagents.ts +8 -0
  11. package/src/agent/system-prompt.ts +70 -35
  12. package/src/bundled-plugins/security/index.ts +3 -2
  13. package/src/channels/adapters/github/auth-app.ts +120 -0
  14. package/src/channels/adapters/github/auth-pat.ts +50 -0
  15. package/src/channels/adapters/github/auth.ts +33 -0
  16. package/src/channels/adapters/github/channel-resolver.ts +30 -0
  17. package/src/channels/adapters/github/dedup.ts +26 -0
  18. package/src/channels/adapters/github/event-allowlist.ts +8 -0
  19. package/src/channels/adapters/github/fetch-attachment.ts +5 -0
  20. package/src/channels/adapters/github/history.ts +63 -0
  21. package/src/channels/adapters/github/inbound.ts +286 -0
  22. package/src/channels/adapters/github/index.ts +286 -0
  23. package/src/channels/adapters/github/managed-path.ts +54 -0
  24. package/src/channels/adapters/github/membership.ts +35 -0
  25. package/src/channels/adapters/github/outbound.ts +145 -0
  26. package/src/channels/adapters/github/webhook-register.ts +349 -0
  27. package/src/channels/manager.ts +94 -9
  28. package/src/channels/router.ts +28 -2
  29. package/src/channels/schema.ts +31 -1
  30. package/src/channels/tunnel-bridge.ts +51 -0
  31. package/src/cli/builtins.ts +28 -0
  32. package/src/cli/channel.ts +511 -25
  33. package/src/cli/container-command-client.ts +244 -0
  34. package/src/cli/cron.ts +173 -0
  35. package/src/cli/host-command-runner.ts +150 -0
  36. package/src/cli/index.ts +42 -1
  37. package/src/cli/init.ts +256 -27
  38. package/src/cli/model.ts +4 -2
  39. package/src/cli/plugin-command-help.ts +49 -0
  40. package/src/cli/plugin-commands-dispatch.ts +112 -0
  41. package/src/cli/plugin-commands.ts +118 -0
  42. package/src/cli/tui.ts +10 -2
  43. package/src/cli/tunnel.ts +533 -0
  44. package/src/cli/ui.ts +8 -3
  45. package/src/cli/usage.ts +30 -2
  46. package/src/config/config.ts +90 -4
  47. package/src/config/reloadable.ts +22 -4
  48. package/src/container/start.ts +30 -3
  49. package/src/cron/bridge.ts +136 -0
  50. package/src/cron/consumer.ts +62 -6
  51. package/src/cron/index.ts +19 -2
  52. package/src/cron/list.ts +105 -0
  53. package/src/cron/scheduler.ts +12 -3
  54. package/src/cron/schema.ts +11 -3
  55. package/src/doctor/checks.ts +0 -50
  56. package/src/init/dockerfile.ts +59 -13
  57. package/src/init/ensure-deps.ts +15 -4
  58. package/src/init/github-webhook-install.ts +109 -0
  59. package/src/init/index.ts +505 -9
  60. package/src/init/run-bun-install.ts +17 -3
  61. package/src/init/run-owner-claim.ts +11 -2
  62. package/src/permissions/builtins.ts +6 -1
  63. package/src/permissions/match-rule.ts +24 -2
  64. package/src/permissions/resolve.ts +1 -0
  65. package/src/plugin/define.ts +42 -1
  66. package/src/plugin/index.ts +18 -3
  67. package/src/plugin/manager.ts +2 -0
  68. package/src/plugin/registry.ts +85 -3
  69. package/src/plugin/types.ts +138 -1
  70. package/src/plugin/zod-introspect.ts +100 -0
  71. package/src/role-claim/match-rule.ts +2 -1
  72. package/src/run/index.ts +119 -4
  73. package/src/secrets/index.ts +1 -1
  74. package/src/secrets/schema.ts +21 -0
  75. package/src/server/command-runner.ts +476 -0
  76. package/src/server/index.ts +393 -15
  77. package/src/shared/index.ts +8 -0
  78. package/src/shared/protocol.ts +80 -1
  79. package/src/skills/typeclaw-channel-github/SKILL.md +24 -0
  80. package/src/skills/typeclaw-config/SKILL.md +27 -26
  81. package/src/skills/typeclaw-cron/SKILL.md +234 -3
  82. package/src/skills/typeclaw-monorepo/SKILL.md +2 -2
  83. package/src/skills/typeclaw-permissions/SKILL.md +5 -4
  84. package/src/skills/typeclaw-plugins/SKILL.md +251 -5
  85. package/src/skills/typeclaw-tunnels/SKILL.md +111 -0
  86. package/src/test-helpers/wait-for.ts +50 -0
  87. package/src/tui/index.ts +35 -4
  88. package/src/tunnels/__fixtures__/cloudflared-quick-stderr.txt +11 -0
  89. package/src/tunnels/events.ts +14 -0
  90. package/src/tunnels/index.ts +12 -0
  91. package/src/tunnels/log-ring.ts +54 -0
  92. package/src/tunnels/manager.ts +139 -0
  93. package/src/tunnels/providers/cloudflare-quick.ts +189 -0
  94. package/src/tunnels/providers/external.ts +53 -0
  95. package/src/tunnels/quick-url-parser.ts +5 -0
  96. package/src/tunnels/types.ts +43 -0
  97. package/src/usage/aggregate.ts +30 -1
  98. package/src/usage/index.ts +3 -2
  99. package/src/usage/report.ts +103 -3
  100. package/src/usage/scan.ts +59 -4
  101. 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
@@ -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 function validateConfig(cwd: string): ValidateConfigResult {
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
- for (const mount of result.data.mounts) {
911
- const check = validateMount(mount, cwd)
912
- if (!check.ok) return check
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 }
@@ -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({ cwd, permissions }: CreateConfigReloadableOptions): Reloadable {
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(cwd: string, permissions: PermissionService | undefined): Promise<ReloadResult> {
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
- const validated = validateConfig(cwd)
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
  }
@@ -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
+ }
@@ -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
- 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
+ })
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
- 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'
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 { 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