typeclaw 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/README.md +20 -15
  2. package/auth.schema.json +113 -0
  3. package/package.json +2 -1
  4. package/scripts/dump-system-prompt.ts +401 -0
  5. package/secrets.schema.json +113 -0
  6. package/src/agent/index.ts +149 -30
  7. package/src/agent/provider-error.ts +44 -0
  8. package/src/agent/session-meta.ts +43 -0
  9. package/src/agent/session-origin.ts +3 -2
  10. package/src/agent/subagents.ts +8 -0
  11. package/src/agent/system-prompt.ts +70 -35
  12. package/src/bundled-plugins/security/index.ts +3 -2
  13. package/src/channels/adapters/github/auth-app.ts +120 -0
  14. package/src/channels/adapters/github/auth-pat.ts +50 -0
  15. package/src/channels/adapters/github/auth.ts +33 -0
  16. package/src/channels/adapters/github/channel-resolver.ts +30 -0
  17. package/src/channels/adapters/github/dedup.ts +26 -0
  18. package/src/channels/adapters/github/event-allowlist.ts +8 -0
  19. package/src/channels/adapters/github/fetch-attachment.ts +5 -0
  20. package/src/channels/adapters/github/history.ts +63 -0
  21. package/src/channels/adapters/github/inbound.ts +286 -0
  22. package/src/channels/adapters/github/index.ts +286 -0
  23. package/src/channels/adapters/github/managed-path.ts +54 -0
  24. package/src/channels/adapters/github/membership.ts +35 -0
  25. package/src/channels/adapters/github/outbound.ts +145 -0
  26. package/src/channels/adapters/github/webhook-register.ts +349 -0
  27. package/src/channels/manager.ts +94 -9
  28. package/src/channels/router.ts +28 -2
  29. package/src/channels/schema.ts +31 -1
  30. package/src/channels/tunnel-bridge.ts +51 -0
  31. package/src/cli/builtins.ts +28 -0
  32. package/src/cli/channel.ts +511 -25
  33. package/src/cli/container-command-client.ts +244 -0
  34. package/src/cli/cron.ts +173 -0
  35. package/src/cli/host-command-runner.ts +150 -0
  36. package/src/cli/index.ts +42 -1
  37. package/src/cli/init.ts +256 -27
  38. package/src/cli/model.ts +4 -2
  39. package/src/cli/plugin-command-help.ts +49 -0
  40. package/src/cli/plugin-commands-dispatch.ts +112 -0
  41. package/src/cli/plugin-commands.ts +118 -0
  42. package/src/cli/tui.ts +10 -2
  43. package/src/cli/tunnel.ts +533 -0
  44. package/src/cli/ui.ts +8 -3
  45. package/src/cli/usage.ts +30 -2
  46. package/src/config/config.ts +90 -4
  47. package/src/config/reloadable.ts +22 -4
  48. package/src/container/start.ts +30 -3
  49. package/src/cron/bridge.ts +136 -0
  50. package/src/cron/consumer.ts +62 -6
  51. package/src/cron/index.ts +19 -2
  52. package/src/cron/list.ts +105 -0
  53. package/src/cron/scheduler.ts +12 -3
  54. package/src/cron/schema.ts +11 -3
  55. package/src/doctor/checks.ts +0 -50
  56. package/src/init/dockerfile.ts +59 -13
  57. package/src/init/ensure-deps.ts +15 -4
  58. package/src/init/github-webhook-install.ts +109 -0
  59. package/src/init/index.ts +505 -9
  60. package/src/init/run-bun-install.ts +17 -3
  61. package/src/init/run-owner-claim.ts +11 -2
  62. package/src/permissions/builtins.ts +6 -1
  63. package/src/permissions/match-rule.ts +24 -2
  64. package/src/permissions/resolve.ts +1 -0
  65. package/src/plugin/define.ts +42 -1
  66. package/src/plugin/index.ts +18 -3
  67. package/src/plugin/manager.ts +2 -0
  68. package/src/plugin/registry.ts +85 -3
  69. package/src/plugin/types.ts +138 -1
  70. package/src/plugin/zod-introspect.ts +100 -0
  71. package/src/role-claim/match-rule.ts +2 -1
  72. package/src/run/index.ts +119 -4
  73. package/src/secrets/index.ts +1 -1
  74. package/src/secrets/schema.ts +21 -0
  75. package/src/server/command-runner.ts +476 -0
  76. package/src/server/index.ts +393 -15
  77. package/src/shared/index.ts +8 -0
  78. package/src/shared/protocol.ts +80 -1
  79. package/src/skills/typeclaw-channel-github/SKILL.md +24 -0
  80. package/src/skills/typeclaw-config/SKILL.md +27 -26
  81. package/src/skills/typeclaw-cron/SKILL.md +234 -3
  82. package/src/skills/typeclaw-monorepo/SKILL.md +2 -2
  83. package/src/skills/typeclaw-permissions/SKILL.md +5 -4
  84. package/src/skills/typeclaw-plugins/SKILL.md +251 -5
  85. package/src/skills/typeclaw-tunnels/SKILL.md +111 -0
  86. package/src/test-helpers/wait-for.ts +50 -0
  87. package/src/tui/index.ts +35 -4
  88. package/src/tunnels/__fixtures__/cloudflared-quick-stderr.txt +11 -0
  89. package/src/tunnels/events.ts +14 -0
  90. package/src/tunnels/index.ts +12 -0
  91. package/src/tunnels/log-ring.ts +54 -0
  92. package/src/tunnels/manager.ts +139 -0
  93. package/src/tunnels/providers/cloudflare-quick.ts +189 -0
  94. package/src/tunnels/providers/external.ts +53 -0
  95. package/src/tunnels/quick-url-parser.ts +5 -0
  96. package/src/tunnels/types.ts +43 -0
  97. package/src/usage/aggregate.ts +30 -1
  98. package/src/usage/index.ts +3 -2
  99. package/src/usage/report.ts +103 -3
  100. package/src/usage/scan.ts +59 -4
  101. package/typeclaw.schema.json +254 -1
