typeclaw 0.10.0 → 0.11.1

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 (62) hide show
  1. package/README.md +5 -1
  2. package/package.json +1 -1
  3. package/src/agent/index.ts +37 -4
  4. package/src/agent/multimodal/look-at.ts +8 -0
  5. package/src/agent/restart-handoff/index.ts +91 -0
  6. package/src/agent/restart-handoff/paths.ts +11 -0
  7. package/src/agent/session-origin.ts +30 -10
  8. package/src/agent/subagent-completion-reminder.ts +4 -2
  9. package/src/agent/system-prompt.ts +3 -1
  10. package/src/agent/tools/restart.ts +42 -1
  11. package/src/agent/tools/skip-response.ts +157 -0
  12. package/src/bundled-plugins/memory/README.md +18 -2
  13. package/src/bundled-plugins/memory/index.ts +108 -6
  14. package/src/bundled-plugins/memory/memory-logger.ts +33 -24
  15. package/src/bundled-plugins/security/policies/prompt-injection.ts +1 -1
  16. package/src/channels/adapters/discord-bot-invite.ts +89 -0
  17. package/src/channels/adapters/github/auth-app.ts +53 -9
  18. package/src/channels/adapters/github/auth-pat.ts +4 -1
  19. package/src/channels/adapters/github/auth.ts +10 -0
  20. package/src/channels/adapters/github/event-permissions.ts +83 -0
  21. package/src/channels/adapters/github/inbound.ts +126 -1
  22. package/src/channels/adapters/github/index.ts +60 -66
  23. package/src/channels/adapters/github/outbound.ts +65 -17
  24. package/src/channels/adapters/github/permission-guidance.ts +169 -0
  25. package/src/channels/adapters/github/team-membership.ts +56 -0
  26. package/src/channels/adapters/kakaotalk-classify.ts +13 -1
  27. package/src/channels/adapters/kakaotalk.ts +2 -0
  28. package/src/channels/router.ts +269 -34
  29. package/src/channels/schema.ts +8 -7
  30. package/src/channels/types.ts +1 -1
  31. package/src/cli/channel.ts +138 -52
  32. package/src/cli/init.ts +139 -100
  33. package/src/cli/inspect-controller.ts +66 -0
  34. package/src/cli/inspect.ts +24 -32
  35. package/src/cli/prompt-pem.ts +113 -0
  36. package/src/cli/run.ts +24 -5
  37. package/src/cli/tui.ts +34 -10
  38. package/src/cli/tunnel.ts +453 -14
  39. package/src/cli/ui.ts +22 -0
  40. package/src/compose/discover.ts +5 -0
  41. package/src/config/config.ts +35 -7
  42. package/src/config/providers.ts +64 -56
  43. package/src/init/env-file.ts +66 -0
  44. package/src/init/hatching.ts +32 -5
  45. package/src/init/index.ts +131 -39
  46. package/src/init/validate-api-key.ts +31 -0
  47. package/src/inspect/index.ts +5 -1
  48. package/src/inspect/loop.ts +12 -1
  49. package/src/inspect/replay.ts +15 -1
  50. package/src/run/codex-fetch-observer.ts +377 -0
  51. package/src/run/index.ts +14 -2
  52. package/src/server/command-runner.ts +31 -2
  53. package/src/server/index.ts +59 -1
  54. package/src/shared/protocol.ts +1 -1
  55. package/src/skills/typeclaw-channel-github/SKILL.md +47 -1
  56. package/src/skills/typeclaw-tunnels/SKILL.md +33 -1
  57. package/src/tui/index.ts +17 -5
  58. package/src/tunnels/index.ts +1 -0
  59. package/src/tunnels/manager.ts +18 -0
  60. package/src/tunnels/providers/cloudflare-named.ts +224 -0
  61. package/src/tunnels/types.ts +17 -1
  62. 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,
@@ -66,6 +62,15 @@ export const TYPING_HEARTBEAT_MS = 8000
66
62
  // platform-side typing forever. Slack Assistant status in particular has a
67
63
  // documented 2-minute timeout, so repeatedly refreshing it after that point
68
64
  // turns a temporary status into a permanent-looking artifact.
