typeclaw 0.19.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.
@@ -54,6 +54,10 @@ import type {
54
54
  OutboundCallback,
55
55
  OutboundMessage,
56
56
  QuoteAnchorSource,
57
+ ReactionCallback,
58
+ ReactionRef,
59
+ ReactionRequest,
60
+ ReactionResult,
57
61
  ResolvedChannelNames,
58
62
  SendErrorCode,
59
63
  SendResult,
@@ -108,6 +112,7 @@ export const SESSION_GC_INTERVAL_MS = 60 * 1000
108
112
  // Enforced inside router.send for `source: 'tool'` callers; system
109
113
  // recovery paths (`source: 'system'`) bypass.
110
114
  export const MAX_CHANNEL_SENDS_PER_TURN = 10
115
+ export const ENGAGE_REACTION_EMOJI = 'eyes'
111
116
  // Ceiling on tool-source channel sends that a same-turn router policy DENIED
112
117
  // without delivering — `skip-locked`, `turn-cap`, or `duplicate`. Such denials
113
118
  // return a soft error and do NOT increment `consecutiveSends`, so a model that
@@ -276,6 +281,7 @@ type QueuedInbound = {
276
281
  authorName: string
277
282
  authorIsBot: boolean
278
283
  externalMessageId: string
284
+ reactionRef?: ReactionRef
279
285
  isBotMention: boolean
280
286
  replyToBotMessageId: string | null
281
287
  isDm: boolean
@@ -324,6 +330,14 @@ type LiveSession = {
324
330
  originRef: { current: SessionOrigin | undefined }
325
331
  promptQueue: QueuedInbound[]
326
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[]
327
341
  draining: boolean
328
342
  debounceTimer: ReturnType<typeof setTimeout> | null
329
343
  typingTimer: ReturnType<typeof setInterval> | null
@@ -334,6 +348,11 @@ type LiveSession = {
334
348
  firstUnprocessedAt: number
335
349
  currentTurnAuthorId: string | null
336
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
337
356
  lastTurnAuthorIds: Set<string>
338
357
  // Mirror of currentTurnAuthorId at end-of-turn (the LAST speaker of the
339
358
  // prior batch), preserved across the drain finally-block which resets
@@ -511,6 +530,14 @@ export type ChannelRouter = {
511
530
  }) => { count: number; windowMs: number }
512
531
  registerOutbound: (adapter: ChannelKey['adapter'], cb: OutboundCallback) => void
513
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>
514
541
  registerTyping: (adapter: ChannelKey['adapter'], cb: TypingCallback) => void
515
542
  unregisterTyping: (adapter: ChannelKey['adapter'], cb: TypingCallback) => void
516
543
  registerChannelNameResolver: (adapter: ChannelKey['adapter'], resolver: ChannelNameResolver) => void
@@ -703,6 +730,7 @@ export type ClaimHandler = (input: ClaimHandlerInput) => Promise<ClaimHandlerOut
703
730
  const GRANT_ALL_PERMISSIONS: PermissionService = {
704
731
  has: () => true,
705
732
  resolveRole: () => 'owner',
733
+ compareRoleSeverity: () => 1,
706
734
  describe: () => ({ role: 'owner', permissions: [CORE_PERMISSIONS.channelRespond] }),
707
735
  replaceRoles: () => {},
708
736
  }
@@ -728,6 +756,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
728
756
  // teardown was meant to clear.
729
757
  let liveGeneration = 0
730
758
  const outboundCallbacks = new Map<ChannelKey['adapter'], Set<OutboundCallback>>()
759
+ const reactionCallbacks = new Map<ChannelKey['adapter'], Set<ReactionCallback>>()
731
760
  const typingCallbacks = new Map<ChannelKey['adapter'], Set<TypingCallback>>()
732
761
  const channelNameResolvers = new Map<ChannelKey['adapter'], Set<ChannelNameResolver>>()
733
762
  const membershipResolvers = new Map<ChannelKey['adapter'], Set<MembershipResolver>>()
@@ -1081,6 +1110,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1081
1110
  promptQueue: [],
1082
1111
  pendingSystemReminders: [],
1083
1112
  contextBuffer: [],
1113
+ currentTurnAttachments: [],
1084
1114
  draining: false,
1085
1115
  debounceTimer: null,
1086
1116
  typingTimer: null,
@@ -1091,6 +1121,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1091
1121
  firstUnprocessedAt: 0,
1092
1122
  currentTurnAuthorId: null,
1093
1123
  currentTurnAuthorIds: new Set(),
1124
+ currentTurnReactionRef: null,
1094
1125
  // `lastTurnAuthorId` (string, used for `lastInboundAuthorId` in
1095
1126
  // origin) and `lastTurnAuthorIds` (Set, used by
1096
1127
  // `grantStickyForReplyTargets` as the fallback when
@@ -1451,6 +1482,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1451
1482
  ...(live.resolvedNames.chatName !== undefined ? { chatName: live.resolvedNames.chatName } : {}),
1452
1483
  thread: live.key.thread,
1453
1484
  ...(live.currentTurnAuthorId !== null ? { lastInboundAuthorId: live.currentTurnAuthorId } : {}),
1485
+ ...(live.currentTurnReactionRef !== null ? { reactionRef: live.currentTurnReactionRef } : {}),
1454
1486
  participants: live.participants,
1455
1487
  ...(membership !== null ? { membership } : {}),
1456
1488
  }
@@ -1494,10 +1526,12 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1494
1526
  const batch = live.promptQueue.splice(0, live.promptQueue.length)
1495
1527
  const observed = live.contextBuffer.splice(0, live.contextBuffer.length)
1496
1528
  const reminders = live.pendingSystemReminders.splice(0, live.pendingSystemReminders.length)
1529
+ live.currentTurnAttachments = collectTurnAttachments(observed, batch)
1497
1530
 
1498
1531
  if (batch.length > 0) {
1499
1532
  live.currentTurnAuthorId = batch[batch.length - 1]!.authorId
1500
1533
  live.currentTurnAuthorIds = new Set(batch.map((m) => m.authorId))
1534
+ live.currentTurnReactionRef = batch[batch.length - 1]!.reactionRef ?? null
1501
1535
  live.consecutiveSends.clear()
1502
1536
  live.lastSentText.clear()
1503
1537
  live.pendingQuoteCandidate = captureQuoteCandidate(live.key.adapter, batch, observed)
@@ -1568,6 +1602,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1568
1602
  live.draining = false
1569
1603
  live.currentTurnAuthorId = null
1570
1604
  live.currentTurnAuthorIds = new Set()
1605
+ live.currentTurnReactionRef = null
1606
+ live.currentTurnAttachments = []
1571
1607
  await stopTypingHeartbeat(live)
1572
1608
  }
1573
1609
  }
@@ -1816,6 +1852,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1816
1852
 
1817
1853
  publishInbound(event, 'engage', live.sessionId)
1818
1854
 
1855
+ autoReactOnEngage(event)
1856
+
1819
1857
  updateLoopGuard(live, event)
1820
1858
 
1821
1859
  enqueue(live, event)
@@ -1908,6 +1946,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1908
1946
  authorName: event.authorName,
1909
1947
  authorIsBot: event.authorIsBot,
1910
1948
  externalMessageId: event.externalMessageId,
1949
+ ...(event.reactionRef !== undefined ? { reactionRef: event.reactionRef } : {}),
1911
1950
  isBotMention: event.isBotMention,
1912
1951
  replyToBotMessageId: event.replyToBotMessageId,
1913
1952
  isDm: event.isDm,
@@ -1925,6 +1964,69 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1925
1964
  set.add(cb)
1926
1965
  }
