typeclaw 0.19.0 → 0.21.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 (38) hide show
  1. package/package.json +1 -1
  2. package/src/agent/index.ts +7 -0
  3. package/src/agent/live-subagents.ts +4 -0
  4. package/src/agent/restart/index.ts +101 -0
  5. package/src/agent/session-origin.ts +32 -10
  6. package/src/agent/tools/channel-react.ts +79 -0
  7. package/src/agent/tools/restart.ts +23 -52
  8. package/src/agent/tools/spawn-subagent.ts +1 -0
  9. package/src/agent/tools/subagent-access.ts +67 -0
  10. package/src/agent/tools/subagent-cancel.ts +11 -6
  11. package/src/agent/tools/subagent-output.ts +10 -2
  12. package/src/channels/adapters/discord-bot-classify.ts +8 -2
  13. package/src/channels/adapters/discord-bot.ts +265 -22
  14. package/src/channels/adapters/github/decoy-reviewer.ts +43 -0
  15. package/src/channels/adapters/github/inbound.ts +79 -0
  16. package/src/channels/adapters/github/index.ts +19 -0
  17. package/src/channels/adapters/github/permission-guidance.ts +20 -1
  18. package/src/channels/adapters/github/reactions.ts +276 -0
  19. package/src/channels/adapters/slack-bot-classify.ts +2 -2
  20. package/src/channels/adapters/slack-bot.ts +25 -2
  21. package/src/channels/engagement.ts +81 -44
  22. package/src/channels/router.ts +255 -18
  23. package/src/channels/types.ts +57 -0
  24. package/src/cli/builtins.ts +1 -0
  25. package/src/cli/dreams.ts +147 -0
  26. package/src/cli/index.ts +1 -0
  27. package/src/cli/inspect.ts +3 -0
  28. package/src/dreams/git.ts +85 -0
  29. package/src/dreams/index.ts +134 -0
  30. package/src/dreams/parse.ts +224 -0
  31. package/src/dreams/render.ts +155 -0
  32. package/src/dreams/types.ts +50 -0
  33. package/src/inspect/loop.ts +12 -1
  34. package/src/permissions/permissions.ts +24 -0
  35. package/src/server/index.ts +49 -0
  36. package/src/shared/protocol.ts +2 -0
  37. package/src/skills/typeclaw-channel-github/SKILL.md +6 -2
  38. package/src/tui/index.ts +70 -18