@@ -0,0 +1,349 @@
1
+ import { GITHUB_API_BASE, githubJsonHeaders } from './auth-pat'
2
+
3
+ export type RegisterGithubWebhooksOptions = {
4
+ token: () => Promise<string>
5
+ webhookUrl: string
6
+ webhookSecret: string
7
+ repos: readonly string[]
8
+ events: readonly string[]
9
+ // Stable, hostname-agnostic marker embedded in the webhook URL's path so
10
+ // hooks created by this agent in past runs can be recognized as ours even
11
+ // after the host part of the URL has rotated (e.g. cloudflare-quick tunnels
12
+ // mint a fresh `*.trycloudflare.com` on every container restart).
13
+ //
14
+ // When set, any hook whose `config.url` URL.pathname ends with this exact
15
+ // string is considered owned by this agent: at register time we PATCH the
16
+ // first such hook to the current URL and delete the rest as stale orphans.
17
+ //
18
+ // Convention: `/typeclaw/v1/github/<containerName>` — see
19
+ // `buildManagedPath` in `./managed-path.ts`. The path is appended onto
20
+ // tunnel-derived URLs by the adapter; user-set `webhookUrl` is kept
21
+ // verbatim (the operator is in control of their own URL — we trust them
22
+ // not to point two agents at the same URL).
23
+ //
24
+ // Omitted means the legacy URL-equality path is used (no orphan cleanup).
25
+ // The adapter always passes it in production; the option stays optional so
26
+ // direct unit-test calls can opt out of the cleanup logic.
27
+ managedPath?: string
28
+ // Opt-in legacy-orphan cleanup for hooks created before the marker existed.
29
+ // When set (e.g. `.trycloudflare.com`), the lister ALSO claims any hook
30
+ // whose URL host endsWith this suffix AND whose pathname is empty or `/`
31
+ // (unmarked = necessarily pre-fix). The adapter passes this only when the
32
+ // CURRENT effective URL itself lives on the same provider domain, so an
33
+ // agent on an external/self-hosted tunnel can never claim a colleague's
34
+ // cloudflare-quick hook. Hooks with a non-trivial path are still skipped
35
+ // unconditionally so a foreign service that happens to also use
36
+ // *.trycloudflare.com with its own path stays safe.
37
+ legacyProviderHostSuffix?: string
38
+ fetchImpl?: typeof fetch
39
+ }
40
+
41
+ export type WebhookRepoResult =
42
+ | { repo: string; action: 'created'; hookId: number }
43
+ | { repo: string; action: 'updated'; hookId: number; stalePruned: number }
44
+ | { repo: string; action: 'failed'; error: string }
45
+
46
+ export type WebhookRegistrationResult = {
47
+ repos: WebhookRepoResult[]
48
+ }
49
+
50
+ export async function registerGithubWebhooks(
51
+ options: RegisterGithubWebhooksOptions,
52
+ ): Promise<WebhookRegistrationResult> {
53
+ const fetchImpl = options.fetchImpl ?? fetch
54
+ let token: string
55
+ try {
56
+ token = await options.token()
57
+ } catch (err) {
58
+ const error = describe(err)
59
+ return { repos: options.repos.map((repo) => ({ repo, action: 'failed' as const, error })) }
60
+ }
61
+ const repos: WebhookRepoResult[] = []
62
+ for (const repo of options.repos) {
63
+ repos.push(await registerOne(fetchImpl, token, repo, options))
64
+ }
65
+ return { repos }
66
+ }
67
+
68
+ export type DeregisterGithubWebhooksOptions = {
69
+ token: () => Promise<string>
70
+ hooks: ReadonlyArray<{ repo: string; hookId: number }>
71
+ fetchImpl?: typeof fetch
72
+ }
73
+
74
+ export type WebhookDeregistrationResult = {
75
+ hooks: Array<{ repo: string; hookId: number; action: 'deleted' | 'missing' | 'failed'; error?: string }>
76
+ }
77
+
78
+ export async function deregisterGithubWebhooks(
79
+ options: DeregisterGithubWebhooksOptions,
80
+ ): Promise<WebhookDeregistrationResult> {
81
+ const fetchImpl = options.fetchImpl ?? fetch
82
+ let token: string
83
+ try {
84
+ token = await options.token()
85
+ } catch (err) {
86
+ const error = describe(err)
87
+ return { hooks: options.hooks.map((h) => ({ ...h, action: 'failed', error })) }
88
+ }
89
+ const hooks: WebhookDeregistrationResult['hooks'] = []
90
+ for (const hook of options.hooks) {
91
+ hooks.push(await deleteOne(fetchImpl, token, hook))
92
+ }
93
+ return { hooks }
94
+ }
95
+
96
+ async function registerOne(
97
+ fetchImpl: typeof fetch,
98
+ token: string,
99
+ repo: string,
100
+ options: RegisterGithubWebhooksOptions,
101
+ ): Promise<WebhookRepoResult> {
102
+ const parsed = parseRepoSlug(repo)
103
+ if (parsed === null) {
104
+ return { repo, action: 'failed', error: `invalid repo slug: "${repo}" (expected owner/name)` }
105
+ }
106
+ try {
107
+ const owned = await findManagedHooks(
108
+ fetchImpl,
109
+ token,
110
+ parsed,
111
+ options.webhookUrl,
112
+ options.managedPath,
113
+ options.legacyProviderHostSuffix,
114
+ )
115
+ if (owned.length === 0) {
116
+ const hookId = await createHook(fetchImpl, token, parsed, options)
117
+ return { repo, action: 'created', hookId }
118
+ }
119
+ // Sort by id ascending so the canonical kept hook is deterministic
120
+ // (oldest = lowest id wins). This makes successive runs converge on the
121
+ // same hookId for the same repo, which is friendlier to anyone
122
+ // inspecting the repo's webhook list.
123
+ const [keep, ...stale] = owned.slice().sort((a, b) => a - b)
124
+ await updateHook(fetchImpl, token, parsed, keep!, options)
125
+ let stalePruned = 0
126
+ for (const id of stale) {
127
+ const ok = await tryDeleteHook(fetchImpl, token, parsed, id)
128
+ if (ok) stalePruned++
129
+ }
130
+ return { repo, action: 'updated', hookId: keep!, stalePruned }
131
+ } catch (err) {
132
+ return { repo, action: 'failed', error: describe(err) }
133
+ }
134
+ }
135
+
136
+ async function deleteOne(
137
+ fetchImpl: typeof fetch,
138
+ token: string,
139
+ hook: { repo: string; hookId: number },
140
+ ): Promise<WebhookDeregistrationResult['hooks'][number]> {
141
+ const parsed = parseRepoSlug(hook.repo)
142
+ if (parsed === null) {
143
+ return { ...hook, action: 'failed', error: `invalid repo slug: "${hook.repo}"` }
144
+ }
145
+ try {
146
+ const response = await fetchImpl(`${GITHUB_API_BASE}/repos/${parsed.owner}/${parsed.name}/hooks/${hook.hookId}`, {
147
+ method: 'DELETE',
148
+ headers: githubJsonHeaders(token),
149
+ })
150
+ if (response.status === 404) return { ...hook, action: 'missing' }
151
+ if (!response.ok) {
152
+ const body = await response.text().catch(() => '')
153
+ return {
154
+ ...hook,
155
+ action: 'failed',
156
+ error: `delete hook failed: ${response.status}${body !== '' ? ` ${body}` : ''}`,
157
+ }
158
+ }
159
+ return { ...hook, action: 'deleted' }
160
+ } catch (err) {
161
+ return { ...hook, action: 'failed', error: describe(err) }
162
+ }
163
+ }
164
+
165
+ // Best-effort stale-hook prune. We don't surface 404/403/etc. as a register
166
+ // failure because the primary keep-hook is already updated; an inability to
167
+ // delete a stale orphan is a soft warning at most. Caller counts successful
168
+ // prunes for the log line.
169
+ async function tryDeleteHook(fetchImpl: typeof fetch, token: string, repo: RepoSlug, hookId: number): Promise<boolean> {
170
+ try {
171
+ const response = await fetchImpl(`${GITHUB_API_BASE}/repos/${repo.owner}/${repo.name}/hooks/${hookId}`, {
172
+ method: 'DELETE',
173
+ headers: githubJsonHeaders(token),
174
+ })
175
+ // 404 = already gone; treat as a successful prune for log-summary purposes
176
+ // (the orphan is no longer on the repo, which is what we wanted).
177
+ return response.ok || response.status === 404
178
+ } catch {
179
+ return false
180
+ }
181
+ }
182
+
183
+ type RepoSlug = { owner: string; name: string }
184
+
185
+ function parseRepoSlug(slug: string): RepoSlug | null {
186
+ const parts = slug.split('/')
187
+ if (parts.length !== 2) return null
188
+ const [owner, name] = parts
189
+ if (!owner || !name) return null
190
+ if (!REPO_SEGMENT.test(owner) || !REPO_SEGMENT.test(name)) return null
191
+ return { owner, name }
192
+ }
193
+
194
+ const REPO_SEGMENT = /^[A-Za-z0-9._-]+$/
195
+
196
+ // Returns the hookIds of every hook owned by this agent on `repo`, in the
197
+ // order GitHub returned them. Ownership is the union of three rules:
198
+ //
199
+ // 1. `config.url === webhookUrl` — the live URL match. Covers the
200
+ // common case (user-set webhookUrl, or a tunnel URL that hasn't
201
+ // rotated since the last register).
202
+ //
203
+ // 2. `URL(config.url).pathname` ends with `managedPath` — the
204
+ // hostname-agnostic path-marker match. Covers hooks that THIS agent
205
+ // created in a previous run whose tunnel host has since rotated.
206
+ // Skipped when `managedPath` is omitted (legacy callers).
207
+ //
208
+ // 3. (Opt-in via `legacyProviderHostSuffix`) `URL(config.url).host` ends
209
+ // with the supplied suffix AND pathname is empty or `/`. Covers the
210
+ // pre-marker orphans the user reported in the bug. Tightly bounded:
211
+ // same provider domain only, unmarked hooks only.
212
+ //
213
+ // Hooks whose `config.url` isn't a parseable URL are ignored. Hooks
214
+ // without an `id` are ignored.
215
+ async function findManagedHooks(
216
+ fetchImpl: typeof fetch,
217
+ token: string,
218
+ repo: RepoSlug,
219
+ webhookUrl: string,
220
+ managedPath: string | undefined,
221
+ legacyProviderHostSuffix: string | undefined,
222
+ ): Promise<number[]> {
223
+ const response = await fetchImpl(`${GITHUB_API_BASE}/repos/${repo.owner}/${repo.name}/hooks?per_page=100`, {
224
+ method: 'GET',
225
+ headers: githubJsonHeaders(token),
226
+ })
227
+ if (!response.ok) {
228
+ const body = await response.text().catch(() => '')
229
+ throw new Error(`list hooks failed: ${response.status}${body !== '' ? ` ${body}` : ''}`)
230
+ }
231
+ const hooks = (await response.json()) as Array<{ id?: unknown; config?: { url?: unknown } }>
232
+ const owned: number[] = []
233
+ for (const hook of hooks) {
234
+ if (typeof hook.id !== 'number') continue
235
+ const url = hook.config?.url
236
+ if (typeof url !== 'string') continue
237
+ if (url === webhookUrl) {
238
+ owned.push(hook.id)
239
+ continue
240
+ }
241
+ if (managedPath !== undefined && hookPathMatchesMarker(url, managedPath)) {
242
+ owned.push(hook.id)
243
+ continue
244
+ }
245
+ if (legacyProviderHostSuffix !== undefined && hookIsUnmarkedOnProvider(url, legacyProviderHostSuffix)) {
246
+ owned.push(hook.id)
247
+ }
248
+ }
249
+ return owned
250
+ }
251
+
252
+ function hookPathMatchesMarker(rawUrl: string, marker: string): boolean {
253
+ let parsed: URL
254
+ try {
255
+ parsed = new URL(rawUrl)
256
+ } catch {
257
+ return false
258
+ }
259
+ // Suffix match on pathname only (not the full URL). Rotating Cloudflare
260
+ // hostnames change `parsed.host`; the marker survives in `parsed.pathname`.
261
+ // Suffix (not equality) so a future reverse-proxy that prepends a path
262
+ // prefix doesn't break recognition.
263
+ return parsed.pathname === marker || parsed.pathname.endsWith(marker)
264
+ }
265
+
266
+ function hookIsUnmarkedOnProvider(rawUrl: string, hostSuffix: string): boolean {
267
+ let parsed: URL
268
+ try {
269
+ parsed = new URL(rawUrl)
270
+ } catch {
271
+ return false
272
+ }
273
+ // Empty pathname (rare, depends on URL parser) or root only. Anything
274
+ // with a real path is treated as user-controlled and left alone.
275
+ const unmarked = parsed.pathname === '' || parsed.pathname === '/'
276
+ // hostSuffix must start with a dot OR be the full host — guards against
277
+ // `foo.com` accidentally matching `evilfoo.com`.
278
+ const onProvider = parsed.host === hostSuffix || (hostSuffix.startsWith('.') && parsed.host.endsWith(hostSuffix))
279
+ return unmarked && onProvider
280
+ }
281
+
282
+ async function createHook(
283
+ fetchImpl: typeof fetch,
284
+ token: string,
285
+ repo: RepoSlug,
286
+ options: RegisterGithubWebhooksOptions,
287
+ ): Promise<number> {
288
+ const response = await fetchImpl(`${GITHUB_API_BASE}/repos/${repo.owner}/${repo.name}/hooks`, {
289
+ method: 'POST',
290
+ headers: githubJsonHeaders(token),
291
+ body: JSON.stringify(buildHookPayload(options, { includeName: true })),
292
+ })
293
+ if (!response.ok) {
294
+ const body = await response.text().catch(() => '')
295
+ throw new Error(`create hook failed: ${response.status}${body !== '' ? ` ${body}` : ''}`)
296
+ }
297
+ const raw = (await response.json()) as { id?: unknown }
298
+ if (typeof raw.id !== 'number') throw new Error('create hook response missing id')
299
+ return raw.id
300
+ }
301
+
302
+ async function updateHook(
303
+ fetchImpl: typeof fetch,
304
+ token: string,
305
+ repo: RepoSlug,
306
+ hookId: number,
307
+ options: RegisterGithubWebhooksOptions,
308
+ ): Promise<void> {
309
+ const response = await fetchImpl(`${GITHUB_API_BASE}/repos/${repo.owner}/${repo.name}/hooks/${hookId}`, {
310
+ method: 'PATCH',
311
+ headers: githubJsonHeaders(token),
312
+ body: JSON.stringify(buildHookPayload(options, { includeName: false })),
313
+ })
314
+ if (!response.ok) {
315
+ const body = await response.text().catch(() => '')
316
+ throw new Error(`update hook failed: ${response.status}${body !== '' ? ` ${body}` : ''}`)
317
+ }
318
+ }
319
+
320
+ function buildHookPayload(
321
+ options: RegisterGithubWebhooksOptions,
322
+ { includeName }: { includeName: boolean },
323
+ ): Record<string, unknown> {
324
+ const payload: Record<string, unknown> = {
325
+ active: true,
326
+ events: toCoarseEvents(options.events),
327
+ config: {
328
+ url: options.webhookUrl,
329
+ content_type: 'json',
330
+ secret: options.webhookSecret,
331
+ insecure_ssl: '0',
332
+ },
333
+ }
334
+ if (includeName) payload.name = 'web'
335
+ return payload
336
+ }
337
+
338
+ function toCoarseEvents(events: readonly string[]): string[] {
339
+ const seen = new Set<string>()
340
+ for (const e of events) {
341
+ const coarse = e.split('.')[0]
342
+ if (coarse && coarse.length > 0) seen.add(coarse)
343
+ }
344
+ return [...seen]
345
+ }
346
+
347
+ function describe(err: unknown): string {
348
+ return err instanceof Error ? err.message : String(err)
349
+ }
@@ -2,15 +2,23 @@ import { createHash } from 'node:crypto'
2
2
  import { join } from 'node:path'
