typeclaw 0.32.1 → 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 (65) 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 +2 -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/init.ts +267 -82
  29. package/src/cli/model.ts +5 -1
  30. package/src/cli/oauth-callbacks.ts +5 -4
  31. package/src/cli/provider.ts +41 -10
  32. package/src/config/providers.ts +366 -7
  33. package/src/cron/consumer.ts +33 -0
  34. package/src/cron/count-state.ts +208 -0
  35. package/src/cron/index.ts +4 -17
  36. package/src/cron/list.ts +24 -6
  37. package/src/cron/scheduler.ts +84 -9
  38. package/src/cron/schema.ts +100 -13
  39. package/src/doctor/channel-checks.ts +28 -0
  40. package/src/hostd/daemon.ts +14 -6
  41. package/src/hostd/protocol.ts +6 -2
  42. package/src/init/gitignore.ts +1 -1
  43. package/src/init/index.ts +36 -3
  44. package/src/init/line-auth.ts +98 -0
  45. package/src/init/models-dev.ts +3 -0
  46. package/src/init/run-owner-claim.ts +1 -0
  47. package/src/init/validate-api-key.ts +15 -0
  48. package/src/inspect/label.ts +1 -0
  49. package/src/permissions/match-rule.ts +28 -12
  50. package/src/permissions/resolve.ts +8 -1
  51. package/src/role-claim/match-rule.ts +5 -1
  52. package/src/run/index.ts +41 -4
  53. package/src/secrets/line-store.ts +112 -0
  54. package/src/secrets/oauth-xai.ts +342 -0
  55. package/src/secrets/schema.ts +25 -0
  56. package/src/secrets/storage.ts +2 -0
  57. package/src/server/index.ts +17 -4
  58. package/src/shared/protocol.ts +4 -1
  59. package/src/skills/typeclaw-channel-line/SKILL.md +46 -0
  60. package/src/skills/typeclaw-channels/SKILL.md +153 -0
  61. package/src/skills/typeclaw-config/SKILL.md +54 -184
  62. package/src/skills/typeclaw-config/references/dockerfile.md +66 -0
  63. package/src/skills/typeclaw-cron/SKILL.md +68 -14
  64. package/src/skills/typeclaw-permissions/SKILL.md +3 -3
  65. package/typeclaw.schema.json +185 -3
