typeclaw 0.33.0 → 0.34.1

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 (64) 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/github-cli-auth/git-askpass.ts +65 -0
  15. package/src/bundled-plugins/github-cli-auth/git-command.ts +492 -0
  16. package/src/bundled-plugins/github-cli-auth/index.ts +97 -36
  17. package/src/bundled-plugins/guard/policies/managed-config.ts +1 -1
  18. package/src/bundled-plugins/memory/memory-logger.ts +7 -0
  19. package/src/bundled-plugins/researcher/researcher.ts +14 -11
  20. package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +1 -0
  21. package/src/channels/adapters/line-channel-resolver.ts +129 -0
  22. package/src/channels/adapters/line-classify.ts +80 -0
  23. package/src/channels/adapters/line-format.ts +11 -0
  24. package/src/channels/adapters/line.ts +350 -0
  25. package/src/channels/engagement.ts +4 -2
  26. package/src/channels/manager.ts +65 -6
  27. package/src/channels/router.ts +186 -41
  28. package/src/channels/schema.ts +6 -1
  29. package/src/cli/channel.ts +112 -1
  30. package/src/cli/cron.ts +22 -4
  31. package/src/cli/oauth-callbacks.ts +5 -4
  32. package/src/config/providers.ts +62 -0
  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 +1 -0
  46. package/src/init/run-owner-claim.ts +1 -0
  47. package/src/init/validate-api-key.ts +2 -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 +1 -1
  55. package/src/secrets/schema.ts +25 -0
  56. package/src/server/index.ts +17 -4
  57. package/src/shared/protocol.ts +4 -1
  58. package/src/skills/typeclaw-channel-line/SKILL.md +46 -0
  59. package/src/skills/typeclaw-channels/SKILL.md +153 -0
  60. package/src/skills/typeclaw-config/SKILL.md +54 -184
  61. package/src/skills/typeclaw-config/references/dockerfile.md +66 -0
  62. package/src/skills/typeclaw-cron/SKILL.md +68 -14
  63. package/src/skills/typeclaw-permissions/SKILL.md +3 -3
  64. 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(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',
@@ -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 parsed = kakaoChannelBlockSchema.safeParse(req.patch?.channels?.kakaotalk)
419
- if (!parsed.success) {
420
- return { ok: false, reason: parsed.error.issues.map((issue) => issue.message).join('; ') }
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, kakaotalk: parsed.data },
433
+ next: { ...channels, [patch.key]: data },
426
434
  }))
427
435
  const result: SecretsPatchResult = { containerName: req.containerName, patched: true }
428
436
  return { ok: true, result }
@@ -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
- | { kind: 'secrets-patch'; containerName: string; patch: { channels: { kakaotalk: KakaoChannelBlock } } }
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' }
@@ -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.