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.
- package/package.json +1 -1
- package/src/agent/index.ts +91 -22
- package/src/agent/plugin-tools.ts +38 -2
- package/src/agent/restart/index.ts +15 -3
- package/src/agent/restart-handoff/index.ts +110 -12
- package/src/agent/session-origin.ts +41 -2
- package/src/agent/subagent-completion-reminder.ts +3 -1
- package/src/agent/subagents.ts +44 -1
- package/src/agent/system-prompt.ts +4 -0
- package/src/agent/todo/continuation-policy.ts +242 -0
- package/src/agent/todo/continuation-state.ts +87 -0
- package/src/agent/todo/continuation-wiring.ts +113 -0
- package/src/agent/todo/continuation.ts +71 -0
- package/src/agent/todo/scope.ts +77 -0
- package/src/agent/todo/store.ts +98 -0
- package/src/agent/tool-not-found-nudge.ts +119 -0
- package/src/agent/tools/channel-reply.ts +51 -0
- package/src/agent/tools/restart.ts +11 -4
- package/src/agent/tools/todo/index.ts +119 -0
- package/src/bundled-plugins/backup/runner.ts +1 -1
- package/src/bundled-plugins/memory/memory-logger.ts +28 -10
- package/src/bundled-plugins/reviewer/reviewer.ts +14 -0
- package/src/channels/adapters/discord-bot-reference.ts +78 -0
- package/src/channels/adapters/discord-bot.ts +31 -3
- package/src/channels/adapters/github/inbound.ts +161 -10
- package/src/channels/adapters/github/index.ts +18 -0
- package/src/channels/adapters/github/review-thread-resolver.ts +246 -0
- package/src/channels/adapters/kakaotalk-classify.ts +67 -6
- package/src/channels/adapters/slack-bot-classify.ts +9 -1
- package/src/channels/adapters/slack-bot-reference.ts +129 -0
- package/src/channels/adapters/slack-bot.ts +75 -8
- package/src/channels/adapters/telegram-bot.ts +11 -0
- package/src/channels/manager.ts +8 -2
- package/src/channels/router.ts +477 -22
- package/src/channels/schema.ts +20 -4
- package/src/channels/types.ts +95 -0
- package/src/cli/inspect-controller.ts +99 -0
- package/src/cli/inspect.ts +21 -123
- package/src/commands/index.ts +9 -0
- package/src/init/gitignore.ts +5 -2
- package/src/inspect/index.ts +30 -26
- package/src/inspect/live.ts +17 -3
- package/src/inspect/loop.ts +23 -17
- package/src/run/index.ts +60 -5
- package/src/sandbox/build.ts +10 -0
- package/src/sandbox/index.ts +2 -0
- package/src/sandbox/policy.ts +10 -0
- package/src/sandbox/writable-zones.ts +78 -0
- package/src/server/index.ts +118 -4
- package/src/skills/typeclaw-channel-github/SKILL.md +34 -7
- package/src/skills/typeclaw-config/SKILL.md +1 -1
- package/src/skills/typeclaw-git/SKILL.md +1 -1
- package/typeclaw.schema.json +10 -0
package/src/channels/router.ts
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
|
3727
|
-
//
|
|
3728
|
-
//
|
|
3729
|
-
//
|
|
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 `
|
|
3741
|
-
// must enclose at least one `"` (the
|
|
3742
|
-
// matches the leak shape while letting prose that merely
|
|
3743
|
-
// tool name (e.g. "I would normally call channel_reply here
|
|
3744
|
-
// the user — that false-positive class is already locked in by
|
|
3745
|
-
// `still recovers
|
|
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())
|