65
+ //
66
+ // The cap is measured from `live.typingStartedAt`, which is refreshed by
67
+ // two signals of life (see `bumpTypingActivity`):
68
+ // 1. Each new `drain()` iteration (a new turn is starting).
69
+ // 2. Each `tool_execution_end` from the agent session (a tool just
70
+ // completed — the prompt is progressing, not stuck).
71
+ // A 2-minute bash command that emits no intermediate events still trips
72
+ // the cap, but a chatty agent running long tools stays under it
73
+ // indefinitely. The cap exists to catch *silence*, not duration.
69
74
  export const MAX_TYPING_HEARTBEAT_MS = 2 * 60 * 1000
70
75
 
71
76
  // Idle GC: a LiveSession whose `lastInboundAt` is older than
@@ -234,6 +239,19 @@ type ObservedInbound = {
234
239
  authorIsBot: boolean
235
240
  receivedAt: number
236
241
  ts: number
242
+ // Distinguishes scrollback that was bulk-loaded at session cold-start
243
+ // (`prefetch`) from messages that actually arrived in the channel after
244
+ // the session went live (`observed`). Both share the same in-memory
245
+ // shape because the model sees them identically in the prompt's
246
+ // "Recent context" block, but the quote-anchor decision must treat them
247
+ // differently: prefetched scrollback is HISTORICAL context, not new
248
+ // chatter that happened between the primary inbound and the agent's
249
+ // reply. Counting prefetch entries as "intervening" would fire the
250
+ // anchor on every fresh-thread first turn (the prefetch stamps
251
+ // `receivedAt = now()` AFTER the inbound was received during ensureLive,
252
+ // so by primary-vs-observed timestamp comparison they always look
253
+ // "later"). See captureQuoteCandidate.
254
+ source: 'prefetch' | 'observed'
237
255
  }
238
256
 
