typeclaw 0.1.4 → 0.1.6

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 (134) hide show
  1. package/README.md +15 -13
  2. package/auth.schema.json +41 -0
  3. package/cron.schema.json +8 -0
  4. package/package.json +1 -1
  5. package/secrets.schema.json +41 -0
  6. package/src/agent/auth.ts +45 -22
  7. package/src/agent/index.ts +189 -19
  8. package/src/agent/multimodal/index.ts +12 -0
  9. package/src/agent/multimodal/look-at.ts +185 -0
  10. package/src/agent/multimodal/looker.ts +145 -0
  11. package/src/agent/plugin-tools.ts +30 -1
  12. package/src/agent/session-origin.ts +194 -46
  13. package/src/agent/subagents.ts +57 -1
  14. package/src/agent/system-prompt.ts +1 -1
  15. package/src/agent/tool-result-budget.ts +121 -0
  16. package/src/bundled-plugins/backup/index.ts +23 -8
  17. package/src/bundled-plugins/backup/runner.ts +22 -0
  18. package/src/bundled-plugins/memory/README.md +13 -10
  19. package/src/bundled-plugins/memory/append-tool.ts +87 -61
  20. package/src/bundled-plugins/memory/dreaming.ts +137 -7
  21. package/src/bundled-plugins/memory/find-entry-tool.ts +62 -0
  22. package/src/bundled-plugins/memory/fragment-parser.ts +19 -44
  23. package/src/bundled-plugins/memory/index.ts +91 -8
  24. package/src/bundled-plugins/memory/load-memory.ts +74 -34
  25. package/src/bundled-plugins/memory/memory-logger.ts +72 -29
  26. package/src/bundled-plugins/memory/migration.ts +276 -0
  27. package/src/bundled-plugins/memory/stream-events.ts +55 -0
  28. package/src/bundled-plugins/memory/stream-io.ts +63 -0
  29. package/src/bundled-plugins/memory/watermark.ts +48 -8
  30. package/src/bundled-plugins/security/index.ts +103 -10
  31. package/src/bundled-plugins/security/permissions.ts +12 -0
  32. package/src/bundled-plugins/security/policies/git-exfil.ts +51 -18
  33. package/src/bundled-plugins/tool-result-cap/README.md +9 -4
  34. package/src/bundled-plugins/tool-result-cap/cap-jsonl.ts +115 -0
  35. package/src/bundled-plugins/tool-result-cap/cap-result.ts +25 -13
  36. package/src/bundled-plugins/tool-result-cap/index.ts +16 -2
  37. package/src/channels/adapters/discord-bot-classify.ts +2 -6
  38. package/src/channels/adapters/discord-bot.ts +4 -45
  39. package/src/channels/adapters/kakaotalk-classify.ts +3 -7
  40. package/src/channels/adapters/kakaotalk.ts +28 -47
  41. package/src/channels/adapters/slack-bot-classify.ts +2 -6
  42. package/src/channels/adapters/slack-bot.ts +4 -50
  43. package/src/channels/adapters/telegram-bot-classify.ts +8 -10
  44. package/src/channels/adapters/telegram-bot.ts +3 -16
  45. package/src/channels/index.ts +3 -2
  46. package/src/channels/manager.ts +15 -1
  47. package/src/channels/persistence.ts +44 -10
  48. package/src/channels/router.ts +228 -19
  49. package/src/channels/schema.ts +6 -156
  50. package/src/cli/channel.ts +200 -4
  51. package/src/cli/compose-usage.ts +182 -0
  52. package/src/cli/compose.ts +33 -0
  53. package/src/cli/hostd.ts +49 -1
  54. package/src/cli/index.ts +4 -0
  55. package/src/cli/init.ts +809 -300
  56. package/src/cli/model.ts +244 -0
  57. package/src/cli/provider.ts +404 -0
  58. package/src/cli/reload.ts +11 -3
  59. package/src/cli/role.ts +156 -0
  60. package/src/cli/run.ts +3 -1
  61. package/src/cli/tui.ts +13 -3
  62. package/src/cli/usage-args.ts +47 -0
  63. package/src/cli/usage.ts +97 -0
  64. package/src/compose/index.ts +1 -0
  65. package/src/compose/usage.ts +65 -0
  66. package/src/config/config.ts +491 -19
  67. package/src/config/index.ts +15 -1
  68. package/src/config/models-mutation.ts +200 -0
  69. package/src/config/providers-mutation.ts +250 -0
  70. package/src/config/providers.ts +141 -2
  71. package/src/config/reloadable.ts +15 -4
  72. package/src/container/index.ts +6 -1
  73. package/src/container/port.ts +10 -0
  74. package/src/container/require-running.ts +33 -0
  75. package/src/container/start.ts +81 -63
  76. package/src/cron/consumer.ts +22 -2
  77. package/src/cron/index.ts +45 -4
  78. package/src/cron/schema.ts +104 -0
  79. package/src/doctor/checks.ts +51 -34
  80. package/src/doctor/plugin-bridge.ts +28 -4
  81. package/src/git/system-commit.ts +103 -0
  82. package/src/hostd/daemon.ts +16 -0
  83. package/src/hostd/kakao-renewal-manager.ts +223 -0
  84. package/src/hostd/paths.ts +7 -0
  85. package/src/init/dockerfile.ts +36 -10
  86. package/src/init/gitignore.ts +1 -1
  87. package/src/init/index.ts +213 -85
  88. package/src/init/kakaotalk-auth.ts +18 -1
  89. package/src/init/models-dev.ts +26 -1
  90. package/src/init/run-owner-claim.ts +77 -0
  91. package/src/permissions/builtins.ts +70 -0
  92. package/src/permissions/grant.ts +99 -0
  93. package/src/permissions/index.ts +29 -0
  94. package/src/permissions/match-rule.ts +305 -0
  95. package/src/permissions/permissions.ts +196 -0
  96. package/src/permissions/resolve.ts +80 -0
  97. package/src/permissions/schema.ts +79 -0
  98. package/src/plugin/context.ts +8 -4
  99. package/src/plugin/define.ts +2 -0
  100. package/src/plugin/index.ts +2 -0
  101. package/src/plugin/manager.ts +41 -0
  102. package/src/plugin/registry.ts +9 -0
  103. package/src/plugin/types.ts +35 -1
  104. package/src/reload/client.ts +25 -1
  105. package/src/role-claim/client.ts +182 -0
  106. package/src/role-claim/code.ts +53 -0
  107. package/src/role-claim/controller.ts +194 -0
  108. package/src/role-claim/index.ts +19 -0
  109. package/src/role-claim/match-rule.ts +43 -0
  110. package/src/role-claim/pending.ts +100 -0
  111. package/src/run/channel-session-factory.ts +76 -5
  112. package/src/run/index.ts +68 -7
  113. package/src/secrets/encryption.ts +116 -0
  114. package/src/secrets/kakao-renewal.ts +248 -0
  115. package/src/secrets/kakao-store.ts +66 -7
  116. package/src/secrets/keys.ts +173 -0
  117. package/src/secrets/schema.ts +23 -0
  118. package/src/secrets/storage.ts +83 -0
  119. package/src/server/index.ts +198 -71
  120. package/src/shared/index.ts +4 -0
  121. package/src/shared/protocol.ts +27 -0
  122. package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +3 -3
  123. package/src/skills/typeclaw-config/SKILL.md +104 -112
  124. package/src/skills/typeclaw-memory/SKILL.md +9 -9
  125. package/src/skills/typeclaw-permissions/SKILL.md +166 -0
  126. package/src/stream/types.ts +7 -1
  127. package/src/tui/client.ts +66 -5
  128. package/src/tui/index.ts +61 -9
  129. package/src/usage/aggregate.ts +117 -0
  130. package/src/usage/format.ts +30 -0
  131. package/src/usage/index.ts +68 -0
  132. package/src/usage/report.ts +354 -0
  133. package/src/usage/scan.ts +186 -0
  134. package/typeclaw.schema.json +134 -98