3
3
 
4
4
  import type { PermissionService } from '@/permissions'
5
+ import type { GithubSecretsBlock } from '@/secrets'
5
6
  import { SecretsKakaoCredentialStore } from '@/secrets/kakao-store'
6
7
  import { SecretsBackend } from '@/secrets/storage'
7
8
 
8
9
  import { createDiscordBotAdapter, type DiscordBotAdapter } from './adapters/discord-bot'
10
+ import { createGithubAdapter, type GithubAdapter } from './adapters/github'
9
11
  import { createKakaotalkAdapter, type KakaotalkAdapter } from './adapters/kakaotalk'
10
12
  import { createSlackBotAdapter, type SlackBotAdapter } from './adapters/slack-bot'
11
13
  import { createTelegramBotAdapter, type TelegramBotAdapter } from './adapters/telegram-bot'
12
14
  import { createChannelRouter, type ChannelRouter, type ClaimHandler, type CreateSessionForChannel } from './router'
13
- import { ADAPTER_IDS, type AdapterId, type ChannelAdapterConfig, type ChannelsConfig } from './schema'
15
+ import {
16
+ ADAPTER_IDS,
17
+ type AdapterId,
18
+ type ChannelAdapterConfig,
19
+ type ChannelsConfig,
20
+ type GithubAdapterConfig,
21
+ } from './schema'
14
22
 
