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.
Files changed (46) hide show
  1. package/package.json +1 -1
  2. package/src/agent/index.ts +9 -1
  3. package/src/agent/live-subagents.ts +4 -0
  4. package/src/agent/model-overrides.ts +77 -0
  5. package/src/agent/plugin-tools.ts +53 -4
  6. package/src/agent/session-origin.ts +32 -10
  7. package/src/agent/tools/channel-react.ts +79 -0
  8. package/src/agent/tools/grant-role.ts +102 -8
  9. package/src/agent/tools/spawn-subagent.ts +1 -0
  10. package/src/agent/tools/subagent-access.ts +67 -0
  11. package/src/agent/tools/subagent-cancel.ts +11 -6
  12. package/src/agent/tools/subagent-output.ts +10 -2
  13. package/src/bundled-plugins/github-cli-auth/gh-command.ts +372 -0
  14. package/src/bundled-plugins/github-cli-auth/index.ts +42 -0
  15. package/src/bundled-plugins/github-cli-auth/token-class.ts +11 -0
  16. package/src/bundled-plugins/reviewer/skills/code-review.ts +18 -1
  17. package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +9 -2
  18. package/src/channels/adapters/discord-bot.ts +242 -7
  19. package/src/channels/adapters/github/inbound.ts +40 -55
  20. package/src/channels/adapters/github/index.ts +89 -18
  21. package/src/channels/adapters/github/membership.ts +4 -0
  22. package/src/channels/adapters/github/permission-guidance.ts +20 -1
  23. package/src/channels/adapters/github/reactions.ts +142 -0
  24. package/src/channels/adapters/slack-bot-slash-commands.ts +3 -1
  25. package/src/channels/adapters/slack-bot.ts +4 -4
  26. package/src/channels/commands.ts +10 -0
  27. package/src/channels/engagement.ts +30 -2
  28. package/src/channels/github-token-bridge.ts +42 -0
  29. package/src/channels/index.ts +6 -0
  30. package/src/channels/manager.ts +6 -0
  31. package/src/channels/membership.ts +9 -0
  32. package/src/channels/router.ts +295 -42
  33. package/src/channels/types.ts +42 -0
  34. package/src/cli/inspect.ts +3 -0
  35. package/src/cli/ui.ts +6 -0
  36. package/src/commands/index.ts +54 -4
  37. package/src/init/dockerfile.ts +60 -0
  38. package/src/init/validate-api-key.ts +15 -1
  39. package/src/inspect/loop.ts +12 -1
  40. package/src/permissions/permissions.ts +24 -0
  41. package/src/plugin/context.ts +8 -0
  42. package/src/plugin/manager.ts +3 -0
  43. package/src/plugin/types.ts +6 -0
  44. package/src/run/bundled-plugins.ts +9 -0
  45. package/src/run/index.ts +4 -0
  46. package/src/skills/typeclaw-channel-github/SKILL.md +80 -43
@@ -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 { decideEngagement, grantStickyForReplyTargets, StickyLedger, type EngagementDecision } from './engagement'
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
- const commands = createCommandRegistry<ChannelCommandContext>([
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
- await stopCurrentChannelTurn(live)
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 (!commands.has(parsedCommand.name)) {
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
- const existingLive = liveSessions.get(keyId)
1677
- if (!existingLive || existingLive.destroyed) {
1678
- logger.info(`[channels] ${keyId}: ignoring command /${parsedCommand.name} with no live session`)
1679
- return
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 commands.execute(event.text, { live: existingLive, event })
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. promptQueue holds the about-to-be-delivered turn and
1982
- // is therefore the freshest; within each list, append-order maps to
1983
- // wall-clock order, so iterating in reverse gives recency.
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 found = item?.attachments?.find((attachment) => attachment.id === args.id)
1992
- if (found !== undefined) return found
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
- if (!commands.has(lowered)) {
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
- const partial: SessionOrigin = {
2428
- kind: 'channel',
2429
- adapter: key.adapter,
2430
- workspace: key.workspace,
2431
- chat: key.chat,
2432
- thread: key.thread,
2433
- lastInboundAuthorId: options.invokerId,
2434
- }
2435
- if (!permissions.has(partial, CORE_PERMISSIONS.sessionControl)) {
2436
- return { kind: 'permission-denied' }
2437
- }
2438
- const resolved = resolveLiveSessionForCommand(liveSessions, key)
2439
- if (resolved.kind === 'none') {
2440
- return { kind: 'no-live-session' }
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
- if (resolved.kind === 'ambiguous') {
2443
- return { kind: 'ambiguous', matchCount: resolved.count }
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: resolved.session, event: null })
2642
+ const result = await commands.execute(`/${lowered}`, { live, event: null })
2446
2643
  if (result.kind === 'handled') {
2447
- return { kind: 'handled', name: result.name }
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 has()),
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) {
@@ -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
@@ -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`),