239
257
  type LiveSession = {
@@ -306,6 +324,31 @@ type LiveSession = {
306
324
  // future hard cap without picking a threshold out of thin air.
307
325
  sendTimestamps: Map<string, number[]>
308
326
  successfulChannelSends: number
327
+ // Monotonic per-LiveSession turn counter incremented just before each
328
+ // `live.session.prompt(...)` call in `drain()`. Used as a turn identity
329
+ // so `skip_response` can record "I skipped turn N" without leaking
330
+ // across turns. `validateChannelTurn` only honors `skippedTurn` when it
331
+ // equals `turnSeq`; a stale value from a crashed/aborted prior turn is
332
+ // ignored (defensive: an unmatched skippedTurn would otherwise silently
333
+ // drop the next user-facing reply). NOT cleared on drain finally — the
334
+ // counter is purely monotonic; the matching comparison is what protects
335
+ // against stale state.
336
+ turnSeq: number
337
+ // Snapshot of `successfulChannelSends` taken at turn start (same
338
+ // moment `turnSeq` increments). Lets `markTurnSkipped` detect "a
339
+ // channel send already landed in this turn" and reject the skip,
340
+ // making the rejection symmetric with the send-after-skip lock in
341
+ // `send()`: commit to silence or commit to replying, not both,
342
+ // regardless of which order the model tried them in. Updated only at
343
+ // turn start; reads against the live counter elsewhere are intentional.
344
+ successfulSendsAtTurnStart: number
345
+ // Stamped by `markTurnSkipped` (called from the `skip_response` tool)
346
+ // with the current `turnSeq`. Read at the top of `validateChannelTurn`:
347
+ // if it matches the just-completed turn, recovery is skipped entirely
348
+ // (no NO_REPLY check, no Kimi leak check, no assistant-text recovery).
349
+ // The model has explicitly opted out of this turn and we honor that
350
+ // unconditionally. `null` when no skip has been recorded.
351
+ skippedTurn: { turnSeq: number; reason: string } | null
309
352
  // Captured by drain() at batch dequeue; read+cleared by send() on the
310
353
  // first tool-source send of the turn. The anchor decision (delay
311
354
  // threshold + intervening-observed check) is evaluated at SEND time
@@ -327,6 +370,7 @@ type LiveSession = {
327
370
  membershipFetch: Promise<MembershipCount | null> | null
328
371
  destroyed: boolean
329
372
  unsubProviderErrors: (() => void) | null
373
+ unsubTypingActivity: (() => void) | null
330
374
  }
331
375
 
332
376
  // `event` is null for command invocations that originated outside the inbound
@@ -369,6 +413,11 @@ export const TURN_CAP_ERROR =
369
413
  `Send-cap reached for this turn (${MAX_CHANNEL_SENDS_PER_TURN} messages already sent to this conversation). ` +
370
414
  'End your turn now. The user can prompt you again for more output.'
371
415
 
416
+ export const SKIP_RESPONSE_LOCK_ERROR =
417
+ 'You called `skip_response` earlier in this turn, which committed to staying silent. ' +
418
+ 'Channel sends are blocked for the rest of this turn. End your turn now; if you have ' +
419
+ 'something to say, send it on the next turn.'
420
+
372
421
  export type ChannelRouter = {
373
422
  route: (event: InboundMessage) => Promise<void>
374
423
  send: (msg: OutboundMessage, opts?: SendOptions) => Promise<SendResult>
@@ -434,6 +483,31 @@ export type ChannelRouter = {
434
483
  durationMs: number
435
484
  error?: string
436
485
  }) => { kind: 'delivered'; keyId: string } | { kind: 'no-live-session' }
486
+ // Record that the agent invoked `skip_response` during the current turn
487
+ // for the channel session identified by `parentSessionId`. The reason is
488
+ // logged at INFO level inside `validateChannelTurn` (single log line per
489
+ // skip, so operators see exactly one record per silent turn). Stamps the
490
+ // current `turnSeq` on the live session so a stale record from an earlier
491
+ // turn cannot drop a future legitimate reply.
492
+ //
493
+ // Returns:
494
+ // - 'recorded' — the live session was found and the skip was stamped
495
+ // - 'send-already-happened' — a tool-source channel send already landed
496
+ // in this turn; the skip is refused (symmetric with
497
+ // the send-after-skip lock in `send()`) so the model
498
+ // cannot land a reply AND claim silence. The flag is
499
+ // NOT stamped, so the turn proceeds as a normal
500
+ // reply turn.
501
+ // - 'no-live-session' — no matching channel session (e.g. tool fired
502
+ // outside a channel origin); the tool should
503
+ // still log the reason but cannot suppress.
504
+ markTurnSkipped: (args: {
505
+ parentSessionId: string
506
+ reason: string
507
+ }) =>
508
+ | { kind: 'recorded'; keyId: string }
509
+ | { kind: 'send-already-happened'; keyId: string }
510
+ | { kind: 'no-live-session' }
437
511
  stop: () => Promise<void>
438
512
  liveCount: () => number
439
513
  __testing?: {
@@ -926,6 +1000,9 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
926
1000
  lastSentText: new Map(),
927
1001
  sendTimestamps: new Map(),
928
1002
  successfulChannelSends: 0,
1003
+ turnSeq: 0,
1004
+ successfulSendsAtTurnStart: 0,
1005
+ skippedTurn: null,
929
1006
  pendingQuoteCandidate: null,
930
1007
  recentEngagedPeerBotTurns: [],
931
1008
  consecutiveEngagedPeerBotTurns: 0,
@@ -933,10 +1010,12 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
933
1010
  membershipFetch,
934
1011
  destroyed: false,
935
1012
  unsubProviderErrors: null,
1013
+ unsubTypingActivity: null,
936
1014
  }
937
1015
  live.unsubProviderErrors = subscribeProviderErrors(created.session, (err) => {
938
1016
  logger.error(`[channels] ${live.keyId}: LLM call failed: ${err.message}`)
939
1017
  })
1018
+ live.unsubTypingActivity = subscribeTypingActivity(created.session, live)
940
1019
  liveSessions.set(keyId, live)
941
1020
 
942
1021
  if (isColdStart) {
@@ -1019,6 +1098,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1019
1098
  authorIsBot: item.message.isBot,
1020
1099
  receivedAt: now(),
1021
1100
  ts: item.message.ts,
1101
+ source: 'prefetch',
1022
1102
  })
1023
1103
  } else {
1024
1104
  observed.push({
@@ -1028,6 +1108,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1028
1108
  authorIsBot: true,
1029
1109
  receivedAt: now(),
1030
1110
  ts: 0,
1111
+ source: 'prefetch',
1031
1112
  })
1032
1113
  }
1033
1114
  }
@@ -1080,9 +1161,25 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1080
1161
  )
1081
1162
  }
1082
1163
 
