typeclaw 0.22.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 (53) 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/session-origin.ts +41 -2
  7. package/src/agent/subagent-completion-reminder.ts +3 -1
  8. package/src/agent/subagents.ts +44 -1
  9. package/src/agent/system-prompt.ts +4 -0
  10. package/src/agent/todo/continuation-policy.ts +242 -0
  11. package/src/agent/todo/continuation-state.ts +87 -0
  12. package/src/agent/todo/continuation-wiring.ts +113 -0
  13. package/src/agent/todo/continuation.ts +71 -0
  14. package/src/agent/todo/scope.ts +77 -0
  15. package/src/agent/todo/store.ts +98 -0
  16. package/src/agent/tool-not-found-nudge.ts +119 -0
  17. package/src/agent/tools/channel-reply.ts +51 -0
  18. package/src/agent/tools/restart.ts +11 -4
  19. package/src/agent/tools/todo/index.ts +119 -0
  20. package/src/bundled-plugins/backup/runner.ts +1 -1
  21. package/src/bundled-plugins/memory/memory-logger.ts +28 -10
  22. package/src/bundled-plugins/reviewer/reviewer.ts +14 -0
  23. package/src/channels/adapters/discord-bot-reference.ts +78 -0
  24. package/src/channels/adapters/discord-bot.ts +31 -3
  25. package/src/channels/adapters/github/inbound.ts +161 -10
  26. package/src/channels/adapters/github/index.ts +18 -0
  27. package/src/channels/adapters/github/review-thread-resolver.ts +246 -0
  28. package/src/channels/adapters/kakaotalk-classify.ts +67 -6
  29. package/src/channels/adapters/slack-bot-classify.ts +9 -1
  30. package/src/channels/adapters/slack-bot-reference.ts +129 -0
  31. package/src/channels/adapters/slack-bot.ts +75 -8
  32. package/src/channels/adapters/telegram-bot.ts +11 -0
  33. package/src/channels/manager.ts +8 -2
  34. package/src/channels/router.ts +477 -22
  35. package/src/channels/schema.ts +20 -4
  36. package/src/channels/types.ts +95 -0
  37. package/src/cli/inspect-controller.ts +99 -0
  38. package/src/cli/inspect.ts +21 -123
  39. package/src/commands/index.ts +9 -0
  40. package/src/init/gitignore.ts +5 -2
  41. package/src/inspect/index.ts +30 -26
  42. package/src/inspect/live.ts +17 -3
  43. package/src/inspect/loop.ts +23 -17
  44. package/src/run/index.ts +60 -5
  45. package/src/sandbox/build.ts +10 -0
  46. package/src/sandbox/index.ts +2 -0
  47. package/src/sandbox/policy.ts +10 -0
  48. package/src/sandbox/writable-zones.ts +78 -0
  49. package/src/server/index.ts +118 -4
  50. package/src/skills/typeclaw-channel-github/SKILL.md +34 -7
  51. package/src/skills/typeclaw-config/SKILL.md +1 -1
  52. package/src/skills/typeclaw-git/SKILL.md +1 -1
  53. 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'
