typeclaw 0.10.0 → 0.11.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 (53) hide show
  1. package/package.json +1 -1
  2. package/src/agent/index.ts +37 -4
  3. package/src/agent/restart-handoff/index.ts +91 -0
  4. package/src/agent/restart-handoff/paths.ts +11 -0
  5. package/src/agent/session-origin.ts +30 -10
  6. package/src/agent/subagent-completion-reminder.ts +4 -2
  7. package/src/agent/system-prompt.ts +1 -1
  8. package/src/agent/tools/restart.ts +42 -1
  9. package/src/agent/tools/skip-response.ts +157 -0
  10. package/src/bundled-plugins/memory/README.md +18 -2
  11. package/src/bundled-plugins/memory/index.ts +108 -6
  12. package/src/bundled-plugins/memory/memory-logger.ts +33 -24
  13. package/src/bundled-plugins/security/policies/prompt-injection.ts +1 -1
  14. package/src/channels/adapters/github/auth-app.ts +53 -9
  15. package/src/channels/adapters/github/auth-pat.ts +4 -1
  16. package/src/channels/adapters/github/auth.ts +10 -0
  17. package/src/channels/adapters/github/event-permissions.ts +83 -0
  18. package/src/channels/adapters/github/inbound.ts +126 -1
  19. package/src/channels/adapters/github/index.ts +60 -66
  20. package/src/channels/adapters/github/outbound.ts +65 -17
  21. package/src/channels/adapters/github/permission-guidance.ts +169 -0
  22. package/src/channels/adapters/github/team-membership.ts +56 -0
  23. package/src/channels/router.ts +213 -32
  24. package/src/channels/schema.ts +8 -7
  25. package/src/channels/types.ts +1 -1
  26. package/src/cli/channel.ts +135 -38
  27. package/src/cli/init.ts +133 -86
  28. package/src/cli/inspect-controller.ts +66 -0
  29. package/src/cli/inspect.ts +24 -32
  30. package/src/cli/run.ts +24 -5
  31. package/src/cli/tui.ts +34 -10
  32. package/src/cli/tunnel.ts +453 -14
  33. package/src/config/config.ts +35 -7
  34. package/src/config/providers.ts +64 -56
  35. package/src/init/env-file.ts +66 -0
  36. package/src/init/hatching.ts +32 -5
  37. package/src/init/index.ts +131 -39
  38. package/src/init/validate-api-key.ts +31 -0
  39. package/src/inspect/index.ts +5 -1
  40. package/src/inspect/loop.ts +12 -1
  41. package/src/inspect/replay.ts +15 -1
  42. package/src/run/codex-fetch-observer.ts +377 -0
  43. package/src/run/index.ts +12 -2
  44. package/src/server/index.ts +59 -1
  45. package/src/shared/protocol.ts +1 -1
  46. package/src/skills/typeclaw-channel-github/SKILL.md +45 -1
  47. package/src/skills/typeclaw-tunnels/SKILL.md +33 -1
  48. package/src/tui/index.ts +17 -5
  49. package/src/tunnels/index.ts +1 -0
  50. package/src/tunnels/manager.ts +18 -0
  51. package/src/tunnels/providers/cloudflare-named.ts +224 -0
  52. package/src/tunnels/types.ts +17 -1
  53. package/typeclaw.schema.json +25 -7
@@ -29,11 +29,7 @@ import {
29
29
  saveChannelSessions,
30
30
  type ChannelSessionRecord,
31
31
  } from './persistence'