15
23
  export type ChannelManagerLogger = {
16
24
  info: (msg: string) => void
@@ -48,6 +56,7 @@ export type ChannelManagerOptions = {
48
56
  createSessionForChannel?: CreateSessionForChannel
49
57
  // Test seams: let fake adapters replace the real adapter wiring per id.
50
58
  createDiscordAdapter?: typeof createDiscordBotAdapter
59
+ createGithubAdapter?: typeof createGithubAdapter
51
60
  createKakaotalkAdapter?: typeof createKakaotalkAdapter
52
61
  createSlackAdapter?: typeof createSlackBotAdapter
53
62
  createTelegramAdapter?: typeof createTelegramBotAdapter
@@ -62,16 +71,24 @@ export type ChannelManagerOptions = {
62
71
  // code. Production wiring sets this from the role-claim subsystem (see
63
72
  // src/run/index.ts). Tests typically omit it.
64
73
  claimHandler?: ClaimHandler
74
+ tunnelUrlForChannel?: (channelName: string) => string | null
75
+ // Whether the user declared a `tunnels[]` entry bound to this channel.
76
+ // Lets channel-bound adapters distinguish "operator opted out of public
77
+ // webhook delivery" from "operator opted in but the tunnel never produced
78
+ // a URL" so error logs can be precise. Same shape as
79
+ // `tunnelUrlForChannel` for consistency. Optional for tests.
80
+ tunnelConfiguredForChannel?: (channelName: string) => boolean
65
81
  }
66
82
 
67
83
  export type ChannelManager = {
68
84
  router: ChannelRouter
69
85
  start: () => Promise<void>
70
86
  stop: () => Promise<void>
87
+ restartAdapter: (name: AdapterId) => Promise<void>
71
88
  reload: () => Promise<{ started: string[]; stopped: string[]; restartRequired: string[] }>
72
89
  }
73
90
 
74
- type AnyAdapter = DiscordBotAdapter | KakaotalkAdapter | SlackBotAdapter | TelegramBotAdapter
91
+ type AnyAdapter = DiscordBotAdapter | GithubAdapter | KakaotalkAdapter | SlackBotAdapter | TelegramBotAdapter
75
92
 
76
93
  // Credential signature is the comparison key for credential-rotation
77
94
  // detection on reload. Discord and Telegram each use a single bot token;
@@ -98,14 +115,27 @@ export function createChannelManager(options: ChannelManagerOptions): ChannelMan
98
115
  ...(options.claimHandler ? { claimHandler: options.claimHandler } : {}),
99
116
  })