@@ -43,6 +51,8 @@ import type {
43
51
  ChannelHistoryMessage,
44
52
  ChannelKey,
45
53
  ChannelNameResolver,
54
+ ChannelSelfIdentity,
55
+ ChannelSelfIdentityResolver,
46
56
  FetchAttachmentArgs,
47
57
  FetchAttachmentCallback,
48
58
  FetchAttachmentResult,
@@ -51,6 +61,7 @@ import type {
51
61
  HistoryCallback,
52
62
  InboundAttachment,
53
63
  InboundMessage,
64
+ InboundReferenceContext,
54
65
  RemoveReactionCallback,
55
66
  RemoveReactionRequest,
56
67
  OutboundCallback,
@@ -61,6 +72,9 @@ import type {
61
72
  ReactionRequest,
62
73
  ReactionResult,
63
74
  ResolvedChannelNames,
75
+ ReviewThreadResolveRequest,
76
+ ReviewThreadResolveResult,
77
+ ReviewThreadResolver,
64
78
  SendErrorCode,
65
79
  SendResult,
66
80
  TypingCallback,
@@ -115,6 +129,23 @@ export const SESSION_GC_INTERVAL_MS = 60 * 1000
115
129
  // recovery paths (`source: 'system'`) bypass.
116
130
  export const MAX_CHANNEL_SENDS_PER_TURN = 10
117
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')
118
149
  // Ceiling on tool-source channel sends that a same-turn router policy DENIED
119
150
  // without delivering — `skip-locked`, `turn-cap`, or `duplicate`. Such denials
120
151
  // return a soft error and do NOT increment `consecutiveSends`, so a model that
@@ -278,6 +309,7 @@ export type ConfigForAdapter = (adapter: ChannelKey['adapter']) => ChannelAdapte
278
309
 
279
310
  type QueuedInbound = {
280
311
  text: string
312
+ referenceContext?: InboundReferenceContext
281
313
  attachments?: readonly InboundAttachment[]
282
314
  authorId: string
283
315
  authorName: string
@@ -288,6 +320,7 @@ type QueuedInbound = {
288
320
  isBotMention: boolean
289
321
  replyToBotMessageId: string | null
290
322
  isDm: boolean
323
+ typingThread?: string
291
324
  receivedAt: number
292
325
  // Original platform timestamp (Slack/Discord), in ms since epoch. Used
293
326
  // by composeTurnPrompt to render an ISO 8601 prefix on each line so the
@@ -299,6 +332,7 @@ type QueuedInbound = {
299
332
 
300
333
  type ObservedInbound = {
301
334
  text: string
335
+ referenceContext?: InboundReferenceContext
302
336
  attachments?: readonly InboundAttachment[]
303
337
  authorId: string
304
338
  authorName: string
@@ -356,6 +390,11 @@ type LiveSession = {
356
390
  // origin so `channel_react` reacts to the triggering message, not whichever
357
391
  // inbound happens to be latest in the queue. Null on reminder-only turns.
358
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
359
398
  // One engage-:eyes:-add promise per inbound coalesced into THIS turn, each
360
399
  // resolving to its removable per-instance ref (or null). A debounced turn can
361
400
  // batch several inbounds that each got their own :eyes:, so every entry is
@@ -443,9 +482,25 @@ type LiveSession = {
443
482
  // with the current `turnSeq`. Read at the top of `validateChannelTurn`:
444
483
  // if it matches the just-completed turn, recovery is skipped entirely
445
484
  // (no NO_REPLY check, no Kimi leak check, no assistant-text recovery).
446
- // The model has explicitly opted out of this turn and we honor that
447
- // 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.
448
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
449
504
  // Captured by drain() at batch dequeue; read+cleared by send() on the
450
505
  // first tool-source send of the turn. The anchor decision (delay
451
506
  // threshold + intervening-observed check) is evaluated at SEND time
@@ -472,6 +527,7 @@ type LiveSession = {
472
527
  destroyed: boolean
473
528
  unsubProviderErrors: (() => void) | null
474
529
  unsubTypingActivity: (() => void) | null
530
+ unsubTodoOutcome: (() => void) | null
475
531
  }
476
532
 
477
533
  // `event` is null for command invocations that originated outside the inbound
@@ -553,6 +609,13 @@ export type ChannelRouter = {
553
609
  unregisterTyping: (adapter: ChannelKey['adapter'], cb: TypingCallback) => void
554
610
  registerChannelNameResolver: (adapter: ChannelKey['adapter'], resolver: ChannelNameResolver) => void
555
611
  unregisterChannelNameResolver: (adapter: ChannelKey['adapter'], resolver: ChannelNameResolver) => void
612
+ // Self-identity is a per-adapter singleton (one bot account per adapter),
613
+ // so unlike the multi-resolver registries above this is last-write-wins:
614
+ // register overwrites, unregister clears only if the current resolver is
615
+ // the one being removed (guards against a late stop() of a replaced adapter
616
+ // wiping a fresh registration).
617
+ registerSelfIdentity: (adapter: ChannelKey['adapter'], resolver: ChannelSelfIdentityResolver) => void
618
+ unregisterSelfIdentity: (adapter: ChannelKey['adapter'], resolver: ChannelSelfIdentityResolver) => void
556
619
  registerMembership: (adapter: ChannelKey['adapter'], resolver: MembershipResolver) => void
557
620
  unregisterMembership: (adapter: ChannelKey['adapter'], resolver: MembershipResolver) => void
558
621
  registerHistory: (adapter: ChannelKey['adapter'], cb: HistoryCallback) => void
@@ -561,6 +624,14 @@ export type ChannelRouter = {
561
624
  registerFetchAttachment: (adapter: ChannelKey['adapter'], cb: FetchAttachmentCallback) => void
562
625
  unregisterFetchAttachment: (adapter: ChannelKey['adapter'], cb: FetchAttachmentCallback) => void
563
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>
564
635
  lookupInboundAttachment: (args: ChannelKey & { id: number }) => InboundAttachment | null
565
636
  listInboundAttachmentIds: (args: ChannelKey) => readonly number[]
566
637
  // Execute a command by name against an existing live session, bypassing
@@ -626,6 +697,17 @@ export type ChannelRouter = {
626
697
  | { kind: 'recorded'; keyId: string }
627
698
  | { kind: 'recorded-after-send'; keyId: string }
628
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>
629
711
  stop: () => Promise<void>
630
712
  tearDownAllLive: () => Promise<void>
631
713
  liveCount: () => number
@@ -730,7 +812,18 @@ export type CreateChannelRouterOptions = {
730
812
  // confirmation string (the container exits shortly after, so the reply is
731
813
  // best-effort).
732
814
  onReload?: () => Promise<string>
733
- 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 }
734
827
  }
735
828
 
736
829
  export type ClaimHandlerInput = {
@@ -747,6 +840,16 @@ export type ClaimHandlerOutcome =
747
840
  | { kind: 'fail'; reply: string }
748
841
  | { kind: 'fallthrough' }
749
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
+
750
853
  export type ClaimHandler = (input: ClaimHandlerInput) => Promise<ClaimHandlerOutcome>
751
854
 
752
855
  const GRANT_ALL_PERMISSIONS: PermissionService = {
@@ -771,6 +874,14 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
771
874
  const onRestart = options.onRestart
772
875
  const liveSessions = new Map<string, LiveSession>()
773
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>()
774
885
  // Bumped by tearDownAllLive() and stop() before they tear sessions down. An
775
886
  // in-flight ensureLive() captures the value at creation start and re-checks
776
887
  // it right before installing into liveSessions; if it changed, a teardown
@@ -785,9 +896,11 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
785
896
  const typingCallbacks = new Map<ChannelKey['adapter'], Set<TypingCallback>>()
786
897
  const channelNameResolvers = new Map<ChannelKey['adapter'], Set<ChannelNameResolver>>()
787
898
  const membershipResolvers = new Map<ChannelKey['adapter'], Set<MembershipResolver>>()
899
+ const selfIdentityResolvers = new Map<ChannelKey['adapter'], ChannelSelfIdentityResolver>()
788
900
  const membershipCaches = new Map<ChannelKey['adapter'], MembershipCache>()
789
901
  const historyCallbacks = new Map<ChannelKey['adapter'], Set<HistoryCallback>>()
790
902
  const fetchAttachmentCallbacks = new Map<ChannelKey['adapter'], Set<FetchAttachmentCallback>>()
903
+ const reviewThreadResolvers = new Map<ChannelKey['adapter'], ReviewThreadResolver>()
791
904
  const stickyLedger = new StickyLedger()
792
905
  // The /help handler reads the live registry to enumerate commands, so it
793
906
  // forward-references `commands`. Safe at runtime — the handler only runs on
@@ -832,7 +945,22 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
832
945
  description: 'Restart the typeclaw container.',
833
946
  permission: 'session.admin',
834
947
  requiresLiveSession: false,
835
- 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
+ }),
836
964
  })
837
965
  }
838
966
  const commands = createCommandRegistry<ChannelCommandContext>(channelCommands)
@@ -987,10 +1115,20 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
987
1115
  key: ChannelKey,
988
1116
  triggeringMessageId?: string,
989
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 },
990
1125
  ): Promise<LiveSession> => {
991
1126
  const keyId = channelKeyId(key)
992
1127
  const existing = liveSessions.get(keyId)
993
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
994
1132
  const idleMs = now() - existing.lastInboundAt
995
1133
  // `lastInboundAt` is only bumped on engaged inbounds (see route()),
996
1134
  // so a session whose drain loop has been compiling a slow reply for
@@ -1045,6 +1183,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1045
1183
  const record = mappings ? findRecord(mappings, key) : undefined
1046
1184
  let resolvedRecord = record
1047
1185
  if (
1186
+ resumeTarget === undefined &&
1048
1187
  record?.sessionId !== undefined &&
1049
1188
  existing === undefined &&
1050
1189
  now() - (record.lastInboundAt ?? 0) > SESSION_FRESHNESS_TTL_MS
@@ -1073,6 +1212,21 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1073
1212
  }
1074
1213
  }
1075
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
+ }
1076
1230
  const phase = resolvedRecord?.sessionId === undefined ? 'cold-start' : 'rehydrate'
1077
1231
  logger.info(`[channels] ${keyId}: ensureLive begin (${phase})`)
1078
1232
  const participants = (resolvedRecord?.participants ?? []) as ChannelParticipant[]
@@ -1088,6 +1242,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1088
1242
  // channel.respond gate just admitted on. Per-turn updates after this
1089
1243
  // point are handled by `originRef.current = buildLiveOrigin(live)`
1090
1244
  // before each prompt() call.
1245
+ const self = resolveSelfIdentity(key)
1091
1246
  const origin: SessionOrigin = {
1092
1247
  kind: 'channel',
1093
1248
  adapter: key.adapter,
@@ -1099,6 +1254,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1099
1254
  ...(triggeringAuthorId !== undefined ? { lastInboundAuthorId: triggeringAuthorId } : {}),
1100
1255
  participants,
1101
1256
  ...(membership !== null ? { membership } : {}),
1257
+ ...(self !== undefined ? { self } : {}),
1102
1258
  }
1103
1259
 
1104
1260
  const isColdStart = resolvedRecord?.sessionId === undefined
@@ -1169,6 +1325,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1169
1325
  currentTurnAuthorId: null,
1170
1326
  currentTurnAuthorIds: new Set(),
1171
1327
  currentTurnReactionRef: null,
1328
+ currentTurnTypingThread: null,
1172
1329
  currentTurnEngageReactions: [],
1173
1330
  // `lastTurnAuthorId` (string, used for `lastInboundAuthorId` in
1174
1331
  // origin) and `lastTurnAuthorIds` (Set, used by
@@ -1192,6 +1349,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1192
1349
  inFlightToolSends: new Map(),
1193
1350
  policyDeniedToolSendsThisTurn: new Map(),
1194
1351
  skippedTurn: null,
1352
+ skipLockedSendTurn: null,
1195
1353
  pendingQuoteCandidate: null,
1196
1354
  recentEngagedPeerBotTurns: [],
1197
1355
  consecutiveEngagedPeerBotTurns: 0,
@@ -1201,10 +1359,22 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1201
1359
  destroyed: false,
1202
1360
  unsubProviderErrors: null,
1203
1361
  unsubTypingActivity: null,
1362
+ unsubTodoOutcome: null,
1204
1363
  }
1205
1364
  live.unsubProviderErrors = subscribeProviderErrors(created.session, (err) => {
1206
1365
  logger.error(`[channels] ${live.keyId}: LLM call failed: ${err.message}`)
1207
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
+ })
1208
1378
  live.unsubTypingActivity = subscribeTypingActivity(created.session, live)
1209
1379
  installChannelReplyTerminalHook(live)
1210
1380
  installChannelOutputCap(live)
@@ -1296,6 +1466,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1296
1466
  if (item.kind === 'message') {
1297
1467
  observed.push({
1298
1468
  text: item.message.text,
1469
+ ...(item.message.referenceContext !== undefined ? { referenceContext: item.message.referenceContext } : {}),
1299
1470
  authorId: item.message.authorId,
1300
1471
  authorName: item.message.authorName,
1301
1472
  authorIsBot: item.message.isBot,
@@ -1354,6 +1525,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1354
1525
  workspace: live.key.workspace,
1355
1526
  chat: live.key.chat,
1356
1527
  thread: live.key.thread,
1528
+ ...(live.currentTurnTypingThread !== null ? { typingThread: live.currentTurnTypingThread } : {}),
1357
1529
  phase,
1358
1530
  }
1359
1531
  await Promise.all(
@@ -1493,6 +1665,36 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1493
1665
  }
1494
1666
  }
1495
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
+
1496
1698
  const fireSessionTurnStart = async (live: LiveSession, userPrompt: string): Promise<void> => {
1497
1699
  if (!live.hooks) return
1498
1700
  try {
@@ -1522,6 +1724,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1522
1724
 
1523
1725
  const buildLiveOrigin = (live: LiveSession): SessionOrigin => {
1524
1726
  const membership = readMembership(live.key)
1727
+ const self = resolveSelfIdentity(live.key)
1525
1728
  return {
1526
1729
  kind: 'channel',
1527
1730
  adapter: live.key.adapter,
@@ -1534,6 +1737,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1534
1737
  ...(live.currentTurnReactionRef !== null ? { reactionRef: live.currentTurnReactionRef } : {}),
1535
1738
  participants: live.participants,
1536
1739
  ...(membership !== null ? { membership } : {}),
1740
+ ...(self !== undefined ? { self } : {}),
1537
1741
  }
1538
1742
  }
1539
1743
 
@@ -1581,6 +1785,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1581
1785
  live.currentTurnAuthorId = batch[batch.length - 1]!.authorId
1582
1786
  live.currentTurnAuthorIds = new Set(batch.map((m) => m.authorId))
1583
1787
  live.currentTurnReactionRef = batch[batch.length - 1]!.reactionRef ?? null
1788
+ live.currentTurnTypingThread = batch[batch.length - 1]!.typingThread ?? null
1584
1789
  live.currentTurnEngageReactions = batch.flatMap((m) =>
1585
1790
  m.engageReaction !== undefined ? [m.engageReaction] : [],
1586
1791
  )
@@ -1634,7 +1839,9 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1634
1839
  const engageAddPromises = live.currentTurnEngageReactions
1635
1840
  live.turnSeq++
1636
1841
  live.successfulSendsAtTurnStart = successfulSendsBeforePrompt
1842
+ live.skipLockedSendTurn = null
1637
1843
  live.policyDeniedToolSendsThisTurn.clear()
1844
+ const isRealUserTurn = batch.length > 0
1638
1845
  await fireSessionTurnStart(live, text)
1639
1846
  try {
1640
1847
  await live.session.prompt(text)
@@ -1651,6 +1858,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1651
1858
  await fireSessionTurnEnd(live)
1652
1859
  }
1653
1860
  await fireSessionIdle(live)
1861
+ await recordTodoTurnStart(live, isRealUserTurn)
1862
+ await maybeContinueTodosChannel(live)
1654
1863
  live.lastTurnAuthorIds = new Set(live.currentTurnAuthorIds)
1655
1864
  if (live.currentTurnAuthorId !== null) {
1656
1865
  live.lastTurnAuthorId = live.currentTurnAuthorId
@@ -1663,7 +1872,10 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1663
1872
  live.currentTurnReactionRef = null
1664
1873
  live.currentTurnEngageReactions = []
1665
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.
1666
1877
  await stopTypingHeartbeat(live)
1878
+ live.currentTurnTypingThread = null
1667
1879
  }
1668
1880
  }
1669
1881
 
@@ -1844,6 +2056,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1844
2056
  }
1845
2057
  // Session-less commands (e.g. /help) are informational and run without a
1846
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.
1847
2061
  let existingLive: LiveSession | null = null
1848
2062
  if (commandInfo.requiresLiveSession) {
1849
2063
  existingLive = liveSessions.get(keyId) ?? null
@@ -1851,11 +2065,21 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1851
2065
  logger.info(`[channels] ${keyId}: ignoring command /${parsedCommand.name} with no live session`)
1852
2066
  return
1853
2067
  }
2068
+ } else if (commandInfo.wantsLiveSession) {
2069
+ const candidate = liveSessions.get(keyId) ?? null
2070
+ existingLive = candidate !== null && !candidate.destroyed ? candidate : null
1854
2071
  }
1855
2072
  const commandResult = await runChannelCommand(event, existingLive)
1856
2073
  if (commandResult.kind !== 'not-command') return
1857
2074
  }
1858
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
+
1859
2083
  const live = await ensureLive(key, event.externalMessageId, event.authorId)
1860
2084
 
1861
2085
  const isNewAuthor = !live.participants.some((p) => p.authorId === event.authorId)
@@ -1999,6 +2223,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1999
2223
  const observe = (live: LiveSession, event: InboundMessage): void => {
2000
2224
  live.contextBuffer.push({
2001
2225
  text: event.text,
2226
+ ...(event.referenceContext !== undefined ? { referenceContext: event.referenceContext } : {}),
2002
2227
  ...(event.attachments !== undefined && event.attachments.length > 0 ? { attachments: event.attachments } : {}),
2003
2228
  authorId: event.authorId,
2004
2229
  authorName: event.authorName,
@@ -2019,6 +2244,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2019
2244
  ): void => {
2020
2245
  live.promptQueue.push({
2021
2246
  text: event.text,
2247
+ ...(event.referenceContext !== undefined ? { referenceContext: event.referenceContext } : {}),
2022
2248
  ...(event.attachments !== undefined && event.attachments.length > 0 ? { attachments: event.attachments } : {}),
2023
2249
  authorId: event.authorId,
2024
2250
  authorName: event.authorName,
@@ -2029,9 +2255,14 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2029
2255
  isBotMention: event.isBotMention,
2030
2256
  replyToBotMessageId: event.replyToBotMessageId,
2031
2257
  isDm: event.isDm,
2258
+ ...(event.typingThread !== undefined ? { typingThread: event.typingThread } : {}),
2032
2259
  receivedAt: now(),
2033
2260
  ts: event.ts,
2034
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
2035
2266
  }
2036
2267
 
2037
2268
  const registerOutbound = (adapter: ChannelKey['adapter'], cb: OutboundCallback): void => {
@@ -2201,6 +2432,22 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2201
2432
  channelNameResolvers.get(adapter)?.delete(resolver)
2202
2433
  }
2203
2434
 
2435
+ const registerSelfIdentity = (adapter: ChannelKey['adapter'], resolver: ChannelSelfIdentityResolver): void => {
2436
+ selfIdentityResolvers.set(adapter, resolver)
2437
+ }
2438
+
2439
+ const unregisterSelfIdentity = (adapter: ChannelKey['adapter'], resolver: ChannelSelfIdentityResolver): void => {
2440
+ if (selfIdentityResolvers.get(adapter) === resolver) {
2441
+ selfIdentityResolvers.delete(adapter)
2442
+ }
2443
+ }
2444
+
2445
+ const resolveSelfIdentity = (key: ChannelKey): ChannelSelfIdentity | undefined => {
2446
+ const resolver = selfIdentityResolvers.get(key.adapter)
2447
+ if (resolver === undefined) return undefined
2448
+ return resolver(key.workspace) ?? undefined
2449
+ }
2450
+
2204
2451
  const registerMembership = (adapter: ChannelKey['adapter'], resolver: MembershipResolver): void => {
2205
2452
  let set = membershipResolvers.get(adapter)
2206
2453
  if (!set) {
@@ -2298,6 +2545,30 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2298
2545
  return lastError
2299
2546
  }
2300
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
+
2301
2572
  const lookupInboundAttachment = (args: ChannelKey & { id: number }): InboundAttachment | null => {
2302
2573
  const live = liveSessions.get(channelKeyId(args))
2303
2574
  if (live === undefined) return null
@@ -2429,7 +2700,11 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2429
2700
  // violation: the model already committed to silence. Reject before any
2430
2701
  // state mutation so the model gets a clear error and the channel stays
2431
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.
2432
2706
  if (live.skippedTurn !== null && live.skippedTurn.turnSeq === live.turnSeq) {
2707
+ live.skipLockedSendTurn = live.turnSeq
2433
2708
  return denyPolicyToolSend(SKIP_RESPONSE_LOCK_ERROR, 'skip-locked')
2434
2709
  }
2435
2710
  const currentCount = live.consecutiveSends.get(sendKey) ?? 0
@@ -2452,6 +2727,13 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2452
2727
  reserved = true
2453
2728
  }
2454
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
+
2455
2737
  // Snapshot the callbacks before iterating so a callback that mutates the
2456
2738
  // set (e.g. unregisters mid-send) does not cause the iterator to skip
2457
2739
  // siblings or trip into surprising behavior.
@@ -2531,19 +2813,30 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2531
2813
  }
2532
2814
 
2533
2815
  const validateChannelTurn = async (live: LiveSession, successfulSendsBeforePrompt: number): Promise<void> => {
2534
- // `skip_response` short-circuit. Must run before the `successfulChannelSends`
2535
- // check so a model that called both `skip_response` and a channel tool in
2536
- // the same turn still resolves as "skipped" — the rejection inside `send()`
2537
- // means the channel tool returned an error and never actually delivered.
2816
+ // `skip_response` short-circuit. Honoring it bypasses recovery entirely.
2538
2817
  // Stale-flag protection: only honor when stamped on the just-completed
2539
2818
  // turn. A flag set by a previous turn that crashed before validation
2540
2819
  // would otherwise drop the next legitimate user-facing reply.
2541
- 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) {
2542
2830
  const { reason } = live.skippedTurn
2543
2831
  live.skippedTurn = null
2544
2832
  logger.info(`[channels] ${live.keyId} skipped_by_tool reason=${JSON.stringify(reason)}`)
2545
2833
  return
2546
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
+ }
2547
2840
  if (live.successfulChannelSends > successfulSendsBeforePrompt) return
2548
2841
 
2549
2842
  const candidate = recoverableAssistantText(live.session)
@@ -2665,6 +2958,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2665
2958
  live.unsubProviderErrors = null
2666
2959
  live.unsubTypingActivity?.()
2667
2960
  live.unsubTypingActivity = null
2961
+ live.unsubTodoOutcome?.()
2962
+ live.unsubTodoOutcome = null
2668
2963
  await stopTypingHeartbeat(live)
2669
2964
  try {
2670
2965
  await live.session.abort()
@@ -2742,6 +3037,127 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2742
3037
  }
2743
3038
  }
2744
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
+
2745
3161
  const executeCommand = async (
2746
3162
  key: ChannelKey,
2747
3163
  name: string,
@@ -2785,6 +3201,11 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2785
3201
  return { kind: 'ambiguous', matchCount: resolved.count }
2786
3202
  }
2787
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
2788
3209
  }
2789
3210
  const result = await commands.execute(`/${lowered}`, { live, event: null })
2790
3211
  if (result.kind === 'handled') {
@@ -2882,6 +3303,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2882
3303
  unregisterTyping,
2883
3304
  registerChannelNameResolver,
2884
3305
  unregisterChannelNameResolver,
3306
+ registerSelfIdentity,
3307
+ unregisterSelfIdentity,
2885
3308
  registerMembership,
2886
3309
  unregisterMembership,
2887
3310
  registerHistory,
@@ -2890,12 +3313,17 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2890
3313
  registerFetchAttachment,
2891
3314
  unregisterFetchAttachment,
2892
3315
  fetchAttachment,
3316
+ registerReviewThreadResolver,
3317
+ unregisterReviewThreadResolver,
3318
+ resolveReviewThread,
2893
3319
  lookupInboundAttachment,
2894
3320
  listInboundAttachmentIds,
2895
3321
  executeCommand,
2896
3322
  getSelfAliases: computeSelfAliases,
2897
3323
  injectSubagentCompletionReminder,
2898
3324
  markTurnSkipped,
3325
+ reserveRestartHandoff,
3326
+ resumeRestartHandoff,
2899
3327
  stop,
2900
3328
  tearDownAllLive,
2901
3329
  liveCount: () => liveSessions.size,
@@ -3100,7 +3528,7 @@ function composeTurnPrompt(
3100
3528
  if (observed.length > 0) {
3101
3529
  parts.push('## Recent context (not addressed to you, for awareness only)')
3102
3530
  for (const o of observed) {
3103
- parts.push(formatAuthorLine(o.ts, adapter, o.authorId, o.authorName, o.authorIsBot, o.text))
3531
+ parts.push(formatInboundPromptLines(o, adapter))
3104
3532
  }
3105
3533
  parts.push('')
3106
3534
  }
@@ -3118,7 +3546,7 @@ function composeTurnPrompt(
3118
3546
  )
3119
3547
  }
3120
3548
  for (const b of batch) {
3121
- parts.push(formatAuthorLine(b.ts, adapter, b.authorId, b.authorName, b.authorIsBot, b.text))
3549
+ parts.push(formatInboundPromptLines(b, adapter))
3122
3550
  }
3123
3551
  }
3124
3552
  return parts.join('\n')
@@ -3137,6 +3565,24 @@ function formatAuthorLine(
3137
3565
  return `${stamp}${formatAuthorReference(adapter, authorId, authorName)} (${authorName})${tag}: ${text}`
3138
3566
  }
3139
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
+
3140
3586
  export type { QuoteAnchorSource } from './types'
3141
3587
 
3142
3588
  // Picks the right author syntax for the platform so prompts and rendered
@@ -3723,10 +4169,10 @@ export function isLikelyKimiChannelToolLeak(text: string): boolean {
3723
4169
  return KIMI_CHANNEL_TOOL_ID_RE.test(text)
3724
4170
  }
3725
4171
 
3726
- // Detects the *plain-text* shape of a leaked channel-tool invocation — the
3727
- // model serialized the tool call as ordinary prose instead of producing a
3728
- // real tool call. Observed against Kimi-family deployments on KakaoTalk:
3729
- // 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
3730
4176
  //
3731
4177
  // channel_reply({"text":"<the user-facing greeting the bot meant to send>"})
3732
4178
  //
@@ -3736,18 +4182,27 @@ export function isLikelyKimiChannelToolLeak(text: string): boolean {
3736
4182
  // serialization straight to the channel, which is exactly what
3737
4183
  // users see in the reported screenshots.
3738
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
+ //
3739
4194
  // Structural-only detection (NOT a substring search): the trimmed text must
3740
- // *start* with `channel_reply(` or `channel_send(`, and that opening paren
3741
- // must enclose at least one `"` (the JSON argument). This deliberately
3742
- // matches the leak shape while letting prose that merely *mentions* the
3743
- // tool name (e.g. "I would normally call channel_reply here but...") reach
3744
- // the user — that false-positive class is already locked in by the
3745
- // `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.
3746
4201
  //
3747
4202
  // The trailing close paren is NOT required: the model sometimes truncates
3748
4203
  // mid-serialization, and a half-leaked `channel_reply({"text":"..."` is
3749
4204
  // just as user-hostile as the full shape.
3750
- 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*[^)]*"/
3751
4206
 
3752
4207
  export function isLikelyPlainTextChannelToolCall(text: string): boolean {
3753
4208
  return PLAIN_TEXT_CHANNEL_TOOL_CALL_RE.test(text.trim())