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