typeclaw 0.9.2 → 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 (76) hide show
  1. package/package.json +2 -2
  2. package/src/agent/index.ts +46 -11
  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/index.ts +19 -17
  14. package/src/bundled-plugins/security/permissions.ts +9 -8
  15. package/src/bundled-plugins/security/policies/cron-promotion.ts +26 -9
  16. package/src/bundled-plugins/security/policies/git-exfil.ts +23 -15
  17. package/src/bundled-plugins/security/policies/prompt-injection.ts +1 -1
  18. package/src/bundled-plugins/security/policies/role-promotion.ts +25 -18
  19. package/src/channels/adapters/github/auth-app.ts +53 -9
  20. package/src/channels/adapters/github/auth-pat.ts +4 -1
  21. package/src/channels/adapters/github/auth.ts +10 -0
  22. package/src/channels/adapters/github/event-permissions.ts +83 -0
  23. package/src/channels/adapters/github/inbound.ts +126 -1
  24. package/src/channels/adapters/github/index.ts +60 -66
  25. package/src/channels/adapters/github/outbound.ts +65 -17
  26. package/src/channels/adapters/github/permission-guidance.ts +169 -0
  27. package/src/channels/adapters/github/team-membership.ts +56 -0
  28. package/src/channels/router.ts +313 -10
  29. package/src/channels/schema.ts +22 -0
  30. package/src/channels/types.ts +1 -1
  31. package/src/cli/channel.ts +135 -38
  32. package/src/cli/cron.ts +1 -1
  33. package/src/cli/init.ts +133 -86
  34. package/src/cli/inspect-controller.ts +66 -0
  35. package/src/cli/inspect.ts +99 -14
  36. package/src/cli/role.ts +2 -2
  37. package/src/cli/run.ts +24 -5
  38. package/src/cli/tui.ts +34 -10
  39. package/src/cli/tunnel.ts +453 -14
  40. package/src/config/config.ts +35 -7
  41. package/src/config/providers.ts +82 -56
  42. package/src/cron/bridge.ts +25 -4
  43. package/src/hostd/daemon.ts +44 -24
  44. package/src/hostd/portbroker-manager.ts +19 -3
  45. package/src/init/dockerfile.ts +52 -0
  46. package/src/init/env-file.ts +66 -0
  47. package/src/init/gitignore.ts +8 -0
  48. package/src/init/hatching.ts +32 -5
  49. package/src/init/index.ts +131 -39
  50. package/src/init/validate-api-key.ts +31 -0
  51. package/src/inspect/index.ts +47 -6
  52. package/src/inspect/loop.ts +31 -0
  53. package/src/inspect/replay.ts +15 -1
  54. package/src/permissions/builtins.ts +29 -21
  55. package/src/permissions/permissions.ts +32 -5
  56. package/src/role-claim/code.ts +9 -9
  57. package/src/role-claim/controller.ts +3 -2
  58. package/src/role-claim/match-rule.ts +14 -19
  59. package/src/role-claim/pending.ts +2 -2
  60. package/src/run/codex-fetch-observer.ts +377 -0
  61. package/src/run/index.ts +12 -2
  62. package/src/server/index.ts +59 -1
  63. package/src/shared/protocol.ts +1 -1
  64. package/src/skills/typeclaw-channel-github/SKILL.md +45 -1
  65. package/src/skills/typeclaw-codex-cli/SKILL.md +1 -1
  66. package/src/skills/typeclaw-codex-cli/references/auth-flow.md +14 -1
  67. package/src/skills/typeclaw-config/SKILL.md +7 -1
  68. package/src/skills/typeclaw-config/references/recommended-mounts.md +233 -0
  69. package/src/skills/typeclaw-permissions/SKILL.md +24 -18
  70. package/src/skills/typeclaw-tunnels/SKILL.md +33 -1
  71. package/src/tui/index.ts +17 -5
  72. package/src/tunnels/index.ts +1 -0
  73. package/src/tunnels/manager.ts +18 -0
  74. package/src/tunnels/providers/cloudflare-named.ts +224 -0
  75. package/src/tunnels/types.ts +17 -1
  76. package/typeclaw.schema.json +120 -7
@@ -29,7 +29,7 @@ import {
29
29
  saveChannelSessions,
30
30
  type ChannelSessionRecord,
31
31
  } from './persistence'
