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.
- package/package.json +2 -2
- package/src/agent/index.ts +46 -11
- package/src/agent/restart-handoff/index.ts +91 -0
- package/src/agent/restart-handoff/paths.ts +11 -0
- package/src/agent/session-origin.ts +30 -10
- package/src/agent/subagent-completion-reminder.ts +4 -2
- package/src/agent/system-prompt.ts +1 -1
- package/src/agent/tools/restart.ts +42 -1
- package/src/agent/tools/skip-response.ts +157 -0
- package/src/bundled-plugins/memory/README.md +18 -2
- package/src/bundled-plugins/memory/index.ts +108 -6
- package/src/bundled-plugins/memory/memory-logger.ts +33 -24
- package/src/bundled-plugins/security/index.ts +19 -17
- package/src/bundled-plugins/security/permissions.ts +9 -8
- package/src/bundled-plugins/security/policies/cron-promotion.ts +26 -9
- package/src/bundled-plugins/security/policies/git-exfil.ts +23 -15
- package/src/bundled-plugins/security/policies/prompt-injection.ts +1 -1
- package/src/bundled-plugins/security/policies/role-promotion.ts +25 -18
- package/src/channels/adapters/github/auth-app.ts +53 -9
- package/src/channels/adapters/github/auth-pat.ts +4 -1
- package/src/channels/adapters/github/auth.ts +10 -0
- package/src/channels/adapters/github/event-permissions.ts +83 -0
- package/src/channels/adapters/github/inbound.ts +126 -1
- package/src/channels/adapters/github/index.ts +60 -66
- package/src/channels/adapters/github/outbound.ts +65 -17
- package/src/channels/adapters/github/permission-guidance.ts +169 -0
- package/src/channels/adapters/github/team-membership.ts +56 -0
- package/src/channels/router.ts +313 -10
- package/src/channels/schema.ts +22 -0
- package/src/channels/types.ts +1 -1
- package/src/cli/channel.ts +135 -38
- package/src/cli/cron.ts +1 -1
- package/src/cli/init.ts +133 -86
- package/src/cli/inspect-controller.ts +66 -0
- package/src/cli/inspect.ts +99 -14
- package/src/cli/role.ts +2 -2
- package/src/cli/run.ts +24 -5
- package/src/cli/tui.ts +34 -10
- package/src/cli/tunnel.ts +453 -14
- package/src/config/config.ts +35 -7
- package/src/config/providers.ts +82 -56
- package/src/cron/bridge.ts +25 -4
- package/src/hostd/daemon.ts +44 -24
- package/src/hostd/portbroker-manager.ts +19 -3
- package/src/init/dockerfile.ts +52 -0
- package/src/init/env-file.ts +66 -0
- package/src/init/gitignore.ts +8 -0
- package/src/init/hatching.ts +32 -5
- package/src/init/index.ts +131 -39
- package/src/init/validate-api-key.ts +31 -0
- package/src/inspect/index.ts +47 -6
- package/src/inspect/loop.ts +31 -0
- package/src/inspect/replay.ts +15 -1
- package/src/permissions/builtins.ts +29 -21
- package/src/permissions/permissions.ts +32 -5
- package/src/role-claim/code.ts +9 -9
- package/src/role-claim/controller.ts +3 -2
- package/src/role-claim/match-rule.ts +14 -19
- package/src/role-claim/pending.ts +2 -2
- package/src/run/codex-fetch-observer.ts +377 -0
- package/src/run/index.ts +12 -2
- package/src/server/index.ts +59 -1
- package/src/shared/protocol.ts +1 -1
- package/src/skills/typeclaw-channel-github/SKILL.md +45 -1
- package/src/skills/typeclaw-codex-cli/SKILL.md +1 -1
- package/src/skills/typeclaw-codex-cli/references/auth-flow.md +14 -1
- package/src/skills/typeclaw-config/SKILL.md +7 -1
- package/src/skills/typeclaw-config/references/recommended-mounts.md +233 -0
- package/src/skills/typeclaw-permissions/SKILL.md +24 -18
- package/src/skills/typeclaw-tunnels/SKILL.md +33 -1
- package/src/tui/index.ts +17 -5
- package/src/tunnels/index.ts +1 -0
- package/src/tunnels/manager.ts +18 -0
- package/src/tunnels/providers/cloudflare-named.ts +224 -0
- package/src/tunnels/types.ts +17 -1
- package/typeclaw.schema.json +120 -7
package/src/channels/router.ts
CHANGED
|
@@ -29,7 +29,7 @@ import {
|
|
|
29
29
|
saveChannelSessions,
|
|
30
30
|
type ChannelSessionRecord,
|
|
31
31
|
} from './persistence'
|
|
32
|
-
import type
|
|
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(
|
|
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:
|
|
1344
|
-
// a `claim-` prefix
|
|
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
|
-
|
|
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(
|
|
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[] } = {
|
|
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}
|
|
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 }
|
package/src/channels/schema.ts
CHANGED
|
@@ -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
|
package/src/channels/types.ts
CHANGED
|
@@ -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
|
|