typeclaw 0.33.0 → 0.34.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 (61) hide show
  1. package/auth.schema.json +66 -0
  2. package/cron.schema.json +26 -2
  3. package/package.json +1 -1
  4. package/secrets.schema.json +66 -0
  5. package/src/agent/index.ts +7 -3
  6. package/src/agent/session-origin.ts +17 -0
  7. package/src/agent/subagent-completion-reminder.ts +14 -1
  8. package/src/agent/subagent-drain.ts +2 -0
  9. package/src/agent/subagents.ts +21 -7
  10. package/src/agent/tools/channel-disengage.ts +66 -0
  11. package/src/agent/tools/channel-log.ts +3 -2
  12. package/src/agent/tools/spawn-subagent.ts +25 -5
  13. package/src/agent/tools/subagent-output.ts +13 -1
  14. package/src/bundled-plugins/guard/policies/managed-config.ts +1 -1
  15. package/src/bundled-plugins/memory/memory-logger.ts +7 -0
  16. package/src/bundled-plugins/researcher/researcher.ts +14 -11
  17. package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +1 -0
  18. package/src/channels/adapters/line-channel-resolver.ts +129 -0
  19. package/src/channels/adapters/line-classify.ts +80 -0
  20. package/src/channels/adapters/line-format.ts +11 -0
  21. package/src/channels/adapters/line.ts +350 -0
  22. package/src/channels/engagement.ts +4 -2
  23. package/src/channels/manager.ts +65 -6
  24. package/src/channels/router.ts +186 -41
  25. package/src/channels/schema.ts +6 -1
  26. package/src/cli/channel.ts +112 -1
  27. package/src/cli/cron.ts +22 -4
  28. package/src/cli/oauth-callbacks.ts +5 -4
  29. package/src/config/providers.ts +62 -0
  30. package/src/cron/consumer.ts +33 -0
  31. package/src/cron/count-state.ts +208 -0
  32. package/src/cron/index.ts +4 -17
  33. package/src/cron/list.ts +24 -6
  34. package/src/cron/scheduler.ts +84 -9
  35. package/src/cron/schema.ts +100 -13
  36. package/src/doctor/channel-checks.ts +28 -0
  37. package/src/hostd/daemon.ts +14 -6
  38. package/src/hostd/protocol.ts +6 -2
  39. package/src/init/gitignore.ts +1 -1
  40. package/src/init/index.ts +36 -3
  41. package/src/init/line-auth.ts +98 -0
  42. package/src/init/models-dev.ts +1 -0
  43. package/src/init/run-owner-claim.ts +1 -0
  44. package/src/init/validate-api-key.ts +2 -0
  45. package/src/inspect/label.ts +1 -0
  46. package/src/permissions/match-rule.ts +28 -12
  47. package/src/permissions/resolve.ts +8 -1
  48. package/src/role-claim/match-rule.ts +5 -1
  49. package/src/run/index.ts +41 -4
  50. package/src/secrets/line-store.ts +112 -0
  51. package/src/secrets/oauth-xai.ts +1 -1
  52. package/src/secrets/schema.ts +25 -0
  53. package/src/server/index.ts +17 -4
  54. package/src/shared/protocol.ts +4 -1
  55. package/src/skills/typeclaw-channel-line/SKILL.md +46 -0
  56. package/src/skills/typeclaw-channels/SKILL.md +153 -0
  57. package/src/skills/typeclaw-config/SKILL.md +54 -184
  58. package/src/skills/typeclaw-config/references/dockerfile.md +66 -0
  59. package/src/skills/typeclaw-cron/SKILL.md +68 -14
  60. package/src/skills/typeclaw-permissions/SKILL.md +3 -3
  61. package/typeclaw.schema.json +167 -3
@@ -22,8 +22,10 @@ import {
22
22
  type GithubCredentialPatch,
23
23
  type GithubTunnelProvider,
24
24
  type KakaotalkAuthResult,
25
+ type LineAuthResult,
25
26
  } from '@/init'
26
27
  import { runKakaotalkBootstrap } from '@/init/kakaotalk-auth'
28
+ import { runLineBootstrap } from '@/init/line-auth'
27
29
  import { SecretsKakaoCredentialStore } from '@/secrets/kakao-store'
28
30
 
29
31
  import { CANCEL_SYMBOL, promptPrivateKeyPem } from './prompt-pem'
