typeclaw 0.37.4 → 0.37.5

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.
@@ -11,7 +11,7 @@ import { createDeliveryDedup } from './dedup'
11
11
  import { findPermissionGaps } from './event-permissions'
12
12
  import { createGithubFetchAttachmentCallback } from './fetch-attachment'
13
13
  import { createGithubHistoryCallback } from './history'
14
- import { createGithubWebhookHandler } from './inbound'
14
+ import { createGithubWebhookHandler, processVerifiedGithubDelivery, type GithubWebhookHandlerOptions } from './inbound'
15
15
  import { applyManagedPath, buildManagedPath, resolveAgentId } from './managed-path'
16
16
  import { createGithubMembershipResolver } from './membership'
17
17
  import { createGithubOutboundCallback } from './outbound'
@@ -22,6 +22,7 @@ import {
22
22
  } from './permission-guidance'
23
23
  import { createGithubReactionCallback, createGithubRemoveReactionCallback } from './reactions'
24
24
  import { reconcileOpenPrs } from './reconcile-open-prs'
25
+ import { createRecoveredGuidLog, recoverFailedGithubDeliveries } from './recover-failed-deliveries'
25
26
  import { createGithubReviewStateResolver } from './review-state'
26
27
  import { createGithubReviewThreadResolver } from './review-thread-resolver'
27
28
  import { createTeamMembershipChecker } from './team-membership'
@@ -67,6 +68,10 @@ export type GithubAdapterOptions = {
67
68
  // Test-only: replaces `setInterval` so tests can control when the
68
69
  // background refresh fires without waiting on real wall-clock time.
69
70
  setInterval?: (handler: () => void, ms: number) => { clear: () => void }
71
+ // How often to sweep each managed hook's GitHub delivery log for events whose
72
+ // inbound delivery failed (and that GitHub never redelivered), re-injecting
73
+ // them through the live event path. Zero disables the sweep. Default: 5 min.
74
+ deliveryRecoveryIntervalMs?: number
70
75
  // Write-side of the GithubTokenBridge. On App-auth start the adapter
71
76
  // registers a per-repo minter here so plugin hooks can resolve a token for
72
77
  // ad-hoc `gh` commands; it unregisters on stop and on start rollback. PAT
@@ -89,6 +94,12 @@ const consoleLogger: GithubAdapterLogger = {
89
94
 
90
95
  const DEFAULT_WEBHOOK_REGISTRATION_DELAY_MS = 2_000
91
96
  const DEFAULT_TOKEN_REFRESH_INTERVAL_MS = 30 * 60 * 1000
97
+ const DEFAULT_DELIVERY_RECOVERY_INTERVAL_MS = 5 * 60 * 1000
98
+ // GitHub retains the delivery log for 3 days; sweep a little under that so a
99
+ // failed delivery is always still listable on the next interval.
100
+ const DELIVERY_RECOVERY_LOOKBACK_MS = 70 * 60 * 60 * 1000
101
+ // Bounds an LLM-session storm if a bad tunnel window drops a large burst.
102
+ const MAX_RECOVERED_PER_SWEEP = 50
92
103
 
93
104
  export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapter {
94
105
  const logger = options.logger ?? consoleLogger
@@ -105,8 +116,15 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
105
116
  let started = false
106
117
  let managedHooks: ReadonlyArray<{ repo: string; hookId: number }> = []
107
118
  let tokenRefreshTimer: { clear: () => void } | null = null
119
+ let deliveryRecoveryTimer: { clear: () => void } | null = null
108
120
  let unregisterTokenBridge: (() => void) | null = null
109
121
  const workspaceByChat = new Map<string, string>()
122
+ const setIntervalFn =
123
+ options.setInterval ??
124
+ ((handler: () => void, ms: number) => {
125
+ const timer = setInterval(handler, ms)
126
+ return { clear: () => clearInterval(timer) }
127
+ })
110
128
 
111
129
  const rememberWorkspace = (workspace: string, chat: string): void => {
112
130
  workspaceByChat.set(chat, workspace)
@@ -174,7 +192,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
174
192
  logger.error(`[github] route failed: ${err instanceof Error ? err.message : String(err)}`)
175
193
  })
176
194
  }
177
- const handler = createGithubWebhookHandler({
195
+ const handlerOptions: GithubWebhookHandlerOptions = {
178
196
  webhookSecret,
179
197
  dedup,
180
198
  allowlist: () => options.configRef().eventAllowlist,
@@ -188,7 +206,8 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
188
206
  fetchImpl,
189
207
  logger,
190
208
  route: routeInbound,
191
- })
209
+ }
210
+ const handler = createGithubWebhookHandler(handlerOptions)
192
211
 
193
212
  return {
194
213
  async start(): Promise<void> {
@@ -251,12 +270,6 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
251
270
  )
252
271
  })
