typeclaw 0.32.1 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/auth.schema.json +66 -0
  2. package/cron.schema.json +26 -2
  3. package/package.json +1 -1
  4. package/secrets.schema.json +66 -0
  5. package/src/agent/index.ts +7 -3
  6. package/src/agent/session-origin.ts +17 -0
  7. package/src/agent/subagent-completion-reminder.ts +14 -1
  8. package/src/agent/subagent-drain.ts +2 -0
  9. package/src/agent/subagents.ts +21 -7
  10. package/src/agent/tools/channel-disengage.ts +66 -0
  11. package/src/agent/tools/channel-log.ts +3 -2
  12. package/src/agent/tools/spawn-subagent.ts +25 -5
  13. package/src/agent/tools/subagent-output.ts +13 -1
  14. package/src/bundled-plugins/guard/policies/managed-config.ts +1 -1
  15. package/src/bundled-plugins/memory/memory-logger.ts +7 -0
  16. package/src/bundled-plugins/researcher/researcher.ts +14 -11
  17. package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +2 -0
  18. package/src/channels/adapters/line-channel-resolver.ts +129 -0
  19. package/src/channels/adapters/line-classify.ts +80 -0
  20. package/src/channels/adapters/line-format.ts +11 -0
  21. package/src/channels/adapters/line.ts +350 -0
  22. package/src/channels/engagement.ts +4 -2
  23. package/src/channels/manager.ts +65 -6
  24. package/src/channels/router.ts +186 -41
  25. package/src/channels/schema.ts +6 -1
  26. package/src/cli/channel.ts +112 -1
  27. package/src/cli/cron.ts +22 -4
  28. package/src/cli/init.ts +267 -82
  29. package/src/cli/model.ts +5 -1
  30. package/src/cli/oauth-callbacks.ts +5 -4
  31. package/src/cli/provider.ts +41 -10
  32. package/src/config/providers.ts +366 -7
  33. package/src/cron/consumer.ts +33 -0
  34. package/src/cron/count-state.ts +208 -0
  35. package/src/cron/index.ts +4 -17
  36. package/src/cron/list.ts +24 -6
  37. package/src/cron/scheduler.ts +84 -9
  38. package/src/cron/schema.ts +100 -13
  39. package/src/doctor/channel-checks.ts +28 -0
  40. package/src/hostd/daemon.ts +14 -6
  41. package/src/hostd/protocol.ts +6 -2
  42. package/src/init/gitignore.ts +1 -1
  43. package/src/init/index.ts +36 -3
  44. package/src/init/line-auth.ts +98 -0
  45. package/src/init/models-dev.ts +3 -0
  46. package/src/init/run-owner-claim.ts +1 -0
  47. package/src/init/validate-api-key.ts +15 -0
  48. package/src/inspect/label.ts +1 -0
  49. package/src/permissions/match-rule.ts +28 -12
  50. package/src/permissions/resolve.ts +8 -1
  51. package/src/role-claim/match-rule.ts +5 -1
  52. package/src/run/index.ts +41 -4
  53. package/src/secrets/line-store.ts +112 -0
  54. package/src/secrets/oauth-xai.ts +342 -0
  55. package/src/secrets/schema.ts +25 -0
  56. package/src/secrets/storage.ts +2 -0
  57. package/src/server/index.ts +17 -4
  58. package/src/shared/protocol.ts +4 -1
  59. package/src/skills/typeclaw-channel-line/SKILL.md +46 -0
  60. package/src/skills/typeclaw-channels/SKILL.md +153 -0
  61. package/src/skills/typeclaw-config/SKILL.md +54 -184
  62. package/src/skills/typeclaw-config/references/dockerfile.md +66 -0
  63. package/src/skills/typeclaw-cron/SKILL.md +68 -14
  64. package/src/skills/typeclaw-permissions/SKILL.md +3 -3
  65. package/typeclaw.schema.json +185 -3
@@ -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.
@@ -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
+ }
@@ -20,6 +20,9 @@ const PROVIDER_TO_MODELS_DEV: Record<KnownProviderId, string> = {
20
20
  // catalog. models.dev tracks the underlying model metadata under `zai`,
21
21
  // so we route lookups there. The curated entries still get surfaced.
22
22
  'zai-coding': 'zai',
23
+ xai: 'xai',
24
+ minimax: 'minimax',
25
+ deepseek: 'deepseek',
23
26
  }
24
27
 
25
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
 
@@ -7,6 +7,9 @@ const PROVIDER_PROBE: Partial<Record<KnownProviderId, { url: string; authHeader:
7
7
  fireworks: { url: 'https://api.fireworks.ai/inference/v1/models', authHeader: 'bearer' },
8
8
  zai: { url: 'https://api.z.ai/api/paas/v4/models', authHeader: 'bearer' },
9
9
  'zai-coding': { url: 'https://api.z.ai/api/coding/paas/v4/models', authHeader: 'bearer' },
10
+ xai: { url: 'https://api.x.ai/v1/models', authHeader: 'bearer' },
11
+ minimax: { url: 'https://api.minimax.io/v1/models', authHeader: 'bearer' },
12
+ deepseek: { url: 'https://api.deepseek.com/models', authHeader: 'bearer' },
10
13
  }
11
14
 
12
15
  // When a base-URL override (ANTHROPIC_BASE_URL / OPENAI_BASE_URL) points at a
@@ -159,8 +162,20 @@ export const API_KEY_DASHBOARD_URL: Partial<Record<KnownProviderId, string>> = {
159
162
  fireworks: 'https://fireworks.ai/account/api-keys',
160
163
  zai: 'https://docs.z.ai/devpack/tool/claude#api-key',
161
164
  'zai-coding': 'https://docs.z.ai/devpack/tool/claude#api-key',
165
+ xai: 'https://console.x.ai',
166
+ minimax: 'https://platform.minimax.io/user-center/basic-information/interface-key',
167
+ deepseek: 'https://platform.deepseek.com/api_keys',
162
168
  }
163
169
 
170
+ // MiniMax sells the same `minimax` provider under two billing surfaces that
171
+ // each hand out a key on a DIFFERENT dashboard page: pay-as-you-go API keys
172
+ // (the API_KEY_DASHBOARD_URL above) and Token Plan "Subscription Keys"
173
+ // (sk-cp-…, this URL). Both keys are Bearer tokens for the same api.minimax.io
174
+ // endpoint and store in the same MINIMAX_API_KEY slot — the runtime doesn't
175
+ // care which. The init wizard surfaces the choice only to deep-link the
176
+ // correct dashboard, so a Token Plan subscriber isn't sent to the paygo page.
177
+ export const MINIMAX_TOKEN_PLAN_DASHBOARD_URL = 'https://platform.minimax.io/user-center/payment/token-plan'
178
+
164
179
  export function providersWithApiKeyProbe(): KnownProviderId[] {
165
180
  return Object.keys(PROVIDER_PROBE) as KnownProviderId[]
166
181
  }
@@ -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