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/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(
|
|
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 {
|
package/src/cron/scheduler.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
package/src/cron/schema.ts
CHANGED
|
@@ -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
|
|
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,
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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',
|
package/src/hostd/daemon.ts
CHANGED
|
@@ -7,7 +7,7 @@ import type { Socket, UnixSocketListener } from 'bun'
|
|
|
7
7
|
import type { PortForward } from '@/config'
|
|
8
8
|
import { defaultDockerExec, type DockerExec } from '@/container'
|
|
9
9
|
import type { PortForwardEvent } from '@/portbroker'
|
|
10
|
-
import { kakaoChannelBlockSchema } from '@/secrets/schema'
|
|
10
|
+
import { kakaoChannelBlockSchema, lineChannelBlockSchema } from '@/secrets/schema'
|
|
11
11
|
import { SecretsBackend } from '@/secrets/storage'
|
|
12
12
|
|
|
13
13
|
import { isDaemonReachable } from './client'
|
|
@@ -410,19 +410,27 @@ export async function startDaemon(opts: DaemonOptions = {}): Promise<Daemon> {
|
|
|
410
410
|
|
|
411
411
|
const handleSecretsPatch = async (req: {
|
|
412
412
|
containerName: string
|
|
413
|
-
patch: { channels: { kakaotalk: unknown } }
|
|
413
|
+
patch: { channels: { kakaotalk: unknown } | { line: unknown } }
|
|
414
414
|
}): Promise<RpcResponse> =>
|
|
415
415
|
runSerially(req.containerName, async () => {
|
|
416
416
|
const cwd = cwds.get(req.containerName)
|
|
417
417
|
if (!cwd) return { ok: false, reason: `not registered: ${req.containerName}` }
|
|
418
|
-
const
|
|
419
|
-
|
|
420
|
-
|
|
418
|
+
const channelsPatch = req.patch?.channels
|
|
419
|
+
// Exactly one personal-account channel block per patch. KakaoTalk and
|
|
420
|
+
// LINE both write their structured account block through this RPC; the
|
|
421
|
+
// key present in the patch selects which block to validate and merge.
|
|
422
|
+
const patch =
|
|
423
|
+
'line' in channelsPatch
|
|
424
|
+
? { key: 'line' as const, parsed: lineChannelBlockSchema.safeParse(channelsPatch.line) }
|
|
425
|
+
: { key: 'kakaotalk' as const, parsed: kakaoChannelBlockSchema.safeParse(channelsPatch.kakaotalk) }
|
|
426
|
+
if (!patch.parsed.success) {
|
|
427
|
+
return { ok: false, reason: patch.parsed.error.issues.map((issue) => issue.message).join('; ') }
|
|
421
428
|
}
|
|
429
|
+
const data = patch.parsed.data
|
|
422
430
|
const backend = new SecretsBackend(join(cwd, 'secrets.json'))
|
|
423
431
|
await backend.updateChannelsAsync(async (channels) => ({
|
|
424
432
|
result: undefined,
|
|
425
|
-
next: { ...channels,
|
|
433
|
+
next: { ...channels, [patch.key]: data },
|
|
426
434
|
}))
|
|
427
435
|
const result: SecretsPatchResult = { containerName: req.containerName, patched: true }
|
|
428
436
|
return { ok: true, result }
|
package/src/hostd/protocol.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { PortForward } from '@/config'
|
|
2
|
-
import type { KakaoChannelBlock } from '@/secrets/schema'
|
|
2
|
+
import type { KakaoChannelBlock, LineChannelBlock } from '@/secrets/schema'
|
|
3
3
|
|
|
4
4
|
export type Request =
|
|
5
5
|
| {
|
|
@@ -15,7 +15,11 @@ export type Request =
|
|
|
15
15
|
| { kind: 'list' }
|
|
16
16
|
| { kind: 'status'; containerName: string }
|
|
17
17
|
| { kind: 'restart'; containerName: string; build?: boolean }
|
|
18
|
-
| {
|
|
18
|
+
| {
|
|
19
|
+
kind: 'secrets-patch'
|
|
20
|
+
containerName: string
|
|
21
|
+
patch: { channels: { kakaotalk: KakaoChannelBlock } | { line: LineChannelBlock } }
|
|
22
|
+
}
|
|
19
23
|
| { kind: 'http-info' }
|
|
20
24
|
| { kind: 'version' }
|
|
21
25
|
| { kind: 'shutdown' }
|
package/src/init/gitignore.ts
CHANGED
|
@@ -23,7 +23,7 @@ export const TRULY_IGNORED_PATTERNS = [
|
|
|
23
23
|
// The reconciler MUST fail-closed and never untrack these, even if a custom
|
|
24
24
|
// git.ignore.append pattern (e.g. `**`) matches them — doing so would drop
|
|
25
25
|
// runtime-owned state out of git.
|
|
26
|
-
export const SYSTEM_MANAGED_ROOTS = ['sessions/', 'memory/', 'channels/', 'todo/'] as const
|
|
26
|
+
export const SYSTEM_MANAGED_ROOTS = ['sessions/', 'memory/', 'channels/', 'todo/', 'cron/'] as const
|
|
27
27
|
|
|
28
28
|
export function buildGitignore(config: GitignoreConfig = { append: [] }): string {
|
|
29
29
|
const customEntries = renderCustomGitignoreEntries(config.append)
|
package/src/init/index.ts
CHANGED
|
@@ -139,6 +139,9 @@ export type HatchRunner = (options: {
|
|
|
139
139
|
|
|
140
140
|
export type KakaotalkAuthRunner = (options: { cwd: string }) => Promise<KakaotalkAuthResult>
|
|
141
141
|
|
|
142
|
+
export type LineAuthResult = { ok: true } | { ok: false; reason: string }
|
|
143
|
+
export type LineAuthRunner = (options: { cwd: string }) => Promise<LineAuthResult>
|
|
144
|
+
|
|
142
145
|
// Discriminated by `kind` so the type system enforces "you can't pass an
|
|
143
146
|
// API key to an OAuth provider, and you can't pass an OAuth runner to an
|
|
144
147
|
// API-key provider". Optional model defaults to DEFAULT_MODEL_REF, which is
|
|
@@ -834,7 +837,7 @@ export async function hasExistingOAuthCredentials(root: string, providerId: Know
|
|
|
834
837
|
// kakaotalk` anyway — better to re-auth now during init.
|
|
835
838
|
export async function hasExistingChannelSecrets(
|
|
836
839
|
root: string,
|
|
837
|
-
channel: 'discord' | 'slack' | 'telegram' | 'kakaotalk' | 'github',
|
|
840
|
+
channel: 'discord' | 'slack' | 'telegram' | 'line' | 'kakaotalk' | 'github',
|
|
838
841
|
): Promise<boolean> {
|
|
839
842
|
const channels = new SecretsBackend(join(root, 'secrets.json')).tryReadChannelsSync()
|
|
840
843
|
if (channels === null) return false
|
|
@@ -854,6 +857,21 @@ export async function hasExistingChannelSecrets(
|
|
|
854
857
|
// surfaced as a hard error inside `runAddChannel` to prevent silent
|
|
855
858
|
// overwrites.
|
|
856
859
|
return false
|
|
860
|
+
case 'line': {
|
|
861
|
+
// A usable LINE block needs a current account whose record carries an
|
|
862
|
+
// auth_token. Unlike KakaoTalk there are no renewal fields (email +
|
|
863
|
+
// encrypted password) to require — LINE has no unattended renewal cron.
|
|
864
|
+
const block = channels.line
|
|
865
|
+
if (!isObjectRecord(block)) return false
|
|
866
|
+
const current = (block as { currentAccount?: unknown }).currentAccount
|
|
867
|
+
if (typeof current !== 'string' || current.length === 0) return false
|
|
868
|
+
const accounts = (block as { accounts?: unknown }).accounts
|
|
869
|
+
if (!isObjectRecord(accounts)) return false
|
|
870
|
+
const account = accounts[current]
|
|
871
|
+
if (!isObjectRecord(account)) return false
|
|
872
|
+
const authToken = (account as { auth_token?: unknown }).auth_token
|
|
873
|
+
return typeof authToken === 'string' && authToken.length > 0
|
|
874
|
+
}
|
|
857
875
|
case 'kakaotalk': {
|
|
858
876
|
const block = channels.kakaotalk
|
|
859
877
|
if (!isObjectRecord(block)) return false
|
|
@@ -924,7 +942,7 @@ function ignoreExists(error: NodeJS.ErrnoException): void {
|
|
|
924
942
|
// scaffold-test cases above demonstrates how easy it is to lose a single
|
|
925
943
|
// behavior under a mode flag.
|
|
926
944
|
|
|
927
|
-
export type ChannelKind = 'discord-bot' | 'slack-bot' | 'telegram-bot' | 'kakaotalk' | 'github'
|
|
945
|
+
export type ChannelKind = 'discord-bot' | 'slack-bot' | 'telegram-bot' | 'line' | 'kakaotalk' | 'github'
|
|
928
946
|
|
|
929
947
|
// Public adapter names match the typeclaw.json `channels.*` keys exactly.
|
|
930
948
|
// The CLI takes these as the optional positional arg, the picker shows
|
|
@@ -934,15 +952,18 @@ export const CHANNEL_KINDS: ReadonlyArray<ChannelKind> = [
|
|
|
934
952
|
'slack-bot',
|
|
935
953
|
'discord-bot',
|
|
936
954
|
'telegram-bot',
|
|
955
|
+
'line',
|
|
937
956
|
'kakaotalk',
|
|
938
957
|
'github',
|
|
939
958
|
]
|
|
940
959
|
|
|
941
|
-
export type AddChannelStep = 'kakaotalk-auth' | 'config' | 'secrets' | 'github-webhooks'
|
|
960
|
+
export type AddChannelStep = 'line-auth' | 'kakaotalk-auth' | 'config' | 'secrets' | 'github-webhooks'
|
|
942
961
|
|
|
943
962
|
export type AddChannelStepEvent =
|
|
944
963
|
| { step: 'config'; phase: 'start' }
|
|
945
964
|
| { step: 'config'; phase: 'done' }
|
|
965
|
+
| { step: 'line-auth'; phase: 'start' }
|
|
966
|
+
| { step: 'line-auth'; phase: 'done'; result: LineAuthResult }
|
|
946
967
|
| { step: 'kakaotalk-auth'; phase: 'start' }
|
|
947
968
|
| { step: 'kakaotalk-auth'; phase: 'done'; result: KakaotalkAuthResult }
|
|
948
969
|
| { step: 'secrets'; phase: 'start' }
|
|
@@ -960,6 +981,7 @@ export type AddChannelOptions = {
|
|
|
960
981
|
| { channel: 'discord-bot'; discordBotToken: string }
|
|
961
982
|
| { channel: 'slack-bot'; slackBotToken: string; slackAppToken: string }
|
|
962
983
|
| { channel: 'telegram-bot'; telegramBotToken: string }
|
|
984
|
+
| { channel: 'line'; runLineAuth: LineAuthRunner }
|
|
963
985
|
| { channel: 'kakaotalk'; runKakaotalkAuth: KakaotalkAuthRunner }
|
|
964
986
|
| {
|
|
965
987
|
channel: 'github'
|
|
@@ -986,6 +1008,13 @@ export async function runAddChannel(options: AddChannelOptions): Promise<void> {
|
|
|
986
1008
|
// drops messages — the same trap `runInit` already guards against. Aborting
|
|
987
1009
|
// before any file write means the user's next `typeclaw channel add
|
|
988
1010
|
// kakaotalk` retry has no half-applied state to clean up.
|
|
1011
|
+
if (options.channel === 'line') {
|
|
1012
|
+
emit({ step: 'line-auth', phase: 'start' })
|
|
1013
|
+
const result = await options.runLineAuth({ cwd: options.cwd })
|
|
1014
|
+
emit({ step: 'line-auth', phase: 'done', result })
|
|
1015
|
+
if (!result.ok) throw new Error(`LINE authentication failed: ${result.reason}`)
|
|
1016
|
+
}
|
|
1017
|
+
|
|
989
1018
|
if (options.channel === 'kakaotalk') {
|
|
990
1019
|
emit({ step: 'kakaotalk-auth', phase: 'start' })
|
|
991
1020
|
const result = await options.runKakaotalkAuth({ cwd: options.cwd })
|
|
@@ -1065,6 +1094,10 @@ function channelSecretsFromOptions(options: AddChannelOptions): ChannelSecrets {
|
|
|
1065
1094
|
return { botToken: options.slackBotToken, appToken: options.slackAppToken }
|
|
1066
1095
|
case 'telegram-bot':
|
|
1067
1096
|
return { token: options.telegramBotToken }
|
|
1097
|
+
case 'line':
|
|
1098
|
+
// LINE auth writes its structured account block directly to
|
|
1099
|
+
// secrets.json#channels.line before config mutation.
|
|
1100
|
+
return {}
|
|
1068
1101
|
case 'kakaotalk':
|
|
1069
1102
|
// KakaoTalk auth writes its structured multi-account block directly to
|
|
1070
1103
|
// secrets.json#channels.kakaotalk before config mutation.
|