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
@@ -0,0 +1,100 @@
1
+ import { z } from 'zod'
2
+
3
+ export type LeafKind = 'string' | 'number' | 'boolean' | 'unknown'
4
+
5
+ export type LeafDescription = {
6
+ kind: LeafKind
7
+ required: boolean
8
+ defaultValue: string | undefined
9
+ description: string | undefined
10
+ }
11
+
12
+ // Walks the chain of Zod 4 wrappers (optional, default, nullable) and returns
13
+ // the inner-most leaf node. Reads `_def.type` (Zod 4's lowercase discriminator)
14
+ // directly because the public `instanceof ZodOptional` checks don't work for
15
+ // `.innerType` — Zod 4 types it as the base `$ZodType`, not the public class
16
+ // hierarchy. If Zod ships a breaking change to `_def.type`, this is the only
17
+ // file that needs to update.
18
+ export function describeLeaf(leaf: unknown): LeafDescription {
19
+ let cur: unknown = leaf
20
+ let required = true
21
+ let defaultValue: string | undefined
22
+ let description: string | undefined
23
+
24
+ while (cur !== null && typeof cur === 'object') {
25
+ const node = cur as {
26
+ _def?: { type?: string; innerType?: unknown; defaultValue?: unknown }
27
+ description?: string
28
+ }
29
+ if (typeof node.description === 'string') description = node.description
30
+ const def = node._def
31
+ if (def === undefined) break
32
+ if (def.type === 'optional') {
33
+ required = false
34
+ cur = def.innerType
35
+ continue
36
+ }
37
+ if (def.type === 'default') {
38
+ required = false
39
+ const raw = typeof def.defaultValue === 'function' ? (def.defaultValue as () => unknown)() : def.defaultValue
40
+ defaultValue = raw === undefined ? undefined : JSON.stringify(raw)
41
+ cur = def.innerType
42
+ continue
43
+ }
44
+ if (def.type === 'nullable') {
45
+ cur = def.innerType
46
+ continue
47
+ }
48
+ return { kind: classify(def.type), required, defaultValue, description }
49
+ }
50
+ return { kind: 'unknown', required, defaultValue, description }
51
+ }
52
+
53
+ function classify(t: string | undefined): LeafKind {
54
+ switch (t) {
55
+ case 'string':
56
+ case 'literal':
57
+ case 'enum':
58
+ return 'string'
59
+ case 'number':
60
+ case 'int':
61
+ return 'number'
62
+ case 'boolean':
63
+ return 'boolean'
64
+ default:
65
+ return 'unknown'
66
+ }
67
+ }
68
+
69
+ // Coerces a single CLI flag value (string from argv or `true` when bare) to the
70
+ // type the leaf expects. Throws a precise error referencing the flag key when
71
+ // coercion fails; the caller surfaces the message to stderr.
72
+ export function coerceFlag(leaf: unknown, raw: string | true, key: string): unknown {
73
+ const info = describeLeaf(leaf)
74
+ if (info.kind === 'boolean') {
75
+ if (raw === true || raw === 'true') return true
76
+ if (raw === 'false') return false
77
+ throw new Error(`--${key}: expected true/false, got "${raw}"`)
78
+ }
79
+ if (info.kind === 'number') {
80
+ if (raw === true) throw new Error(`--${key} requires a numeric value`)
81
+ if (raw === '') throw new Error(`--${key}: empty value rejected; pass a number`)
82
+ const n = Number(raw)
83
+ if (Number.isNaN(n)) throw new Error(`--${key}: not a number: "${raw}"`)
84
+ return n
85
+ }
86
+ if (raw === true) throw new Error(`--${key} requires a value`)
87
+ return raw
88
+ }
89
+
90
+ // Returns true when `schema` is a Zod 4 z.object whose leaf properties are all
91
+ // primitive-shaped (string/number/boolean, with optional/default/nullable
92
+ // wrappers). Plugin command args schemas MUST satisfy this in v1.
93
+ export function isPrimitiveZodObject(schema: unknown): schema is z.ZodObject<z.ZodRawShape> {
94
+ if (!(schema instanceof z.ZodObject)) return false
95
+ const shape = (schema as z.ZodObject<z.ZodRawShape>).shape as Record<string, unknown>
96
+ for (const leaf of Object.values(shape)) {
97
+ if (describeLeaf(leaf).kind === 'unknown') return false
98
+ }
99
+ return true
100
+ }
@@ -21,9 +21,10 @@ export type PartialChannelOrigin = {
21
21
  authorId: string
22
22
  }
