typeclaw 0.37.4 → 0.37.6

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 (51) hide show
  1. package/package.json +1 -1
  2. package/src/agent/doctor.ts +6 -1
  3. package/src/agent/plugin-tools.ts +23 -1
  4. package/src/agent/subagents.ts +146 -14
  5. package/src/agent/todo/scope.ts +4 -2
  6. package/src/agent/tools/channel-reply.ts +7 -9
  7. package/src/bundled-plugins/doc-render/index.ts +10 -0
  8. package/src/bundled-plugins/doc-render/skills/typeclaw-render-pdf/SKILL.md +171 -165
  9. package/src/bundled-plugins/doc-render/templates/lib.typ +339 -0
  10. package/src/bundled-plugins/github-cli-auth/gh-command.ts +95 -11
  11. package/src/bundled-plugins/github-cli-auth/git-command.ts +11 -0
  12. package/src/bundled-plugins/github-cli-auth/index.ts +68 -7
  13. package/src/bundled-plugins/memory/index.ts +9 -6
  14. package/src/bundled-plugins/memory/load-memory.ts +16 -2
  15. package/src/bundled-plugins/memory/slug.ts +19 -0
  16. package/src/bundled-plugins/security/policies/private-surface-read.ts +4 -1
  17. package/src/channels/adapters/github/inbound.ts +68 -43
  18. package/src/channels/adapters/github/index.ts +57 -9
  19. package/src/channels/adapters/github/recover-failed-deliveries.ts +270 -0
  20. package/src/channels/adapters/kakaotalk.ts +5 -1
  21. package/src/channels/adapters/mention-hints.ts +17 -0
  22. package/src/channels/manager.ts +77 -1
  23. package/src/channels/router.ts +181 -12
  24. package/src/cli/compose.ts +11 -2
  25. package/src/cli/dreams.ts +2 -2
  26. package/src/cli/inspect.ts +2 -2
  27. package/src/cli/logs.ts +2 -2
  28. package/src/cli/mount.ts +5 -5
  29. package/src/cli/require-agent-dir.ts +31 -0
  30. package/src/cli/restart.ts +2 -1
  31. package/src/cli/shell.ts +2 -2
  32. package/src/cli/start.ts +2 -1
  33. package/src/cli/stop.ts +2 -2
  34. package/src/cli/tui.ts +20 -6
  35. package/src/cli/ui.ts +13 -0
  36. package/src/compose/restart.ts +1 -1
  37. package/src/compose/start.ts +4 -2
  38. package/src/config/config.ts +200 -9
  39. package/src/container/shared.ts +18 -0
  40. package/src/container/start.ts +1 -1
  41. package/src/cron/consumer.ts +3 -3
  42. package/src/hostd/client.ts +48 -52
  43. package/src/hostd/daemon.ts +82 -39
  44. package/src/hostd/paths.ts +22 -2
  45. package/src/hostd/spawn.ts +7 -0
  46. package/src/init/dockerfile.ts +11 -8
  47. package/src/init/kakaotalk-auth.ts +2 -2
  48. package/src/init/packagejson.ts +2 -2
  49. package/src/plugin/loader.ts +7 -4
  50. package/src/sandbox/session-tmp.ts +6 -1
  51. package/src/secrets/export-claude-credentials-file.ts +2 -2
@@ -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
- const filename = a.filename ?? a.path.split('/').pop() ?? 'file'
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>,
@@ -104,6 +104,17 @@ export type ChannelManagerOptions = {
104
104
  // unregistered. See CreateChannelRouterOptions.onReload/onRestart.
105
105
  onReload?: () => Promise<string>
106
106
  onRestart?: (ctx?: RestartCommandContext) => Promise<string>
107
+ // Persistent messenger SDKs usually reconnect themselves, but a host sleep/offline
108
+ // cycle can leave a socket half-dead forever. The manager watches live adapters
109
+ // and restarts one that stays disconnected past this grace period. Test seams are
110
+ // optional so production uses normal timers/time.
111
+ connectionRecovery?: {
112
+ checkIntervalMs?: number
113
+ disconnectedGraceMs?: number
114
+ now?: () => number
115
+ setInterval?: (fn: () => void, ms: number) => unknown
116
+ clearInterval?: (handle: unknown) => void
117
+ }
107
118
  }
108
119
 