1927
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
+
1928
2030
  const unregisterOutbound = (adapter: ChannelKey['adapter'], cb: OutboundCallback): void => {
1929
2031
  outboundCallbacks.get(adapter)?.delete(cb)
1930
2032
  }
@@ -2058,9 +2160,14 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2058
2160
  // Walk newest → oldest so that when an id collides across messages
2059
2161
  // (e.g. two photos in the same session each labelled `#1`) the agent's
2060
2162
  // `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.
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
2064
2171
  const haystacks: ReadonlyArray<ReadonlyArray<{ attachments?: readonly InboundAttachment[] }>> = [
2065
2172
  live.promptQueue,
2066
2173
  live.contextBuffer,
@@ -2068,8 +2175,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2068
2175
  for (const haystack of haystacks) {
2069
2176
  for (let i = haystack.length - 1; i >= 0; i--) {
2070
2177
  const item = haystack[i]
2071
- const found = item?.attachments?.find((attachment) => attachment.id === args.id)
2072
- if (found !== undefined) return found
2178
+ const hit = item?.attachments?.find((attachment) => attachment.id === args.id)
2179
+ if (hit !== undefined) return hit
2073
2180
  }
2074
2181
  }
2075
2182
  return null
@@ -2079,6 +2186,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2079
2186
  const live = liveSessions.get(channelKeyId(args))
2080
2187
  if (live === undefined) return []
2081
2188
  const ids = new Set<number>()
2189
+ for (const attachment of live.currentTurnAttachments) ids.add(attachment.id)
2082
2190
  for (const item of [...live.promptQueue, ...live.contextBuffer]) {
2083
2191
  for (const attachment of item.attachments ?? []) ids.add(attachment.id)
2084
2192
  }
@@ -2617,6 +2725,9 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2617
2725
  getSendRate,
2618
2726
  registerOutbound,
2619
2727
  unregisterOutbound,
2728
+ registerReaction,
2729
+ unregisterReaction,
2730
+ react,
2620
2731
  registerTyping,
2621
2732
  unregisterTyping,
2622
2733
  registerChannelNameResolver,
@@ -2701,6 +2812,24 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2701
2812
  }
