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
@@ -25,17 +25,20 @@ import { createWriteReportTool } from './write-report'
25
25
  // `./skills/`; no runtime change required.
26
26
  export const RESEARCHER_SKILLS: readonly LoadableSkill[] = [GENERAL_RESEARCH_SKILL]
27
27
 
28
- // Mirrors the reviewer ceiling. A researcher whose `session.prompt` stalls
29
- // mid-turn would otherwise leave `completion` pending forever the
30
- // `subagent.completed` broadcast never fires and the parent is never woken to
31
- // read the report. The ceiling makes `awaitWithSubagentTimeout` settle with
32
- // SubagentTimeoutError, surfacing a FAILED completion reminder so the request
33
- // fails loudly instead of vanishing. Sized for a thorough `deep`-model pass
34
- // (multi-source gathering, a few delegated workers, writing a report file),
35
- // well above a typical sub-minute lookup. This is liveness for the parent, not
36
- // hard cancellation: pi's `session.prompt` takes no AbortSignal, so the LLM
37
- // stream may run until the OS reaps it. See src/agent/subagents.ts `timeoutMs`.
38
- export const RESEARCHER_SPAWN_TIMEOUT_MS = 600_000
28
+ // A researcher whose `session.prompt` stalls mid-turn would otherwise leave
29
+ // `completion` pending forever and never wake the parent. This ceiling makes
30
+ // the spawn settle with SubagentTimeoutError, surfacing a completion reminder
31
+ // so the request resolves loudly instead of vanishing.
32
+ //
33
+ // 30m, not the prior 10m: a real pass spent ~2.5m composing its scout fan-out,
34
+ // ~4–7m on 4 parallel scouts, then was killed ~2s into the final `write_report`
35
+ // discarding a finished report. The `deep` profile trades speed for quality,
36
+ // so nested scout warmup + multi-source gathering + synthesis routinely exceed
37
+ // 10m. This is liveness, not hard cancellation: `session.prompt` takes no
38
+ // AbortSignal, so the stream may run until the OS reaps it. A report produced
39
+ // before the ceiling is no longer lost — see startSubagent's finalMessage
40
+ // preservation in src/agent/subagents.ts.
41
+ export const RESEARCHER_SPAWN_TIMEOUT_MS = 1_800_000
39
42
 
40
43
  // TODO(#452): Restrict the researcher's `bash` to a curated read-only allowlist
41
44
  // once per-subagent bash allowlist support lands. Today the read-only contract
