typeclaw 0.33.0 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) 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 +1 -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/oauth-callbacks.ts +5 -4
  29. package/src/config/providers.ts +62 -0
  30. package/src/cron/consumer.ts +33 -0
  31. package/src/cron/count-state.ts +208 -0
  32. package/src/cron/index.ts +4 -17
  33. package/src/cron/list.ts +24 -6
  34. package/src/cron/scheduler.ts +84 -9
  35. package/src/cron/schema.ts +100 -13
  36. package/src/doctor/channel-checks.ts +28 -0
  37. package/src/hostd/daemon.ts +14 -6
  38. package/src/hostd/protocol.ts +6 -2
  39. package/src/init/gitignore.ts +1 -1
  40. package/src/init/index.ts +36 -3
  41. package/src/init/line-auth.ts +98 -0
  42. package/src/init/models-dev.ts +1 -0
  43. package/src/init/run-owner-claim.ts +1 -0
  44. package/src/init/validate-api-key.ts +2 -0
  45. package/src/inspect/label.ts +1 -0
  46. package/src/permissions/match-rule.ts +28 -12
  47. package/src/permissions/resolve.ts +8 -1
  48. package/src/role-claim/match-rule.ts +5 -1
  49. package/src/run/index.ts +41 -4
  50. package/src/secrets/line-store.ts +112 -0
  51. package/src/secrets/oauth-xai.ts +1 -1
  52. package/src/secrets/schema.ts +25 -0
  53. package/src/server/index.ts +17 -4
  54. package/src/shared/protocol.ts +4 -1
  55. package/src/skills/typeclaw-channel-line/SKILL.md +46 -0
  56. package/src/skills/typeclaw-channels/SKILL.md +153 -0
  57. package/src/skills/typeclaw-config/SKILL.md +54 -184
  58. package/src/skills/typeclaw-config/references/dockerfile.md +66 -0
  59. package/src/skills/typeclaw-cron/SKILL.md +68 -14
  60. package/src/skills/typeclaw-permissions/SKILL.md +3 -3
  61. package/typeclaw.schema.json +167 -3
@@ -4,12 +4,14 @@ import { join } from 'node:path'
4
4
  import type { PermissionService } from '@/permissions'
5
5
  import type { GithubSecretsBlock } from '@/secrets'
6
6
  import { SecretsKakaoCredentialStore } from '@/secrets/kakao-store'
7
+ import { SecretsLineCredentialStore } from '@/secrets/line-store'
7
8
  import { SecretsBackend } from '@/secrets/storage'
8
9
  import type { Stream } from '@/stream'
9
10
 
10
11
  import { createDiscordBotAdapter, type DiscordBotAdapter } from './adapters/discord-bot'
11
12
  import { createGithubAdapter, type GithubAdapter } from './adapters/github'
12
13
  import { createKakaotalkAdapter, type KakaotalkAdapter } from './adapters/kakaotalk'
14
+ import { createLineAdapter, type LineAdapter } from './adapters/line'
13
15
  import { createSlackBotAdapter, type SlackBotAdapter } from './adapters/slack-bot'
14
16
  import { createTelegramBotAdapter, type TelegramBotAdapter } from './adapters/telegram-bot'
15
17
  import type { GithubTokenBridge } from './github-token-bridge'
@@ -66,6 +68,7 @@ export type ChannelManagerOptions = {
66
68
  createDiscordAdapter?: typeof createDiscordBotAdapter
67
69
  createGithubAdapter?: typeof createGithubAdapter
68
70
  createKakaotalkAdapter?: typeof createKakaotalkAdapter
71
+ createLineAdapter?: typeof createLineAdapter
69
72
  createSlackAdapter?: typeof createSlackBotAdapter
70
73
  createTelegramAdapter?: typeof createTelegramBotAdapter
71
74
  // Wake-up gate: forwarded to the router, which calls
@@ -111,7 +114,13 @@ export type ChannelManager = {
111
114
  reload: () => Promise<{ started: string[]; stopped: string[]; restartRequired: string[] }>
112
115
  }
113
116
 
114
- type AnyAdapter = DiscordBotAdapter | GithubAdapter | KakaotalkAdapter | SlackBotAdapter | TelegramBotAdapter
117
+ type AnyAdapter =
118
+ | DiscordBotAdapter
119
+ | GithubAdapter
120
+ | LineAdapter
121
+ | KakaotalkAdapter
122
+ | SlackBotAdapter
123
+ | TelegramBotAdapter
115
124
 
116
125
  // Credential signature is the comparison key for credential-rotation
117
126
  // detection on reload. Discord and Telegram each use a single bot token;
@@ -143,6 +152,7 @@ export function createChannelManager(options: ChannelManagerOptions): ChannelMan
143
152
  const createDiscordAdapter = options.createDiscordAdapter ?? createDiscordBotAdapter
144
153
  const createGithub = options.createGithubAdapter ?? createGithubAdapter
145
154
  const createKakaotalk = options.createKakaotalkAdapter ?? createKakaotalkAdapter
155
+ const createLine = options.createLineAdapter ?? createLineAdapter
146
156
  const createSlackAdapter = options.createSlackAdapter ?? createSlackBotAdapter
147
157
  const createTelegramAdapter = options.createTelegramAdapter ?? createTelegramBotAdapter
148
158
 
@@ -160,6 +170,7 @@ export function createChannelManager(options: ChannelManagerOptions): ChannelMan
160
170
  }