@@ -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,
package/src/cron/list.ts CHANGED
@@ -15,7 +15,10 @@ export type CronListEntry = {
15
15
  id: string
16
16
  source: CronListSource
17
17
  kind: 'prompt' | 'exec' | 'handler'
18
- schedule: string
18
+ schedule: string | undefined
19
+ at: string | undefined
20
+ until: string | undefined
21
+ count: number | undefined
19
22
  timezone: string | undefined
20
23
  enabled: boolean
21
24
  scheduledByRole: string | undefined
@@ -35,15 +38,27 @@ export type AggregateCronListOptions = {
35
38
  // to its plugin + localId without re-parsing the global id.
36
39
  pluginJobs: readonly RegisteredCronJob[]
37
40
  now: number
41
+ // Durable fire progress for count-limited jobs. Without this, an exhausted
42
+ // count job would render with a future next-fire time and lie about being
43
+ // retired, so the listing threads the same firedCount the scheduler uses.
44
+ firedCount?: (job: CronJob) => number
38
45
  }
39
46
 
40
47
  export function aggregateCronList(opts: AggregateCronListOptions): CronListEntry[] {
48
+ const firedCount = opts.firedCount ?? (() => 0)
41
49
  const entries: CronListEntry[] = []
42
50
  for (const job of opts.userJobs) {
43
- entries.push(toEntry(job, { kind: 'user' }, opts.now))
51
+ entries.push(toEntry(job, { kind: 'user' }, opts.now, firedCount(job)))
44
52
  }
45
53
  for (const reg of opts.pluginJobs) {
46
- entries.push(toEntry(reg.job, { kind: 'plugin', pluginName: reg.pluginName, localId: reg.localId }, opts.now))
54
+ entries.push(
55
+ toEntry(
56
+ reg.job,
57
+ { kind: 'plugin', pluginName: reg.pluginName, localId: reg.localId },
58
+ opts.now,
59
+ firedCount(reg.job),
60
+ ),
61
+ )
47
62
  }
48
63
  // Sort by next-fire time ascending so the soonest-firing job is at the
49
64
  // top. Jobs with a null nextFireMs (parse errors) sort to the bottom
@@ -55,17 +70,20 @@ export function aggregateCronList(opts: AggregateCronListOptions): CronListEntry
55
70
  return entries
56
71
  }
57
72
 
58
- function toEntry(job: CronJob, source: CronListSource, now: number): CronListEntry {
59
- const fire = computeNextFire(job, now)
73
+ function toEntry(job: CronJob, source: CronListSource, now: number, firedCount: number): CronListEntry {
74
+ const fire = computeNextFire(job, now, { firedCount })
60
75
  const base = {
61
76
  id: job.id,
62
77
  source,
63
78
  schedule: job.schedule,
79
+ at: job.at,
80
+ until: job.until,
81
+ count: job.count,
64
82
  timezone: job.timezone,
65
83
  enabled: job.enabled,
66
84
  scheduledByRole: job.scheduledByRole,
67
85
  nextFireMs: fire.ok ? fire.nextFire : null,
68
- scheduleError: fire.ok ? undefined : fire.reason,
86
+ scheduleError: fire.ok || fire.expired ? undefined : fire.reason,
69
87
  } as const
70
88
  if (job.kind === 'prompt') {
71
89
  return {
@@ -14,11 +14,22 @@ export type SchedulerLogger = {
14
14
  error: (msg: string) => void
15
15
  }
16
16
 
17
+ // The scheduler uses the count store only to stop arming once a job's durable
18
+ // count is exhausted (an optimization) and to reconcile progress on reload.
19
+ // The authoritative count gate lives in the consumer, which owns accepted-fire
20
+ // accounting — the scheduler must not be the correctness boundary because it
21
+ // can't know whether the downstream coalescer will accept or skip a fire.
22
+ export type SchedulerCountStore = {
23
+ get: (id: string, job: CronJob) => number
24
+ reconcile: (jobs: CronJob[]) => Promise<void>
25
+ }
26
+
17
27
  export type CreateSchedulerOptions = {
18
28
  jobs: CronJob[]
19
29
  onFire: (job: CronJob) => void
20
30
  clock?: SchedulerClock
21
31
  logger?: SchedulerLogger
32
+ countStore?: SchedulerCountStore
22
33
  }
23
34
 
24
35
  export type JobDiff = {
@@ -51,6 +62,7 @@ export function createScheduler({
51
62
  onFire,
52
63
  clock = realClock,
53
64
  logger = consoleLogger,
65
+ countStore,
54
66
  }: CreateSchedulerOptions): Scheduler {
55
67
  const registry = new Map<string, CronJob>()
56
68
  for (const job of jobs) registry.set(job.id, job)
@@ -69,9 +81,16 @@ export function createScheduler({
69
81
  const job = currentEnabled(id)
70
82
  if (!job) return
71
83
 
72
- const result = computeNextFire(job, clock.now())
84
+ const firedCount = job.count !== undefined ? (countStore?.get(id, job) ?? 0) : 0
85
+ const result = computeNextFire(job, clock.now(), { firedCount })
73
86
  if (!result.ok) {
74
- logger.warn(`[cron] ${id} not scheduled: invalid schedule "${job.schedule}"${tzSuffix(job)}: ${result.reason}`)
87
+ if (result.expired) {
88
+ logger.info(`[cron] ${id} retired: ${result.reason}`)
89
+ } else {
90
+ logger.warn(
91
+ `[cron] ${id} not scheduled: invalid schedule "${job.schedule ?? job.at}"${tzSuffix(job)}: ${result.reason}`,
92
+ )
93
+ }
75
94
  return
76
95
  }
77
96
 
@@ -94,8 +113,7 @@ export function createScheduler({
94
113
  try {
95
114
  onFire(job)
96
115
  } catch (err) {
97
- const message = err instanceof Error ? err.message : String(err)
98
- logger.error(`[cron] ${job.id} onFire threw synchronously: ${message}`)
116
+ logger.error(`[cron] ${job.id} onFire threw synchronously: ${describe(err)}`)
99
117
  }
100
118
  }
101
119
 
@@ -153,6 +171,14 @@ export function createScheduler({
153
171
  registry.clear()
154
172
  for (const [id, job] of newRegistry) registry.set(id, job)
155
173
 
174
+ // Reconcile counts before arming so re-added/changed jobs don't inherit
175
+ // stale progress. `reconcile` settles the authoritative in-memory map
176
+ // synchronously; only the persist is async, so a failure there just means
177
+ // disk lags the (correct) in-memory state — log it, don't fail the reload.
178
+ countStore?.reconcile(next).catch((err) => {
179
+ logger.error(`[cron] failed to persist count reconciliation: ${describe(err)}`)
180
+ })
181
+
156
182
  for (const job of result.removed) cancel(job.id)
157
183
  for (const job of result.updated) {
158
184
  cancel(job.id)
@@ -167,7 +193,10 @@ export function createScheduler({
167
193
 
168
194
  function jobFingerprint(job: CronJob): string {
169
195
  return JSON.stringify({
170
- schedule: job.schedule,
196
+ schedule: job.schedule ?? null,
197
+ at: job.at ?? null,
198
+ until: job.until ?? null,
199
+ count: job.count ?? null,
171
200
  enabled: job.enabled,
172
201
  timezone: job.timezone ?? null,
173
202
  kind: job.kind,
@@ -189,21 +218,67 @@ function jobPayload(job: CronJob): unknown {
189
218
  return { handler: String(job.handler) }
190
219
  }
191
220
 
192
- export type ComputeNextFireResult = { ok: true; nextFire: number } | { ok: false; reason: string }
221
+ export type ComputeNextFireResult =
222
+ | { ok: true; nextFire: number }
223
+ // `expired` distinguishes a reached end-boundary (count/until/past `at`) from
224
+ // a malformed schedule: the former is silent and final, the latter warns.
225
+ | { ok: false; expired: true; reason: string }
226
+ | { ok: false; expired: false; reason: string }
193
227
 
194
- export function computeNextFire(job: CronJob, now: number): ComputeNextFireResult {
228
+ export type ComputeNextFireOptions = {
229
+ // Accepted fires so far, sourced from the count store. Defaults to 0 so
230
+ // callers that don't track counts (cron list rendering, pure schedule
231
+ // preview) compute the raw next occurrence.
232
+ firedCount?: number
233
+ }
234
+
235
+ export function computeNextFire(
236
+ job: CronJob,
237
+ now: number,
238
+ options: ComputeNextFireOptions = {},
239
+ ): ComputeNextFireResult {
240
+ const firedCount = options.firedCount ?? 0
241
+ if (job.count !== undefined && firedCount >= job.count) {
242
+ return { ok: false, expired: true, reason: `count limit reached (${firedCount}/${job.count})` }
243
+ }
244
+
245
+ if (job.at !== undefined) {
246
+ const at = Date.parse(job.at)
247
+ if (Number.isNaN(at)) return { ok: false, expired: false, reason: `invalid "at": ${job.at}` }
248
+ if (at <= now) return { ok: false, expired: true, reason: `one-shot "at" already elapsed` }
249
+ return { ok: true, nextFire: at }
250
+ }
251
+
252
+ if (job.schedule === undefined) {
253
+ return { ok: false, expired: false, reason: `job has neither "schedule" nor "at"` }
254
+ }
255
+
256
+ let nextFire: number
195
257
  try {
196
258
  const expr = CronExpressionParser.parse(job.schedule, {
197
259
  currentDate: new Date(now),
198
260
  ...(job.timezone ? { tz: job.timezone } : {}),
199
261
  })
200
- return { ok: true, nextFire: expr.next().getTime() }
262
+ nextFire = expr.next().getTime()
201
263
  } catch (err) {
202
264
  const reason = err instanceof Error ? err.message : String(err)
203
- return { ok: false, reason }
265
+ return { ok: false, expired: false, reason }
204
266
  }
267
+
268
+ if (job.until !== undefined) {
269
+ const until = Date.parse(job.until)
270
+ if (!Number.isNaN(until) && nextFire > until) {
271
+ return { ok: false, expired: true, reason: `next occurrence is after "until" (${job.until})` }
272
+ }
273
+ }
274
+
275
+ return { ok: true, nextFire }
205
276
  }
206
277
 
207
278
  function tzSuffix(job: CronJob): string {
208
279
  return job.timezone ? ` (timezone "${job.timezone}")` : ''
209
280
  }
281
+
282
+ function describe(err: unknown): string {
283
+ return err instanceof Error ? err.message : String(err)
284
+ }
@@ -9,7 +9,18 @@ const idPattern = /^[a-zA-Z0-9_-]+$/
9
9
 
10
10
  const baseJob = z.object({
11
11
  id: z.string().min(1).regex(idPattern, 'id must contain only letters, digits, hyphens, or underscores'),
12
- schedule: z.string().min(1),
12
+ // `schedule` (recurring cron expression) and `at` (one-shot ISO instant) are
13
+ // mutually exclusive: exactly one must be present. Zod marks both optional so
14
+ // the discriminated union still parses; the XOR is enforced in parseCronFile
15
+ // where we can emit a job-id-scoped error message.
16
+ schedule: z.string().min(1).optional(),
17
+ at: z.string().min(1).optional(),
18
+ // End boundaries. `until` is an absolute ISO instant (last allowed fire,
19
+ // inclusive); `count` stops after N accepted fires. Both may coexist on one
20
+ // job — the scheduler stops at whichever limit is reached first. `count`
21
+ // progress is tracked out-of-band in cron-state.json, never written back here.
22
+ until: z.string().min(1).optional(),
23
+ count: z.number().int().positive().optional(),
13
24
  enabled: z.boolean().default(true),
14
25
  timezone: z.string().optional(),
15
26
  scheduledByRole: z.string().optional(),
@@ -57,11 +68,25 @@ export type CronFile = z.infer<typeof cronFileSchema>
57
68
 
58
69
  export type ParseCronResult = { ok: true; file: CronFile } | { ok: false; reason: string }
59
70
 
71
+ // `edit` is the strict path for an agent writing/editing cron.json: a past
72
+ // enabled `at` is rejected so a reminder scheduled in the past surfaces as an
73
+ // error instead of silently becoming a never-firing no-op. `load` is the
74
+ // tolerant path the scheduler uses on boot/reload: a fired or missed one-shot
75
+ // lingers on disk with a now-past `at`, and rejecting it would brick the whole
76
+ // file — so `load` accepts it and lets the scheduler retire it passively.
77
+ export type ParseCronMode = 'edit' | 'load'
78
+
60
79
  export type ParseCronOptions = {
61
80
  // When provided, prompt jobs with a `subagent` field are validated against
62
81
  // the registry: the name must exist, and the optional `payload` must match
63
82
  // the registered subagent's payloadSchema (or be absent if no schema).
64
83
  subagents?: SubagentRegistry
84
+ // Injected by tests so past/future boundary checks on `at`/`until` are
85
+ // deterministic. Production omits it and validation reads the wall clock.
86
+ now?: number
87
+ // Defaults to `load` (reload-safe). Callers validating an agent edit pass
88
+ // `edit` to reject newly-scheduled past `at` reminders.
89
+ mode?: ParseCronMode
65
90
  }
66
91
 
67
92
  export type ParseCronJsonOptions = ParseCronOptions
@@ -74,7 +99,11 @@ export function parseCronJson(raw: string, options: ParseCronJsonOptions = {}):
74
99
  return { ok: false, reason: `cron.json is not valid JSON: ${err instanceof Error ? err.message : String(err)}` }
75
100
  }
76
101
 
77
- return parseCronFile(json, options.subagents !== undefined ? { subagents: options.subagents } : {})
102
+ return parseCronFile(json, {
103
+ ...(options.subagents !== undefined ? { subagents: options.subagents } : {}),
104
+ ...(options.now !== undefined ? { now: options.now } : {}),
105
+ ...(options.mode !== undefined ? { mode: options.mode } : {}),
106
+ })
78
107
  }
79
108
 
80
109
  export function parseCronFile(raw: unknown, options: ParseCronOptions = {}): ParseCronResult {
@@ -91,17 +120,9 @@ export function parseCronFile(raw: unknown, options: ParseCronOptions = {}): Par
91
120
  }
92
121
  seen.add(job.id)
93
122
 
94
- try {
95
- const expr = CronExpressionParser.parse(job.schedule, job.timezone ? { tz: job.timezone } : undefined)
96
- // cron-parser validates the timezone lazily on first next() call, not at
97
- // parse time, so we must force evaluation here to catch bogus zones.
98
- expr.next()
99
- } catch (err) {
100
- const message = err instanceof Error ? err.message : String(err)
101
- if (job.timezone && /invalid|unhandled timestamp|unrecognized/i.test(message)) {
102
- return { ok: false, reason: `job ${job.id}: invalid timezone "${job.timezone}": ${message}` }
103
- }
104
- return { ok: false, reason: `job ${job.id}: invalid schedule "${job.schedule}": ${message}` }
123
+ const timingError = validateTiming(job, options.now ?? Date.now(), options.mode ?? 'load')
124
+ if (timingError !== null) {
125
+ return { ok: false, reason: `job ${job.id}: ${timingError}` }
105
126
  }
106
127
 
107
128
  if (job.scheduledByRole === undefined) {
@@ -128,6 +149,72 @@ export function parseCronFile(raw: unknown, options: ParseCronOptions = {}): Par
128
149
  return { ok: true, file }
129
150
  }
130
151
 
152
+ type TimingJob = {
153
+ schedule?: string | undefined
154
+ at?: string | undefined
155
+ until?: string | undefined
156
+ count?: number | undefined
157
+ timezone?: string | undefined
158
+ enabled: boolean
159
+ }
160
+
161
+ function validateTiming(job: TimingJob, now: number = Date.now(), mode: ParseCronMode = 'load'): string | null {
162
+ const hasSchedule = job.schedule !== undefined
163
+ const hasAt = job.at !== undefined
164
+ if (hasSchedule === hasAt) {
165
+ return `must set exactly one of "schedule" (recurring) or "at" (one-shot)`
166
+ }
167
+
168
+ if (hasAt) {
169
+ if (job.timezone !== undefined) return `"timezone" is only valid with "schedule", not "at"`
170
+ if (job.until !== undefined) return `"until" is only valid with "schedule", not "at"`
171
+ if (job.count !== undefined && job.count !== 1) return `one-shot "at" jobs may only set "count": 1`
172
+ const at = parseInstant(job.at!)
173
+ if (at === null) return `invalid "at": "${job.at}" is not an ISO datetime with an explicit zone/offset`
174
+ // Reject a past reminder only on `edit`; `load` tolerates fired tombstones.
175
+ // See ParseCronMode for the full rationale.
176
+ if (mode === 'edit' && job.enabled && at <= now) return `"at" is in the past: "${job.at}"`
177
+ return null
178
+ }
179
+
180
+ let until: number | null = null
181
+ if (job.until !== undefined) {
182
+ until = parseInstant(job.until)
183
+ if (until === null) return `invalid "until": "${job.until}" is not an ISO datetime with an explicit zone/offset`
184
+ if (job.enabled && until <= now) return `"until" is in the past: "${job.until}"`
185
+ }
186
+
187
+ let firstFire: number
188
+ try {
189
+ const expr = CronExpressionParser.parse(job.schedule!, {
190
+ currentDate: new Date(now),
191
+ ...(job.timezone ? { tz: job.timezone } : {}),
192
+ })
193
+ firstFire = expr.next().getTime()
194
+ } catch (err) {
195
+ const message = err instanceof Error ? err.message : String(err)
196
+ if (job.timezone && /invalid|unhandled timestamp|unrecognized/i.test(message)) {
197
+ return `invalid timezone "${job.timezone}": ${message}`
198
+ }
199
+ return `invalid schedule "${job.schedule}": ${message}`
200
+ }
201
+
202
+ if (job.enabled && until !== null && firstFire > until) {
203
+ return `schedule has no occurrence at or before "until" ("${job.until}")`
204
+ }
205
+
206
+ return null
207
+ }
208
+
209
+ // Accepts only absolute instants: an explicit `Z` or numeric offset is
210
+ // required so "remind me at 9am" can never silently resolve to the host's
211
+ // local zone. A bare `2026-06-09T09:00:00` (no zone) is rejected.
212
+ function parseInstant(value: string): number | null {
213
+ if (!/[zZ]$|[+-]\d{2}:?\d{2}$/.test(value)) return null
214
+ const ms = Date.parse(value)
215
+ return Number.isNaN(ms) ? null : ms
216
+ }
217
+
131
218
  function formatIssue(issue: { path: PropertyKey[]; message: string }): string {
132
219
  const path = issue.path.length > 0 ? issue.path.map(String).join('.') : '<root>'
133
220
  return `${path}: ${issue.message}`
@@ -28,6 +28,7 @@ export function buildChannelChecks(): DoctorCheck[] {
28
28
  slackBotCredentials(),
29
29
  discordBotCredentials(),
30
30
  telegramBotCredentials(),
31
+ lineCredentials(),
31
32
  kakaotalkCredentials(),
32
33
  githubCredentials(),
33
34
  githubWebhookDelivery(),
@@ -64,6 +65,33 @@ function telegramBotCredentials(): DoctorCheck {
64
65
  }
65
66
  }
66
67
 
68
+ function lineCredentials(): DoctorCheck {
69
+ return {
70
+ name: 'channel.line.credentials',
71
+ category: 'channels',
72
+ description: 'line adapter has at least one account in secrets.json',
73
+ applies: (ctx) => ctx.hasAgentFolder,
74
+ async run(ctx) {
75
+ const channels = readDeclaredChannels(ctx)
76
+ if (channels === null) return configInvalidResult()
77
+ if (!isAdapterActive(channels, 'line')) {
78
+ return { status: 'skipped', message: 'line not configured' }
79
+ }
80
+ const block = readChannelsSecrets(ctx)?.line
81
+ const accountCount = block?.accounts ? Object.keys(block.accounts).length : 0
82
+ if (accountCount === 0) {
83
+ return {
84
+ status: 'warning',
85
+ message: 'line has no accounts in secrets.json',
86
+ details: ['Adapter will start but fail authentication and stay disconnected.'],
87
+ fix: { description: 'Run `typeclaw channel add line` to log in an account.' },
88
+ }
89
+ }
90
+ return { status: 'ok', message: `line has ${accountCount} account(s)` }
91
+ },
92
+ }
93
+ }
94
+
67
95
  function kakaotalkCredentials(): DoctorCheck {
68
96
  return {
69
97
  name: 'channel.kakaotalk.credentials',