2702
2813
  }
2703
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
+
2704
2833
  function composeTurnPrompt(
2705
2834
  observed: readonly ObservedInbound[],
2706
2835
  batch: readonly QueuedInbound[],
@@ -2782,13 +2911,14 @@ function composeTurnPrompt(
2782
2911
  )
2783
2912
  }
2784
2913
  // 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.
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.
2792
2922
  if (state.groupChatNudge === true && !state.loopGuardActive) {
2793
2923
  parts.push(
2794
2924
  '---',
@@ -2798,10 +2928,15 @@ function composeTurnPrompt(
2798
2928
  'signal from the channel router, not a message from anyone in the chat.',
2799
2929
  '**Do not acknowledge or reply to this notice.**',
2800
2930
  '',
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.',
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.',
2805
2940
  '',
2806
2941
  '---',
2807
2942
  '',
@@ -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`),
@@ -2,6 +2,12 @@ import { runInspect, type RunInspectOptions, type RunInspectResult } from './ind
2
2
 
3
3
  export type RunInspectLoopOptions = Omit<RunInspectOptions, 'escSignal'> & {
4
4
  newEscSignal: () => AbortSignal
5
+ // Runs after every runInspect attempt settles. The caller disarms the raw-mode
6
+ // ESC listener here so the live tail releases stdin before clack re-opens the
7
+ // picker: an ESC-aborted tail leaves the listener armed (raw mode on, 'data'
8
+ // handler attached), and handing clack that flowing stream freezes the picker
9
+ // on SSH/Bun pseudo-TTYs.
10
+ afterEscStream?: () => void
5
11
  }
6
12
 
7
13
  export async function runInspectLoop(opts: RunInspectLoopOptions): Promise<RunInspectResult> {
@@ -23,7 +29,12 @@ export async function runInspectLoop(opts: RunInspectLoopOptions): Promise<RunIn
23
29
  if (sessionArg !== undefined) callOpts.sessionIdOrPrefix = sessionArg
24
30
  else delete (callOpts as { sessionIdOrPrefix?: string }).sessionIdOrPrefix
25
31
 
26
- const result = await runInspect(callOpts)
32
+ let result: RunInspectResult
33
+ try {
34
+ result = await runInspect(callOpts)
35
+ } finally {
36
+ opts.afterEscStream?.()
37
+ }
27
38
  if (!result.ok) return result
28
39
  if (result.escToPicker !== true) return result
29
40
  sessionArg = undefined
@@ -9,6 +9,12 @@ export type PermissionService = {
9
9
  has(origin: SessionOrigin | undefined, permission: string): boolean
10
10
  resolveRole(origin: SessionOrigin | undefined): string
11
11
  describe(origin: SessionOrigin | undefined): { role: string; permissions: readonly string[] }
12
+ // Orders two role names on the severity tower so callers can cap an
13
+ // action to the requester's role (a guest turn must not read the output
14
+ // of a member-spawned subagent). `undefined` means an unknown role on
15
+ // either side and MUST be treated as deny, never allow — mistreating it
16
+ // as allow reopens the privilege-escalation hole this gate closes.
17
+ compareRoleSeverity(a: string, b: string): -1 | 0 | 1 | undefined
12
18
  // Rebuilds the resolved role table from the given roles config, preserving
13
19
  // the same plugin-permission set captured at construction time. Used by
14
20
  // the config reloadable so role match-rule edits (typeclaw role claim,
@@ -25,6 +31,7 @@ export type UnknownPermissionWarning = {
25
31
  export const noopPermissionService: PermissionService = {
26
32
  has: () => false,
27
33
  resolveRole: () => 'guest',
34
+ compareRoleSeverity: () => undefined,
28
35
  describe: () => ({ role: 'guest', permissions: [] }),
29
36
  replaceRoles: () => {},
30
37
  }
@@ -139,6 +146,15 @@ export function createPermissionService(opts: CreatePermissionServiceOptions = {
139
146
  return 'guest'
140
147
  }
141
148
 
149
+ function roleSeverity(name: string): number | undefined {
150
+ if (name === 'owner') return 4
151
+ if (name === 'trusted') return 3
152
+ if (name === 'member') return 1
153
+ if (name === 'guest') return 0
154
+ if (byName.has(name)) return 2
155
+ return undefined
156
+ }
157
+
142
158
  return {
143
159
  has(origin, permission) {
144
160
  // Fail-safe floor: an undefined origin holds nothing, regardless of
@@ -156,6 +172,14 @@ export function createPermissionService(opts: CreatePermissionServiceOptions = {
156
172
  return role.permissions.includes(permission)
157
173
  },
158
174
  resolveRole,
175
+ compareRoleSeverity(a, b) {
176
+ const aRank = roleSeverity(a)
177
+ const bRank = roleSeverity(b)
178
+ if (aRank === undefined || bRank === undefined) return undefined
179
+ if (aRank < bRank) return -1
180
+ if (aRank > bRank) return 1
181
+ return 0
182
+ },
159
183
  describe(origin) {
160
184
  const name = resolveRole(origin)
161
185
  const role = byName.get(name)
@@ -50,7 +50,7 @@ The `reviewer` subagent is the analyst; you are the integration layer between it
50
50
 
51
51
  2. **Spawn the `reviewer` subagent with the PR target.** Use `run_in_background: true` so you stay responsive while the deep model works. Pass the PR URL (or `owner/repo#N`) plus any context the requester gave you (focus areas, specific files, etc.). The reviewer fetches the diff itself (`gh pr diff`, `gh api /repos/.../pulls/<n>`), loads the `code-review` skill, and returns a `<review>` block whose code findings carry `location="path:line"`.
52
52
 
53
- If you post an "on it" acknowledgement before spawning the reviewer, it **must** be `channel_reply({ text: "…", continue: true })` a bare reply ends the turn and the review never starts (see "Mid-turn status replies need `continue: true`" above).
53
+ Do **not** post an "on it" acknowledgement comment before spawning the reviewer the runtime already adds an :eyes: reaction to the PR the moment it engages, so a "looking into this" comment is redundant noise. Just spawn the reviewer with `run_in_background: true` and keep working; the formal review is your reply. If you want to acknowledge explicitly, use `channel_react({ emoji: "eyes" })`, which reacts without posting a comment.
54
54
 
55
55
  3. **Wait for the completion `<system-reminder>`,** then call `subagent_output({ task_id })` to read the reviewer's final assistant message. The structured payload looks like:
56
56
 
@@ -118,7 +118,15 @@ The `reviewer` subagent is the analyst; you are the integration layer between it
118
118
 
119
119
  The returned `id`/`state` is your proof the formal review posted. If the call errored or the review is absent, do **not** fall back to a top-level `channel_reply` that _claims_ a review was posted — fix the payload (most often a `line` that isn't part of the diff; re-anchor it or move that finding to the top-level `body`) and resubmit. A trace reply that says "Posted review" when no review exists is worse than silence.
120
120
 
121
- 6. **End the turn with `skip_response`, not a trace reply.** The formal review from step 4 already landed _in this PR_ it carries the summary, the verdict, and the inline comments. A `channel_reply` here does **not** go to a separate operator channel; on GitHub it posts another public comment on the same PR. A one-line "Posted review on PR #N: …" narrated into the PR thread is meta-commentary addressed to a phantom operator, and it reads absurdly next to the review it claims to point at. So once step 5 confirms the review exists, call `skip_response({ reason: "review posted via gh api" })` to close the turn silently. Only fall back to `channel_reply` when there was **no** formal review to post the zero-actionable-findings branch below uses `channel_reply`/issue comments _as_ the substantive reply.
121
+ 6. **Drop the decoy reviewer App auth only, request-path only.** When this review was triggered by an explicit review-request inbound (path A) **and** you are running under **GitHub App** auth, remove the decoy reviewer from the PR's requested-reviewers list once the review has landed (after step 5 confirms it). Why: GitHub auto-adds **you** (the App account) to the PR's reviewers the moment your formal review posts, so the "reviewed" state is already recorded under the App identity but the **decoy** account stays pinned in the requested-reviewers list as a perpetual "review requested", as if the review never happened. Removing it is the natural "I'm done, drop me" cleanup a human reviewer gets for free. The decoy login is the App slug `selfLogin` with the trailing `[bot]` removed (`my-app[bot]` `my-app`); see [GitHub decoy reviewer](/docs/internals/github-decoy-reviewer).
122
+
123
+ ```sh
124
+ gh api -X DELETE /repos/owner/repo/pulls/<N>/requested_reviewers -f 'reviewers[]=<decoy-login>'
125
+ ```
126
+
127
+ **Skip this entirely** when (a) you are under **PAT** auth — there is no decoy; the bot is a real user GitHub keeps listed as a reviewer, and removing yourself there is not wanted — or (b) the review came from a **plain-language** ask (path B) or a **team** request, where no decoy user was ever placed on the requested-reviewers list and the DELETE would be a no-op/404. The removal is safe under the adapter's self-loop guard: you make the `DELETE` authenticated as the App, so the `review_request_removed` webhook GitHub emits has your bot actor (`slug[bot]`) as `sender`, which the classifier drops, so it never wakes a fresh session (see "Self-loop safety" below). Treat a failure here as non-fatal — the review already landed; do not retry in a loop or surface it to the PR thread.
128
+
129
+ 7. **End the turn with `skip_response`, not a trace reply.** The formal review from step 4 already landed _in this PR_ — it carries the summary, the verdict, and the inline comments. A `channel_reply` here does **not** go to a separate operator channel; on GitHub it posts another public comment on the same PR. A one-line "Posted review on PR #N: …" narrated into the PR thread is meta-commentary addressed to a phantom operator, and it reads absurdly next to the review it claims to point at. So once step 5 confirms the review exists (and step 6 has dropped the decoy, if applicable), call `skip_response({ reason: "review posted via gh api" })` to close the turn silently. Only fall back to `channel_reply` when there was **no** formal review to post — the zero-actionable-findings branch below uses `channel_reply`/issue comments _as_ the substantive reply.
122
130
 
123
131
  ### Zero actionable findings
124
132
 
@@ -147,3 +155,5 @@ For App auth, `GH_TOKEN` is an installation access token that refreshes automati
147
155
  ## Self-loop safety
148
156
 
149
157
  The adapter will **not** wake you when you assign yourself as a reviewer (e.g., via `gh pr edit --add-reviewer`). It will only wake you when someone else requests your review.
158
+
159
+ The same guard covers **removing** a reviewer: when you drop the decoy after a review (step 6 of the PR review flow), you act as the App, so the `review_request_removed` webhook GitHub emits carries your bot actor (`slug[bot]`) as its `sender`, which the classifier drops. So the cleanup never echoes back as a fresh wake. Both directions — add and remove — are matched on `sender.login` (against either the bot actor or its decoy), so any reviewer-list mutation you make yourself stays silent.