@@ -51,9 +51,15 @@ import type {
51
51
  HistoryCallback,
52
52
  InboundAttachment,
53
53
  InboundMessage,
54
+ RemoveReactionCallback,
55
+ RemoveReactionRequest,
54
56
  OutboundCallback,
55
57
  OutboundMessage,
56
58
  QuoteAnchorSource,
59
+ ReactionCallback,
60
+ ReactionRef,
61
+ ReactionRequest,
62
+ ReactionResult,
57
63
  ResolvedChannelNames,
58
64
  SendErrorCode,
59
65
  SendResult,
@@ -108,6 +114,7 @@ export const SESSION_GC_INTERVAL_MS = 60 * 1000
108
114
  // Enforced inside router.send for `source: 'tool'` callers; system
109
115
  // recovery paths (`source: 'system'`) bypass.
110
116
  export const MAX_CHANNEL_SENDS_PER_TURN = 10
117
+ export const ENGAGE_REACTION_EMOJI = 'eyes'
111
118
  // Ceiling on tool-source channel sends that a same-turn router policy DENIED
112
119
  // without delivering — `skip-locked`, `turn-cap`, or `duplicate`. Such denials
113
120
  // return a soft error and do NOT increment `consecutiveSends`, so a model that
@@ -276,6 +283,8 @@ type QueuedInbound = {
276
283
  authorName: string
277
284
  authorIsBot: boolean
278
285
  externalMessageId: string
286
+ reactionRef?: ReactionRef
287
+ engageReaction?: Promise<ReactionRef | null>
279
288
  isBotMention: boolean
280
289
  replyToBotMessageId: string | null
281
290
  isDm: boolean
@@ -324,6 +333,14 @@ type LiveSession = {
324
333
  originRef: { current: SessionOrigin | undefined }
325
334
  promptQueue: QueuedInbound[]
326
335
  contextBuffer: ObservedInbound[]
336
+ // Attachments of the messages composing the in-flight turn. drain()
337
+ // splices promptQueue/contextBuffer empty BEFORE calling prompt(), but
338
+ // the model only requests an attachment (look_at_channel_attachment /
339
+ // channel_fetch_attachment) DURING prompt() — by which point both queues
340
+ // are empty. This turn-scoped snapshot, populated right after the splice
341
+ // and cleared when the turn ends, is what the lookup reads so a freshly-
342
+ // arrived attachment stays resolvable for the whole turn it belongs to.
343
+ currentTurnAttachments: readonly InboundAttachment[]
327
344
  draining: boolean
328
345
  debounceTimer: ReturnType<typeof setTimeout> | null
329
346
  typingTimer: ReturnType<typeof setInterval> | null
@@ -334,6 +351,16 @@ type LiveSession = {
334
351
  firstUnprocessedAt: number
335
352
  currentTurnAuthorId: string | null
336
353
  currentTurnAuthorIds: Set<string>
354
+ // Reaction target of the inbound that triggered THIS turn (the last item in
355
+ // the drained batch, mirroring `currentTurnAuthorId`). Surfaced on the live
356
+ // origin so `channel_react` reacts to the triggering message, not whichever
357
+ // inbound happens to be latest in the queue. Null on reminder-only turns.
358
+ currentTurnReactionRef: ReactionRef | null
359
+ // One engage-:eyes:-add promise per inbound coalesced into THIS turn, each
360
+ // resolving to its removable per-instance ref (or null). A debounced turn can
361
+ // batch several inbounds that each got their own :eyes:, so every entry is
362
+ // removed after the reply. Empty on turns with no reactable inbound.
363
+ currentTurnEngageReactions: Array<Promise<ReactionRef | null>>
337
364
  lastTurnAuthorIds: Set<string>
338
365
  // Mirror of currentTurnAuthorId at end-of-turn (the LAST speaker of the
339
366
  // prior batch), preserved across the drain finally-block which resets
@@ -511,6 +538,17 @@ export type ChannelRouter = {
511
538
  }) => { count: number; windowMs: number }
512
539
  registerOutbound: (adapter: ChannelKey['adapter'], cb: OutboundCallback) => void
513
540
  unregisterOutbound: (adapter: ChannelKey['adapter'], cb: OutboundCallback) => void
541
+ // Reaction support is opt-in per adapter: an adapter that never calls
542
+ // registerReaction makes `react` resolve to `code: 'unsupported'`, and
543
+ // auto-react-on-engage becomes a silent no-op for it. Kept separate from
544
+ // the outbound path on purpose — reactions are best-effort side effects, not
545
+ // messages, so they must not flow through send()'s flood/cap/dup/sticky guards.
546
+ registerReaction: (adapter: ChannelKey['adapter'], cb: ReactionCallback) => void
547
+ unregisterReaction: (adapter: ChannelKey['adapter'], cb: ReactionCallback) => void
548
+ react: (req: ReactionRequest) => Promise<ReactionResult>
549
+ registerRemoveReaction: (adapter: ChannelKey['adapter'], cb: RemoveReactionCallback) => void
550
+ unregisterRemoveReaction: (adapter: ChannelKey['adapter'], cb: RemoveReactionCallback) => void
551
+ removeReaction: (req: RemoveReactionRequest) => Promise<ReactionResult>
514
552
  registerTyping: (adapter: ChannelKey['adapter'], cb: TypingCallback) => void
515
553
  unregisterTyping: (adapter: ChannelKey['adapter'], cb: TypingCallback) => void
516
554
  registerChannelNameResolver: (adapter: ChannelKey['adapter'], resolver: ChannelNameResolver) => void
@@ -703,6 +741,7 @@ export type ClaimHandler = (input: ClaimHandlerInput) => Promise<ClaimHandlerOut
703
741
  const GRANT_ALL_PERMISSIONS: PermissionService = {
704
742
  has: () => true,
705
743
  resolveRole: () => 'owner',
744
+ compareRoleSeverity: () => 1,
706
745
  describe: () => ({ role: 'owner', permissions: [CORE_PERMISSIONS.channelRespond] }),
707
746
  replaceRoles: () => {},
708
747
  }
@@ -728,6 +767,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
728
767
  // teardown was meant to clear.
729
768
  let liveGeneration = 0
730
769
  const outboundCallbacks = new Map<ChannelKey['adapter'], Set<OutboundCallback>>()
770
+ const reactionCallbacks = new Map<ChannelKey['adapter'], Set<ReactionCallback>>()
771
+ const removeReactionCallbacks = new Map<ChannelKey['adapter'], Set<RemoveReactionCallback>>()
731
772
  const typingCallbacks = new Map<ChannelKey['adapter'], Set<TypingCallback>>()
732
773
  const channelNameResolvers = new Map<ChannelKey['adapter'], Set<ChannelNameResolver>>()
733
774
  const membershipResolvers = new Map<ChannelKey['adapter'], Set<MembershipResolver>>()
@@ -1081,6 +1122,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1081
1122
  promptQueue: [],
1082
1123
  pendingSystemReminders: [],
1083
1124
  contextBuffer: [],
1125
+ currentTurnAttachments: [],
1084
1126
  draining: false,
1085
1127
  debounceTimer: null,
1086
1128
  typingTimer: null,
@@ -1091,6 +1133,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1091
1133
  firstUnprocessedAt: 0,
1092
1134
  currentTurnAuthorId: null,
1093
1135
  currentTurnAuthorIds: new Set(),
1136
+ currentTurnReactionRef: null,
1137
+ currentTurnEngageReactions: [],
1094
1138
  // `lastTurnAuthorId` (string, used for `lastInboundAuthorId` in
1095
1139
  // origin) and `lastTurnAuthorIds` (Set, used by
1096
1140
  // `grantStickyForReplyTargets` as the fallback when
@@ -1223,6 +1267,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1223
1267
  receivedAt: now(),
1224
1268
  ts: item.message.ts,
1225
1269
  source: 'prefetch',
1270
+ ...(item.message.attachments !== undefined ? { attachments: item.message.attachments } : {}),
1226
1271
  })
1227
1272
  } else {
1228
1273
  observed.push({
@@ -1451,6 +1496,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1451
1496
  ...(live.resolvedNames.chatName !== undefined ? { chatName: live.resolvedNames.chatName } : {}),
1452
1497
  thread: live.key.thread,
1453
1498
  ...(live.currentTurnAuthorId !== null ? { lastInboundAuthorId: live.currentTurnAuthorId } : {}),
1499
+ ...(live.currentTurnReactionRef !== null ? { reactionRef: live.currentTurnReactionRef } : {}),
1454
1500
  participants: live.participants,
1455
1501
  ...(membership !== null ? { membership } : {}),
1456
1502
  }
@@ -1494,14 +1540,20 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1494
1540
  const batch = live.promptQueue.splice(0, live.promptQueue.length)
1495
1541
  const observed = live.contextBuffer.splice(0, live.contextBuffer.length)
1496
1542
  const reminders = live.pendingSystemReminders.splice(0, live.pendingSystemReminders.length)
1543
+ live.currentTurnAttachments = collectTurnAttachments(observed, batch)
1497
1544
 
1498
1545
  if (batch.length > 0) {
1499
1546
  live.currentTurnAuthorId = batch[batch.length - 1]!.authorId
1500
1547
  live.currentTurnAuthorIds = new Set(batch.map((m) => m.authorId))
1548
+ live.currentTurnReactionRef = batch[batch.length - 1]!.reactionRef ?? null
1549
+ live.currentTurnEngageReactions = batch.flatMap((m) =>
1550
+ m.engageReaction !== undefined ? [m.engageReaction] : [],
1551
+ )
1501
1552
  live.consecutiveSends.clear()
1502
1553
  live.lastSentText.clear()
1503
1554
  live.pendingQuoteCandidate = captureQuoteCandidate(live.key.adapter, batch, observed)
1504
1555
  } else if (live.lastTurnAuthorId !== null) {
1556
+ live.currentTurnEngageReactions = []
1505
1557
  // Reminder-only turn (batch.length === 0, reminders.length > 0):
1506
1558
  // restore the author identity from the prior turn so author-
1507
1559
  // scoped role resolution still works on this turn. The drain
@@ -1516,6 +1568,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1516
1568
  // author prior turn like alice→bob restores `bob`, not alice.
1517
1569
  live.currentTurnAuthorId = live.lastTurnAuthorId
1518
1570
  live.currentTurnAuthorIds = new Set(live.lastTurnAuthorIds)
1571
+ } else {
1572
+ live.currentTurnEngageReactions = []
1519
1573
  }
1520
1574
 
1521
1575
  // Update the live origin holder so this turn's tool.before events
@@ -1542,6 +1596,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1542
1596
  logger.info(`[channels] ${live.keyId} prompting batch=${batch.length} text_len=${text.length}`)
1543
1597
  const promptStart = now()
1544
1598
  const successfulSendsBeforePrompt = live.successfulChannelSends
1599
+ const engageAddPromises = live.currentTurnEngageReactions
1545
1600
  live.turnSeq++
1546
1601
  live.successfulSendsAtTurnStart = successfulSendsBeforePrompt
1547
1602
  live.policyDeniedToolSendsThisTurn.clear()
@@ -1556,6 +1611,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1556
1611
  live.consecutiveSends.clear()
1557
1612
  live.lastSentText.clear()
1558
1613
  } finally {
1614
+ const sentReplyThisTurn = live.successfulChannelSends > successfulSendsBeforePrompt
1615
+ if (sentReplyThisTurn) dropEngageReactionsAfterReply(live, engageAddPromises)
1559
1616
  await fireSessionTurnEnd(live)
1560
1617
  }
1561
1618
  await fireSessionIdle(live)
@@ -1568,6 +1625,9 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1568
1625
  live.draining = false
1569
1626
  live.currentTurnAuthorId = null
1570
1627
  live.currentTurnAuthorIds = new Set()
1628
+ live.currentTurnReactionRef = null
1629
+ live.currentTurnEngageReactions = []
1630
+ live.currentTurnAttachments = []
1571
1631
  await stopTypingHeartbeat(live)
1572
1632
  }