253
272
  }
254
- const setIntervalFn =
255
- options.setInterval ??
256
- ((handler: () => void, ms: number) => {
257
- const timer = setInterval(handler, ms)
258
- return { clear: () => clearInterval(timer) }
259
- })
260
273
  tokenRefreshTimer = setIntervalFn(refresh, tokenRefreshIntervalMs)
261
274
  }
262
275
  } else {
@@ -355,10 +368,45 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
355
368
  logger.warn(`[github] reconcile pass failed: ${err instanceof Error ? err.message : String(err)}`)
356
369
  })
357
370
  }
371
+ // Periodically recover inbound deliveries that failed at the tunnel and
372
+ // were never redelivered (the cloudflare-quick 502 loss). Registered only
373
+ // when we manage hooks to query, and driven by the same injectable timer
374
+ // as the token refresh. The first sweep fires after one interval — NOT
375
+ // inside start() — so start() stays free of surprise API traffic; the
376
+ // reconcile pass above already covers the review-needed case immediately.
377
+ const deliveryRecoveryIntervalMs = options.deliveryRecoveryIntervalMs ?? DEFAULT_DELIVERY_RECOVERY_INTERVAL_MS
378
+ if (managedHooks.length > 0 && deliveryRecoveryIntervalMs > 0) {
379
+ // Created once and captured by `sweep`, so recovery idempotency persists
380
+ // across ticks even when the shared live dedup evicts the guid.
381
+ const recoveredLog = createRecoveredGuidLog(DELIVERY_RECOVERY_LOOKBACK_MS)
382
+ const sweep = () => {
383
+ recoverFailedGithubDeliveries({
384
+ hooks: managedHooks,
385
+ token: (repoSlug: string) => auth.token({ repoSlug }),
386
+ process: (input) => processVerifiedGithubDelivery(handlerOptions, input),
387
+ alreadySeen: (guid: string) => dedup.has(guid),
388
+ recoveredLog,
389
+ lookbackMs: DELIVERY_RECOVERY_LOOKBACK_MS,
390
+ maxPerSweep: MAX_RECOVERED_PER_SWEEP,
391
+ logger,
392
+ fetchImpl,
393
+ }).catch((err: unknown) => {
394
+ logger.warn(`[github] delivery recovery sweep failed: ${err instanceof Error ? err.message : String(err)}`)
395
+ })
396
+ }
397
+ deliveryRecoveryTimer = setIntervalFn(sweep, deliveryRecoveryIntervalMs)
398
+ }
358
399
  },
