typeclaw 0.23.0 → 0.25.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 (90) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/src/agent/index.ts +133 -27
  4. package/src/agent/llm-replay-sanitizer.ts +120 -0
  5. package/src/agent/loop-guard.ts +34 -0
  6. package/src/agent/multimodal/look-at.ts +1 -1
  7. package/src/agent/plugin-tools.ts +122 -8
  8. package/src/agent/restart/index.ts +15 -3
  9. package/src/agent/restart-handoff/index.ts +110 -12
  10. package/src/agent/session-origin.ts +30 -0
  11. package/src/agent/subagent-completion-reminder.ts +26 -1
  12. package/src/agent/subagents.ts +75 -3
  13. package/src/agent/system-prompt.ts +5 -1
  14. package/src/agent/todo/continuation-policy.ts +242 -0
  15. package/src/agent/todo/continuation-state.ts +87 -0
  16. package/src/agent/todo/continuation-wiring.ts +113 -0
  17. package/src/agent/todo/continuation.ts +71 -0
  18. package/src/agent/todo/scope.ts +77 -0
  19. package/src/agent/todo/store.ts +98 -0
  20. package/src/agent/tool-not-found-nudge.ts +126 -0
  21. package/src/agent/tools/channel-reply.ts +51 -0
  22. package/src/agent/tools/curl-impersonate.ts +2 -2
  23. package/src/agent/tools/restart.ts +11 -4
  24. package/src/agent/tools/spawn-subagent.ts +19 -2
  25. package/src/agent/tools/subagent-access.ts +40 -5
  26. package/src/agent/tools/subagent-cancel.ts +3 -1
  27. package/src/agent/tools/subagent-output.ts +6 -2
  28. package/src/agent/tools/todo/index.ts +119 -0
  29. package/src/agent/tools/webfetch/fetch.ts +18 -18
  30. package/src/agent/tools/webfetch/index.ts +1 -1
  31. package/src/agent/tools/webfetch/tool.ts +13 -13
  32. package/src/agent/tools/webfetch/types.ts +1 -1
  33. package/src/agent/tools/websearch.ts +6 -6
  34. package/src/bundled-plugins/backup/index.ts +40 -37
  35. package/src/bundled-plugins/backup/runner.ts +23 -2
  36. package/src/bundled-plugins/github-cli-auth/gh-command.ts +15 -7
  37. package/src/bundled-plugins/guard/policies/non-workspace-write.ts +38 -1
  38. package/src/bundled-plugins/memory/README.md +11 -11
  39. package/src/bundled-plugins/memory/dreaming.ts +5 -0
  40. package/src/bundled-plugins/memory/search-tool.ts +98 -1
  41. package/src/bundled-plugins/operator/operator.ts +5 -1
  42. package/src/bundled-plugins/reviewer/reviewer.ts +32 -9
  43. package/src/bundled-plugins/reviewer/skills/code-review.ts +1 -1
  44. package/src/bundled-plugins/reviewer/skills/general.ts +1 -1
  45. package/src/bundled-plugins/scout/scout.ts +7 -7
  46. package/src/bundled-plugins/security/policies/private-surface-read.ts +2 -2
  47. package/src/bundled-plugins/security/policies/ssrf.ts +3 -3
  48. package/src/bundled-plugins/tool-result-cap/README.md +1 -1
  49. package/src/channels/adapters/discord-bot-reference.ts +78 -0
  50. package/src/channels/adapters/discord-bot.ts +25 -3
  51. package/src/channels/adapters/github/inbound.ts +172 -10
  52. package/src/channels/adapters/github/index.ts +10 -0
  53. package/src/channels/adapters/github/review-thread-resolver.ts +246 -0
  54. package/src/channels/adapters/github/webhook-register.ts +32 -27
  55. package/src/channels/adapters/kakaotalk-classify.ts +67 -6
  56. package/src/channels/adapters/slack-bot-classify.ts +9 -1
  57. package/src/channels/adapters/slack-bot-reference.ts +129 -0
  58. package/src/channels/adapters/slack-bot.ts +67 -8
  59. package/src/channels/manager.ts +8 -2
  60. package/src/channels/router.ts +506 -45
  61. package/src/channels/schema.ts +21 -4
  62. package/src/channels/subagent-completion-bridge.ts +18 -18
  63. package/src/channels/types.ts +69 -1
  64. package/src/cli/inspect-controller.ts +132 -33
  65. package/src/cli/inspect.ts +2 -1
  66. package/src/commands/index.ts +9 -0
  67. package/src/container/start.ts +7 -1
  68. package/src/git/mutex.ts +22 -0
  69. package/src/git/reconcile-ignored.ts +214 -0
  70. package/src/hostd/daemon.ts +26 -1
  71. package/src/hostd/portbroker-manager.ts +7 -0
  72. package/src/init/dockerfile.ts +1 -1
  73. package/src/init/gitignore.ts +28 -16
  74. package/src/inspect/index.ts +53 -4
  75. package/src/inspect/loop.ts +16 -12
  76. package/src/plugin/define.ts +2 -2
  77. package/src/plugin/index.ts +2 -2
  78. package/src/portbroker/hostd-client.ts +36 -13
  79. package/src/run/index.ts +74 -5
  80. package/src/sandbox/build.ts +20 -0
  81. package/src/sandbox/index.ts +10 -0
  82. package/src/sandbox/policy.ts +22 -0
  83. package/src/sandbox/session-tmp.ts +43 -0
  84. package/src/sandbox/writable-zones.ts +178 -0
  85. package/src/server/command-runner.ts +1 -1
  86. package/src/server/index.ts +126 -4
  87. package/src/skills/typeclaw-channel-github/SKILL.md +71 -17
  88. package/src/skills/typeclaw-memory/SKILL.md +3 -1
  89. package/src/tui/format.ts +11 -11
  90. package/typeclaw.schema.json +10 -0
@@ -4,9 +4,19 @@ import type { AssistantMessage } from '@mariozechner/pi-ai'
4
4
  import { SessionManager } from '@mariozechner/pi-coding-agent'
5
5
 
6
6
  import { createSession, renderTurnRoleAnchor, renderTurnTimeAnchor, type AgentSession } from '@/agent'
7
+ import { forgetSharedLoopGuardTool } from '@/agent/plugin-tools'
7
8
  import { subscribeProviderErrors } from '@/agent/provider-error'
9
+ import type { RestartHandoff } from '@/agent/restart-handoff'
8
10
  import type { ChannelParticipant, SessionOrigin } from '@/agent/session-origin'
9
11
  import { renderSubagentCompletionReminder } from '@/agent/subagent-completion-reminder'
12
+ import {
13
+ armRestartKickForOrigin,
14
+ extractTurnUsage,
15
+ recordTurnOutcome,
16
+ recordTurnStart,
17
+ runIdleContinuation,
18
+ } from '@/agent/todo/continuation-wiring'
19
+ import { SUBAGENT_OUTPUT_TOOL_NAME } from '@/agent/tools/subagent-output'
10
20
  import { type Command, type CommandPermission, type CommandResult, createCommandRegistry } from '@/commands'
11
21
  import { CORE_PERMISSIONS, type PermissionService } from '@/permissions'
12
22
  import type { HookBus } from '@/plugin'