1164
+ const bumpTypingActivity = (live: LiveSession): void => {
1165
+ if (live.typingTimer === null) return
1166
+ live.typingStartedAt = now()
1167
+ }
1168
+
1169
+ const subscribeTypingActivity = (session: AgentSession, live: LiveSession): (() => void) => {
1170
+ return session.subscribe((event) => {
1171
+ if (event.type !== 'tool_execution_end') return
1172
+ bumpTypingActivity(live)
1173
+ })
1174
+ }
1175
+
1083
1176
  const startTypingHeartbeat = (live: LiveSession): void => {
1084
1177
  if (live.typingTimedOut || live.typingStopPromise) return
1085
- if (live.typingTimer || live.destroyed) return
1178
+ if (live.destroyed) return
1179
+ if (live.typingTimer) {
1180
+ bumpTypingActivity(live)
1181
+ return
1182
+ }
1086
1183
  live.typingStartedAt = now()
1087
1184
  // Fire immediately so the indicator appears on the very first inbound,
1088
1185
  // not 8 seconds later.
@@ -1093,7 +1190,9 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1093
1190
  return
1094
1191
  }
1095
1192
  if (now() - live.typingStartedAt >= MAX_TYPING_HEARTBEAT_MS) {
1096
- logger.warn(`[channels] ${live.keyId}: typing heartbeat timed out after ${MAX_TYPING_HEARTBEAT_MS}ms`)
1193
+ logger.warn(
1194
+ `[channels] ${live.keyId}: typing indicator paused after ${MAX_TYPING_HEARTBEAT_MS}ms with no activity; prompt still in flight`,
1195
+ )
1097
1196
  live.typingTimedOut = true
1098
1197
  void stopTypingHeartbeat(live)
1099
1198
  return
@@ -1218,6 +1317,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1218
1317
  const observed = live.contextBuffer.splice(0, live.contextBuffer.length)
1219
1318
  const reminders = live.pendingSystemReminders.splice(0, live.pendingSystemReminders.length)
1220
1319
  const text = composeTurnPrompt(observed, batch, {
1320
+ adapter: live.key.adapter,
1221
1321
  loopGuardActive: live.loopGuardActive,
1222
1322
  systemReminders: reminders,
1223
1323
  })
@@ -1227,7 +1327,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1227
1327
  live.currentTurnAuthorIds = new Set(batch.map((m) => m.authorId))
1228
1328
  live.consecutiveSends.clear()
1229
1329
  live.lastSentText.clear()
1230
- live.pendingQuoteCandidate = captureQuoteCandidate(batch, observed)
1330
+ live.pendingQuoteCandidate = captureQuoteCandidate(live.key.adapter, batch, observed)
1231
1331
  } else if (live.lastTurnAuthorId !== null) {
1232
1332
  // Reminder-only turn (batch.length === 0, reminders.length > 0):
1233
1333
  // restore the author identity from the prior turn so author-
@@ -1259,6 +1359,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1259
1359
  logger.info(`[channels] ${live.keyId} prompting batch=${batch.length} text_len=${text.length}`)
1260
1360
  const promptStart = now()
1261
1361
  const successfulSendsBeforePrompt = live.successfulChannelSends
1362
+ live.turnSeq++
1363
+ live.successfulSendsAtTurnStart = successfulSendsBeforePrompt
1262
1364
  await fireSessionTurnStart(live, text)
1263
1365
  try {
1264
1366
  await live.session.prompt(text)
@@ -1538,6 +1640,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1538
1640
  authorIsBot: event.authorIsBot,
1539
1641
  receivedAt: now(),
1540
1642
  ts: event.ts,
1643
+ source: 'observed',
1541
1644
  })
