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