typeclaw 0.18.0 → 0.20.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 +9 -1
- package/src/agent/live-subagents.ts +4 -0
- package/src/agent/model-overrides.ts +77 -0
- package/src/agent/plugin-tools.ts +53 -4
- package/src/agent/session-origin.ts +32 -10
- package/src/agent/tools/channel-react.ts +79 -0
- package/src/agent/tools/grant-role.ts +102 -8
- package/src/agent/tools/spawn-subagent.ts +1 -0
- package/src/agent/tools/subagent-access.ts +67 -0
- package/src/agent/tools/subagent-cancel.ts +11 -6
- package/src/agent/tools/subagent-output.ts +10 -2
- package/src/bundled-plugins/github-cli-auth/gh-command.ts +372 -0
- package/src/bundled-plugins/github-cli-auth/index.ts +42 -0
- package/src/bundled-plugins/github-cli-auth/token-class.ts +11 -0
- package/src/bundled-plugins/reviewer/skills/code-review.ts +18 -1
- package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +9 -2
- package/src/channels/adapters/discord-bot.ts +242 -7
- package/src/channels/adapters/github/inbound.ts +40 -55
- package/src/channels/adapters/github/index.ts +89 -18
- package/src/channels/adapters/github/membership.ts +4 -0
- package/src/channels/adapters/github/permission-guidance.ts +20 -1
- package/src/channels/adapters/github/reactions.ts +142 -0
- package/src/channels/adapters/slack-bot-slash-commands.ts +3 -1
- package/src/channels/adapters/slack-bot.ts +4 -4
- package/src/channels/commands.ts +10 -0
- package/src/channels/engagement.ts +30 -2
- package/src/channels/github-token-bridge.ts +42 -0
- package/src/channels/index.ts +6 -0
- package/src/channels/manager.ts +6 -0
- package/src/channels/membership.ts +9 -0
- package/src/channels/router.ts +295 -42
- package/src/channels/types.ts +42 -0
- package/src/cli/inspect.ts +3 -0
- package/src/cli/ui.ts +6 -0
- package/src/commands/index.ts +54 -4
- package/src/init/dockerfile.ts +60 -0
- package/src/init/validate-api-key.ts +15 -1
- package/src/inspect/loop.ts +12 -1
- package/src/permissions/permissions.ts +24 -0
- package/src/plugin/context.ts +8 -0
- package/src/plugin/manager.ts +3 -0
- package/src/plugin/types.ts +6 -0
- package/src/run/bundled-plugins.ts +9 -0
- package/src/run/index.ts +4 -0
- package/src/skills/typeclaw-channel-github/SKILL.md +80 -43
package/src/channels/router.ts
CHANGED
|
@@ -7,13 +7,21 @@ import { createSession, renderTurnRoleAnchor, renderTurnTimeAnchor, type AgentSe
|
|
|
7
7
|
import { subscribeProviderErrors } from '@/agent/provider-error'
|
|
8
8
|
import type { ChannelParticipant, SessionOrigin } from '@/agent/session-origin'
|
|
9
9
|
import { renderSubagentCompletionReminder } from '@/agent/subagent-completion-reminder'
|
|
10
|
-
import { createCommandRegistry } from '@/commands'
|
|
10
|
+
import { type Command, type CommandResult, createCommandRegistry } from '@/commands'
|
|
11
11
|
import { CORE_PERMISSIONS, type PermissionService } from '@/permissions'
|
|
12
12
|
import type { HookBus } from '@/plugin'
|
|
13
13
|
import { extractClaimCode } from '@/role-claim'
|
|
14
14
|
import type { Stream } from '@/stream'
|
|
15
15
|
|
|
16
|
-
import {
|
|
16
|
+
import { formatChannelCommandHelp } from './commands'
|
|
17
|
+
import {
|
|
18
|
+
countEffectiveHumans,
|
|
19
|
+
decideEngagement,
|
|
20
|
+
grantStickyForReplyTargets,
|
|
21
|
+
isMultiHumanGroup,
|
|
22
|
+
StickyLedger,
|
|
23
|
+
type EngagementDecision,
|
|
24
|
+
} from './engagement'
|
|
17
25
|
import {
|
|
18
26
|
MEMBERSHIP_COLD_FETCH_TIMEOUT_MS,
|
|
19
27
|
type MembershipCount,
|
|
@@ -46,6 +54,10 @@ import type {
|
|
|
46
54
|
OutboundCallback,
|
|
47
55
|
OutboundMessage,
|
|
48
56
|
QuoteAnchorSource,
|
|
57
|
+
ReactionCallback,
|
|
58
|
+
ReactionRef,
|
|
59
|
+
ReactionRequest,
|
|
60
|
+
ReactionResult,
|
|
49
61
|
ResolvedChannelNames,
|
|
50
62
|
SendErrorCode,
|
|
51
63
|
SendResult,
|
|
@@ -100,6 +112,7 @@ export const SESSION_GC_INTERVAL_MS = 60 * 1000
|
|
|
100
112
|
// Enforced inside router.send for `source: 'tool'` callers; system
|
|
101
113
|
// recovery paths (`source: 'system'`) bypass.
|
|
102
114
|
export const MAX_CHANNEL_SENDS_PER_TURN = 10
|
|
115
|
+
export const ENGAGE_REACTION_EMOJI = 'eyes'
|
|
103
116
|
// Ceiling on tool-source channel sends that a same-turn router policy DENIED
|
|
104
117
|
// without delivering — `skip-locked`, `turn-cap`, or `duplicate`. Such denials
|
|
105
118
|
// return a soft error and do NOT increment `consecutiveSends`, so a model that
|
|
@@ -268,6 +281,7 @@ type QueuedInbound = {
|
|
|
268
281
|
authorName: string
|
|
269
282
|
authorIsBot: boolean
|
|
270
283
|
externalMessageId: string
|
|
284
|
+
reactionRef?: ReactionRef
|
|
271
285
|
isBotMention: boolean
|
|
272
286
|
replyToBotMessageId: string | null
|
|
273
287
|
isDm: boolean
|
|
@@ -316,6 +330,14 @@ type LiveSession = {
|
|
|
316
330
|
originRef: { current: SessionOrigin | undefined }
|
|
317
331
|
promptQueue: QueuedInbound[]
|
|
318
332
|
contextBuffer: ObservedInbound[]
|
|
333
|
+
// Attachments of the messages composing the in-flight turn. drain()
|
|
334
|
+
// splices promptQueue/contextBuffer empty BEFORE calling prompt(), but
|
|
335
|
+
// the model only requests an attachment (look_at_channel_attachment /
|
|
336
|
+
// channel_fetch_attachment) DURING prompt() — by which point both queues
|
|
337
|
+
// are empty. This turn-scoped snapshot, populated right after the splice
|
|
338
|
+
// and cleared when the turn ends, is what the lookup reads so a freshly-
|
|
339
|
+
// arrived attachment stays resolvable for the whole turn it belongs to.
|
|
340
|
+
currentTurnAttachments: readonly InboundAttachment[]
|
|
319
341
|
draining: boolean
|
|
320
342
|
debounceTimer: ReturnType<typeof setTimeout> | null
|
|
321
343
|
typingTimer: ReturnType<typeof setInterval> | null
|
|
@@ -326,6 +348,11 @@ type LiveSession = {
|
|
|
326
348
|
firstUnprocessedAt: number
|
|
327
349
|
currentTurnAuthorId: string | null
|
|
328
350
|
currentTurnAuthorIds: Set<string>
|
|
351
|
+
// Reaction target of the inbound that triggered THIS turn (the last item in
|
|
352
|
+
// the drained batch, mirroring `currentTurnAuthorId`). Surfaced on the live
|
|
353
|
+
// origin so `channel_react` reacts to the triggering message, not whichever
|
|
354
|
+
// inbound happens to be latest in the queue. Null on reminder-only turns.
|
|
355
|
+
currentTurnReactionRef: ReactionRef | null
|
|
329
356
|
lastTurnAuthorIds: Set<string>
|
|
330
357
|
// Mirror of currentTurnAuthorId at end-of-turn (the LAST speaker of the
|
|
331
358
|
// prior batch), preserved across the drain finally-block which resets
|
|
@@ -429,6 +456,10 @@ type LiveSession = {
|
|
|
429
456
|
recentEngagedPeerBotTurns: { authorId: string; ts: number }[]
|
|
430
457
|
consecutiveEngagedPeerBotTurns: number
|
|
431
458
|
loopGuardActive: boolean
|
|
459
|
+
// Set in route() from the same membership+participants the engagement
|
|
460
|
+
// decision used, so the prompt nudge and sticky suppression agree on
|
|
461
|
+
// "is this a multi-human group". Read by composeTurnPrompt().
|
|
462
|
+
multiHumanGroup: boolean
|
|
432
463
|
membershipFetch: Promise<MembershipCount | null> | null
|
|
433
464
|
destroyed: boolean
|
|
434
465
|
unsubProviderErrors: (() => void) | null
|
|
@@ -439,14 +470,16 @@ type LiveSession = {
|
|
|
439
470
|
// pipeline (e.g. Discord native slash commands fired from listener.on
|
|
440
471
|
// ('interaction_create')). Handlers that need a real inbound — for some
|
|
441
472
|
// future hypothetical command like `/quote` — must guard on event !== null
|
|
442
|
-
// instead of assuming it.
|
|
473
|
+
// instead of assuming it. `live` is null for session-less commands
|
|
474
|
+
// (requiresLiveSession:false, e.g. /help); session-control handlers run only
|
|
475
|
+
// after the dispatch layer has resolved a live session, so they may assert it.
|
|
443
476
|
type ChannelCommandContext = {
|
|
444
|
-
live: LiveSession
|
|
477
|
+
live: LiveSession | null
|
|
445
478
|
event: InboundMessage | null
|
|
446
479
|
}
|
|
447
480
|
|
|
448
481
|
export type ExecuteCommandResult =
|
|
449
|
-
| { kind: 'handled'; name: string }
|
|
482
|
+
| { kind: 'handled'; name: string; reply?: string }
|
|
450
483
|
| { kind: 'unknown-command'; name: string }
|
|
451
484
|
| { kind: 'no-live-session' }
|
|
452
485
|
| { kind: 'permission-denied' }
|
|
@@ -497,6 +530,14 @@ export type ChannelRouter = {
|
|
|
497
530
|
}) => { count: number; windowMs: number }
|
|
498
531
|
registerOutbound: (adapter: ChannelKey['adapter'], cb: OutboundCallback) => void
|
|
499
532
|
unregisterOutbound: (adapter: ChannelKey['adapter'], cb: OutboundCallback) => void
|
|
533
|
+
// Reaction support is opt-in per adapter: an adapter that never calls
|
|
534
|
+
// registerReaction makes `react` resolve to `code: 'unsupported'`, and
|
|
535
|
+
// auto-react-on-engage becomes a silent no-op for it. Kept separate from
|
|
536
|
+
// the outbound path on purpose — reactions are best-effort side effects, not
|
|
537
|
+
// messages, so they must not flow through send()'s flood/cap/dup/sticky guards.
|
|
538
|
+
registerReaction: (adapter: ChannelKey['adapter'], cb: ReactionCallback) => void
|
|
539
|
+
unregisterReaction: (adapter: ChannelKey['adapter'], cb: ReactionCallback) => void
|
|
540
|
+
react: (req: ReactionRequest) => Promise<ReactionResult>
|
|
500
541
|
registerTyping: (adapter: ChannelKey['adapter'], cb: TypingCallback) => void
|
|
501
542
|
unregisterTyping: (adapter: ChannelKey['adapter'], cb: TypingCallback) => void
|
|
502
543
|
registerChannelNameResolver: (adapter: ChannelKey['adapter'], resolver: ChannelNameResolver) => void
|
|
@@ -689,6 +730,7 @@ export type ClaimHandler = (input: ClaimHandlerInput) => Promise<ClaimHandlerOut
|
|
|
689
730
|
const GRANT_ALL_PERMISSIONS: PermissionService = {
|
|
690
731
|
has: () => true,
|
|
691
732
|
resolveRole: () => 'owner',
|
|
733
|
+
compareRoleSeverity: () => 1,
|
|
692
734
|
describe: () => ({ role: 'owner', permissions: [CORE_PERMISSIONS.channelRespond] }),
|
|
693
735
|
replaceRoles: () => {},
|
|
694
736
|
}
|
|
@@ -714,6 +756,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
714
756
|
// teardown was meant to clear.
|
|
715
757
|
let liveGeneration = 0
|
|
716
758
|
const outboundCallbacks = new Map<ChannelKey['adapter'], Set<OutboundCallback>>()
|
|
759
|
+
const reactionCallbacks = new Map<ChannelKey['adapter'], Set<ReactionCallback>>()
|
|
717
760
|
const typingCallbacks = new Map<ChannelKey['adapter'], Set<TypingCallback>>()
|
|
718
761
|
const channelNameResolvers = new Map<ChannelKey['adapter'], Set<ChannelNameResolver>>()
|
|
719
762
|
const membershipResolvers = new Map<ChannelKey['adapter'], Set<MembershipResolver>>()
|
|
@@ -721,14 +764,31 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
721
764
|
const historyCallbacks = new Map<ChannelKey['adapter'], Set<HistoryCallback>>()
|
|
722
765
|
const fetchAttachmentCallbacks = new Map<ChannelKey['adapter'], Set<FetchAttachmentCallback>>()
|
|
723
766
|
const stickyLedger = new StickyLedger()
|
|
724
|
-
|
|
767
|
+
// The /help handler reads the live registry to enumerate commands, so it
|
|
768
|
+
// forward-references `commands`. Safe at runtime — the handler only runs on
|
|
769
|
+
// invocation, long after the assignment below completes.
|
|
770
|
+
const channelCommands: readonly Command<ChannelCommandContext>[] = [
|
|
771
|
+
{
|
|
772
|
+
name: 'help',
|
|
773
|
+
description: 'List available commands.',
|
|
774
|
+
permission: 'none',
|
|
775
|
+
requiresLiveSession: false,
|
|
776
|
+
handler: () => ({ reply: formatChannelCommandHelp(commands.list()) }),
|
|
777
|
+
},
|
|
725
778
|
{
|
|
726
779
|
name: 'stop',
|
|
780
|
+
description: 'Stop the current agent turn in this channel.',
|
|
781
|
+
permission: 'session.control',
|
|
782
|
+
requiresLiveSession: true,
|
|
727
783
|
handler: async ({ live }) => {
|
|
728
|
-
|
|
784
|
+
// requiresLiveSession:true guarantees the dispatch layer resolved a
|
|
785
|
+
// session before running this handler, so `live` is non-null here.
|
|
786
|
+
await stopCurrentChannelTurn(live!)
|
|
787
|
+
return { reply: 'Stopped the current turn.' }
|
|
729
788
|
},
|
|
730
789
|
},
|
|
731
|
-
]
|
|
790
|
+
]
|
|
791
|
+
const commands = createCommandRegistry<ChannelCommandContext>(channelCommands)
|
|
732
792
|
|
|
733
793
|
// Implicit dir-name alias: agent folder basename matches Docker
|
|
734
794
|
// container name (per AGENTS.md), the typical Discord/Slack bot
|
|
@@ -1050,6 +1110,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1050
1110
|
promptQueue: [],
|
|
1051
1111
|
pendingSystemReminders: [],
|
|
1052
1112
|
contextBuffer: [],
|
|
1113
|
+
currentTurnAttachments: [],
|
|
1053
1114
|
draining: false,
|
|
1054
1115
|
debounceTimer: null,
|
|
1055
1116
|
typingTimer: null,
|
|
@@ -1060,6 +1121,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1060
1121
|
firstUnprocessedAt: 0,
|
|
1061
1122
|
currentTurnAuthorId: null,
|
|
1062
1123
|
currentTurnAuthorIds: new Set(),
|
|
1124
|
+
currentTurnReactionRef: null,
|
|
1063
1125
|
// `lastTurnAuthorId` (string, used for `lastInboundAuthorId` in
|
|
1064
1126
|
// origin) and `lastTurnAuthorIds` (Set, used by
|
|
1065
1127
|
// `grantStickyForReplyTargets` as the fallback when
|
|
@@ -1086,6 +1148,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1086
1148
|
recentEngagedPeerBotTurns: [],
|
|
1087
1149
|
consecutiveEngagedPeerBotTurns: 0,
|
|
1088
1150
|
loopGuardActive: false,
|
|
1151
|
+
multiHumanGroup: false,
|
|
1089
1152
|
membershipFetch,
|
|
1090
1153
|
destroyed: false,
|
|
1091
1154
|
unsubProviderErrors: null,
|
|
@@ -1419,6 +1482,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1419
1482
|
...(live.resolvedNames.chatName !== undefined ? { chatName: live.resolvedNames.chatName } : {}),
|
|
1420
1483
|
thread: live.key.thread,
|
|
1421
1484
|
...(live.currentTurnAuthorId !== null ? { lastInboundAuthorId: live.currentTurnAuthorId } : {}),
|
|
1485
|
+
...(live.currentTurnReactionRef !== null ? { reactionRef: live.currentTurnReactionRef } : {}),
|
|
1422
1486
|
participants: live.participants,
|
|
1423
1487
|
...(membership !== null ? { membership } : {}),
|
|
1424
1488
|
}
|
|
@@ -1462,10 +1526,12 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1462
1526
|
const batch = live.promptQueue.splice(0, live.promptQueue.length)
|
|
1463
1527
|
const observed = live.contextBuffer.splice(0, live.contextBuffer.length)
|
|
1464
1528
|
const reminders = live.pendingSystemReminders.splice(0, live.pendingSystemReminders.length)
|
|
1529
|
+
live.currentTurnAttachments = collectTurnAttachments(observed, batch)
|
|
1465
1530
|
|
|
1466
1531
|
if (batch.length > 0) {
|
|
1467
1532
|
live.currentTurnAuthorId = batch[batch.length - 1]!.authorId
|
|
1468
1533
|
live.currentTurnAuthorIds = new Set(batch.map((m) => m.authorId))
|
|
1534
|
+
live.currentTurnReactionRef = batch[batch.length - 1]!.reactionRef ?? null
|
|
1469
1535
|
live.consecutiveSends.clear()
|
|
1470
1536
|
live.lastSentText.clear()
|
|
1471
1537
|
live.pendingQuoteCandidate = captureQuoteCandidate(live.key.adapter, batch, observed)
|
|
@@ -1499,6 +1565,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1499
1565
|
const text = composeTurnPrompt(observed, batch, {
|
|
1500
1566
|
adapter: live.key.adapter,
|
|
1501
1567
|
loopGuardActive: live.loopGuardActive,
|
|
1568
|
+
groupChatNudge: live.multiHumanGroup,
|
|
1502
1569
|
systemReminders: reminders,
|
|
1503
1570
|
role: liveRole,
|
|
1504
1571
|
})
|
|
@@ -1535,6 +1602,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1535
1602
|
live.draining = false
|
|
1536
1603
|
live.currentTurnAuthorId = null
|
|
1537
1604
|
live.currentTurnAuthorIds = new Set()
|
|
1605
|
+
live.currentTurnReactionRef = null
|
|
1606
|
+
live.currentTurnAttachments = []
|
|
1538
1607
|
await stopTypingHeartbeat(live)
|
|
1539
1608
|
}
|
|
1540
1609
|
}
|
|
@@ -1603,6 +1672,28 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1603
1672
|
}
|
|
1604
1673
|
}
|
|
1605
1674
|
|
|
1675
|
+
// Executes a parsed channel command and posts its reply (if any) back to the
|
|
1676
|
+
// originating channel. Shared by the pre-gate public-command fast path and the
|
|
1677
|
+
// post-gate command block so the execute→reply shape can't drift between them.
|
|
1678
|
+
// Gating (channel.respond / session.control) and live-session resolution stay
|
|
1679
|
+
// at the call sites — this helper only runs the handler and delivers the reply.
|
|
1680
|
+
const runChannelCommand = async (event: InboundMessage, live: LiveSession | null): Promise<CommandResult> => {
|
|
1681
|
+
const result = await commands.execute(event.text, { live, event })
|
|
1682
|
+
if (result.kind === 'handled' && result.reply !== undefined) {
|
|
1683
|
+
await send(
|
|
1684
|
+
{
|
|
1685
|
+
adapter: event.adapter,
|
|
1686
|
+
workspace: event.workspace,
|
|
1687
|
+
chat: event.chat,
|
|
1688
|
+
thread: event.thread,
|
|
1689
|
+
text: result.reply,
|
|
1690
|
+
},
|
|
1691
|
+
{ source: 'system' },
|
|
1692
|
+
)
|
|
1693
|
+
}
|
|
1694
|
+
return result
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1606
1697
|
const route = async (event: InboundMessage): Promise<void> => {
|
|
1607
1698
|
const adapterConfig = options.configForAdapter(event.adapter)
|
|
1608
1699
|
if (!adapterConfig) return
|
|
@@ -1650,6 +1741,25 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1650
1741
|
}
|
|
1651
1742
|
}
|
|
1652
1743
|
|
|
1744
|
+
// Parse once, here, so the public-command fast path (below) and the
|
|
1745
|
+
// post-gate command block share one parse and lookup.
|
|
1746
|
+
const parsedCommand = commands.parse(event.text)
|
|
1747
|
+
const commandInfo = parsedCommand === null ? undefined : commands.get(parsedCommand.name)
|
|
1748
|
+
|
|
1749
|
+
// Public-command fast path: a known command that is both ungated
|
|
1750
|
+
// (permission:'none') AND informational (requiresLiveSession:false) runs
|
|
1751
|
+
// BEFORE the channel.respond gate, mirroring the native-slash path where
|
|
1752
|
+
// such commands skip permissions entirely. Both conditions are required so
|
|
1753
|
+
// a future "public but live-session-aware" command can't silently bypass
|
|
1754
|
+
// the gate. It only reveals already-public command names — it never creates
|
|
1755
|
+
// a session or prompts the agent — so it is not a channel.respond bypass in
|
|
1756
|
+
// any meaningful sense. Unknown commands, /stop, //escaped text, and plain
|
|
1757
|
+
// messages all fall through to the gate unchanged.
|
|
1758
|
+
if (parsedCommand !== null && commandInfo?.permission === 'none' && !commandInfo.requiresLiveSession) {
|
|
1759
|
+
await runChannelCommand(event, null)
|
|
1760
|
+
return
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1653
1763
|
if (isChannelRespondDenied(event)) {
|
|
1654
1764
|
publishInbound(event, 'denied')
|
|
1655
1765
|
logger.info(
|
|
@@ -1658,27 +1768,31 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1658
1768
|
return
|
|
1659
1769
|
}
|
|
1660
1770
|
|
|
1661
|
-
const parsedCommand = commands.parse(event.text)
|
|
1662
1771
|
if (parsedCommand !== null) {
|
|
1663
1772
|
// Commands are control traffic, not engaged inbounds; if the session is stale,
|
|
1664
1773
|
// the next engaged inbound will perform the rollover before prompting.
|
|
1665
1774
|
const keyId = channelKeyId(key)
|
|
1666
|
-
if (
|
|
1775
|
+
if (commandInfo === undefined) {
|
|
1667
1776
|
logger.info(`[channels] ${keyId}: ignoring unknown command /${parsedCommand.name}`)
|
|
1668
1777
|
return
|
|
1669
1778
|
}
|
|
1670
|
-
if (isSessionControlDenied(event)) {
|
|
1779
|
+
if (commandInfo.permission === 'session.control' && isSessionControlDenied(event)) {
|
|
1671
1780
|
logger.info(
|
|
1672
1781
|
`[channels] ${keyId}: denied command /${parsedCommand.name} by permissions (session.control) author=${event.authorId}`,
|
|
1673
1782
|
)
|
|
1674
1783
|
return
|
|
1675
1784
|
}
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1785
|
+
// Session-less commands (e.g. /help) are informational and run without a
|
|
1786
|
+
// live session; their handler reply is posted straight back to the channel.
|
|
1787
|
+
let existingLive: LiveSession | null = null
|
|
1788
|
+
if (commandInfo.requiresLiveSession) {
|
|
1789
|
+
existingLive = liveSessions.get(keyId) ?? null
|
|
1790
|
+
if (existingLive === null || existingLive.destroyed) {
|
|
1791
|
+
logger.info(`[channels] ${keyId}: ignoring command /${parsedCommand.name} with no live session`)
|
|
1792
|
+
return
|
|
1793
|
+
}
|
|
1680
1794
|
}
|
|
1681
|
-
const commandResult = await
|
|
1795
|
+
const commandResult = await runChannelCommand(event, existingLive)
|
|
1682
1796
|
if (commandResult.kind !== 'not-command') return
|
|
1683
1797
|
}
|
|
1684
1798
|
|
|
@@ -1712,6 +1826,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1712
1826
|
|
|
1713
1827
|
const membership = await membershipForEngagement(live)
|
|
1714
1828
|
|
|
1829
|
+
live.multiHumanGroup = isMultiHumanGroup(event.isDm, countEffectiveHumans(live.participants, membership, now()))
|
|
1830
|
+
|
|
1715
1831
|
const decision: EngagementDecision = decideEngagement({
|
|
1716
1832
|
message: event,
|
|
1717
1833
|
config: adapterConfig.engagement,
|
|
@@ -1736,6 +1852,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1736
1852
|
|
|
1737
1853
|
publishInbound(event, 'engage', live.sessionId)
|
|
1738
1854
|
|
|
1855
|
+
autoReactOnEngage(event)
|
|
1856
|
+
|
|
1739
1857
|
updateLoopGuard(live, event)
|
|
1740
1858
|
|
|
1741
1859
|
enqueue(live, event)
|
|
@@ -1828,6 +1946,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1828
1946
|
authorName: event.authorName,
|
|
1829
1947
|
authorIsBot: event.authorIsBot,
|
|
1830
1948
|
externalMessageId: event.externalMessageId,
|
|
1949
|
+
...(event.reactionRef !== undefined ? { reactionRef: event.reactionRef } : {}),
|
|
1831
1950
|
isBotMention: event.isBotMention,
|
|
1832
1951
|
replyToBotMessageId: event.replyToBotMessageId,
|
|
1833
1952
|
isDm: event.isDm,
|
|
@@ -1845,6 +1964,69 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1845
1964
|
set.add(cb)
|
|
1846
1965
|
}
|
|
1847
1966
|
|
|
1967
|
+
const registerReaction = (adapter: ChannelKey['adapter'], cb: ReactionCallback): void => {
|
|
1968
|
+
let set = reactionCallbacks.get(adapter)
|
|
1969
|
+
if (!set) {
|
|
1970
|
+
set = new Set()
|
|
1971
|
+
reactionCallbacks.set(adapter, set)
|
|
1972
|
+
}
|
|
1973
|
+
set.add(cb)
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
const unregisterReaction = (adapter: ChannelKey['adapter'], cb: ReactionCallback): void => {
|
|
1977
|
+
reactionCallbacks.get(adapter)?.delete(cb)
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
const react = async (req: ReactionRequest): Promise<ReactionResult> => {
|
|
1981
|
+
if (req.reactionRef.adapter !== req.adapter) {
|
|
1982
|
+
return { ok: false, error: 'reaction ref adapter mismatch', code: 'unsupported' }
|
|
1983
|
+
}
|
|
1984
|
+
const callbacks = reactionCallbacks.get(req.adapter)
|
|
1985
|
+
if (!callbacks || callbacks.size === 0) {
|
|
1986
|
+
return { ok: false, error: `adapter "${req.adapter}" does not support reactions`, code: 'unsupported' }
|
|
1987
|
+
}
|
|
1988
|
+
let lastError: ReactionResult | undefined
|
|
1989
|
+
for (const cb of Array.from(callbacks)) {
|
|
1990
|
+
// A ReactionCallback that throws must not reject this promise: react() is
|
|
1991
|
+
// called both fire-and-forget (autoReactOnEngage) and awaited by the
|
|
1992
|
+
// channel_react tool, and neither should have to wrap it in try/catch. A
|
|
1993
|
+
// throw is converted to a transient failure result so every caller gets a
|
|
1994
|
+
// uniform { ok: false } instead of an exception.
|
|
1995
|
+
const result = await cb(req).catch(
|
|
1996
|
+
(err): ReactionResult => ({ ok: false, error: describe(err), code: 'transient' }),
|
|
1997
|
+
)
|
|
1998
|
+
if (result.ok) return result
|
|
1999
|
+
lastError = result
|
|
2000
|
+
}
|
|
2001
|
+
return lastError ?? { ok: false, error: 'no reaction callback handled request', code: 'unsupported' }
|
|
2002
|
+
}
|
|
2003
|
+
|
|
2004
|
+
// Best-effort acknowledgment: drop an :eyes: on the triggering inbound the
|
|
2005
|
+
// moment we decide to engage, replacing the old "On it" ack comment on
|
|
2006
|
+
// GitHub. Fire-and-forget so a reaction failure (missing permission, the
|
|
2007
|
+
// adapter not supporting reactions, a transient API error) can NEVER block
|
|
2008
|
+
// engagement, enqueueing, or the agent's actual reply. No reactionRef =
|
|
2009
|
+
// nothing reactable (synthetic inbounds, reaction-less adapters) = silent skip.
|
|
2010
|
+
const autoReactOnEngage = (event: InboundMessage): void => {
|
|
2011
|
+
if (event.reactionRef === undefined) return
|
|
2012
|
+
void react({
|
|
2013
|
+
adapter: event.adapter,
|
|
2014
|
+
workspace: event.workspace,
|
|
2015
|
+
chat: event.chat,
|
|
2016
|
+
thread: event.thread,
|
|
2017
|
+
reactionRef: event.reactionRef,
|
|
2018
|
+
emoji: ENGAGE_REACTION_EMOJI,
|
|
2019
|
+
})
|
|
2020
|
+
.then((result) => {
|
|
2021
|
+
if (!result.ok && result.code !== 'unsupported') {
|
|
2022
|
+
logger.info(`[channels] engage-react failed adapter=${event.adapter} chat=${event.chat}: ${result.error}`)
|
|
2023
|
+
}
|
|
2024
|
+
})
|
|
2025
|
+
.catch((err) => {
|
|
2026
|
+
logger.info(`[channels] engage-react threw adapter=${event.adapter} chat=${event.chat}: ${describe(err)}`)
|
|
2027
|
+
})
|
|
2028
|
+
}
|
|
2029
|
+
|
|
1848
2030
|
const unregisterOutbound = (adapter: ChannelKey['adapter'], cb: OutboundCallback): void => {
|
|
1849
2031
|
outboundCallbacks.get(adapter)?.delete(cb)
|
|
1850
2032
|
}
|
|
@@ -1978,9 +2160,14 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1978
2160
|
// Walk newest → oldest so that when an id collides across messages
|
|
1979
2161
|
// (e.g. two photos in the same session each labelled `#1`) the agent's
|
|
1980
2162
|
// `attachment_id: 1` always resolves to the CURRENT inbound's
|
|
1981
|
-
// attachment.
|
|
1982
|
-
//
|
|
1983
|
-
//
|
|
2163
|
+
// attachment. currentTurnAttachments holds the in-flight turn — the
|
|
2164
|
+
// only place the about-to-be-viewed attachment lives once drain() has
|
|
2165
|
+
// spliced promptQueue empty — and is therefore the freshest; promptQueue
|
|
2166
|
+
// then holds any inbound that arrived mid-turn. Within each list,
|
|
2167
|
+
// append-order maps to wall-clock order, so iterating in reverse gives
|
|
2168
|
+
// recency.
|
|
2169
|
+
const found = findAttachmentById(live.currentTurnAttachments, args.id)
|
|
2170
|
+
if (found !== null) return found
|
|
1984
2171
|
const haystacks: ReadonlyArray<ReadonlyArray<{ attachments?: readonly InboundAttachment[] }>> = [
|
|
1985
2172
|
live.promptQueue,
|
|
1986
2173
|
live.contextBuffer,
|
|
@@ -1988,8 +2175,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1988
2175
|
for (const haystack of haystacks) {
|
|
1989
2176
|
for (let i = haystack.length - 1; i >= 0; i--) {
|
|
1990
2177
|
const item = haystack[i]
|
|
1991
|
-
const
|
|
1992
|
-
if (
|
|
2178
|
+
const hit = item?.attachments?.find((attachment) => attachment.id === args.id)
|
|
2179
|
+
if (hit !== undefined) return hit
|
|
1993
2180
|
}
|
|
1994
2181
|
}
|
|
1995
2182
|
return null
|
|
@@ -1999,6 +2186,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1999
2186
|
const live = liveSessions.get(channelKeyId(args))
|
|
2000
2187
|
if (live === undefined) return []
|
|
2001
2188
|
const ids = new Set<number>()
|
|
2189
|
+
for (const attachment of live.currentTurnAttachments) ids.add(attachment.id)
|
|
2002
2190
|
for (const item of [...live.promptQueue, ...live.contextBuffer]) {
|
|
2003
2191
|
for (const attachment of item.attachments ?? []) ids.add(attachment.id)
|
|
2004
2192
|
}
|
|
@@ -2416,38 +2604,49 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
2416
2604
|
options: ExecuteCommandOptions,
|
|
2417
2605
|
): Promise<ExecuteCommandResult> => {
|
|
2418
2606
|
const lowered = name.toLowerCase()
|
|
2419
|
-
|
|
2607
|
+
const commandInfo = commands.get(lowered)
|
|
2608
|
+
if (commandInfo === undefined) {
|
|
2420
2609
|
return { kind: 'unknown-command', name: lowered }
|
|
2421
2610
|
}
|
|
2422
2611
|
// Gates on session.control (not channel.respond) so a respond-capable
|
|
2423
2612
|
// guest cannot abort another speaker's turn. Runs BEFORE the live-session
|
|
2424
2613
|
// lookup so an unauthorized invoker gets 'permission-denied' regardless of
|
|
2425
2614
|
// session state, rather than leaking session presence via the
|
|
2426
|
-
// 'no-live-session' vs 'permission-denied' distinction.
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2615
|
+
// 'no-live-session' vs 'permission-denied' distinction. Session-less
|
|
2616
|
+
// informational commands (e.g. /help) declare permission:'none' and skip
|
|
2617
|
+
// both the gate and the lookup so they work in channels with no live turn.
|
|
2618
|
+
if (commandInfo.permission === 'session.control') {
|
|
2619
|
+
const partial: SessionOrigin = {
|
|
2620
|
+
kind: 'channel',
|
|
2621
|
+
adapter: key.adapter,
|
|
2622
|
+
workspace: key.workspace,
|
|
2623
|
+
chat: key.chat,
|
|
2624
|
+
thread: key.thread,
|
|
2625
|
+
lastInboundAuthorId: options.invokerId,
|
|
2626
|
+
}
|
|
2627
|
+
if (!permissions.has(partial, CORE_PERMISSIONS.sessionControl)) {
|
|
2628
|
+
return { kind: 'permission-denied' }
|
|
2629
|
+
}
|
|
2441
2630
|
}
|
|
2442
|
-
|
|
2443
|
-
|
|
2631
|
+
let live: LiveSession | null = null
|
|
2632
|
+
if (commandInfo.requiresLiveSession) {
|
|
2633
|
+
const resolved = resolveLiveSessionForCommand(liveSessions, key)
|
|
2634
|
+
if (resolved.kind === 'none') {
|
|
2635
|
+
return { kind: 'no-live-session' }
|
|
2636
|
+
}
|
|
2637
|
+
if (resolved.kind === 'ambiguous') {
|
|
2638
|
+
return { kind: 'ambiguous', matchCount: resolved.count }
|
|
2639
|
+
}
|
|
2640
|
+
live = resolved.session
|
|
2444
2641
|
}
|
|
2445
|
-
const result = await commands.execute(`/${lowered}`, { live
|
|
2642
|
+
const result = await commands.execute(`/${lowered}`, { live, event: null })
|
|
2446
2643
|
if (result.kind === 'handled') {
|
|
2447
|
-
return
|
|
2644
|
+
return result.reply !== undefined
|
|
2645
|
+
? { kind: 'handled', name: result.name, reply: result.reply }
|
|
2646
|
+
: { kind: 'handled', name: result.name }
|
|
2448
2647
|
}
|
|
2449
2648
|
// commands.execute can only return not-command (impossible — we pass a
|
|
2450
|
-
// leading slash), unknown-command (impossible — we just checked
|
|
2649
|
+
// leading slash), unknown-command (impossible — we just checked get()),
|
|
2451
2650
|
// or handled. Any other outcome is a bug.
|
|
2452
2651
|
return { kind: 'unknown-command', name: lowered }
|
|
2453
2652
|
}
|
|
@@ -2526,6 +2725,9 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
2526
2725
|
getSendRate,
|
|
2527
2726
|
registerOutbound,
|
|
2528
2727
|
unregisterOutbound,
|
|
2728
|
+
registerReaction,
|
|
2729
|
+
unregisterReaction,
|
|
2730
|
+
react,
|
|
2529
2731
|
registerTyping,
|
|
2530
2732
|
unregisterTyping,
|
|
2531
2733
|
registerChannelNameResolver,
|
|
@@ -2610,12 +2812,31 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
2610
2812
|
}
|
|
2611
2813
|
}
|
|
2612
2814
|
|
|
2815
|
+
function collectTurnAttachments(
|
|
2816
|
+
observed: readonly ObservedInbound[],
|
|
2817
|
+
batch: readonly QueuedInbound[],
|
|
2818
|
+
): readonly InboundAttachment[] {
|
|
2819
|
+
const out: InboundAttachment[] = []
|
|
2820
|
+
for (const item of observed) out.push(...(item.attachments ?? []))
|
|
2821
|
+
for (const item of batch) out.push(...(item.attachments ?? []))
|
|
2822
|
+
return out
|
|
2823
|
+
}
|
|
2824
|
+
|
|
2825
|
+
function findAttachmentById(attachments: readonly InboundAttachment[], id: number): InboundAttachment | null {
|
|
2826
|
+
for (let i = attachments.length - 1; i >= 0; i--) {
|
|
2827
|
+
const attachment = attachments[i]
|
|
2828
|
+
if (attachment?.id === id) return attachment
|
|
2829
|
+
}
|
|
2830
|
+
return null
|
|
2831
|
+
}
|
|
2832
|
+
|
|
2613
2833
|
function composeTurnPrompt(
|
|
2614
2834
|
observed: readonly ObservedInbound[],
|
|
2615
2835
|
batch: readonly QueuedInbound[],
|
|
2616
2836
|
state: {
|
|
2617
2837
|
adapter?: AdapterId
|
|
2618
2838
|
loopGuardActive: boolean
|
|
2839
|
+
groupChatNudge?: boolean
|
|
2619
2840
|
systemReminders?: readonly string[]
|
|
2620
2841
|
now?: Date
|
|
2621
2842
|
role?: string
|
|
@@ -2689,6 +2910,38 @@ function composeTurnPrompt(
|
|
|
2689
2910
|
'',
|
|
2690
2911
|
)
|
|
2691
2912
|
}
|
|
2913
|
+
// Group-chat nudge: same SYSTEM MESSAGE convention as the loop guard. We
|
|
2914
|
+
// engaged this turn — possibly via sticky credit, which now wakes us on
|
|
2915
|
+
// every follow-up in a group too (the engagement gate is content-blind by
|
|
2916
|
+
// design). In a multi-human room the default "answer everything" posture is
|
|
2917
|
+
// wrong, so this nudge is the ONLY thing that makes the bot selective: it
|
|
2918
|
+
// tells the model to answer genuine follow-ups and stay silent on chatter.
|
|
2919
|
+
// The gate gets us into the turn; the model decides whether to speak.
|
|
2920
|
+
// Cache-neutral (user-turn suffix), and skipped when the loop guard already
|
|
2921
|
+
// fired to avoid stacking two silence notices in one turn.
|
|
2922
|
+
if (state.groupChatNudge === true && !state.loopGuardActive) {
|
|
2923
|
+
parts.push(
|
|
2924
|
+
'---',
|
|
2925
|
+
'**[SYSTEM MESSAGE — not from a human]**',
|
|
2926
|
+
'',
|
|
2927
|
+
'You are in a group chat with multiple people. This is an automated',
|
|
2928
|
+
'signal from the channel router, not a message from anyone in the chat.',
|
|
2929
|
+
'**Do not acknowledge or reply to this notice.**',
|
|
2930
|
+
'',
|
|
2931
|
+
'You are woken on every message from someone you recently talked with, so',
|
|
2932
|
+
'most turns you should stay quiet. Reply ONLY when:',
|
|
2933
|
+
'- the current message is addressed to you (by name, @-mention, or reply), or',
|
|
2934
|
+
'- it directly continues your own last exchange and clearly wants an answer',
|
|
2935
|
+
' (e.g. a follow-up question about what you just said).',
|
|
2936
|
+
'',
|
|
2937
|
+
'Otherwise — chatter between others, side-conversation, banter, or anything',
|
|
2938
|
+
'not actually waiting on you — reply with `NO_REPLY` (or call `skip_response`)',
|
|
2939
|
+
'to stay silent and keep watching. When unsure, prefer silence.',
|
|
2940
|
+
'',
|
|
2941
|
+
'---',
|
|
2942
|
+
'',
|
|
2943
|
+
)
|
|
2944
|
+
}
|
|
2692
2945
|
if (observed.length > 0) {
|
|
2693
2946
|
parts.push('## Recent context (not addressed to you, for awareness only)')
|
|
2694
2947
|
for (const o of observed) {
|
package/src/channels/types.ts
CHANGED
|
@@ -94,8 +94,50 @@ export type InboundMessage = {
|
|
|
94
94
|
// means "unknown" — the formatter renders such lines without a
|
|
95
95
|
// timestamp prefix instead of stamping them with the wrong clock.
|
|
96
96
|
ts: number
|
|
97
|
+
// Opaque, adapter-owned handle for the entity an emoji reaction would
|
|
98
|
+
// attach to. The classifier stamps it because only there is the platform-
|
|
99
|
+
// side target type still known (GitHub: issue body vs issue-comment vs
|
|
100
|
+
// pr-review-comment — all collapse to the same `chat`/`externalMessageId`
|
|
101
|
+
// pair downstream). Mirrors the `InboundAttachment.ref` opaque-handle
|
|
102
|
+
// pattern: ONLY the originating adapter's ReactionCallback knows how to
|
|
103
|
+
// parse `value`; the router and tools treat it as a pass-through token and
|
|
104
|
+
// never inspect it. Omitted when the inbound has no reactable target (e.g.
|
|
105
|
+
// synthetic review-request inbounds, or adapters without reaction support).
|
|
106
|
+
reactionRef?: ReactionRef
|
|
97
107
|
}
|
|
98
108
|
|
|
109
|
+
// Opaque reaction target handle. `adapter` lets the router refuse a ref to
|
|
110
|
+
// the wrong adapter's callback; `value` is an adapter-private encoding (for
|
|
111
|
+
// GitHub, a JSON blob distinguishing issue / issue-comment / pr-review-comment
|
|
112
|
+
// / discussion plus the numeric id). Never rendered into prompt context.
|
|
113
|
+
export type ReactionRef = {
|
|
114
|
+
adapter: AdapterId
|
|
115
|
+
value: string
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// A request to add an emoji reaction to a previously-seen inbound. Distinct
|
|
119
|
+
// from OutboundMessage on purpose: reactions are best-effort side effects, not
|
|
120
|
+
// messages, so they bypass `send()`'s flood guard, per-turn send cap, exact-
|
|
121
|
+
// duplicate guard, sticky-credit grants, and typing heartbeat — all of which
|
|
122
|
+
// are message semantics that would misbehave on a reaction.
|
|
123
|
+
export type ReactionRequest = {
|
|
124
|
+
adapter: AdapterId
|
|
125
|
+
workspace: string
|
|
126
|
+
chat: string
|
|
127
|
+
thread?: string | null
|
|
128
|
+
reactionRef: ReactionRef
|
|
129
|
+
// Bare emoji name, no surrounding colons (e.g. 'eyes', '+1'). Each adapter
|
|
130
|
+
// maps this to its platform's reaction vocabulary and rejects unsupported
|
|
131
|
+
// names via `code: 'unsupported'`.
|
|
132
|
+
emoji: string
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export type ReactionErrorCode = 'permission-denied' | 'not-found' | 'unsupported' | 'rate-limited' | 'transient'
|
|
136
|
+
|
|
137
|
+
export type ReactionResult = { ok: true } | { ok: false; error: string; code?: ReactionErrorCode }
|
|
138
|
+
|
|
139
|
+
export type ReactionCallback = (req: ReactionRequest) => Promise<ReactionResult>
|
|
140
|
+
|
|
99
141
|
// File on disk that the agent wants to attach to an outbound message. The
|
|
100
142
|
// agent runs inside a container with /agent bind-mounted from the host;
|
|
101
143
|
// `path` should be an absolute path the container can `readFile`. The
|
package/src/cli/inspect.ts
CHANGED
|
@@ -75,6 +75,9 @@ export const inspectCommand = defineCommand({
|
|
|
75
75
|
if (escListener === null) return new AbortController().signal
|
|
76
76
|
return escListener.armForStream()
|
|
77
77
|
},
|
|
78
|
+
afterEscStream: () => {
|
|
79
|
+
escListener?.pause()
|
|
80
|
+
},
|
|
78
81
|
...(liveHint !== undefined ? { liveHint } : {}),
|
|
79
82
|
stdout: (line) => process.stdout.write(`${line}\n`),
|
|
80
83
|
stderr: (line) => process.stderr.write(`${line}\n`),
|