typeclaw 0.3.1 → 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 (89) hide show
  1. package/README.md +20 -15
  2. package/auth.schema.json +113 -0
  3. package/package.json +1 -1
  4. package/secrets.schema.json +113 -0
  5. package/src/agent/session-meta.ts +1 -1
  6. package/src/agent/session-origin.ts +3 -2
  7. package/src/bundled-plugins/security/index.ts +3 -2
  8. package/src/channels/adapters/github/auth-app.ts +120 -0
  9. package/src/channels/adapters/github/auth-pat.ts +50 -0
  10. package/src/channels/adapters/github/auth.ts +33 -0
  11. package/src/channels/adapters/github/channel-resolver.ts +30 -0
  12. package/src/channels/adapters/github/dedup.ts +26 -0
  13. package/src/channels/adapters/github/event-allowlist.ts +8 -0
  14. package/src/channels/adapters/github/fetch-attachment.ts +5 -0
  15. package/src/channels/adapters/github/history.ts +63 -0
  16. package/src/channels/adapters/github/inbound.ts +286 -0
  17. package/src/channels/adapters/github/index.ts +286 -0
  18. package/src/channels/adapters/github/managed-path.ts +54 -0
  19. package/src/channels/adapters/github/membership.ts +35 -0
  20. package/src/channels/adapters/github/outbound.ts +145 -0
  21. package/src/channels/adapters/github/webhook-register.ts +349 -0
  22. package/src/channels/manager.ts +94 -9
  23. package/src/channels/schema.ts +31 -1
  24. package/src/channels/tunnel-bridge.ts +51 -0
  25. package/src/cli/builtins.ts +28 -0
  26. package/src/cli/channel.ts +511 -25
  27. package/src/cli/container-command-client.ts +244 -0
  28. package/src/cli/cron.ts +173 -0
  29. package/src/cli/host-command-runner.ts +150 -0
  30. package/src/cli/index.ts +42 -1
  31. package/src/cli/init.ts +256 -27
  32. package/src/cli/model.ts +4 -2
  33. package/src/cli/plugin-command-help.ts +49 -0
  34. package/src/cli/plugin-commands-dispatch.ts +112 -0
  35. package/src/cli/plugin-commands.ts +118 -0
  36. package/src/cli/tui.ts +10 -2
  37. package/src/cli/tunnel.ts +533 -0
  38. package/src/cli/ui.ts +8 -3
  39. package/src/config/config.ts +75 -0
  40. package/src/container/start.ts +30 -3
  41. package/src/cron/bridge.ts +136 -0
  42. package/src/cron/consumer.ts +45 -5
  43. package/src/cron/index.ts +19 -2
  44. package/src/cron/list.ts +105 -0
  45. package/src/cron/scheduler.ts +12 -3
  46. package/src/cron/schema.ts +11 -3
  47. package/src/doctor/checks.ts +0 -50
  48. package/src/init/dockerfile.ts +59 -13
  49. package/src/init/ensure-deps.ts +15 -4
  50. package/src/init/github-webhook-install.ts +109 -0
  51. package/src/init/index.ts +505 -9
  52. package/src/init/run-bun-install.ts +17 -3
  53. package/src/init/run-owner-claim.ts +11 -2
  54. package/src/permissions/builtins.ts +6 -1
  55. package/src/permissions/match-rule.ts +24 -2
  56. package/src/permissions/resolve.ts +1 -0
  57. package/src/plugin/define.ts +42 -1
  58. package/src/plugin/index.ts +18 -3
  59. package/src/plugin/manager.ts +2 -0
  60. package/src/plugin/registry.ts +85 -3
  61. package/src/plugin/types.ts +138 -1
  62. package/src/plugin/zod-introspect.ts +100 -0
  63. package/src/role-claim/match-rule.ts +2 -1
  64. package/src/run/index.ts +110 -3
  65. package/src/secrets/index.ts +1 -1
  66. package/src/secrets/schema.ts +21 -0
  67. package/src/server/command-runner.ts +476 -0
  68. package/src/server/index.ts +388 -5
  69. package/src/shared/index.ts +8 -0
  70. package/src/shared/protocol.ts +80 -1
  71. package/src/skills/typeclaw-channel-github/SKILL.md +24 -0
  72. package/src/skills/typeclaw-config/SKILL.md +27 -26
  73. package/src/skills/typeclaw-cron/SKILL.md +234 -3
  74. package/src/skills/typeclaw-monorepo/SKILL.md +2 -2
  75. package/src/skills/typeclaw-permissions/SKILL.md +5 -4
  76. package/src/skills/typeclaw-plugins/SKILL.md +251 -5
  77. package/src/skills/typeclaw-tunnels/SKILL.md +111 -0
  78. package/src/test-helpers/wait-for.ts +50 -0
  79. package/src/tui/index.ts +35 -4
  80. package/src/tunnels/__fixtures__/cloudflared-quick-stderr.txt +11 -0
  81. package/src/tunnels/events.ts +14 -0
  82. package/src/tunnels/index.ts +12 -0
  83. package/src/tunnels/log-ring.ts +54 -0
  84. package/src/tunnels/manager.ts +139 -0
  85. package/src/tunnels/providers/cloudflare-quick.ts +189 -0
  86. package/src/tunnels/providers/external.ts +53 -0
  87. package/src/tunnels/quick-url-parser.ts +5 -0
  88. package/src/tunnels/types.ts +43 -0
  89. package/typeclaw.schema.json +254 -1