@@ -55,6 +55,7 @@ const PROCESS_ENV_TARGETS: ReadonlyArray<string> = [
55
55
  'OPENAI_API_KEY',
56
56
  'ANTHROPIC_API_KEY',
57
57
  'MINIMAX_API_KEY',
58
+ 'DEEPSEEK_API_KEY',
58
59
  'GOOGLE_API_KEY',
59
60
  'GEMINI_API_KEY',
60
61
  'AWS_ACCESS_KEY_ID',
@@ -0,0 +1,129 @@
1
+ import type { LineChat, LineClient } from 'agent-messenger/line'
2
+
3
+ import type { ChannelKey, ChannelNameResolver, ResolvedChannelNames } from '@/channels/types'
4
+
5
+ const DEFAULT_TTL_MS = 5 * 60 * 1000
6
+
7
+ // LINE's chat list is fetched in a bounded page; there is no `{ all: true }`
8
+ // equivalent the way KakaoTalk has. This cap is generous for a personal
9
+ // account but keeps the GETCHATS payload bounded.
10
+ const CHAT_FETCH_LIMIT = 500
11
+
12
+ export type LineWorkspace = '@line-dm' | '@line-group' | '@line-square'
13
+
14
+ // `user` is a 1:1 DM; `group` and `room` are both multi-party invite chats
15
+ // (LINE's legacy "room" vs modern "group" distinction is immaterial to
16
+ // engagement, so they share a bucket); `square` is an OpenChat-style public
17
+ // community, kept separate because it is the most public surface and least-
18
+ // privilege rules want to target it on its own.
19
+ export function lineWorkspaceForType(type: LineChat['type']): LineWorkspace {
20
+ if (type === 'user') return '@line-dm'
21
+ if (type === 'square') return '@line-square'
22
+ return '@line-group'
23
+ }
24
+
25
+ export type LineChatLookupValue = {
26
+ workspace: LineWorkspace
27
+ isDm: boolean
28
+ }
29
+
30
+ export type LineChannelResolver = {
31
+ resolve: ChannelNameResolver
32
+ lookupChat: (chatId: string) => LineChatLookupValue | null
33
+ refresh: () => Promise<void>
34
+ // Register a chat learned from an inbound push event when `refresh()` did
35
+ // not surface it (a new chat that hasn't propagated to GETCHATS yet).
36
+ // Provisional entries default to @line-group — the strictest multi-party
37
+ // bucket — so allow-rule enforcement stays conservative until the next real
38
+ // refresh upgrades the entry to its authoritative type.
39
+ ingestProvisional: (chatId: string) => void
40
+ }
41
+
42
+ export type LineChannelResolverOptions = {
43
+ client: Pick<LineClient, 'getChats'>
44
+ now?: () => number
45
+ ttlMs?: number
46
+ logger?: { warn: (msg: string) => void }
47
+ }
48
+
49
+ type Entry = {
50
+ workspace: LineWorkspace
51
+ isDm: boolean
52
+ chatName: string | null
53
+ expiresAt: number
54
+ }
55
+
56
+ export function createLineChannelResolver(options: LineChannelResolverOptions): LineChannelResolver {
57
+ const now = options.now ?? Date.now
58
+ const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS
59
+ const cache = new Map<string, Entry>()
60
+ let inflight: Promise<void> | null = null
61
+
62
+ const refresh = async (): Promise<void> => {
63
+ if (inflight !== null) {
64
+ await inflight
65
+ return
66
+ }
67
+ const promise = loadAll().finally(() => {
68
+ inflight = null
69
+ })
70
+ inflight = promise
71
+ await promise
72
+ }
73
+
74
+ const loadAll = async (): Promise<void> => {
75
+ try {
76
+ const chats = await options.client.getChats({ limit: CHAT_FETCH_LIMIT })
77
+ const expiresAt = now() + ttlMs
78
+ for (const chat of chats) ingest(chat, expiresAt)
79
+ } catch (err) {
80
+ options.logger?.warn(`[line] channel resolver refresh failed: ${describe(err)}`)
81
+ }
82
+ }
83
+
84
+ const ingest = (chat: LineChat, expiresAt: number): void => {
85
+ const workspace = lineWorkspaceForType(chat.type)
86
+ cache.set(chat.chat_id, {
87
+ workspace,
88
+ isDm: chat.type === 'user',
89
+ chatName: chat.display_name === '' ? null : chat.display_name,
90
+ expiresAt,
91
+ })
92
+ }
93
+
94
+ const resolve: ChannelNameResolver = async (key: ChannelKey): Promise<ResolvedChannelNames> => {
95
+ const entry = cache.get(key.chat)
96
+ if (entry === undefined || entry.expiresAt <= now()) await refresh()
97
+ const fresh = cache.get(key.chat)
98
+ if (fresh === undefined) return {}
99
+ const result: ResolvedChannelNames = {}
100
+ if (fresh.chatName !== null && fresh.chatName !== '') result.chatName = fresh.chatName
101
+ return result
102
+ }
103
+
104
+ // Sync lookup. Returns null when the entry is missing OR stale; callers MUST
105
+ // treat null as "refresh needed", not "unknown forever" — the adapter awaits
106
+ // refresh() and re-checks before dropping a message as unknown_chat.
107
+ const lookupChat = (chatId: string): LineChatLookupValue | null => {
108
+ const entry = cache.get(chatId)
109
+ if (entry === undefined || entry.expiresAt <= now()) return null
110
+ return { workspace: entry.workspace, isDm: entry.isDm }
111
+ }
112
+
113
+ const ingestProvisional = (chatId: string): void => {
114
+ const existing = cache.get(chatId)
115
+ if (existing !== undefined && existing.expiresAt > now()) return
116
+ cache.set(chatId, {
117
+ workspace: '@line-group',
118
+ isDm: false,
119
+ chatName: null,
120
+ expiresAt: now() + ttlMs,
121
+ })
122
+ }
123
+
124
+ return { resolve, lookupChat, refresh, ingestProvisional }
125
+ }
126
+
127
+ function describe(err: unknown): string {
128
+ return err instanceof Error ? err.message : String(err)
129
+ }
@@ -0,0 +1,80 @@
1
+ import type { LinePushMessageEvent } from 'agent-messenger/line'
2
+
3
+ import { matchesAnyAlias } from '@/channels/engagement'
4
+ import type { ChannelAdapterConfig } from '@/channels/schema'
5
+ import type { InboundMessage } from '@/channels/types'
6
+
7
+ export type InboundDropReason = 'self_author' | 'empty_text' | 'unknown_chat' | 'pre_connect'
8
+
9
+ export type InboundClassification =
10
+ | { kind: 'drop'; reason: InboundDropReason }
11
+ | { kind: 'route'; payload: InboundMessage }
12
+
13
+ export type LineChatLookup = (chatId: string) => {
14
+ workspace: '@line-dm' | '@line-group' | '@line-square'
15
+ isDm: boolean
16
+ } | null
17
+
18
+ export type LineInboundContext = {
19
+ selfUserId: string | null
20
+ lookupChat: LineChatLookup
21
+ selfAliases?: readonly string[]
22
+ // LINE push events lack `author_name`, so the adapter resolves it (best
23
+ // effort) and passes it here; falls back to the raw author id.
24
+ authorName?: string
25
+ }
26
+
27
+ export function classifyInbound(
28
+ event: LinePushMessageEvent,
29
+ _config: ChannelAdapterConfig,
30
+ context: LineInboundContext,
31
+ ): InboundClassification {
32
+ if (context.selfUserId === null) {
33
+ return { kind: 'drop', reason: 'pre_connect' }
34
+ }
35
+ if (event.author_id === context.selfUserId) {
36
+ return { kind: 'drop', reason: 'self_author' }
37
+ }
38
+
39
+ const text = event.text ?? ''
40
+ if (text === '') return { kind: 'drop', reason: 'empty_text' }
41
+
42
+ const chatInfo = context.lookupChat(event.chat_id)
43
+ if (chatInfo === null) {
44
+ return { kind: 'drop', reason: 'unknown_chat' }
45
+ }
46
+
47
+ // LINE has no native @-mention the push protocol surfaces. Like KakaoTalk,
48
+ // mention-equivalent engagement comes solely from plain-text alias matching,
49
+ // which the engagement layer ranks alongside an explicit mention.
50
+ const aliasMatched = matchesAnyAlias(text, context.selfAliases ?? [])
51
+ const authorName = context.authorName ?? event.author_id
52
+
53
+ // LINE's `sent_at` is an ISO-ish string (vs KakaoTalk's Unix seconds). The
54
+ // contract wants ms since epoch; a malformed timestamp degrades to 0
55
+ // ("unknown") so the formatter omits the time prefix rather than stamping a
56
+ // wrong clock.
57
+ const parsed = Date.parse(event.sent_at)
58
+ const ts = Number.isNaN(parsed) ? 0 : parsed
59
+
60
+ return {
61
+ kind: 'route',
62
+ payload: {
63
+ adapter: 'line',
64
+ workspace: chatInfo.workspace,
65
+ chat: event.chat_id,
66
+ thread: null,
67
+ text,
68
+ externalMessageId: event.message_id,
69
+ authorId: event.author_id,
70
+ authorName,
71
+ authorIsBot: false,
72
+ isBotMention: aliasMatched,
73
+ replyToBotMessageId: null,
74
+ mentionsOthers: false,
75
+ replyToOtherMessageId: null,
76
+ isDm: chatInfo.isDm,
77
+ ts,
78
+ },
79
+ }
80
+ }
@@ -0,0 +1,11 @@
1
+ import { toKakaoPlainText } from './kakaotalk-format'
2
+
3
+ // LINE chat renders no rich text, exactly like KakaoTalk's LOCO surface:
4
+ // `**bold**`, `### headings`, `| tables |`, and fenced code blocks all show
5
+ // their literal markers. The markdown-stripping rules are identical, so this
6
+ // reuses the KakaoTalk stripper rather than maintaining a second copy that
7
+ // would drift. If LINE ever grows a formatting quirk KakaoTalk lacks, fork the
8
+ // implementation here.
9
+ export function toLinePlainText(input: string): string {
10
+ return toKakaoPlainText(input)
11
+ }
@@ -0,0 +1,350 @@
1
+ import {
2
+ LineClient as RealLineClient,
3
+ LineListener as RealLineListener,
4
+ type LineAccountCredentials,
5
+ type LineChat,
6
+ type LineConfig,
7
+ type LineListenerEventMap,
8
+ type LineMessage,
9
+ type LineProfile,
10
+ type LinePushMessageEvent,
11
+ type LineSendResult,
12
+ } from 'agent-messenger/line'
13
+
14
+ import type { ChannelRouter } from '@/channels/router'
15
+ import type { ChannelAdapterConfig } from '@/channels/schema'
16
+ import type {
17
+ ChannelHistoryMessage,
18
+ FetchHistoryArgs,
19
+ FetchHistoryResult,
20
+ HistoryCallback,
21
+ OutboundCallback,
22
+ OutboundMessage,
23
+ ResolvedChannelNames,
24
+ SendResult,
25
+ } from '@/channels/types'
26
+
27
+ import { createLineChannelResolver } from './line-channel-resolver'
28
+ import { classifyInbound } from './line-classify'
29
+ import { toLinePlainText } from './line-format'
30
+
31
+ // Structural duck-type of the upstream LineClient class. Declaring this as an
32
+ // interface (rather than reusing the nominal class type) lets test fakes
33
+ // satisfy the public surface without inheriting the class's private fields.
34
+ // The cast on the const below bridges the runtime class onto this interface.
35
+ export interface LineClient {
36
+ login(credentials?: LineAccountCredentials): Promise<this>
37
+ getProfile(): Promise<LineProfile>
38
+ getChats(options?: { limit?: number }): Promise<LineChat[]>
39
+ getMessages(chatId: string, options?: { count?: number }): Promise<LineMessage[]>
40
+ sendMessage(chatId: string, text: string): Promise<LineSendResult>
41
+ close(): void
42
+ }
43
+
44
+ export interface LineListener {
45
+ start(): Promise<void>
46
+ stop(): void
47
+ on<K extends keyof LineListenerEventMap>(event: K, listener: (...args: LineListenerEventMap[K]) => void): this
48
+ off<K extends keyof LineListenerEventMap>(event: K, listener: (...args: LineListenerEventMap[K]) => void): this
49
+ }
50
+
51
+ export type LineCredentialStore = {
52
+ load(): Promise<LineConfig>
53
+ getAccount(id?: string): Promise<LineAccountCredentials | null>
54
+ }
55
+
56
+ const LineClient = RealLineClient as unknown as new () => LineClient
57
+ const LineListener = RealLineListener as unknown as new (client: LineClient) => LineListener
58
+
59
+ export type LineAdapterLogger = {
60
+ info: (msg: string) => void
61
+ warn: (msg: string) => void
62
+ error: (msg: string) => void
63
+ }
64
+
65
+ const consoleLogger: LineAdapterLogger = {
66
+ info: (m) => console.log(m),
67
+ warn: (m) => console.warn(m),
68
+ error: (m) => console.error(m),
69
+ }
70
+
71
+ export type LineAdapterOptions = {
72
+ router: ChannelRouter
73
+ configRef: () => ChannelAdapterConfig
74
+ logger?: LineAdapterLogger
75
+ selfAliasesRef?: () => readonly string[]
76
+ credentialsStore?: LineCredentialStore
77
+ client?: LineClient
78
+ listenerFactory?: (client: LineClient) => LineListener
79
+ }
80
+
81
+ export type LineAdapter = {
82
+ start: () => Promise<void>
83
+ stop: () => Promise<void>
84
+ isConnected: () => boolean
85
+ }
86
+
87
+ export const LINE_HISTORY_LIMIT_MAX = 200
88
+
89
+ export function createOutboundCallback(deps: {
90
+ client: Pick<LineClient, 'sendMessage'>
91
+ logger: LineAdapterLogger
92
+ formatChannelTag: (workspace: string, chat: string) => Promise<string>
93
+ }): OutboundCallback {
94
+ const { client, logger, formatChannelTag } = deps
95
+ return async (msg: OutboundMessage): Promise<SendResult> => {
96
+ if (msg.adapter !== 'line') {
97
+ return { ok: false, error: `unknown adapter: ${msg.adapter}` }
98
+ }
99
+ // LINE's SDK exposes text sends only — there is no attachment upload
100
+ // primitive, so an outbound carrying attachments is rejected loudly
101
+ // rather than silently dropping the files.
102
+ if (msg.attachments !== undefined && msg.attachments.length > 0) {
103
+ return { ok: false, error: 'line adapter does not support outbound attachments' }
104
+ }
105
+ const text = toLinePlainText(msg.text ?? '')
106
+ if (text === '') {
107
+ return { ok: false, error: 'message has no text' }
108
+ }
109
+ const tag = await formatChannelTag(msg.workspace, msg.chat)
110
+ logger.info(`[line] outbound ${tag} text_len=${text.length}`)
111
+ try {
112
+ const result = await client.sendMessage(msg.chat, text)
113
+ if (!result.success) {
114
+ logger.error(`[line] sendMessage non-success ${tag}`)
115
+ return { ok: false, error: 'line send failed' }
116
+ }
117
+ logger.info(`[line] sent message_id=${result.message_id} ${tag}`)
118
+ } catch (err) {
119
+ const message = describe(err)
120
+ logger.error(`[line] sendMessage failed: ${message}`)
121
+ return { ok: false, error: message }
122
+ }
123
+ return { ok: true }
124
+ }
125
+ }
126
+
127
+ export function createLineHistoryCallback(deps: {
128
+ client: Pick<LineClient, 'getMessages'>
129
+ logger: LineAdapterLogger
130
+ selfUserIdRef: () => string | null
131
+ }): HistoryCallback {
132
+ const { client, logger, selfUserIdRef } = deps
133
+ return async (args: FetchHistoryArgs): Promise<FetchHistoryResult> => {
134
+ const limit = clampLimit(args.limit, LINE_HISTORY_LIMIT_MAX)
135
+ try {
136
+ const messages = await client.getMessages(args.chat, { count: limit })
137
+ const selfId = selfUserIdRef()
138
+ const mapped: ChannelHistoryMessage[] = messages.map((m) => {
139
+ const parsed = Date.parse(m.sent_at)
140
+ return {
141
+ externalMessageId: m.message_id,
142
+ authorId: m.author_id,
143
+ authorName: m.author_name ?? m.author_id,
144
+ text: m.text ?? '',
145
+ ts: Number.isNaN(parsed) ? 0 : parsed,
146
+ isBot: selfId !== null && m.author_id === selfId,
147
+ replyToBotMessageId: null,
148
+ }
149
+ })
150
+ return { ok: true, messages: mapped }
151
+ } catch (err) {
152
+ const message = describe(err)
153
+ logger.warn(`[line] history fetch failed: ${message}`)
154
+ return { ok: false, error: message }
155
+ }
156
+ }
157
+ }
158
+
159
+ function clampLimit(requested: number, max: number): number {
160
+ if (!Number.isFinite(requested) || requested <= 0) return max
161
+ return Math.min(Math.floor(requested), max)
162
+ }
163
+
164
+ export function createLineAdapter(options: LineAdapterOptions): LineAdapter {
165
+ const logger = options.logger ?? consoleLogger
166
+ const client = options.client ?? new LineClient()
167
+ let listener: LineListener | null = null
168
+ let selfUserId: string | null = null
169
+ let connected = false
170
+ let started = false
171
+ let inflightInbounds = 0
172
+ let stopWaiters: Array<() => void> = []
173
+
174
+ const channelResolver = createLineChannelResolver({ client, logger })
175
+
176
+ const formatChannelTag = async (workspace: string, chat: string): Promise<string> => {
177
+ const names = await channelResolver
178
+ .resolve({ adapter: 'line', workspace, chat, thread: null })
179
+ .catch(() => ({}) as ResolvedChannelNames)
180
+ return `bucket=${workspace} chat=${formatLabel(names.chatName, chat)}`
181
+ }
182
+
183
+ const historyCallback = createLineHistoryCallback({
184
+ client,
185
+ logger,
186
+ selfUserIdRef: () => selfUserId,
187
+ })
188
+
189
+ const outboundCallback = createOutboundCallback({ client, logger, formatChannelTag })
190
+
191
+ const processInbound = async (event: LinePushMessageEvent): Promise<void> => {
192
+ inflightInbounds++
193
+ try {
194
+ if (channelResolver.lookupChat(event.chat_id) === null) {
195
+ await channelResolver.refresh()
196
+ if (channelResolver.lookupChat(event.chat_id) === null) {
197
+ // The push event itself proves the chat exists even when GETCHATS
198
+ // hasn't surfaced it yet. Register a provisional @line-group entry
199
+ // (the strictest multi-party bucket) so the message is not silently
200
+ // dropped as unknown_chat; the next refresh upgrades it.
201
+ channelResolver.ingestProvisional(event.chat_id)
202
+ logger.warn(
203
+ `[line] provisional chat=${event.chat_id} message_id=${event.message_id} bucket=@line-group reason=not_in_getchats`,
204
+ )
205
+ }
206
+ }
207
+
208
+ const bucket = channelResolver.lookupChat(event.chat_id)?.workspace ?? '@line-group'
209
+ const inboundTag = await formatChannelTag(bucket, event.chat_id)
210
+ logger.info(
211
+ `[line] inbound message_id=${event.message_id} author=${event.author_id} ${inboundTag} text_len=${(event.text ?? '').length}`,
212
+ )
213
+
214
+ const verdict = classifyInbound(event, options.configRef(), {
215
+ selfUserId,
216
+ lookupChat: (id) => channelResolver.lookupChat(id),
217
+ ...(options.selfAliasesRef ? { selfAliases: options.selfAliasesRef() } : {}),
218
+ })
219
+ if (verdict.kind === 'drop') {
220
+ logger.info(`[line] dropped message_id=${event.message_id} reason=${verdict.reason}`)
221
+ return
222
+ }
223
+
224
+ logger.info(
225
+ `[line] routed message_id=${event.message_id} ${inboundTag} mention=${verdict.payload.isBotMention} dm=${verdict.payload.isDm}`,
226
+ )
227
+ await options.router.route(verdict.payload)
228
+ } catch (err) {
229
+ logger.error(`[line] handleInbound failed: ${describe(err)}`)
230
+ } finally {
231
+ inflightInbounds--
232
+ if (inflightInbounds === 0 && stopWaiters.length > 0) {
233
+ const waiters = stopWaiters
234
+ stopWaiters = []
235
+ for (const w of waiters) w()
236
+ }
237
+ }
238
+ }
239
+
240
+ return {
241
+ async start(): Promise<void> {
242
+ if (started) return
243
+ started = true
244
+ try {
245
+ const credentialStore = options.credentialsStore ?? null
246
+ if (credentialStore !== null) {
247
+ const account = await credentialStore.getAccount()
248
+ if (account === null) {
249
+ throw new Error('no LINE account in secrets.json#channels.line (run typeclaw init to authenticate)')
250
+ }
251
+ await client.login(account)
252
+ } else {
253
+ await client.login()
254
+ }
255
+ } catch (err) {
256
+ started = false
257
+ logger.error(`[line] login failed: ${describe(err)}`)
258
+ throw err
259
+ }
260
+
261
+ try {
262
+ const profile = await client.getProfile()
263
+ selfUserId = profile.mid
264
+ logger.info(`[line] authenticated as ${profile.display_name || profile.mid} (${profile.mid})`)
265
+ } catch (err) {
266
+ started = false
267
+ logger.error(`[line] getProfile failed: ${describe(err)}`)
268
+ throw err
269
+ }
270
+
271
+ try {
272
+ await channelResolver.refresh()
273
+ } catch (err) {
274
+ logger.warn(`[line] initial chat list fetch failed: ${describe(err)}`)
275
+ }
276
+
277
+ listener = options.listenerFactory ? options.listenerFactory(client) : new LineListener(client)
278
+ listener.on('connected', (info) => {
279
+ connected = true
280
+ logger.info(`[line] connected (account_id=${info.account_id})`)
281
+ })
282
+ listener.on('disconnected', () => {
283
+ connected = false
284
+ logger.warn('[line] disconnected; SDK will reconnect with backoff')
285
+ })
286
+ listener.on('error', (err) => {
287
+ logger.error(`[line] listener error: ${describe(err)}`)
288
+ })
289
+ listener.on('message', (event) => {
290
+ void processInbound(event)
291
+ })
292
+
293
+ try {
294
+ await listener.start()
295
+ } catch (err) {
296
+ try {
297
+ listener.stop()
298
+ } catch {
299
+ // best-effort cleanup; the start failure is what we surface
300
+ }
301
+ listener = null
302
+ started = false
303
+ logger.error(`[line] listener start failed: ${describe(err)}`)
304
+ throw err
305
+ }
306
+
307
+ // Registration happens AFTER listener.start() resolves so a start
308
+ // failure cannot leave the router pointing at callbacks for a
309
+ // half-initialized adapter. stop() unregisters in inverse order.
310
+ options.router.registerOutbound('line', outboundCallback)
311
+ options.router.registerChannelNameResolver('line', channelResolver.resolve)
312
+ options.router.registerHistory('line', historyCallback)
313
+ },
314
+
315
+ async stop(): Promise<void> {
316
+ if (!started) return
317
+ started = false
318
+ options.router.unregisterOutbound('line', outboundCallback)
319
+ options.router.unregisterChannelNameResolver('line', channelResolver.resolve)
320
+ options.router.unregisterHistory('line', historyCallback)
321
+ if (inflightInbounds > 0) {
322
+ await new Promise<void>((resolve) => {
323
+ stopWaiters.push(resolve)
324
+ })
325
+ }
326
+ listener?.stop()
327
+ listener = null
328
+ try {
329
+ client.close()
330
+ } catch {
331
+ // close() throwing on a half-initialized client is benign.
332
+ }
333
+ selfUserId = null
334
+ connected = false
335
+ },
336
+
337
+ isConnected(): boolean {
338
+ return connected && selfUserId !== null
339
+ },
340
+ }
341
+ }
342
+
343
+ function formatLabel(name: string | undefined, id: string): string {
344
+ if (name === undefined || name === '' || name === id) return id
345
+ return `${name}(${id})`
346
+ }
347
+
348
+ function describe(err: unknown): string {
349
+ return err instanceof Error ? err.message : String(err)
350
+ }
@@ -37,8 +37,10 @@ export class StickyLedger {
37
37
  return expiresAt !== undefined && expiresAt > now
38
38
  }
39
39
 
40
- clear(key: string): void {
40
+ clear(key: string): number {
41
+ const cleared = this.byKey.get(key)?.size ?? 0
41
42
  this.byKey.delete(key)
43
+ return cleared
42
44
  }
43
45
  }
44
46
 
@@ -60,7 +62,7 @@ export type EngagementInput = {
60
62
  // once. Empty list means alias-based engagement is off — useful for
61
63
  // tests and for agents that explicitly want strict-mention behavior.
62
64
  // Match semantics: case-insensitive substring of inbound text. This is
63
- // the operator contract documented in typeclaw-config; if a name is too
65
+ // the operator contract documented in typeclaw-channels; if a name is too
64
66
  // generic ("bot", "ai") it WILL produce false matches and the operator
65
67
  // owns curation.
66
68
  selfAliases: readonly string[]