@@ -0,0 +1,33 @@
1
+ import { containerNameFromCwd, inspectContainer, type ContainerState } from './shared'
2
+
3
+ export type RequireContainerRunningResult = { ok: true; containerName: string } | { ok: false; reason: string }
4
+
5
+ export type RequireContainerRunningOptions = {
6
+ cwd: string
7
+ }
8
+
9
+ type RequireContainerRunningDeps = {
10
+ inspect?: (name: string) => Promise<ContainerState>
11
+ }
12
+
13
+ // Pre-flight for CLI commands that need to talk to a live agent (tui, reload,
14
+ // role claim). Without this, `resolveHostPort` silently falls back to the
15
+ // configured port when the container is missing/stopped and the caller hits
16
+ // an opaque websocket "Connection refused" or fetch error several frames deep.
17
+ // We probe with `inspectContainer` — the same helper `shell` and `start` use —
18
+ // and surface the canonical "Run `typeclaw start` first." prose that matches
19
+ // `src/container/shell.ts`'s wording.
20
+ export async function requireContainerRunning(
21
+ { cwd }: RequireContainerRunningOptions,
22
+ deps: RequireContainerRunningDeps = {},
23
+ ): Promise<RequireContainerRunningResult> {
24
+ const containerName = containerNameFromCwd(cwd)
25
+ const state = await (deps.inspect ?? inspectContainer)(containerName)
26
+ if (!state.exists) {
27
+ return { ok: false, reason: `Container ${containerName} not found. Run \`typeclaw start\` first.` }
28
+ }
29
+ if (!state.running) {
30
+ return { ok: false, reason: `Container ${containerName} is not running. Run \`typeclaw start\` first.` }
31
+ }
32
+ return { ok: true, containerName }
33
+ }
@@ -3,7 +3,8 @@ import { existsSync } from 'node:fs'
3
3
  import { readFile, writeFile } from 'node:fs/promises'
