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.
- package/auth.schema.json +66 -0
- package/cron.schema.json +26 -2
- package/package.json +1 -1
- package/secrets.schema.json +66 -0
- package/src/agent/index.ts +7 -3
- package/src/agent/session-origin.ts +17 -0
- package/src/agent/subagent-completion-reminder.ts +14 -1
- package/src/agent/subagent-drain.ts +2 -0
- package/src/agent/subagents.ts +21 -7
- package/src/agent/tools/channel-disengage.ts +66 -0
- package/src/agent/tools/channel-log.ts +3 -2
- package/src/agent/tools/spawn-subagent.ts +25 -5
- package/src/agent/tools/subagent-output.ts +13 -1
- package/src/bundled-plugins/guard/policies/managed-config.ts +1 -1
- package/src/bundled-plugins/memory/memory-logger.ts +7 -0
- package/src/bundled-plugins/researcher/researcher.ts +14 -11
- package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +1 -0
- package/src/channels/adapters/line-channel-resolver.ts +129 -0
- package/src/channels/adapters/line-classify.ts +80 -0
- package/src/channels/adapters/line-format.ts +11 -0
- package/src/channels/adapters/line.ts +350 -0
- package/src/channels/engagement.ts +4 -2
- package/src/channels/manager.ts +65 -6
- package/src/channels/router.ts +186 -41
- package/src/channels/schema.ts +6 -1
- package/src/cli/channel.ts +112 -1
- package/src/cli/cron.ts +22 -4
- package/src/cli/oauth-callbacks.ts +5 -4
- package/src/config/providers.ts +62 -0
- package/src/cron/consumer.ts +33 -0
- package/src/cron/count-state.ts +208 -0
- package/src/cron/index.ts +4 -17
- package/src/cron/list.ts +24 -6
- package/src/cron/scheduler.ts +84 -9
- package/src/cron/schema.ts +100 -13
- package/src/doctor/channel-checks.ts +28 -0
- package/src/hostd/daemon.ts +14 -6
- package/src/hostd/protocol.ts +6 -2
- package/src/init/gitignore.ts +1 -1
- package/src/init/index.ts +36 -3
- package/src/init/line-auth.ts +98 -0
- package/src/init/models-dev.ts +1 -0
- package/src/init/run-owner-claim.ts +1 -0
- package/src/init/validate-api-key.ts +2 -0
- package/src/inspect/label.ts +1 -0
- package/src/permissions/match-rule.ts +28 -12
- package/src/permissions/resolve.ts +8 -1
- package/src/role-claim/match-rule.ts +5 -1
- package/src/run/index.ts +41 -4
- package/src/secrets/line-store.ts +112 -0
- package/src/secrets/oauth-xai.ts +1 -1
- package/src/secrets/schema.ts +25 -0
- package/src/server/index.ts +17 -4
- package/src/shared/protocol.ts +4 -1
- package/src/skills/typeclaw-channel-line/SKILL.md +46 -0
- package/src/skills/typeclaw-channels/SKILL.md +153 -0
- package/src/skills/typeclaw-config/SKILL.md +54 -184
- package/src/skills/typeclaw-config/references/dockerfile.md +66 -0
- package/src/skills/typeclaw-cron/SKILL.md +68 -14
- package/src/skills/typeclaw-permissions/SKILL.md +3 -3
- package/typeclaw.schema.json +167 -3
package/src/cli/channel.ts
CHANGED
|
@@ -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
|
-
|
|
118
|
-
|
|
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
|
-
|
|
122
|
-
|
|
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
|
|
48
|
-
'
|
|
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
|
-
'
|
|
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')
|
package/src/config/providers.ts
CHANGED
|
@@ -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
|
package/src/cron/consumer.ts
CHANGED
|
@@ -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 {
|
|
10
|
-
export {
|
|
11
|
-
|
|
12
|
-
|
|
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,
|