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
@@ -0,0 +1,98 @@
1
+ import { join } from 'node:path'
2
+
3
+ import { LineClient as RealLineClient, LineCredentialManager, type LineLoginResult } from 'agent-messenger/line'
4
+
5
+ import { SecretsLineCredentialStore } from '@/secrets/line-store'
6
+
7
+ export type LineBootstrapStatus = { ok: true } | { ok: false; reason: string }
8
+
9
+ export type LineLoginCallbacks = {
10
+ onQRUrl?: (url: string) => void
11
+ onPincode: (pin: string) => void
12
+ }
13
+
14
+ // QR is the default because a LINE account may have no usable e-mail/password
15
+ // (social-login accounts), and QR only adds bootstrap-time UX — the persisted
16
+ // credential (auth_token + certificate) is identical regardless of method.
17
+ export type LineLoginInput =
18
+ | {
19
+ method: 'qr'
20
+ agentDir: string
21
+ callbacks: LineLoginCallbacks
22
+ client?: LineLoginClient
23
+ }
24
+ | {
25
+ method: 'email'
26
+ email: string
27
+ password: string
28
+ agentDir: string
29
+ callbacks: LineLoginCallbacks
30
+ client?: LineLoginClient
31
+ }
32
+
33
+ // Structural subset of the upstream LineClient the bootstrap drives. Declared
34
+ // here so tests can inject a fake without standing up the real LOCO client.
35
+ export type LineLoginClient = {
36
+ loginWithQR(options: { onQRUrl: (url: string) => void; onPincode: (pin: string) => void }): Promise<LineLoginResult>
37
+ loginWithEmail(options: {
38
+ email: string
39
+ password: string
40
+ onPincode: (pin: string) => void
41
+ }): Promise<LineLoginResult>
42
+ }
43
+
44
+ export function lineSecretsPath(agentDir: string): string {
45
+ return join(agentDir, 'secrets.json')
46
+ }
47
+
48
+ export async function runLineBootstrap(input: LineLoginInput): Promise<LineBootstrapStatus> {
49
+ try {
50
+ const store = new SecretsLineCredentialStore({ mode: 'host', secretsPath: lineSecretsPath(input.agentDir) })
51
+ // The LINE SDK persists the minted auth_token + certificate by calling
52
+ // setAccount() on whatever credential manager the client was built with.
53
+ // Wiring our secrets.json-backed store in here means a successful login
54
+ // writes straight to secrets.json#channels.line — no second copy in
55
+ // ~/.config/agent-messenger to keep in sync.
56
+ const client = input.client ?? buildLineClient(store)
57
+
58
+ const result =
59
+ input.method === 'qr'
60
+ ? await client.loginWithQR({
61
+ onQRUrl: (url) => input.callbacks.onQRUrl?.(url),
62
+ onPincode: input.callbacks.onPincode,
63
+ })
64
+ : await client.loginWithEmail({
65
+ email: input.email,
66
+ password: input.password,
67
+ onPincode: input.callbacks.onPincode,
68
+ })
69
+
70
+ if (!result.authenticated || result.account_id === undefined) {
71
+ const reason = result.message ?? result.error ?? 'LINE login did not authenticate'
72
+ return { ok: false, reason }
73
+ }
74
+
75
+ // The SDK persists the account by calling setAccount() on the credential
76
+ // manager as a side effect of login. We can't assume it did: read the
77
+ // record back and require an auth_token before declaring success, so a
78
+ // login that authenticated but failed to persist surfaces as an error
79
+ // instead of a green "added" with an empty secrets.json#channels.line.
80
+ const persisted = await store.getAccount(result.account_id)
81
+ if (persisted === null || persisted.auth_token === '') {
82
+ return { ok: false, reason: 'LINE login authenticated but did not persist credentials' }
83
+ }
84
+
85
+ await store.setCurrentAccount(result.account_id)
86
+ return { ok: true }
87
+ } catch (err) {
88
+ return { ok: false, reason: err instanceof Error ? err.message : String(err) }
89
+ }
90
+ }
91
+
92
+ function buildLineClient(store: SecretsLineCredentialStore): LineLoginClient {
93
+ // The upstream LineClient constructor takes a LineCredentialManager. Our
94
+ // store implements the same setAccount/getAccount surface the login path
95
+ // calls, so it stands in as the credential manager via a structural cast.
96
+ const credManager = store as unknown as LineCredentialManager
97
+ return new RealLineClient(credManager) as unknown as LineLoginClient
98
+ }
@@ -22,6 +22,7 @@ const PROVIDER_TO_MODELS_DEV: Record<KnownProviderId, string> = {
22
22
  'zai-coding': 'zai',
23
23
  xai: 'xai',
24
24
  minimax: 'minimax',
25
+ deepseek: 'deepseek',
25
26
  }
