typeclaw 0.20.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
@@ -757,6 +768,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
757
768
  let liveGeneration = 0
758
769
  const outboundCallbacks = new Map<ChannelKey['adapter'], Set<OutboundCallback>>()
759
770
  const reactionCallbacks = new Map<ChannelKey['adapter'], Set<ReactionCallback>>()
771
+ const removeReactionCallbacks = new Map<ChannelKey['adapter'], Set<RemoveReactionCallback>>()
760
772
  const typingCallbacks = new Map<ChannelKey['adapter'], Set<TypingCallback>>()
761
773
  const channelNameResolvers = new Map<ChannelKey['adapter'], Set<ChannelNameResolver>>()
762
774
  const membershipResolvers = new Map<ChannelKey['adapter'], Set<MembershipResolver>>()
@@ -1122,6 +1134,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1122
1134
  currentTurnAuthorId: null,
1123
1135
  currentTurnAuthorIds: new Set(),
1124
1136
  currentTurnReactionRef: null,
1137
+ currentTurnEngageReactions: [],
1125
1138
  // `lastTurnAuthorId` (string, used for `lastInboundAuthorId` in
1126
1139
  // origin) and `lastTurnAuthorIds` (Set, used by
1127
1140
  // `grantStickyForReplyTargets` as the fallback when
@@ -1254,6 +1267,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1254
1267
  receivedAt: now(),
1255
1268
  ts: item.message.ts,
1256
1269
  source: 'prefetch',
1270
+ ...(item.message.attachments !== undefined ? { attachments: item.message.attachments } : {}),
1257
1271
  })
1258
1272
  } else {
1259
1273
  observed.push({
@@ -1532,10 +1546,14 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1532
1546
  live.currentTurnAuthorId = batch[batch.length - 1]!.authorId
1533
1547
  live.currentTurnAuthorIds = new Set(batch.map((m) => m.authorId))
1534
1548
  live.currentTurnReactionRef = batch[batch.length - 1]!.reactionRef ?? null
1549
+ live.currentTurnEngageReactions = batch.flatMap((m) =>
1550
+ m.engageReaction !== undefined ? [m.engageReaction] : [],
1551
+ )
1535
1552
  live.consecutiveSends.clear()
1536
1553
  live.lastSentText.clear()
1537
1554
  live.pendingQuoteCandidate = captureQuoteCandidate(live.key.adapter, batch, observed)
1538
1555
  } else if (live.lastTurnAuthorId !== null) {
1556
+ live.currentTurnEngageReactions = []
1539
1557
  // Reminder-only turn (batch.length === 0, reminders.length > 0):
1540
1558
  // restore the author identity from the prior turn so author-
1541
1559
  // scoped role resolution still works on this turn. The drain
@@ -1550,6 +1568,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1550
1568
  // author prior turn like alice→bob restores `bob`, not alice.
1551
1569
  live.currentTurnAuthorId = live.lastTurnAuthorId
1552
1570
  live.currentTurnAuthorIds = new Set(live.lastTurnAuthorIds)
1571
+ } else {
1572
+ live.currentTurnEngageReactions = []
1553
1573
  }
1554
1574
 
1555
1575
  // Update the live origin holder so this turn's tool.before events
@@ -1576,6 +1596,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1576
1596
  logger.info(`[channels] ${live.keyId} prompting batch=${batch.length} text_len=${text.length}`)
1577
1597
  const promptStart = now()
1578
1598
  const successfulSendsBeforePrompt = live.successfulChannelSends
1599
+ const engageAddPromises = live.currentTurnEngageReactions
1579
1600
  live.turnSeq++
1580
1601
  live.successfulSendsAtTurnStart = successfulSendsBeforePrompt
1581
1602
  live.policyDeniedToolSendsThisTurn.clear()
@@ -1590,6 +1611,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1590
1611
  live.consecutiveSends.clear()
1591
1612
  live.lastSentText.clear()
1592
1613
  } finally {
1614
+ const sentReplyThisTurn = live.successfulChannelSends > successfulSendsBeforePrompt
1615
+ if (sentReplyThisTurn) dropEngageReactionsAfterReply(live, engageAddPromises)
1593
1616
  await fireSessionTurnEnd(live)
1594
1617
  }
1595
1618
  await fireSessionIdle(live)
@@ -1603,6 +1626,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1603
1626
  live.currentTurnAuthorId = null
1604
1627
  live.currentTurnAuthorIds = new Set()
1605
1628
  live.currentTurnReactionRef = null
1629
+ live.currentTurnEngageReactions = []
1606
1630
  live.currentTurnAttachments = []
1607
1631
  await stopTypingHeartbeat(live)
1608
1632
  }