1573
1633
  }
@@ -1816,9 +1876,11 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1816
1876
 
1817
1877
  publishInbound(event, 'engage', live.sessionId)
1818
1878
 
1879
+ const engageReaction = autoReactOnEngage(event)
1880
+
1819
1881
  updateLoopGuard(live, event)
1820
1882
 
1821
- enqueue(live, event)
1883
+ enqueue(live, event, engageReaction)
1822
1884
 
1823
1885
  // Start showing "typing..." the moment we know we're going to engage,
1824
1886
  // so users see the indicator during the debounce window — not just
@@ -1900,7 +1962,11 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1900
1962
  }
1901
1963
  }
1902
1964
 
1903
- const enqueue = (live: LiveSession, event: InboundMessage): void => {
1965
+ const enqueue = (
1966
+ live: LiveSession,
1967
+ event: InboundMessage,
1968
+ engageReaction: Promise<ReactionRef | null> | null,
1969
+ ): void => {
1904
1970
  live.promptQueue.push({
1905
1971
  text: event.text,
1906
1972
  ...(event.attachments !== undefined && event.attachments.length > 0 ? { attachments: event.attachments } : {}),
@@ -1908,6 +1974,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1908
1974
  authorName: event.authorName,
1909
1975
  authorIsBot: event.authorIsBot,
1910
1976
  externalMessageId: event.externalMessageId,
1977
+ ...(event.reactionRef !== undefined ? { reactionRef: event.reactionRef } : {}),
1978
+ ...(engageReaction !== null ? { engageReaction } : {}),
1911
1979
  isBotMention: event.isBotMention,
1912
1980
  replyToBotMessageId: event.replyToBotMessageId,
1913
1981
  isDm: event.isDm,
@@ -1925,6 +1993,134 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1925
1993
  set.add(cb)
1926
1994
  }
1927
1995
 
1996
+ const registerReaction = (adapter: ChannelKey['adapter'], cb: ReactionCallback): void => {
1997
+ let set = reactionCallbacks.get(adapter)
1998
+ if (!set) {
1999
+ set = new Set()
2000
+ reactionCallbacks.set(adapter, set)
2001
+ }
2002
+ set.add(cb)
2003
+ }
2004
+
2005
+ const unregisterReaction = (adapter: ChannelKey['adapter'], cb: ReactionCallback): void => {
2006
+ reactionCallbacks.get(adapter)?.delete(cb)
2007
+ }
2008
+
2009
+ const registerRemoveReaction = (adapter: ChannelKey['adapter'], cb: RemoveReactionCallback): void => {
2010
+ let set = removeReactionCallbacks.get(adapter)
2011
+ if (!set) {
2012
+ set = new Set()
2013
+ removeReactionCallbacks.set(adapter, set)
2014
+ }
2015
+ set.add(cb)
2016
+ }
2017
+
2018
+ const unregisterRemoveReaction = (adapter: ChannelKey['adapter'], cb: RemoveReactionCallback): void => {
2019
+ removeReactionCallbacks.get(adapter)?.delete(cb)
2020
+ }
2021
+
2022
+ const react = async (req: ReactionRequest): Promise<ReactionResult> => {
2023
+ if (req.reactionRef.adapter !== req.adapter) {
2024
+ return { ok: false, error: 'reaction ref adapter mismatch', code: 'unsupported' }
2025
+ }
2026
+ const callbacks = reactionCallbacks.get(req.adapter)
2027
+ if (!callbacks || callbacks.size === 0) {
2028
+ return { ok: false, error: `adapter "${req.adapter}" does not support reactions`, code: 'unsupported' }
2029
+ }
2030
+ let lastError: ReactionResult | undefined
2031
+ for (const cb of Array.from(callbacks)) {
2032
+ // A ReactionCallback that throws must not reject this promise: react() is
2033
+ // called both fire-and-forget (autoReactOnEngage) and awaited by the
2034
+ // channel_react tool, and neither should have to wrap it in try/catch. A
2035
+ // throw is converted to a transient failure result so every caller gets a
2036
+ // uniform { ok: false } instead of an exception.
2037
+ const result = await cb(req).catch(
2038
+ (err): ReactionResult => ({ ok: false, error: describe(err), code: 'transient' }),
2039
+ )
2040
+ if (result.ok) return result
2041
+ lastError = result
2042
+ }
2043
+ return lastError ?? { ok: false, error: 'no reaction callback handled request', code: 'unsupported' }
2044
+ }
2045
+
2046
+ const removeReaction = async (req: RemoveReactionRequest): Promise<ReactionResult> => {
2047
+ if (req.reactionRef.adapter !== req.adapter) {
2048
+ return { ok: false, error: 'reaction ref adapter mismatch', code: 'unsupported' }
2049
+ }
2050
+ const callbacks = removeReactionCallbacks.get(req.adapter)
2051
+ if (!callbacks || callbacks.size === 0) {
2052
+ return { ok: false, error: `adapter "${req.adapter}" does not support reaction removal`, code: 'unsupported' }
2053
+ }
2054
+ let lastError: ReactionResult | undefined
2055
+ for (const cb of Array.from(callbacks)) {
2056
+ const result = await cb(req).catch(
2057
+ (err): ReactionResult => ({ ok: false, error: describe(err), code: 'transient' }),
2058
+ )
2059
+ if (result.ok) return result
2060
+ lastError = result
2061
+ }
2062
+ return lastError ?? { ok: false, error: 'no reaction removal callback handled request', code: 'unsupported' }
2063
+ }
2064
+
2065
+ // Best-effort acknowledgment: drop an :eyes: on the triggering inbound the
2066
+ // moment we decide to engage, replacing the old "On it" ack comment on
2067
+ // GitHub. Fire-and-forget so a reaction failure (missing permission, the
2068
+ // adapter not supporting reactions, a transient API error) can NEVER block
2069
+ // engagement, enqueueing, or the agent's actual reply. No reactionRef =
2070
+ // nothing reactable (synthetic inbounds, reaction-less adapters) = silent skip.
2071
+ const autoReactOnEngage = (event: InboundMessage): Promise<ReactionRef | null> | null => {
2072
+ if (event.reactionRef === undefined) return null
2073
+ const addResult = react({
2074
+ adapter: event.adapter,
2075
+ workspace: event.workspace,
2076
+ chat: event.chat,
2077
+ thread: event.thread,
2078
+ reactionRef: event.reactionRef,
2079
+ emoji: ENGAGE_REACTION_EMOJI,
2080
+ })
2081
+ const addReactionRef = addResult.then((r) => (r.ok ? (r.reactionRef ?? null) : null)).catch(() => null)
2082
+ void addResult
2083
+ .then((result) => {
2084
+ if (!result.ok && result.code !== 'unsupported') {
2085
+ logger.info(`[channels] engage-react failed adapter=${event.adapter} chat=${event.chat}: ${result.error}`)
2086
+ }
2087
+ })
2088
+ .catch((err) => {
2089
+ logger.info(`[channels] engage-react threw adapter=${event.adapter} chat=${event.chat}: ${describe(err)}`)
2090
+ })
2091
+ return addReactionRef
2092
+ }
2093
+
2094
+ const dropEngageReactionsAfterReply = (live: LiveSession, addPromises: Array<Promise<ReactionRef | null>>): void => {
2095
+ for (const addPromise of addPromises) dropOneEngageReactionAfterReply(live, addPromise)
2096
+ }
2097
+
2098
+ const dropOneEngageReactionAfterReply = (live: LiveSession, addPromise: Promise<ReactionRef | null>): void => {
2099
+ void addPromise
2100
+ .then((reactionRef) => {
2101
+ if (reactionRef === null) return undefined
2102
+ return removeReaction({
2103
+ adapter: live.key.adapter,
2104
+ workspace: live.key.workspace,
2105
+ chat: live.key.chat,
2106
+ thread: live.key.thread,
2107
+ reactionRef,
2108
+ })
2109
+ })
2110
+ .then((result) => {
2111
+ if (result && !result.ok && result.code !== 'unsupported' && result.code !== 'not-found') {
2112
+ logger.info(
2113
+ `[channels] engage-unreact failed adapter=${live.key.adapter} chat=${live.key.chat}: ${result.error}`,
2114
+ )
2115
+ }
2116
+ })
2117
+ .catch((err) => {
2118
+ logger.info(
2119
+ `[channels] engage-unreact threw adapter=${live.key.adapter} chat=${live.key.chat}: ${describe(err)}`,
2120
+ )
2121
+ })
2122
+ }
2123
+
1928
2124
  const unregisterOutbound = (adapter: ChannelKey['adapter'], cb: OutboundCallback): void => {
1929
2125
  outboundCallbacks.get(adapter)?.delete(cb)
1930
2126
  }
@@ -2058,9 +2254,14 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2058
2254
  // Walk newest → oldest so that when an id collides across messages
2059
2255
  // (e.g. two photos in the same session each labelled `#1`) the agent's
2060
2256
  // `attachment_id: 1` always resolves to the CURRENT inbound's
2061
- // attachment. promptQueue holds the about-to-be-delivered turn and
2062
- // is therefore the freshest; within each list, append-order maps to
2063
- // wall-clock order, so iterating in reverse gives recency.
2257
+ // attachment. currentTurnAttachments holds the in-flight turn — the
2258
+ // only place the about-to-be-viewed attachment lives once drain() has
2259
+ // spliced promptQueue empty and is therefore the freshest; promptQueue
2260
+ // then holds any inbound that arrived mid-turn. Within each list,
2261
+ // append-order maps to wall-clock order, so iterating in reverse gives
2262
+ // recency.
2263
+ const found = findAttachmentById(live.currentTurnAttachments, args.id)
2264
+ if (found !== null) return found
2064
2265
  const haystacks: ReadonlyArray<ReadonlyArray<{ attachments?: readonly InboundAttachment[] }>> = [
2065
2266
  live.promptQueue,
2066
2267
  live.contextBuffer,
@@ -2068,8 +2269,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2068
2269
  for (const haystack of haystacks) {
2069
2270
  for (let i = haystack.length - 1; i >= 0; i--) {
2070
2271
  const item = haystack[i]
2071
- const found = item?.attachments?.find((attachment) => attachment.id === args.id)
2072
- if (found !== undefined) return found
2272
+ const hit = item?.attachments?.find((attachment) => attachment.id === args.id)
2273
+ if (hit !== undefined) return hit
2073
2274
  }
2074
2275
  }
2075
2276
  return null
@@ -2079,6 +2280,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2079
2280
  const live = liveSessions.get(channelKeyId(args))
2080
2281
  if (live === undefined) return []
2081
2282
  const ids = new Set<number>()
2283
+ for (const attachment of live.currentTurnAttachments) ids.add(attachment.id)
2082
2284
  for (const item of [...live.promptQueue, ...live.contextBuffer]) {
2083
2285
  for (const attachment of item.attachments ?? []) ids.add(attachment.id)
2084
2286
  }
@@ -2617,6 +2819,12 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2617
2819
  getSendRate,
2618
2820
  registerOutbound,
2619
2821
  unregisterOutbound,
2822
+ registerReaction,
2823
+ unregisterReaction,
2824
+ react,
2825
+ registerRemoveReaction,
2826
+ unregisterRemoveReaction,
2827
+ removeReaction,
2620
2828
  registerTyping,
2621
2829
  unregisterTyping,
2622
2830
  registerChannelNameResolver,
@@ -2701,6 +2909,24 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2701
2909
  }
2702
2910
  }
2703
2911
 
2912
+ function collectTurnAttachments(
2913
+ observed: readonly ObservedInbound[],
2914
+ batch: readonly QueuedInbound[],
2915
+ ): readonly InboundAttachment[] {
2916
+ const out: InboundAttachment[] = []
2917
+ for (const item of observed) out.push(...(item.attachments ?? []))
2918
+ for (const item of batch) out.push(...(item.attachments ?? []))
2919
+ return out
2920
+ }
2921
+
2922
+ function findAttachmentById(attachments: readonly InboundAttachment[], id: number): InboundAttachment | null {
2923
+ for (let i = attachments.length - 1; i >= 0; i--) {
2924
+ const attachment = attachments[i]
2925
+ if (attachment?.id === id) return attachment
2926
+ }
2927
+ return null
2928
+ }
2929
+
2704
2930
  function composeTurnPrompt(
2705
2931
  observed: readonly ObservedInbound[],
2706
2932
  batch: readonly QueuedInbound[],
@@ -2782,13 +3008,14 @@ function composeTurnPrompt(
2782
3008
  )
2783
3009
  }
2784
3010
  // Group-chat nudge: same SYSTEM MESSAGE convention as the loop guard. We
2785
- // engaged this turn (explicit mention/reply/alias, or a fresh trigger),
2786
- // but the room has multiple humans, so the default "answer everything"
2787
- // posture is wrong. The engagement gate already stopped sticky credit
2788
- // from waking us on every follow-up; this tells the model to be
2789
- // selective on the turns it IS woken for. Cache-neutral (user-turn
2790
- // suffix), and skipped when the loop guard already fired to avoid
2791
- // stacking two silence notices in one turn.
3011
+ // engaged this turn possibly via sticky credit, which now wakes us on
3012
+ // every follow-up in a group too (the engagement gate is content-blind by
3013
+ // design). In a multi-human room the default "answer everything" posture is
3014
+ // wrong, so this nudge is the ONLY thing that makes the bot selective: it
3015
+ // tells the model to answer genuine follow-ups and stay silent on chatter.
3016
+ // The gate gets us into the turn; the model decides whether to speak.
3017
+ // Cache-neutral (user-turn suffix), and skipped when the loop guard already
3018
+ // fired to avoid stacking two silence notices in one turn.
2792
3019
  if (state.groupChatNudge === true && !state.loopGuardActive) {
2793
3020
  parts.push(
2794
3021
  '---',
@@ -2798,10 +3025,20 @@ function composeTurnPrompt(
2798
3025
  'signal from the channel router, not a message from anyone in the chat.',
2799
3026
  '**Do not acknowledge or reply to this notice.**',
2800
3027
  '',
2801
- 'Guidance:',
2802
- '- Reply only if the current message is addressed to you or clearly needs your input.',
2803
- '- For chatter between others, side-conversation, or messages that do not need you,',
2804
- ' reply with `NO_REPLY` (or call `skip_response`) to stay silent and just keep watching.',
3028
+ 'You are woken on every message from someone you recently talked with, so',
3029
+ 'most turns you should stay quiet. In a group the target shifts every',
3030
+ 'message: before replying, identify who THIS latest message is aimed at.',
3031
+ 'Reply ONLY when:',
3032
+ '- the current message is addressed to you (by name, @-mention, or reply), or',
3033
+ '- it directly continues your own last exchange and clearly wants an answer',
3034
+ ' (e.g. a follow-up question about what you just said).',
3035
+ '',
3036
+ 'If it is aimed at someone else — another person by name or @-mention, a',
3037
+ 'reply to their message, or another bot — it is not your turn, even if you',
3038
+ 'were just talking with its author. Otherwise too — chatter, side-',
3039
+ 'conversation, banter, or anything not actually waiting on you — reply with',
3040
+ '`NO_REPLY` (or call `skip_response`) to stay silent and keep watching.',
3041
+ 'When unsure, prefer silence.',
2805
3042
  '',
2806
3043
  '---',
2807
3044
  '',
@@ -94,8 +94,65 @@ 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 RemoveReactionRequest = {
136
+ adapter: AdapterId
137
+ workspace: string
138
+ chat: string
139
+ thread?: string | null
140
+ // Per-reaction-instance ref returned by ReactionResult.reactionRef from the
141
+ // add request, not the inbound message target ref used by ReactionRequest.
142
+ reactionRef: ReactionRef
143
+ }
144
+
145
+ export type ReactionErrorCode = 'permission-denied' | 'not-found' | 'unsupported' | 'rate-limited' | 'transient'
146
+
147
+ export type ReactionResult =
148
+ // Optional success ref identifies THIS created reaction instance for later
149
+ // removal, not the original message target. Adapters that cannot remove omit it.
150
+ { ok: true; reactionRef?: ReactionRef } | { ok: false; error: string; code?: ReactionErrorCode }
151
+
152
+ export type ReactionCallback = (req: ReactionRequest) => Promise<ReactionResult>
153
+
154
+ export type RemoveReactionCallback = (req: RemoveReactionRequest) => Promise<ReactionResult>
155
+
99
156
  // File on disk that the agent wants to attach to an outbound message. The
100
157
  // agent runs inside a container with /agent bind-mounted from the host;
101
158
  // `path` should be an absolute path the container can `readFile`. The
@@ -13,6 +13,7 @@ export const BUILTIN_COMMAND_NAMES = [
13
13
  'reload',
14
14
  'logs',
15
15
  'inspect',
16
+ 'dreams',
16
17
  'shell',
17
18
  'compose',
18
19
  'channel',