32
- import {
33
- DEFAULT_QUOTED_REPLY_QUEUE_DELAY_MS,
34
- QUOTED_REPLY_EXCERPT_MAX_CHARS,
35
- type ChannelAdapterConfig,
36
- } from './schema'
32
+ import { QUOTED_REPLY_EXCERPT_MAX_CHARS, type AdapterId, type ChannelAdapterConfig } from './schema'
37
33
  import type {
38
34
  ChannelHistoryMessage,
39
35
  ChannelKey,
@@ -234,6 +230,19 @@ type ObservedInbound = {
234
230
  authorIsBot: boolean
235
231
  receivedAt: number
236
232
  ts: number
233
+ // Distinguishes scrollback that was bulk-loaded at session cold-start
234
+ // (`prefetch`) from messages that actually arrived in the channel after
235
+ // the session went live (`observed`). Both share the same in-memory
236
+ // shape because the model sees them identically in the prompt's
237
+ // "Recent context" block, but the quote-anchor decision must treat them
238
+ // differently: prefetched scrollback is HISTORICAL context, not new
239
+ // chatter that happened between the primary inbound and the agent's
240
+ // reply. Counting prefetch entries as "intervening" would fire the
241
+ // anchor on every fresh-thread first turn (the prefetch stamps
242
+ // `receivedAt = now()` AFTER the inbound was received during ensureLive,
243
+ // so by primary-vs-observed timestamp comparison they always look
244
+ // "later"). See captureQuoteCandidate.
245
+ source: 'prefetch' | 'observed'
237
246
  }
238
247
 
239
248
  type LiveSession = {
@@ -306,6 +315,31 @@ type LiveSession = {
306
315
  // future hard cap without picking a threshold out of thin air.
307
316
  sendTimestamps: Map<string, number[]>
308
317
  successfulChannelSends: number
318
+ // Monotonic per-LiveSession turn counter incremented just before each
319
+ // `live.session.prompt(...)` call in `drain()`. Used as a turn identity
320
+ // so `skip_response` can record "I skipped turn N" without leaking
321
+ // across turns. `validateChannelTurn` only honors `skippedTurn` when it
322
+ // equals `turnSeq`; a stale value from a crashed/aborted prior turn is
323
+ // ignored (defensive: an unmatched skippedTurn would otherwise silently
324
+ // drop the next user-facing reply). NOT cleared on drain finally — the
325
+ // counter is purely monotonic; the matching comparison is what protects
326
+ // against stale state.
327
+ turnSeq: number
328
+ // Snapshot of `successfulChannelSends` taken at turn start (same
329
+ // moment `turnSeq` increments). Lets `markTurnSkipped` detect "a
330
+ // channel send already landed in this turn" and reject the skip,
331
+ // making the rejection symmetric with the send-after-skip lock in
332
+ // `send()`: commit to silence or commit to replying, not both,
333
+ // regardless of which order the model tried them in. Updated only at
334
+ // turn start; reads against the live counter elsewhere are intentional.
335
+ successfulSendsAtTurnStart: number
336
+ // Stamped by `markTurnSkipped` (called from the `skip_response` tool)
337
+ // with the current `turnSeq`. Read at the top of `validateChannelTurn`:
338
+ // if it matches the just-completed turn, recovery is skipped entirely
339
+ // (no NO_REPLY check, no Kimi leak check, no assistant-text recovery).
340
+ // The model has explicitly opted out of this turn and we honor that
341
+ // unconditionally. `null` when no skip has been recorded.
342
+ skippedTurn: { turnSeq: number; reason: string } | null
309
343
  // Captured by drain() at batch dequeue; read+cleared by send() on the
310
344
  // first tool-source send of the turn. The anchor decision (delay
311
345
  // threshold + intervening-observed check) is evaluated at SEND time
@@ -369,6 +403,11 @@ export const TURN_CAP_ERROR =
369
403
  `Send-cap reached for this turn (${MAX_CHANNEL_SENDS_PER_TURN} messages already sent to this conversation). ` +
370
404
  'End your turn now. The user can prompt you again for more output.'
371
405
 
406
+ export const SKIP_RESPONSE_LOCK_ERROR =
407
+ 'You called `skip_response` earlier in this turn, which committed to staying silent. ' +
408
+ 'Channel sends are blocked for the rest of this turn. End your turn now; if you have ' +
409
+ 'something to say, send it on the next turn.'
410
+
372
411
  export type ChannelRouter = {
373
412
  route: (event: InboundMessage) => Promise<void>
374
413
  send: (msg: OutboundMessage, opts?: SendOptions) => Promise<SendResult>
@@ -434,6 +473,31 @@ export type ChannelRouter = {
434
473
  durationMs: number
435
474
  error?: string
436
475
  }) => { kind: 'delivered'; keyId: string } | { kind: 'no-live-session' }
476
+ // Record that the agent invoked `skip_response` during the current turn
477
+ // for the channel session identified by `parentSessionId`. The reason is
478
+ // logged at INFO level inside `validateChannelTurn` (single log line per
479
+ // skip, so operators see exactly one record per silent turn). Stamps the
480
+ // current `turnSeq` on the live session so a stale record from an earlier
481
+ // turn cannot drop a future legitimate reply.
482
+ //
483
+ // Returns:
484
+ // - 'recorded' — the live session was found and the skip was stamped
485
+ // - 'send-already-happened' — a tool-source channel send already landed
486
+ // in this turn; the skip is refused (symmetric with
487
+ // the send-after-skip lock in `send()`) so the model
488
+ // cannot land a reply AND claim silence. The flag is
489
+ // NOT stamped, so the turn proceeds as a normal
490
+ // reply turn.
491
+ // - 'no-live-session' — no matching channel session (e.g. tool fired
492
+ // outside a channel origin); the tool should
493
+ // still log the reason but cannot suppress.
494
+ markTurnSkipped: (args: {
495
+ parentSessionId: string
496
+ reason: string
497
+ }) =>
498
+ | { kind: 'recorded'; keyId: string }
499
+ | { kind: 'send-already-happened'; keyId: string }
500
+ | { kind: 'no-live-session' }
437
501
  stop: () => Promise<void>
438
502
  liveCount: () => number
439
503
  __testing?: {
@@ -926,6 +990,9 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
926
990
  lastSentText: new Map(),
927
991
  sendTimestamps: new Map(),
928
992
  successfulChannelSends: 0,
993
+ turnSeq: 0,
994
+ successfulSendsAtTurnStart: 0,
995
+ skippedTurn: null,
929
996
  pendingQuoteCandidate: null,
930
997
  recentEngagedPeerBotTurns: [],
931
998
  consecutiveEngagedPeerBotTurns: 0,
@@ -1019,6 +1086,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1019
1086
  authorIsBot: item.message.isBot,
1020
1087
  receivedAt: now(),
1021
1088
  ts: item.message.ts,
1089
+ source: 'prefetch',
1022
1090
  })
1023
1091
  } else {
1024
1092
  observed.push({
@@ -1028,6 +1096,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1028
1096
  authorIsBot: true,
1029
1097
  receivedAt: now(),
1030
1098
  ts: 0,
1099
+ source: 'prefetch',
1031
1100
  })
1032
1101
  }
1033
1102
  }