4
4
  import { isAbsolute, join, resolve } from 'node:path'
5
5
 
6
- import { configSchema, expandMountPath, type Config } from '@/config/config'
6
+ import { expandMountPath, loadConfigSync, type Config } from '@/config'
7
+ import { commitSystemFile as commitSystemFileShared } from '@/git/system-commit'
7
8
  import { send as sendToDaemon } from '@/hostd/client'
8
9
  import type { HttpInfoResult } from '@/hostd/protocol'
9
10
  import { ensureDaemon } from '@/hostd/spawn'
@@ -22,7 +23,7 @@ import { refreshPackageJson } from '@/init/packagejson'
22
23
  import { runBunUpdate, type UpdateRunner } from '@/init/run-bun-install'
23
24
  import { migrateKakaotalkCredentials } from '@/secrets'
24
25
 
25
- import { CONTAINER_PORT, findFreePort, isPortAllocatedError } from './port'
26
+ import { CONTAINER_PORT, TUI_TOKEN_LABEL, findFreePort, isPortAllocatedError, resolveTuiToken } from './port'
26
27
  import {
27
28
  classifyRmStderr,
28
29
  cleanupRunCorpse,
@@ -30,7 +31,6 @@ import {
30
31
  defaultDockerExec,
31
32
  type DockerExec,
32
33
  type DockerExecResult,
33
- getBun,
34
34
  imageTagFromCwd,
35
35
  isContainerNameConflict,
36
36
  sanitizeDockerStderr,
@@ -41,7 +41,6 @@ import { buildCrashReason, createVerifyRunning, type VerifyRunningFn } from './v
41
41
  const PACKAGE_FILE = 'package.json'
42
42
  const BUN_LOCK_FILE = 'bun.lock'
43
43
  const DEPENDENCY_FILES = [PACKAGE_FILE, BUN_LOCK_FILE] as const
44
- const CONFIG_FILE = 'typeclaw.json'
45
44
  const ENV_FILE = '.env'
46
45
  const COMPOSE_PROJECT = 'typeclaw'
47
46
  const CONTAINER_HOSTD_HOST = 'host.docker.internal'
@@ -57,6 +56,7 @@ export type StartPlan = {
57
56
  runArgs: string[]
58
57
  needsBuild: boolean
59
58
  hostPort: number
59
+ tuiToken: string | null
60
60
  }
61
61
 
62
62
  export type PlanStartOptions = {
@@ -65,6 +65,8 @@ export type PlanStartOptions = {
65
65
  imageExists: boolean
66
66
  forceBuild?: boolean
67
67
  hostdControl?: HostDaemonControl
68
+ publishHost?: string
69
+ tuiToken?: string | null
68
70
  }
69
71
 
70
72
  export type HostDaemonControl = {
@@ -127,6 +129,7 @@ export type StartResult =
127
129
  containerId: string
128
130
  built: boolean
129
131
  hostPort: number
132
+ tuiToken: string | null
130
133
  hostd: HostDaemonStatus
131
134
  // True when the container was already running and start() became a no-op.
132
135
  // Callers that want to distinguish "I just launched it" from "it was up
@@ -175,6 +178,14 @@ export async function start({
175
178
  // one-shot and idempotent — once `workspaces` is set, refreshPackageJson
176
179
  // is a no-op, so users who never edit their agent folder pay zero cost on
177
180
  // subsequent starts and users who customized `workspaces` are not clobbered.
181
+ //
182
+ // typeclaw.json migration is NOT triggered explicitly here — it follows
183
+ // the disk rewrite via persistMigratedConfig (src/config/config.ts), so
184
+ // every entry point that reads typeclaw.json (host CLI, hostd daemon,
185
+ // container runtime) also produces the commit. start() therefore only
186
+ // needs to orchestrate the .gitignore / package.json side; the
187
+ // refreshGitignore call below reads typeclaw.json and will incidentally
188
+ // trigger the migration commit if the file was legacy.
178
189
  await refreshGitignore(cwd)
179
190
  const pkgRefresh = await refreshPackageJson(cwd)
180
191
  await commitSystemFile(cwd, GITIGNORE_FILE, 'Update .gitignore')
@@ -233,7 +244,7 @@ export async function start({
233
244
  // line resolves against the agent's installed node_modules/typeclaw —
234
245
  // ensures the base image's CLI version matches the runtime the
235
246
  // container will actually load.
236
- await refreshDockerfile(cwd)
247
+ const dockerfileRefresh = await refreshDockerfile(cwd)
237
248
  await migrateKakaotalkCredentials(cwd)
238
249
 
239
250
  if (state.exists) {
@@ -298,7 +309,17 @@ export async function start({
298
309
  : { state: 'disabled' as const }
299
310
  let hostdControl = hostd.state === 'registered' ? hostd.control : undefined
300
311
 
301
- let plan = await planStart({ cwd, hostPort, imageExists: imageExisted, forceBuild, hostdControl })
312
+ const publishHost = await resolvePublishHost(exec)
313
+ const tuiToken = randomBytes(32).toString('base64url')
314
+ let plan = await planStart({
315
+ cwd,
316
+ hostPort,
317
+ imageExists: imageExisted,
318
+ forceBuild: forceBuild || dockerfileRefresh.changed,
319
+ hostdControl,
320
+ publishHost,
321
+ tuiToken,
322
+ })
302
323
 
303
324
  let built = false
304
325
  if (plan.needsBuild) {
@@ -347,7 +368,15 @@ export async function start({
347
368
  hostd = await registerWithDaemon({ cwd, containerName, cliEntry, hostPort, reuseCurrentHostDaemon })
348
369
  hostdControl = hostd.state === 'registered' ? hostd.control : undefined
349
370
  }
350
- plan = await planStart({ cwd, hostPort, imageExists: true, forceBuild: false, hostdControl })
371
+ plan = await planStart({
372
+ cwd,
373
+ hostPort,
374
+ imageExists: true,
375
+ forceBuild: false,
376
+ hostdControl,
377
+ publishHost,
378
+ tuiToken,
379
+ })
351
380
  run = await execRunWithConflictRetry(exec, plan.runArgs, cwd, containerName)
352
381
  }
353
382
 
@@ -370,6 +399,7 @@ export async function start({
370
399
  containerId,
371
400
  built,
372
401
  hostPort,
402
+ tuiToken,
373
403
  hostd: stripHostDaemonControl(hostd),
374
404
  alreadyRunning: false,
375
405
  autoUpgrade: upgrade,
@@ -403,6 +433,8 @@ export async function planStart({
403
433
  imageExists,
404
434
  forceBuild = false,
405
435
  hostdControl,
436
+ publishHost = '127.0.0.1',
437
+ tuiToken = null,
406
438
  }: PlanStartOptions): Promise<StartPlan> {
407
439
  const containerName = containerNameFromCwd(cwd)
408
440
  const imageTag = imageTagFromCwd(cwd)
@@ -416,7 +448,7 @@ export async function planStart({
416
448
  // the start() preflight force-removes any lingering corpse before the next
417
449
  // launch — so the only state Docker ever sees in `docker ps -a` is either
418
450
  // a running container or one the user has not started again yet.
419
- const runArgs = ['run', '-d', '--name', containerName, '-p', `127.0.0.1:${hostPort}:${CONTAINER_PORT}`]
451
+ const runArgs = ['run', '-d', '--name', containerName, '-p', `${publishHost}:${hostPort}:${CONTAINER_PORT}`]
420
452
 
421
453
  // Network egress filter: when `typeclaw.json#network.blockInternal` is true,
422
454
  // grant the container CAP_NET_ADMIN at boot so the entrypoint shim can
@@ -443,6 +475,9 @@ export async function planStart({
443
475
  for (const [key, value] of Object.entries(composeLabels(cwd, containerName))) {
444
476
  runArgs.push('--label', `${key}=${value}`)
445
477
  }
478
+ if (tuiToken !== null) {
479
+ runArgs.push('--label', `${TUI_TOKEN_LABEL}=${tuiToken}`, '-e', `TYPECLAW_TUI_TOKEN=${tuiToken}`)
480
+ }
446
481
 
447
482
  if (existsSync(join(cwd, ENV_FILE))) {
448
483
  runArgs.push('--env-file', join(cwd, ENV_FILE))
@@ -493,59 +528,44 @@ export async function planStart({
493
528
  runArgs,
494
529
  needsBuild: forceBuild || !imageExists,
495
530
  hostPort,
531
+ tuiToken,
496
532
  }
497
533
  }
498
534
 
499
- export async function refreshDockerfile(cwd: string): Promise<void> {
535
+ async function resolvePublishHost(exec: DockerExec): Promise<string> {
536
+ const result = await exec(['version', '--format', '{{.Server.Platform.Name}}'])
537
+ if (result.exitCode === 0 && result.stdout.toLowerCase().includes('docker desktop')) return '0.0.0.0'
538
+ return '127.0.0.1'
539
+ }
540
+
541
+ // The `changed` return drives auto-rebuild in start() so users don't need to
542
+ // pass `--build` after a CLI upgrade or after editing `typeclaw.json#docker.*`.
543
+ // Comparing rendered contents (rather than tracking a separate state file) is
544
+ // the cheapest correct signal: the build context for `docker build` is the
545
+ // Dockerfile itself, so equal contents definitionally produce an equivalent
546
+ // image.
547
+ export async function refreshDockerfile(cwd: string): Promise<{ changed: boolean }> {
500
548
  const cfg = await loadTypeclawConfig(cwd)
501
- await writeFile(
502
- join(cwd, DOCKERFILE),
503
- buildDockerfile(cfg.dockerfile, { baseImageVersion: resolveBaseImageVersion(cwd) }),
504
- )
549
+ const next = buildDockerfile(cfg.docker.file, { baseImageVersion: resolveBaseImageVersion(cwd) })
550
+ const path = join(cwd, DOCKERFILE)
551
+ const prev = await readFile(path, 'utf8').catch(() => null)
552
+ if (prev === next) return { changed: false }
553
+ await writeFile(path, next)
554
+ return { changed: true }
505
555
  }
506
556
 
507
557
  export async function refreshGitignore(cwd: string): Promise<void> {
508
558
  const cfg = await loadTypeclawConfig(cwd)
509
- await writeFile(join(cwd, GITIGNORE_FILE), buildGitignore(cfg.gitignore))
559
+ await writeFile(join(cwd, GITIGNORE_FILE), buildGitignore(cfg.git.ignore))
510
560
  }
511
561
 
512
- // Commits TypeClaw-owned system file(s) if any are dirty in git. Skips silently
513
- // when the agent folder is not a git repo, when bun is unavailable, or when
514
- // every named file is clean (no changes since last commit). Uses the user's
515
- // global git config for authorship TypeClaw does not impersonate the user
516
- // here. Accepts a single file or an array; the array form produces a single
517
- // atomic commit covering all listed paths, used for migrations that touch
518
- // multiple files together (e.g. enabling bun workspaces writes both
519
- // package.json and packages/.gitkeep in one commit).
520
- export async function commitSystemFile(cwd: string, file: string | readonly string[], message: string): Promise<void> {
521
- const files = typeof file === 'string' ? [file] : file
522
- if (files.length === 0) return
523
-
524
- const bun = getBun()
525
- if (!bun) return
526
- if (!existsSync(join(cwd, '.git'))) return
527
-
528
- const status = bun.spawn({
529
- cmd: ['git', 'status', '--porcelain', '--', ...files],
530
- cwd,
531
- stdout: 'pipe',
532
- stderr: 'pipe',
533
- })
534
- if ((await status.exited) !== 0) return
535
- const dirty = (await new Response(status.stdout).text()).trim().length > 0
536
- if (!dirty) return
537
-
538
- const add = bun.spawn({ cmd: ['git', 'add', '--', ...files], cwd, stdout: 'pipe', stderr: 'pipe' })
539
- if ((await add.exited) !== 0) return
540
-
541
- const commit = bun.spawn({
542
- cmd: ['git', 'commit', '-m', message, '--only', '--', ...files],
543
- cwd,
544
- stdout: 'pipe',
545
- stderr: 'pipe',
546
- })
547
- await commit.exited
548
- }
562
+ // Re-exported from src/git/system-commit.ts so existing call sites
563
+ // (refreshGitignore wiring, doctor/commit.ts comment references, test
564
+ // imports) keep working under the original name. New code should import
565
+ // directly from @/git/system-commit instead. The sync sibling lives
566
+ // alongside in that module and is used by persistMigratedConfig to pair
567
+ // the migration write with a commit on every read path, not only here.
568
+ export const commitSystemFile = commitSystemFileShared
549
569
 
550
570
  async function imageExists(exec: DockerExec, tag: string): Promise<boolean> {
551
571
  const result = await exec(['image', 'inspect', tag])
@@ -623,13 +643,15 @@ async function reportAlreadyRunning(exec: DockerExec, cwd: string, containerName
623
643
  reason: `Container ${containerName} is running but its published host port could not be resolved.`,
624
644
  }
625
645
  }
626
- const plan = await planStart({ cwd, hostPort, imageExists: true, forceBuild: false })
646
+ const tuiToken = await resolveTuiToken({ cwd, exec })
647
+ const plan = await planStart({ cwd, hostPort, imageExists: true, forceBuild: false, tuiToken })
627
648
  return {
628
649
  ok: true,
629
650
  plan,
630
651
  containerId,
631
652
  built: false,
632
653
  hostPort,
654
+ tuiToken,
633
655
  hostd: { state: 'disabled' },
634
656
  alreadyRunning: true,
635
657
  autoUpgrade: { kind: 'skipped-already-running' },
@@ -693,7 +715,13 @@ async function detectDevSource(cwd: string): Promise<string | null> {
693
715
  // invalid mount entry — must surface so the user sees they configured a mount
694
716
  // that won't be applied.
695
717
  async function loadTypeclawConfig(cwd: string): Promise<Config> {
696
- return configSchema.parse(await loadConfigJson(cwd))
718
+ // Goes through the shared loadConfigSync so the legacy-shape migration
719
+ // (and its paired git commit via persistMigratedConfig) follows every
720
+ // start() read path — refreshGitignore, the docker run argv builder, and
721
+ // the daemon-register payload all eventually land here. The function is
722
+ // declared async only to preserve the existing await sites; the work is
723
+ // synchronous under the hood.
724
+ return loadConfigSync(cwd)
697
725
  }
698
726
 
699
727
  async function registerWithDaemon({
@@ -740,16 +768,6 @@ async function useCurrentHostDaemon(): Promise<{ ok: true; httpPort: number } |
740
768
  return { ok: true, httpPort: result.port }
741
769
  }
742
770
 
743
- async function loadConfigJson(cwd: string): Promise<unknown> {
744
- let raw: string
745
- try {
746
- raw = await readFile(join(cwd, CONFIG_FILE), 'utf8')
747
- } catch {
748
- return {}
749
- }
750
- return JSON.parse(raw)
751
- }
752
-
753
771
  type PreparedHostDaemonStatus =
754
772
  | { state: 'registered'; control: HostDaemonControl }
755
773
  | { state: 'unavailable'; reason: string }
@@ -100,8 +100,24 @@ async function runPrompt(
100
100
  stream: Stream,
101
101
  ): Promise<void> {
102
102
  if (job.subagent !== undefined) {
103
+ // Propagate the cron job's role and origin into the spawned subagent.
104
+ // Without this, every cron-triggered subagent (e.g. memory dreaming)
105
+ // resolves to `guest` because the new-session consumer reads provenance
106
+ // off the stream target rather than rebuilding it. Encode the parent
107
+ // origin as JSON since StreamTarget is a flat-string shape.
108
+ const parentOrigin: SessionOrigin = {
109
+ kind: 'cron',
110
+ jobId: job.id,
111
+ jobKind: 'prompt',
112
+ ...(job.scheduledByRole !== undefined ? { scheduledByRole: job.scheduledByRole } : {}),
113
+ }
103
114
  stream.publish({
104
- target: { kind: 'new-session', subagent: job.subagent },
115
+ target: {
116
+ kind: 'new-session',
117
+ subagent: job.subagent,
118
+ ...(job.scheduledByRole !== undefined ? { spawnedByRole: job.scheduledByRole } : {}),
119
+ spawnedByOriginJson: JSON.stringify(parentOrigin),
120
+ },
105
121
  payload: job.payload,
106
122
  })
107
123
  return
@@ -131,11 +147,15 @@ async function runPrompt(
131
147
  sessionId: session.sessionId,
132
148
  parentTranscriptPath: session.getTranscriptPath?.(),
133
149
  idleMs: 0,
150
+ ...(session.origin !== undefined ? { origin: session.origin } : {}),
134
151
  })
135
152
  }
136
153
  } finally {
137
154
  if (session.hooks && session.sessionId !== undefined) {
138
- await session.hooks.runSessionEnd({ sessionId: session.sessionId })
155
+ await session.hooks.runSessionEnd({
156
+ sessionId: session.sessionId,
157
+ ...(session.origin !== undefined ? { origin: session.origin } : {}),
158
+ })
139
159
  }
140
160
  session.dispose?.()
141
161
  }
package/src/cron/index.ts CHANGED
@@ -1,10 +1,17 @@
1
1
  import { existsSync } from 'node:fs'
2
- import { readFile } from 'node:fs/promises'
2
+ import { readFile, writeFile } from 'node:fs/promises'
3
3
  import { join } from 'node:path'
4
4
 
5
5
  import type { SubagentRegistry } from '@/agent/subagents'
6
+ import { commitSystemFile } from '@/git/system-commit'
6
7
 
7
- import { type CronFile, parseCronFile } from './schema'
8
+ import {
9
+ buildCronMigrationCommitMessage,
10
+ type CronFile,
11
+ type CronMigrationStep,
12
+ migrateLegacyCronShape,
13
+ parseCronFile,
14
+ } from './schema'
8
15
 
9
16
  export { createCronReloadable, type CreateCronReloadableOptions } from './reloadable'
10
17
  export {
@@ -15,7 +22,18 @@ export {
15
22
  type CronSession,
16
23
  } from './consumer'
17
24
  export { createScheduler, type JobDiff, type Scheduler, type SchedulerLogger } from './scheduler'
18
- export { cronFileSchema, cronJobSchema, type CronFile, type CronJob, type ExecJob, type PromptJob } from './schema'
25
+ export {
26
+ buildCronMigrationCommitMessage,
27
+ cronFileSchema,
28
+ cronJobSchema,
29
+ type CronFile,
30
+ type CronJob,
31
+ type CronMigrationResult,
32
+ type CronMigrationStep,
33
+ type ExecJob,
34
+ migrateLegacyCronShape,
35
+ type PromptJob,
36
+ } from './schema'
19
37
 
20
38
  const CRON_FILE = 'cron.json'
21
39
 
@@ -43,12 +61,35 @@ export async function loadCron(agentDir: string, options: LoadCronOptions = {}):
43
61
  return { ok: false, reason: `cron.json is not valid JSON: ${errorMessage(err)}` }
44
62
  }
45
63
 
46
- const result = parseCronFile(parsed, options.subagents !== undefined ? { subagents: options.subagents } : {})
64
+ const migrated = migrateLegacyCronShape(parsed)
65
+ if (migrated.changed) {
66
+ await persistMigratedCron(path, migrated.json, agentDir, migrated.applied)
67
+ }
68
+
69
+ const result = parseCronFile(migrated.json, options.subagents !== undefined ? { subagents: options.subagents } : {})
47
70
  if (!result.ok) return { ok: false, reason: result.reason }
48
71
 
49
72
  return { ok: true, file: result.file }
50
73
  }
51
74
 
75
+ async function persistMigratedCron(
76
+ path: string,
77
+ json: unknown,
78
+ agentDir: string,
79
+ applied: readonly CronMigrationStep[],
80
+ ): Promise<void> {
81
+ try {
82
+ await writeFile(path, `${JSON.stringify(json, null, 2)}\n`)
83
+ } catch {
84
+ return
85
+ }
86
+
87
+ const message = buildCronMigrationCommitMessage(applied)
88
+ if (message !== null) {
89
+ await commitSystemFile(agentDir, CRON_FILE, message)
90
+ }
91
+ }
92
+
52
93
  function errorMessage(err: unknown): string {
53
94
  return err instanceof Error ? err.message : String(err)
54
95
  }
@@ -11,6 +11,16 @@ const baseJob = z.object({
11
11
  schedule: z.string().min(1),
12
12
  enabled: z.boolean().default(true),
13
13
  timezone: z.string().optional(),
14
+ scheduledByRole: z.string().optional(),
15
+ // Audit snapshot of the SessionOrigin that scheduled this job. Persisted
16
+ // as opaque z.unknown() because SessionOrigin is recursive (a cron origin
17
+ // can contain a subagent origin can contain another cron origin, etc.)
18
+ // and we do not want to mirror that union in the cron schema. The cron
19
+ // consumer reads this back as-is and stamps it into the firing session's
20
+ // origin without further validation -- if it's malformed, role
21
+ // resolution falls back to `guest` via the same path that handles
22
+ // missing fields.
23
+ scheduledByOrigin: z.unknown().optional(),
14
24
  })
15
25
 
16
26
  const promptJob = baseJob.extend({
@@ -46,6 +56,93 @@ export type ParseCronOptions = {
46
56
  subagents?: SubagentRegistry
47
57
  }
48
58
 
59
+ // One-shot rewrite for cron.json files that predate PR #171, when
60
+ // `scheduledByRole` became mandatory on every job. The schema gate
61
+ // (`parseCronFile`) rejects legacy entries with a precise remediation
62
+ // message, but rejecting on every container boot is a stuck state for
63
+ // the user — the agent crashes in a tight restart loop with no path
64
+ // forward except hand-editing cron.json.
65
+ //
66
+ // The migration stamps `scheduledByRole: 'owner'` on every job that's
67
+ // missing it. `owner` is the right default for two reasons:
68
+ // 1. Before #171 there was no role concept; every cron job ran with
69
+ // the same (effectively-owner) privileges the agent had.
70
+ // 2. The schema gate's own error message tells users to add
71
+ // `"scheduledByRole": "owner"` for manually-authored entries —
72
+ // we just do it for them.
73
+ //
74
+ // Mirrors `migrateLegacyConfigShape` in src/config/config.ts: pure
75
+ // function, returns the rewritten JSON plus an `applied` array so
76
+ // callers can build a meaningful commit message. Returns `changed:
77
+ // false` on canonical input so the persist + commit path stays
78
+ // untouched on the happy path.
79
+ export type CronMigrationStep = { kind: 'stamp-scheduled-by-role-owner'; jobIds: string[] }
80
+
81
+ export type CronMigrationResult = { json: unknown; changed: boolean; applied: CronMigrationStep[] }
82
+
83
+ export function migrateLegacyCronShape(json: unknown): CronMigrationResult {
84
+ if (typeof json !== 'object' || json === null || Array.isArray(json)) {
85
+ return { json, changed: false, applied: [] }
86
+ }
87
+
88
+ const obj = json as Record<string, unknown>
89
+ const jobs = obj.jobs
90
+ if (!Array.isArray(jobs)) {
91
+ return { json, changed: false, applied: [] }
92
+ }
93
+
94
+ const stampedIds: string[] = []
95
+ const nextJobs = jobs.map((job) => {
96
+ if (typeof job !== 'object' || job === null || Array.isArray(job)) return job
97
+ const record = job as Record<string, unknown>
98
+ if ('scheduledByRole' in record) return job
99
+ const id = typeof record.id === 'string' ? record.id : '<unknown>'
100
+ stampedIds.push(id)
101
+ return { ...record, scheduledByRole: 'owner' }
102
+ })
103
+
104
+ if (stampedIds.length === 0) {
105
+ return { json, changed: false, applied: [] }
106
+ }
107
+
108
+ return {
109
+ json: { ...obj, jobs: nextJobs },
110
+ changed: true,
111
+ applied: [{ kind: 'stamp-scheduled-by-role-owner', jobIds: stampedIds }],
112
+ }
113
+ }
114
+
115
+ // Builds a one-line git commit subject (plus enumerating body) for a
116
+ // cron.json migration. Returns null when no steps were applied — callers
117
+ // should not commit in that case. Mirrors `buildConfigMigrationCommitMessage`
118
+ // in src/config/config.ts.
119
+ export function buildCronMigrationCommitMessage(applied: readonly CronMigrationStep[]): string | null {
120
+ const first = applied[0]
121
+ if (first === undefined) return null
122
+
123
+ const subject =
124
+ applied.length === 1
125
+ ? `cron.json: ${shortCronStepLabel(first)}`
126
+ : `cron.json: migrate legacy shape (${applied.length} steps)`
127
+
128
+ const bodyLines: string[] = applied.map((step) => `- ${describeCronStep(step)}`)
129
+ return `${subject}\n\n${bodyLines.join('\n')}\n`
130
+ }
131
+
132
+ function shortCronStepLabel(step: CronMigrationStep): string {
133
+ switch (step.kind) {
134
+ case 'stamp-scheduled-by-role-owner':
135
+ return `stamp scheduledByRole: "owner" on ${step.jobIds.length} legacy job${step.jobIds.length === 1 ? '' : 's'}`
136
+ }
137
+ }
138
+
139
+ function describeCronStep(step: CronMigrationStep): string {
140
+ switch (step.kind) {
141
+ case 'stamp-scheduled-by-role-owner':
142
+ return `stamp scheduledByRole: "owner" on jobs without provenance (PR #171 backfill): ${step.jobIds.join(', ')}`
143
+ }
144
+ }
145
+
49
146
  export function parseCronFile(raw: unknown, options: ParseCronOptions = {}): ParseCronResult {
50
147
  const parsed = cronFileSchema.safeParse(raw)
51
148
  if (!parsed.success) {
@@ -73,6 +170,13 @@ export function parseCronFile(raw: unknown, options: ParseCronOptions = {}): Par
73
170
  return { ok: false, reason: `job ${job.id}: invalid schedule "${job.schedule}": ${message}` }
74
171
  }
75
172
 
173
+ if (job.scheduledByRole === undefined) {
174
+ return {
175
+ ok: false,
176
+ reason: `job ${job.id}: missing 'scheduledByRole'. Add "scheduledByRole": "owner" if you authored this entry manually.`,
177
+ }
178
+ }
179
+
76
180
  if (job.kind === 'prompt' && job.subagent !== undefined && options.subagents !== undefined) {
77
181
  const subagent = options.subagents[job.subagent]
78
182
  if (!subagent) {