@@ -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
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod'
2
2
 
3
- export const ADAPTER_IDS = ['discord-bot', 'kakaotalk', 'slack-bot', 'telegram-bot'] as const
3
+ export const ADAPTER_IDS = ['discord-bot', 'github', 'kakaotalk', 'slack-bot', 'telegram-bot'] as const
4
4
 
5
5
  export type AdapterId = (typeof ADAPTER_IDS)[number]
6
6
 
@@ -99,6 +99,33 @@ const adapterSchema = z.object({
99
99
  enabled: z.boolean().default(true),
100
100
  })
101
101
 
102
+ export const DEFAULT_GITHUB_EVENT_ALLOWLIST = [
103
+ 'issue_comment.created',
104
+ 'pull_request_review_comment.created',
105
+ 'discussion_comment.created',
106
+ 'issues.opened',
107
+ 'pull_request.opened',
108
+ 'discussion.created',
109
+ 'pull_request_review.submitted',
110
+ ] as const
111
+
112
+ const githubChannelSchema = adapterSchema.extend({
113
+ // Optional now (PR 2): when omitted and a `tunnels[]` entry with
114
+ // `for: { kind: 'channel', name: 'github' }` exists, the runtime resolves
115
+ // the URL from the tunnel manager via the adapter's `tunnelUrl` callback.
116
+ // The github adapter skips webhook registration when no effective URL is available.
117
+ webhookUrl: z.string().url().optional(),
118
+ webhookPort: z.number().int().positive().default(8975),
119
+ eventAllowlist: z.array(z.string()).default([...DEFAULT_GITHUB_EVENT_ALLOWLIST]),
120
+ // Repositories whose webhooks the adapter manages. Each entry is an
121
+ // `owner/name` slug. On adapter start(), TypeClaw registers a webhook
122
+ // pointing at webhookUrl for every repo here (idempotent: existing hooks
123
+ // at the same URL are updated). On stop(), every hook TypeClaw created
124
+ // this session is deleted so a restart with a different webhookUrl (e.g.
125
+ // a tunnel reassigning a URL) doesn't leave orphaned hooks on GitHub.
126
+ repos: z.array(z.string()).default([]),
127
+ })
128
+
102
129
  // KakaoTalk uses the same shape as every other adapter. There used to be an
103
130
  // `autoMarkRead` opt-in here; the adapter now fires a LOCO NOTIREAD ack on
104
131
  // every inbound MSG event unconditionally (see kakaotalk.ts) so the sender's