100
117
  const createDiscordAdapter = options.createDiscordAdapter ?? createDiscordBotAdapter
118
+ const createGithub = options.createGithubAdapter ?? createGithubAdapter
101
119
  const createKakaotalk = options.createKakaotalkAdapter ?? createKakaotalkAdapter
102
120
  const createSlackAdapter = options.createSlackAdapter ?? createSlackBotAdapter
103
121
  const createTelegramAdapter = options.createTelegramAdapter ?? createTelegramBotAdapter
104
122
 
105
123
  const live = new Map<AdapterId, AdapterEntry>()
124
+ const perAdapterSerial = new Map<AdapterId, Promise<unknown>>()
125
+
126
+ const runSerially = <T>(name: AdapterId, op: () => Promise<T>): Promise<T> => {
127
+ const prev = perAdapterSerial.get(name) ?? Promise.resolve()
128
+ const next = prev.then(op, op)
129
+ perAdapterSerial.set(
130
+ name,
131
+ next.catch(() => {}),
132
+ )
133
+ return next
134
+ }
106
135
 
107
136
  const buildCredentialSignature = (name: AdapterId): { signature: string; missing: string[] } => {
108
137
  if (name === 'kakaotalk') return buildKakaotalkSignature(options.agentDir)
138
+ if (name === 'github') return buildGithubSignature(options.agentDir)
109
139
  const requiredEnvs = TOKEN_ENV[name]
110
140
  const parts: string[] = []
111
141
  const missing: string[] = []
@@ -151,6 +181,19 @@ export function createChannelManager(options: ChannelManagerOptions): ChannelMan
151
181
  credentialsStore: createContainerKakaoCredentialStore(options.agentDir, env),
152
182
  })
