typeclaw 0.23.0 → 0.24.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 (46) hide show
  1. package/package.json +1 -1
  2. package/src/agent/index.ts +91 -22
  3. package/src/agent/plugin-tools.ts +38 -2
  4. package/src/agent/restart/index.ts +15 -3
  5. package/src/agent/restart-handoff/index.ts +110 -12
  6. package/src/agent/subagent-completion-reminder.ts +3 -1
  7. package/src/agent/subagents.ts +44 -1
  8. package/src/agent/system-prompt.ts +4 -0
  9. package/src/agent/todo/continuation-policy.ts +242 -0
  10. package/src/agent/todo/continuation-state.ts +87 -0
  11. package/src/agent/todo/continuation-wiring.ts +113 -0
  12. package/src/agent/todo/continuation.ts +71 -0
  13. package/src/agent/todo/scope.ts +77 -0
  14. package/src/agent/todo/store.ts +98 -0
  15. package/src/agent/tool-not-found-nudge.ts +119 -0
  16. package/src/agent/tools/channel-reply.ts +51 -0
  17. package/src/agent/tools/restart.ts +11 -4
  18. package/src/agent/tools/todo/index.ts +119 -0
  19. package/src/bundled-plugins/backup/runner.ts +1 -1
  20. package/src/bundled-plugins/reviewer/reviewer.ts +14 -0
  21. package/src/channels/adapters/discord-bot-reference.ts +78 -0
  22. package/src/channels/adapters/discord-bot.ts +25 -3
  23. package/src/channels/adapters/github/inbound.ts +161 -10
  24. package/src/channels/adapters/github/index.ts +10 -0
  25. package/src/channels/adapters/github/review-thread-resolver.ts +246 -0
  26. package/src/channels/adapters/kakaotalk-classify.ts +67 -6
  27. package/src/channels/adapters/slack-bot-classify.ts +9 -1
  28. package/src/channels/adapters/slack-bot-reference.ts +129 -0
  29. package/src/channels/adapters/slack-bot.ts +67 -8
  30. package/src/channels/manager.ts +8 -2
  31. package/src/channels/router.ts +445 -22
  32. package/src/channels/schema.ts +20 -4
  33. package/src/channels/types.ts +68 -0
  34. package/src/cli/inspect-controller.ts +7 -0
  35. package/src/cli/inspect.ts +2 -1
  36. package/src/commands/index.ts +9 -0
  37. package/src/init/gitignore.ts +5 -2
  38. package/src/inspect/index.ts +22 -0
  39. package/src/run/index.ts +60 -5
  40. package/src/sandbox/build.ts +10 -0
  41. package/src/sandbox/index.ts +2 -0
  42. package/src/sandbox/policy.ts +10 -0
  43. package/src/sandbox/writable-zones.ts +78 -0
  44. package/src/server/index.ts +118 -4
  45. package/src/skills/typeclaw-channel-github/SKILL.md +34 -7
  46. package/typeclaw.schema.json +10 -0
@@ -5,8 +5,16 @@ import { SessionManager } from '@mariozechner/pi-coding-agent'
5
5
 
6
6
  import { createSession, renderTurnRoleAnchor, renderTurnTimeAnchor, type AgentSession } from '@/agent'
7
7
  import { subscribeProviderErrors } from '@/agent/provider-error'
8
+ import type { RestartHandoff } from '@/agent/restart-handoff'
8
9
  import type { ChannelParticipant, SessionOrigin } from '@/agent/session-origin'
9
10
  import { renderSubagentCompletionReminder } from '@/agent/subagent-completion-reminder'
11
+ import {
12
+ armRestartKickForOrigin,
13
+ extractTurnUsage,
14
+ recordTurnOutcome,
15
+ recordTurnStart,
16
+ runIdleContinuation,
17
+ } from '@/agent/todo/continuation-wiring'
10
18
  import { type Command, type CommandPermission, type CommandResult, createCommandRegistry } from '@/commands'
11
19
  import { CORE_PERMISSIONS, type PermissionService } from '@/permissions'
12
20
  import type { HookBus } from '@/plugin'
