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