109
120
  export type ChannelManager = {
@@ -132,6 +143,8 @@ type AnyAdapter =
132
143
  type AdapterEntry = {
133
144
  adapter: AnyAdapter
134
145
  credentialSignature: string
146
+ disconnectedSinceMs: number | null
147
+ recoveryRestartQueued: boolean
135
148
  }
136
149
 
137
150
  export function createChannelManager(options: ChannelManagerOptions): ChannelManager {
@@ -158,6 +171,14 @@ export function createChannelManager(options: ChannelManagerOptions): ChannelMan
158
171
 
159
172
  const live = new Map<AdapterId, AdapterEntry>()
160
173
  const perAdapterSerial = new Map<AdapterId, Promise<unknown>>()
174
+ const recovery = options.connectionRecovery ?? {}
175
+ const recoveryCheckIntervalMs = recovery.checkIntervalMs ?? 30_000
176
+ const recoveryDisconnectedGraceMs = recovery.disconnectedGraceMs ?? 90_000
177
+ const recoveryNow = recovery.now ?? (() => Date.now())
178
+ const recoverySetInterval = recovery.setInterval ?? ((fn: () => void, ms: number) => setInterval(fn, ms))
179
+ const recoveryClearInterval =
180
+ recovery.clearInterval ?? ((handle: unknown) => clearInterval(handle as ReturnType<typeof setInterval>))
181
+ let recoveryTimer: unknown = null
161
182
 
162
183
  const runSerially = <T>(name: AdapterId, op: () => Promise<T>): Promise<T> => {
163
184
  const prev = perAdapterSerial.get(name) ?? Promise.resolve()
@@ -271,7 +292,12 @@ export function createChannelManager(options: ChannelManagerOptions): ChannelMan
271
292
  }
272
293
  try {
273
294
  await adapter.start()
274
- live.set(name, { adapter, credentialSignature: signature })
295
+ live.set(name, {
296
+ adapter,
297
+ credentialSignature: signature,
298
+ disconnectedSinceMs: adapter.isConnected() ? null : recoveryNow(),
299
+ recoveryRestartQueued: false,
300
+ })
275
301
  logger.info(`[channels] adapter "${name}" started`)
276
302
  return true
277
303
  } catch (err) {
@@ -292,6 +318,54 @@ export function createChannelManager(options: ChannelManagerOptions): ChannelMan
292
318
  }
293
319
  }
294
320
 
321
+ const checkConnectionRecovery = (): void => {
322
+ const now = recoveryNow()
323
+ for (const [name, entry] of live) {
324
+ if (entry.adapter.isConnected()) {
325
+ entry.disconnectedSinceMs = null
326
+ entry.recoveryRestartQueued = false
327
+ continue
328
+ }
329
+ if (entry.disconnectedSinceMs === null) {
330
+ entry.disconnectedSinceMs = now
331
+ logger.warn(`[channels] adapter "${name}" is disconnected; waiting for SDK recovery`)
332
+ continue
333
+ }
334
+ const disconnectedForMs = now - entry.disconnectedSinceMs
335
+ if (disconnectedForMs < recoveryDisconnectedGraceMs || entry.recoveryRestartQueued) continue
336
+ entry.recoveryRestartQueued = true
337
+ logger.warn(
338
+ `[channels] adapter "${name}" disconnected for ${Math.round(disconnectedForMs)}ms; restarting adapter`,
339
+ )
340
+ void runSerially(name, async () => {
341
+ try {
342
+ const current = live.get(name)
343
+ if (current !== entry) return
344
+ const currentCfg = options.channelsConfigRef()[name]
345
+ if (currentCfg === undefined || currentCfg.enabled === false) {
346
+ logger.info(`[channels] recovery restart for "${name}" skipped; adapter no longer enabled`)
347
+ return
348
+ }
349
+ await stopAdapter(name)
350
+ await startAdapter(name, currentCfg)
351
+ } finally {
352
+ if (live.get(name) === entry) entry.recoveryRestartQueued = false
353
+ }
354
+ })
355
+ }
356
+ }
357
+
358
+ const startRecoveryTimer = (): void => {
359
+ if (recoveryTimer !== null) return
360
+ recoveryTimer = recoverySetInterval(checkConnectionRecovery, recoveryCheckIntervalMs)
361
+ }
362
+
363
+ const stopRecoveryTimer = (): void => {
364
+ if (recoveryTimer === null) return
365
+ recoveryClearInterval(recoveryTimer)
366
+ recoveryTimer = null
367
+ }
368
+
295
369
  return {
296
370
  router,
297
371
 
@@ -313,9 +387,11 @@ export function createChannelManager(options: ChannelManagerOptions): ChannelMan
313
387
  const results = await Promise.allSettled(starts)
314
388
  const failure = results.find((r): r is PromiseRejectedResult => r.status === 'rejected')
315
389
  if (failure !== undefined) throw failure.reason
390
+ startRecoveryTimer()
316
391
  },
317
392
 
318
393
  async stop(): Promise<void> {
394
+ stopRecoveryTimer()
319
395
  for (const name of Array.from(live.keys())) await runSerially(name, () => stopAdapter(name))
320
396
  await router.stop()
321
397
  },