359
400
  async stop(): Promise<void> {
360
401
  if (!started) return
361
402
  started = false
403
+ // Stop the recovery sweep first: its async work outlives the synchronous
404
+ // unregister calls below, and a tick landing mid-teardown would query a
405
+ // hook we're about to deregister and could route during shutdown.
406
+ if (deliveryRecoveryTimer !== null) {
407
+ deliveryRecoveryTimer.clear()
408
+ deliveryRecoveryTimer = null
409
+ }
362
410
  options.router.unregisterOutbound('github', outbound)
363
411
  options.router.unregisterReaction('github', reaction)
364
412
  options.router.unregisterRemoveReaction('github', removeReaction)
@@ -0,0 +1,270 @@
1
+ import { GITHUB_API_BASE, githubJsonHeaders } from './auth-pat'
2
+
3
+ // Recovers webhook events whose delivery to our ingress FAILED and that GitHub
4
+ // never successfully redelivered. The production failure mode is inbound-only: a
5
+ // cloudflare-quick tunnel drops ~half its deliveries with 502 "failed to connect
6
+ // to host", and GitHub does not auto-redeliver issue_comment events — so a
7
+ // `@bot review please` comment is lost with no log entry and no reply.
8
+ //
9
+ // This sweep is OUTBOUND-only, so it never touches the broken inbound leg: it
10
+ // lists each managed hook's delivery log, finds events with no successful
11
+ // delivery, fetches the original payload from GitHub's authenticated deliveries
12
+ // API, and feeds it through the SAME processVerifiedGithubDelivery core a live
13
+ // webhook uses (passed in as `process`). It is a floor, not the primary path —
14
+ // webhooks remain low-latency when delivery works; reconcile-open-prs.ts is the
15
+ // sibling floor for review-state drift.
16
+
17
+ export type ManagedHook = { repo: string; hookId: number }
18
+
19
+ // Recovery-owned idempotency keyed by delivery GUID, retained for the FULL
20
+ // lookback window. The shared live dedup cannot serve this alone: it is a
21
+ // fixed 1000-entry LRU, so during a 70h lookback across many repos it can evict
22
+ // a GUID we already recovered, after which the still-listed failed delivery
23
+ // would be re-fetched and re-routed. This TTL log holds only RECOVERED GUIDs
24
+ // (failed-then-recovered deliveries, a small set), expiring each exactly when it
25
+ // also falls out of the scan window — so a recovered delivery is routed once
26
+ // regardless of live-dedup churn. (The shared dedup still guards the live-vs-
27
+ // sweep concurrency race; this guards cross-sweep durability.)
28
+ export type RecoveredGuidLog = { has: (guid: string) => boolean; record: (guid: string) => void }
29
+
30
+ export function createRecoveredGuidLog(ttlMs: number, now: () => number = Date.now): RecoveredGuidLog {
31
+ const expiresAt = new Map<string, number>()
32
+ return {
33
+ has(guid: string): boolean {
34
+ const expiry = expiresAt.get(guid)
35
+ if (expiry === undefined) return false
36
+ if (expiry <= now()) {
37
+ expiresAt.delete(guid)
38
+ return false
39
+ }
40
+ return true
41
+ },
42
+ record(guid: string): void {
43
+ const t = now()
44
+ for (const [g, expiry] of expiresAt) if (expiry <= t) expiresAt.delete(g)
45
+ expiresAt.set(guid, t + ttlMs)
46
+ },
47
+ }
48
+ }
49
+
50
+ export type RecoverFailedDeliveriesOptions = {
51
+ hooks: readonly ManagedHook[]
52
+ token: (repoSlug: string) => Promise<string>
53
+ // The shared processVerifiedGithubDelivery, bound to the adapter's handler
54
+ // options. `delivery` is the GUID; the core dedups, filters by allowlist,
55
+ // drops self-authored, and routes exactly as the live path does.
56
+ process: (input: { event: string; delivery: string; payload: Record<string, unknown> }) => Promise<void>
57
+ // Fast-path skip backed by the LIVE delivery dedup (shared with the webhook
58
+ // handler): a guid here was just routed live (or reserved by `process` on
59
+ // entry), so skip it. Best-effort only — it is a 1000-entry LRU and may evict
60
+ // within the lookback window, which is exactly why `recoveredLog` exists.
61
+ alreadySeen: (guid: string) => boolean
62
+ // Durable recovery idempotency for the whole lookback window (see
63
+ // createRecoveredGuidLog). Caller-owned so it persists across sweeps.
64
+ recoveredLog: RecoveredGuidLog
65
+ lookbackMs: number
66
+ maxPerSweep: number
67
+ logger: { info: (m: string) => void; warn: (m: string) => void }
68
+ now?: () => number
69
+ fetchImpl?: typeof fetch
70
+ }
71
+
72
+ export type RecoverOutcome = { recovered: number; scanned: number }
73
+
74
+ export async function recoverFailedGithubDeliveries(options: RecoverFailedDeliveriesOptions): Promise<RecoverOutcome> {
75
+ const fetchImpl = options.fetchImpl ?? fetch
76
+ const now = options.now ?? Date.now
77
+ const cutoff = now() - options.lookbackMs
78
+ let recovered = 0
79
+ let scanned = 0
80
+
81
+ for (const hook of options.hooks) {
82
+ // maxPerSweep is a GLOBAL budget across all hooks (an LLM-session storm
83
+ // guard), so pass the remaining budget into each hook rather than letting
84
+ // every hook recover up to the full cap independently.
85
+ const remaining = options.maxPerSweep - recovered
86
+ if (remaining <= 0) break
87
+ const target = parseRepo(hook.repo)
88
+ if (target === null) {
89
+ options.logger.warn(`[github] recovery skipped malformed repo slug "${hook.repo}"`)
90
+ continue
91
+ }
92
+ try {
93
+ const result = await recoverHook(hook, target, cutoff, options, remaining, fetchImpl)
94
+ scanned += result.scanned
95
+ recovered += result.recovered
96
+ if (result.recovered > 0) {
97
+ options.logger.info(`[github] recovered ${result.recovered} missed delivery(s) on ${hook.repo}`)
98
+ }
99
+ } catch (err) {
100
+ // Per-hook isolation: one repo's token/list/detail failure must not abort
101
+ // the others. The next interval retries this hook.
102
+ options.logger.warn(`[github] delivery recovery failed for ${hook.repo}: ${describe(err)}`)
103
+ }
104
+ }
105
+ return { recovered, scanned }
106
+ }
107
+
108
+ async function recoverHook(
109
+ hook: ManagedHook,
110
+ target: RepoTarget,
111
+ cutoff: number,
112
+ options: RecoverFailedDeliveriesOptions,
113
+ budget: number,
114
+ fetchImpl: typeof fetch,
115
+ ): Promise<RecoverOutcome> {
116
+ const token = await options.token(hook.repo)
117
+ const deliveries = await listRecentDeliveries(fetchImpl, token, target, hook.hookId, cutoff)
118
+
119
+ // Any 2xx/3xx delivery for a guid means the event got through (e.g. GitHub
120
+ // auto-redelivered, or a manual redeliver succeeded). Never recover those.
121
+ const succeededGuids = new Set<string>()
122
+ for (const d of deliveries) {
123
+ if (isSuccess(d.statusCode)) succeededGuids.add(d.guid)
124
+ }
125
+
126
+ let recovered = 0
127
+ let scanned = 0
128
+ const handledThisSweep = new Set<string>()
129
+ for (const delivery of deliveries) {
130
+ if (recovered >= budget) break
131
+ if (isSuccess(delivery.statusCode)) continue
132
+ scanned += 1
133
+ const guid = delivery.guid
134
+ if (guid === '') continue
135
+ if (
136
+ succeededGuids.has(guid) ||
137
+ handledThisSweep.has(guid) ||
138
+ options.alreadySeen(guid) ||
139
+ options.recoveredLog.has(guid)
140
+ ) {
141
+ continue
142
+ }
143
+ handledThisSweep.add(guid)
144
+
145
+ const payload = await fetchDeliveryPayload(fetchImpl, token, target, hook.hookId, delivery.id)
146
+ if (payload === null) continue
147
+ await options.process({ event: delivery.event, delivery: guid, payload })
148
+ // Record AFTER process resolves: an unexpected throw leaves the guid
149
+ // unrecorded so the next sweep retries it. A no-op classify still records
150
+ // (process returned), so a non-routable failed delivery is not refetched.
151
+ options.recoveredLog.record(guid)
152
+ recovered += 1
153
+ }
154
+ return { recovered, scanned }
155
+ }
156
+
157
+ type RepoTarget = { owner: string; repo: string }
158
+
159
+ type DeliverySummary = { id: number; guid: string; event: string; statusCode: number }
160
+
161
+ async function listRecentDeliveries(
162
+ fetchImpl: typeof fetch,
163
+ token: string,
164
+ target: RepoTarget,
165
+ hookId: number,
166
+ cutoff: number,
167
+ ): Promise<DeliverySummary[]> {
168
+ const summaries: DeliverySummary[] = []
169
+ let url: string | null =
170
+ `${GITHUB_API_BASE}/repos/${target.owner}/${target.repo}/hooks/${hookId}/deliveries?per_page=100`
171
+ while (url !== null) {
172
+ const response = await fetchImpl(url, { headers: githubJsonHeaders(token) })
173
+ if (!response.ok) {
174
+ const body = await response.text().catch(() => '')
175
+ throw new Error(`GitHub deliveries ${response.status}${body !== '' ? `: ${body}` : ''}`)
176
+ }
177
+ const page = (await response.json().catch(() => null)) as DeliveryRow[] | null
178
+ if (page === null) throw new Error('GitHub deliveries returned non-JSON')
179
+ // Deliveries are newest-first; once a page's oldest entry predates the
180
+ // lookback cutoff we can stop paginating instead of walking the full log.
181
+ let reachedCutoff = false
182
+ for (const row of page) {
183
+ const parsed = parseDeliveryRow(row)
184
+ if (parsed === null) continue
185
+ if (parsed.deliveredAt !== null && parsed.deliveredAt < cutoff) {
186
+ reachedCutoff = true
187
+ continue
188
+ }
189
+ summaries.push(parsed.summary)
190
+ }
191
+ if (reachedCutoff) break
192
+ url = nextLink(response.headers.get('link'))
193
+ }
194
+ return summaries
195
+ }
196
+
197
+ async function fetchDeliveryPayload(
198
+ fetchImpl: typeof fetch,
199
+ token: string,
200
+ target: RepoTarget,
201
+ hookId: number,
202
+ deliveryId: number,
203
+ ): Promise<Record<string, unknown> | null> {
204
+ const response = await fetchImpl(
205
+ `${GITHUB_API_BASE}/repos/${target.owner}/${target.repo}/hooks/${hookId}/deliveries/${deliveryId}`,
206
+ { headers: githubJsonHeaders(token) },
207
+ )
208
+ if (!response.ok) return null
209
+ const raw = (await response.json().catch(() => null)) as { request?: { payload?: unknown } } | null
210
+ return coercePayload(raw?.request?.payload)
211
+ }
212
+
213
+ function coercePayload(value: unknown): Record<string, unknown> | null {
214
+ if (typeof value === 'string') {
215
+ try {
216
+ const parsed = JSON.parse(value) as unknown
217
+ return isRecord(parsed) ? parsed : null
218
+ } catch {
219
+ return null
220
+ }
221
+ }
222
+ return isRecord(value) ? value : null
223
+ }
224
+
225
+ // GitHub records a non-delivery (connection refused / DNS / tunnel down) as
226
+ // status_code 0, and HTTP failures as 4xx/5xx. Treat 2xx and 3xx as success.
227
+ function isSuccess(statusCode: number): boolean {
228
+ return statusCode >= 200 && statusCode < 400
229
+ }
230
+
231
+ type DeliveryRow = {
232
+ id?: unknown
233
+ guid?: unknown
234
+ event?: unknown
235
+ status_code?: unknown
236
+ delivered_at?: unknown
237
+ }
238
+
239
+ function parseDeliveryRow(row: DeliveryRow): { summary: DeliverySummary; deliveredAt: number | null } | null {
240
+ const id = typeof row.id === 'number' ? row.id : null
241
+ const guid = typeof row.guid === 'string' ? row.guid : null
242
+ const event = typeof row.event === 'string' ? row.event : null
243
+ const statusCode = typeof row.status_code === 'number' ? row.status_code : null
244
+ if (id === null || guid === null || event === null || statusCode === null) return null
245
+ const deliveredAt = typeof row.delivered_at === 'string' ? Date.parse(row.delivered_at) || null : null
246
+ return { summary: { id, guid, event, statusCode }, deliveredAt }
247
+ }
248
+
249
+ function parseRepo(slug: string): RepoTarget | null {
250
+ const [owner, repo, ...rest] = slug.trim().split('/')
251
+ if (owner === undefined || owner === '' || repo === undefined || repo === '' || rest.length > 0) return null
252
+ return { owner, repo }
253
+ }
254
+
255
+ function nextLink(linkHeader: string | null): string | null {
256
+ if (linkHeader === null) return null
257
+ for (const part of linkHeader.split(',')) {
258
+ const m = /<([^>]+)>;\s*rel="next"/.exec(part)
259
+ if (m !== null) return m[1] ?? null
260
+ }
261
+ return null
262
+ }
263
+
264
+ function isRecord(value: unknown): value is Record<string, unknown> {
265
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
266
+ }
267
+
268
+ function describe(err: unknown): string {
269
+ return err instanceof Error ? err.message : String(err)
270
+ }
@@ -1,3 +1,5 @@
1
+ import { basename } from 'node:path'
2
+
1
3
  import {
2
4
  KakaoCredentialManager,
3
5
  KakaoTalkClient as RealKakaoTalkClient,
@@ -203,7 +205,9 @@ export function createOutboundCallback(deps: {
203
205
  try {
204
206
  items = await Promise.all(
205
207
  attachments.map(async (a) => {
206
- const filename = a.filename ?? a.path.split('/').pop() ?? 'file'
208
+ // basename (not split('/')) so a native-Windows host's backslash
209
+ // attachment paths still yield just the filename (issue #899).
210
+ const filename = a.filename ?? (basename(a.path) || 'file')
207
211
  const data = await readFile(a.path)
208
212
  return { data, filename }
209
213
  }),
@@ -1,3 +1,5 @@
1
+ import type { AdapterId } from '../schema'
2
+
1
3
  export type DiscordMentionUser = { id: string; username?: string; global_name?: string | null }
2
4
 
3
5
  export type MentionHintOptions = { botUserId?: string | null }
@@ -12,6 +14,21 @@ const SLACK_MENTION_PATTERN = /<@([UW][A-Z0-9]+)(?:\|[^>]*)?>/g
12
14
  // irrelevant to the target user, so it is captured but discarded on rewrite.
13
15
  const DISCORD_MENTION_PATTERN = /<@!?(\d+)>/g
14
16
 
17
+ // User ids the agent addressed by @-mention in an outbound message, so the
18
+ // router can grant them sticky credit (their reply answers us without a
19
+ // re-mention). Only Slack (`<@U…>`) and Discord (`<@id>`) encode mentions as
20
+ // raw id tokens that map 1:1 to an inbound `authorId`; Telegram (`@username`),
21
+ // GitHub (`@login`), LINE and KakaoTalk have no token→authorId mapping here, so
22
+ // they return [] by design and the caller falls back to existing rules.
23
+ export function extractMentionedUserIds(adapter: AdapterId, text: string): string[] {
24
+ const pattern =
25
+ adapter === 'slack-bot' ? SLACK_MENTION_PATTERN : adapter === 'discord-bot' ? DISCORD_MENTION_PATTERN : null
26
+ if (pattern === null) return []
27
+ const ids = new Set<string>()
28
+ for (const match of text.matchAll(pattern)) ids.add(match[1]!)
29
+ return Array.from(ids)
30
+ }
31
+
15
32
  export async function addSlackMentionHints(
16
33
  text: string,
17
34
  resolveUserName: (id: string) => Promise<string>,
@@ -24,6 +24,7 @@ import type { HookBus } from '@/plugin'
24
24
  import { extractClaimCode } from '@/role-claim'
25
25
  import type { Stream } from '@/stream'
26
26
 
27
+ import { extractMentionedUserIds } from './adapters/mention-hints'
27
28
  import { formatChannelCommandHelp } from './commands'
28
29
  import { detectContinuationWillingness } from './continuation-willingness'
29
30
  import {
@@ -111,13 +112,18 @@ export const TYPING_HEARTBEAT_MS = 8000
111
112
  // turns a temporary status into a permanent-looking artifact.
112
113
  //
113
114
  // The cap is measured from `live.typingStartedAt`, which is refreshed by
114
- // two signals of life (see `bumpTypingActivity`):
115
+ // these signals of life (see `bumpTypingActivity`):
115
116
  // 1. Each new `drain()` iteration (a new turn is starting).
116
117
  // 2. Each `tool_execution_end` from the agent session (a tool just
117
118
  // completed — the prompt is progressing, not stuck).
118
- // A 2-minute bash command that emits no intermediate events still trips
119
- // the cap, but a chatty agent running long tools stays under it
120
- // indefinitely. The cap exists to catch *silence*, not duration.
119
+ // 3. Each streaming token (`message_update` carrying a `text_delta` or
120
+ // `thinking_delta`) the model is actively generating, even on a
121
+ // pure-text reply that calls no tools.
122
+ // Signal 3 is what keeps a long conversational reply (no tool calls, just
123
+ // minutes of streamed text or extended thinking) under the cap: without it,
124
+ // such a turn emits no `tool_execution_end` and the indicator was paused
125
+ // mid-generation. A genuinely stuck model call — no tokens, no tools — still
126
+ // trips the cap. The cap exists to catch *silence*, not duration.
121
127
  export const MAX_TYPING_HEARTBEAT_MS = 2 * 60 * 1000
122
128
 
123
129
  // Idle GC: a LiveSession whose `lastInboundAt` is older than
@@ -260,6 +266,28 @@ export const EMPTY_TURN_RETRY_NUDGE = [
260
266
  // drop so the human is never left staring at dead air after a degenerate turn.
261
267
  export const EMPTY_TURN_FALLBACK_TEXT =
262
268
  "⚠️ I got stuck putting together a reply and couldn't finish. Could you rephrase or try again?"
269
+ // Distinct from EMPTY_TURN_RETRY_NUDGE: that one diagnoses budget exhaustion
270
+ // ("ran out of output budget"), which is FALSE for a clean `stop` with empty
271
+ // text. This nudge names the real failure — a turn that ended sending nothing
272
+ // to a message addressed to the agent in a one-on-one conversation — and steers
273
+ // the model to either answer or record the silence explicitly (skip_response /
274
+ // NO_REPLY) rather than ending empty again.
275
+ export const COLD_START_REPLY_NUDGE = [
276
+ '---',
277
+ '**[SYSTEM MESSAGE — not from a human]**',
278
+ '',
279
+ 'Your previous turn ended without sending anything, but the last message was',
280
+ 'addressed to you in a direct, one-on-one conversation — ending silent there',
281
+ 'reads as ignoring the person. This is an automated signal from the channel',
282
+ 'router, not a message from anyone in the chat. **Do not acknowledge or reply',
283
+ 'to this notice itself.**',
284
+ '',
285
+ 'Answer the last user message now via your channel reply tool. If you truly',
286
+ 'have nothing to add, call `skip_response({ reason })` (preferred) or end with',
287
+ 'exactly `NO_REPLY` so the silence is recorded — do not just end empty.',
288
+ '',
289
+ '---',
290
+ ].join('\n')
263
291
  // At most one continuation nudge per logical turn. Stricter than the empty-turn
264
292
  // retry budget (2) because the turn ALREADY delivered a user-facing reply — this
265
293
  // is a one-shot correction offer, not recovery from no output.
@@ -772,6 +800,20 @@ type LiveSession = {
772
800
  // decision used, so the prompt nudge and sticky suppression agree on
773
801
  // "is this a multi-human group". Read by composeTurnPrompt().
774
802
  multiHumanGroup: boolean
803
+ // True when this live session was born from a cold-start (no persisted
804
+ // session existed — first contact or a stale-rollover after long idle), as
805
+ // opposed to rehydrating an existing session. Combined with `turnSeq === 0`
806
+ // it pinpoints the very first prompt of a freshly woken session.
807
+ createdFromColdStart: boolean
808
+ // Set in route() when the FIRST turn of a cold-start session engages via the
809
+ // solo-human "answer everything" fallback (not an explicit mention/reply/DM,
810
+ // not a multi-human group). Read by validateChannelTurn: a BARE-EMPTY stop on
811
+ // such a turn is a model whiff on a direct one-on-one question, not deliberate
812
+ // silence, so it earns an empty-turn retry instead of a silent no_reply.
813
+ // Recomputed on every engage, so it self-clears once turnSeq leaves the first
814
+ // turn; explicit NO_REPLY / skip_response and any turn that already sent stay
815
+ // on the historical silent path.
816
+ coldStartSoloFallbackTurnActive: boolean
775
817
  membershipFetch: Promise<MembershipCount | null> | null
776
818
  // Provider soft-error (`stopReason: 'error'`) captured during the current
777
819
  // turn, deferred to turn-end. Upstream surfaces transient errors (e.g.
@@ -1673,6 +1715,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1673
1715
  consecutiveEngagedPeerBotTurns: 0,
1674
1716
  loopGuardActive: false,
1675
1717
  multiHumanGroup: false,
1718
+ createdFromColdStart: isColdStart,
1719
+ coldStartSoloFallbackTurnActive: false,
1676
1720
  membershipFetch,
1677
1721
  pendingProviderError: null,
1678
1722
  destroyed: false,
@@ -1888,8 +1932,15 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1888
1932
 
1889
1933
  const subscribeTypingActivity = (session: AgentSession, live: LiveSession): (() => void) => {
1890
1934
  return session.subscribe((event) => {
1891
- if (event.type !== 'tool_execution_end') return
1892
- bumpTypingActivity(live)
1935
+ if (event.type === 'tool_execution_end') {
1936
+ bumpTypingActivity(live)
1937
+ return
1938
+ }
1939
+ if (event.type !== 'message_update') return
1940
+ const streamed = event.assistantMessageEvent.type
1941
+ if (streamed === 'text_delta' || streamed === 'thinking_delta') {
1942
+ bumpTypingActivity(live)
1943
+ }
1893
1944
  })
1894
1945
  }
1895
1946
 
@@ -2557,7 +2608,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2557
2608
 
2558
2609
  const membership = await membershipForEngagement(live)
2559
2610
 
2560
- live.multiHumanGroup = isMultiHumanGroup(event.isDm, countEffectiveHumans(live.participants, membership, now()))
2611
+ const effectiveHumans = countEffectiveHumans(live.participants, membership, now())
2612
+ live.multiHumanGroup = isMultiHumanGroup(event.isDm, effectiveHumans)
2561
2613
 
2562
2614
  const decision: EngagementDecision = decideEngagement({
2563
2615
  message: event,
@@ -2583,6 +2635,22 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2583
2635
 
2584
2636
  publishInbound(event, 'engage', live.sessionId)
2585
2637
 
2638
+ // Arm cold-start bare-empty recovery only for the exact incident shape: the
2639
+ // FIRST prompt (`turnSeq === 0`) of a freshly cold-started session that
2640
+ // engaged via the solo-human answer-everything fallback — a lone human, no
2641
+ // explicit mention/reply/DM, not a multi-human group. Recomputed on every
2642
+ // engage so it self-clears once the first turn advances `turnSeq`; explicit
2643
+ // address (mention/reply/DM) keeps the historical silent-on-empty path.
2644
+ live.coldStartSoloFallbackTurnActive =
2645
+ live.createdFromColdStart &&
2646
+ live.turnSeq === 0 &&
2647
+ effectiveHumans <= 1 &&
2648
+ !event.authorIsBot &&
2649
+ !event.isDm &&
2650
+ !event.isBotMention &&
2651
+ event.replyToBotMessageId === null &&
2652
+ !live.multiHumanGroup
2653
+
2586
2654
  const engageReaction = autoReactOnEngage(event)
2587
2655
 
2588
2656
  updateLoopGuard(live, event)
@@ -3320,11 +3388,21 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
3320
3388
  const disengagedThisTurn = live.disengagedTurn !== null && live.disengagedTurn === live.turnSeq
3321
3389
  const adapterConfig = options.configForAdapter(msg.adapter)
3322
3390
  if (adapterConfig && !disengagedThisTurn) {
3323
- const targetIds = Array.from(
3324
- live.currentTurnAuthorIds.size > 0 ? live.currentTurnAuthorIds : live.lastTurnAuthorIds,
3325
- )
3326
- if (targetIds.length > 0) {
3327
- grantStickyForReplyTargets(stickyLedger, keyId, targetIds, adapterConfig.engagement, now())
3391
+ const targets = new Set(live.currentTurnAuthorIds.size > 0 ? live.currentTurnAuthorIds : live.lastTurnAuthorIds)
3392
+ // A user the agent addresses by @-mention is a reply target too: their
3393
+ // next message answers us without re-mentioning the bot. Granting them
3394
+ // sticky closes the gap where the agent asks "<@U123> can you confirm?"
3395
+ // and that user's plain reply was observed until they re-pinged.
3396
+ // Self-mentions (e.g. a quoted inbound) are excluded — we credit the
3397
+ // OTHERS we addressed, not ourselves.
3398
+ if (text !== undefined) {
3399
+ const selfId = resolveSelfIdentity(live.key)?.id
3400
+ for (const id of extractMentionedUserIds(msg.adapter, text)) {
3401
+ if (id !== selfId) targets.add(id)
3402
+ }
3403
+ }
3404
+ if (targets.size > 0) {
3405
+ grantStickyForReplyTargets(stickyLedger, keyId, Array.from(targets), adapterConfig.engagement, now())
3328
3406
  }
3329
3407
  }
3330
3408
  const turnCount = live.consecutiveSends.get(sendKey) ?? 0
@@ -3597,6 +3675,36 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
3597
3675
  let assistantText = candidateText
3598
3676
 
3599
3677
  if (endsWithNoReplySignal(assistantText)) {
3678
+ // A BARE-EMPTY stop (no visible text, not an explicit NO_REPLY token) on
3679
+ // the armed cold-start solo-human fallback turn is the production "dropped
3680
+ // the owner's first question" shape — a model whiff on a direct one-on-one
3681
+ // question, not a deliberate decline. Give it the bounded empty-turn retry
3682
+ // with a dedicated nudge; on exhaustion post the visible fallback so the
3683
+ // human is never stranded on silence. Gated hard so deliberate silence
3684
+ // still stays silent: explicit NO_REPLY (non-empty trim), any turn that
3685
+ // already sent (successfulChannelSends moved), a queued fresh inbound (the
3686
+ // next drain answers it), and every turn outside the armed cold-start solo
3687
+ // path all fall through to the historical no_reply below.
3688
+ if (
3689
+ assistantText.trim() === '' &&
3690
+ source === 'leaf' &&
3691
+ live.coldStartSoloFallbackTurnActive &&
3692
+ live.currentTurnAuthorId !== null &&
3693
+ live.successfulChannelSends === successfulSendsBeforePrompt &&
3694
+ live.promptQueue.length === 0
3695
+ ) {
3696
+ if (live.emptyTurnRetries < MAX_EMPTY_TURN_RETRIES) {
3697
+ live.emptyTurnRetries++
3698
+ logger.warn(
3699
+ `[channels] ${live.keyId} empty_turn_retry attempt=${live.emptyTurnRetries}/${MAX_EMPTY_TURN_RETRIES} ` +
3700
+ `cause=cold_start_solo_bare_empty`,
3701
+ )
3702
+ live.pendingSystemReminders.push(COLD_START_REPLY_NUDGE)
3703
+ return
3704
+ }
3705
+ await postEmptyTurnFallback('cold_start_solo_bare_empty_retries_exhausted')
3706
+ return
3707
+ }
3600
3708
  const leakedReasoning = !isNoReplySignal(assistantText)
3601
3709
  logger.info(`[channels] ${live.keyId} no_reply${leakedReasoning ? ' (with_leaked_reasoning)' : ''}`)
3602
3710
  return
package/src/cli/dreams.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  import { defineCommand } from 'citty'
2
2
 
3
3
  import { type DreamEntry, renderListRow, runDreams, type ViewAction } from '@/dreams'
4
- import { findAgentDir } from '@/init'
5
4
 
6
5
  import { createEscController } from './inspect-controller'
6
+ import { requireAgentDir } from './require-agent-dir'
7
7
  import { c, cancel, errorLine, isCancel, prepareStdinForClack } from './ui'
8
8
 
9
9
  const ESC_DEBOUNCE_MS = 50
@@ -31,7 +31,7 @@ export const dreamsCommand = defineCommand({
31
31
  },
32
32
  },
33
33
  async run({ args }) {
34
- const cwd = findAgentDir(process.cwd()) ?? process.cwd()
34
+ const cwd = requireAgentDir()
35
35
  const color = useColor()
36
36
  const limit = parseLimit(args.limit)
37
37
  const interactive = isInteractive() && args.json !== true