1542
1645
  if (live.contextBuffer.length > CONTEXT_BUFFER_SIZE) {
1543
1646
  live.contextBuffer.splice(0, live.contextBuffer.length - CONTEXT_BUFFER_SIZE)
@@ -1711,17 +1814,17 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1711
1814
  const live = liveSessions.get(keyId)
1712
1815
  const sendKey = consecutiveSendKey(msg.chat, msg.thread)
1713
1816
  // 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-
1817
+ // once per turn — the intervening-observed check runs HERE against
1818
+ // the live buffer so the relevant signal is actual channel chatter
1819
+ // between inbound and reply landing, not drain-vs-send timing
1820
+ // artifacts. System sources (recovery, role-
1718
1821
  // claim) skip so they can't accidentally swallow the candidate
1719
1822
  // 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.
1823
+ // returns null (nothing intervened), the candidate is cleared — a
1824
+ // multi-part reply must not retroactively anchor chunk 2.
1723
1825
  if (live && source === 'tool' && live.pendingQuoteCandidate !== null) {
1724
- const anchor = decideQuoteAnchor(live.pendingQuoteCandidate, now(), options.configForAdapter(msg.adapter))
1826
+ const quoteCandidate = refreshQuoteCandidate(live.pendingQuoteCandidate, live.contextBuffer)
1827
+ const anchor = decideQuoteAnchor(quoteCandidate, now(), options.configForAdapter(msg.adapter))
1725
1828
  if (anchor !== null) msg = { ...msg, text: prependQuoteAnchor(msg.text ?? '', anchor) }
1726
1829
  live.pendingQuoteCandidate = null
1727
1830
  }
@@ -1739,6 +1842,13 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1739
1842
  let priorLastSentText: string | undefined
1740
1843
  let reserved = false
1741
1844
  if (live && source === 'tool') {
1845
+ // Tool-source send after `skip_response` for the same turn is a contract
1846
+ // violation: the model already committed to silence. Reject before any
1847
+ // state mutation so the model gets a clear error and the channel stays
1848
+ // silent. System-source sends (recovery, role-claim) are not affected.
1849
+ if (live.skippedTurn !== null && live.skippedTurn.turnSeq === live.turnSeq) {
1850
+ return { ok: false, error: SKIP_RESPONSE_LOCK_ERROR, code: 'skip-locked' }
1851
+ }
1742
1852
  const currentCount = live.consecutiveSends.get(sendKey) ?? 0
1743
1853
  if (currentCount >= MAX_CHANNEL_SENDS_PER_TURN) {
1744
1854
  return { ok: false, error: TURN_CAP_ERROR, code: 'turn-cap' }
@@ -1825,6 +1935,19 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1825
1935
  }
1826
1936
 
1827
1937
  const validateChannelTurn = async (live: LiveSession, successfulSendsBeforePrompt: number): Promise<void> => {
1938
+ // `skip_response` short-circuit. Must run before the `successfulChannelSends`
1939
+ // check so a model that called both `skip_response` and a channel tool in
1940
+ // the same turn still resolves as "skipped" — the rejection inside `send()`
1941
+ // means the channel tool returned an error and never actually delivered.
1942
+ // Stale-flag protection: only honor when stamped on the just-completed
1943
+ // turn. A flag set by a previous turn that crashed before validation
1944
+ // would otherwise drop the next legitimate user-facing reply.
1945
+ if (live.skippedTurn !== null && live.skippedTurn.turnSeq === live.turnSeq) {
1946
+ const { reason } = live.skippedTurn
1947
+ live.skippedTurn = null
1948
+ logger.info(`[channels] ${live.keyId} skipped_by_tool reason=${JSON.stringify(reason)}`)
1949
+ return
1950
+ }
1828
1951
  if (live.successfulChannelSends > successfulSendsBeforePrompt) return
1829
1952
 
1830
1953
  const candidate = recoverableAssistantText(live.session)
@@ -1934,6 +2057,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1934
2057
  live.debounceTimer = null
1935
2058
  live.unsubProviderErrors?.()
1936
2059
  live.unsubProviderErrors = null
2060
+ live.unsubTypingActivity?.()
2061
+ live.unsubTypingActivity = null
1937
2062
  await stopTypingHeartbeat(live)
1938
2063
  try {
1939
2064
  await live.session.abort()
@@ -2071,6 +2196,25 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2071
2196
  return { kind: 'no-live-session' }
2072
2197
  }
2073
2198
 
2199
+ const markTurnSkipped = (args: {
2200
+ parentSessionId: string
2201
+ reason: string
2202
+ }):
2203
+ | { kind: 'recorded'; keyId: string }
2204
+ | { kind: 'send-already-happened'; keyId: string }
2205
+ | { kind: 'no-live-session' } => {
2206
+ for (const live of liveSessions.values()) {
2207
+ if (live.destroyed) continue
2208
+ if (live.sessionId !== args.parentSessionId) continue
2209
+ if (live.successfulChannelSends > live.successfulSendsAtTurnStart) {
2210
+ return { kind: 'send-already-happened', keyId: live.keyId }
2211
+ }
2212
+ live.skippedTurn = { turnSeq: live.turnSeq, reason: args.reason }
2213
+ return { kind: 'recorded', keyId: live.keyId }
2214
+ }
2215
+ return { kind: 'no-live-session' }
2216
+ }
2217
+
2074
2218
  return {
2075
2219
  route,
2076
2220
  send,
@@ -2093,6 +2237,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2093
2237
  executeCommand,
2094
2238
  getSelfAliases: computeSelfAliases,
2095
2239
  injectSubagentCompletionReminder,
2240
+ markTurnSkipped,
2096
2241
  stop,
2097
2242
  liveCount: () => liveSessions.size,
2098
2243
  __testing: {
@@ -2119,7 +2264,9 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2119
2264
  return
2120
2265
  }
2121
2266
  if (now() - live.typingStartedAt >= MAX_TYPING_HEARTBEAT_MS) {
2122
- logger.warn(`[channels] ${live.keyId}: typing heartbeat timed out after ${MAX_TYPING_HEARTBEAT_MS}ms`)
2267
+ logger.warn(
2268
+ `[channels] ${live.keyId}: typing indicator paused after ${MAX_TYPING_HEARTBEAT_MS}ms with no activity; prompt still in flight`,
2269
+ )
2123
2270
  live.typingTimedOut = true
2124
2271
  await stopTypingHeartbeat(live)
2125
2272
  return
@@ -2159,8 +2306,11 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2159
2306
  function composeTurnPrompt(
2160
2307
  observed: readonly ObservedInbound[],
2161
2308
  batch: readonly QueuedInbound[],
2162
- state: { loopGuardActive: boolean; systemReminders?: readonly string[] } = { loopGuardActive: false },
2309
+ state: { adapter?: AdapterId; loopGuardActive: boolean; systemReminders?: readonly string[] } = {
2310
+ loopGuardActive: false,
2311
+ },
2163
2312
  ): string {
2313
+ const adapter = state.adapter ?? 'discord-bot'
2164
2314
  const parts: string[] = []
2165
2315
  // System reminders (subagent-completion wakeups today) lead the turn body
2166
2316
  // because they are typically what triggered the drain — when the prompt
@@ -2226,7 +2376,7 @@ function composeTurnPrompt(
2226
2376
  if (observed.length > 0) {
2227
2377
  parts.push('## Recent context (not addressed to you, for awareness only)')
2228
2378
  for (const o of observed) {
2229
- parts.push(formatAuthorLine(o.ts, o.authorId, o.authorName, o.authorIsBot, o.text))
2379
+ parts.push(formatAuthorLine(o.ts, adapter, o.authorId, o.authorName, o.authorIsBot, o.text))
2230
2380
  }
2231
2381
  parts.push('')
2232
2382
  }
@@ -2244,7 +2394,7 @@ function composeTurnPrompt(
2244
2394
  )
2245
2395
  }
2246
2396
  for (const b of batch) {
2247
- parts.push(formatAuthorLine(b.ts, b.authorId, b.authorName, b.authorIsBot, b.text))
2397
+ parts.push(formatAuthorLine(b.ts, adapter, b.authorId, b.authorName, b.authorIsBot, b.text))
2248
2398
  }
2249
2399
  }
2250
2400
  return parts.join('\n')
@@ -2252,6 +2402,7 @@ function composeTurnPrompt(
2252
2402
 
2253
2403
  function formatAuthorLine(
2254
2404
  ts: number,
2405
+ adapter: AdapterId,
2255
2406
  authorId: string,
2256
2407
  authorName: string,
2257
2408
  authorIsBot: boolean,
@@ -2259,15 +2410,44 @@ function formatAuthorLine(
2259
2410
  ): string {
2260
2411
  const tag = authorIsBot ? ' [bot]' : ''
2261
2412
  const stamp = ts > 0 ? `[${new Date(ts).toISOString()}] ` : ''
2262
- return `${stamp}<@${authorId}> (${authorName})${tag}: ${text}`
2413
+ return `${stamp}${formatAuthorReference(adapter, authorId, authorName)} (${authorName})${tag}: ${text}`
2263
2414
  }
2264
2415
 
2265
2416
  export type QuoteAnchorSource = {
2417
+ adapter: AdapterId
2418
+ authorId: string
2266
2419
  authorName: string
2267
2420
  text: string
2268
2421
  }
2269
2422
 
2270
- // Renders the single-line `> @name: excerpt` blockquote prepended to
2423
+ // Picks the right author syntax for the platform so prompts and rendered
2424
+ // quote anchors use the same form the user would type in that channel.
2425
+ // Slack/Discord need id mentions (`<@U…>`), GitHub needs handle mentions
2426
+ // (`@login`) because inbound author ids are numeric, and adapters without
2427
+ // stable id-only mention syntax fall back to plain display names.
2428
+ //
2429
+ // Notification semantics: Slack and Discord both render `<@…>` as a
2430
+ // styled mention link inside blockquotes; whether the mentioned user is
2431
+ // PINGED is a separate platform-level UX (Slack pings on first appearance
2432
+ // in the message regardless of position, Discord respects the
2433
+ // `allowed_mentions` field which defaults to "ping everyone parsed").
2434
+ // This matches PR #374's intent — the user IS being notified that the
2435
+ // agent replied to them, which is the whole point of a quote anchor.
2436
+ function formatAuthorReference(adapter: AdapterId, authorId: string, authorName: string): string {
2437
+ const displayName = authorName.trim() !== '' ? authorName.trim() : authorId
2438
+ switch (adapter) {
2439
+ case 'slack-bot':
2440
+ case 'discord-bot':
2441
+ return `<@${authorId}>`
2442
+ case 'github':
2443
+ return displayName.startsWith('@') ? displayName : `@${displayName}`
2444
+ case 'telegram-bot':
2445
+ case 'kakaotalk':
2446
+ return displayName
2447
+ }
2448
+ }
2449
+
2450
+ // Renders the single-line `> @mention: excerpt` blockquote prepended to
2271
2451
  // outbound replies when the router decides the reply needs an anchor.
2272
2452
  // Collapses newlines to spaces so a multi-line user message renders on
2273
2453
  // one quoted line (markdown blockquote semantics: a blank line ends the
@@ -2286,17 +2466,27 @@ export function renderQuoteAnchor(source: QuoteAnchorSource): string {
2286
2466
  : collapsed.length > QUOTED_REPLY_EXCERPT_MAX_CHARS
2287
2467
  ? `${collapsed.slice(0, QUOTED_REPLY_EXCERPT_MAX_CHARS - 1)}…`
2288
2468
  : collapsed
2289
- return `> @${source.authorName}: ${excerpt}`
2469
+ const mention = formatAuthorReference(source.adapter, source.authorId, source.authorName)
2470
+ return `> ${mention}: ${excerpt}`
2290
2471
  }
2291
2472
 
2473
+ // Separates the anchor from the reply with a blank line (`\n\n`), not a
2474
+ // single `\n`. In standard GFM and Slack's `markdown` block, a single
2475
+ // `\n` inside a paragraph is a soft break rendered as whitespace, which
2476
+ // keeps the `>` blockquote styling running visually through the next
2477
+ // line — i.e. the agent's reply text gets swallowed into the quote. The
2478
+ // blank line forces a paragraph boundary that unambiguously ends the
2479
+ // blockquote on every renderer (CommonMark, GFM, Slack mrkdwn, Discord
2480
+ // markdown).
2292
2481
  export function prependQuoteAnchor(replyText: string, source: QuoteAnchorSource): string {
2293
2482
  const anchor = renderQuoteAnchor(source)
2294
2483
  if (replyText === '') return anchor
2295
- return `${anchor}\n${replyText}`
2484
+ return `${anchor}\n\n${replyText}`
2296
2485
  }
2297
2486
 
2298
2487
  type QuoteAnchorBatchEntry = {
2299
2488
  text: string
2489
+ authorId: string
2300
2490
  authorName: string
2301
2491
  authorIsBot: boolean
2302
2492
  receivedAt: number
@@ -2304,6 +2494,7 @@ type QuoteAnchorBatchEntry = {
2304
2494
 
2305
2495
  type QuoteAnchorObservedEntry = {
2306
2496
  receivedAt: number
2497
+ source: 'prefetch' | 'observed'
2307
2498
  }
2308
2499
 
2309
2500
  export type QuoteAnchorCandidate = {
@@ -2312,42 +2503,86 @@ export type QuoteAnchorCandidate = {
2312
2503
  hadInterveningObserved: boolean
2313
2504
  }
2314
2505
 
2506
+ // Strips `[<Adapter> message with ...]` placeholders that adapter
2507
+ // classifiers synthesize for non-text inbounds (KakaoTalk stickers,
2508
+ // Slack/Discord/Telegram attachments). The quote anchor is a UX
2509
+ // affordance pointing the human at *their words* — quoting a sticker as
2510
+ // `> Alice: [KakaoTalk message with sticker (sticker_ani) pack=... path=...]`
2511
+ // is noise, and for mixed inbounds like `사진 [KakaoTalk message with
2512
+ // photo 1254x1254 ...]` the human only wrote `사진`, so the placeholder
2513
+ // is the wrong thing to surface. The callsite (captureQuoteCandidate)
2514
+ // treats an empty residue as "no quote anchor"; mixed inbounds keep the
2515
+ // human-written portion. renderQuoteAnchor later collapses whitespace
2516
+ // so residual double-spaces from mid-string strips are harmless.
2517
+ const CHANNEL_MEDIA_PLACEHOLDER_RE = /\[(?:KakaoTalk|Slack|Discord|Telegram) message with [^\]]*\]/g
2518
+
2519
+ export function stripChannelMediaPlaceholders(text: string): string {
2520
+ return text
2521
+ .replace(CHANNEL_MEDIA_PLACEHOLDER_RE, '')
2522
+ .replace(/[ \t]+\n/g, '\n')
2523
+ .trim()
2524
+ }
2525
+
2315
2526
  // Snapshot the primary inbound + observed-buffer state at drain time so
2316
2527
  // the send-side decision has the data it needs without holding a
2317
2528
  // reference to the batch arrays. Returns null when there's nothing
2318
- // anchorable (empty batch, primary is a bot).
2529
+ // anchorable (empty batch, primary is a bot, or primary is a non-text
2530
+ // inbound with no residual human-written text after stripping the
2531
+ // adapter's media placeholder).
2532
+ //
2533
+ // `hadInterveningObserved` counts ONLY live observations (`source ===
2534
+ // 'observed'`), not prefetched scrollback. Prefetch stamps `receivedAt =
2535
+ // now()` inside ensureLive — wall-clock-later than the primary inbound
2536
+ // that triggered ensureLive — so without this gate, every cold-start
2537
+ // first turn would see "intervening observed" entries and fire the
2538
+ // quote anchor even when the reply lands within milliseconds. The
2539
+ // signal we actually want is "did real new chatter arrive between the
2540
+ // user's inbound and the agent's reply", which only live observations
2541
+ // represent.
2319
2542
  export function captureQuoteCandidate(
2543
+ adapter: AdapterId,
2320
2544
  batch: readonly QuoteAnchorBatchEntry[],
2321
2545
  observed: readonly QuoteAnchorObservedEntry[],
2322
2546
  ): QuoteAnchorCandidate | null {
2323
2547
  if (batch.length === 0) return null
2324
2548
  const primary = batch[batch.length - 1]!
2325
2549
  if (primary.authorIsBot) return null
2326
- const hadInterveningObserved = observed.some((o) => o.receivedAt >= primary.receivedAt)
2550
+ const cleaned = stripChannelMediaPlaceholders(primary.text)
2551
+ if (cleaned === '') return null
2327
2552
  return {
2328
- source: { authorName: primary.authorName, text: primary.text },
2553
+ source: { adapter, authorId: primary.authorId, authorName: primary.authorName, text: cleaned },
2329
2554
  primaryReceivedAt: primary.receivedAt,
2330
- hadInterveningObserved,
2555
+ hadInterveningObserved: hasInterveningObserved(primary.receivedAt, observed),
2331
2556
  }
2332
2557
  }
2333
2558
 
2559
+ function refreshQuoteCandidate(
2560
+ candidate: QuoteAnchorCandidate,
2561
+ observed: readonly QuoteAnchorObservedEntry[],
2562
+ ): QuoteAnchorCandidate {
2563
+ if (candidate.hadInterveningObserved) return candidate
2564
+ if (!hasInterveningObserved(candidate.primaryReceivedAt, observed)) return candidate
2565
+ return { ...candidate, hadInterveningObserved: true }
2566
+ }
2567
+
2568
+ function hasInterveningObserved(primaryReceivedAt: number, observed: readonly QuoteAnchorObservedEntry[]): boolean {
2569
+ return observed.some((o) => o.source === 'observed' && o.receivedAt >= primaryReceivedAt)
2570
+ }
2571
+
2334
2572
  // Send-time decision: given a captured candidate and the current clock,
2335
2573
  // returns the source to anchor against or null. Skips when:
2336
2574
  // - 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)
2575
+ // - no observed messages came between primary inbound and now
2339
2576
  // A null candidate (no batch yet, or batch was bot-only) always skips.
2340
2577
  export function decideQuoteAnchor(
2341
2578
  candidate: QuoteAnchorCandidate | null,
2342
- nowMs: number,
2579
+ _nowMs: number,
2343
2580
  adapterConfig: ChannelAdapterConfig | undefined,
2344
2581
  ): QuoteAnchorSource | null {
2345
2582
  if (candidate === null) return null
2346
2583
  const config = adapterConfig?.quotedReply
2347
2584
  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
2585
+ if (!candidate.hadInterveningObserved) return null
2351
2586
  return candidate.source
2352
2587
  }
2353
2588
 
@@ -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