@@ -1852,11 +1876,11 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1852
1876
 
1853
1877
  publishInbound(event, 'engage', live.sessionId)
1854
1878
 
1855
- autoReactOnEngage(event)
1879
+ const engageReaction = autoReactOnEngage(event)
1856
1880
 
1857
1881
  updateLoopGuard(live, event)
1858
1882
 
1859
- enqueue(live, event)
1883
+ enqueue(live, event, engageReaction)
1860
1884
 
1861
1885
  // Start showing "typing..." the moment we know we're going to engage,
1862
1886
  // so users see the indicator during the debounce window — not just
@@ -1938,7 +1962,11 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1938
1962
  }
1939
1963
  }
1940
1964
 
1941
- const enqueue = (live: LiveSession, event: InboundMessage): void => {
1965
+ const enqueue = (
1966
+ live: LiveSession,
1967
+ event: InboundMessage,
1968
+ engageReaction: Promise<ReactionRef | null> | null,
1969
+ ): void => {
1942
1970
  live.promptQueue.push({
1943
1971
  text: event.text,
1944
1972
  ...(event.attachments !== undefined && event.attachments.length > 0 ? { attachments: event.attachments } : {}),
@@ -1947,6 +1975,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1947
1975
  authorIsBot: event.authorIsBot,
1948
1976
  externalMessageId: event.externalMessageId,
1949
1977
  ...(event.reactionRef !== undefined ? { reactionRef: event.reactionRef } : {}),
1978
+ ...(engageReaction !== null ? { engageReaction } : {}),
1950
1979
  isBotMention: event.isBotMention,
1951
1980
  replyToBotMessageId: event.replyToBotMessageId,
1952
1981
  isDm: event.isDm,
@@ -1977,6 +2006,19 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1977
2006
  reactionCallbacks.get(adapter)?.delete(cb)
1978
2007
  }
1979
2008
 
2009
+ const registerRemoveReaction = (adapter: ChannelKey['adapter'], cb: RemoveReactionCallback): void => {
2010
+ let set = removeReactionCallbacks.get(adapter)
2011
+ if (!set) {
2012
+ set = new Set()
2013
+ removeReactionCallbacks.set(adapter, set)
2014
+ }
2015
+ set.add(cb)
2016
+ }
2017
+
2018
+ const unregisterRemoveReaction = (adapter: ChannelKey['adapter'], cb: RemoveReactionCallback): void => {
2019
+ removeReactionCallbacks.get(adapter)?.delete(cb)
2020
+ }
2021
+
1980
2022
  const react = async (req: ReactionRequest): Promise<ReactionResult> => {
1981
2023
  if (req.reactionRef.adapter !== req.adapter) {
1982
2024
  return { ok: false, error: 'reaction ref adapter mismatch', code: 'unsupported' }
@@ -2001,15 +2043,34 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2001
2043
  return lastError ?? { ok: false, error: 'no reaction callback handled request', code: 'unsupported' }
2002
2044
  }
2003
2045
 
2046
+ const removeReaction = async (req: RemoveReactionRequest): Promise<ReactionResult> => {
2047
+ if (req.reactionRef.adapter !== req.adapter) {
2048
+ return { ok: false, error: 'reaction ref adapter mismatch', code: 'unsupported' }
2049
+ }
2050
+ const callbacks = removeReactionCallbacks.get(req.adapter)
2051
+ if (!callbacks || callbacks.size === 0) {
2052
+ return { ok: false, error: `adapter "${req.adapter}" does not support reaction removal`, code: 'unsupported' }
2053
+ }
2054
+ let lastError: ReactionResult | undefined
2055
+ for (const cb of Array.from(callbacks)) {
2056
+ const result = await cb(req).catch(
2057
+ (err): ReactionResult => ({ ok: false, error: describe(err), code: 'transient' }),
2058
+ )
2059
+ if (result.ok) return result
2060
+ lastError = result
2061
+ }
2062
+ return lastError ?? { ok: false, error: 'no reaction removal callback handled request', code: 'unsupported' }
2063
+ }
2064
+
2004
2065
  // Best-effort acknowledgment: drop an :eyes: on the triggering inbound the
2005
2066
  // moment we decide to engage, replacing the old "On it" ack comment on
2006
2067
  // GitHub. Fire-and-forget so a reaction failure (missing permission, the
2007
2068
  // adapter not supporting reactions, a transient API error) can NEVER block
2008
2069
  // engagement, enqueueing, or the agent's actual reply. No reactionRef =
2009
2070
  // nothing reactable (synthetic inbounds, reaction-less adapters) = silent skip.
2010
- const autoReactOnEngage = (event: InboundMessage): void => {
2011
- if (event.reactionRef === undefined) return
2012
- void react({
2071
+ const autoReactOnEngage = (event: InboundMessage): Promise<ReactionRef | null> | null => {
2072
+ if (event.reactionRef === undefined) return null
2073
+ const addResult = react({
2013
2074
  adapter: event.adapter,
2014
2075
  workspace: event.workspace,
2015
2076
  chat: event.chat,
@@ -2017,6 +2078,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2017
2078
  reactionRef: event.reactionRef,
2018
2079
  emoji: ENGAGE_REACTION_EMOJI,
2019
2080
  })
2081
+ const addReactionRef = addResult.then((r) => (r.ok ? (r.reactionRef ?? null) : null)).catch(() => null)
2082
+ void addResult
2020
2083
  .then((result) => {
2021
2084
  if (!result.ok && result.code !== 'unsupported') {
2022
2085
  logger.info(`[channels] engage-react failed adapter=${event.adapter} chat=${event.chat}: ${result.error}`)
@@ -2025,6 +2088,37 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2025
2088
  .catch((err) => {
2026
2089
  logger.info(`[channels] engage-react threw adapter=${event.adapter} chat=${event.chat}: ${describe(err)}`)
2027
2090
  })
2091
+ return addReactionRef
2092
+ }
2093
+
2094
+ const dropEngageReactionsAfterReply = (live: LiveSession, addPromises: Array<Promise<ReactionRef | null>>): void => {
2095
+ for (const addPromise of addPromises) dropOneEngageReactionAfterReply(live, addPromise)
2096
+ }
2097
+
2098
+ const dropOneEngageReactionAfterReply = (live: LiveSession, addPromise: Promise<ReactionRef | null>): void => {
2099
+ void addPromise
2100
+ .then((reactionRef) => {
2101
+ if (reactionRef === null) return undefined
2102
+ return removeReaction({
2103
+ adapter: live.key.adapter,
2104
+ workspace: live.key.workspace,
2105
+ chat: live.key.chat,
2106
+ thread: live.key.thread,
2107
+ reactionRef,
2108
+ })
2109
+ })
2110
+ .then((result) => {
2111
+ if (result && !result.ok && result.code !== 'unsupported' && result.code !== 'not-found') {
2112
+ logger.info(
2113
+ `[channels] engage-unreact failed adapter=${live.key.adapter} chat=${live.key.chat}: ${result.error}`,
2114
+ )
2115
+ }
2116
+ })
2117
+ .catch((err) => {
2118
+ logger.info(
2119
+ `[channels] engage-unreact threw adapter=${live.key.adapter} chat=${live.key.chat}: ${describe(err)}`,
2120
+ )
2121
+ })
2028
2122
  }
2029
2123
 
2030
2124
  const unregisterOutbound = (adapter: ChannelKey['adapter'], cb: OutboundCallback): void => {
@@ -2728,6 +2822,9 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2728
2822
  registerReaction,
2729
2823
  unregisterReaction,
2730
2824
  react,
2825
+ registerRemoveReaction,
2826
+ unregisterRemoveReaction,
2827
+ removeReaction,
2731
2828
  registerTyping,
2732
2829
  unregisterTyping,
2733
2830
  registerChannelNameResolver,
@@ -2929,14 +3026,19 @@ function composeTurnPrompt(
2929
3026
  '**Do not acknowledge or reply to this notice.**',
2930
3027
  '',
2931
3028
  'You are woken on every message from someone you recently talked with, so',
2932
- 'most turns you should stay quiet. Reply ONLY when:',
3029
+ 'most turns you should stay quiet. In a group the target shifts every',
3030
+ 'message: before replying, identify who THIS latest message is aimed at.',
3031
+ 'Reply ONLY when:',
2933
3032
  '- the current message is addressed to you (by name, @-mention, or reply), or',
2934
3033
  '- it directly continues your own last exchange and clearly wants an answer',
2935
3034
  ' (e.g. a follow-up question about what you just said).',
2936
3035
  '',
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.',
3036
+ 'If it is aimed at someone else another person by name or @-mention, a',
3037
+ 'reply to their message, or another bot it is not your turn, even if you',
3038
+ 'were just talking with its author. Otherwise too — chatter, side-',
3039
+ 'conversation, banter, or anything not actually waiting on you — reply with',
3040
+ '`NO_REPLY` (or call `skip_response`) to stay silent and keep watching.',
3041
+ 'When unsure, prefer silence.',
2940
3042
  '',
2941
3043
  '---',
2942
3044
  '',
@@ -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,147 @@
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 } 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
+ const preferred = initialSha !== undefined && entries.some((e) => e.sha === initialSha) ? initialSha : entries[0]?.sha
127
+ const picked = await select<string>({
128
+ message: `Pick a dream to open (${entries.length} total)`,
129
+ options: entries.map((entry) => ({
130
+ value: entry.sha,
131
+ label: renderListRow(entry, { color }),
132
+ })),
133
+ initialValue: preferred,
134
+ })
135
+ if (isCancel(picked)) {
136
+ cancel('Cancelled.')
137
+ return null
138
+ }
139
+ return entries.find((entry) => entry.sha === picked) ?? null
140
+ }
141
+
142
+ function useColor(): boolean {
143
+ if (process.env.NO_COLOR !== undefined && process.env.NO_COLOR !== '') return false
144
+ if (process.env.FORCE_COLOR === '0') return false
145
+ if (process.env.FORCE_COLOR) return true
146
+ return Boolean(process.stdout.isTTY)
147
+ }
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),
@@ -0,0 +1,85 @@
1
+ export type GitResult = { exitCode: number; stdout: string; stderr: string }
2
+ export type SpawnGit = (args: string[], cwd: string) => Promise<GitResult>
3
+
4
+ export type RawCommit = {
5
+ sha: string
6
+ shortSha: string
7
+ committedAt: string
8
+ subject: string
9
+ }
10
+
11
+ export type ResolveRepoResult = { ok: true; root: string } | { ok: false; reason: 'not-a-repo' | 'git-failed' }
12
+
13
+ const FIELD_SEP = '\x1f'
14
+ const RECORD_SEP = '\x1e'
15
+
16
+ export async function resolveGitRepo(cwd: string, spawnGit: SpawnGit = defaultSpawnGit): Promise<ResolveRepoResult> {
17
+ const res = await spawnGit(['rev-parse', '--show-toplevel'], cwd)
18
+ if (res.exitCode === 0) {
19
+ const root = res.stdout.trim()
20
+ if (root.length > 0) return { ok: true, root }
21
+ return { ok: false, reason: 'git-failed' }
22
+ }
23
+ if (/not a git repository/i.test(res.stderr)) return { ok: false, reason: 'not-a-repo' }
24
+ return { ok: false, reason: 'git-failed' }
25
+ }
26
+
27
+ const DREAM_SUBJECT_PREFIX = 'dream: '
28
+
29
+ export async function readDreamCommitLog(
30
+ root: string,
31
+ opts: { limit?: number } = {},
32
+ spawnGit: SpawnGit = defaultSpawnGit,
33
+ ): Promise<RawCommit[]> {
34
+ // --grep is only a cheap pre-filter: it matches ANY line of the commit
35
+ // message, so a non-dream commit with a `dream: ...` body line slips
36
+ // through. The subject is the authoritative contract, so the prefix filter
37
+ // below is what actually decides membership — and the limit is applied
38
+ // AFTER it so body-matching impostors can't consume a slot and shrink the
39
+ // result below the requested count.
40
+ const args = ['log', '--grep=^dream: ', `--format=%H${FIELD_SEP}%h${FIELD_SEP}%cI${FIELD_SEP}%s${RECORD_SEP}`]
41
+
42
+ const res = await spawnGit(args, root)
43
+ if (res.exitCode !== 0) return []
44
+ const dreams = parseLogOutput(res.stdout).filter((c) => c.subject.startsWith(DREAM_SUBJECT_PREFIX))
45
+ if (opts.limit !== undefined && opts.limit > 0) return dreams.slice(0, opts.limit)
46
+ return dreams
47
+ }
48
+
49
+ export function parseLogOutput(stdout: string): RawCommit[] {
50
+ const commits: RawCommit[] = []
51
+ for (const record of stdout.split(RECORD_SEP)) {
52
+ const trimmed = record.replace(/^\n+/, '')
53
+ if (trimmed.length === 0) continue
54
+ const [sha, shortSha, committedAt, subject] = trimmed.split(FIELD_SEP)
55
+ if (sha === undefined || shortSha === undefined || committedAt === undefined || subject === undefined) continue
56
+ commits.push({ sha, shortSha, committedAt, subject })
57
+ }
58
+ return commits
59
+ }
60
+
61
+ export async function readDreamCommitShow(
62
+ root: string,
63
+ sha: string,
64
+ spawnGit: SpawnGit = defaultSpawnGit,
65
+ ): Promise<{ nameStatus: string; patch: string } | null> {
66
+ const nameStatus = await spawnGit(['show', '--no-color', '--find-renames', '--format=', '--name-status', sha], root)
67
+ if (nameStatus.exitCode !== 0) return null
68
+ const patch = await spawnGit(['show', '--no-color', '--format=', '--unified=0', sha], root)
69
+ if (patch.exitCode !== 0) return null
70
+ return { nameStatus: nameStatus.stdout, patch: patch.stdout }
71
+ }
72
+
73
+ const defaultSpawnGit: SpawnGit = async (args, cwd) => {
74
+ const bun = (globalThis as { Bun?: { spawn: typeof Bun.spawn } }).Bun
75
+ if (!bun) return { exitCode: -1, stdout: '', stderr: 'bun runtime not available' }
76
+ try {
77
+ const proc = bun.spawn({ cmd: ['git', ...args], cwd, stdout: 'pipe', stderr: 'pipe' })
78
+ const exitCode = await proc.exited
79
+ const stdout = await new Response(proc.stdout).text()
80
+ const stderr = await new Response(proc.stderr).text()
81
+ return { exitCode, stdout, stderr }
82
+ } catch (err) {
83
+ return { exitCode: -1, stdout: '', stderr: err instanceof Error ? err.message : String(err) }
84
+ }
85
+ }