32
- import type { ChannelAdapterConfig } from './schema'
32
+ import { QUOTED_REPLY_EXCERPT_MAX_CHARS, type AdapterId, type ChannelAdapterConfig } from './schema'
33
33
  import type {
34
34
  ChannelHistoryMessage,
35
35
  ChannelKey,
@@ -230,6 +230,19 @@ type ObservedInbound = {
230
230
  authorIsBot: boolean
231
231
  receivedAt: number
232
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'
233
246
  }
234
247
 
235
248
  type LiveSession = {
@@ -302,6 +315,40 @@ type LiveSession = {
302
315
  // future hard cap without picking a threshold out of thin air.
303
316
  sendTimestamps: Map<string, number[]>
304
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
343
+ // Captured by drain() at batch dequeue; read+cleared by send() on the
344
+ // first tool-source send of the turn. The anchor decision (delay
345
+ // threshold + intervening-observed check) is evaluated at SEND time
346
+ // against this snapshot — not at drain time — because the relevant
347
+ // signal is how long the user waited from inbound to seeing the reply
348
+ // land, which only the send-side clock knows. Cleared after first
349
+ // consumption so multi-part replies anchor only on chunk 1. A new
350
+ // batch overwrites unconditionally.
351
+ pendingQuoteCandidate: QuoteAnchorCandidate | null
305
352
  // Loop-guard state. See PEER_BOT_TURNS_WINDOW_MS / MAX_* constants
306
353
  // above. Updated in route() on every engaged peer-bot inbound, reset on
307
354
  // any human inbound. The two axes (window ring buffer + since-human
@@ -356,6 +403,11 @@ export const TURN_CAP_ERROR =
356
403
  `Send-cap reached for this turn (${MAX_CHANNEL_SENDS_PER_TURN} messages already sent to this conversation). ` +
357
404
  'End your turn now. The user can prompt you again for more output.'
358
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
+
359
411
  export type ChannelRouter = {
360
412
  route: (event: InboundMessage) => Promise<void>
361
413
  send: (msg: OutboundMessage, opts?: SendOptions) => Promise<SendResult>
@@ -421,6 +473,31 @@ export type ChannelRouter = {
421
473
  durationMs: number
422
474
  error?: string
423
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' }
424
501
  stop: () => Promise<void>
425
502
  liveCount: () => number
426
503
  __testing?: {
@@ -913,6 +990,10 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
913
990
  lastSentText: new Map(),
914
991
  sendTimestamps: new Map(),
915
992
  successfulChannelSends: 0,
993
+ turnSeq: 0,
994
+ successfulSendsAtTurnStart: 0,
995
+ skippedTurn: null,
996
+ pendingQuoteCandidate: null,
916
997
  recentEngagedPeerBotTurns: [],
917
998
  consecutiveEngagedPeerBotTurns: 0,
918
999
  loopGuardActive: false,
@@ -1005,6 +1086,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1005
1086
  authorIsBot: item.message.isBot,
1006
1087
  receivedAt: now(),
1007
1088
  ts: item.message.ts,
1089
+ source: 'prefetch',
1008
1090
  })
1009
1091
  } else {
1010
1092
  observed.push({
@@ -1014,6 +1096,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1014
1096
  authorIsBot: true,
1015
1097
  receivedAt: now(),
1016
1098
  ts: 0,
1099
+ source: 'prefetch',
1017
1100
  })
1018
1101
  }
1019
1102
  }
@@ -1079,7 +1162,9 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1079
1162
  return
1080
1163
  }
1081
1164
  if (now() - live.typingStartedAt >= MAX_TYPING_HEARTBEAT_MS) {
1082
- 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
+ )
1083
1168
  live.typingTimedOut = true
1084
1169
  void stopTypingHeartbeat(live)
1085
1170
  return
@@ -1204,6 +1289,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1204
1289
  const observed = live.contextBuffer.splice(0, live.contextBuffer.length)
1205
1290
  const reminders = live.pendingSystemReminders.splice(0, live.pendingSystemReminders.length)
1206
1291
  const text = composeTurnPrompt(observed, batch, {
1292
+ adapter: live.key.adapter,
1207
1293
  loopGuardActive: live.loopGuardActive,
1208
1294
  systemReminders: reminders,
1209
1295
  })
@@ -1213,6 +1299,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1213
1299
  live.currentTurnAuthorIds = new Set(batch.map((m) => m.authorId))
1214
1300
  live.consecutiveSends.clear()
1215
1301
  live.lastSentText.clear()
1302
+ live.pendingQuoteCandidate = captureQuoteCandidate(live.key.adapter, batch, observed)
1216
1303
  } else if (live.lastTurnAuthorId !== null) {
1217
1304
  // Reminder-only turn (batch.length === 0, reminders.length > 0):
1218
1305
  // restore the author identity from the prior turn so author-
@@ -1244,6 +1331,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1244
1331
  logger.info(`[channels] ${live.keyId} prompting batch=${batch.length} text_len=${text.length}`)
1245
1332
  const promptStart = now()
1246
1333
  const successfulSendsBeforePrompt = live.successfulChannelSends
1334
+ live.turnSeq++
1335
+ live.successfulSendsAtTurnStart = successfulSendsBeforePrompt
1247
1336
  await fireSessionTurnStart(live, text)
1248
1337
  try {
1249
1338
  await live.session.prompt(text)
@@ -1340,10 +1429,13 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1340
1429
 
1341
1430
  // Role-claim intercept runs BEFORE the channel.respond gate so the
1342
1431
  // operator can bootstrap permissions on a fresh agent that has no
1343
- // role match rules yet. Cheap pre-check: only DMs whose text contains
1344
- // a `claim-` prefix can be claim attempts, and only when a handler
1432
+ // role match rules yet. Cheap pre-check: any inbound whose text
1433
+ // contains a `claim-` prefix is a candidate, and only when a handler
1345
1434
  // is registered. Everything else falls straight through to the gate.
1346
- if (claimHandler !== undefined && event.isDm && extractClaimCode(event.text) !== null) {
1435
+ // Claims are accepted from any chat (DM, group, thread) because the
1436
+ // resulting match rule is platform-wide + author-scoped — see
1437
+ // src/role-claim/match-rule.ts.
1438
+ if (claimHandler !== undefined && extractClaimCode(event.text) !== null) {
1347
1439
  const outcome = await claimHandler({
1348
1440
  adapter: event.adapter,
1349
1441
  workspace: event.workspace,
@@ -1520,6 +1612,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1520
1612
  authorIsBot: event.authorIsBot,
1521
1613
  receivedAt: now(),
1522
1614
  ts: event.ts,
1615
+ source: 'observed',
1523
1616
  })
1524
1617
  if (live.contextBuffer.length > CONTEXT_BUFFER_SIZE) {
1525
1618
  live.contextBuffer.splice(0, live.contextBuffer.length - CONTEXT_BUFFER_SIZE)
@@ -1692,6 +1785,21 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1692
1785
  })
1693
1786
  const live = liveSessions.get(keyId)
1694
1787
  const sendKey = consecutiveSendKey(msg.chat, msg.thread)
1788
+ // Tool-source sends consume the captured quote candidate exactly
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-
1793
+ // claim) skip so they can't accidentally swallow the candidate
1794
+ // before the model's own first reply lands. Even when the decision
1795
+ // returns null (nothing intervened), the candidate is cleared — a
1796
+ // multi-part reply must not retroactively anchor chunk 2.
1797
+ if (live && source === 'tool' && live.pendingQuoteCandidate !== null) {
1798
+ const quoteCandidate = refreshQuoteCandidate(live.pendingQuoteCandidate, live.contextBuffer)
1799
+ const anchor = decideQuoteAnchor(quoteCandidate, now(), options.configForAdapter(msg.adapter))
1800
+ if (anchor !== null) msg = { ...msg, text: prependQuoteAnchor(msg.text ?? '', anchor) }
1801
+ live.pendingQuoteCandidate = null
1802
+ }
1695
1803
  const text = normalizeSendText(msg.text)
1696
1804
 
1697
1805
  // Central enforcement. Tool-initiated sends are subject to two policies:
@@ -1706,6 +1814,13 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1706
1814
  let priorLastSentText: string | undefined
1707
1815
  let reserved = false
1708
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
+ }
1709
1824
  const currentCount = live.consecutiveSends.get(sendKey) ?? 0
1710
1825
  if (currentCount >= MAX_CHANNEL_SENDS_PER_TURN) {
1711
1826
  return { ok: false, error: TURN_CAP_ERROR, code: 'turn-cap' }
@@ -1792,6 +1907,19 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1792
1907
  }
1793
1908
 
1794
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
+ }
1795
1923
  if (live.successfulChannelSends > successfulSendsBeforePrompt) return
1796
1924
 
1797
1925
  const candidate = recoverableAssistantText(live.session)
@@ -2038,6 +2166,25 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2038
2166
  return { kind: 'no-live-session' }
2039
2167
  }
