typeclaw 0.20.0 → 0.22.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 (55) hide show
  1. package/package.json +2 -1
  2. package/src/agent/index.ts +55 -1
  3. package/src/agent/loop-guard.ts +180 -53
  4. package/src/agent/restart/index.ts +101 -0
  5. package/src/agent/tools/restart.ts +23 -52
  6. package/src/bundled-plugins/bun-hygiene/README.md +82 -0
  7. package/src/bundled-plugins/bun-hygiene/index.ts +11 -0
  8. package/src/bundled-plugins/bun-hygiene/policy.ts +318 -0
  9. package/src/bundled-plugins/github-cli-auth/gh-command.ts +98 -6
  10. package/src/bundled-plugins/github-cli-auth/graphql-auth-nudge.ts +80 -0
  11. package/src/bundled-plugins/github-cli-auth/index.ts +7 -0
  12. package/src/bundled-plugins/memory/memory-logger.ts +6 -2
  13. package/src/bundled-plugins/reviewer/skills/code-review.ts +8 -0
  14. package/src/channels/adapters/discord-bot-classify.ts +8 -2
  15. package/src/channels/adapters/discord-bot.ts +29 -2
  16. package/src/channels/adapters/github/decoy-reviewer.ts +43 -0
  17. package/src/channels/adapters/github/inbound.ts +92 -1
  18. package/src/channels/adapters/github/index.ts +12 -1
  19. package/src/channels/adapters/github/reactions.ts +138 -4
  20. package/src/channels/adapters/slack-bot-classify.ts +2 -2
  21. package/src/channels/adapters/slack-bot.ts +129 -7
  22. package/src/channels/engagement.ts +71 -31
  23. package/src/channels/manager.ts +8 -0
  24. package/src/channels/router.ts +180 -25
  25. package/src/channels/schema.ts +18 -0
  26. package/src/channels/types.ts +16 -1
  27. package/src/cli/builtins.ts +1 -0
  28. package/src/cli/dreams.ts +148 -0
  29. package/src/cli/index.ts +1 -0
  30. package/src/cli/inspect.ts +2 -1
  31. package/src/cli/ui.ts +34 -0
  32. package/src/commands/index.ts +5 -2
  33. package/src/config/config.ts +89 -0
  34. package/src/dreams/git.ts +85 -0
  35. package/src/dreams/index.ts +134 -0
  36. package/src/dreams/parse.ts +224 -0
  37. package/src/dreams/render.ts +155 -0
  38. package/src/dreams/types.ts +50 -0
  39. package/src/mcp/catalog.ts +29 -0
  40. package/src/mcp/client.ts +236 -0
  41. package/src/mcp/index.ts +25 -0
  42. package/src/mcp/manager.ts +156 -0
  43. package/src/mcp/tools.ts +190 -0
  44. package/src/permissions/builtins.ts +9 -0
  45. package/src/reload/format.ts +14 -0
  46. package/src/reload/index.ts +1 -0
  47. package/src/run/bundled-plugins.ts +7 -0
  48. package/src/run/channel-session-factory.ts +3 -0
  49. package/src/run/index.ts +38 -1
  50. package/src/server/command-runner.ts +5 -0
  51. package/src/server/index.ts +53 -0
  52. package/src/shared/protocol.ts +2 -0
  53. package/src/skills/typeclaw-channel-github/SKILL.md +86 -22
  54. package/src/tui/index.ts +70 -18
  55. package/typeclaw.schema.json +82 -0