23
23
 
24
- const ADAPTER_TO_PLATFORM: Record<ChannelKey['adapter'], 'slack' | 'discord' | 'telegram' | 'kakao'> = {
24
+ const ADAPTER_TO_PLATFORM: Record<ChannelKey['adapter'], 'slack' | 'discord' | 'telegram' | 'kakao' | 'github'> = {
25
25
  'slack-bot': 'slack',
26
26
  'discord-bot': 'discord',
27
+ github: 'github',
27
28
  'telegram-bot': 'telegram',
28
29
  kakaotalk: 'kakao',
29
30
  }
package/src/run/index.ts CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  } from '@/agent/subagents'
13
13
  import { resolveCapOptionsFromConfig } from '@/bundled-plugins/tool-result-cap'
14
14
  import { createChannelManager, createChannelsReloadable, type ChannelManager } from '@/channels'
15
+ import { createTunnelBridge, type TunnelBridge } from '@/channels/tunnel-bridge'
15
16
  import { createConfigReloadable, getConfig, loadConfigSync, loadPluginConfigsSync } from '@/config'
16
17
  import {
17
18
  type CronConsumer,
@@ -26,14 +27,24 @@ import {
26
27
  } from '@/cron'
27
28
  import { CLI_VERSION } from '@/init/cli-version'
28
29
  import { loadPlugins, type LoadPluginsResult, pluginCronJobs, type PluginRegistry, summarizeLoaded } from '@/plugin'
30
+ import { createPluginLogger } from '@/plugin/context'
31
+ import type { CronHandlerContext } from '@/plugin/types'
29
32
  import { createContainerBroker, publishForwardResult } from '@/portbroker'
30
33
  import { ReloadRegistry } from '@/reload'
31
34
  import { createClaimController } from '@/role-claim'
32
35
  import { hydrateChannelEnvFromSecrets } from '@/secrets'
33
36
  import { createServer, type Server } from '@/server'
37
+ import {
38
+ createCommandRunner,
39
+ type CommandRunner,
40
+ type CommandSpawnSubagent,
41
+ runExecForCommand,
42
+ runPromptForCommand,
43
+ } from '@/server/command-runner'
34
44
  import { createSessionFactory, type SessionFactory } from '@/sessions'
35
45
  import { createStream, type Stream } from '@/stream'
36
46
  import { createTui as createTuiDefault, type TuiOptions } from '@/tui'
47
+ import { createTunnelManager, type TunnelManager, type TunnelManagerOptions } from '@/tunnels'
37
48
 
38
49
  import { BUNDLED_PLUGINS } from './bundled-plugins'
39
50
  import { buildChannelSessionFactory } from './channel-session-factory'
@@ -45,6 +56,8 @@ export type TuiFactory = (options: TuiOptions) => { run: () => Promise<void> }
45
56
 
46
57
  export type LoadCronFn = (agentDir: string, options?: { subagents?: SubagentRegistry }) => Promise<LoadCronResult>
47
58
  export type SchedulerFactory = (options: { cwd: string; file: CronFile; onFire: (job: CronJob) => void }) => Scheduler
59
+ export type ChannelManagerFactory = typeof createChannelManager
60
+ export type TunnelManagerFactory = (options: TunnelManagerOptions) => TunnelManager
48
61
 
49
62
  export type StartAgentOptions = {
50
63
  port: number
@@ -56,6 +69,8 @@ export type StartAgentOptions = {
56
69
  createSchedulerFor?: SchedulerFactory
57
70
  sessionFactory?: SessionFactory
58
71
  stream?: Stream
72
+ createChannelManager?: ChannelManagerFactory
73
+ createTunnelManager?: TunnelManagerFactory
59
74
  }
60
75
 
61
76
  export type StartAgentResult = {
@@ -82,6 +97,8 @@ export async function startAgent({
82
97
  createSchedulerFor,
83
98
  sessionFactory = createSessionFactory({ agentDir: cwd }),
84
99
  stream = createStream(),
100
+ createChannelManager: createChannelManagerFor = createChannelManager,
101
+ createTunnelManager: createTunnelManagerFor = createTunnelManager,
85
102
  }: StartAgentOptions): Promise<StartAgentResult> {
86
103
  const reloadRegistry = new ReloadRegistry()
87
104
 
@@ -123,6 +140,7 @@ export async function startAgent({
123
140
  pluginRegistry.cronJobs.length > 0 ||
124
141
  pluginRegistry.skills.length > 0 ||
125
142
  pluginRegistry.skillsDirs.length > 0 ||
143
+ pluginRegistry.commands.length > 0 ||
126
144
  pluginsLoaded.loadedPlugins.length > 0
127
145
 
128
146
  const pluginRuntime = createPluginRuntime({
@@ -149,10 +167,21 @@ export async function startAgent({
149
167
  rolesProvider: () => getConfig().roles,
150
168
  })
151
169
 
152
- const channelManager = createChannelManager({
170
+ const tunnelManager: TunnelManager = createTunnelManagerFor({
171
+ tunnels: getConfig().tunnels,
172
+ stream,
173
+ resolveChannelUpstreamPort: (name) => {
174
+ if (name === 'github') return getConfig().channels.github?.webhookPort ?? null
175
+ return null
176
+ },
177
+ })
178
+
179
+ const channelManager = createChannelManagerFor({
153
180
  agentDir: cwd,
154
181
  channelsConfigRef: () => getConfig().channels,
155
182
  aliasesRef: () => getConfig().alias,
183
+ tunnelUrlForChannel: (name) => resolveTunnelUrlForChannel(name, tunnelManager),
184
+ tunnelConfiguredForChannel: (name) => isTunnelConfiguredForChannel(name),
156
185
  createSessionForChannel: buildChannelSessionFactory({
157
186
  cwd,
158
187
  sessionFactory,
@@ -244,6 +273,47 @@ export async function startAgent({
244
273
  const cronConsumer = createCronConsumer({
245
274
  stream,
246
275
  cwd,
276
+ invokeHandler: async (job) => {
277
+ const snap = pluginRuntime.get()
278
+ const registered = snap.registry.cronJobs.find((j) => j.globalId === job.id)
279
+ const pluginName = registered?.pluginName ?? '<unknown>'
280
+ const logger = createPluginLogger(pluginName)
281
+ const abortController = new AbortController()
282
+ const origin: SessionOrigin = {
283
+ kind: 'cron',
284
+ jobId: job.id,
285
+ jobKind: 'handler',
286
+ ...(job.scheduledByRole !== undefined ? { scheduledByRole: job.scheduledByRole } : {}),
287
+ scheduledByOrigin: (job.scheduledByOrigin as SessionOrigin | undefined) ?? { kind: 'config-file' },
288
+ }
289
+ const ctx: CronHandlerContext = {
290
+ jobId: job.id,
291
+ name: pluginName,
292
+ agentDir: cwd,
293
+ logger,
294
+ signal: abortController.signal,
295
+ permissions: pluginsLoaded.permissions,
296
+ origin,
297
+ prompt: (text: string) =>
298
+ runPromptForCommand({
299
+ text,
300
+ origin,
301
+ runtime: pluginRuntime,
302
+ agentDir: cwd,
303
+ permissions: pluginsLoaded.permissions,
304
+ signal: abortController.signal,
305
+ runtimeVersion: runtimeVersionOpt.runtimeVersion,
306
+ containerName: containerNameOpt.containerName,
307
+ }),
308
+ subagent: (subName: string, payload?: unknown) =>
309
+ dispatchSpawnSubagent(subName, payload, {
310
+ spawnedByOrigin: origin,
311
+ }),
312
+ exec: (strings: TemplateStringsArray, ...values: unknown[]) =>
313
+ runExecForCommand(strings, values, { cwd, signal: abortController.signal }),
314
+ }
315
+ await job.handler(ctx)
316
+ },
247
317
  createSessionForCron: async (job) => {
248
318
  const snap = pluginRuntime.get()
249
319
  const sessionManager = SessionManager.create(cwd, sessionFactory.sessionDir())
@@ -310,10 +380,15 @@ export async function startAgent({
310
380
  )
311
381
  }
312
382
 
383
+ const tunnelBridge: TunnelBridge = createTunnelBridge({ stream, channelManager })
384
+
313
385
  reloadRegistry.register(createChannelsReloadable({ manager: channelManager }))
314
386
  await channelManager.start()
315
387
 
316
- pluginsLoaded.setSpawnSubagent(async (name, payload, options) => {
388
+ // Captured separately from setSpawnSubagent so both the plugin context and
389
+ // the plugin-command runner can dispatch through the same path. The setter
390
+ // returns void, so without this local binding we couldn't reuse the fn.
391
+ const dispatchSpawnSubagent: CommandSpawnSubagent = async (name, payload, options) => {
317
392
  // Resolve the spawning session's role from its origin so the subagent
318
393
  // inherits it. Callers (hooks like session.idle) pass the parent origin
319
394
  // verbatim; we look up the role rather than letting the caller forge it,
@@ -333,7 +408,8 @@ export async function startAgent({
333
408
  ...(spawnedByRole !== undefined ? { spawnedByRole } : {}),
334
409
  ...(options?.spawnedByOrigin !== undefined ? { spawnedByOrigin: options.spawnedByOrigin } : {}),
335
410
  })
336
- })
411
+ }
412
+ pluginsLoaded.setSpawnSubagent(dispatchSpawnSubagent)
337
413
  pluginsLoaded.markBooted()
338
414
 
339
415
  if (pluginsLoaded.loadedPlugins.length > 0) {
@@ -365,6 +441,17 @@ export async function startAgent({
365
441
  : undefined
366
442
  const containerBrokerOpt = containerBroker ? { containerBroker } : {}
367
443
 
444
+ const commandRunnerFactory = (outbound: import('@/server/command-runner').CommandOutbound): CommandRunner =>
445
+ createCommandRunner({
446
+ pluginRuntime,
447
+ permissions: pluginsLoaded.permissions,
448
+ spawnSubagent: dispatchSpawnSubagent,
449
+ agentDir: cwd,
450
+ runtimeVersion: CLI_VERSION,
451
+ containerName,
452
+ outbound,
453
+ })
454
+
368
455
  const server = createServer({
369
456
  port,
370
457
  reloadAll: () => reloadRegistry.reloadAll(),
@@ -375,12 +462,21 @@ export async function startAgent({
375
462
  agentDir: cwd,
376
463
  pluginRuntime,
377
464
  claimController,
465
+ commandRunnerFactory,
466
+ tunnelManager,
378
467
  ...containerNameOpt,
379
468
  ...runtimeVersionOpt,
380
469
  ...tuiTokenOpt,
381
470
  ...containerBrokerOpt,
382
471
  }).start()
383
472
 
473
+ // Tunnel manager starts AFTER the WS server is up so a slow/hanging
474
+ // provider (PR 2's cloudflared first-URL wait) cannot block TUI, reload,
475
+ // or channel adapter availability. External providers resolve URLs
476
+ // synchronously; future managed providers will resolve asynchronously
477
+ // and broadcast URL events when ready.
478
+ await tunnelManager.start()
479
+
384
480
  let stopped = false
385
481
  const stop = async () => {
386
482
  if (stopped) return
@@ -390,6 +486,8 @@ export async function startAgent({
390
486
  subagentConsumer.stop()
391
487
  server.stop(true)
392
488
  void disposeMaterializedSkills(pluginRuntime)
489
+ tunnelBridge.stop()
490
+ await tunnelManager.stop()
393
491
  await channelManager.stop()
394
492
  }
395
493
 
@@ -436,6 +534,15 @@ function buildLocalTuiUrl(port: number, token: string | null): string {
436
534
  return url.toString()
437
535
  }
438
536
 
537
+ function resolveTunnelUrlForChannel(channelName: string, tunnelManager: TunnelManager): string | null {
538
+ const tunnel = getConfig().tunnels.find((entry) => entry.for.kind === 'channel' && entry.for.name === channelName)
539
+ return tunnel ? tunnelManager.urlFor(tunnel.name) : null
540
+ }
541
+
542
+ function isTunnelConfiguredForChannel(channelName: string): boolean {
543
+ return getConfig().tunnels.some((entry) => entry.for.kind === 'channel' && entry.for.name === channelName)
544
+ }
545
+
439
546
  async function disposeMaterializedSkills(pluginRuntime: PluginRuntime): Promise<void> {
440
547
  const pending = pluginRuntime.drainPendingDisposal()
441
548
  const current = pluginRuntime.get().materializedSkills
@@ -1,4 +1,4 @@
1
- export { type Channels } from './schema'
1
+ export { type Channels, type GithubSecretsBlock } from './schema'
2
2
 
3
3
  export { createSecretsStoreForAgent, SecretsBackend } from './storage'
4
4
 
@@ -40,6 +40,23 @@ const telegramBotChannelSchema = z.object({
40
40
  token: secretFieldSchema.optional(),
41
41
  })
42
42
 
43
+ const githubPatAuthSchema = z.object({
44
+ type: z.literal('pat'),
45
+ token: secretFieldSchema,
46
+ })
47
+
48
+ const githubAppAuthSchema = z.object({
49
+ type: z.literal('app'),
50
+ appId: z.number().int().positive(),
51
+ privateKey: secretFieldSchema,
52
+ installationId: z.number().int().positive().optional(),
53
+ })
54
+
55
+ const githubChannelSchema = z.object({
56
+ auth: z.discriminatedUnion('type', [githubPatAuthSchema, githubAppAuthSchema]),
57
+ webhookSecret: secretFieldSchema,
58
+ })
59
+
43
60
  // Encrypted password envelope produced by src/secrets/encryption.ts. Optional
44
61
  // in the schema because legacy v2 accounts (pre-renewal feature) don't have
45
62
  // one; the renewal cron treats a missing envelope as "reauth required" and
@@ -92,6 +109,7 @@ export const channelsSchema = z
92
109
  .object({
93
110
  'slack-bot': slackBotChannelSchema.optional(),
94
111
  'discord-bot': discordBotChannelSchema.optional(),
112
+ github: githubChannelSchema.optional(),
95
113
  'telegram-bot': telegramBotChannelSchema.optional(),
96
114
  kakaotalk: kakaoChannelBlockSchema.optional(),
97
115
  })
@@ -113,6 +131,9 @@ export const secretsFileSchema = z.object({
113
131
  export type ProviderCredential = z.infer<typeof providerCredentialSchema>
114
132
  export type Providers = z.infer<typeof providersSchema>
115
133
  export type Channels = z.infer<typeof channelsSchema>
134
+ export type GithubPatAuthBlock = z.infer<typeof githubPatAuthSchema>
135
+ export type GithubAppAuthBlock = z.infer<typeof githubAppAuthSchema>
136
+ export type GithubSecretsBlock = z.infer<typeof githubChannelSchema>
116
137
  export type KakaoAccountRecord = z.infer<typeof kakaoAccountRecordSchema>
117
138
  export type PendingLoginRecord = z.infer<typeof kakaoPendingLoginRecordSchema>
118
139
  export type KakaoChannelBlock = z.infer<typeof kakaoChannelBlockSchema>