161
171
 
162
172
  const buildCredentialSignature = (name: AdapterId): { signature: string; missing: string[] } => {
173
+ if (name === 'line') return buildLineSignature(options.agentDir)
163
174
  if (name === 'kakaotalk') return buildKakaotalkSignature(options.agentDir)
164
175
  if (name === 'github') return buildGithubSignature(options.agentDir)
165
176
  const requiredEnvs = TOKEN_ENV[name]
@@ -198,6 +209,15 @@ export function createChannelManager(options: ChannelManagerOptions): ChannelMan
198
209
  selfAliasesRef: () => router.getSelfAliases(),
199
210
  })
200
211
  }
212
+ if (name === 'line') {
213
+ return createLine({
214
+ router,
215
+ configRef: () => options.channelsConfigRef()[name] ?? cfg,
216
+ logger,
217
+ selfAliasesRef: () => router.getSelfAliases(),
218
+ credentialsStore: createContainerLineCredentialStore(options.agentDir, env),
219
+ })
220
+ }
201
221
  if (name === 'kakaotalk') {
202
222
  return createKakaotalk({
203
223
  router,
@@ -335,7 +355,7 @@ export function createChannelManager(options: ChannelManagerOptions): ChannelMan
335
355
  await runSerially(name, () => stopAdapter(name))
336
356
  stopped.push(name)
337
357
  } else if (signature !== current.credentialSignature) {
338
- const reason = name === 'kakaotalk' ? 'credential rotation' : 'token rotation'
358
+ const reason = name === 'kakaotalk' || name === 'line' ? 'credential rotation' : 'token rotation'
339
359
  restartRequired.push(`${name} (${reason})`)
340
360
  }
341
361
  }
@@ -346,10 +366,10 @@ export function createChannelManager(options: ChannelManagerOptions): ChannelMan
346
366
  }
347
367
  }
348
368
 