@@ -7,7 +7,7 @@ import { createSession, renderTurnRoleAnchor, renderTurnTimeAnchor, type AgentSe
7
7
  import { subscribeProviderErrors } from '@/agent/provider-error'
8
8
  import type { ChannelParticipant, SessionOrigin } from '@/agent/session-origin'
9
9
  import { renderSubagentCompletionReminder } from '@/agent/subagent-completion-reminder'
10
- import { type Command, type CommandResult, createCommandRegistry } from '@/commands'
10
+ import { type Command, type CommandPermission, type CommandResult, createCommandRegistry } from '@/commands'
11
11
  import { CORE_PERMISSIONS, type PermissionService } from '@/permissions'
12
12
  import type { HookBus } from '@/plugin'
13
13
  import { extractClaimCode } from '@/role-claim'
@@ -51,6 +51,8 @@ import type {
51
51
  HistoryCallback,
52
52
  InboundAttachment,
53
53
  InboundMessage,
54
+ RemoveReactionCallback,
55
+ RemoveReactionRequest,
54
56
  OutboundCallback,
55
57
  OutboundMessage,
56
58
  QuoteAnchorSource,
@@ -282,6 +284,7 @@ type QueuedInbound = {
282
284
  authorIsBot: boolean
283
285
  externalMessageId: string
284
286
  reactionRef?: ReactionRef
287
+ engageReaction?: Promise<ReactionRef | null>
285
288
  isBotMention: boolean
286
289
  replyToBotMessageId: string | null
287
290
  isDm: boolean
@@ -353,6 +356,11 @@ type LiveSession = {
353
356
  // origin so `channel_react` reacts to the triggering message, not whichever
354
357
  // inbound happens to be latest in the queue. Null on reminder-only turns.
355
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>>
356
364
  lastTurnAuthorIds: Set<string>
357
365
  // Mirror of currentTurnAuthorId at end-of-turn (the LAST speaker of the
358
366
  // prior batch), preserved across the drain finally-block which resets
@@ -538,6 +546,9 @@ export type ChannelRouter = {
538
546
  registerReaction: (adapter: ChannelKey['adapter'], cb: ReactionCallback) => void
539
547
  unregisterReaction: (adapter: ChannelKey['adapter'], cb: ReactionCallback) => void
540
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>
541
552
  registerTyping: (adapter: ChannelKey['adapter'], cb: TypingCallback) => void
542
553
  unregisterTyping: (adapter: ChannelKey['adapter'], cb: TypingCallback) => void
543
554
  registerChannelNameResolver: (adapter: ChannelKey['adapter'], resolver: ChannelNameResolver) => void
@@ -709,6 +720,17 @@ export type CreateChannelRouterOptions = {
709
720
  // can diagnose silent drops from `typeclaw inspect` alone. Omitted in
710
721
  // tests that don't care about inspect surfacing.
711
722
  stream?: Stream
723
+ // Operate-the-agent command handlers. When set, the router registers the
724
+ // matching channel command (/reload, /restart) gated on session.admin
725
+ // (owner+trusted). Omitted means the command is not registered at all — it
726
+ // won't appear in /help and a text-prefix or native-slash invocation is
727
+ // treated as unknown. Production wiring (src/run/index.ts via the channel
728
+ // manager) supplies both; tests opt in per-case. `onReload` returns a short
729
+ // human-readable summary posted back to the channel; `onRestart` returns a
730
+ // confirmation string (the container exits shortly after, so the reply is
731
+ // best-effort).
732
+ onReload?: () => Promise<string>
733
+ onRestart?: () => Promise<string>
712
734
  }
713
735
 
714
736
  export type ClaimHandlerInput = {
@@ -745,6 +767,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
745
767
  const permissions = options.permissions ?? GRANT_ALL_PERMISSIONS
746
768
  const claimHandler = options.claimHandler
747
769
  const stream = options.stream
770
+ const onReload = options.onReload
771
+ const onRestart = options.onRestart
748
772
  const liveSessions = new Map<string, LiveSession>()
749
773
  const creating = new Map<string, Promise<LiveSession>>()
750
774
  // Bumped by tearDownAllLive() and stop() before they tear sessions down. An
@@ -757,6 +781,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
757
781
  let liveGeneration = 0
758
782
  const outboundCallbacks = new Map<ChannelKey['adapter'], Set<OutboundCallback>>()
759
783
  const reactionCallbacks = new Map<ChannelKey['adapter'], Set<ReactionCallback>>()
784
+ const removeReactionCallbacks = new Map<ChannelKey['adapter'], Set<RemoveReactionCallback>>()
760
785
  const typingCallbacks = new Map<ChannelKey['adapter'], Set<TypingCallback>>()
761
786
  const channelNameResolvers = new Map<ChannelKey['adapter'], Set<ChannelNameResolver>>()
762
787
  const membershipResolvers = new Map<ChannelKey['adapter'], Set<MembershipResolver>>()
@@ -767,7 +792,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
767
792
  // The /help handler reads the live registry to enumerate commands, so it
768
793
  // forward-references `commands`. Safe at runtime — the handler only runs on
769
794
  // invocation, long after the assignment below completes.
770
- const channelCommands: readonly Command<ChannelCommandContext>[] = [
795
+ const channelCommands: Command<ChannelCommandContext>[] = [
771
796
  {
772
797
  name: 'help',
773
798
  description: 'List available commands.',
@@ -788,6 +813,28 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
788
813
  },
789
814
  },
790
815
  ]
816
+ // /reload and /restart are registered only when the operate-the-agent
817
+ // callbacks are wired (production via the channel manager). Without them the
818
+ // capability doesn't exist for this router, so the commands stay absent from
819
+ // /help and resolve as unknown — never a silent no-op.
820
+ if (onReload !== undefined) {
821
+ channelCommands.push({
822
+ name: 'reload',
823
+ description: 'Reload typeclaw config and subsystems from disk.',
824
+ permission: 'session.admin',
825
+ requiresLiveSession: false,
826
+ handler: async () => ({ reply: await onReload() }),
827
+ })
828
+ }
829
+ if (onRestart !== undefined) {
830
+ channelCommands.push({
831
+ name: 'restart',
832
+ description: 'Restart the typeclaw container.',
833
+ permission: 'session.admin',
834
+ requiresLiveSession: false,
835
+ handler: async () => ({ reply: await onRestart() }),
836
+ })
837
+ }
791
838
  const commands = createCommandRegistry<ChannelCommandContext>(channelCommands)
792
839
 
793
840
  // Implicit dir-name alias: agent folder basename matches Docker
@@ -1122,6 +1169,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1122
1169
  currentTurnAuthorId: null,
1123
1170
  currentTurnAuthorIds: new Set(),
1124
1171
  currentTurnReactionRef: null,
1172
+ currentTurnEngageReactions: [],
1125
1173
  // `lastTurnAuthorId` (string, used for `lastInboundAuthorId` in
1126
1174
  // origin) and `lastTurnAuthorIds` (Set, used by
1127
1175
  // `grantStickyForReplyTargets` as the fallback when
@@ -1254,6 +1302,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1254
1302
  receivedAt: now(),
1255
1303
  ts: item.message.ts,
1256
1304
  source: 'prefetch',
1305
+ ...(item.message.attachments !== undefined ? { attachments: item.message.attachments } : {}),
1257
1306
  })
1258
1307
  } else {
1259
1308
  observed.push({
@@ -1532,10 +1581,14 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1532
1581
  live.currentTurnAuthorId = batch[batch.length - 1]!.authorId
1533
1582
  live.currentTurnAuthorIds = new Set(batch.map((m) => m.authorId))
1534
1583
  live.currentTurnReactionRef = batch[batch.length - 1]!.reactionRef ?? null
1584
+ live.currentTurnEngageReactions = batch.flatMap((m) =>
1585
+ m.engageReaction !== undefined ? [m.engageReaction] : [],
1586
+ )
1535
1587
  live.consecutiveSends.clear()
1536
1588
  live.lastSentText.clear()
1537
1589
  live.pendingQuoteCandidate = captureQuoteCandidate(live.key.adapter, batch, observed)
1538
1590
  } else if (live.lastTurnAuthorId !== null) {
1591
+ live.currentTurnEngageReactions = []
1539
1592
  // Reminder-only turn (batch.length === 0, reminders.length > 0):
1540
1593
  // restore the author identity from the prior turn so author-
1541
1594
  // scoped role resolution still works on this turn. The drain
@@ -1550,6 +1603,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1550
1603
  // author prior turn like alice→bob restores `bob`, not alice.
1551
1604
  live.currentTurnAuthorId = live.lastTurnAuthorId
1552
1605
  live.currentTurnAuthorIds = new Set(live.lastTurnAuthorIds)
1606
+ } else {
1607
+ live.currentTurnEngageReactions = []
1553
1608
  }
1554
1609
 
1555
1610
  // Update the live origin holder so this turn's tool.before events
@@ -1576,6 +1631,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1576
1631
  logger.info(`[channels] ${live.keyId} prompting batch=${batch.length} text_len=${text.length}`)
1577
1632
  const promptStart = now()
1578
1633
  const successfulSendsBeforePrompt = live.successfulChannelSends
1634
+ const engageAddPromises = live.currentTurnEngageReactions
1579
1635
  live.turnSeq++
1580
1636
  live.successfulSendsAtTurnStart = successfulSendsBeforePrompt
1581
1637
  live.policyDeniedToolSendsThisTurn.clear()
@@ -1590,6 +1646,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1590
1646
  live.consecutiveSends.clear()
1591
1647
  live.lastSentText.clear()
1592
1648
  } finally {
1649
+ const sentReplyThisTurn = live.successfulChannelSends > successfulSendsBeforePrompt
1650
+ if (sentReplyThisTurn) dropEngageReactionsAfterReply(live, engageAddPromises)
1593
1651
  await fireSessionTurnEnd(live)
1594
1652
  }
1595
1653
  await fireSessionIdle(live)
@@ -1603,6 +1661,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1603
1661
  live.currentTurnAuthorId = null
1604
1662
  live.currentTurnAuthorIds = new Set()
1605
1663
  live.currentTurnReactionRef = null
1664
+ live.currentTurnEngageReactions = []
1606
1665
  live.currentTurnAttachments = []
1607
1666
  await stopTypingHeartbeat(live)
1608
1667
  }
@@ -1776,9 +1835,10 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1776
1835
  logger.info(`[channels] ${keyId}: ignoring unknown command /${parsedCommand.name}`)
1777
1836
  return
1778
1837
  }
1779
- if (commandInfo.permission === 'session.control' && isSessionControlDenied(event)) {
1838
+ const requiredPermission = commandPermissionString(commandInfo.permission)
1839
+ if (requiredPermission !== null && !permissions.has(inboundAuthorOrigin(event), requiredPermission)) {
1780
1840
  logger.info(
1781
- `[channels] ${keyId}: denied command /${parsedCommand.name} by permissions (session.control) author=${event.authorId}`,
1841
+ `[channels] ${keyId}: denied command /${parsedCommand.name} by permissions (${requiredPermission}) author=${event.authorId}`,
1782
1842
  )
1783
1843
  return
1784
1844
  }
@@ -1852,11 +1912,11 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1852
1912
 
1853
1913
  publishInbound(event, 'engage', live.sessionId)
1854
1914
 
1855
- autoReactOnEngage(event)
1915
+ const engageReaction = autoReactOnEngage(event)
1856
1916
 
1857
1917
  updateLoopGuard(live, event)
1858
1918
 
1859
- enqueue(live, event)
1919
+ enqueue(live, event, engageReaction)
1860
1920
 
1861
1921
  // Start showing "typing..." the moment we know we're going to engage,
1862
1922
  // so users see the indicator during the debounce window — not just
@@ -1889,8 +1949,22 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1889
1949
  // operator can grant guest channelRespond for masked stranger turns)
1890
1950
  // cannot /stop another speaker's in-flight turn. session.control is
1891
1951
  // member-and-up by default.
1892
- const isSessionControlDenied = (event: InboundMessage): boolean =>
1893
- !permissions.has(inboundAuthorOrigin(event), CORE_PERMISSIONS.sessionControl)
1952
+ // Maps a command's declared permission tier to the concrete permission
1953
+ // string gated on both the text-prefix path (route) and the native-slash
1954
+ // path (executeCommand). 'none' is never gated. session.admin (owner+trusted,
1955
+ // not member) covers /reload and /restart, which mutate global agent state
1956
+ // and drop every in-flight session. Centralized so a new tier can't be
1957
+ // honored on one path and silently skipped on the other.
1958
+ const commandPermissionString = (permission: CommandPermission): string | null => {
1959
+ switch (permission) {
1960
+ case 'none':
1961
+ return null
1962
+ case 'session.control':
1963
+ return CORE_PERMISSIONS.sessionControl
1964
+ case 'session.admin':
1965
+ return CORE_PERMISSIONS.sessionAdmin
1966
+ }
1967
+ }
1894
1968
 
1895
1969
  const updateLoopGuard = (live: LiveSession, event: InboundMessage): void => {
1896
1970
  if (!event.authorIsBot) {
@@ -1938,7 +2012,11 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1938
2012
  }
1939
2013
  }
1940
2014
 
1941
- const enqueue = (live: LiveSession, event: InboundMessage): void => {
2015
+ const enqueue = (
2016
+ live: LiveSession,
2017
+ event: InboundMessage,
2018
+ engageReaction: Promise<ReactionRef | null> | null,
2019
+ ): void => {
1942
2020
  live.promptQueue.push({
1943
2021
  text: event.text,
1944
2022
  ...(event.attachments !== undefined && event.attachments.length > 0 ? { attachments: event.attachments } : {}),
@@ -1947,6 +2025,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1947
2025
  authorIsBot: event.authorIsBot,
1948
2026
  externalMessageId: event.externalMessageId,
1949
2027
  ...(event.reactionRef !== undefined ? { reactionRef: event.reactionRef } : {}),
2028
+ ...(engageReaction !== null ? { engageReaction } : {}),
1950
2029
  isBotMention: event.isBotMention,
1951
2030
  replyToBotMessageId: event.replyToBotMessageId,
1952
2031
  isDm: event.isDm,
@@ -1977,6 +2056,19 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1977
2056
  reactionCallbacks.get(adapter)?.delete(cb)
1978
2057
  }
1979
2058
 
2059
+ const registerRemoveReaction = (adapter: ChannelKey['adapter'], cb: RemoveReactionCallback): void => {
2060
+ let set = removeReactionCallbacks.get(adapter)
2061
+ if (!set) {
2062
+ set = new Set()
2063
+ removeReactionCallbacks.set(adapter, set)
2064
+ }
2065
+ set.add(cb)
2066
+ }
2067
+
2068
+ const unregisterRemoveReaction = (adapter: ChannelKey['adapter'], cb: RemoveReactionCallback): void => {
2069
+ removeReactionCallbacks.get(adapter)?.delete(cb)
2070
+ }
2071
+
1980
2072
  const react = async (req: ReactionRequest): Promise<ReactionResult> => {
1981
2073
  if (req.reactionRef.adapter !== req.adapter) {
1982
2074
  return { ok: false, error: 'reaction ref adapter mismatch', code: 'unsupported' }
@@ -2001,15 +2093,34 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2001
2093
  return lastError ?? { ok: false, error: 'no reaction callback handled request', code: 'unsupported' }
2002
2094
  }
2003
2095
 
2096
+ const removeReaction = async (req: RemoveReactionRequest): Promise<ReactionResult> => {
2097
+ if (req.reactionRef.adapter !== req.adapter) {
2098
+ return { ok: false, error: 'reaction ref adapter mismatch', code: 'unsupported' }
2099
+ }
2100
+ const callbacks = removeReactionCallbacks.get(req.adapter)
2101
+ if (!callbacks || callbacks.size === 0) {
2102
+ return { ok: false, error: `adapter "${req.adapter}" does not support reaction removal`, code: 'unsupported' }
2103
+ }
2104
+ let lastError: ReactionResult | undefined
2105
+ for (const cb of Array.from(callbacks)) {
2106
+ const result = await cb(req).catch(
2107
+ (err): ReactionResult => ({ ok: false, error: describe(err), code: 'transient' }),
2108
+ )
2109
+ if (result.ok) return result
2110
+ lastError = result
2111
+ }
2112
+ return lastError ?? { ok: false, error: 'no reaction removal callback handled request', code: 'unsupported' }
2113
+ }
2114
+
2004
2115
  // Best-effort acknowledgment: drop an :eyes: on the triggering inbound the
2005
2116
  // moment we decide to engage, replacing the old "On it" ack comment on
2006
2117
  // GitHub. Fire-and-forget so a reaction failure (missing permission, the
2007
2118
  // adapter not supporting reactions, a transient API error) can NEVER block
2008
2119
  // engagement, enqueueing, or the agent's actual reply. No reactionRef =
2009
2120
  // nothing reactable (synthetic inbounds, reaction-less adapters) = silent skip.
2010
- const autoReactOnEngage = (event: InboundMessage): void => {
2011
- if (event.reactionRef === undefined) return
2012
- void react({
2121
+ const autoReactOnEngage = (event: InboundMessage): Promise<ReactionRef | null> | null => {
2122
+ if (event.reactionRef === undefined) return null
2123
+ const addResult = react({
2013
2124
  adapter: event.adapter,
2014
2125
  workspace: event.workspace,
2015
2126
  chat: event.chat,
@@ -2017,6 +2128,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2017
2128
  reactionRef: event.reactionRef,
2018
2129
  emoji: ENGAGE_REACTION_EMOJI,
2019
2130
  })
2131
+ const addReactionRef = addResult.then((r) => (r.ok ? (r.reactionRef ?? null) : null)).catch(() => null)
2132
+ void addResult
2020
2133
  .then((result) => {
2021
2134
  if (!result.ok && result.code !== 'unsupported') {
2022
2135
  logger.info(`[channels] engage-react failed adapter=${event.adapter} chat=${event.chat}: ${result.error}`)
@@ -2025,6 +2138,37 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2025
2138
  .catch((err) => {
2026
2139
  logger.info(`[channels] engage-react threw adapter=${event.adapter} chat=${event.chat}: ${describe(err)}`)
2027
2140
  })
2141
+ return addReactionRef
2142
+ }
2143
+
2144
+ const dropEngageReactionsAfterReply = (live: LiveSession, addPromises: Array<Promise<ReactionRef | null>>): void => {
2145
+ for (const addPromise of addPromises) dropOneEngageReactionAfterReply(live, addPromise)
2146
+ }
2147
+
2148
+ const dropOneEngageReactionAfterReply = (live: LiveSession, addPromise: Promise<ReactionRef | null>): void => {
2149
+ void addPromise
2150
+ .then((reactionRef) => {
2151
+ if (reactionRef === null) return undefined
2152
+ return removeReaction({
2153
+ adapter: live.key.adapter,
2154
+ workspace: live.key.workspace,
2155
+ chat: live.key.chat,
2156
+ thread: live.key.thread,
2157
+ reactionRef,
2158
+ })
2159
+ })
2160
+ .then((result) => {
2161
+ if (result && !result.ok && result.code !== 'unsupported' && result.code !== 'not-found') {
2162
+ logger.info(
2163
+ `[channels] engage-unreact failed adapter=${live.key.adapter} chat=${live.key.chat}: ${result.error}`,
2164
+ )
2165
+ }
2166
+ })
2167
+ .catch((err) => {
2168
+ logger.info(
2169
+ `[channels] engage-unreact threw adapter=${live.key.adapter} chat=${live.key.chat}: ${describe(err)}`,
2170
+ )
2171
+ })
2028
2172
  }
2029
2173
 
2030
2174
  const unregisterOutbound = (adapter: ChannelKey['adapter'], cb: OutboundCallback): void => {
@@ -2608,14 +2752,17 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2608
2752
  if (commandInfo === undefined) {
2609
2753
  return { kind: 'unknown-command', name: lowered }
2610
2754
  }
2611
- // Gates on session.control (not channel.respond) so a respond-capable
2612
- // guest cannot abort another speaker's turn. Runs BEFORE the live-session
2613
- // lookup so an unauthorized invoker gets 'permission-denied' regardless of
2614
- // session state, rather than leaking session presence via the
2615
- // 'no-live-session' vs 'permission-denied' distinction. Session-less
2616
- // informational commands (e.g. /help) declare permission:'none' and skip
2617
- // both the gate and the lookup so they work in channels with no live turn.
2618
- if (commandInfo.permission === 'session.control') {
2755
+ // Gates on the command's declared tier (session.control for /stop,
2756
+ // session.admin for /reload and /restart) — never channel.respond so a
2757
+ // respond-capable guest cannot abort another speaker's turn or bounce the
2758
+ // container. Runs BEFORE the live-session lookup so an unauthorized invoker
2759
+ // gets 'permission-denied' regardless of session state, rather than leaking
2760
+ // session presence via the 'no-live-session' vs 'permission-denied'
2761
+ // distinction. Session-less informational commands (e.g. /help) declare
2762
+ // permission:'none' and skip both the gate and the lookup so they work in
2763
+ // channels with no live turn.
2764
+ const requiredPermission = commandPermissionString(commandInfo.permission)
2765
+ if (requiredPermission !== null) {
2619
2766
  const partial: SessionOrigin = {
2620
2767
  kind: 'channel',
2621
2768
  adapter: key.adapter,
@@ -2624,7 +2771,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2624
2771
  thread: key.thread,
2625
2772
  lastInboundAuthorId: options.invokerId,
2626
2773
  }
2627
- if (!permissions.has(partial, CORE_PERMISSIONS.sessionControl)) {
2774
+ if (!permissions.has(partial, requiredPermission)) {
2628
2775
  return { kind: 'permission-denied' }
2629
2776
  }
2630
2777
  }
@@ -2728,6 +2875,9 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2728
2875
  registerReaction,
2729
2876
  unregisterReaction,
2730
2877
  react,
2878
+ registerRemoveReaction,
2879
+ unregisterRemoveReaction,
2880
+ removeReaction,
2731
2881
  registerTyping,
2732
2882
  unregisterTyping,
2733
2883
  registerChannelNameResolver,
@@ -2929,14 +3079,19 @@ function composeTurnPrompt(
2929
3079
  '**Do not acknowledge or reply to this notice.**',
2930
3080
  '',
2931
3081
  'You are woken on every message from someone you recently talked with, so',
2932
- 'most turns you should stay quiet. Reply ONLY when:',
3082
+ 'most turns you should stay quiet. In a group the target shifts every',
3083
+ 'message: before replying, identify who THIS latest message is aimed at.',
3084
+ 'Reply ONLY when:',
2933
3085
  '- the current message is addressed to you (by name, @-mention, or reply), or',
2934
3086
  '- it directly continues your own last exchange and clearly wants an answer',
2935
3087
  ' (e.g. a follow-up question about what you just said).',
2936
3088
  '',
2937
- 'Otherwisechatter between others, side-conversation, banter, or anything',
2938
- 'not actually waiting on youreply with `NO_REPLY` (or call `skip_response`)',
2939
- 'to stay silent and keep watching. When unsure, prefer silence.',
3089
+ 'If it is aimed at someone else another person by name or @-mention, a',
3090
+ 'reply to their message, or another bot it is not your turn, even if you',
3091
+ 'were just talking with its author. Otherwise too — chatter, side-',
3092
+ 'conversation, banter, or anything not actually waiting on you — reply with',
3093
+ '`NO_REPLY` (or call `skip_response`) to stay silent and keep watching.',
3094
+ 'When unsure, prefer silence.',
2940
3095
  '',
2941
3096
  '---',
2942
3097
  '',
@@ -131,6 +131,23 @@ export const DEFAULT_GITHUB_EVENT_ALLOWLIST = [
131
131
  'pull_request_review.submitted',
132
132
  ] as const
133
133
 
134
+ // PR-review policy knobs. Grouped under `review` so future toggles
135
+ // (`requestChanges`, auto-review-on-request, severity thresholds) cluster
136
+ // here instead of flattening onto the channel root.
137
+ //
138
+ // `approve` gates whether the agent may submit a formal review with
139
+ // `event: APPROVE`. When `false`, the adapter appends an operator-policy note
140
+ // to inbounds and the `typeclaw-channel-github` skill downgrades an `approve`
141
+ // verdict to a `COMMENT` review (findings still posted, no formal approval).
142
+ // Enforced in the inbound text rather than at the bash layer because the
143
+ // review posts via `gh api --input <file>`, so the `event` value lives in a
144
+ // temp file the command interceptor never sees.
145
+ const githubReviewSchema = z
146
+ .object({
147
+ approve: z.boolean().default(true),
148
+ })
149
+ .default({ approve: true })
150
+
134
151
  const githubChannelSchema = adapterSchema.extend({
135
152
  // Optional now (PR 2): when omitted and a `tunnels[]` entry with
136
153
  // `for: { kind: 'channel', name: 'github' }` exists, the runtime resolves
@@ -146,6 +163,7 @@ const githubChannelSchema = adapterSchema.extend({
146
163
  // this session is deleted so a restart with a different webhookUrl (e.g.
147
164
  // a tunnel reassigning a URL) doesn't leave orphaned hooks on GitHub.
148
165
  repos: z.array(z.string()).default([]),
166
+ review: githubReviewSchema,
149
167
  })
150
168
 
151
169
  // KakaoTalk uses the same shape as every other adapter. There used to be an
@@ -132,12 +132,27 @@ export type ReactionRequest = {
132
132
  emoji: string
133
133
  }
134
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
+
135
145
  export type ReactionErrorCode = 'permission-denied' | 'not-found' | 'unsupported' | 'rate-limited' | 'transient'
136
146
 
137
- export type ReactionResult = { ok: true } | { ok: false; error: string; code?: ReactionErrorCode }
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 }
138
151
 
139
152
  export type ReactionCallback = (req: ReactionRequest) => Promise<ReactionResult>
140
153
 
154
+ export type RemoveReactionCallback = (req: RemoveReactionRequest) => Promise<ReactionResult>
155
+
141
156
  // File on disk that the agent wants to attach to an outbound message. The
142
157
  // agent runs inside a container with /agent bind-mounted from the host;
143
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',
@@ -0,0 +1,148 @@
1
+ import { defineCommand } from 'citty'
2
+
3
+ import { type DreamEntry, renderListRow, runDreams, type ViewAction } from '@/dreams'
4
+ import { findAgentDir } from '@/init'
5
+
6
+ import { createEscController } from './inspect-controller'
7
+ import { c, cancel, errorLine, isCancel, prepareStdinForClack } from './ui'
8
+
9
+ const ESC_DEBOUNCE_MS = 50
10
+ const QUIT_KEY = 0x71
11
+
12
+ export const dreamsCommand = defineCommand({
13
+ meta: {
14
+ name: 'dreams',
15
+ description: "browse the dreaming subagent's memory-consolidation journal from git history (host stage)",
16
+ },
17
+ args: {
18
+ limit: {
19
+ type: 'string',
20
+ description: 'show at most N most-recent dreams',
21
+ },
22
+ json: {
23
+ type: 'boolean',
24
+ description: 'emit one JSON object per dream (subject-level)',
25
+ default: false,
26
+ },
27
+ details: {
28
+ type: 'boolean',
29
+ description: 'with --json, hydrate each dream with its consolidated fragments/shards/skills',
30
+ default: false,
31
+ },
32
+ },
33
+ async run({ args }) {
34
+ const cwd = findAgentDir(process.cwd()) ?? process.cwd()
35
+ const color = useColor()
36
+ const limit = parseLimit(args.limit)
37
+ const interactive = isInteractive() && args.json !== true
38
+
39
+ const result = await runDreams({
40
+ agentDir: cwd,
41
+ json: args.json === true,
42
+ details: args.details === true,
43
+ color,
44
+ ...(limit !== undefined ? { limit } : {}),
45
+ selectDream: (entries, selectOpts) => clackSelect(entries, color, selectOpts?.initialSha),
46
+ ...(interactive ? { viewDream: () => waitForViewerKey(color) } : {}),
47
+ stdout: (line) => process.stdout.write(`${line}\n`),
48
+ })
49
+
50
+ if (!result.ok) {
51
+ process.stderr.write(`${errorLine(result.reason)}\n`)
52
+ process.exit(result.exitCode)
53
+ }
54
+ process.exit(result.exitCode)
55
+ },
56
+ })
57
+
58
+ function isInteractive(): boolean {
59
+ return Boolean(process.stdout.isTTY) && Boolean(process.stdin.isTTY)
60
+ }
61
+
62
+ type RawInput = Pick<NodeJS.ReadStream, 'isTTY' | 'setRawMode' | 'resume' | 'pause' | 'on' | 'off'>
63
+
64
+ // Esc routes through createEscController so a standalone Esc returns 'back'
65
+ // while a multi-byte CSI sequence (↑/↓ arrows) does not. Teardown restores
66
+ // raw mode but deliberately does NOT pause stdin: clack cannot re-flow a
67
+ // paused process.stdin under Bun, so the next picker would freeze — the same
68
+ // reason cli/inspect.ts leaves the stream flowing on its return path.
69
+ export async function waitForViewerKey(color: boolean, input: RawInput = process.stdin): Promise<ViewAction> {
70
+ const stdin = input
71
+ if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') return 'exit'
72
+
73
+ process.stdout.write(`${viewerHintLine(color)}\n`)
74
+
75
+ const ctrl = createEscController({ debounceMs: ESC_DEBOUNCE_MS })
76
+ const escSignal = ctrl.armForStream()
77
+
78
+ return new Promise<ViewAction>((resolve) => {
79
+ let settled = false
80
+ const finish = (action: ViewAction): void => {
81
+ if (settled) return
82
+ settled = true
83
+ escSignal.removeEventListener('abort', onEscAbort)
84
+ stdin.off('data', onData)
85
+ ctrl.dispose()
86
+ try {
87
+ stdin.setRawMode(false)
88
+ } catch {
89
+ /* terminal already torn down */
90
+ }
91
+ resolve(action)
92
+ }
93
+ const onEscAbort = (): void => finish('back')
94
+ const onData = (chunk: Buffer): void => {
95
+ if (chunk[0] === QUIT_KEY) {
96
+ finish('exit')
97
+ return
98
+ }
99
+ const { sigint } = ctrl.onChunk(chunk)
100
+ if (sigint) finish('exit')
101
+ }
102
+ escSignal.addEventListener('abort', onEscAbort, { once: true })
103
+ stdin.setRawMode(true)
104
+ stdin.resume()
105
+ stdin.on('data', onData)
106
+ })
107
+ }
108
+
109
+ function viewerHintLine(color: boolean): string {
110
+ const text = '(esc to go back to the list · q to quit)'
111
+ return color ? c.dim(text) : text
112
+ }
113
+
114
+ function parseLimit(raw: unknown): number | undefined {
115
+ if (typeof raw !== 'string') return undefined
116
+ const n = Number.parseInt(raw, 10)
117
+ return Number.isFinite(n) && n > 0 ? n : undefined
118
+ }
119
+
120
+ async function clackSelect(
121
+ entries: DreamEntry[],
122
+ color: boolean,
123
+ initialSha: string | undefined,
124
+ ): Promise<DreamEntry | null> {
125
+ const { select } = await import('@clack/prompts')
126
+ prepareStdinForClack()
127
+ const preferred = initialSha !== undefined && entries.some((e) => e.sha === initialSha) ? initialSha : entries[0]?.sha
128
+ const picked = await select<string>({
129
+ message: `Pick a dream to open (${entries.length} total)`,
130
+ options: entries.map((entry) => ({
131
+ value: entry.sha,
132
+ label: renderListRow(entry, { color }),
133
+ })),
134
+ initialValue: preferred,
135
+ })
136
+ if (isCancel(picked)) {
137
+ cancel('Cancelled.')
138
+ return null
139
+ }
140
+ return entries.find((entry) => entry.sha === picked) ?? null
141
+ }
142
+
143
+ function useColor(): boolean {
144
+ if (process.env.NO_COLOR !== undefined && process.env.NO_COLOR !== '') return false
145
+ if (process.env.FORCE_COLOR === '0') return false
146
+ if (process.env.FORCE_COLOR) return true
147
+ return Boolean(process.stdout.isTTY)
148
+ }
package/src/cli/index.ts CHANGED
@@ -23,6 +23,7 @@ const main = defineCommand({
23
23
  reload: () => import('./reload').then((m) => m.reload),
24
24
  logs: () => import('./logs').then((m) => m.logsCommand),
25
25
  inspect: () => import('./inspect').then((m) => m.inspectCommand),
26
+ dreams: () => import('./dreams').then((m) => m.dreamsCommand),
26
27
  shell: () => import('./shell').then((m) => m.shellCommand),
27
28
  compose: () => import('./compose').then((m) => m.composeCommand),
28
29
  channel: () => import('./channel').then((m) => m.channelCommand),
@@ -6,7 +6,7 @@ import { runInspectLoop, streamLive, type LiveSourceFactory, type SessionSummary
6
6
  import { originLabel, shortSessionId } from '@/inspect/label'
7
7
 
8
8
  import { createEscController } from './inspect-controller'
9
- import { cancel, c, errorLine, isCancel } from './ui'
9
+ import { cancel, c, errorLine, isCancel, prepareStdinForClack } from './ui'
10
10
 
11
11
  const ESC_LISTEN_DELAY_MS = 50
12
12
 
@@ -212,6 +212,7 @@ async function clackSelect(
212
212
  initialSessionId: string | undefined,
213
213
  ): Promise<SessionSummary | null> {
214
214
  const { select } = await import('@clack/prompts')
215
+ prepareStdinForClack()
215
216
  const preferred =
216
217
  initialSessionId !== undefined && sessions.some((s) => s.sessionId === initialSessionId)
217
218
  ? initialSessionId