@@ -33,6 +35,7 @@ const CHANNEL_LABELS: Record<ChannelKind, string> = {
33
35
  'slack-bot': 'Slack',
34
36
  'discord-bot': 'Discord',
35
37
  'telegram-bot': 'Telegram',
38
+ line: 'LINE',
36
39
  kakaotalk: 'KakaoTalk',
37
40
  github: 'GitHub',
38
41
  }
@@ -127,6 +130,15 @@ const setSub = defineCommand({
127
130
  process.exit(1)
128
131
  }
129
132
 
133
+ if (args.adapter === 'line') {
134
+ console.error(
135
+ errorLine(
136
+ 'LINE uses an interactive auth flow (QR or email + PIN). Use `typeclaw channel reauth line` to rotate its credentials.',
137
+ ),
138
+ )
139
+ process.exit(1)
140
+ }
141
+
130
142
  const adapter =
131
143
  args.adapter === undefined
132
144
  ? await pickSettableAdapter(configured)
@@ -138,7 +150,7 @@ const setSub = defineCommand({
138
150
  },
139
151
  })
140
152
 
141
- const REAUTHABLE_ADAPTERS = ['kakaotalk'] as const
153
+ const REAUTHABLE_ADAPTERS = ['line', 'kakaotalk'] as const
142
154
  type ReauthableAdapter = (typeof REAUTHABLE_ADAPTERS)[number]
143
155
 