349
- // Token-based adapters only. KakaoTalk's credentials live in
350
- // secrets.json#channels.kakaotalk, not in env, so it goes through
351
- // buildKakaotalkSignature instead.
352
- const TOKEN_ENV: Record<Exclude<AdapterId, 'kakaotalk' | 'github'>, readonly string[]> = {
369
+ // Token-based adapters only. KakaoTalk's and LINE's credentials live in
370
+ // secrets.json#channels.<adapter>, not in env, so they go through
371
+ // buildKakaotalkSignature / buildLineSignature instead.
372
+ const TOKEN_ENV: Record<Exclude<AdapterId, 'kakaotalk' | 'line' | 'github'>, readonly string[]> = {
353
373
  'discord-bot': ['DISCORD_BOT_TOKEN'],
354
374
  'slack-bot': ['SLACK_BOT_TOKEN', 'SLACK_APP_TOKEN'],
355
375
  'telegram-bot': ['TELEGRAM_BOT_TOKEN'],
@@ -387,6 +407,36 @@ function buildKakaotalkSignature(agentDir: string): { signature: string; missing
387
407
  }
388
408
  }
389
409
 
410
+ function createContainerLineCredentialStore(agentDir: string, env: NodeJS.ProcessEnv): SecretsLineCredentialStore {
411
+ const hostdUrl = env.TYPECLAW_HOSTD_URL
412
+ const restartToken = env.TYPECLAW_HOSTD_TOKEN
413
+ const containerName = env.TYPECLAW_CONTAINER_NAME
414
+ if (!hostdUrl || !restartToken || !containerName) {
415
+ throw new Error('LINE credentials require TYPECLAW_HOSTD_URL, TYPECLAW_HOSTD_TOKEN, and TYPECLAW_CONTAINER_NAME')
416
+ }
417
+ return new SecretsLineCredentialStore({
418
+ mode: 'container',
419
+ secretsPath: join(agentDir, 'secrets.json'),
420
+ hostdUrl,
421
+ restartToken,
422
+ containerName,
423
+ })
424
+ }
425
+
426
+ function buildLineSignature(agentDir: string): { signature: string; missing: string[] } {
427
+ const path = join(agentDir, 'secrets.json')
428
+ try {
429
+ const block = new SecretsBackend(path).tryReadChannelsSync()?.line
430
+ if (!isLineCredentialBlock(block)) {
431
+ return { signature: '', missing: ['secrets.json#channels.line'] }
432
+ }
433
+ const digest = createHash('sha256').update(JSON.stringify(block)).digest('hex')
434
+ return { signature: `secrets.json#channels.line@sha256:${digest}`, missing: [] }
435
+ } catch (err) {
436
+ return { signature: '', missing: [`secrets.json#channels.line (${describe(err)})`] }
437
+ }
438
+ }
439
+
390
440
  function buildGithubSignature(agentDir: string): { signature: string; missing: string[] } {
391
441
  const block = readGithubSecrets(agentDir)
392
442
  if (block === null) return { signature: '', missing: ['secrets.json#channels.github'] }
@@ -422,6 +472,15 @@ function isKakaoCredentialBlock(value: unknown): value is { accounts: Record<str
422
472
  )
423
473
  }
424
474
 
475
+ function isLineCredentialBlock(value: unknown): value is { accounts: Record<string, unknown> } {
476
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
477
+ if (!('accounts' in value)) return false
478
+ const accounts = value.accounts
479
+ return (
480
+ typeof accounts === 'object' && accounts !== null && !Array.isArray(accounts) && Object.keys(accounts).length > 0
481
+ )
482
+ }
483
+
425
484
  function describe(err: unknown): string {
426
485
  return err instanceof Error ? err.message : String(err)
427
486
  }
@@ -595,6 +595,16 @@ type LiveSession = {
595
595
  // send was attempted. Compared by `turnSeq` so a stale value can't leak across
596
596
  // turns.
597
597
  skipLockedSendTurn: number | null
598
+ // Stamped with the current `turnSeq` by `clearSticky` (called from the
599
+ // `channel_disengage` tool). Read in `send()`'s post-delivery grant block: a
600
+ // successful outbound normally re-grants sticky credit to the turn's authors,
601
+ // which would silently re-arm the engagement the model just dropped if it
602
+ // acks ("ok, backing off") via `channel_reply` in the same turn. When this
603
+ // matches the live `turnSeq`, the grant is suppressed so disengage stays
604
+ // binding for the rest of the turn — the same "commit to it within the turn"
605
+ // shape as the skip lock. `null` when no disengage has been recorded.
606
+ // Compared by `turnSeq` so a stale value can't leak across turns.
607
+ disengagedTurn: number | null
598
608
  // Captured by drain() at batch dequeue; read+cleared by send() on the
599
609
  // first tool-source send of the turn. The anchor decision (delay
600
610
  // threshold + intervening-observed check) is evaluated at SEND time
@@ -618,6 +628,16 @@ type LiveSession = {
618
628
  // "is this a multi-human group". Read by composeTurnPrompt().
619
629
  multiHumanGroup: boolean
620
630
  membershipFetch: Promise<MembershipCount | null> | null
631
+ // Provider soft-error (`stopReason: 'error'`) captured during the current
632
+ // turn, deferred to turn-end. Upstream surfaces transient errors (e.g.
633
+ // `server_is_overloaded`) MID-turn and the turn often recovers, replying
634
+ // seconds later — posting the "⚠️ provider failed" notice on the spot strands
635
+ // a false failure above the eventual real reply. So the listener only RECORDS
636
+ // here (stamped with the firing turnSeq; raw text logged for operators); the
637
+ // turn-end finally posts `safeMessage` ONLY if no reply landed, else discards.
638
+ // First error per turn wins. Keyed by `turnSeq` so a stale record from a
639
+ // crashed prior turn can't leak into the next.
640
+ pendingProviderError: { turnSeq: number; safeMessage: string } | null
621
641
  destroyed: boolean
622
642
  unsubProviderErrors: (() => void) | null
623
643
  unsubTypingActivity: (() => void) | null
@@ -807,6 +827,14 @@ export type ChannelRouter = {
807
827
  | { kind: 'recorded'; keyId: string }
808
828
  | { kind: 'recorded-after-send'; keyId: string }
809
829
  | { kind: 'no-live-session' }
830
+ // Force-clear every sticky credit for one channel key. Stickiness normally
831
+ // expires on TTL or is consumed on the next inbound, but in a busy group each
832
+ // reply re-grants a fresh credit, so the bot can stay force-engaged turn after
833
+ // turn even after being told to stop. This is the escape hatch the
834
+ // `channel_disengage` tool calls to drop back to strict mention/reply/dm
835
+ // engagement without waiting out the window. In-memory only,
836
+ // so a later reply re-grants. `cleared` counts the author credits dropped.
837
+ clearSticky: (key: ChannelKey) => { keyId: string; cleared: number }
810
838
  // Two-phase boot restart-resume. Call `reserveRestartHandoff(handoff)` BEFORE
811
839
  // `channelManager.start()` to install a per-key gate so an inbound that races
812
840
  // the adapters coming online coalesces onto the resume instead of competing
@@ -1466,55 +1494,31 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1466
1494
  nextPromptMaxTokens: undefined,
1467
1495
  skippedTurn: null,
1468
1496
  skipLockedSendTurn: null,
1497
+ disengagedTurn: null,
1469
1498
  pendingQuoteCandidate: null,
1470
1499
  recentEngagedPeerBotTurns: [],
1471
1500
  consecutiveEngagedPeerBotTurns: 0,
1472
1501
  loopGuardActive: false,
1473
1502
  multiHumanGroup: false,
1474
1503
  membershipFetch,
1504
+ pendingProviderError: null,
1475
1505
  destroyed: false,
1476
1506
  unsubProviderErrors: null,
1477
1507
  unsubTypingActivity: null,
1478
1508
  unsubTodoOutcome: null,
1479
1509
  }
1480
- // Tracks the `turnSeq` a provider-error notice was last POSTED for, so the
1481
- // channel surfaces at most one notice per turn. The upstream SDK retries
1482
- // internally, and each retry emits its own `message_end` with
1483
- // `stopReason: 'error'` without this gate a single failing turn posts N
1484
- // identical "⚠️ upstream provider failed" notices (one per retry). Logs
1485
- // still record every attempt; only the user-facing notice is deduped.
1486
- let lastProviderErrorNoticeTurn: number | undefined
1510
+ // Raw text is logged on EVERY attempt for operators; the user-facing
1511
+ // notice is deferred. The upstream SDK retries internally, and each retry
1512
+ // emits its own `message_end` with `stopReason: 'error'` — but the turn
1513
+ // frequently recovers and replies anyway. Recording the first error per
1514
+ // turn (keyed by `turnSeq`) lets drain()'s turn-end decide: post the
1515
+ // notice only if no reply landed, suppress it if the turn recovered. This
1516
+ // both dedupes the retry storm AND prevents a stranded false-failure
1517
+ // notice above a successful reply. See `pendingProviderError`.
1487
1518
  live.unsubProviderErrors = subscribeProviderErrors(created.session, (err) => {
1488
1519
  logger.error(`[channels] ${live.keyId}: LLM call failed: ${err.message}`)
1489
- // Suppress duplicate notices for the SAME turn (retry storm). Set the
1490
- // marker BEFORE the async send so a synchronous burst of retry events
1491
- // can't each slip past the check and enqueue their own notice.
1492
- if (lastProviderErrorNoticeTurn === live.turnSeq) return
1493
- lastProviderErrorNoticeTurn = live.turnSeq
1494
- // A provider soft-error (rate/usage limit, billing, malformed response)
1495
- // ends the turn with no assistant text, so the human otherwise sees
1496
- // silence. Surface the REDACTED `safeMessage` (never the raw provider
1497
- // text, which can carry response bodies / URLs / tokens) via a 'system'
1498
- // send — the same one-shot bypass path validateChannelTurn uses, so it
1499
- // lands regardless of per-turn send caps and skips the duplicate guard.
1500
- void send(
1501
- {
1502
- adapter: live.key.adapter,
1503
- workspace: live.key.workspace,
1504
- chat: live.key.chat,
1505
- thread: live.key.thread,
1506
- text: `⚠️ ${err.safeMessage}`,
1507
- },
1508
- { source: 'system' },
1509
- )
1510
- .then((result) => {
1511
- if (!result.ok) {
1512
- logger.warn(`[channels] ${live.keyId}: provider-error notice send failed: ${result.error}`)
1513
- }
1514
- })
1515
- .catch((sendErr) => {
1516
- logger.warn(`[channels] ${live.keyId}: provider-error notice send threw: ${describe(sendErr)}`)
1517
- })
1520
+ if (live.pendingProviderError?.turnSeq === live.turnSeq) return
1521
+ live.pendingProviderError = { turnSeq: live.turnSeq, safeMessage: err.safeMessage }
1518
1522
  })
1519
1523
  live.unsubTodoOutcome = created.session.subscribe((event: unknown) => {
1520
1524
  const usage = extractTurnUsage(event)
@@ -1931,6 +1935,56 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1931
1935
  }
1932
1936
  }
1933
1937
 
1938
+ const maybePostDeferredProviderError = async (
1939
+ live: LiveSession,
1940
+ sentReplyThisTurn: boolean,
1941
+ retryQueued: boolean,
1942
+ ): Promise<void> => {
1943
+ const pending = live.pendingProviderError
1944
+ if (pending === null || pending.turnSeq !== live.turnSeq) {
1945
+ live.pendingProviderError = null
1946
+ return
1947
+ }
1948
+ // The turn recovered and replied — the provider blip was transient, so a
1949
+ // failure notice would be a false alarm stranded above the real reply.
1950
+ if (sentReplyThisTurn) {
1951
+ live.pendingProviderError = null
1952
+ return
1953
+ }
1954
+ // An empty-turn retry was queued (validateChannelTurn pushed
1955
+ // EMPTY_TURN_RETRY_NUDGE): the same logical turn re-prompts in the next
1956
+ // drain iteration and may yet recover. Carry the pending error forward,
1957
+ // re-stamping to that iteration's turnSeq (drain does `turnSeq++` once per
1958
+ // iteration, so it will be exactly `turnSeq + 1`). Posting now would strand
1959
+ // a false-failure notice above the retry's eventual reply — the same defect
1960
+ // one logical turn later.
1961
+ if (retryQueued) {
1962
+ live.pendingProviderError = { turnSeq: live.turnSeq + 1, safeMessage: pending.safeMessage }
1963
+ return
1964
+ }
1965
+ live.pendingProviderError = null
1966
+ // Genuinely empty turn with no retry left: surface the REDACTED
1967
+ // `safeMessage` (never raw provider text, which can carry response bodies /
1968
+ // URLs / tokens) via a 'system' send — the one-shot bypass path that lands
1969
+ // regardless of per-turn send caps — so the human doesn't just see silence.
1970
+ const result = await send(
1971
+ {
1972
+ adapter: live.key.adapter,
1973
+ workspace: live.key.workspace,
1974
+ chat: live.key.chat,
1975
+ thread: live.key.thread,
1976
+ text: `⚠️ ${pending.safeMessage}`,
1977
+ },
1978
+ { source: 'system' },
1979
+ ).catch((sendErr) => {
1980
+ logger.warn(`[channels] ${live.keyId}: provider-error notice send threw: ${describe(sendErr)}`)
1981
+ return null
1982
+ })
1983
+ if (result !== null && !result.ok) {
1984
+ logger.warn(`[channels] ${live.keyId}: provider-error notice send failed: ${result.error}`)
1985
+ }
1986
+ }
1987
+
1934
1988
  const drain = async (live: LiveSession): Promise<void> => {
1935
1989
  if (live.draining || live.destroyed) return
1936
1990
  live.draining = true
@@ -1948,6 +2002,16 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1948
2002
  live.currentTurnAttachments = collectTurnAttachments(observed, batch)
1949
2003
 
1950
2004
  if (batch.length > 0) {
2005
+ // A fresh user batch starts a NEW logical turn. Drop any provider
2006
+ // error carried forward from a prior turn's empty-turn retry: the
2007
+ // drain loop splices promptQueue AND pendingSystemReminders together,
2008
+ // so a user message arriving while a retry nudge is pending coalesces
2009
+ // into this iteration. Without this clear, the carried error (stamped
2010
+ // `turnSeq + 1`) would match this fresh turn and post the prior turn's
2011
+ // notice misattributed to an unrelated new message. Carry-forward stays
2012
+ // intact for genuinely reminder-only iterations (batch.length === 0),
2013
+ // which skip this branch.
2014
+ live.pendingProviderError = null
1951
2015
  live.currentTurnAuthorId = batch[batch.length - 1]!.authorId
1952
2016
  live.currentTurnAuthorIds = new Set(batch.map((m) => m.authorId))
1953
2017
  live.currentTurnReactionRef = batch[batch.length - 1]!.reactionRef ?? null
@@ -2010,10 +2074,12 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2010
2074
  logger.info(`[channels] ${live.keyId} prompting batch=${batch.length} text_len=${text.length}`)
2011
2075
  const promptStart = now()
2012
2076
  const successfulSendsBeforePrompt = live.successfulChannelSends
2077
+ const emptyTurnRetriesBeforePrompt = live.emptyTurnRetries
2013
2078
  const engageAddPromises = live.currentTurnEngageReactions
2014
2079
  live.turnSeq++
2015
2080
  live.successfulSendsAtTurnStart = successfulSendsBeforePrompt
2016
2081
  live.skipLockedSendTurn = null
2082
+ live.disengagedTurn = null
2017
2083
  live.policyDeniedToolSendsThisTurn.clear()
2018
2084
  resetReviewTurn(live.sessionId)
2019
2085
  const isRealUserTurn = batch.length > 0
@@ -2030,6 +2096,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2030
2096
  } finally {
2031
2097
  const sentReplyThisTurn = live.successfulChannelSends > successfulSendsBeforePrompt
2032
2098
  if (sentReplyThisTurn) dropEngageReactionsAfterReply(live, engageAddPromises)
2099
+ const emptyTurnRetryQueued = live.emptyTurnRetries > emptyTurnRetriesBeforePrompt
2100
+ await maybePostDeferredProviderError(live, sentReplyThisTurn, emptyTurnRetryQueued)
2033
2101
  await fireSessionTurnEnd(live)
2034
2102
  }
2035
2103
  await fireSessionIdle(live)
@@ -2389,8 +2457,16 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2389
2457
 
2390
2458
  const hasBotParticipated = (live: LiveSession): boolean => {
2391
2459
  if (live.successfulChannelSends > 0) return true
2460
+ // Only OUR own prefetched history counts as participation — matching any
2461
+ // bot here let a PEER bot's buffered message flip botInThread=true,
2462
+ // neutralizing the replyToOtherMessageId suppressor and engaging us in a
2463
+ // thread aimed at another bot. Self id is unavailable during the startup
2464
+ // identity race; then this falls back to successfulChannelSends, which is
2465
+ // safe (conservative: we just don't claim participation we can't prove).
2466
+ const selfId = resolveSelfIdentity(live.key)?.id
2467
+ if (selfId === undefined) return false
2392
2468
  for (const item of live.contextBuffer) {
2393
- if (item.authorIsBot) return true
2469
+ if (item.authorId === selfId) return true
2394
2470
  }
2395
2471
  return false
2396
2472
  }
@@ -2847,7 +2923,16 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2847
2923
  return { ok: false, error: `no adapter registered for "${msg.adapter}"`, code: 'no-adapter' }
2848
2924
  }
2849
2925
 
2850
- const authoredText = normalizeSendText(msg.text)
2926
+ // Strip leaked `<think>` reasoning off the message itself, up front, so the
2927
+ // stripped text flows through EVERY downstream consumer: the flood check,
2928
+ // the duplicate guard, the quote-anchor prepend, and the adapter callback.
2929
+ // A body that was nothing but a think block collapses to undefined and is
2930
+ // delivered as a text-less send (attachments, if any, still go through).
2931
+ if (msg.text !== undefined) {
2932
+ msg = { ...msg, text: normalizeSendText(msg.text) }
2933
+ }
2934
+
2935
+ const authoredText = msg.text
2851
2936
  if (authoredText !== undefined) {
2852
2937
  const flood = checkOutboundFlood(authoredText)
2853
2938
  if (!flood.ok) return { ok: false, error: OUTBOUND_FLOOD_ERROR, code: 'outbound-flood' }
@@ -3023,8 +3108,13 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
3023
3108
  // land after it. Discord and Telegram treat the extra tick as a
3024
3109
  // no-op refresh of their already-armed (auto-expiring) indicators.
3025
3110
  if (live.typingTimer) void fireTyping(live, 'tick')
3111
+ // Disengage is binding for the rest of the turn: if the model dropped
3112
+ // sticky via `channel_disengage` this turn, a same-turn ack reply must NOT
3113
+ // silently re-grant the credit it just cleared. Skipped only for the live
3114
+ // turn (matched by `turnSeq`); the next turn re-grants normally.
3115
+ const disengagedThisTurn = live.disengagedTurn !== null && live.disengagedTurn === live.turnSeq
3026
3116
  const adapterConfig = options.configForAdapter(msg.adapter)
3027
- if (adapterConfig) {
3117
+ if (adapterConfig && !disengagedThisTurn) {
3028
3118
  const targetIds = Array.from(
3029
3119
  live.currentTurnAuthorIds.size > 0 ? live.currentTurnAuthorIds : live.lastTurnAuthorIds,
3030
3120
  )
@@ -3632,6 +3722,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
3632
3722
  ok: boolean
3633
3723
  durationMs: number
3634
3724
  error?: string
3725
+ hasRecoverableOutput?: boolean
3635
3726
  },
3636
3727
  ): { kind: 'delivered'; keyId: string } => {
3637
3728
  const adapter = live.keyId.split(':', 1)[0] ?? ''
@@ -3641,6 +3732,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
3641
3732
  ok: args.ok,
3642
3733
  durationMs: args.durationMs,
3643
3734
  ...(args.error !== undefined ? { error: args.error } : {}),
3735
+ ...(args.hasRecoverableOutput === true ? { hasRecoverableOutput: true } : {}),
3644
3736
  channel: true,
3645
3737
  adapter,
3646
3738
  })
@@ -3673,6 +3765,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
3673
3765
  ok: boolean
3674
3766
  durationMs: number
3675
3767
  error?: string
3768
+ hasRecoverableOutput?: boolean
3676
3769
  channelKey?: { adapter: string; workspace: string; chat: string; thread: string | null }
3677
3770
  }): { kind: 'delivered'; keyId: string } | { kind: 'no-live-session' } => {
3678
3771
  for (const live of liveSessions.values()) {
@@ -3727,6 +3820,18 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
3727
3820
  return { kind: 'no-live-session' }
3728
3821
  }
3729
3822
 
3823
+ const clearSticky = (key: ChannelKey): { keyId: string; cleared: number } => {
3824
+ const keyId = channelKeyId(key)
3825
+ const cleared = stickyLedger.clear(keyId)
3826
+ // Arm the same-turn re-grant guard so a subsequent ack reply this turn does
3827
+ // not re-grant the credit just cleared (see `disengagedTurn`). No-op when
3828
+ // the key has no live session — the ledger clear above still stands.
3829
+ const live = liveSessions.get(keyId)
3830
+ if (live && !live.destroyed) live.disengagedTurn = live.turnSeq
3831
+ logger.info(`[channels] ${keyId} sticky cleared count=${cleared}`)
3832
+ return { keyId, cleared }
3833
+ }
3834
+
3730
3835
  return {
3731
3836
  route,
3732
3837
  send,
@@ -3768,6 +3873,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
3768
3873
  getSelfAliases: computeSelfAliases,
3769
3874
  injectSubagentCompletionReminder,
3770
3875
  markTurnSkipped,
3876
+ clearSticky,
3771
3877
  reserveRestartHandoff,
3772
3878
  resumeRestartHandoff,
3773
3879
  stop,
@@ -3987,6 +4093,22 @@ function composeTurnPrompt(
3987
4093
  // the `## Recent context (not addressed to you …)` header — mislabeling the
3988
4094
  // one line the model is supposed to answer as context it should ignore.
3989
4095
  if (batch.length > 0) {
4096
+ // The `## Current message` header is a WITHIN-turn label, but it also gets
4097
+ // persisted into the transcript — so after many turns the model sees a
4098
+ // chain of turns each headed "addressed to you" and a weak model collapses
4099
+ // that into "only the latest turn exists", denying it can see what the user
4100
+ // said earlier (those turns are in its own message history). This note
4101
+ // re-anchors the header as turn-local. Conditional "if earlier turns
4102
+ // appear" wording so it is not a false premise on turn 1 (a fresh session
4103
+ // has no history). No leading `>` — that is this repo's quote-anchor syntax
4104
+ // and would read as quoted content. Worded to NOT contain the literal
4105
+ // `## Current message` heading — a pinned test asserts its absence on
4106
+ // reminder-only drains, so it must stay batch-gated and substring-free.
4107
+ parts.push(
4108
+ 'Note: if earlier turns appear above, they are real conversation history you can use.',
4109
+ "The heading below marks this turn's new message, not the only message that may exist.",
4110
+ '',
4111
+ )
3990
4112
  parts.push(batch.length === 1 ? '## Current message (addressed to you)' : '## Current messages (addressed to you)')
3991
4113
  for (const b of batch) {
3992
4114
  parts.push(formatInboundPromptLines(b, adapter))
@@ -4050,6 +4172,7 @@ function formatAuthorReference(adapter: AdapterId, authorId: string, authorName:
4050
4172
  case 'github':
4051
4173
  return displayName.startsWith('@') ? displayName : `@${displayName}`
4052
4174
  case 'telegram-bot':
4175
+ case 'line':
4053
4176
  case 'kakaotalk':
4054
4177
  return displayName
4055
4178
  }
@@ -4316,10 +4439,32 @@ export function resolveLiveSessionForCommand(
4316
4439
  return { kind: 'none' }
4317
4440
  }
4318
4441
 
4442
+ // Strips leaked `<think>…</think>` reasoning from outbound message text. Some
4443
+ // models (DeepSeek-R1 / Qwen-QwQ family) emit chain-of-thought inline as a
4444
+ // literal `<think>` span in `delta.content` rather than a dedicated `thinking`
4445
+ // content block, so it lands verbatim in the assistant body and — without this
4446
+ // — gets posted to the channel (production: a reasoning paragraph leaked into a
4447
+ // Slack thread). The whole block is removed, not just the tags.
4448
+ //
4449
+ // THINK_BLOCK_RE matches closed blocks (case-insensitive, attribute-tolerant,
4450
+ // multi-line). DANGLING_THINK_RE catches an UNCLOSED trailing `<think>` (model
4451
+ // ran out of budget mid-reasoning) by dropping open-tag-to-end. The final pass
4452
+ // collapses excision-left blank-line runs and trims.
4453
+ const THINK_BLOCK_RE = /<think\b[^>]*>[\s\S]*?<\/think\s*>/gi
4454
+ const DANGLING_THINK_RE = /<think\b[^>]*>[\s\S]*$/i
4455
+
4456
+ export function stripThinkBlocks(text: string): string {
4457
+ const withoutBlocks = text.replace(THINK_BLOCK_RE, '').replace(DANGLING_THINK_RE, '')
4458
+ return withoutBlocks.replace(/\n{3,}/g, '\n\n').trim()
4459
+ }
4460
+
4319
4461
  function normalizeSendText(text: string | undefined): string | undefined {
4320
4462
  if (text === undefined) return undefined
4321
- if (text === '') return undefined
4322
- return text
4463
+ // Strip before the empty-collapse so a turn that was ONLY a think block
4464
+ // resolves to `undefined` (suppressed) instead of posting an empty shell.
4465
+ const stripped = stripThinkBlocks(text)
4466
+ if (stripped === '') return undefined
4467
+ return stripped
4323
4468
  }
4324
4469
 
4325
4470
  function recordSendTimestamp(live: LiveSession, sendKey: string, ts: number): number {
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod'
2
2
 
3
- export const ADAPTER_IDS = ['discord-bot', 'github', 'kakaotalk', 'slack-bot', 'telegram-bot'] as const
3
+ export const ADAPTER_IDS = ['discord-bot', 'github', 'line', 'kakaotalk', 'slack-bot', 'telegram-bot'] as const
4
4
 
5
5
  export type AdapterId = (typeof ADAPTER_IDS)[number]
6
6
 
@@ -254,6 +254,11 @@ export const channelsSchema = z
254
254
  .object({
255
255
  'discord-bot': adapterSchema.optional(),
256
256
  github: githubChannelSchema.optional(),
257
+ // LINE is a personal-account channel like KakaoTalk: plain-text only,
258
+ // alias-only engagement (no native @-mention), credentials in
259
+ // secrets.json#channels.line (not env). Unlike KakaoTalk it has no
260
+ // token-renewal cron — it persists a long-lived auth token + certificate.
261
+ line: adapterSchema.optional(),
257
262
  kakaotalk: adapterSchema.optional(),
258
263
  'slack-bot': adapterSchema.optional(),
259
264
  'telegram-bot': adapterSchema.optional(),