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.
- package/package.json +1 -1
- package/src/agent/index.ts +7 -0
- package/src/agent/live-subagents.ts +4 -0
- package/src/agent/session-origin.ts +32 -10
- package/src/agent/tools/channel-react.ts +79 -0
- package/src/agent/tools/spawn-subagent.ts +1 -0
- package/src/agent/tools/subagent-access.ts +67 -0
- package/src/agent/tools/subagent-cancel.ts +11 -6
- package/src/agent/tools/subagent-output.ts +10 -2
- package/src/channels/adapters/discord-bot.ts +238 -20
- package/src/channels/adapters/github/inbound.ts +10 -0
- package/src/channels/adapters/github/index.ts +9 -0
- package/src/channels/adapters/github/permission-guidance.ts +20 -1
- package/src/channels/adapters/github/reactions.ts +142 -0
- package/src/channels/engagement.ts +16 -19
- package/src/channels/router.ts +151 -16
- package/src/channels/types.ts +42 -0
- package/src/cli/inspect.ts +3 -0
- package/src/inspect/loop.ts +12 -1
- package/src/permissions/permissions.ts +24 -0
- package/src/skills/typeclaw-channel-github/SKILL.md +12 -2
package/src/channels/router.ts
CHANGED
|
@@ -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.
|
|
2062
|
-
//
|
|
2063
|
-
//
|
|
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
|
|
2072
|
-
if (
|
|
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
|
|
2786
|
-
//
|
|
2787
|
-
//
|
|
2788
|
-
//
|
|
2789
|
-
//
|
|
2790
|
-
//
|
|
2791
|
-
//
|
|
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
|
-
'
|
|
2802
|
-
'
|
|
2803
|
-
'-
|
|
2804
|
-
'
|
|
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
|
'',
|
package/src/channels/types.ts
CHANGED
|
@@ -94,8 +94,50 @@ export type InboundMessage = {
|
|
|
94
94
|
// means "unknown" — the formatter renders such lines without a
|
|
95
95
|
// timestamp prefix instead of stamping them with the wrong clock.
|
|
96
96
|
ts: number
|
|
97
|
+
// Opaque, adapter-owned handle for the entity an emoji reaction would
|
|
98
|
+
// attach to. The classifier stamps it because only there is the platform-
|
|
99
|
+
// side target type still known (GitHub: issue body vs issue-comment vs
|
|
100
|
+
// pr-review-comment — all collapse to the same `chat`/`externalMessageId`
|
|
101
|
+
// pair downstream). Mirrors the `InboundAttachment.ref` opaque-handle
|
|
102
|
+
// pattern: ONLY the originating adapter's ReactionCallback knows how to
|
|
103
|
+
// parse `value`; the router and tools treat it as a pass-through token and
|
|
104
|
+
// never inspect it. Omitted when the inbound has no reactable target (e.g.
|
|
105
|
+
// synthetic review-request inbounds, or adapters without reaction support).
|
|
106
|
+
reactionRef?: ReactionRef
|
|
97
107
|
}
|
|
98
108
|
|
|
109
|
+
// Opaque reaction target handle. `adapter` lets the router refuse a ref to
|
|
110
|
+
// the wrong adapter's callback; `value` is an adapter-private encoding (for
|
|
111
|
+
// GitHub, a JSON blob distinguishing issue / issue-comment / pr-review-comment
|
|
112
|
+
// / discussion plus the numeric id). Never rendered into prompt context.
|
|
113
|
+
export type ReactionRef = {
|
|
114
|
+
adapter: AdapterId
|
|
115
|
+
value: string
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// A request to add an emoji reaction to a previously-seen inbound. Distinct
|
|
119
|
+
// from OutboundMessage on purpose: reactions are best-effort side effects, not
|
|
120
|
+
// messages, so they bypass `send()`'s flood guard, per-turn send cap, exact-
|
|
121
|
+
// duplicate guard, sticky-credit grants, and typing heartbeat — all of which
|
|
122
|
+
// are message semantics that would misbehave on a reaction.
|
|
123
|
+
export type ReactionRequest = {
|
|
124
|
+
adapter: AdapterId
|
|
125
|
+
workspace: string
|
|
126
|
+
chat: string
|
|
127
|
+
thread?: string | null
|
|
128
|
+
reactionRef: ReactionRef
|
|
129
|
+
// Bare emoji name, no surrounding colons (e.g. 'eyes', '+1'). Each adapter
|
|
130
|
+
// maps this to its platform's reaction vocabulary and rejects unsupported
|
|
131
|
+
// names via `code: 'unsupported'`.
|
|
132
|
+
emoji: string
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export type ReactionErrorCode = 'permission-denied' | 'not-found' | 'unsupported' | 'rate-limited' | 'transient'
|
|
136
|
+
|
|
137
|
+
export type ReactionResult = { ok: true } | { ok: false; error: string; code?: ReactionErrorCode }
|
|
138
|
+
|
|
139
|
+
export type ReactionCallback = (req: ReactionRequest) => Promise<ReactionResult>
|
|
140
|
+
|
|
99
141
|
// File on disk that the agent wants to attach to an outbound message. The
|
|
100
142
|
// agent runs inside a container with /agent bind-mounted from the host;
|
|
101
143
|
// `path` should be an absolute path the container can `readFile`. The
|
package/src/cli/inspect.ts
CHANGED
|
@@ -75,6 +75,9 @@ export const inspectCommand = defineCommand({
|
|
|
75
75
|
if (escListener === null) return new AbortController().signal
|
|
76
76
|
return escListener.armForStream()
|
|
77
77
|
},
|
|
78
|
+
afterEscStream: () => {
|
|
79
|
+
escListener?.pause()
|
|
80
|
+
},
|
|
78
81
|
...(liveHint !== undefined ? { liveHint } : {}),
|
|
79
82
|
stdout: (line) => process.stdout.write(`${line}\n`),
|
|
80
83
|
stderr: (line) => process.stderr.write(`${line}\n`),
|
package/src/inspect/loop.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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. **
|
|
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.
|