@@ -53,6 +63,7 @@ import type {
53
63
  HistoryCallback,
54
64
  InboundAttachment,
55
65
  InboundMessage,
66
+ InboundReferenceContext,
56
67
  RemoveReactionCallback,
57
68
  RemoveReactionRequest,
58
69
  OutboundCallback,
@@ -63,6 +74,9 @@ import type {
63
74
  ReactionRequest,
64
75
  ReactionResult,
65
76
  ResolvedChannelNames,
77
+ ReviewThreadResolveRequest,
78
+ ReviewThreadResolveResult,
79
+ ReviewThreadResolver,
66
80
  SendErrorCode,
67
81
  SendResult,
68
82
  TypingCallback,
@@ -117,6 +131,23 @@ export const SESSION_GC_INTERVAL_MS = 60 * 1000
117
131
  // recovery paths (`source: 'system'`) bypass.
118
132
  export const MAX_CHANNEL_SENDS_PER_TURN = 10
119
133
  export const ENGAGE_REACTION_EMOJI = 'eyes'
134
+
135
+ // Wake nudge pushed into a resumed channel session at boot so drain() has a
136
+ // non-empty batch and fires a turn. The substantive instruction the model acts
137
+ // on is the `typeclaw.restart-self` entry already in the reopened JSONL (pi
138
+ // hydrates it as a user message); this nudge only triggers the turn. Uses the
139
+ // repo's SYSTEM MESSAGE framing (see composeTurnPrompt) so persona-rich models
140
+ // do not reply to it as if a human wrote it.
141
+ export const RESTART_RESUME_WAKE_REMINDER = [
142
+ '---',
143
+ '**[SYSTEM MESSAGE — not from a human]**',
144
+ '',
145
+ 'The container just restarted and this session was resumed. Act on the',
146
+ 'restart instructions already in your context. Do not acknowledge or reply to',
147
+ 'this notice itself.',
148
+ '',
149
+ '---',
150
+ ].join('\n')
120
151
  // Ceiling on tool-source channel sends that a same-turn router policy DENIED
121
152
  // without delivering — `skip-locked`, `turn-cap`, or `duplicate`. Such denials
122
153
  // return a soft error and do NOT increment `consecutiveSends`, so a model that
@@ -280,6 +311,7 @@ export type ConfigForAdapter = (adapter: ChannelKey['adapter']) => ChannelAdapte
280
311
 
281
312
  type QueuedInbound = {
282
313
  text: string
314
+ referenceContext?: InboundReferenceContext
283
315
  attachments?: readonly InboundAttachment[]
284
316
  authorId: string
285
317
  authorName: string
@@ -290,6 +322,7 @@ type QueuedInbound = {
290
322
  isBotMention: boolean
291
323
  replyToBotMessageId: string | null
292
324
  isDm: boolean
325
+ typingThread?: string
293
326
  receivedAt: number
294
327
  // Original platform timestamp (Slack/Discord), in ms since epoch. Used
295
328
  // by composeTurnPrompt to render an ISO 8601 prefix on each line so the
@@ -301,6 +334,7 @@ type QueuedInbound = {
301
334
 
302
335
  type ObservedInbound = {
303
336
  text: string
337
+ referenceContext?: InboundReferenceContext
304
338
  attachments?: readonly InboundAttachment[]
305
339
  authorId: string
306
340
  authorName: string
@@ -358,6 +392,11 @@ type LiveSession = {
358
392
  // origin so `channel_react` reacts to the triggering message, not whichever
359
393
  // inbound happens to be latest in the queue. Null on reminder-only turns.
360
394
  currentTurnReactionRef: ReactionRef | null
395
+ // Typing-status anchor of the inbound that triggered THIS turn (last item in
396
+ // the drained batch, mirroring `currentTurnReactionRef`). Adapter-opaque ts
397
+ // carried only to the typing path; null when the triggering inbound supplied
398
+ // none (every non-DM inbound, and reminder-only turns).
399
+ currentTurnTypingThread: string | null
361
400
  // One engage-:eyes:-add promise per inbound coalesced into THIS turn, each
362
401
  // resolving to its removable per-instance ref (or null). A debounced turn can
363
402
  // batch several inbounds that each got their own :eyes:, so every entry is
@@ -445,9 +484,25 @@ type LiveSession = {
445
484
  // with the current `turnSeq`. Read at the top of `validateChannelTurn`:
446
485
  // if it matches the just-completed turn, recovery is skipped entirely
447
486
  // (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.
487
+ // The model has explicitly opted out of this turn and we honor that
488
+ // UNLESS the model also tried a tool-source send this turn (see
489
+ // `skipLockedSendTurn`), in which case the skip was contested and we let
490
+ // recovery run so the contested reply isn't silently dropped. `null` when
491
+ // no skip has been recorded.
450
492
  skippedTurn: { turnSeq: number; reason: string } | null
493
+ // Stamped by `send()` with the current `turnSeq` when a tool-source send is
494
+ // DENIED by the skip lock (the model called `skip_response` first, then
495
+ // changed its mind and tried `channel_reply`). The send still stays denied —
496
+ // "commit to silence" is binding for the live send path — but a contested
497
+ // skip must NOT also suppress the post-turn recovery net: the model produced
498
+ // user-facing reply text that the skip short-circuit would otherwise drop on
499
+ // the floor with no retry (the inbound is already drained). When this matches
500
+ // the just-completed turn, `validateChannelTurn` falls through to the normal
501
+ // `recoverableAssistantText` path, which posts the reply via `source:'system'`
502
+ // (subject to the existing NO_REPLY / leak guards). `null` when no skip-locked
503
+ // send was attempted. Compared by `turnSeq` so a stale value can't leak across
504
+ // turns.
505
+ skipLockedSendTurn: number | null
451
506
  // Captured by drain() at batch dequeue; read+cleared by send() on the
452
507
  // first tool-source send of the turn. The anchor decision (delay
453
508
  // threshold + intervening-observed check) is evaluated at SEND time
@@ -474,6 +529,7 @@ type LiveSession = {
474
529
  destroyed: boolean
475
530
  unsubProviderErrors: (() => void) | null
476
531
  unsubTypingActivity: (() => void) | null
532
+ unsubTodoOutcome: (() => void) | null
477
533
  }
478
534
 
479
535
  // `event` is null for command invocations that originated outside the inbound
@@ -570,6 +626,14 @@ export type ChannelRouter = {
570
626
  registerFetchAttachment: (adapter: ChannelKey['adapter'], cb: FetchAttachmentCallback) => void
571
627
  unregisterFetchAttachment: (adapter: ChannelKey['adapter'], cb: FetchAttachmentCallback) => void
572
628
  fetchAttachment: (adapter: ChannelKey['adapter'], args: FetchAttachmentArgs) => Promise<FetchAttachmentResult>
629
+ // Review-thread resolution is opt-in per adapter and last-write-wins (one
630
+ // bot account per adapter, like self-identity). An adapter that never calls
631
+ // registerReviewThreadResolver makes `resolveReviewThread` answer
632
+ // `unsupported`. Kept off the outbound path: resolving is a side-effect close-
633
+ // out, not a message, so it bypasses send()'s flood/cap/dup/sticky guards.
634
+ registerReviewThreadResolver: (adapter: ChannelKey['adapter'], resolver: ReviewThreadResolver) => void
635
+ unregisterReviewThreadResolver: (adapter: ChannelKey['adapter'], resolver: ReviewThreadResolver) => void
636
+ resolveReviewThread: (req: ReviewThreadResolveRequest) => Promise<ReviewThreadResolveResult>
573
637
  lookupInboundAttachment: (args: ChannelKey & { id: number }) => InboundAttachment | null
574
638
  listInboundAttachmentIds: (args: ChannelKey) => readonly number[]
575
639
  // Execute a command by name against an existing live session, bypassing
@@ -607,6 +671,7 @@ export type ChannelRouter = {
607
671
  ok: boolean
608
672
  durationMs: number
609
673
  error?: string
674
+ channelKey?: { adapter: string; workspace: string; chat: string; thread: string | null }
610
675
  }) => { kind: 'delivered'; keyId: string } | { kind: 'no-live-session' }
611
676
  // Record that the agent invoked `skip_response` during the current turn
612
677
  // for the channel session identified by `parentSessionId`. The reason is
@@ -635,6 +700,17 @@ export type ChannelRouter = {
635
700
  | { kind: 'recorded'; keyId: string }
636
701
  | { kind: 'recorded-after-send'; keyId: string }
637
702
  | { kind: 'no-live-session' }
703
+ // Two-phase boot restart-resume. Call `reserveRestartHandoff(handoff)` BEFORE
704
+ // `channelManager.start()` to install a per-key gate so an inbound that races
705
+ // the adapters coming online coalesces onto the resume instead of competing
706
+ // with it; then `await reservation.resume()` AFTER start so the reopen + wake
707
+ // reply have registered adapters. Returns null for non-channel handoffs or an
708
+ // unconfigured adapter. `resume()` is safe on a stale/missing mapping — it
709
+ // logs and skips, leaving the todo to resume on the next real inbound.
710
+ reserveRestartHandoff: (handoff: RestartHandoff) => RestartReservation | null
711
+ // Reserve + resume in one call (reserve, then immediately resume). For
712
+ // callers already past adapter startup; prefer the two-phase form at boot.
713
+ resumeRestartHandoff: (handoff: RestartHandoff) => Promise<void>
638
714
  stop: () => Promise<void>
639
715
  tearDownAllLive: () => Promise<void>
640
716
  liveCount: () => number
@@ -739,7 +815,18 @@ export type CreateChannelRouterOptions = {
739
815
  // confirmation string (the container exits shortly after, so the reply is
740
816
  // best-effort).
741
817
  onReload?: () => Promise<string>
742
- onRestart?: () => Promise<string>
818
+ // `ctx` is present only when the /restart command resolved a live session for
819
+ // the invoking channel (wantsLiveSession). When present, the handler should
820
+ // write a channel-origin resume handoff so the originating conversation
821
+ // resumes on the next boot; when absent (cold channel / native slash with no
822
+ // session) it should just bounce the container with no handoff.
823
+ onRestart?: (ctx?: RestartCommandContext) => Promise<string>
824
+ }
825
+
826
+ export type RestartCommandContext = {
827
+ originatingSessionId: string
828
+ originatingSessionFile?: string
829
+ handoffOrigin: { kind: 'channel'; key: ChannelKey }
743
830
  }
744
831
 
745
832
  export type ClaimHandlerInput = {
@@ -756,6 +843,16 @@ export type ClaimHandlerOutcome =
756
843
  | { kind: 'fail'; reply: string }
757
844
  | { kind: 'fallthrough' }
758
845
 
846
+ // A boot-time restart-resume reservation for one channel key. `resume()` runs
847
+ // the real reopen after adapters are ready; `sawInbound` records whether a real
848
+ // inbound coalesced onto it in the meantime (in which case the synthetic wake
849
+ // is skipped — the inbound already triggers the turn).
850
+ export type RestartReservation = {
851
+ keyId: string
852
+ sawInbound: boolean
853
+ resume: () => Promise<void>
854
+ }
855
+
759
856
  export type ClaimHandler = (input: ClaimHandlerInput) => Promise<ClaimHandlerOutcome>
760
857
 
761
858
  const GRANT_ALL_PERMISSIONS: PermissionService = {
@@ -780,6 +877,14 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
780
877
  const onRestart = options.onRestart
781
878
  const liveSessions = new Map<string, LiveSession>()
782
879
  const creating = new Map<string, Promise<LiveSession>>()
880
+ // Restart-resume reservations, keyed by channelKeyId. Installed by
881
+ // reserveRestartHandoff BEFORE channel adapters start receiving, so an
882
+ // inbound that races the boot resume coalesces onto the reservation (via the
883
+ // `creating` entry it seeds) instead of stale-rolling the mapping or
884
+ // creating a competing session. `sawInbound` is flipped by route() when an
885
+ // inbound waited on it, which suppresses the synthetic wake (the real inbound
886
+ // is the wake). Cleared when the reservation resolves.
887
+ const restartReservations = new Map<string, RestartReservation>()
783
888
  // Bumped by tearDownAllLive() and stop() before they tear sessions down. An
784
889
  // in-flight ensureLive() captures the value at creation start and re-checks
785
890
  // it right before installing into liveSessions; if it changed, a teardown
@@ -798,6 +903,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
798
903
  const membershipCaches = new Map<ChannelKey['adapter'], MembershipCache>()
799
904
  const historyCallbacks = new Map<ChannelKey['adapter'], Set<HistoryCallback>>()
800
905
  const fetchAttachmentCallbacks = new Map<ChannelKey['adapter'], Set<FetchAttachmentCallback>>()
906
+ const reviewThreadResolvers = new Map<ChannelKey['adapter'], ReviewThreadResolver>()
801
907
  const stickyLedger = new StickyLedger()
802
908
  // The /help handler reads the live registry to enumerate commands, so it
803
909
  // forward-references `commands`. Safe at runtime — the handler only runs on
@@ -842,7 +948,22 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
842
948
  description: 'Restart the typeclaw container.',
843
949
  permission: 'session.admin',
844
950
  requiresLiveSession: false,
845
- handler: async () => ({ reply: await onRestart() }),
951
+ // Resolve the live session when one exists so the restart can write a
952
+ // resume handoff for this conversation; still bounces from a cold channel.
953
+ wantsLiveSession: true,
954
+ handler: async ({ live }) => ({
955
+ reply: await onRestart(
956
+ live !== null
957
+ ? {
958
+ originatingSessionId: live.sessionId,
959
+ ...(live.getTranscriptPath?.() !== undefined
960
+ ? { originatingSessionFile: live.getTranscriptPath!()! }
961
+ : {}),
962
+ handoffOrigin: { kind: 'channel', key: live.key },
963
+ }
964
+ : undefined,
965
+ ),
966
+ }),
846
967
  })
847
968
  }
848
969
  const commands = createCommandRegistry<ChannelCommandContext>(channelCommands)
@@ -997,10 +1118,20 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
997
1118
  key: ChannelKey,
998
1119
  triggeringMessageId?: string,
999
1120
  triggeringAuthorId?: string,
1121
+ // Restart-resume only: force rehydration of this exact (sessionId,
1122
+ // sessionFile) and bypass stale-rollover, so the originating session's
1123
+ // `typeclaw.restart-self` entry is reopened rather than rolled into a fresh
1124
+ // session (a restart easily outlasts SESSION_FRESHNESS_TTL_MS). The mapping
1125
+ // is persisted only through the normal success path below — no pre-mutation
1126
+ // — so a reopen failure leaves the durable mapping untouched.
1127
+ resumeTarget?: { sessionId: string; sessionFile: string },
1000
1128
  ): Promise<LiveSession> => {
1001
1129
  const keyId = channelKeyId(key)
1002
1130
  const existing = liveSessions.get(keyId)
1003
1131
  if (existing && !existing.destroyed) {
1132
+ // A resume that finds the key already live is a no-op for reopening: the
1133
+ // session is up, so just hand it back and let the caller enqueue the wake.
1134
+ if (resumeTarget !== undefined) return existing
1004
1135
  const idleMs = now() - existing.lastInboundAt
1005
1136
  // `lastInboundAt` is only bumped on engaged inbounds (see route()),
1006
1137
  // so a session whose drain loop has been compiling a slow reply for
@@ -1055,6 +1186,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1055
1186
  const record = mappings ? findRecord(mappings, key) : undefined
1056
1187
  let resolvedRecord = record
1057
1188
  if (
1189
+ resumeTarget === undefined &&
1058
1190
  record?.sessionId !== undefined &&
1059
1191
  existing === undefined &&
1060
1192
  now() - (record.lastInboundAt ?? 0) > SESSION_FRESHNESS_TTL_MS
@@ -1083,6 +1215,21 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1083
1215
  }
1084
1216
  }
1085
1217
  }
1218
+ if (resumeTarget !== undefined) {
1219
+ // Reopen the exact originating session in-memory only; the success
1220
+ // path below persists it. Carry the prior record's participants when
1221
+ // present so the reopened session keeps its roster.
1222
+ resolvedRecord = {
1223
+ adapter: key.adapter,
1224
+ workspace: key.workspace,
1225
+ chat: key.chat,
1226
+ thread: key.thread,
1227
+ sessionId: resumeTarget.sessionId,
1228
+ sessionFile: resumeTarget.sessionFile,
1229
+ participants: (record?.participants ?? []) as ChannelParticipant[],
1230
+ lastInboundAt: now(),
1231
+ }
1232
+ }
1086
1233
  const phase = resolvedRecord?.sessionId === undefined ? 'cold-start' : 'rehydrate'
1087
1234
  logger.info(`[channels] ${keyId}: ensureLive begin (${phase})`)
1088
1235
  const participants = (resolvedRecord?.participants ?? []) as ChannelParticipant[]
@@ -1181,6 +1328,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1181
1328
  currentTurnAuthorId: null,
1182
1329
  currentTurnAuthorIds: new Set(),
1183
1330
  currentTurnReactionRef: null,
1331
+ currentTurnTypingThread: null,
1184
1332
  currentTurnEngageReactions: [],
1185
1333
  // `lastTurnAuthorId` (string, used for `lastInboundAuthorId` in
1186
1334
  // origin) and `lastTurnAuthorIds` (Set, used by
@@ -1204,6 +1352,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1204
1352
  inFlightToolSends: new Map(),
1205
1353
  policyDeniedToolSendsThisTurn: new Map(),
1206
1354
  skippedTurn: null,
1355
+ skipLockedSendTurn: null,
1207
1356
  pendingQuoteCandidate: null,
1208
1357
  recentEngagedPeerBotTurns: [],
1209
1358
  consecutiveEngagedPeerBotTurns: 0,
@@ -1213,10 +1362,22 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1213
1362
  destroyed: false,
1214
1363
  unsubProviderErrors: null,
1215
1364
  unsubTypingActivity: null,
1365
+ unsubTodoOutcome: null,
1216
1366
  }
1217
1367
  live.unsubProviderErrors = subscribeProviderErrors(created.session, (err) => {
1218
1368
  logger.error(`[channels] ${live.keyId}: LLM call failed: ${err.message}`)
1219
1369
  })
1370
+ live.unsubTodoOutcome = created.session.subscribe((event: unknown) => {
1371
+ const usage = extractTurnUsage(event)
1372
+ if (usage === null) return
1373
+ void recordTurnOutcome({
1374
+ agentDir: options.agentDir,
1375
+ origin: buildLiveOrigin(live),
1376
+ turnId: live.sessionId,
1377
+ stopReason: usage.stopReason,
1378
+ ...(usage.tokens !== undefined ? { tokens: usage.tokens } : {}),
1379
+ }).catch((err) => logger.error(`[channels] ${live.keyId}: todo outcome capture failed: ${describe(err)}`))
1380
+ })
1220
1381
  live.unsubTypingActivity = subscribeTypingActivity(created.session, live)
1221
1382
  installChannelReplyTerminalHook(live)
1222
1383
  installChannelOutputCap(live)
@@ -1308,6 +1469,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1308
1469
  if (item.kind === 'message') {
1309
1470
  observed.push({
1310
1471
  text: item.message.text,
1472
+ ...(item.message.referenceContext !== undefined ? { referenceContext: item.message.referenceContext } : {}),
1311
1473
  authorId: item.message.authorId,
1312
1474
  authorName: item.message.authorName,
1313
1475
  authorIsBot: item.message.isBot,
@@ -1366,6 +1528,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1366
1528
  workspace: live.key.workspace,
1367
1529
  chat: live.key.chat,
1368
1530
  thread: live.key.thread,
1531
+ ...(live.currentTurnTypingThread !== null ? { typingThread: live.currentTurnTypingThread } : {}),
1369
1532
  phase,
1370
1533
  }
1371
1534
  await Promise.all(
@@ -1505,6 +1668,36 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1505
1668
  }
1506
1669
  }
1507
1670
 
1671
+ const recordTodoTurnStart = async (live: LiveSession, isRealUserTurn: boolean): Promise<void> => {
1672
+ try {
1673
+ await recordTurnStart({ agentDir: options.agentDir, origin: buildLiveOrigin(live), isRealUserTurn })
1674
+ } catch (err) {
1675
+ logger.warn(`[channels] ${live.keyId}: todo turn-start failed: ${describe(err)}`)
1676
+ }
1677
+ }
1678
+
1679
+ // After the drain queue empties, push at most one continuation reminder into
1680
+ // pendingSystemReminders. The enclosing drain `while` re-checks that array,
1681
+ // so the reminder is picked up as a batch-empty (injected, non-user) turn in
1682
+ // the same drain pass. The episode guard bounds how many times this can
1683
+ // re-fire; a reminder-only turn records isRealUserTurn=false so it never
1684
+ // resets the budget.
1685
+ const maybeContinueTodosChannel = async (live: LiveSession): Promise<void> => {
1686
+ if (live.destroyed) return
1687
+ if (live.promptQueue.length > 0 || live.pendingSystemReminders.length > 0) return
1688
+ try {
1689
+ await runIdleContinuation({
1690
+ agentDir: options.agentDir,
1691
+ origin: buildLiveOrigin(live),
1692
+ deliver: (text) => {
1693
+ live.pendingSystemReminders.push(text)
1694
+ },
1695
+ })
1696
+ } catch (err) {
1697
+ logger.warn(`[channels] ${live.keyId}: todo continuation failed: ${describe(err)}`)
1698
+ }
1699
+ }
1700
+
1508
1701
  const fireSessionTurnStart = async (live: LiveSession, userPrompt: string): Promise<void> => {
1509
1702
  if (!live.hooks) return
1510
1703
  try {
@@ -1595,6 +1788,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1595
1788
  live.currentTurnAuthorId = batch[batch.length - 1]!.authorId
1596
1789
  live.currentTurnAuthorIds = new Set(batch.map((m) => m.authorId))
1597
1790
  live.currentTurnReactionRef = batch[batch.length - 1]!.reactionRef ?? null
1791
+ live.currentTurnTypingThread = batch[batch.length - 1]!.typingThread ?? null
1598
1792
  live.currentTurnEngageReactions = batch.flatMap((m) =>
1599
1793
  m.engageReaction !== undefined ? [m.engageReaction] : [],
1600
1794
  )
@@ -1648,7 +1842,9 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1648
1842
  const engageAddPromises = live.currentTurnEngageReactions
1649
1843
  live.turnSeq++
1650
1844
  live.successfulSendsAtTurnStart = successfulSendsBeforePrompt
1845
+ live.skipLockedSendTurn = null
1651
1846
  live.policyDeniedToolSendsThisTurn.clear()
1847
+ const isRealUserTurn = batch.length > 0
1652
1848
  await fireSessionTurnStart(live, text)
1653
1849
  try {
1654
1850
  await live.session.prompt(text)
@@ -1665,6 +1861,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1665
1861
  await fireSessionTurnEnd(live)
1666
1862
  }
1667
1863
  await fireSessionIdle(live)
1864
+ await recordTodoTurnStart(live, isRealUserTurn)
1865
+ await maybeContinueTodosChannel(live)
1668
1866
  live.lastTurnAuthorIds = new Set(live.currentTurnAuthorIds)
1669
1867
  if (live.currentTurnAuthorId !== null) {
1670
1868
  live.lastTurnAuthorId = live.currentTurnAuthorId
@@ -1677,7 +1875,10 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1677
1875
  live.currentTurnReactionRef = null
1678
1876
  live.currentTurnEngageReactions = []
1679
1877
  live.currentTurnAttachments = []
1878
+ // Reset AFTER stopTypingHeartbeat: its final 'stop' tick reads the anchor
1879
+ // to clear a flat-DM status; clearing it first would strand the indicator.
1680
1880
  await stopTypingHeartbeat(live)
1881
+ live.currentTurnTypingThread = null
1681
1882
  }
1682
1883
  }
1683
1884
 
@@ -1858,6 +2059,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1858
2059
  }
1859
2060
  // Session-less commands (e.g. /help) are informational and run without a
1860
2061
  // live session; their handler reply is posted straight back to the channel.
2062
+ // `wantsLiveSession` commands (/restart) resolve an existing session when
2063
+ // present but do not abort when absent.
1861
2064
  let existingLive: LiveSession | null = null
1862
2065
  if (commandInfo.requiresLiveSession) {
1863
2066
  existingLive = liveSessions.get(keyId) ?? null
@@ -1865,11 +2068,21 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1865
2068
  logger.info(`[channels] ${keyId}: ignoring command /${parsedCommand.name} with no live session`)
1866
2069
  return
1867
2070
  }
2071
+ } else if (commandInfo.wantsLiveSession) {
2072
+ const candidate = liveSessions.get(keyId) ?? null
2073
+ existingLive = candidate !== null && !candidate.destroyed ? candidate : null
1868
2074
  }
1869
2075
  const commandResult = await runChannelCommand(event, existingLive)
1870
2076
  if (commandResult.kind !== 'not-command') return
1871
2077
  }
1872
2078
 
2079
+ // If a boot restart-resume reservation is pending for this key, mark that a
2080
+ // real inbound arrived: ensureLive below will coalesce onto the reservation
2081
+ // (via its `creating` seed), and the reservation's resume() will skip the
2082
+ // synthetic wake since this inbound already triggers the turn.
2083
+ const reservation = restartReservations.get(channelKeyId(key))
2084
+ if (reservation !== undefined) reservation.sawInbound = true
2085
+
1873
2086
  const live = await ensureLive(key, event.externalMessageId, event.authorId)
1874
2087
 
1875
2088
  const isNewAuthor = !live.participants.some((p) => p.authorId === event.authorId)
@@ -2013,6 +2226,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2013
2226
  const observe = (live: LiveSession, event: InboundMessage): void => {
2014
2227
  live.contextBuffer.push({
2015
2228
  text: event.text,
2229
+ ...(event.referenceContext !== undefined ? { referenceContext: event.referenceContext } : {}),
2016
2230
  ...(event.attachments !== undefined && event.attachments.length > 0 ? { attachments: event.attachments } : {}),
2017
2231
  authorId: event.authorId,
2018
2232
  authorName: event.authorName,
@@ -2033,6 +2247,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2033
2247
  ): void => {
2034
2248
  live.promptQueue.push({
2035
2249
  text: event.text,
2250
+ ...(event.referenceContext !== undefined ? { referenceContext: event.referenceContext } : {}),
2036
2251
  ...(event.attachments !== undefined && event.attachments.length > 0 ? { attachments: event.attachments } : {}),
2037
2252
  authorId: event.authorId,
2038
2253
  authorName: event.authorName,
@@ -2043,9 +2258,14 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2043
2258
  isBotMention: event.isBotMention,
2044
2259
  replyToBotMessageId: event.replyToBotMessageId,
2045
2260
  isDm: event.isDm,
2261
+ ...(event.typingThread !== undefined ? { typingThread: event.typingThread } : {}),
2046
2262
  receivedAt: now(),
2047
2263
  ts: event.ts,
2048
2264
  })
2265
+ // Make the typing anchor live BEFORE startTypingHeartbeat fires (route()
2266
+ // starts the heartbeat right after enqueue, ahead of drain). drain() later
2267
+ // refreshes it to the last inbound of a coalesced batch.
2268
+ if (event.typingThread !== undefined) live.currentTurnTypingThread = event.typingThread
2049
2269
  }
2050
2270
 
2051
2271
  const registerOutbound = (adapter: ChannelKey['adapter'], cb: OutboundCallback): void => {
@@ -2328,6 +2548,30 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2328
2548
  return lastError
2329
2549
  }
2330
2550
 
2551
+ const registerReviewThreadResolver = (adapter: ChannelKey['adapter'], resolver: ReviewThreadResolver): void => {
2552
+ reviewThreadResolvers.set(adapter, resolver)
2553
+ }
2554
+
2555
+ const unregisterReviewThreadResolver = (adapter: ChannelKey['adapter'], resolver: ReviewThreadResolver): void => {
2556
+ if (reviewThreadResolvers.get(adapter) === resolver) {
2557
+ reviewThreadResolvers.delete(adapter)
2558
+ }
2559
+ }
2560
+
2561
+ const resolveReviewThread = async (req: ReviewThreadResolveRequest): Promise<ReviewThreadResolveResult> => {
2562
+ const resolver = reviewThreadResolvers.get(req.adapter)
2563
+ if (resolver === undefined) {
2564
+ return {
2565
+ ok: false,
2566
+ error: `adapter "${req.adapter}" does not support review-thread resolution`,
2567
+ code: 'unsupported',
2568
+ }
2569
+ }
2570
+ return await resolver(req).catch(
2571
+ (err): ReviewThreadResolveResult => ({ ok: false, error: describe(err), code: 'transient' }),
2572
+ )
2573
+ }
2574
+
2331
2575
  const lookupInboundAttachment = (args: ChannelKey & { id: number }): InboundAttachment | null => {
2332
2576
  const live = liveSessions.get(channelKeyId(args))
2333
2577
  if (live === undefined) return null
@@ -2459,7 +2703,11 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2459
2703
  // violation: the model already committed to silence. Reject before any
2460
2704
  // state mutation so the model gets a clear error and the channel stays
2461
2705
  // silent. System-source sends (recovery, role-claim) are not affected.
2706
+ // Record the contested skip so `validateChannelTurn` doesn't ALSO drop the
2707
+ // reply text on the floor — the live send stays denied, but the post-turn
2708
+ // recovery net must still surface what the model wanted to say.
2462
2709
  if (live.skippedTurn !== null && live.skippedTurn.turnSeq === live.turnSeq) {
2710
+ live.skipLockedSendTurn = live.turnSeq
2463
2711
  return denyPolicyToolSend(SKIP_RESPONSE_LOCK_ERROR, 'skip-locked')
2464
2712
  }
2465
2713
  const currentCount = live.consecutiveSends.get(sendKey) ?? 0
@@ -2482,6 +2730,13 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2482
2730
  reserved = true
2483
2731
  }
2484
2732
 
2733
+ // The adapter needs the typing anchor to clear a flat-DM status (msg.thread
2734
+ // is null there, so a thread-keyed clear would no-op). Kept off msg.thread
2735
+ // to leave reply threading untouched.
2736
+ if (live?.currentTurnTypingThread != null && msg.typingThread === undefined) {
2737
+ msg = { ...msg, typingThread: live.currentTurnTypingThread }
2738
+ }
2739
+
2485
2740
  // Snapshot the callbacks before iterating so a callback that mutates the
2486
2741
  // set (e.g. unregisters mid-send) does not cause the iterator to skip
2487
2742
  // siblings or trip into surprising behavior.
@@ -2561,19 +2816,30 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2561
2816
  }
2562
2817
 
2563
2818
  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.
2819
+ // `skip_response` short-circuit. Honoring it bypasses recovery entirely.
2568
2820
  // Stale-flag protection: only honor when stamped on the just-completed
2569
2821
  // turn. A flag set by a previous turn that crashed before validation
2570
2822
  // would otherwise drop the next legitimate user-facing reply.
2571
- if (live.skippedTurn !== null && live.skippedTurn.turnSeq === live.turnSeq) {
2823
+ //
2824
+ // Contested-skip carve-out: if the model ALSO attempted a tool-source send
2825
+ // this turn (denied `skip-locked` in `send()`, stamped on `skipLockedSendTurn`),
2826
+ // the skip is no longer a clean opt-out — the model produced reply text it
2827
+ // wanted delivered. The live send stays denied, but we must NOT also suppress
2828
+ // recovery, or the reply is silently dropped with nothing to retry it (the
2829
+ // inbound is already drained). Fall through to the normal recovery path, which
2830
+ // posts it via `source:'system'` under the existing NO_REPLY / leak guards.
2831
+ const skipContested = live.skipLockedSendTurn === live.turnSeq
2832
+ if (live.skippedTurn !== null && live.skippedTurn.turnSeq === live.turnSeq && !skipContested) {
2572
2833
  const { reason } = live.skippedTurn
2573
2834
  live.skippedTurn = null
2574
2835
  logger.info(`[channels] ${live.keyId} skipped_by_tool reason=${JSON.stringify(reason)}`)
2575
2836
  return
2576
2837
  }
2838
+ if (live.skippedTurn !== null && live.skippedTurn.turnSeq === live.turnSeq) {
2839
+ // Clear the now-contested skip so it can't leak into a later turn's check.
2840
+ live.skippedTurn = null
2841
+ logger.info(`[channels] ${live.keyId} skip_contested_by_send recovering reply`)
2842
+ }
2577
2843
  if (live.successfulChannelSends > successfulSendsBeforePrompt) return
2578
2844
 
2579
2845
  const candidate = recoverableAssistantText(live.session)
@@ -2695,6 +2961,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2695
2961
  live.unsubProviderErrors = null
2696
2962
  live.unsubTypingActivity?.()
2697
2963
  live.unsubTypingActivity = null
2964
+ live.unsubTodoOutcome?.()
2965
+ live.unsubTodoOutcome = null
2698
2966
  await stopTypingHeartbeat(live)
2699
2967
  try {
2700
2968
  await live.session.abort()
@@ -2772,6 +3040,127 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2772
3040
  }
2773
3041
  }
2774
3042
 
3043
+ // Boot-time resume for a restart that originated from a channel session, in
3044
+ // two phases to close the race with adapters that begin receiving inbounds.
3045
+ //
3046
+ // PHASE 1 — reserveRestartHandoff(handoff): called BEFORE the adapters start.
3047
+ // It seeds a per-key entry in `creating` so any inbound that arrives during
3048
+ // boot coalesces onto the (not-yet-run) resume instead of stale-rolling the
3049
+ // mapping or creating a competing session. It does NOT touch resolvers or
3050
+ // outbound callbacks (not registered yet) — it only installs the gate.
3051
+ //
3052
+ // PHASE 2 — reservation.resume(): called AFTER channelManager.start(), when
3053
+ // adapters (and thus resolvers + the outbound callback the wake reply needs)
3054
+ // are ready. It removes its own `creating` seed, reopens the exact session
3055
+ // via ensureLive(resumeTarget) (bypassing stale-rollover, persisting only on
3056
+ // success), and — only if no real inbound coalesced in the meantime — arms
3057
+ // the restart-kick suppressor and enqueues the synthetic wake. If an inbound
3058
+ // did arrive, that inbound is the wake, so the synthetic one is skipped to
3059
+ // avoid a duplicate/spurious "I'm back" turn.
3060
+ //
3061
+ // The `typeclaw.restart-self` entry is already in the reopened JSONL (the
3062
+ // dying container appended it on the restart broadcast), so reopening the
3063
+ // file is what produces the greeting; adapter readiness only matters for
3064
+ // delivering the eventual reply.
3065
+ const reserveRestartHandoff = (handoff: RestartHandoff): RestartReservation | null => {
3066
+ if (handoff.origin.kind !== 'channel') return null
3067
+ const key: ChannelKey = {
3068
+ adapter: handoff.origin.key.adapter,
3069
+ workspace: handoff.origin.key.workspace,
3070
+ chat: handoff.origin.key.chat,
3071
+ thread: handoff.origin.key.thread,
3072
+ }
3073
+ const keyId = channelKeyId(key)
3074
+
3075
+ if (options.configForAdapter(key.adapter) === undefined) {
3076
+ logger.warn(`[channels] ${keyId}: restart-resume skipped — adapter not configured`)
3077
+ return null
3078
+ }
3079
+
3080
+ let resolveGate!: (live: LiveSession) => void
3081
+ let rejectGate!: (err: unknown) => void
3082
+ const gate = new Promise<LiveSession>((res, rej) => {
3083
+ resolveGate = res
3084
+ rejectGate = rej
3085
+ })
3086
+ // Seed `creating` so a racing inbound's ensureLive awaits this gate rather
3087
+ // than starting its own create. Suppress an unhandled-rejection warning on
3088
+ // the skip/failure paths that never get an inbound waiter.
3089
+ creating.set(keyId, gate)
3090
+ gate.catch(() => undefined)
3091
+
3092
+ const reservation: RestartReservation = {
3093
+ keyId,
3094
+ sawInbound: false,
3095
+ resume: async () => {
3096
+ // Drop our own seed BEFORE calling ensureLive, or ensureLive would
3097
+ // await the gate we are about to resolve and deadlock.
3098
+ if (creating.get(keyId) === gate) creating.delete(keyId)
3099
+ restartReservations.delete(keyId)
3100
+
3101
+ await ensureLoaded()
3102
+ const record = mappings ? findRecord(mappings, key) : undefined
3103
+ if (record?.sessionId !== handoff.originatingSessionId) {
3104
+ logger.warn(
3105
+ `[channels] ${keyId}: restart-resume skipped — persisted session ` +
3106
+ `${record?.sessionId ?? '<none>'} no longer matches handoff ${handoff.originatingSessionId}`,
3107
+ )
3108
+ rejectGate(new StaleLiveSessionError(keyId))
3109
+ return
3110
+ }
3111
+
3112
+ let live: LiveSession
3113
+ try {
3114
+ live = await ensureLive(key, undefined, undefined, {
3115
+ sessionId: handoff.originatingSessionId,
3116
+ sessionFile: handoff.originatingSessionFile,
3117
+ })
3118
+ } catch (err) {
3119
+ logger.warn(`[channels] ${keyId}: restart-resume ensureLive failed: ${describe(err)}`)
3120
+ rejectGate(err)
3121
+ return
3122
+ }
3123
+ resolveGate(live)
3124
+
3125
+ if (live.sessionId !== handoff.originatingSessionId) {
3126
+ logger.warn(
3127
+ `[channels] ${keyId}: restart-resume reopened a different session ` +
3128
+ `(${live.sessionId} != ${handoff.originatingSessionId}); skipping wake`,
3129
+ )
3130
+ return
3131
+ }
3132
+
3133
+ // A real inbound coalesced onto the reservation during boot: it is the
3134
+ // wake. Adding the synthetic "I'm back" turn on top would duplicate
3135
+ // work / stack a spurious turn, so skip it and let the inbound drain.
3136
+ if (reservation.sawInbound) {
3137
+ logger.info(`[channels] ${keyId}: restart-resume coalesced with a real inbound; skipping synthetic wake`)
3138
+ return
3139
+ }
3140
+
3141
+ await armRestartKickForOrigin(options.agentDir, buildLiveOrigin(live)).catch((err) =>
3142
+ logger.error(`[channels] ${keyId}: restart-resume arm restart-kick failed: ${describe(err)}`),
3143
+ )
3144
+
3145
+ live.pendingSystemReminders.push(RESTART_RESUME_WAKE_REMINDER)
3146
+ logger.info(`[channels] ${keyId}: restart-resume waking session ${live.sessionId}`)
3147
+ void drain(live)
3148
+ },
3149
+ }
3150
+ restartReservations.set(keyId, reservation)
3151
+ return reservation
3152
+ }
3153
+
3154
+ // Reserve + resume in one call, for callers (and tests) that run after the
3155
+ // adapters are already started and so don't need the pre-start gate. Still
3156
+ // benefits from the reservation's sawInbound suppression for inbounds that
3157
+ // race between reserve and resume.
3158
+ const resumeRestartHandoff = async (handoff: RestartHandoff): Promise<void> => {
3159
+ const reservation = reserveRestartHandoff(handoff)
3160
+ if (reservation === null) return
3161
+ await reservation.resume()
3162
+ }
3163
+
2775
3164
  const executeCommand = async (
2776
3165
  key: ChannelKey,
2777
3166
  name: string,
@@ -2815,6 +3204,11 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2815
3204
  return { kind: 'ambiguous', matchCount: resolved.count }
2816
3205
  }
2817
3206
  live = resolved.session
3207
+ } else if (commandInfo.wantsLiveSession) {
3208
+ // Best-effort: resolve a session if exactly one matches, but never fail
3209
+ // the command when absent or ambiguous — /restart still bounces.
3210
+ const resolved = resolveLiveSessionForCommand(liveSessions, key)
3211
+ live = resolved.kind === 'found' ? resolved.session : null
2818
3212
  }
2819
3213
  const result = await commands.execute(`/${lowered}`, { live, event: null })
2820
3214
  if (result.kind === 'handled') {
@@ -2828,6 +3222,47 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2828
3222
  return { kind: 'unknown-command', name: lowered }
2829
3223
  }
2830
3224
 
3225
+ const deliverCompletionReminder = (
3226
+ live: LiveSession,
3227
+ args: {
3228
+ parentSessionId: string
3229
+ subagent: string
3230
+ taskId: string
3231
+ ok: boolean
3232
+ durationMs: number
3233
+ error?: string
3234
+ },
3235
+ ): { kind: 'delivered'; keyId: string } => {
3236
+ const text = renderSubagentCompletionReminder({
3237
+ subagent: args.subagent,
3238
+ taskId: args.taskId,
3239
+ ok: args.ok,
3240
+ durationMs: args.durationMs,
3241
+ ...(args.error !== undefined ? { error: args.error } : {}),
3242
+ channel: true,
3243
+ })
3244
+ live.pendingSystemReminders.push(text)
3245
+ // The reminder tells the agent to fetch this result now; clear the
3246
+ // subagent_output window so an earlier premature-polling streak can't
3247
+ // hard-block that legitimate fetch.
3248
+ forgetSharedLoopGuardTool(live.sessionId, SUBAGENT_OUTPUT_TOOL_NAME)
3249
+ logger.info(`[channels] ${live.keyId}: subagent-completion reminder queued task=${args.taskId} ok=${args.ok}`)
3250
+ // Wake the drain loop. If a turn is already in flight, the wakeup is
3251
+ // a no-op because drain() will pick up the reminder on its next
3252
+ // iteration (it now gates on promptQueue OR pendingSystemReminders).
3253
+ // If the session is idle, fire drain() immediately rather than going
3254
+ // through the debounce path — the reminder is not a user inbound,
3255
+ // so the "coalesce nearby inbounds" rationale for debouncing does
3256
+ // not apply. Mirrors the TUI path's `idle ? 'interrupt' : 'queue'`
3257
+ // semantics: the channel router doesn't have a `delivery: interrupt`
3258
+ // mechanism (no in-flight abort during a turn), but firing drain()
3259
+ // immediately is the equivalent for an idle session.
3260
+ if (!live.draining) {
3261
+ void drain(live)
3262
+ }
3263
+ return { kind: 'delivered', keyId: live.keyId }
3264
+ }
3265
+
2831
3266
  const injectSubagentCompletionReminder = (args: {
2832
3267
  parentSessionId: string
2833
3268
  subagent: string
@@ -2835,34 +3270,28 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2835
3270
  ok: boolean
2836
3271
  durationMs: number
2837
3272
  error?: string
3273
+ channelKey?: { adapter: string; workspace: string; chat: string; thread: string | null }
2838
3274
  }): { kind: 'delivered'; keyId: string } | { kind: 'no-live-session' } => {
2839
3275
  for (const live of liveSessions.values()) {
2840
3276
  if (live.destroyed) continue
2841
3277
  if (live.sessionId !== args.parentSessionId) continue
2842
- const text = renderSubagentCompletionReminder({
2843
- subagent: args.subagent,
2844
- taskId: args.taskId,
2845
- ok: args.ok,
2846
- durationMs: args.durationMs,
2847
- ...(args.error !== undefined ? { error: args.error } : {}),
2848
- channel: true,
2849
- })
2850
- live.pendingSystemReminders.push(text)
2851
- logger.info(`[channels] ${live.keyId}: subagent-completion reminder queued task=${args.taskId} ok=${args.ok}`)
2852
- // Wake the drain loop. If a turn is already in flight, the wakeup is
2853
- // a no-op because drain() will pick up the reminder on its next
2854
- // iteration (it now gates on promptQueue OR pendingSystemReminders).
2855
- // If the session is idle, fire drain() immediately rather than going
2856
- // through the debounce path — the reminder is not a user inbound,
2857
- // so the "coalesce nearby inbounds" rationale for debouncing does
2858
- // not apply. Mirrors the TUI path's `idle ? 'interrupt' : 'queue'`
2859
- // semantics: the channel router doesn't have a `delivery: interrupt`
2860
- // mechanism (no in-flight abort during a turn), but firing drain()
2861
- // immediately is the equivalent for an idle session.
2862
- if (!live.draining) {
2863
- void drain(live)
3278
+ return deliverCompletionReminder(live, args)
3279
+ }
3280
+ // The exact parent session is gone. If the subagent was spawned from a
3281
+ // channel session, the conversation may have rolled over
3282
+ // (SESSION_FRESHNESS_TTL_MS) or been idle-evicted onto a fresh sessionId
3283
+ // for the same channel key while the subagent ran. Fall back to the live
3284
+ // successor for that key so a finished review/result still surfaces
3285
+ // instead of being silently dropped.
3286
+ if (args.channelKey !== undefined) {
3287
+ const targetKeyId = channelKeyId(args.channelKey)
3288
+ const successor = liveSessions.get(targetKeyId)
3289
+ if (successor !== undefined && !successor.destroyed) {
3290
+ logger.info(
3291
+ `[channels] ${targetKeyId}: subagent-completion reminder rerouted to live successor (parent ${args.parentSessionId} gone) task=${args.taskId}`,
3292
+ )
3293
+ return deliverCompletionReminder(successor, args)
2864
3294
  }
2865
- return { kind: 'delivered', keyId: live.keyId }
2866
3295
  }
2867
3296
  return { kind: 'no-live-session' }
2868
3297
  }
@@ -2922,12 +3351,17 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2922
3351
  registerFetchAttachment,
2923
3352
  unregisterFetchAttachment,
2924
3353
  fetchAttachment,
3354
+ registerReviewThreadResolver,
3355
+ unregisterReviewThreadResolver,
3356
+ resolveReviewThread,
2925
3357
  lookupInboundAttachment,
2926
3358
  listInboundAttachmentIds,
2927
3359
  executeCommand,
2928
3360
  getSelfAliases: computeSelfAliases,
2929
3361
  injectSubagentCompletionReminder,
2930
3362
  markTurnSkipped,
3363
+ reserveRestartHandoff,
3364
+ resumeRestartHandoff,
2931
3365
  stop,
2932
3366
  tearDownAllLive,
2933
3367
  liveCount: () => liveSessions.size,
@@ -3132,7 +3566,7 @@ function composeTurnPrompt(
3132
3566
  if (observed.length > 0) {
3133
3567
  parts.push('## Recent context (not addressed to you, for awareness only)')
3134
3568
  for (const o of observed) {
3135
- parts.push(formatAuthorLine(o.ts, adapter, o.authorId, o.authorName, o.authorIsBot, o.text))
3569
+ parts.push(formatInboundPromptLines(o, adapter))
3136
3570
  }
3137
3571
  parts.push('')
3138
3572
  }
@@ -3150,7 +3584,7 @@ function composeTurnPrompt(
3150
3584
  )
3151
3585
  }
3152
3586
  for (const b of batch) {
3153
- parts.push(formatAuthorLine(b.ts, adapter, b.authorId, b.authorName, b.authorIsBot, b.text))
3587
+ parts.push(formatInboundPromptLines(b, adapter))
3154
3588
  }
3155
3589
  }
3156
3590
  return parts.join('\n')
@@ -3169,6 +3603,24 @@ function formatAuthorLine(
3169
3603
  return `${stamp}${formatAuthorReference(adapter, authorId, authorName)} (${authorName})${tag}: ${text}`
3170
3604
  }
3171
3605
 
3606
+ function formatInboundPromptLines(
3607
+ inbound: {
3608
+ ts: number
3609
+ authorId: string
3610
+ authorName: string
3611
+ authorIsBot: boolean
3612
+ text: string
3613
+ referenceContext?: InboundReferenceContext
3614
+ },
3615
+ adapter: AdapterId,
3616
+ ): string {
3617
+ const lines = inbound.referenceContext?.sources.map(renderQuoteAnchor) ?? []
3618
+ lines.push(
3619
+ formatAuthorLine(inbound.ts, adapter, inbound.authorId, inbound.authorName, inbound.authorIsBot, inbound.text),
3620
+ )
3621
+ return lines.join('\n')
3622
+ }
3623
+
3172
3624
  export type { QuoteAnchorSource } from './types'
3173
3625
 
3174
3626
  // Picks the right author syntax for the platform so prompts and rendered
@@ -3755,10 +4207,10 @@ export function isLikelyKimiChannelToolLeak(text: string): boolean {
3755
4207
  return KIMI_CHANNEL_TOOL_ID_RE.test(text)
3756
4208
  }
3757
4209
 
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
4210
+ // Detects the *plain-text* shape of a leaked tool invocation — the model
4211
+ // serialized a tool call as ordinary prose instead of producing a real tool
4212
+ // call. Observed against Kimi-family deployments on KakaoTalk: the entire
4213
+ // assistant message body is literally
3762
4214
  //
3763
4215
  // channel_reply({"text":"<the user-facing greeting the bot meant to send>"})
3764
4216
  //
@@ -3768,18 +4220,27 @@ export function isLikelyKimiChannelToolLeak(text: string): boolean {
3768
4220
  // serialization straight to the channel, which is exactly what
3769
4221
  // users see in the reported screenshots.
3770
4222
  //
4223
+ // `skip_response` belongs here too, and is the more insidious case: the model
4224
+ // means to *decline* the turn but serializes the decision as prose —
4225
+ //
4226
+ // skip_response({ reason: "Empty messages, no content to respond to" })
4227
+ //
4228
+ // Because the recovery path treats this as ordinary assistant text, the bot
4229
+ // posts its own "I'm staying silent" plumbing to the channel, the exact
4230
+ // opposite of the intended no-op. It is never a legitimate user-facing reply.
4231
+ //
3771
4232
  // 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.
4233
+ // *start* with `channel_reply(`, `channel_send(`, or `skip_response(`, and
4234
+ // that opening paren must enclose at least one `"` (the serialized argument).
4235
+ // This deliberately matches the leak shape while letting prose that merely
4236
+ // *mentions* a tool name (e.g. "I would normally call channel_reply here
4237
+ // but...") reach the user — that false-positive class is already locked in by
4238
+ // the `still recovers prose that mentions channel_reply` test.
3778
4239
  //
3779
4240
  // The trailing close paren is NOT required: the model sometimes truncates
3780
4241
  // mid-serialization, and a half-leaked `channel_reply({"text":"..."` is
3781
4242
  // just as user-hostile as the full shape.
3782
- const PLAIN_TEXT_CHANNEL_TOOL_CALL_RE = /^channel_(?:reply|send)\s*\(\s*[^)]*"/
4243
+ const PLAIN_TEXT_CHANNEL_TOOL_CALL_RE = /^(?:channel_(?:reply|send)|skip_response)\s*\(\s*[^)]*"/
3783
4244
 
3784
4245
  export function isLikelyPlainTextChannelToolCall(text: string): boolean {
3785
4246
  return PLAIN_TEXT_CHANNEL_TOOL_CALL_RE.test(text.trim())