@@ -1093,7 +1162,9 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1093
1162
  return
1094
1163
  }
1095
1164
  if (now() - live.typingStartedAt >= MAX_TYPING_HEARTBEAT_MS) {
1096
- logger.warn(`[channels] ${live.keyId}: typing heartbeat timed out after ${MAX_TYPING_HEARTBEAT_MS}ms`)
1165
+ logger.warn(
1166
+ `[channels] ${live.keyId}: typing indicator paused after ${MAX_TYPING_HEARTBEAT_MS}ms; prompt still in flight`,
1167
+ )
1097
1168
  live.typingTimedOut = true
1098
1169
  void stopTypingHeartbeat(live)
1099
1170
  return
@@ -1218,6 +1289,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1218
1289
  const observed = live.contextBuffer.splice(0, live.contextBuffer.length)
1219
1290
  const reminders = live.pendingSystemReminders.splice(0, live.pendingSystemReminders.length)
1220
1291
  const text = composeTurnPrompt(observed, batch, {
1292
+ adapter: live.key.adapter,
1221
1293
  loopGuardActive: live.loopGuardActive,
1222
1294
  systemReminders: reminders,
1223
1295
  })
@@ -1227,7 +1299,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1227
1299
  live.currentTurnAuthorIds = new Set(batch.map((m) => m.authorId))
1228
1300
  live.consecutiveSends.clear()
1229
1301
  live.lastSentText.clear()
1230
- live.pendingQuoteCandidate = captureQuoteCandidate(batch, observed)
1302
+ live.pendingQuoteCandidate = captureQuoteCandidate(live.key.adapter, batch, observed)
1231
1303
  } else if (live.lastTurnAuthorId !== null) {
1232
1304
  // Reminder-only turn (batch.length === 0, reminders.length > 0):
1233
1305
  // restore the author identity from the prior turn so author-
@@ -1259,6 +1331,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1259
1331
  logger.info(`[channels] ${live.keyId} prompting batch=${batch.length} text_len=${text.length}`)
1260
1332
  const promptStart = now()
1261
1333
  const successfulSendsBeforePrompt = live.successfulChannelSends
1334
+ live.turnSeq++
1335
+ live.successfulSendsAtTurnStart = successfulSendsBeforePrompt
1262
1336
  await fireSessionTurnStart(live, text)
1263
1337
  try {
1264
1338
  await live.session.prompt(text)
@@ -1538,6 +1612,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1538
1612
  authorIsBot: event.authorIsBot,
1539
1613
  receivedAt: now(),
1540
1614
  ts: event.ts,
1615
+ source: 'observed',
1541
1616
  })