26
27
 
27
28
  export type ModelOption = {
@@ -10,6 +10,7 @@ const CHANNEL_LABELS: Record<ChannelKind, string> = {
10
10
  'discord-bot': 'Discord',
11
11
  github: 'GitHub',
12
12
  'telegram-bot': 'Telegram',
13
+ line: 'LINE',
13
14
  kakaotalk: 'KakaoTalk',
14
15
  }
15
16
 
@@ -9,6 +9,7 @@ const PROVIDER_PROBE: Partial<Record<KnownProviderId, { url: string; authHeader:
9
9
  'zai-coding': { url: 'https://api.z.ai/api/coding/paas/v4/models', authHeader: 'bearer' },
10
10
  xai: { url: 'https://api.x.ai/v1/models', authHeader: 'bearer' },
11
11
  minimax: { url: 'https://api.minimax.io/v1/models', authHeader: 'bearer' },
12
+ deepseek: { url: 'https://api.deepseek.com/models', authHeader: 'bearer' },
12
13
  }
13
14
 
14
15
  // When a base-URL override (ANTHROPIC_BASE_URL / OPENAI_BASE_URL) points at a
@@ -163,6 +164,7 @@ export const API_KEY_DASHBOARD_URL: Partial<Record<KnownProviderId, string>> = {
163
164
  'zai-coding': 'https://docs.z.ai/devpack/tool/claude#api-key',
164
165
  xai: 'https://console.x.ai',
165
166
  minimax: 'https://platform.minimax.io/user-center/basic-information/interface-key',
167
+ deepseek: 'https://platform.deepseek.com/api_keys',
166
168
  }
167
169
 
168
170
  // MiniMax sells the same `minimax` provider under two billing surfaces that
@@ -5,6 +5,7 @@ const ADAPTER_DISPLAY: Record<string, string> = {
5
5
  'discord-bot': 'Discord',
6
6
  github: 'GitHub',
7
7
  'telegram-bot': 'Telegram',
8
+ line: 'LINE',
8
9
  kakaotalk: 'KakaoTalk',
9
10
  }
10
11
 
@@ -16,7 +16,7 @@
16
16
  // error messages with typo suggestions; a single big regex would only ever
17
17
  // say "didn't match".
18
18
 
19
- export const PLATFORMS = ['slack', 'discord', 'telegram', 'kakao', 'github'] as const
19
+ export const PLATFORMS = ['slack', 'discord', 'telegram', 'line', 'kakao', 'github'] as const
20
20
  export type Platform = (typeof PLATFORMS)[number]
21
21
 
22
22
  const SUBAGENT_NAME = /^[a-z][a-z0-9-]*$/
@@ -35,9 +35,10 @@ export type MatchRule =
35
35
  workspace?: string
36
36
  chat?: string
37
37
  // Buckets for DM-style scopes. `slack:dm/*`, `discord:dm/*`,
38
- // `kakao:dm/*`, `kakao:group/*`, `kakao:open/*` produce `bucket` only
39
- // (no workspace, no chat).
40
- bucket?: 'dm' | 'group' | 'open'
38
+ // `kakao:dm/*`, `kakao:group/*`, `kakao:open/*`, `line:dm/*`,
39
+ // `line:group/*`, `line:square/*` produce `bucket` only (no workspace,
40
+ // no chat).
41
+ bucket?: 'dm' | 'group' | 'open' | 'square'
41
42
  author?: string
42
43
  }
43
44
 