2040
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
+
2041
2188
  return {
2042
2189
  route,
2043
2190
  send,
@@ -2060,6 +2207,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2060
2207
  executeCommand,
2061
2208
  getSelfAliases: computeSelfAliases,
2062
2209
  injectSubagentCompletionReminder,
2210
+ markTurnSkipped,
2063
2211
  stop,
2064
2212
  liveCount: () => liveSessions.size,
2065
2213
  __testing: {
@@ -2086,7 +2234,9 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2086
2234
  return
2087
2235
  }
2088
2236
  if (now() - live.typingStartedAt >= MAX_TYPING_HEARTBEAT_MS) {
2089
- 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
+ )
2090
2240
  live.typingTimedOut = true
2091
2241
  await stopTypingHeartbeat(live)
2092
2242
  return
@@ -2126,8 +2276,11 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2126
2276
  function composeTurnPrompt(
2127
2277
  observed: readonly ObservedInbound[],
2128
2278
  batch: readonly QueuedInbound[],
2129
- state: { loopGuardActive: boolean; systemReminders?: readonly string[] } = { loopGuardActive: false },
2279
+ state: { adapter?: AdapterId; loopGuardActive: boolean; systemReminders?: readonly string[] } = {
2280
+ loopGuardActive: false,
2281
+ },
2130
2282
  ): string {
2283
+ const adapter = state.adapter ?? 'discord-bot'
2131
2284
  const parts: string[] = []
2132
2285
  // System reminders (subagent-completion wakeups today) lead the turn body
2133
2286
  // because they are typically what triggered the drain — when the prompt
@@ -2193,7 +2346,7 @@ function composeTurnPrompt(
2193
2346
  if (observed.length > 0) {
2194
2347
  parts.push('## Recent context (not addressed to you, for awareness only)')
2195
2348
  for (const o of observed) {
2196
- 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))
2197
2350
  }
2198
2351
  parts.push('')
2199
2352
  }
@@ -2211,7 +2364,7 @@ function composeTurnPrompt(
2211
2364
  )
2212
2365
  }