1542
1617
  if (live.contextBuffer.length > CONTEXT_BUFFER_SIZE) {
1543
1618
  live.contextBuffer.splice(0, live.contextBuffer.length - CONTEXT_BUFFER_SIZE)
@@ -1711,17 +1786,17 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1711
1786
  const live = liveSessions.get(keyId)
1712
1787
  const sendKey = consecutiveSendKey(msg.chat, msg.thread)
1713
1788
  // Tool-source sends consume the captured quote candidate exactly
1714
- // once per turn — the decision (delay threshold + intervening-
1715
- // observed check) runs HERE against the live clock so the relevant
1716
- // signal is real wall-time between inbound and reply landing, not
1717
- // drain-vs-send timing artifacts. System sources (recovery, role-
1789
+ // once per turn — the intervening-observed check runs HERE against
1790
+ // the live buffer so the relevant signal is actual channel chatter
1791
+ // between inbound and reply landing, not drain-vs-send timing
1792
+ // artifacts. System sources (recovery, role-
1718
1793
  // claim) skip so they can't accidentally swallow the candidate
1719
1794
  // before the model's own first reply lands. Even when the decision
1720
- // returns null (delay below threshold, nothing intervened), the
1721
- // candidate is cleared — a multi-part reply that crosses the
1722
- // threshold mid-flight must not retroactively anchor chunk 2.
1795
+ // returns null (nothing intervened), the candidate is cleared — a
1796
+ // multi-part reply must not retroactively anchor chunk 2.
1723
1797
  if (live && source === 'tool' && live.pendingQuoteCandidate !== null) {
1724
- const anchor = decideQuoteAnchor(live.pendingQuoteCandidate, now(), options.configForAdapter(msg.adapter))
1798
+ const quoteCandidate = refreshQuoteCandidate(live.pendingQuoteCandidate, live.contextBuffer)
1799
+ const anchor = decideQuoteAnchor(quoteCandidate, now(), options.configForAdapter(msg.adapter))
1725
1800
  if (anchor !== null) msg = { ...msg, text: prependQuoteAnchor(msg.text ?? '', anchor) }
1726
1801
  live.pendingQuoteCandidate = null
1727
1802
  }
@@ -1739,6 +1814,13 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1739
1814
  let priorLastSentText: string | undefined
1740
1815
  let reserved = false
1741
1816
  if (live && source === 'tool') {
1817
+ // Tool-source send after `skip_response` for the same turn is a contract
1818
+ // violation: the model already committed to silence. Reject before any
1819
+ // state mutation so the model gets a clear error and the channel stays
1820
+ // silent. System-source sends (recovery, role-claim) are not affected.
1821
+ if (live.skippedTurn !== null && live.skippedTurn.turnSeq === live.turnSeq) {
1822
+ return { ok: false, error: SKIP_RESPONSE_LOCK_ERROR, code: 'skip-locked' }
1823
+ }
1742
1824
  const currentCount = live.consecutiveSends.get(sendKey) ?? 0
1743
1825
  if (currentCount >= MAX_CHANNEL_SENDS_PER_TURN) {
1744
1826
  return { ok: false, error: TURN_CAP_ERROR, code: 'turn-cap' }
@@ -1825,6 +1907,19 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1825
1907
  }
1826
1908
 
1827
1909
  const validateChannelTurn = async (live: LiveSession, successfulSendsBeforePrompt: number): Promise<void> => {
1910
+ // `skip_response` short-circuit. Must run before the `successfulChannelSends`
1911
+ // check so a model that called both `skip_response` and a channel tool in
1912
+ // the same turn still resolves as "skipped" — the rejection inside `send()`
1913
+ // means the channel tool returned an error and never actually delivered.
1914
+ // Stale-flag protection: only honor when stamped on the just-completed
1915
+ // turn. A flag set by a previous turn that crashed before validation
1916
+ // would otherwise drop the next legitimate user-facing reply.
1917
+ if (live.skippedTurn !== null && live.skippedTurn.turnSeq === live.turnSeq) {
1918
+ const { reason } = live.skippedTurn
1919
+ live.skippedTurn = null
1920
+ logger.info(`[channels] ${live.keyId} skipped_by_tool reason=${JSON.stringify(reason)}`)
1921
+ return
1922
+ }
1828
1923
  if (live.successfulChannelSends > successfulSendsBeforePrompt) return
1829
1924
 
1830
1925
  const candidate = recoverableAssistantText(live.session)
@@ -2071,6 +2166,25 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2071
2166
  return { kind: 'no-live-session' }
2072
2167
  }
2073
2168
 
2169
+ const markTurnSkipped = (args: {
2170
+ parentSessionId: string
2171
+ reason: string
2172
+ }):
2173
+ | { kind: 'recorded'; keyId: string }
2174
+ | { kind: 'send-already-happened'; keyId: string }
2175
+ | { kind: 'no-live-session' } => {
2176
+ for (const live of liveSessions.values()) {
2177
+ if (live.destroyed) continue
2178
+ if (live.sessionId !== args.parentSessionId) continue
2179
+ if (live.successfulChannelSends > live.successfulSendsAtTurnStart) {
2180
+ return { kind: 'send-already-happened', keyId: live.keyId }
2181
+ }
2182
+ live.skippedTurn = { turnSeq: live.turnSeq, reason: args.reason }
2183
+ return { kind: 'recorded', keyId: live.keyId }
2184
+ }
2185
+ return { kind: 'no-live-session' }
2186
+ }
2187
+
2074
2188
  return {
2075
2189
  route,
2076
2190
  send,
@@ -2093,6 +2207,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2093
2207
  executeCommand,
2094
2208
  getSelfAliases: computeSelfAliases,
2095
2209
  injectSubagentCompletionReminder,
2210
+ markTurnSkipped,
2096
2211
  stop,
2097
2212
  liveCount: () => liveSessions.size,
2098
2213
  __testing: {
@@ -2119,7 +2234,9 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2119
2234
  return
2120
2235
  }
2121
2236
  if (now() - live.typingStartedAt >= MAX_TYPING_HEARTBEAT_MS) {
2122
- logger.warn(`[channels] ${live.keyId}: typing heartbeat timed out after ${MAX_TYPING_HEARTBEAT_MS}ms`)
2237
+ logger.warn(
2238
+ `[channels] ${live.keyId}: typing indicator paused after ${MAX_TYPING_HEARTBEAT_MS}ms; prompt still in flight`,
2239
+ )
2123
2240
  live.typingTimedOut = true
2124
2241
  await stopTypingHeartbeat(live)
2125
2242
  return
@@ -2159,8 +2276,11 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2159
2276
  function composeTurnPrompt(
2160
2277
  observed: readonly ObservedInbound[],
2161
2278
  batch: readonly QueuedInbound[],
2162
- state: { loopGuardActive: boolean; systemReminders?: readonly string[] } = { loopGuardActive: false },
2279
+ state: { adapter?: AdapterId; loopGuardActive: boolean; systemReminders?: readonly string[] } = {
2280
+ loopGuardActive: false,
2281
+ },
2163
2282
  ): string {
2283
+ const adapter = state.adapter ?? 'discord-bot'
2164
2284
  const parts: string[] = []
2165
2285
  // System reminders (subagent-completion wakeups today) lead the turn body
2166
2286
  // because they are typically what triggered the drain — when the prompt
@@ -2226,7 +2346,7 @@ function composeTurnPrompt(
2226
2346
  if (observed.length > 0) {
2227
2347
  parts.push('## Recent context (not addressed to you, for awareness only)')
2228
2348
  for (const o of observed) {
2229
- parts.push(formatAuthorLine(o.ts, o.authorId, o.authorName, o.authorIsBot, o.text))
2349
+ parts.push(formatAuthorLine(o.ts, adapter, o.authorId, o.authorName, o.authorIsBot, o.text))
2230
2350
  }
2231
2351
  parts.push('')
2232
2352
  }
@@ -2244,7 +2364,7 @@ function composeTurnPrompt(
2244
2364
  )
2245
2365
  }
2246
2366
  for (const b of batch) {
2247
- parts.push(formatAuthorLine(b.ts, b.authorId, b.authorName, b.authorIsBot, b.text))
2367
+ parts.push(formatAuthorLine(b.ts, adapter, b.authorId, b.authorName, b.authorIsBot, b.text))
2248
2368
  }
2249
2369
  }
2250
2370
  return parts.join('\n')
@@ -2252,6 +2372,7 @@ function composeTurnPrompt(
2252
2372
 
2253
2373
  function formatAuthorLine(
2254
2374
  ts: number,
2375
+ adapter: AdapterId,
2255
2376
  authorId: string,
2256
2377
  authorName: string,
2257
2378
  authorIsBot: boolean,
@@ -2259,15 +2380,44 @@ function formatAuthorLine(
2259
2380
  ): string {
2260
2381
  const tag = authorIsBot ? ' [bot]' : ''
2261
2382
  const stamp = ts > 0 ? `[${new Date(ts).toISOString()}] ` : ''
2262
- return `${stamp}<@${authorId}> (${authorName})${tag}: ${text}`
2383
+ return `${stamp}${formatAuthorReference(adapter, authorId, authorName)} (${authorName})${tag}: ${text}`
2263
2384
  }
2264
2385
 
2265
2386
  export type QuoteAnchorSource = {
2387
+ adapter: AdapterId
2388
+ authorId: string
2266
2389
  authorName: string
2267
2390
  text: string
2268
2391
  }
2269
2392
 
2270
- // Renders the single-line `> @name: excerpt` blockquote prepended to
2393
+ // Picks the right author syntax for the platform so prompts and rendered
2394
+ // quote anchors use the same form the user would type in that channel.
2395
+ // Slack/Discord need id mentions (`<@U…>`), GitHub needs handle mentions
2396
+ // (`@login`) because inbound author ids are numeric, and adapters without
2397
+ // stable id-only mention syntax fall back to plain display names.
2398
+ //
2399
+ // Notification semantics: Slack and Discord both render `<@…>` as a
2400
+ // styled mention link inside blockquotes; whether the mentioned user is
2401
+ // PINGED is a separate platform-level UX (Slack pings on first appearance
2402
+ // in the message regardless of position, Discord respects the
2403
+ // `allowed_mentions` field which defaults to "ping everyone parsed").
2404
+ // This matches PR #374's intent — the user IS being notified that the
2405
+ // agent replied to them, which is the whole point of a quote anchor.
2406
+ function formatAuthorReference(adapter: AdapterId, authorId: string, authorName: string): string {
2407
+ const displayName = authorName.trim() !== '' ? authorName.trim() : authorId
2408
+ switch (adapter) {
2409
+ case 'slack-bot':
2410
+ case 'discord-bot':
2411
+ return `<@${authorId}>`
2412
+ case 'github':
2413
+ return displayName.startsWith('@') ? displayName : `@${displayName}`
2414
+ case 'telegram-bot':
2415
+ case 'kakaotalk':
2416
+ return displayName
2417
+ }
2418
+ }
2419
+
2420
+ // Renders the single-line `> @mention: excerpt` blockquote prepended to
2271
2421
  // outbound replies when the router decides the reply needs an anchor.
2272
2422
  // Collapses newlines to spaces so a multi-line user message renders on
2273
2423
  // one quoted line (markdown blockquote semantics: a blank line ends the
@@ -2286,17 +2436,27 @@ export function renderQuoteAnchor(source: QuoteAnchorSource): string {
2286
2436
  : collapsed.length > QUOTED_REPLY_EXCERPT_MAX_CHARS
2287
2437
  ? `${collapsed.slice(0, QUOTED_REPLY_EXCERPT_MAX_CHARS - 1)}…`
2288
2438
  : collapsed
2289
- return `> @${source.authorName}: ${excerpt}`
2439
+ const mention = formatAuthorReference(source.adapter, source.authorId, source.authorName)
2440
+ return `> ${mention}: ${excerpt}`
2290
2441
  }
2291
2442
 
2443
+ // Separates the anchor from the reply with a blank line (`\n\n`), not a
2444
+ // single `\n`. In standard GFM and Slack's `markdown` block, a single
2445
+ // `\n` inside a paragraph is a soft break rendered as whitespace, which
2446
+ // keeps the `>` blockquote styling running visually through the next
2447
+ // line — i.e. the agent's reply text gets swallowed into the quote. The
2448
+ // blank line forces a paragraph boundary that unambiguously ends the
2449
+ // blockquote on every renderer (CommonMark, GFM, Slack mrkdwn, Discord
2450
+ // markdown).
2292
2451
  export function prependQuoteAnchor(replyText: string, source: QuoteAnchorSource): string {
2293
2452
  const anchor = renderQuoteAnchor(source)
2294
2453
  if (replyText === '') return anchor
2295
- return `${anchor}\n${replyText}`
2454
+ return `${anchor}\n\n${replyText}`
2296
2455
  }
2297
2456
 
2298
2457
  type QuoteAnchorBatchEntry = {
2299
2458
  text: string
2459
+ authorId: string
2300
2460
  authorName: string
2301
2461
  authorIsBot: boolean
2302
2462
  receivedAt: number
@@ -2304,6 +2464,7 @@ type QuoteAnchorBatchEntry = {
2304
2464
 
2305
2465
  type QuoteAnchorObservedEntry = {
2306
2466
  receivedAt: number
2467
+ source: 'prefetch' | 'observed'
2307
2468
  }
2308
2469
 
2309
2470
  export type QuoteAnchorCandidate = {
@@ -2316,38 +2477,58 @@ export type QuoteAnchorCandidate = {
2316
2477
  // the send-side decision has the data it needs without holding a
2317
2478
  // reference to the batch arrays. Returns null when there's nothing
2318
2479
  // anchorable (empty batch, primary is a bot).
2480
+ //
2481
+ // `hadInterveningObserved` counts ONLY live observations (`source ===
2482
+ // 'observed'`), not prefetched scrollback. Prefetch stamps `receivedAt =
2483
+ // now()` inside ensureLive — wall-clock-later than the primary inbound
2484
+ // that triggered ensureLive — so without this gate, every cold-start
2485
+ // first turn would see "intervening observed" entries and fire the
2486
+ // quote anchor even when the reply lands within milliseconds. The
2487
+ // signal we actually want is "did real new chatter arrive between the
2488
+ // user's inbound and the agent's reply", which only live observations
2489
+ // represent.
2319
2490
  export function captureQuoteCandidate(
2491
+ adapter: AdapterId,
2320
2492
  batch: readonly QuoteAnchorBatchEntry[],
2321
2493
  observed: readonly QuoteAnchorObservedEntry[],
2322
2494
  ): QuoteAnchorCandidate | null {
2323
2495
  if (batch.length === 0) return null
2324
2496
  const primary = batch[batch.length - 1]!
2325
2497
  if (primary.authorIsBot) return null
2326
- const hadInterveningObserved = observed.some((o) => o.receivedAt >= primary.receivedAt)
2327
2498
  return {
2328
- source: { authorName: primary.authorName, text: primary.text },
2499
+ source: { adapter, authorId: primary.authorId, authorName: primary.authorName, text: primary.text },
2329
2500
  primaryReceivedAt: primary.receivedAt,
2330
- hadInterveningObserved,
2501
+ hadInterveningObserved: hasInterveningObserved(primary.receivedAt, observed),
2331
2502
  }
2332
2503
  }
2333
2504
 
2505
+ function refreshQuoteCandidate(
2506
+ candidate: QuoteAnchorCandidate,
2507
+ observed: readonly QuoteAnchorObservedEntry[],
2508
+ ): QuoteAnchorCandidate {
2509
+ if (candidate.hadInterveningObserved) return candidate
2510
+ if (!hasInterveningObserved(candidate.primaryReceivedAt, observed)) return candidate
2511
+ return { ...candidate, hadInterveningObserved: true }
2512
+ }
2513
+
2514
+ function hasInterveningObserved(primaryReceivedAt: number, observed: readonly QuoteAnchorObservedEntry[]): boolean {
2515
+ return observed.some((o) => o.source === 'observed' && o.receivedAt >= primaryReceivedAt)
2516
+ }
2517
+
2334
2518
  // Send-time decision: given a captured candidate and the current clock,
2335
2519
  // returns the source to anchor against or null. Skips when:
2336
2520
  // - quotedReply is disabled in config
2337
- // - delay is under threshold AND no observed messages came between
2338
- // primary inbound and now (the "felt instantaneous" path)
2521
+ // - no observed messages came between primary inbound and now
2339
2522
  // A null candidate (no batch yet, or batch was bot-only) always skips.
2340
2523
  export function decideQuoteAnchor(
2341
2524
  candidate: QuoteAnchorCandidate | null,
2342
- nowMs: number,
2525
+ _nowMs: number,
2343
2526
  adapterConfig: ChannelAdapterConfig | undefined,
2344
2527
  ): QuoteAnchorSource | null {
2345
2528
  if (candidate === null) return null
2346
2529
  const config = adapterConfig?.quotedReply
2347
2530
  if (config !== undefined && config.enabled === false) return null
2348
- const threshold = config?.queueDelayMs ?? DEFAULT_QUOTED_REPLY_QUEUE_DELAY_MS
2349
- const delay = nowMs - candidate.primaryReceivedAt
2350
- if (delay < threshold && !candidate.hadInterveningObserved) return null
2531
+ if (!candidate.hadInterveningObserved) return null
2351
2532
  return candidate.source
2352
2533
  }
2353
2534
 
@@ -87,13 +87,12 @@ const historySchema = z
87
87
  },
88
88
  })
89
89
 
90
- // When the agent's first send of a turn lands ≥ this many ms after the
91
- // inbound was received, OR there were intervening observed messages
92
- // between the inbound and the reply, the router prepends a `> @author:
93
- // ...` blockquote line referencing the inbound so the user can see which
94
- // message the reply is anchored to even after the channel has scrolled.
95
- // 10s is the empirical "felt instantaneous" ceiling anything faster
96
- // reads as real-time and needs no anchor.
90
+ // Legacy quote-delay knob retained for config compatibility. Quote anchors
91
+ // are now driven by channel ordering instead: when another live-observed
92
+ // message lands between the inbound and the agent's first reply, the router
93
+ // prepends a platform-specific `> author: ...` blockquote line referencing
94
+ // the inbound. If nothing intervened, the reply is adjacent enough in the
95
+ // channel timeline that it needs no anchor regardless of elapsed wall time.
97
96
  export const DEFAULT_QUOTED_REPLY_QUEUE_DELAY_MS = 10_000
98
97
 
99
98
  // Long enough to disambiguate; short enough that a multi-paragraph user
@@ -126,6 +125,8 @@ export const DEFAULT_GITHUB_EVENT_ALLOWLIST = [
126
125
  'discussion_comment.created',
127
126
  'issues.opened',
128
127
  'pull_request.opened',
128
+ 'pull_request.review_requested',
129
+ 'pull_request.review_request_removed',
129
130
  'discussion.created',
130
131
  'pull_request_review.submitted',
131
132
  ] as const
@@ -84,7 +84,7 @@ export type OutboundMessage = {
84
84
  attachments?: OutboundAttachment[]
85
85
  }
86
86
 
87
- export type SendErrorCode = 'duplicate' | 'turn-cap' | 'no-adapter' | 'callback-rejected'
87
+ export type SendErrorCode = 'duplicate' | 'turn-cap' | 'no-adapter' | 'callback-rejected' | 'skip-locked'
88
88
 
89
89
  export type SendResult = { ok: true } | { ok: false; error: string; code?: SendErrorCode }
90
90