@@ -50,7 +51,7 @@ export type ParseMatchRuleResult = { ok: true; value: MatchRule } | { ok: false;
50
51
  // this to `author:` only, the JSON schema would reject typos with a generic
51
52
  // "did not match pattern" error and the user would lose the actionable hint.
52
53
  export const MATCH_RULE_REGEX_SOURCE =
53
- '^(tui|cron|subagent(:[a-z][a-z0-9-]*)?|\\*|(slack|discord|telegram|kakao|github):[^\\s]+)(\\s+[a-zA-Z][a-zA-Z0-9_]*:[^\\s]+)*$'
54
+ '^(tui|cron|subagent(:[a-z][a-z0-9-]*)?|\\*|(slack|discord|telegram|line|kakao|github):[^\\s]+)(\\s+[a-zA-Z][a-zA-Z0-9_]*:[^\\s]+)*$'
54
55
 
55
56
  export function parseMatchRule(input: string): ParseMatchRuleResult {
56
57
  if (input !== input.trim() || input.length === 0) {
@@ -164,15 +165,16 @@ function parseChannelScope(platform: Platform, rest: string, author: string | un
164
165
  }
165
166
  }
166
167
 
167
- if (head === 'dm' || head === 'group' || head === 'open') {
168
- if (platform !== 'kakao' && (head === 'group' || head === 'open')) {
169
- return { ok: false, error: `bucket '${head}' is only valid for kakao` }
168
+ if (head === 'dm' || head === 'group' || head === 'open' || head === 'square') {
169
+ const bucketError = invalidBucketForPlatform(head, platform)
170
+ if (bucketError !== null) {
171
+ return { ok: false, error: bucketError }
170
172
  }
171
173
  if (tail === '') {
172
174
  return { ok: false, error: `bucket '${platform}:${head}/' requires '*' or a chat id` }
173
175
  }
174
176
  if (tail === '*') {
175
- return { ok: true, value: buildChannelRule(platform, { bucket: head as 'dm' | 'group' | 'open', author }) }
177
+ return { ok: true, value: buildChannelRule(platform, { bucket: head, author }) }
176
178
  }
177
179
  // `slack:dm/<id>` — keep the bucket plus the specific chat. We omit a
178
180
  // separate workspace field; DM IDs are globally unique within a
@@ -180,7 +182,7 @@ function parseChannelScope(platform: Platform, rest: string, author: string | un
180
182
  return {
181
183
  ok: true,
182
184
  value: buildChannelRule(platform, {
183
- bucket: head as 'dm' | 'group' | 'open',
185
+ bucket: head,
184
186
  chat: tail,
185
187
  author,
186
188
  }),
@@ -199,12 +201,26 @@ function parseChannelScope(platform: Platform, rest: string, author: string | un
199
201
  }
200
202
 
201
203
  // No slash: `slack:T0123` or `kakao:dm` (bare bucket — error).
202
- if (rest === 'dm' || rest === 'group' || rest === 'open') {
204
+ if (rest === 'dm' || rest === 'group' || rest === 'open' || rest === 'square') {
203
205
  return { ok: false, error: `bucket '${platform}:${rest}' requires a chat id or '*'` }
204
206
  }
205
207
  return { ok: true, value: buildChannelRule(platform, { workspace: rest, author }) }
206
208
  }
207
209
 
210
+ // `dm` is universal. `group`/`open` are KakaoTalk buckets; `group`/`square`
211
+ // are LINE buckets. Reject a bucket on a platform whose workspace shapes don't
212
+ // produce it, so a typo'd rule fails loudly instead of silently never matching.
213
+ function invalidBucketForPlatform(bucket: 'dm' | 'group' | 'open' | 'square', platform: Platform): string | null {
214
+ if (bucket === 'dm') return null
215
+ if (bucket === 'open') {
216
+ return platform === 'kakao' ? null : `bucket 'open' is only valid for kakao`
217
+ }
218
+ if (bucket === 'square') {
219
+ return platform === 'line' ? null : `bucket 'square' is only valid for line`
220
+ }
221
+ return platform === 'kakao' || platform === 'line' ? null : `bucket 'group' is only valid for kakao or line`
222
+ }
223
+
208
224
  function parseGithubChannelScope(rest: string, author: string | undefined): ParseMatchRuleResult {
209
225
  const [owner, repo, ...chatParts] = rest.split('/')
210
226
  if (owner === undefined || owner === '' || repo === undefined || repo === '') {
@@ -230,7 +246,7 @@ function buildChannelRule(
230
246
  parts: {
231
247
  workspace?: string
232
248
  chat?: string
233
- bucket?: 'dm' | 'group' | 'open'
249
+ bucket?: 'dm' | 'group' | 'open' | 'square'
234
250
  author?: string
235
251
  },
236
252
  ): MatchRule {
@@ -7,6 +7,7 @@ const ADAPTER_TO_PLATFORM: Record<AdapterId, Platform> = {
7
7
  'discord-bot': 'discord',
8
8
  github: 'github',
9
9
  'telegram-bot': 'telegram',
10
+ line: 'line',
10
11
  kakaotalk: 'kakao',
11
12
  }
12
13
 
@@ -63,10 +64,16 @@ function matchesChannel(rule: Extract<MatchRule, { kind: 'channel' }>, origin: M
63
64
  // the origin's workspace is `@dm` (Slack) or `dm` (Discord). KakaoTalk uses
64
65
  // the workspace prefix itself.
65
66
  function matchesBucket(
66
- bucket: 'dm' | 'group' | 'open',
67
+ bucket: 'dm' | 'group' | 'open' | 'square',
67
68
  origin: Extract<MatchableOrigin, { kind: 'channel' }>,
68
69
  ): boolean {
69
70
  const platform = ADAPTER_TO_PLATFORM[origin.adapter]
71
+ if (platform === 'line') {
72
+ if (bucket === 'dm') return origin.workspace === '@line-dm'
73
+ if (bucket === 'group') return origin.workspace === '@line-group'
74
+ if (bucket === 'square') return origin.workspace === '@line-square'
75
+ return false
76
+ }
70
77
  if (platform === 'kakao') {
71
78
  if (bucket === 'dm') return origin.workspace === '@kakao-dm'
72
79
  if (bucket === 'group') return origin.workspace === '@kakao-group'
@@ -25,11 +25,15 @@ export type PartialChannelOrigin = {
25
25
  authorId: string
26
26
  }
27
27
 
28
- const ADAPTER_TO_PLATFORM: Record<ChannelKey['adapter'], 'slack' | 'discord' | 'telegram' | 'kakao' | 'github'> = {
28
+ const ADAPTER_TO_PLATFORM: Record<
29
+ ChannelKey['adapter'],
30
+ 'slack' | 'discord' | 'telegram' | 'kakao' | 'line' | 'github'
31
+ > = {
29
32
  'slack-bot': 'slack',
30
33
  'discord-bot': 'discord',
31
34
  github: 'github',
32
35
  'telegram-bot': 'telegram',
36
+ line: 'line',
33
37
  kakaotalk: 'kakao',
34
38
  }
35
39
 
package/src/run/index.ts CHANGED
@@ -30,9 +30,11 @@ import {
30
30
  import { createTunnelBridge, type TunnelBridge } from '@/channels/tunnel-bridge'
31
31
  import { createConfigReloadable, getConfig, loadConfigSync, loadPluginConfigsSync, reloadConfig } from '@/config'
32
32
  import {
33
+ type CountStore,
33
34
  type CronConsumer,
34
35
  type CronJob,
35
36
  type CronFile,
37
+ createCountStore,
36
38
  createCronConsumer,
37
39
  createCronReloadable,
38
40
  createScheduler,
@@ -77,7 +79,12 @@ type BunServer = ReturnType<Server['start']>
77
79
  export type TuiFactory = (options: TuiOptions) => { run: () => Promise<unknown> }
78
80
 
79
81
  export type LoadCronFn = (agentDir: string, options?: { subagents?: SubagentRegistry }) => Promise<LoadCronResult>
80
- export type SchedulerFactory = (options: { cwd: string; file: CronFile; onFire: (job: CronJob) => void }) => Scheduler
82
+ export type SchedulerFactory = (options: {
83
+ cwd: string
84
+ file: CronFile
85
+ onFire: (job: CronJob) => void
86
+ onCountStore?: (store: CountStore) => void
87
+ }) => Scheduler | Promise<Scheduler>
81
88
  export type ChannelManagerFactory = typeof createChannelManager
82
89
  export type TunnelManagerFactory = (options: TunnelManagerOptions) => TunnelManager
83
90
 
@@ -419,9 +426,23 @@ export async function startAgent({
419
426
  })
420
427
  subagentConsumer.start()
421
428
 
429
+ // Populated by startScheduler's factory (onCountStore). The consumer
430
+ // subscribes before this is set, but only touches the holder at fire time
431
+ // (reading the count via `get` and recording it via `increment`) — and the
432
+ // scheduler (the sole cron publisher) is armed only AFTER the holder is
433
+ // populated, so no count-limited fire can observe an undefined holder. If
434
+ // another cron publisher is ever added, create the store before this point.
435
+ let cronCountStore: CountStore | undefined
422
436
  const cronConsumer = createCronConsumer({
423
437
  stream,
424
438
  cwd,
439
+ countStore: {
440
+ get: (id, job) => cronCountStore?.get(id, job) ?? 0,
441
+ // Holder is always set before any fire (see above); the `false` fallback
442
+ // fails safe — skip dispatch rather than run an uncounted count-job — for
443
+ // the unreachable case where a fire somehow predates the holder.
444
+ increment: (id, job, at) => cronCountStore?.increment(id, job, at) ?? Promise.resolve(false),
445
+ },
425
446
  invokeHandler: async (job) => {
426
447
  const snap = pluginRuntime.get()
427
448
  const registered = snap.registry.cronJobs.find((j) => j.globalId === job.id)
@@ -532,6 +553,11 @@ export async function startAgent({
532
553
 
533
554
  const internalJobs = () => pluginCronJobs(pluginRuntime.get().registry)
534
555
  const factory = createSchedulerFor ?? makeDefaultSchedulerFactory(internalJobs)
556
+ // Subscribe the consumer BEFORE the scheduler arms any timers. The stream
557
+ // delivers only to live subscribers (no replay), so a fire published before
558
+ // the subscription exists would be lost. Subscribing to an empty stream is
559
+ // harmless when there are no jobs.
560
+ cronConsumer.start()
535
561
  const scheduler = await startScheduler({
536
562
  cwd,
537
563
  loadCron,
@@ -539,10 +565,12 @@ export async function startAgent({
539
565
  stream,
540
566
  hasInternalJobs: internalJobs().length > 0,
541
567
  getSubagents: () => pluginRuntime.get().subagents,
568
+ onCountStore: (store) => {
569
+ cronCountStore = store
570
+ },
542
571
  })
543
572
 
544
573
  if (scheduler) {
545
- cronConsumer.start()
546
574
  reloadRegistry.register(
547
575
  createCronReloadable({ cwd, scheduler, internalJobs, getSubagents: () => pluginRuntime.get().subagents }),
548
576
  )
@@ -721,6 +749,7 @@ export async function startAgent({
721
749
  ...mcpManagerOpt,
722
750
  agentDir: cwd,
723
751
  pluginRuntime,
752
+ getFiredCount: (job) => cronCountStore?.get(job.id, job) ?? 0,
724
753
  claimController,
725
754
  commandRunnerFactory,
726
755
  tunnelManager,
@@ -838,6 +867,7 @@ async function startScheduler({
838
867
  stream,
839
868
  hasInternalJobs,
840
869
  getSubagents,
870
+ onCountStore,
841
871
  }: {
842
872
  cwd: string
843
873
  loadCron: LoadCronFn
@@ -845,6 +875,7 @@ async function startScheduler({
845
875
  stream: Stream
846
876
  hasInternalJobs: boolean
847
877
  getSubagents?: () => SubagentRegistry
878
+ onCountStore?: (store: CountStore) => void
848
879
  }): Promise<Scheduler | null> {
849
880
  let result: LoadCronResult
850
881
  const subagents = getSubagents?.()
@@ -864,13 +895,19 @@ async function startScheduler({
864
895
  const onFire = (job: CronJob) => {
865
896
  stream.publish({ target: { kind: 'cron', jobId: job.id }, payload: job })
866
897
  }
867
- const scheduler = createSchedulerFor({ cwd, file, onFire })
898
+ const scheduler = await createSchedulerFor({ cwd, file, onFire, onCountStore })
868
899
  scheduler.start()
869
900
  return scheduler
870
901
  }
871
902
 
872
903
  function makeDefaultSchedulerFactory(internalJobs: () => CronJob[]): SchedulerFactory {
873
- return ({ file, onFire }) => createScheduler({ jobs: [...file.jobs, ...internalJobs()], onFire })
904
+ return async ({ cwd, file, onFire, onCountStore }) => {
905
+ const jobs = [...file.jobs, ...internalJobs()]
906
+ const countStore = await createCountStore(cwd, jobs)
907
+ // Share the one store instance with the consumer's authoritative count gate.
908
+ onCountStore?.(countStore)
909
+ return createScheduler({ jobs, onFire, countStore })
910
+ }
874
911
  }
875
912
 
876
913
  // Exported for the regression test in `merge-subagents.test.ts`. The shim
@@ -0,0 +1,112 @@
1
+ import type { LineAccountCredentials, LineConfig } from 'agent-messenger/line'
2
+
3
+ import { sendHttp } from '@/hostd/client'
4
+
5
+ import { type LineChannelBlock, lineChannelBlockSchema } from './schema'
6
+ import { SecretsBackend } from './storage'
7
+
8
+ export type SecretsLineCredentialStoreOptions =
9
+ | { mode: 'host'; secretsPath: string }
10
+ | { mode: 'container'; secretsPath: string; hostdUrl: string; restartToken: string; containerName: string }
11
+
12
+ const EMPTY_BLOCK: LineChannelBlock = { currentAccount: null, accounts: {} }
13
+
14
+ export class SecretsLineCredentialStore {
15
+ private readonly backend: SecretsBackend
16
+ private writeChain: Promise<void> = Promise.resolve()
17
+
18
+ constructor(private readonly options: SecretsLineCredentialStoreOptions) {
19
+ this.backend = new SecretsBackend(options.secretsPath)
20
+ }
21
+
22
+ async load(): Promise<LineConfig> {
23
+ return toLineConfig(this.readBlock())
24
+ }
25
+
26
+ async save(config: LineConfig): Promise<void> {
27
+ await this.writeBlock(() => fromLineConfig(config))
28
+ }
29
+
30
+ async getAccount(id?: string): Promise<LineAccountCredentials | null> {
31
+ const config = await this.load()
32
+ if (id) return config.accounts[id] ?? null
33
+ if (!config.current_account) return null
34
+ return config.accounts[config.current_account] ?? null
35
+ }
36
+
37
+ async setAccount(account: LineAccountCredentials): Promise<void> {
38
+ await this.writeBlock((block) => {
39
+ const accounts = { ...block.accounts, [account.account_id]: account }
40
+ return { ...block, currentAccount: block.currentAccount ?? account.account_id, accounts }
41
+ })
42
+ }
43
+
44
+ async removeAccount(id: string): Promise<void> {
45
+ await this.writeBlock((block) => {
46
+ const accounts = { ...block.accounts }
47
+ delete accounts[id]
48
+ const currentAccount = block.currentAccount === id ? (Object.keys(accounts)[0] ?? null) : block.currentAccount
49
+ return { ...block, currentAccount, accounts }
50
+ })
51
+ }
52
+
53
+ async listAccounts(): Promise<Array<LineAccountCredentials & { is_current: boolean }>> {
54
+ const config = await this.load()
55
+ return Object.values(config.accounts).map((account) => ({
56
+ ...account,
57
+ is_current: account.account_id === config.current_account,
58
+ }))
59
+ }
60
+
61
+ async setCurrentAccount(id: string): Promise<void> {
62
+ await this.writeBlock((block) => ({ ...block, currentAccount: id }))
63
+ }
64
+
65
+ private readBlock(): LineChannelBlock {
66
+ const channels =
67
+ this.options.mode === 'container' ? this.backend.tryReadChannelsSync() : this.backend.readChannelsSync()
68
+ return parseBlock(channels?.line)
69
+ }
70
+
71
+ private async writeBlock(update: (current: LineChannelBlock) => LineChannelBlock): Promise<void> {
72
+ return this.enqueueWrite(async () => {
73
+ if (this.options.mode === 'container') {
74
+ const next = update(this.readBlock())
75
+ const response = await sendHttp(
76
+ {
77
+ kind: 'secrets-patch',
78
+ containerName: this.options.containerName,
79
+ patch: { channels: { line: next } },
80
+ },
81
+ { url: this.options.hostdUrl, token: this.options.restartToken },
82
+ )
83
+ if (!response.ok) throw new Error(`secrets-patch failed: ${response.reason}`)
84
+ return
85
+ }
86
+
87
+ await this.backend.updateChannelsAsync(async (channels) => {
88
+ const next = { ...channels, line: update(parseBlock(channels.line)) }
89
+ return { result: undefined, next }
90
+ })
91
+ })
92
+ }
93
+
94
+ private enqueueWrite(op: () => Promise<void>): Promise<void> {
95
+ const next = this.writeChain.then(op, op)
96
+ this.writeChain = next.catch(() => {})
97
+ return next
98
+ }
99
+ }
100
+
101
+ function parseBlock(value: unknown): LineChannelBlock {
102
+ if (value === undefined) return EMPTY_BLOCK
103
+ return lineChannelBlockSchema.parse(value)
104
+ }
105
+
106
+ function toLineConfig(block: LineChannelBlock): LineConfig {
107
+ return { current_account: block.currentAccount, accounts: block.accounts }
108
+ }
109
+
110
+ function fromLineConfig(config: LineConfig): LineChannelBlock {
111
+ return { currentAccount: config.current_account, accounts: config.accounts }
112
+ }
@@ -240,7 +240,7 @@ export async function loginXai(callbacks: OAuthLoginCallbacks, fetchImpl: FetchF
240
240
  callbacks.onAuth({
241
241
  url: `${AUTHORIZE_URL}?${authParams.toString()}`,
242
242
  instructions:
243
- 'Complete login in your browser. If the browser is on another machine, paste the final redirect URL here.',
243
+ 'Complete login in your browser. Grok shows a code to copy on the "could not establish connection" page — paste that code here. If the browser is on another machine, paste the code (or the final redirect URL) here.',
244
244
  })
245
245
 
246
246
  if (server && callbacks.onManualCodeInput) {
@@ -55,6 +55,28 @@ const githubChannelSchema = z.object({
55
55
  webhookSecret: secretFieldSchema,
56
56
  })
57
57
 
58
+ const lineDeviceSchema = z.enum(['DESKTOPWIN', 'DESKTOPMAC', 'ANDROID', 'ANDROIDSECONDARY', 'IOS', 'IOSIPAD'])
59
+
60
+ // LINE persists a long-lived auth token (+ optional certificate that lets a
61
+ // later re-login skip the e-mail/PIN step on the same device). There is no
62
+ // encrypted-password / renewal-cron path the way KakaoTalk has — LINE tokens
63
+ // don't expire on a fixed short schedule, so the renewal fields are absent by
64
+ // design.
65
+ export const lineAccountRecordSchema = z.object({
66
+ account_id: z.string(),
67
+ auth_token: z.string(),
68
+ certificate: z.string().optional(),
69
+ device: lineDeviceSchema,
70
+ display_name: z.string().optional(),
71
+ created_at: z.string(),
72
+ updated_at: z.string(),
73
+ })
74
+
75
+ export const lineChannelBlockSchema = z.object({
76
+ currentAccount: z.string().nullable(),
77
+ accounts: z.record(z.string(), lineAccountRecordSchema),
78
+ })
79
+
58
80
  // Encrypted password envelope produced by src/secrets/encryption.ts. Optional
59
81
  // in the schema because legacy v2 accounts (pre-renewal feature) don't have
60
82
  // one; the renewal cron treats a missing envelope as "reauth required" and
@@ -109,6 +131,7 @@ export const channelsSchema = z
109
131
  'discord-bot': discordBotChannelSchema.optional(),
110
132
  github: githubChannelSchema.optional(),
111
133
  'telegram-bot': telegramBotChannelSchema.optional(),
134
+ line: lineChannelBlockSchema.optional(),
112
135
  kakaotalk: kakaoChannelBlockSchema.optional(),
113
136
  })
114
137
  .catchall(z.unknown())
@@ -130,6 +153,8 @@ export type Channels = z.infer<typeof channelsSchema>
130
153
  export type GithubPatAuthBlock = z.infer<typeof githubPatAuthSchema>
131
154
  export type GithubAppAuthBlock = z.infer<typeof githubAppAuthSchema>
132
155
  export type GithubSecretsBlock = z.infer<typeof githubChannelSchema>
156
+ export type LineAccountRecord = z.infer<typeof lineAccountRecordSchema>
157
+ export type LineChannelBlock = z.infer<typeof lineChannelBlockSchema>
133
158
  export type KakaoAccountRecord = z.infer<typeof kakaoAccountRecordSchema>
134
159
  export type PendingLoginRecord = z.infer<typeof kakaoPendingLoginRecordSchema>
135
160
  export type KakaoChannelBlock = z.infer<typeof kakaoChannelBlockSchema>
@@ -28,7 +28,7 @@ import {
28
28
  } from '@/agent/todo/continuation-wiring'
29
29
  import { SUBAGENT_OUTPUT_TOOL_NAME } from '@/agent/tools/subagent-output'
30
30
  import type { ChannelRouter } from '@/channels/router'
31
- import { aggregateCronList, type CronListEntry, loadCron } from '@/cron'
31
+ import { aggregateCronList, type CronJob, type CronListEntry, loadCron } from '@/cron'
32
32
  import type { McpManager } from '@/mcp'
33
33
  import type { HookBus } from '@/plugin'
34
34
  import type { BrokerWsData, ContainerBroker } from '@/portbroker'
@@ -75,6 +75,9 @@ export type ServerOptions = {
75
75
  mcpManager?: McpManager
76
76
  agentDir?: string
77
77
  pluginRuntime?: PluginRuntime
78
+ // Durable cron fire-progress lookup so `cron list` marks count-exhausted jobs
79
+ // as retired instead of showing a stale future fire time. Omit in tests/dev.
80
+ getFiredCount?: (job: CronJob) => number
78
81
  containerName?: string
79
82
  runtimeVersion?: string
80
83
  tuiToken?: string
@@ -252,6 +255,7 @@ export function createServer({
252
255
  mcpManager,
253
256
  agentDir,
254
257
  pluginRuntime,
258
+ getFiredCount,
255
259
  containerName,
256
260
  runtimeVersion,
257
261
  tuiToken,
@@ -716,7 +720,7 @@ export function createServer({
716
720
  }
717
721
 
718
722
  if (msg.type === 'cron_list') {
719
- await handleCronList(ws, msg.requestId, pluginRuntime, agentDir)
723
+ await handleCronList(ws, msg.requestId, pluginRuntime, agentDir, getFiredCount)
720
724
  return
721
725
  }
722
726
 
@@ -1186,6 +1190,7 @@ async function handleCronList(
1186
1190
  requestId: string,
1187
1191
  pluginRuntime: PluginRuntime | undefined,
1188
1192
  agentDir: string | undefined,
1193
+ getFiredCount?: (job: CronJob) => number,
1189
1194
  ): Promise<void> {
1190
1195
  if (agentDir === undefined) {
1191
1196
  send(ws, { type: 'cron_list_result', requestId, result: { ok: false, reason: 'agentDir not configured' } })
@@ -1208,7 +1213,12 @@ async function handleCronList(
1208
1213
  const userJobs = loadResult.file?.jobs ?? []
1209
1214
  const pluginJobs = snapshot?.registry.cronJobs ?? []
1210
1215
  const nowMs = Date.now()
1211
- const entries = aggregateCronList({ userJobs, pluginJobs, now: nowMs })
1216
+ const entries = aggregateCronList({
1217
+ userJobs,
1218
+ pluginJobs,
1219
+ now: nowMs,
1220
+ ...(getFiredCount !== undefined ? { firedCount: getFiredCount } : {}),
1221
+ })
1212
1222
  send(ws, {
1213
1223
  type: 'cron_list_result',
1214
1224
  requestId,
@@ -1541,9 +1551,12 @@ function toPayload(entry: CronListEntry): CronListEntryPayload {
1541
1551
  id: entry.id,
1542
1552
  source,
1543
1553
  kind: entry.kind,
1544
- schedule: entry.schedule,
1545
1554
  enabled: entry.enabled,
1546
1555
  nextFireMs: entry.nextFireMs,
1556
+ ...(entry.schedule !== undefined ? { schedule: entry.schedule } : {}),
1557
+ ...(entry.at !== undefined ? { at: entry.at } : {}),
1558
+ ...(entry.until !== undefined ? { until: entry.until } : {}),
1559
+ ...(entry.count !== undefined ? { count: entry.count } : {}),
1547
1560
  ...(entry.timezone !== undefined ? { timezone: entry.timezone } : {}),
1548
1561
  ...(entry.scheduledByRole !== undefined ? { scheduledByRole: entry.scheduledByRole } : {}),
1549
1562
  ...(entry.scheduleError !== undefined ? { scheduleError: entry.scheduleError } : {}),
@@ -165,7 +165,10 @@ export type CronListEntryPayload = {
165
165
  id: string
166
166
  source: CronListSourcePayload
167
167
  kind: 'prompt' | 'exec' | 'handler'
168
- schedule: string
168
+ schedule?: string
169
+ at?: string
170
+ until?: string
171
+ count?: number
169
172
  timezone?: string
170
173
  enabled: boolean
171
174
  scheduledByRole?: string