2213
2366
  for (const b of batch) {
2214
- 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))
2215
2368
  }
2216
2369
  }
2217
2370
  return parts.join('\n')
@@ -2219,6 +2372,7 @@ function composeTurnPrompt(
2219
2372
 
2220
2373
  function formatAuthorLine(
2221
2374
  ts: number,
2375
+ adapter: AdapterId,
2222
2376
  authorId: string,
2223
2377
  authorName: string,
2224
2378
  authorIsBot: boolean,
@@ -2226,7 +2380,156 @@ function formatAuthorLine(
2226
2380
  ): string {
2227
2381
  const tag = authorIsBot ? ' [bot]' : ''
2228
2382
  const stamp = ts > 0 ? `[${new Date(ts).toISOString()}] ` : ''
2229
- return `${stamp}<@${authorId}> (${authorName})${tag}: ${text}`
2383
+ return `${stamp}${formatAuthorReference(adapter, authorId, authorName)} (${authorName})${tag}: ${text}`
2384
+ }
2385
+
2386
+ export type QuoteAnchorSource = {
2387
+ adapter: AdapterId
2388
+ authorId: string
2389
+ authorName: string
2390
+ text: string
2391
+ }
2392
+
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
2421
+ // outbound replies when the router decides the reply needs an anchor.
2422
+ // Collapses newlines to spaces so a multi-line user message renders on
2423
+ // one quoted line (markdown blockquote semantics: a blank line ends the
2424
+ // quote, and `> foo\nbar` would split the quote and the reply); strips
2425
+ // existing leading `>` so a quote-of-a-quote stays single-level. Empty
2426
+ // inbound text (mention-only inbounds like `<@bot>`) falls back to a
2427
+ // generic marker so the user still sees "the bot saw your ping".
2428
+ export function renderQuoteAnchor(source: QuoteAnchorSource): string {
2429
+ const collapsed = source.text
2430
+ .replace(/\s+/g, ' ')
2431
+ .replace(/^>+\s*/, '')
2432
+ .trim()
2433
+ const excerpt =
2434
+ collapsed === ''
2435
+ ? '(no text)'
2436
+ : collapsed.length > QUOTED_REPLY_EXCERPT_MAX_CHARS
2437
+ ? `${collapsed.slice(0, QUOTED_REPLY_EXCERPT_MAX_CHARS - 1)}…`
2438
+ : collapsed
2439
+ const mention = formatAuthorReference(source.adapter, source.authorId, source.authorName)
2440
+ return `> ${mention}: ${excerpt}`
2441
+ }
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).
2451
+ export function prependQuoteAnchor(replyText: string, source: QuoteAnchorSource): string {
2452
+ const anchor = renderQuoteAnchor(source)
2453
+ if (replyText === '') return anchor
2454
+ return `${anchor}\n\n${replyText}`
2455
+ }
2456
+
2457
+ type QuoteAnchorBatchEntry = {
2458
+ text: string
2459
+ authorId: string
2460
+ authorName: string
2461
+ authorIsBot: boolean
2462
+ receivedAt: number
2463
+ }
2464
+
2465
+ type QuoteAnchorObservedEntry = {
2466
+ receivedAt: number
2467
+ source: 'prefetch' | 'observed'
2468
+ }
2469
+
2470
+ export type QuoteAnchorCandidate = {
2471
+ source: QuoteAnchorSource
2472
+ primaryReceivedAt: number
2473
+ hadInterveningObserved: boolean
2474
+ }
2475
+
2476
+ // Snapshot the primary inbound + observed-buffer state at drain time so
2477
+ // the send-side decision has the data it needs without holding a
2478
+ // reference to the batch arrays. Returns null when there's nothing
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.
2490
+ export function captureQuoteCandidate(
2491
+ adapter: AdapterId,
2492
+ batch: readonly QuoteAnchorBatchEntry[],
2493
+ observed: readonly QuoteAnchorObservedEntry[],
2494
+ ): QuoteAnchorCandidate | null {
2495
+ if (batch.length === 0) return null
2496
+ const primary = batch[batch.length - 1]!
2497
+ if (primary.authorIsBot) return null
2498
+ return {
2499
+ source: { adapter, authorId: primary.authorId, authorName: primary.authorName, text: primary.text },
2500
+ primaryReceivedAt: primary.receivedAt,
2501
+ hadInterveningObserved: hasInterveningObserved(primary.receivedAt, observed),
2502
+ }
2503
+ }
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
+
2518
+ // Send-time decision: given a captured candidate and the current clock,
2519
+ // returns the source to anchor against or null. Skips when:
2520
+ // - quotedReply is disabled in config
2521
+ // - no observed messages came between primary inbound and now
2522
+ // A null candidate (no batch yet, or batch was bot-only) always skips.
2523
+ export function decideQuoteAnchor(
2524
+ candidate: QuoteAnchorCandidate | null,
2525
+ _nowMs: number,
2526
+ adapterConfig: ChannelAdapterConfig | undefined,
2527
+ ): QuoteAnchorSource | null {
2528
+ if (candidate === null) return null
2529
+ const config = adapterConfig?.quotedReply
2530
+ if (config !== undefined && config.enabled === false) return null
2531
+ if (!candidate.hadInterveningObserved) return null
2532
+ return candidate.source
2230
2533
  }
2231
2534
 
2232
2535
  type Sliced = { kind: 'message'; message: ChannelHistoryMessage } | { kind: 'elision'; elidedCount: number }
@@ -87,6 +87,25 @@ const historySchema = z
87
87
  },