144
156
  const reauthSub = defineCommand({
@@ -234,12 +246,29 @@ function isReauthableAdapter(value: string): value is ReauthableAdapter {
234
246
 
235
247
  async function runReauth(cwd: string, adapter: ReauthableAdapter): Promise<void> {
236
248
  switch (adapter) {
249
+ case 'line':
250
+ await runLineReauth(cwd)
251
+ return
237
252
  case 'kakaotalk':
238
253
  await runKakaotalkReauth(cwd)
239
254
  return
240
255
  }
241
256
  }
242
257
 
258
+ async function runLineReauth(cwd: string): Promise<void> {
259
+ const login = await promptLineLogin()
260
+ const s = spinner()
261
+ s.start('Logging in to LINE...')
262
+ const result = await runLineBootstrap({ ...login, agentDir: cwd })
263
+ if (!result.ok) {
264
+ s.stop(`LINE login failed: ${result.reason}`)
265
+ process.exit(1)
266
+ }
267
+ s.stop('LINE credentials refreshed in secrets.json.')
268
+
269
+ await maybePromptReauthRefresh(cwd, 'line')
270
+ }
271
+
243
272
  async function runKakaotalkReauth(cwd: string): Promise<void> {
244
273
  const existingEmail = await readExistingKakaotalkEmail(cwd)
245
274
  const creds = await promptKakaotalkCredentials({ defaultEmail: existingEmail })
@@ -566,6 +595,7 @@ type CollectedCredentials =
566
595
  | { channel: 'discord-bot'; discordBotToken: string }
567
596
  | { channel: 'slack-bot'; slackBotToken: string; slackAppToken: string }
568
597
  | { channel: 'telegram-bot'; telegramBotToken: string }
598
+ | { channel: 'line'; runLineAuth: (options: { cwd: string }) => Promise<LineAuthResult> }
569
599
  | { channel: 'kakaotalk'; runKakaotalkAuth: (options: { cwd: string }) => Promise<KakaotalkAuthResult> }
570
600
  | {
571
601
  channel: 'github'
@@ -587,6 +617,13 @@ async function collectCredentials(channel: ChannelKind, cwd: string): Promise<Co
587
617
  }
588
618
  case 'telegram-bot':
589
619
  return { channel, telegramBotToken: await promptTelegramToken() }
620
+ case 'line': {
621
+ const login = await promptLineLogin()
622
+ return {
623
+ channel,
624
+ runLineAuth: ({ cwd: agentDir }) => runLineBootstrap({ ...login, agentDir }),
625
+ }
626
+ }
590
627
  case 'kakaotalk': {
591
628
  const creds = await promptKakaotalkCredentials()
592
629
  return {
@@ -1023,6 +1060,71 @@ async function promptKakaotalkCredentials(
1023
1060
  return { email, password: pwd }
1024
1061
  }
1025
1062
 
1063
+ type LinePromptResult =
1064
+ | { method: 'qr'; callbacks: { onQRUrl: (url: string) => void; onPincode: (pin: string) => void } }
1065
+ | {
1066
+ method: 'email'
1067
+ email: string
1068
+ password: string
1069
+ callbacks: { onPincode: (pin: string) => void }
1070
+ }
1071
+
1072
+ async function promptLineLogin(): Promise<LinePromptResult> {
1073
+ note(
1074
+ [
1075
+ 'LINE authentication uses a personal account registered as a sub-device.',
1076
+ 'Messages will be sent and received under this account — use a',
1077
+ 'non-primary account if possible.',
1078
+ '',
1079
+ 'QR login is recommended: it works even when the account has no',
1080
+ 'email/password set (social-login accounts).',
1081
+ ].join('\n'),
1082
+ 'About to log in to LINE',
1083
+ )
1084
+ const method = await select<'qr' | 'email'>({
1085
+ message: 'How do you want to log in to LINE?',
1086
+ options: [
1087
+ { value: 'qr', label: 'QR code — scan with the LINE app on your phone (recommended)' },
1088
+ { value: 'email', label: 'Email + password — for accounts with email login enabled' },
1089
+ ],
1090
+ initialValue: 'qr',
1091
+ })
1092
+ if (isCancel(method)) {
1093
+ cancel('Aborted.')
1094
+ process.exit(0)
1095
+ }
1096
+
1097
+ const onPincode = (pin: string): void => log.info(`Enter this PIN in the LINE app to confirm: ${pin}`)
1098
+
1099
+ if (method === 'qr') {
1100
+ return {
1101
+ method: 'qr',
1102
+ callbacks: {
1103
+ onQRUrl: (url) => note(url, 'Open this URL on your phone (or scan the QR it renders)'),
1104
+ onPincode,
1105
+ },
1106
+ }
1107
+ }
1108
+
1109
+ const email = await text({
1110
+ message: 'LINE email',
1111
+ validate: (value) => (value && value.length > 0 ? undefined : 'Email is required'),
1112
+ })
1113
+ if (isCancel(email)) {
1114
+ cancel('Aborted.')
1115
+ process.exit(0)
1116
+ }
1117
+ const pwd = await password({
1118
+ message: 'LINE password',
1119
+ validate: (value) => (value && value.length > 0 ? undefined : 'Password is required'),
1120
+ })
1121
+ if (isCancel(pwd)) {
1122
+ cancel('Aborted.')
1123
+ process.exit(0)
1124
+ }
1125
+ return { method: 'email', email, password: pwd, callbacks: { onPincode } }
1126
+ }
1127
+
1026
1128
  function reportProgress(events: AddChannelStepEvent[]): (event: AddChannelStepEvent) => void {
1027
1129
  const spinners: Partial<Record<AddChannelStepEvent['step'], ReturnType<typeof spinner>>> = {}
1028
1130
 
@@ -1039,6 +1141,9 @@ function reportProgress(events: AddChannelStepEvent[]): (event: AddChannelStepEv
1039
1141
  if (!s) return
1040
1142
 
1041
1143
  switch (event.step) {
1144
+ case 'line-auth':
1145
+ s.stop(reportLineAuth(event.result))
1146
+ break
1042
1147
  case 'kakaotalk-auth':
1043
1148
  s.stop(reportKakaotalkAuth(event.result))
1044
1149
  break
@@ -1056,6 +1161,7 @@ function reportProgress(events: AddChannelStepEvent[]): (event: AddChannelStepEv
1056
1161
  }
1057
1162
 
1058
1163
  const START_MESSAGES: Record<AddChannelStepEvent['step'], string> = {
1164
+ 'line-auth': 'Logging in to LINE...',
1059
1165
  'kakaotalk-auth': 'Logging in to KakaoTalk...',
1060
1166
  config: 'Updating typeclaw.json...',
1061
1167
  secrets: 'Saving credentials to secrets.json...',
@@ -1067,6 +1173,11 @@ function reportKakaotalkAuth(result: KakaotalkAuthResult): string {
1067
1173
  return `KakaoTalk login failed: ${result.reason}`
1068
1174
  }
1069
1175
 
1176
+ function reportLineAuth(result: LineAuthResult): string {
1177
+ if (result.ok) return 'LINE credentials saved to secrets.json.'
1178
+ return `LINE login failed: ${result.reason}`
1179
+ }
1180
+
1070
1181
  async function maybePromptRestart(cwd: string, channel: ChannelKind): Promise<void> {
1071
1182
  const label = CHANNEL_LABELS[channel]
1072
1183
  const current = await status({ cwd }).catch(() => null)
package/src/cli/cron.ts CHANGED
@@ -114,12 +114,30 @@ function formatEntry(job: CronListEntryPayload, nowMs: number): string {
114
114
  const kindBadge = c.dim(`[${job.kind}]`)
115
115
  lines.push(`${c.bold(displayId(job))} ${kindBadge} ${sourceLabel}${enabledBadge}`)
116
116
 
117
- const tz = job.timezone !== undefined ? ` ${c.dim(`(${job.timezone})`)}` : ''
118
- lines.push(` ${c.dim('schedule')} ${job.schedule}${tz}`)
117
+ if (job.at !== undefined) {
118
+ lines.push(` ${c.dim('at ')} ${job.at}`)
119
+ } else {
120
+ const tz = job.timezone !== undefined ? ` ${c.dim(`(${job.timezone})`)}` : ''
121
+ lines.push(` ${c.dim('schedule')} ${job.schedule}${tz}`)
122
+ }
123
+
124
+ const stops: string[] = []
125
+ if (job.until !== undefined) stops.push(`until ${job.until}`)
126
+ if (job.count !== undefined) stops.push(`count ${job.count}`)
127
+ if (stops.length > 0) {
128
+ lines.push(` ${c.dim('stops ')} ${stops.join(', ')}`)
129
+ }
119
130
 
120
131
  if (job.nextFireMs === null) {
121
- const why = job.scheduleError !== undefined ? `: ${job.scheduleError}` : ''
122
- lines.push(` ${c.dim('next ')} ${c.red('invalid schedule')}${why}`)
132
+ // A null next-fire means one of two very different things. A parse error
133
+ // carries `scheduleError` and is a problem to surface in red. No error
134
+ // means the job hit its count/until boundary and is simply retired — not
135
+ // broken — so it must not be mislabeled as an invalid schedule.
136
+ if (job.scheduleError !== undefined) {
137
+ lines.push(` ${c.dim('next ')} ${c.red('invalid schedule')}: ${job.scheduleError}`)
138
+ } else {
139
+ lines.push(` ${c.dim('next ')} ${c.dim('retired (boundary reached)')}`)
140
+ }
123
141
  } else {
124
142
  lines.push(` ${c.dim('next ')} ${formatNextFire(job.nextFireMs, nowMs)}`)
125
143
  }
@@ -44,8 +44,9 @@ export function buildOAuthCallbacks(providerName: string): OAuthCallbackHandle {
44
44
  const preamble = [
45
45
  `Open this URL in your browser to sign in to ${providerName}.`,
46
46
  '',
47
- 'If your browser shows "this site can\'t be reached" after you sign in,',
48
- 'copy the full address from the top of the browser and paste it below.',
47
+ 'If the page after sign-in shows a code to copy (or a "this site can\'t',
48
+ 'be reached" / "could not establish connection" error), copy that code ',
49
+ 'or the full address from the top of the browser — and paste it below.',
49
50
  ]
50
51
  if (instructions) preamble.push('', instructions)
51
52
  note(preamble.join('\n'), 'Browser login')
@@ -67,8 +68,8 @@ export function buildOAuthCallbacks(providerName: string): OAuthCallbackHandle {
67
68
  onManualCodeInput: async () => {
68
69
  const value = await text({
69
70
  message:
70
- 'If your browser shows "this site can\'t be reached" after you sign in, copy the full address from the top of the browser and paste it here:',
71
- placeholder: 'http://localhost:1455/auth/callback?code=...&state=...',
71
+ 'After signing in, paste the code shown on the page (some providers offer a copy button), or the full redirect address from the top of the browser:',
72
+ placeholder: 'code, or http://localhost:1455/auth/callback?code=...&state=...',
72
73
  signal,
73
74
  })
74
75
  if (isCancel(value)) throw new Error('Login cancelled by user')
@@ -646,6 +646,63 @@ export const KNOWN_PROVIDERS = {
646
646
  },
647
647
  },
648
648
  },
649
+ // DeepSeek (api.deepseek.com) pay-as-you-go API. OpenAI-compatible (Bearer
650
+ // auth + /chat/completions shape), so models go through pi-ai's
651
+ // `openai-completions` adapter with a custom baseUrl — same trick as
652
+ // Fireworks, Z.AI, and MiniMax. api-key only; DeepSeek ships no OAuth flow.
653
+ //
654
+ // baseUrl is bare `https://api.deepseek.com` (no `/v1` segment) — the SDK
655
+ // appends `/chat/completions`. This mirrors Anthropic's no-`/v1` convention,
656
+ // not the OpenAI/xAI `/v1` one; `validate-api-key.ts` probes
657
+ // `${baseUrl}/models` accordingly.
658
+ //
659
+ // Model lineup is the V4 generation as listed on api-docs.deepseek.com/quick_start/pricing
660
+ // as of 2026-06-08: deepseek-v4-flash (fast, cheap default) and
661
+ // deepseek-v4-pro (stronger). Both default to thinking/reasoning mode (it is
662
+ // toggleable upstream, but reasoning: true reflects the default). 1M context,
663
+ // 384K max output, text-only — DeepSeek's API exposes no image input. The
664
+ // legacy `deepseek-chat` / `deepseek-reasoner` aliases (deprecated 2026-07-24,
665
+ // they redirect into v4-flash's non-thinking/thinking modes) are intentionally
666
+ // omitted in favor of the canonical v4 ids.
667
+ //
668
+ // Costs are USD per 1M tokens (standard tier). DeepSeek prices input on a
669
+ // cache-miss/cache-hit split: `input` is the cache-miss rate, `cacheRead` is
670
+ // the cache-hit rate. There is no published cache-write surcharge, so
671
+ // cacheWrite is 0.
672
+ deepseek: {
673
+ id: 'deepseek',
674
+ name: 'DeepSeek',
675
+ baseUrl: 'https://api.deepseek.com',
676
+ auth: ['api-key'],
677
+ apiKeyEnv: 'DEEPSEEK_API_KEY',
678
+ oauthProviderId: null,
679
+ models: {
680
+ 'deepseek-v4-flash': {
681
+ id: 'deepseek-v4-flash',
682
+ name: 'DeepSeek V4 Flash',
683
+ api: 'openai-completions',
684
+ provider: 'deepseek',
685
+ baseUrl: 'https://api.deepseek.com',
686
+ reasoning: true,
687
+ input: ['text'],
688
+ cost: { input: 0.14, output: 0.28, cacheRead: 0.0028, cacheWrite: 0 },
689
+ contextWindow: 1000000,
690
+ maxTokens: 384000,
691
+ },
692
+ 'deepseek-v4-pro': {
693
+ id: 'deepseek-v4-pro',
694
+ name: 'DeepSeek V4 Pro',
695
+ api: 'openai-completions',
696
+ provider: 'deepseek',
697
+ baseUrl: 'https://api.deepseek.com',
698
+ reasoning: true,
699
+ input: ['text'],
700
+ cost: { input: 0.435, output: 0.87, cacheRead: 0.003625, cacheWrite: 0 },
701
+ contextWindow: 1000000,
702
+ maxTokens: 384000,
703
+ },
704
+ },
705
+ },
649
706
  } as const satisfies Record<string, KnownProvider>
650
707
 
651
708
  export type KnownProviderId = keyof typeof KNOWN_PROVIDERS
@@ -714,6 +771,11 @@ export const KNOWN_PROVIDER_VENDORS = {
714
771
  name: 'MiniMax',
715
772
  providers: ['minimax'],
716
773
  },
774
+ deepseek: {
775
+ id: 'deepseek',
776
+ name: 'DeepSeek',
777
+ providers: ['deepseek'],
778
+ },
717
779
  } as const satisfies Record<string, KnownProviderVendor>
718
780
 
719
781
  export type KnownProviderVendorId = keyof typeof KNOWN_PROVIDER_VENDORS
@@ -56,9 +56,22 @@ export type CreateCronConsumerOptions = {
56
56
  // `ctx.exec`. Optional so unit-test fakes that never schedule handler jobs
57
57
  // stay one-liners.
58
58
  invokeHandler?: CronHandlerInvoker
59
+ // Authoritative count gate. The consumer — not the scheduler — owns
60
+ // accepted-fire accounting: it re-checks the durable count and increments
61
+ // only for runs that pass coalescing, so a coalesced skip never consumes a
62
+ // count. Optional so test fakes that don't exercise counts stay one-liners.
63
+ countStore?: ConsumerCountStore
64
+ now?: () => number
59
65
  logger?: CronConsumerLogger
60
66
  }
61
67
 
68
+ export type ConsumerCountStore = {
69
+ get: (id: string, job: CronJob) => number
70
+ // Resolves true if the fire was accepted/counted, false if the job is no
71
+ // longer live (so the consumer skips dispatching stale config).
72
+ increment: (id: string, job: CronJob, at: number) => Promise<boolean>
73
+ }
74
+
62
75
  export type CronConsumer = {
63
76
  start: () => void
64
77
  stop: () => void
@@ -76,6 +89,8 @@ export function createCronConsumer({
76
89
  cwd,
77
90
  createSessionForCron,
78
91
  invokeHandler,
92
+ countStore,
93
+ now = Date.now,
79
94
  logger = consoleLogger,
80
95
  }: CreateCronConsumerOptions): CronConsumer {
81
96
  const inFlight = new Set<string>()
@@ -94,8 +109,26 @@ export function createCronConsumer({
94
109
  logger.warn(`[cron] ${job.id}: previous run still in progress, skipping`)
95
110
  return
96
111
  }
112
+ // Reserve before the count gate so two close occurrences can't both
113
+ // pass the `firedCount < count` check before either increment lands.
97
114
  inFlight.add(job.id)
98
115
  try {
116
+ if (job.count !== undefined && countStore !== undefined) {
117
+ if (countStore.get(job.id, job) >= job.count) {
118
+ logger.info(`[cron] ${job.id}: count boundary reached, skipping`)
119
+ return
120
+ }
121
+ // Durably record the accepted fire BEFORE dispatch. A crash here
122
+ // consumes the count without running (at-most-count), which is the
123
+ // correct tradeoff for a reminder versus over-firing on restart.
124
+ // A false result means a reload removed/replaced the job while the
125
+ // write was queued — skip dispatch so we never run stale config.
126
+ const accepted = await countStore.increment(job.id, job, now())
127
+ if (!accepted) {
128
+ logger.info(`[cron] ${job.id}: job no longer live, skipping dispatch`)
129
+ return
130
+ }
131
+ }
99
132
  if (job.kind === 'prompt') {
100
133
  await runPrompt(job, createSessionForCron, stream, logger)
101
134
  } else if (job.kind === 'exec') {
@@ -0,0 +1,208 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
3
+ import { dirname, join } from 'node:path'
4
+
5
+ import type { CronJob } from './schema'
6
+
7
+ export const CRON_STATE_FILE = join('cron', 'state.json')
8
+
9
+ type StateEntry = {
10
+ progressFingerprint: string
11
+ firedCount: number
12
+ lastAcceptedAt: string
13
+ }
14
+
15
+ type StateFile = {
16
+ version: 1
17
+ jobs: Record<string, StateEntry>
18
+ }
19
+
20
+ export type CountStore = {
21
+ // Fingerprint-aware: returns 0 unless the stored entry's recurrence
22
+ // fingerprint matches `job`. A stale fire from a previous job definition (or
23
+ // a resurrected entry after reload) therefore can't gate the live job.
24
+ get: (id: string, job: CronJob) => number
25
+ // Resolves `true` when the fire was accepted and counted, `false` when the
26
+ // job is no longer in the live set (removed/replaced while this was queued).
27
+ // Callers use the result to skip dispatching a stale job, not just to avoid
28
+ // miscounting it. The verdict is computed inside the write mutex, so it
29
+ // reflects any reconcile that landed before the write ran.
30
+ increment: (id: string, job: CronJob, at: number) => Promise<boolean>
31
+ // Re-applies boot-time reconciliation against a new job set (called on
32
+ // `typeclaw reload`) so re-added/changed jobs don't inherit stale counts.
33
+ reconcile: (jobs: CronJob[]) => Promise<void>
34
+ }
35
+
36
+ export type CountStoreIO = {
37
+ read: (path: string) => Promise<string | null>
38
+ write: (path: string, data: string) => Promise<void>
39
+ }
40
+
41
+ const realIO: CountStoreIO = {
42
+ read: async (path) => (existsSync(path) ? readFile(path, 'utf8') : null),
43
+ // Temp-file + rename keeps readers from ever seeing a half-written file.
44
+ write: async (path, data) => {
45
+ await mkdir(dirname(path), { recursive: true })
46
+ const tmp = `${path}.${process.pid}.${Date.now()}.tmp`
47
+ await writeFile(tmp, data, 'utf8')
48
+ await rename(tmp, path)
49
+ },
50
+ }
51
+
52
+ // `progressFingerprint` identifies the job's RECURRENCE IDENTITY, deliberately
53
+ // excluding the mutable limits (`count`, `until`) and `enabled`. Two jobs with
54
+ // the same id but a changed schedule/target are different recurrences, so the
55
+ // fire counter resets. Bumping only `count` (3 → 5) leaves the fingerprint
56
+ // unchanged, so progress is preserved and the job resumes firing.
57
+ export function progressFingerprint(job: CronJob): string {
58
+ return JSON.stringify({
59
+ id: job.id,
60
+ schedule: job.schedule ?? null,
61
+ at: job.at ?? null,
62
+ timezone: job.timezone ?? null,
63
+ kind: job.kind,
64
+ target: targetIdentity(job),
65
+ })
66
+ }
67
+
68
+ function targetIdentity(job: CronJob): unknown {
69
+ if (job.kind === 'prompt') return { prompt: job.prompt, subagent: job.subagent ?? null, payload: job.payload ?? null }
70
+ if (job.kind === 'exec') return job.command
71
+ return { handler: String(job.handler) }
72
+ }
73
+
74
+ function matchingCount(entry: StateEntry | undefined, job: CronJob): number {
75
+ if (entry === undefined) return 0
76
+ return entry.progressFingerprint === progressFingerprint(job) ? entry.firedCount : 0
77
+ }
78
+
79
+ function serialize(state: StateFile): string {
80
+ return JSON.stringify(state)
81
+ }
82
+
83
+ function activeFingerprints(jobs: CronJob[]): Map<string, string> {
84
+ const map = new Map<string, string>()
85
+ for (const job of jobs) {
86
+ if (job.count !== undefined) map.set(job.id, progressFingerprint(job))
87
+ }
88
+ return map
89
+ }
90
+
91
+ export async function createCountStore(
92
+ agentDir: string,
93
+ jobs: CronJob[],
94
+ io: CountStoreIO = realIO,
95
+ ): Promise<CountStore> {
96
+ const path = join(agentDir, CRON_STATE_FILE)
97
+ const onDisk = await readState(path, io)
98
+ const state: StateFile = reconcile(onDisk, jobs)
99
+ // Serializes writes so concurrent increments (two jobs firing in the same
100
+ // tick) can't clobber each other via read-modify-write races.
101
+ let tail: Promise<void> = Promise.resolve()
102
+ // Guards against a straggler fire that lost a reconcile race re-adding a
103
+ // tombstone for a removed job: an increment whose id/fingerprint is not in
104
+ // the current live set is dropped. Maps each live id to its fingerprint.
105
+ let active = activeFingerprints(jobs)
106
+
107
+ // Only touch disk when reconciliation actually pruned/reset something.
108
+ // Avoids a force-committed no-op rewrite of cron/state.json on every boot.
109
+ if (serialize(state) !== serialize(onDisk)) {
110
+ await persist(path, state, io)
111
+ }
112
+
113
+ return {
114
+ get: (id, job) => matchingCount(state.jobs[id], job),
115
+ increment: (id, job, at) => {
116
+ const fp = progressFingerprint(job)
117
+ const run = tail.then(async () => {
118
+ // Re-check INSIDE the tail body, not just before queueing: a reconcile
119
+ // can land synchronously between the queue and this body running, so a
120
+ // sync-only guard would still let a straggler write a tombstone for a
121
+ // job that's since been removed. `active` reflects the latest reconcile.
122
+ if (active.get(id) !== fp) return false
123
+ const prev = matchingCount(state.jobs[id], job)
124
+ state.jobs[id] = {
125
+ progressFingerprint: fp,
126
+ firedCount: prev + 1,
127
+ lastAcceptedAt: new Date(at).toISOString(),
128
+ }
129
+ await persist(path, state, io)
130
+ return true
131
+ })
132
+ tail = run.then(
133
+ () => {},
134
+ () => {},
135
+ )
136
+ return run
137
+ },
138
+ reconcile: (nextJobs) => {
139
+ // In-memory map is authoritative for `get`, so it must settle before the
140
+ // caller arms timers; only the on-disk persist trails behind the mutex.
141
+ active = activeFingerprints(nextJobs)
142
+ state.jobs = reconcile(state, nextJobs).jobs
143
+ const run = tail.then(async () => {
144
+ await persist(path, state, io)
145
+ })
146
+ tail = run.catch(() => {})
147
+ return run
148
+ },
149
+ }
150
+ }
151
+
152
+ function emptyState(): StateFile {
153
+ return { version: 1, jobs: {} }
154
+ }
155
+
156
+ async function readState(path: string, io: CountStoreIO): Promise<StateFile> {
157
+ const raw = await io.read(path)
158
+ if (raw === null) return emptyState()
159
+ try {
160
+ return validateState(JSON.parse(raw))
161
+ } catch {
162
+ return emptyState()
163
+ }
164
+ }
165
+
166
+ // Durable on-disk state is untrusted: a corrupt or hand-edited file must never
167
+ // crash the scheduler or feed a bogus count into expiry. Drop the whole file
168
+ // on a version mismatch, and skip individual entries that aren't well-formed.
169
+ function validateState(raw: unknown): StateFile {
170
+ if (typeof raw !== 'object' || raw === null) return emptyState()
171
+ const obj = raw as { version?: unknown; jobs?: unknown }
172
+ if (obj.version !== 1 || typeof obj.jobs !== 'object' || obj.jobs === null) return emptyState()
173
+
174
+ const jobs: Record<string, StateEntry> = {}
175
+ for (const [id, value] of Object.entries(obj.jobs as Record<string, unknown>)) {
176
+ if (typeof value !== 'object' || value === null) continue
177
+ const e = value as Record<string, unknown>
178
+ if (typeof e.progressFingerprint !== 'string') continue
179
+ if (typeof e.firedCount !== 'number' || !Number.isInteger(e.firedCount) || e.firedCount < 0) continue
180
+ if (typeof e.lastAcceptedAt !== 'string') continue
181
+ jobs[id] = {
182
+ progressFingerprint: e.progressFingerprint,
183
+ firedCount: e.firedCount,
184
+ lastAcceptedAt: e.lastAcceptedAt,
185
+ }
186
+ }
187
+ return { version: 1, jobs }
188
+ }
189
+
190
+ // Boot/reload reconciliation. The scary footgun is a job id removed and later
191
+ // re-added with the SAME id: without this, the re-added job would inherit the
192
+ // old counter and never fire. We drop entries for ids that are gone or no
193
+ // longer counted, and reset entries whose recurrence fingerprint changed.
194
+ export function reconcile(state: StateFile, jobs: CronJob[]): StateFile {
195
+ const byId = new Map(jobs.map((j) => [j.id, j]))
196
+ const next: Record<string, StateEntry> = {}
197
+ for (const [id, entry] of Object.entries(state.jobs)) {
198
+ const job = byId.get(id)
199
+ if (!job || job.count === undefined) continue
200
+ if (entry.progressFingerprint !== progressFingerprint(job)) continue
201
+ next[id] = entry
202
+ }
203
+ return { version: 1, jobs: next }
204
+ }
205
+
206
+ async function persist(path: string, state: StateFile, io: CountStoreIO): Promise<void> {
207
+ await io.write(path, JSON.stringify(state, null, 2))
208
+ }
package/src/cron/index.ts CHANGED
@@ -6,22 +6,10 @@ import type { SubagentRegistry } from '@/agent/subagents'
6
6
 
7
7
  import { type CronFile, parseCronFile } from './schema'
8
8
 
9
- export { createCronReloadable, type CreateCronReloadableOptions } from './reloadable'
10
- export {
11
- createCronConsumer,
12
- type CreateCronConsumerOptions,
13
- type CronConsumer,
14
- type CronConsumerLogger,
15
- type CronSession,
16
- } from './consumer'
17
- export {
18
- type ComputeNextFireResult,
19
- computeNextFire,
20
- createScheduler,
21
- type JobDiff,
22
- type Scheduler,
23
- type SchedulerLogger,
24
- } from './scheduler'
9
+ export { type CountStore, createCountStore } from './count-state'
10
+ export { createCronReloadable } from './reloadable'
11
+ export { createCronConsumer, type CronConsumer } from './consumer'
12
+ export { computeNextFire, createScheduler, type JobDiff, type Scheduler } from './scheduler'
25
13
  export { aggregateCronList, type CronListEntry, type CronListSource } from './list'
26
14
  export {
27
15
  cronFileSchema,
@@ -31,7 +19,6 @@ export {
31
19
  type ExecJob,
32
20
  type HandlerJob,
33
21
  parseCronJson,
34
- type ParseCronJsonOptions,
35
22
  type ParseCronResult,
36
23
  type ParsedCronJob,
37
24
  type PromptJob,