@@ -53,6 +61,7 @@ import type {
53
61
  HistoryCallback,
54
62
  InboundAttachment,
55
63
  InboundMessage,
64
+ InboundReferenceContext,
56
65
  RemoveReactionCallback,
57
66
  RemoveReactionRequest,
58
67
  OutboundCallback,
@@ -63,6 +72,9 @@ import type {
63
72
  ReactionRequest,
64
73
  ReactionResult,
65
74
  ResolvedChannelNames,
75
+ ReviewThreadResolveRequest,
76
+ ReviewThreadResolveResult,
77
+ ReviewThreadResolver,
66
78
  SendErrorCode,
67
79
  SendResult,
68
80
  TypingCallback,
@@ -117,6 +129,23 @@ export const SESSION_GC_INTERVAL_MS = 60 * 1000
117
129
  // recovery paths (`source: 'system'`) bypass.
118
130
  export const MAX_CHANNEL_SENDS_PER_TURN = 10
119
131
  export const ENGAGE_REACTION_EMOJI = 'eyes'
132
+
133
+ // Wake nudge pushed into a resumed channel session at boot so drain() has a
134
+ // non-empty batch and fires a turn. The substantive instruction the model acts
135
+ // on is the `typeclaw.restart-self` entry already in the reopened JSONL (pi
136
+ // hydrates it as a user message); this nudge only triggers the turn. Uses the
137
+ // repo's SYSTEM MESSAGE framing (see composeTurnPrompt) so persona-rich models
138
+ // do not reply to it as if a human wrote it.
139
+ export const RESTART_RESUME_WAKE_REMINDER = [
140
+ '---',
141
+ '**[SYSTEM MESSAGE — not from a human]**',
142
+ '',
143
+ 'The container just restarted and this session was resumed. Act on the',
144
+ 'restart instructions already in your context. Do not acknowledge or reply to',
145
+ 'this notice itself.',
146
+ '',
147
+ '---',
148
+ ].join('\n')
120
149
  // Ceiling on tool-source channel sends that a same-turn router policy DENIED
121
150
  // without delivering — `skip-locked`, `turn-cap`, or `duplicate`. Such denials
122
151
  // return a soft error and do NOT increment `consecutiveSends`, so a model that
@@ -280,6 +309,7 @@ export type ConfigForAdapter = (adapter: ChannelKey['adapter']) => ChannelAdapte
280
309
 
281
310
  type QueuedInbound = {
282
311
  text: string
312
+ referenceContext?: InboundReferenceContext
283
313
  attachments?: readonly InboundAttachment[]
284
314
  authorId: string
285
315
  authorName: string
@@ -290,6 +320,7 @@ type QueuedInbound = {
290
320
  isBotMention: boolean
291
321
  replyToBotMessageId: string | null
292
322
  isDm: boolean
323
+ typingThread?: string
293
324
  receivedAt: number
294
325
  // Original platform timestamp (Slack/Discord), in ms since epoch. Used
295
326
  // by composeTurnPrompt to render an ISO 8601 prefix on each line so the
@@ -301,6 +332,7 @@ type QueuedInbound = {
301
332
 
302
333
  type ObservedInbound = {
303
334
  text: string
335
+ referenceContext?: InboundReferenceContext
304
336
  attachments?: readonly InboundAttachment[]
305
337
  authorId: string
306
338
  authorName: string
@@ -358,6 +390,11 @@ type LiveSession = {
358
390
  // origin so `channel_react` reacts to the triggering message, not whichever
359
391
  // inbound happens to be latest in the queue. Null on reminder-only turns.
360
392
  currentTurnReactionRef: ReactionRef | null
393
+ // Typing-status anchor of the inbound that triggered THIS turn (last item in
394
+ // the drained batch, mirroring `currentTurnReactionRef`). Adapter-opaque ts
395
+ // carried only to the typing path; null when the triggering inbound supplied
396
+ // none (every non-DM inbound, and reminder-only turns).
397
+ currentTurnTypingThread: string | null
361
398
  // One engage-:eyes:-add promise per inbound coalesced into THIS turn, each
362
399
  // resolving to its removable per-instance ref (or null). A debounced turn can
363
400
  // batch several inbounds that each got their own :eyes:, so every entry is
@@ -445,9 +482,25 @@ type LiveSession = {
445
482
  // with the current `turnSeq`. Read at the top of `validateChannelTurn`:
446
483
  // if it matches the just-completed turn, recovery is skipped entirely
447
484
  // (no NO_REPLY check, no Kimi leak check, no assistant-text recovery).
448
- // The model has explicitly opted out of this turn and we honor that
449
- // unconditionally. `null` when no skip has been recorded.
485
+ // The model has explicitly opted out of this turn and we honor that
486
+ // UNLESS the model also tried a tool-source send this turn (see
487
+ // `skipLockedSendTurn`), in which case the skip was contested and we let
488
+ // recovery run so the contested reply isn't silently dropped. `null` when
489
+ // no skip has been recorded.
450
490
  skippedTurn: { turnSeq: number; reason: string } | null
491
+ // Stamped by `send()` with the current `turnSeq` when a tool-source send is
492
+ // DENIED by the skip lock (the model called `skip_response` first, then
493
+ // changed its mind and tried `channel_reply`). The send still stays denied —
494
+ // "commit to silence" is binding for the live send path — but a contested
495
+ // skip must NOT also suppress the post-turn recovery net: the model produced
496
+ // user-facing reply text that the skip short-circuit would otherwise drop on
497
+ // the floor with no retry (the inbound is already drained). When this matches
498
+ // the just-completed turn, `validateChannelTurn` falls through to the normal
499
+ // `recoverableAssistantText` path, which posts the reply via `source:'system'`
500
+ // (subject to the existing NO_REPLY / leak guards). `null` when no skip-locked
501
+ // send was attempted. Compared by `turnSeq` so a stale value can't leak across
502
+ // turns.
503
+ skipLockedSendTurn: number | null
451
504
  // Captured by drain() at batch dequeue; read+cleared by send() on the
452
505
  // first tool-source send of the turn. The anchor decision (delay
453
506
  // threshold + intervening-observed check) is evaluated at SEND time
@@ -474,6 +527,7 @@ type LiveSession = {
474
527
  destroyed: boolean
475
528
  unsubProviderErrors: (() => void) | null
476
529
  unsubTypingActivity: (() => void) | null
530
+ unsubTodoOutcome: (() => void) | null
477
531
  }
478
532
 
479
533
  // `event` is null for command invocations that originated outside the inbound
@@ -570,6 +624,14 @@ export type ChannelRouter = {
570
624
  registerFetchAttachment: (adapter: ChannelKey['adapter'], cb: FetchAttachmentCallback) => void
571
625
  unregisterFetchAttachment: (adapter: ChannelKey['adapter'], cb: FetchAttachmentCallback) => void
572
626
  fetchAttachment: (adapter: ChannelKey['adapter'], args: FetchAttachmentArgs) => Promise<FetchAttachmentResult>
627
+ // Review-thread resolution is opt-in per adapter and last-write-wins (one
628
+ // bot account per adapter, like self-identity). An adapter that never calls
629
+ // registerReviewThreadResolver makes `resolveReviewThread` answer
630
+ // `unsupported`. Kept off the outbound path: resolving is a side-effect close-
631
+ // out, not a message, so it bypasses send()'s flood/cap/dup/sticky guards.
632
+ registerReviewThreadResolver: (adapter: ChannelKey['adapter'], resolver: ReviewThreadResolver) => void
633
+ unregisterReviewThreadResolver: (adapter: ChannelKey['adapter'], resolver: ReviewThreadResolver) => void
634
+ resolveReviewThread: (req: ReviewThreadResolveRequest) => Promise<ReviewThreadResolveResult>
573
635
  lookupInboundAttachment: (args: ChannelKey & { id: number }) => InboundAttachment | null
574
636
  listInboundAttachmentIds: (args: ChannelKey) => readonly number[]
575
637
  // Execute a command by name against an existing live session, bypassing
@@ -635,6 +697,17 @@ export type ChannelRouter = {
635
697
  | { kind: 'recorded'; keyId: string }
636
698
  | { kind: 'recorded-after-send'; keyId: string }
637
699
  | { kind: 'no-live-session' }
700
+ // Two-phase boot restart-resume. Call `reserveRestartHandoff(handoff)` BEFORE
701
+ // `channelManager.start()` to install a per-key gate so an inbound that races
702
+ // the adapters coming online coalesces onto the resume instead of competing
703
+ // with it; then `await reservation.resume()` AFTER start so the reopen + wake
704
+ // reply have registered adapters. Returns null for non-channel handoffs or an
705
+ // unconfigured adapter. `resume()` is safe on a stale/missing mapping — it
706
+ // logs and skips, leaving the todo to resume on the next real inbound.
707
+ reserveRestartHandoff: (handoff: RestartHandoff) => RestartReservation | null
708
+ // Reserve + resume in one call (reserve, then immediately resume). For
709
+ // callers already past adapter startup; prefer the two-phase form at boot.
710
+ resumeRestartHandoff: (handoff: RestartHandoff) => Promise<void>
638
711
  stop: () => Promise<void>
639
712
  tearDownAllLive: () => Promise<void>
640
713
  liveCount: () => number
@@ -739,7 +812,18 @@ export type CreateChannelRouterOptions = {
739
812
  // confirmation string (the container exits shortly after, so the reply is
740
813
  // best-effort).
741
814
  onReload?: () => Promise<string>
742
- onRestart?: () => Promise<string>
815
+ // `ctx` is present only when the /restart command resolved a live session for
816
+ // the invoking channel (wantsLiveSession). When present, the handler should
817
+ // write a channel-origin resume handoff so the originating conversation
818
+ // resumes on the next boot; when absent (cold channel / native slash with no
819
+ // session) it should just bounce the container with no handoff.
820
+ onRestart?: (ctx?: RestartCommandContext) => Promise<string>
821
+ }
822
+
823
+ export type RestartCommandContext = {
824
+ originatingSessionId: string
825
+ originatingSessionFile?: string
826
+ handoffOrigin: { kind: 'channel'; key: ChannelKey }
743
827
  }
744
828
 
745
829
  export type ClaimHandlerInput = {
@@ -756,6 +840,16 @@ export type ClaimHandlerOutcome =
756
840
  | { kind: 'fail'; reply: string }
757
841
  | { kind: 'fallthrough' }
758
842
 
843
+ // A boot-time restart-resume reservation for one channel key. `resume()` runs
844
+ // the real reopen after adapters are ready; `sawInbound` records whether a real
845
+ // inbound coalesced onto it in the meantime (in which case the synthetic wake
846
+ // is skipped — the inbound already triggers the turn).
847
+ export type RestartReservation = {
848
+ keyId: string
849
+ sawInbound: boolean
850
+ resume: () => Promise<void>
851
+ }
852
+
759
853
  export type ClaimHandler = (input: ClaimHandlerInput) => Promise<ClaimHandlerOutcome>
760
854
 
761
855
  const GRANT_ALL_PERMISSIONS: PermissionService = {
@@ -780,6 +874,14 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
780
874
  const onRestart = options.onRestart
781
875
  const liveSessions = new Map<string, LiveSession>()
782
876
  const creating = new Map<string, Promise<LiveSession>>()
877
+ // Restart-resume reservations, keyed by channelKeyId. Installed by
878
+ // reserveRestartHandoff BEFORE channel adapters start receiving, so an
879
+ // inbound that races the boot resume coalesces onto the reservation (via the
880
+ // `creating` entry it seeds) instead of stale-rolling the mapping or
881
+ // creating a competing session. `sawInbound` is flipped by route() when an
882
+ // inbound waited on it, which suppresses the synthetic wake (the real inbound
883
+ // is the wake). Cleared when the reservation resolves.
884
+ const restartReservations = new Map<string, RestartReservation>()
783
885
  // Bumped by tearDownAllLive() and stop() before they tear sessions down. An
784
886
  // in-flight ensureLive() captures the value at creation start and re-checks
785
887
  // it right before installing into liveSessions; if it changed, a teardown
@@ -798,6 +900,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
798
900
  const membershipCaches = new Map<ChannelKey['adapter'], MembershipCache>()
799
901
  const historyCallbacks = new Map<ChannelKey['adapter'], Set<HistoryCallback>>()
800
902
  const fetchAttachmentCallbacks = new Map<ChannelKey['adapter'], Set<FetchAttachmentCallback>>()
903
+ const reviewThreadResolvers = new Map<ChannelKey['adapter'], ReviewThreadResolver>()
801
904
  const stickyLedger = new StickyLedger()
802
905
  // The /help handler reads the live registry to enumerate commands, so it
803
906
  // forward-references `commands`. Safe at runtime — the handler only runs on
@@ -842,7 +945,22 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
842
945
  description: 'Restart the typeclaw container.',
843
946
  permission: 'session.admin',
844
947
  requiresLiveSession: false,
845
- handler: async () => ({ reply: await onRestart() }),
948
+ // Resolve the live session when one exists so the restart can write a
949
+ // resume handoff for this conversation; still bounces from a cold channel.
950
+ wantsLiveSession: true,
951
+ handler: async ({ live }) => ({
952
+ reply: await onRestart(
953
+ live !== null
954
+ ? {
955
+ originatingSessionId: live.sessionId,
956
+ ...(live.getTranscriptPath?.() !== undefined
957
+ ? { originatingSessionFile: live.getTranscriptPath!()! }
958
+ : {}),
959
+ handoffOrigin: { kind: 'channel', key: live.key },
960
+ }
961
+ : undefined,
962
+ ),
963
+ }),
846
964
  })
847
965
  }
848
966
  const commands = createCommandRegistry<ChannelCommandContext>(channelCommands)
@@ -997,10 +1115,20 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
997
1115
  key: ChannelKey,
998
1116
  triggeringMessageId?: string,
999
1117
  triggeringAuthorId?: string,
1118
+ // Restart-resume only: force rehydration of this exact (sessionId,
1119
+ // sessionFile) and bypass stale-rollover, so the originating session's
1120
+ // `typeclaw.restart-self` entry is reopened rather than rolled into a fresh
1121
+ // session (a restart easily outlasts SESSION_FRESHNESS_TTL_MS). The mapping
1122
+ // is persisted only through the normal success path below — no pre-mutation
1123
+ // — so a reopen failure leaves the durable mapping untouched.
1124
+ resumeTarget?: { sessionId: string; sessionFile: string },
1000
1125
  ): Promise<LiveSession> => {
1001
1126
  const keyId = channelKeyId(key)
1002
1127
  const existing = liveSessions.get(keyId)
1003
1128
  if (existing && !existing.destroyed) {
1129
+ // A resume that finds the key already live is a no-op for reopening: the
1130
+ // session is up, so just hand it back and let the caller enqueue the wake.
1131
+ if (resumeTarget !== undefined) return existing
1004
1132
  const idleMs = now() - existing.lastInboundAt
1005
1133
  // `lastInboundAt` is only bumped on engaged inbounds (see route()),
1006
1134
  // so a session whose drain loop has been compiling a slow reply for
@@ -1055,6 +1183,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1055
1183
  const record = mappings ? findRecord(mappings, key) : undefined
1056
1184
  let resolvedRecord = record
1057
1185
  if (
1186
+ resumeTarget === undefined &&
1058
1187
  record?.sessionId !== undefined &&
1059
1188
  existing === undefined &&
1060
1189
  now() - (record.lastInboundAt ?? 0) > SESSION_FRESHNESS_TTL_MS
@@ -1083,6 +1212,21 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1083
1212
  }
1084
1213
  }
1085
1214
  }
1215
+ if (resumeTarget !== undefined) {
1216
+ // Reopen the exact originating session in-memory only; the success
1217
+ // path below persists it. Carry the prior record's participants when
1218
+ // present so the reopened session keeps its roster.
1219
+ resolvedRecord = {
1220
+ adapter: key.adapter,
1221
+ workspace: key.workspace,
1222
+ chat: key.chat,
1223
+ thread: key.thread,
1224
+ sessionId: resumeTarget.sessionId,
1225
+ sessionFile: resumeTarget.sessionFile,
1226
+ participants: (record?.participants ?? []) as ChannelParticipant[],
1227
+ lastInboundAt: now(),
1228
+ }
1229
+ }
1086
1230
  const phase = resolvedRecord?.sessionId === undefined ? 'cold-start' : 'rehydrate'
1087
1231
  logger.info(`[channels] ${keyId}: ensureLive begin (${phase})`)
1088
1232
  const participants = (resolvedRecord?.participants ?? []) as ChannelParticipant[]
@@ -1181,6 +1325,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1181
1325
  currentTurnAuthorId: null,
1182
1326
  currentTurnAuthorIds: new Set(),
1183
1327
  currentTurnReactionRef: null,
1328
+ currentTurnTypingThread: null,
1184
1329
  currentTurnEngageReactions: [],
1185
1330
  // `lastTurnAuthorId` (string, used for `lastInboundAuthorId` in
1186
1331
  // origin) and `lastTurnAuthorIds` (Set, used by
@@ -1204,6 +1349,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1204
1349
  inFlightToolSends: new Map(),
1205
1350
  policyDeniedToolSendsThisTurn: new Map(),
1206
1351
  skippedTurn: null,
1352
+ skipLockedSendTurn: null,
1207
1353
  pendingQuoteCandidate: null,
1208
1354
  recentEngagedPeerBotTurns: [],
1209
1355
  consecutiveEngagedPeerBotTurns: 0,
@@ -1213,10 +1359,22 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1213
1359
  destroyed: false,
1214
1360
  unsubProviderErrors: null,
1215
1361
  unsubTypingActivity: null,
1362
+ unsubTodoOutcome: null,
1216
1363
  }
1217
1364
  live.unsubProviderErrors = subscribeProviderErrors(created.session, (err) => {
1218
1365
  logger.error(`[channels] ${live.keyId}: LLM call failed: ${err.message}`)
1219
1366
  })
1367
+ live.unsubTodoOutcome = created.session.subscribe((event: unknown) => {
1368
+ const usage = extractTurnUsage(event)
1369
+ if (usage === null) return
1370
+ void recordTurnOutcome({
1371
+ agentDir: options.agentDir,
1372
+ origin: buildLiveOrigin(live),
1373
+ turnId: live.sessionId,
1374
+ stopReason: usage.stopReason,
1375
+ ...(usage.tokens !== undefined ? { tokens: usage.tokens } : {}),
1376
+ }).catch((err) => logger.error(`[channels] ${live.keyId}: todo outcome capture failed: ${describe(err)}`))
1377
+ })
1220
1378
  live.unsubTypingActivity = subscribeTypingActivity(created.session, live)
1221
1379
  installChannelReplyTerminalHook(live)
1222
1380
  installChannelOutputCap(live)
@@ -1308,6 +1466,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1308
1466
  if (item.kind === 'message') {
1309
1467
  observed.push({
1310
1468
  text: item.message.text,
1469
+ ...(item.message.referenceContext !== undefined ? { referenceContext: item.message.referenceContext } : {}),
1311
1470
  authorId: item.message.authorId,
1312
1471
  authorName: item.message.authorName,
1313
1472
  authorIsBot: item.message.isBot,
@@ -1366,6 +1525,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1366
1525
  workspace: live.key.workspace,
1367
1526
  chat: live.key.chat,
1368
1527
  thread: live.key.thread,
1528
+ ...(live.currentTurnTypingThread !== null ? { typingThread: live.currentTurnTypingThread } : {}),
1369
1529
  phase,
1370
1530
  }
1371
1531
  await Promise.all(
@@ -1505,6 +1665,36 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1505
1665
  }
1506
1666
  }
1507
1667
 
1668
+ const recordTodoTurnStart = async (live: LiveSession, isRealUserTurn: boolean): Promise<void> => {
1669
+ try {
1670
+ await recordTurnStart({ agentDir: options.agentDir, origin: buildLiveOrigin(live), isRealUserTurn })
1671
+ } catch (err) {
1672
+ logger.warn(`[channels] ${live.keyId}: todo turn-start failed: ${describe(err)}`)
1673
+ }
1674
+ }
1675
+
1676
+ // After the drain queue empties, push at most one continuation reminder into
1677
+ // pendingSystemReminders. The enclosing drain `while` re-checks that array,
1678
+ // so the reminder is picked up as a batch-empty (injected, non-user) turn in
1679
+ // the same drain pass. The episode guard bounds how many times this can
1680
+ // re-fire; a reminder-only turn records isRealUserTurn=false so it never
1681
+ // resets the budget.
1682
+ const maybeContinueTodosChannel = async (live: LiveSession): Promise<void> => {
1683
+ if (live.destroyed) return
1684
+ if (live.promptQueue.length > 0 || live.pendingSystemReminders.length > 0) return
1685
+ try {
1686
+ await runIdleContinuation({
1687
+ agentDir: options.agentDir,
1688
+ origin: buildLiveOrigin(live),
1689
+ deliver: (text) => {
1690
+ live.pendingSystemReminders.push(text)
1691
+ },
1692
+ })
1693
+ } catch (err) {
1694
+ logger.warn(`[channels] ${live.keyId}: todo continuation failed: ${describe(err)}`)
1695
+ }
1696
+ }
1697
+
1508
1698
  const fireSessionTurnStart = async (live: LiveSession, userPrompt: string): Promise<void> => {
1509
1699
  if (!live.hooks) return
1510
1700
  try {
@@ -1595,6 +1785,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1595
1785
  live.currentTurnAuthorId = batch[batch.length - 1]!.authorId
1596
1786
  live.currentTurnAuthorIds = new Set(batch.map((m) => m.authorId))
1597
1787
  live.currentTurnReactionRef = batch[batch.length - 1]!.reactionRef ?? null
1788
+ live.currentTurnTypingThread = batch[batch.length - 1]!.typingThread ?? null
1598
1789
  live.currentTurnEngageReactions = batch.flatMap((m) =>
1599
1790
  m.engageReaction !== undefined ? [m.engageReaction] : [],
1600
1791
  )
@@ -1648,7 +1839,9 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1648
1839
  const engageAddPromises = live.currentTurnEngageReactions
1649
1840
  live.turnSeq++
1650
1841
  live.successfulSendsAtTurnStart = successfulSendsBeforePrompt
1842
+ live.skipLockedSendTurn = null
1651
1843
  live.policyDeniedToolSendsThisTurn.clear()
1844
+ const isRealUserTurn = batch.length > 0
1652
1845
  await fireSessionTurnStart(live, text)
1653
1846
  try {
1654
1847
  await live.session.prompt(text)
@@ -1665,6 +1858,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1665
1858
  await fireSessionTurnEnd(live)
1666
1859
  }
1667
1860
  await fireSessionIdle(live)
1861
+ await recordTodoTurnStart(live, isRealUserTurn)
1862
+ await maybeContinueTodosChannel(live)
1668
1863
  live.lastTurnAuthorIds = new Set(live.currentTurnAuthorIds)
1669
1864
  if (live.currentTurnAuthorId !== null) {
1670
1865
  live.lastTurnAuthorId = live.currentTurnAuthorId
@@ -1677,7 +1872,10 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1677
1872
  live.currentTurnReactionRef = null
1678
1873
  live.currentTurnEngageReactions = []
1679
1874
  live.currentTurnAttachments = []
1875
+ // Reset AFTER stopTypingHeartbeat: its final 'stop' tick reads the anchor
1876
+ // to clear a flat-DM status; clearing it first would strand the indicator.
1680
1877
  await stopTypingHeartbeat(live)
1878
+ live.currentTurnTypingThread = null
1681
1879
  }
1682
1880
  }
1683
1881
 
@@ -1858,6 +2056,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1858
2056
  }
1859
2057
  // Session-less commands (e.g. /help) are informational and run without a
1860
2058
  // live session; their handler reply is posted straight back to the channel.
2059
+ // `wantsLiveSession` commands (/restart) resolve an existing session when
2060
+ // present but do not abort when absent.
1861
2061
  let existingLive: LiveSession | null = null
1862
2062
  if (commandInfo.requiresLiveSession) {
1863
2063
  existingLive = liveSessions.get(keyId) ?? null
@@ -1865,11 +2065,21 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1865
2065
  logger.info(`[channels] ${keyId}: ignoring command /${parsedCommand.name} with no live session`)
1866
2066
  return
1867
2067
  }
2068
+ } else if (commandInfo.wantsLiveSession) {
2069
+ const candidate = liveSessions.get(keyId) ?? null
2070
+ existingLive = candidate !== null && !candidate.destroyed ? candidate : null
1868
2071
  }
1869
2072
  const commandResult = await runChannelCommand(event, existingLive)
1870
2073
  if (commandResult.kind !== 'not-command') return
1871
2074
  }
1872
2075
 
2076
+ // If a boot restart-resume reservation is pending for this key, mark that a
2077
+ // real inbound arrived: ensureLive below will coalesce onto the reservation
2078
+ // (via its `creating` seed), and the reservation's resume() will skip the
2079
+ // synthetic wake since this inbound already triggers the turn.
2080
+ const reservation = restartReservations.get(channelKeyId(key))
2081
+ if (reservation !== undefined) reservation.sawInbound = true
2082
+
1873
2083
  const live = await ensureLive(key, event.externalMessageId, event.authorId)
1874
2084
 
1875
2085
  const isNewAuthor = !live.participants.some((p) => p.authorId === event.authorId)
@@ -2013,6 +2223,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2013
2223
  const observe = (live: LiveSession, event: InboundMessage): void => {
2014
2224
  live.contextBuffer.push({
2015
2225
  text: event.text,
2226
+ ...(event.referenceContext !== undefined ? { referenceContext: event.referenceContext } : {}),
2016
2227
  ...(event.attachments !== undefined && event.attachments.length > 0 ? { attachments: event.attachments } : {}),
2017
2228
  authorId: event.authorId,
2018
2229
  authorName: event.authorName,
@@ -2033,6 +2244,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2033
2244
  ): void => {
2034
2245
  live.promptQueue.push({
2035
2246
  text: event.text,
2247
+ ...(event.referenceContext !== undefined ? { referenceContext: event.referenceContext } : {}),
2036
2248
  ...(event.attachments !== undefined && event.attachments.length > 0 ? { attachments: event.attachments } : {}),
2037
2249
  authorId: event.authorId,
2038
2250
  authorName: event.authorName,
@@ -2043,9 +2255,14 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2043
2255
  isBotMention: event.isBotMention,
2044
2256
  replyToBotMessageId: event.replyToBotMessageId,
2045
2257
  isDm: event.isDm,
2258
+ ...(event.typingThread !== undefined ? { typingThread: event.typingThread } : {}),
2046
2259
  receivedAt: now(),
2047
2260
  ts: event.ts,
2048
2261
  })
2262
+ // Make the typing anchor live BEFORE startTypingHeartbeat fires (route()
2263
+ // starts the heartbeat right after enqueue, ahead of drain). drain() later
2264
+ // refreshes it to the last inbound of a coalesced batch.
2265
+ if (event.typingThread !== undefined) live.currentTurnTypingThread = event.typingThread
2049
2266
  }
2050
2267
 
2051
2268
  const registerOutbound = (adapter: ChannelKey['adapter'], cb: OutboundCallback): void => {
@@ -2328,6 +2545,30 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2328
2545
  return lastError
2329
2546
  }
2330
2547
 
2548
+ const registerReviewThreadResolver = (adapter: ChannelKey['adapter'], resolver: ReviewThreadResolver): void => {
2549
+ reviewThreadResolvers.set(adapter, resolver)
2550
+ }
2551
+
2552
+ const unregisterReviewThreadResolver = (adapter: ChannelKey['adapter'], resolver: ReviewThreadResolver): void => {
2553
+ if (reviewThreadResolvers.get(adapter) === resolver) {
2554
+ reviewThreadResolvers.delete(adapter)
2555
+ }
2556
+ }
2557
+
2558
+ const resolveReviewThread = async (req: ReviewThreadResolveRequest): Promise<ReviewThreadResolveResult> => {
2559
+ const resolver = reviewThreadResolvers.get(req.adapter)
2560
+ if (resolver === undefined) {
2561
+ return {
2562
+ ok: false,
2563
+ error: `adapter "${req.adapter}" does not support review-thread resolution`,
2564
+ code: 'unsupported',
2565
+ }
2566
+ }
2567
+ return await resolver(req).catch(
2568
+ (err): ReviewThreadResolveResult => ({ ok: false, error: describe(err), code: 'transient' }),
2569
+ )
2570
+ }
2571
+
2331
2572
  const lookupInboundAttachment = (args: ChannelKey & { id: number }): InboundAttachment | null => {
2332
2573
  const live = liveSessions.get(channelKeyId(args))
2333
2574
  if (live === undefined) return null
@@ -2459,7 +2700,11 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2459
2700
  // violation: the model already committed to silence. Reject before any
2460
2701
  // state mutation so the model gets a clear error and the channel stays
2461
2702
  // silent. System-source sends (recovery, role-claim) are not affected.
2703
+ // Record the contested skip so `validateChannelTurn` doesn't ALSO drop the
2704
+ // reply text on the floor — the live send stays denied, but the post-turn
2705
+ // recovery net must still surface what the model wanted to say.
2462
2706
  if (live.skippedTurn !== null && live.skippedTurn.turnSeq === live.turnSeq) {
2707
+ live.skipLockedSendTurn = live.turnSeq
2463
2708
  return denyPolicyToolSend(SKIP_RESPONSE_LOCK_ERROR, 'skip-locked')
2464
2709
  }
2465
2710
  const currentCount = live.consecutiveSends.get(sendKey) ?? 0
@@ -2482,6 +2727,13 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2482
2727
  reserved = true
2483
2728
  }
2484
2729
 
2730
+ // The adapter needs the typing anchor to clear a flat-DM status (msg.thread
2731
+ // is null there, so a thread-keyed clear would no-op). Kept off msg.thread
2732
+ // to leave reply threading untouched.
2733
+ if (live?.currentTurnTypingThread != null && msg.typingThread === undefined) {
2734
+ msg = { ...msg, typingThread: live.currentTurnTypingThread }
2735
+ }
2736
+
2485
2737
  // Snapshot the callbacks before iterating so a callback that mutates the
2486
2738
  // set (e.g. unregisters mid-send) does not cause the iterator to skip
2487
2739
  // siblings or trip into surprising behavior.
@@ -2561,19 +2813,30 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2561
2813
  }
2562
2814
 
2563
2815
  const validateChannelTurn = async (live: LiveSession, successfulSendsBeforePrompt: number): Promise<void> => {
2564
- // `skip_response` short-circuit. Must run before the `successfulChannelSends`
2565
- // check so a model that called both `skip_response` and a channel tool in
2566
- // the same turn still resolves as "skipped" — the rejection inside `send()`
2567
- // means the channel tool returned an error and never actually delivered.
2816
+ // `skip_response` short-circuit. Honoring it bypasses recovery entirely.
2568
2817
  // Stale-flag protection: only honor when stamped on the just-completed
2569
2818
  // turn. A flag set by a previous turn that crashed before validation
2570
2819
  // would otherwise drop the next legitimate user-facing reply.
2571
- if (live.skippedTurn !== null && live.skippedTurn.turnSeq === live.turnSeq) {
2820
+ //
2821
+ // Contested-skip carve-out: if the model ALSO attempted a tool-source send
2822
+ // this turn (denied `skip-locked` in `send()`, stamped on `skipLockedSendTurn`),
2823
+ // the skip is no longer a clean opt-out — the model produced reply text it
2824
+ // wanted delivered. The live send stays denied, but we must NOT also suppress
2825
+ // recovery, or the reply is silently dropped with nothing to retry it (the
2826
+ // inbound is already drained). Fall through to the normal recovery path, which
2827
+ // posts it via `source:'system'` under the existing NO_REPLY / leak guards.
2828
+ const skipContested = live.skipLockedSendTurn === live.turnSeq
2829
+ if (live.skippedTurn !== null && live.skippedTurn.turnSeq === live.turnSeq && !skipContested) {
2572
2830
  const { reason } = live.skippedTurn
2573
2831
  live.skippedTurn = null
2574
2832
  logger.info(`[channels] ${live.keyId} skipped_by_tool reason=${JSON.stringify(reason)}`)
2575
2833
  return
2576
2834
  }
2835
+ if (live.skippedTurn !== null && live.skippedTurn.turnSeq === live.turnSeq) {
2836
+ // Clear the now-contested skip so it can't leak into a later turn's check.
2837
+ live.skippedTurn = null
2838
+ logger.info(`[channels] ${live.keyId} skip_contested_by_send recovering reply`)
2839
+ }
2577
2840
  if (live.successfulChannelSends > successfulSendsBeforePrompt) return
2578
2841
 
2579
2842
  const candidate = recoverableAssistantText(live.session)
@@ -2695,6 +2958,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2695
2958
  live.unsubProviderErrors = null
2696
2959
  live.unsubTypingActivity?.()
2697
2960
  live.unsubTypingActivity = null
2961
+ live.unsubTodoOutcome?.()
2962
+ live.unsubTodoOutcome = null
2698
2963
  await stopTypingHeartbeat(live)
2699
2964
  try {
2700
2965
  await live.session.abort()
@@ -2772,6 +3037,127 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2772
3037
  }
2773
3038
  }
2774
3039
 
3040
+ // Boot-time resume for a restart that originated from a channel session, in
3041
+ // two phases to close the race with adapters that begin receiving inbounds.
3042
+ //
3043
+ // PHASE 1 — reserveRestartHandoff(handoff): called BEFORE the adapters start.
3044
+ // It seeds a per-key entry in `creating` so any inbound that arrives during
3045
+ // boot coalesces onto the (not-yet-run) resume instead of stale-rolling the
3046
+ // mapping or creating a competing session. It does NOT touch resolvers or
3047
+ // outbound callbacks (not registered yet) — it only installs the gate.
3048
+ //
3049
+ // PHASE 2 — reservation.resume(): called AFTER channelManager.start(), when
3050
+ // adapters (and thus resolvers + the outbound callback the wake reply needs)
3051
+ // are ready. It removes its own `creating` seed, reopens the exact session
3052
+ // via ensureLive(resumeTarget) (bypassing stale-rollover, persisting only on
3053
+ // success), and — only if no real inbound coalesced in the meantime — arms
3054
+ // the restart-kick suppressor and enqueues the synthetic wake. If an inbound
3055
+ // did arrive, that inbound is the wake, so the synthetic one is skipped to
3056
+ // avoid a duplicate/spurious "I'm back" turn.
3057
+ //
3058
+ // The `typeclaw.restart-self` entry is already in the reopened JSONL (the
3059
+ // dying container appended it on the restart broadcast), so reopening the
3060
+ // file is what produces the greeting; adapter readiness only matters for
3061
+ // delivering the eventual reply.
3062
+ const reserveRestartHandoff = (handoff: RestartHandoff): RestartReservation | null => {
3063
+ if (handoff.origin.kind !== 'channel') return null
3064
+ const key: ChannelKey = {
3065
+ adapter: handoff.origin.key.adapter,
3066
+ workspace: handoff.origin.key.workspace,
3067
+ chat: handoff.origin.key.chat,
3068
+ thread: handoff.origin.key.thread,
3069
+ }
3070
+ const keyId = channelKeyId(key)
3071
+
3072
+ if (options.configForAdapter(key.adapter) === undefined) {
3073
+ logger.warn(`[channels] ${keyId}: restart-resume skipped — adapter not configured`)
3074
+ return null
3075
+ }
3076
+
3077
+ let resolveGate!: (live: LiveSession) => void
3078
+ let rejectGate!: (err: unknown) => void
3079
+ const gate = new Promise<LiveSession>((res, rej) => {
3080
+ resolveGate = res
3081
+ rejectGate = rej
3082
+ })
3083
+ // Seed `creating` so a racing inbound's ensureLive awaits this gate rather
3084
+ // than starting its own create. Suppress an unhandled-rejection warning on
3085
+ // the skip/failure paths that never get an inbound waiter.
3086
+ creating.set(keyId, gate)
3087
+ gate.catch(() => undefined)
3088
+
3089
+ const reservation: RestartReservation = {
3090
+ keyId,
3091
+ sawInbound: false,
3092
+ resume: async () => {
3093
+ // Drop our own seed BEFORE calling ensureLive, or ensureLive would
3094
+ // await the gate we are about to resolve and deadlock.
3095
+ if (creating.get(keyId) === gate) creating.delete(keyId)
3096
+ restartReservations.delete(keyId)
3097
+
3098
+ await ensureLoaded()
3099
+ const record = mappings ? findRecord(mappings, key) : undefined
3100
+ if (record?.sessionId !== handoff.originatingSessionId) {
3101
+ logger.warn(
3102
+ `[channels] ${keyId}: restart-resume skipped — persisted session ` +
3103
+ `${record?.sessionId ?? '<none>'} no longer matches handoff ${handoff.originatingSessionId}`,
3104
+ )
3105
+ rejectGate(new StaleLiveSessionError(keyId))
3106
+ return
3107
+ }
3108
+
3109
+ let live: LiveSession
3110
+ try {
3111
+ live = await ensureLive(key, undefined, undefined, {
3112
+ sessionId: handoff.originatingSessionId,
3113
+ sessionFile: handoff.originatingSessionFile,
3114
+ })
3115
+ } catch (err) {
3116
+ logger.warn(`[channels] ${keyId}: restart-resume ensureLive failed: ${describe(err)}`)
3117
+ rejectGate(err)
3118
+ return
3119
+ }
3120
+ resolveGate(live)
3121
+
3122
+ if (live.sessionId !== handoff.originatingSessionId) {
3123
+ logger.warn(
3124
+ `[channels] ${keyId}: restart-resume reopened a different session ` +
3125
+ `(${live.sessionId} != ${handoff.originatingSessionId}); skipping wake`,
3126
+ )
3127
+ return
3128
+ }
3129
+
3130
+ // A real inbound coalesced onto the reservation during boot: it is the
3131
+ // wake. Adding the synthetic "I'm back" turn on top would duplicate
3132
+ // work / stack a spurious turn, so skip it and let the inbound drain.
3133
+ if (reservation.sawInbound) {
3134
+ logger.info(`[channels] ${keyId}: restart-resume coalesced with a real inbound; skipping synthetic wake`)
3135
+ return
3136
+ }
3137
+
3138
+ await armRestartKickForOrigin(options.agentDir, buildLiveOrigin(live)).catch((err) =>
3139
+ logger.error(`[channels] ${keyId}: restart-resume arm restart-kick failed: ${describe(err)}`),
3140
+ )
3141
+
3142
+ live.pendingSystemReminders.push(RESTART_RESUME_WAKE_REMINDER)
3143
+ logger.info(`[channels] ${keyId}: restart-resume waking session ${live.sessionId}`)
3144
+ void drain(live)
3145
+ },
3146
+ }
3147
+ restartReservations.set(keyId, reservation)
3148
+ return reservation
3149
+ }
3150
+
3151
+ // Reserve + resume in one call, for callers (and tests) that run after the
3152
+ // adapters are already started and so don't need the pre-start gate. Still
3153
+ // benefits from the reservation's sawInbound suppression for inbounds that
3154
+ // race between reserve and resume.
3155
+ const resumeRestartHandoff = async (handoff: RestartHandoff): Promise<void> => {
3156
+ const reservation = reserveRestartHandoff(handoff)
3157
+ if (reservation === null) return
3158
+ await reservation.resume()
3159
+ }
3160
+
2775
3161
  const executeCommand = async (
2776
3162
  key: ChannelKey,
2777
3163
  name: string,
@@ -2815,6 +3201,11 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2815
3201
  return { kind: 'ambiguous', matchCount: resolved.count }
2816
3202
  }
2817
3203
  live = resolved.session
3204
+ } else if (commandInfo.wantsLiveSession) {
3205
+ // Best-effort: resolve a session if exactly one matches, but never fail
3206
+ // the command when absent or ambiguous — /restart still bounces.
3207
+ const resolved = resolveLiveSessionForCommand(liveSessions, key)
3208
+ live = resolved.kind === 'found' ? resolved.session : null
2818
3209
  }
2819
3210
  const result = await commands.execute(`/${lowered}`, { live, event: null })
2820
3211
  if (result.kind === 'handled') {
@@ -2922,12 +3313,17 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2922
3313
  registerFetchAttachment,
2923
3314
  unregisterFetchAttachment,
2924
3315
  fetchAttachment,
3316
+ registerReviewThreadResolver,
3317
+ unregisterReviewThreadResolver,
3318
+ resolveReviewThread,
2925
3319
  lookupInboundAttachment,
2926
3320
  listInboundAttachmentIds,
2927
3321
  executeCommand,
2928
3322
  getSelfAliases: computeSelfAliases,
2929
3323
  injectSubagentCompletionReminder,
2930
3324
  markTurnSkipped,
3325
+ reserveRestartHandoff,
3326
+ resumeRestartHandoff,
2931
3327
  stop,
2932
3328
  tearDownAllLive,
2933
3329
  liveCount: () => liveSessions.size,
@@ -3132,7 +3528,7 @@ function composeTurnPrompt(
3132
3528
  if (observed.length > 0) {
3133
3529
  parts.push('## Recent context (not addressed to you, for awareness only)')
3134
3530
  for (const o of observed) {
3135
- parts.push(formatAuthorLine(o.ts, adapter, o.authorId, o.authorName, o.authorIsBot, o.text))
3531
+ parts.push(formatInboundPromptLines(o, adapter))
3136
3532
  }
3137
3533
  parts.push('')
3138
3534
  }
@@ -3150,7 +3546,7 @@ function composeTurnPrompt(
3150
3546
  )
3151
3547
  }
3152
3548
  for (const b of batch) {
3153
- parts.push(formatAuthorLine(b.ts, adapter, b.authorId, b.authorName, b.authorIsBot, b.text))
3549
+ parts.push(formatInboundPromptLines(b, adapter))
3154
3550
  }
3155
3551
  }
3156
3552
  return parts.join('\n')
@@ -3169,6 +3565,24 @@ function formatAuthorLine(
3169
3565
  return `${stamp}${formatAuthorReference(adapter, authorId, authorName)} (${authorName})${tag}: ${text}`
3170
3566
  }
3171
3567
 
3568
+ function formatInboundPromptLines(
3569
+ inbound: {
3570
+ ts: number
3571
+ authorId: string
3572
+ authorName: string
3573
+ authorIsBot: boolean
3574
+ text: string
3575
+ referenceContext?: InboundReferenceContext
3576
+ },
3577
+ adapter: AdapterId,
3578
+ ): string {
3579
+ const lines = inbound.referenceContext?.sources.map(renderQuoteAnchor) ?? []
3580
+ lines.push(
3581
+ formatAuthorLine(inbound.ts, adapter, inbound.authorId, inbound.authorName, inbound.authorIsBot, inbound.text),
3582
+ )
3583
+ return lines.join('\n')
3584
+ }
3585
+
3172
3586
  export type { QuoteAnchorSource } from './types'
3173
3587
 
3174
3588
  // Picks the right author syntax for the platform so prompts and rendered
@@ -3755,10 +4169,10 @@ export function isLikelyKimiChannelToolLeak(text: string): boolean {
3755
4169
  return KIMI_CHANNEL_TOOL_ID_RE.test(text)
3756
4170
  }
3757
4171
 
3758
- // Detects the *plain-text* shape of a leaked channel-tool invocation — the
3759
- // model serialized the tool call as ordinary prose instead of producing a
3760
- // real tool call. Observed against Kimi-family deployments on KakaoTalk:
3761
- // the entire assistant message body is literally
4172
+ // Detects the *plain-text* shape of a leaked tool invocation — the model
4173
+ // serialized a tool call as ordinary prose instead of producing a real tool
4174
+ // call. Observed against Kimi-family deployments on KakaoTalk: the entire
4175
+ // assistant message body is literally
3762
4176
  //
3763
4177
  // channel_reply({"text":"<the user-facing greeting the bot meant to send>"})
3764
4178
  //
@@ -3768,18 +4182,27 @@ export function isLikelyKimiChannelToolLeak(text: string): boolean {
3768
4182
  // serialization straight to the channel, which is exactly what
3769
4183
  // users see in the reported screenshots.
3770
4184
  //
4185
+ // `skip_response` belongs here too, and is the more insidious case: the model
4186
+ // means to *decline* the turn but serializes the decision as prose —
4187
+ //
4188
+ // skip_response({ reason: "Empty messages, no content to respond to" })
4189
+ //
4190
+ // Because the recovery path treats this as ordinary assistant text, the bot
4191
+ // posts its own "I'm staying silent" plumbing to the channel, the exact
4192
+ // opposite of the intended no-op. It is never a legitimate user-facing reply.
4193
+ //
3771
4194
  // Structural-only detection (NOT a substring search): the trimmed text must
3772
- // *start* with `channel_reply(` or `channel_send(`, and that opening paren
3773
- // must enclose at least one `"` (the JSON argument). This deliberately
3774
- // matches the leak shape while letting prose that merely *mentions* the
3775
- // tool name (e.g. "I would normally call channel_reply here but...") reach
3776
- // the user — that false-positive class is already locked in by the
3777
- // `still recovers legit prose that happens to mention "channel_reply"` test.
4195
+ // *start* with `channel_reply(`, `channel_send(`, or `skip_response(`, and
4196
+ // that opening paren must enclose at least one `"` (the serialized argument).
4197
+ // This deliberately matches the leak shape while letting prose that merely
4198
+ // *mentions* a tool name (e.g. "I would normally call channel_reply here
4199
+ // but...") reach the user — that false-positive class is already locked in by
4200
+ // the `still recovers prose that mentions channel_reply` test.
3778
4201
  //
3779
4202
  // The trailing close paren is NOT required: the model sometimes truncates
3780
4203
  // mid-serialization, and a half-leaked `channel_reply({"text":"..."` is
3781
4204
  // just as user-hostile as the full shape.
3782
- const PLAIN_TEXT_CHANNEL_TOOL_CALL_RE = /^channel_(?:reply|send)\s*\(\s*[^)]*"/
4205
+ const PLAIN_TEXT_CHANNEL_TOOL_CALL_RE = /^(?:channel_(?:reply|send)|skip_response)\s*\(\s*[^)]*"/
3783
4206
 
3784
4207
  export function isLikelyPlainTextChannelToolCall(text: string): boolean {
3785
4208
  return PLAIN_TEXT_CHANNEL_TOOL_CALL_RE.test(text.trim())