@@ -112,6 +139,7 @@ const adapterSchema = z.object({
112
139
  export const channelsSchema = z
113
140
  .object({
114
141
  'discord-bot': adapterSchema.optional(),
142
+ github: githubChannelSchema.optional(),
115
143
  kakaotalk: adapterSchema.optional(),
116
144
  'slack-bot': adapterSchema.optional(),
117
145
  'telegram-bot': adapterSchema.optional(),
@@ -120,4 +148,6 @@ export const channelsSchema = z
120
148
 
121
149
  export type EngagementConfig = z.infer<typeof engagementSchema>
122
150
  export type ChannelAdapterConfig = z.infer<typeof adapterSchema>
151
+ type ParsedGithubAdapterConfig = z.infer<typeof githubChannelSchema>
152
+ export type GithubAdapterConfig = ParsedGithubAdapterConfig
123
153
  export type ChannelsConfig = z.infer<typeof channelsSchema>
@@ -0,0 +1,51 @@
1
+ import type { Stream } from '@/stream'
2
+ import { isTunnelUrlChangedPayload } from '@/tunnels'
3
+
4
+ import type { AdapterId } from './schema'
5
+
6
+ export type TunnelBridgeLogger = {
7
+ info: (msg: string) => void
8
+ warn: (msg: string) => void
9
+ error: (msg: string) => void
10
+ }
11
+
12
+ export type TunnelBridgeChannelManager = {
13
+ restartAdapter: (name: AdapterId) => Promise<void>
14
+ }
15
+
16
+ export type TunnelBridgeOptions = {
17
+ stream: Stream
18
+ channelManager: TunnelBridgeChannelManager
19
+ logger?: TunnelBridgeLogger
20
+ }
21
+
22
+ export type TunnelBridge = {
23
+ stop: () => void
24
+ }
25
+
26
+ const consoleLogger: TunnelBridgeLogger = {
27
+ info: (msg) => console.log(msg),
28
+ warn: (msg) => console.warn(msg),
29
+ error: (msg) => console.error(msg),
30
+ }
31
+
32
+ export function createTunnelBridge(options: TunnelBridgeOptions): TunnelBridge {
33
+ const logger = options.logger ?? consoleLogger
34
+ // Subscribe synchronously; run/index.ts must create this bridge before
35
+ // tunnelManager.start() so an initial provider URL broadcast cannot be missed.
36
+ const unsubscribe = options.stream.subscribe({ target: { kind: 'broadcast' } }, (msg) => {
37
+ const payload = msg.payload
38
+ if (!isTunnelUrlChangedPayload(payload)) return
39
+ if (payload.for.kind !== 'channel') return
40
+ const name = (payload.for as { name?: unknown }).name
41
+ if (typeof name !== 'string') return
42
+ logger.info(`[tunnels] ${name} URL → restarting adapter`)
43
+ void options.channelManager.restartAdapter(name as AdapterId).catch((err: unknown) => {
44
+ logger.error(`[tunnels] failed to restart ${name} adapter: ${err instanceof Error ? err.message : String(err)}`)
45
+ })
46
+ })
47
+
48
+ return {
49
+ stop: unsubscribe,
50
+ }
51
+ }
@@ -0,0 +1,28 @@
1
+ // Single source of truth for the top-level `typeclaw` subcommands that the
2
+ // CLI dispatches via citty. Plugin commands MUST NOT shadow these names.
3
+ // `src/cli/index.ts` consumes this for argv interception; `src/plugin/registry.ts`
4
+ // consumes it to reject plugin commands that collide.
5
+ export const BUILTIN_COMMAND_NAMES = [
6
+ 'init',
7
+ 'run',
8
+ 'tui',
9
+ 'start',
10
+ 'stop',
11
+ 'restart',
12
+ 'status',
13
+ 'reload',
14
+ 'logs',
15
+ 'shell',
16
+ 'compose',
17
+ 'channel',
18
+ 'cron',
19
+ 'tunnel',
20
+ 'role',
21
+ 'provider',
22
+ 'model',
23
+ 'doctor',
24
+ 'usage',
25
+ '_hostd',
26
+ ] as const
27
+
28
+ export type BuiltinCommandName = (typeof BUILTIN_COMMAND_NAMES)[number]