88
88
  })
89
89
 
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.
96
+ export const DEFAULT_QUOTED_REPLY_QUEUE_DELAY_MS = 10_000
97
+
98
+ // Long enough to disambiguate; short enough that a multi-paragraph user
99
+ // message doesn't visually dominate the reply.
100
+ export const QUOTED_REPLY_EXCERPT_MAX_CHARS = 100
101
+
102
+ const quotedReplySchema = z
103
+ .object({
104
+ enabled: z.boolean().default(true),
105
+ queueDelayMs: z.number().int().min(0).default(DEFAULT_QUOTED_REPLY_QUEUE_DELAY_MS),
106
+ })
107
+ .default({ enabled: true, queueDelayMs: DEFAULT_QUOTED_REPLY_QUEUE_DELAY_MS })
108
+
90
109
  // Deliberately non-strict: a stale on-disk file may still carry the
91
110
  // legacy `allow` field (`migrateLegacyConfigShape` lifts it into
92
111
  // `roles.member.match[]` on load, but a between-reload window can
@@ -97,6 +116,7 @@ const adapterSchema = z.object({
97
116
  engagement: engagementSchema,
98
117
  history: historySchema,
99
118
  enabled: z.boolean().default(true),
119
+ quotedReply: quotedReplySchema.optional(),
100
120
  })
101
121
 
102
122
  export const DEFAULT_GITHUB_EVENT_ALLOWLIST = [
@@ -105,6 +125,8 @@ export const DEFAULT_GITHUB_EVENT_ALLOWLIST = [
105
125
  'discussion_comment.created',
106
126
  'issues.opened',
107
127
  'pull_request.opened',
128
+ 'pull_request.review_requested',
129
+ 'pull_request.review_request_removed',
108
130
  'discussion.created',
109
131
  'pull_request_review.submitted',
110
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