153
183
  }
184
+ if (name === 'github') {
185
+ const secrets = readGithubSecrets(options.agentDir)
186
+ if (secrets === null) return null
187
+ return createGithub({
188
+ router,
189
+ configRef: () => (options.channelsConfigRef()[name] ?? cfg) as ChannelAdapterConfig & GithubAdapterConfig,
190
+ secrets,
191
+ agentDir: options.agentDir,
192
+ logger,
193
+ tunnelUrl: () => options.tunnelUrlForChannel?.('github') ?? null,
194
+ tunnelConfiguredForChannel: () => options.tunnelConfiguredForChannel?.('github') ?? false,
195
+ })
196
+ }
154
197
  if (name === 'telegram-bot') {
155
198
  const token = env.TELEGRAM_BOT_TOKEN
156
199
  if (token === undefined || token.trim() === '') return null
@@ -193,9 +236,9 @@ export function createChannelManager(options: ChannelManagerOptions): ChannelMan
193
236
  const stopAdapter = async (name: AdapterId): Promise<void> => {
194
237
  const entry = live.get(name)
195
238
  if (!entry) return
196
- live.delete(name)
197
239
  try {
198
240
  await entry.adapter.stop()
241
+ live.delete(name)
199
242
  logger.info(`[channels] adapter "${name}" stopped`)
200
243
  } catch (err) {
201
244
  logger.error(`[channels] adapter "${name}" failed to stop: ${describe(err)}`)
@@ -209,15 +252,31 @@ export function createChannelManager(options: ChannelManagerOptions): ChannelMan
209
252
  const cfg = options.channelsConfigRef()
210
253
  for (const name of ADAPTER_IDS) {
211
254
  const adapterCfg = cfg[name]
212
- if (adapterCfg !== undefined) await startAdapter(name, adapterCfg)
255
+ if (adapterCfg !== undefined) await runSerially(name, () => startAdapter(name, adapterCfg))
213
256
  }
214
257
  },
215
258
 
216
259
  async stop(): Promise<void> {
217
- for (const name of Array.from(live.keys())) await stopAdapter(name)
260
+ for (const name of Array.from(live.keys())) await runSerially(name, () => stopAdapter(name))
218
261
  await router.stop()
219
262
  },
220
263
 
264
+ async restartAdapter(name: AdapterId): Promise<void> {
265
+ await runSerially(name, async () => {
266
+ if (!live.has(name)) {
267
+ logger.info(`[channels] restartAdapter('${name}'): adapter not live, skipping`)
268
+ return
269
+ }
270
+ const currentCfg = options.channelsConfigRef()[name]
271
+ if (currentCfg === undefined) {
272
+ logger.info(`[channels] restartAdapter('${name}'): adapter config missing, skipping`)
273
+ return
274
+ }
275
+ await stopAdapter(name)
276
+ await startAdapter(name, currentCfg)
277
+ })
278
+ },
279
+
221
280
  async reload(): Promise<{ started: string[]; stopped: string[]; restartRequired: string[] }> {
222
281
  const cfg = options.channelsConfigRef()
223
282
  const started: string[] = []
@@ -229,11 +288,11 @@ export function createChannelManager(options: ChannelManagerOptions): ChannelMan
229
288
  const current = live.get(name)
230
289
  if (desired === undefined || desired.enabled === false) {
231
290
  if (current) {
232
- await stopAdapter(name)
291
+ await runSerially(name, () => stopAdapter(name))
233
292
  stopped.push(name)
234
293
  }
235
294
  } else if (!current) {
236
- const ok = await startAdapter(name, desired)
295
+ const ok = await runSerially(name, () => startAdapter(name, desired))
237
296
  if (ok) started.push(name)
238
297
  } else {
239
298
  const { signature, missing } = buildCredentialSignature(name)
@@ -246,7 +305,7 @@ export function createChannelManager(options: ChannelManagerOptions): ChannelMan
246
305
  logger.warn(
247
306
  `[channels] adapter "${name}" missing credentials after reload (${missing.join(', ')}); stopping`,
248
307
  )
249
- await stopAdapter(name)
308
+ await runSerially(name, () => stopAdapter(name))
250
309
  stopped.push(name)
251
310
  } else if (signature !== current.credentialSignature) {
252
311
  const reason = name === 'kakaotalk' ? 'credential rotation' : 'token rotation'
@@ -263,7 +322,7 @@ export function createChannelManager(options: ChannelManagerOptions): ChannelMan
263
322
  // Token-based adapters only. KakaoTalk's credentials live in
264
323
  // secrets.json#channels.kakaotalk, not in env, so it goes through
265
324
  // buildKakaotalkSignature instead.
266
- const TOKEN_ENV: Record<Exclude<AdapterId, 'kakaotalk'>, readonly string[]> = {
325
+ const TOKEN_ENV: Record<Exclude<AdapterId, 'kakaotalk' | 'github'>, readonly string[]> = {
267
326
  'discord-bot': ['DISCORD_BOT_TOKEN'],
268
327
  'slack-bot': ['SLACK_BOT_TOKEN', 'SLACK_APP_TOKEN'],
269
328
  'telegram-bot': ['TELEGRAM_BOT_TOKEN'],
@@ -301,6 +360,32 @@ function buildKakaotalkSignature(agentDir: string): { signature: string; missing
301
360
  }
302
361
  }
303
362
 
363
+ function buildGithubSignature(agentDir: string): { signature: string; missing: string[] } {
364
+ const block = readGithubSecrets(agentDir)
365
+ if (block === null) return { signature: '', missing: ['secrets.json#channels.github'] }
366
+ const digest = createHash('sha256').update(JSON.stringify(block)).digest('hex')
367
+ return { signature: `secrets.json#channels.github@sha256:${digest}`, missing: [] }
368
+ }
369
+
370
+ function readGithubSecrets(agentDir: string): GithubSecretsBlock | null {
371
+ const path = join(agentDir, 'secrets.json')
372
+ try {
373
+ const block = new SecretsBackend(path).tryReadChannelsSync()?.github
374
+ return isGithubSecretsBlock(block) ? block : null
375
+ } catch {
376
+ return null
377
+ }
378
+ }
379
+
380
+ function isGithubSecretsBlock(value: unknown): value is GithubSecretsBlock {
381
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
382
+ const record = value as Record<string, unknown>
383
+ const auth = record.auth
384
+ if (typeof auth !== 'object' || auth === null || Array.isArray(auth)) return false
385
+ const authType = (auth as Record<string, unknown>).type
386
+ return authType === 'pat' || authType === 'app'
387
+ }
388
+
304
389
  function isKakaoCredentialBlock(value: unknown): value is { accounts: Record<string, unknown> } {
305
390
  if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
306
391
  if (!('accounts' in value)) return false
@@ -4,6 +4,7 @@ import type { AssistantMessage } from '@mariozechner/pi-ai'
4
4
  import { SessionManager } from '@mariozechner/pi-coding-agent'
5
5
 
6
6
  import { createSession, type AgentSession } from '@/agent'
7
+ import { subscribeProviderErrors } from '@/agent/provider-error'
7
8
  import type { ChannelParticipant, SessionOrigin } from '@/agent/session-origin'
8
9
  import { createCommandRegistry } from '@/commands'
9
10
  import { CORE_PERMISSIONS, type PermissionService } from '@/permissions'
@@ -255,6 +256,7 @@ type LiveSession = {
255
256
  loopGuardActive: boolean
256
257
  membershipFetch: Promise<MembershipCount | null> | null
257
258
  destroyed: boolean
259
+ unsubProviderErrors: (() => void) | null
258
260
  }
259
261
 
260
262
  type ChannelCommandContext = {
@@ -297,6 +299,7 @@ export type ChannelRouter = {
297
299
  fireTypingHeartbeat: (key: ChannelKey, phase?: 'tick' | 'stop') => Promise<void>
298
300
  fireTypingInterval: (key: ChannelKey) => Promise<void>
299
301
  isTypingActive: (key: ChannelKey) => boolean
302
+ stopTyping: (key: ChannelKey) => Promise<void>
300
303
  runIdleGc: () => Promise<void>
301
304
  }
302
305
  }
@@ -722,7 +725,11 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
722
725
  loopGuardActive: false,
723
726
  membershipFetch,
724
727
  destroyed: false,
728
+ unsubProviderErrors: null,
725
729
  }
730
+ live.unsubProviderErrors = subscribeProviderErrors(created.session, (err) => {
731
+ logger.error(`[channels] ${live.keyId}: LLM call failed: ${err.message}`)
732
+ })
726
733
  liveSessions.set(keyId, live)
727
734
 
728
735
  if (isColdStart) {
@@ -1027,7 +1034,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1027
1034
  live.consecutiveAborts = 0
1028
1035
  logger.info(`[channels] ${live.keyId} prompted elapsed_ms=${now() - promptStart}`)
1029
1036
  } catch (err) {
1030
- logger.warn(`[channels] ${live.keyId}: prompt threw: ${describe(err)}`)
1037
+ logger.error(`[channels] ${live.keyId}: prompt threw: ${describe(err)}`)
1031
1038
  live.consecutiveSends.clear()
1032
1039
  } finally {
1033
1040
  await fireSessionTurnEnd(live)
@@ -1448,7 +1455,19 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1448
1455
  const live = liveSessions.get(keyId)
1449
1456
  if (live) {
1450
1457
  live.successfulChannelSends++
1451
- await stopTypingHeartbeat(live)
1458
+ // Don't stop the heartbeat here: the agent may still be mid-turn and
1459
+ // about to send another reply. drain()'s finally block owns turn-end
1460
+ // stop. But Slack's adapter outbound callback explicitly clears
1461
+ // platform-side typing after every successful postMessage (to defeat
1462
+ // the heartbeat-vs-postMessage race fixed in PR #52), so a fresh
1463
+ // 'tick' must land in the FIFO right after that clear — otherwise
1464
+ // the indicator stays cleared until the next 8s interval, leaving a
1465
+ // visible idle gap between mid-turn sends on Slack. The await on
1466
+ // cb(msg) above already drained the outbound callback's clearAfterSend
1467
+ // through the per-(chat,thread) FIFO, so this tick is guaranteed to
1468
+ // land after it. Discord and Telegram treat the extra tick as a
1469
+ // no-op refresh of their already-armed (auto-expiring) indicators.
1470
+ if (live.typingTimer) void fireTyping(live, 'tick')
1452
1471
  const adapterConfig = options.configForAdapter(msg.adapter)
1453
1472
  if (adapterConfig) {
1454
1473
  const targetIds = Array.from(
@@ -1512,6 +1531,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1512
1531
  live.destroyed = true
1513
1532
  if (live.debounceTimer) clearTimeout(live.debounceTimer)
1514
1533
  live.debounceTimer = null
1534
+ live.unsubProviderErrors?.()
1535
+ live.unsubProviderErrors = null
1515
1536
  await stopTypingHeartbeat(live)
1516
1537
  try {
1517
1538
  await live.session.abort()
@@ -1616,6 +1637,11 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1616
1637
  const live = liveSessions.get(channelKeyId(key))
1617
1638
  return live?.typingTimer !== null && live?.typingTimer !== undefined
1618
1639
  },
1640
+ stopTyping: async (key: ChannelKey) => {
1641
+ const live = liveSessions.get(channelKeyId(key))
1642
+ if (!live) return
1643
+ await stopTypingHeartbeat(live)
1644
+ },
1619
1645
  runIdleGc,
1620
1646
  },
1621
1647
  }