typeclaw 0.3.1 → 0.5.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 (125) 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/auth.ts +4 -2
  6. package/src/agent/index.ts +16 -28
  7. package/src/agent/model-fallback.ts +127 -0
  8. package/src/agent/session-meta.ts +1 -1
  9. package/src/agent/session-origin.ts +3 -2
  10. package/src/agent/tools/curl-impersonate.ts +300 -0
  11. package/src/agent/tools/ddg.ts +13 -88
  12. package/src/agent/tools/webfetch/fetch.ts +105 -2
  13. package/src/agent/tools/webfetch/tool.ts +4 -0
  14. package/src/bundled-plugins/agent-browser/shim.ts +47 -0
  15. package/src/bundled-plugins/backup/subagents.ts +2 -0
  16. package/src/bundled-plugins/memory/README.md +49 -12
  17. package/src/bundled-plugins/memory/citation-superset.ts +63 -0
  18. package/src/bundled-plugins/memory/dreaming.ts +105 -17
  19. package/src/bundled-plugins/memory/index.ts +2 -2
  20. package/src/bundled-plugins/memory/memory-logger.ts +45 -26
  21. package/src/bundled-plugins/memory/strength.ts +127 -0
  22. package/src/bundled-plugins/memory/topics.ts +75 -0
  23. package/src/bundled-plugins/security/index.ts +88 -43
  24. package/src/bundled-plugins/security/permissions.ts +36 -0
  25. package/src/bundled-plugins/security/policies/git-exfil.ts +20 -0
  26. package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +12 -0
  27. package/src/bundled-plugins/security/policies/prompt-injection.ts +23 -3
  28. package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +7 -0
  29. package/src/bundled-plugins/security/policies/secret-exfil-read.ts +6 -0
  30. package/src/bundled-plugins/security/policies/session-search-secrets.ts +9 -0
  31. package/src/bundled-plugins/security/policies/ssrf.ts +6 -0
  32. package/src/bundled-plugins/security/policies/system-prompt-leak.ts +7 -0
  33. package/src/channels/adapters/github/auth-app.ts +120 -0
  34. package/src/channels/adapters/github/auth-pat.ts +50 -0
  35. package/src/channels/adapters/github/auth.ts +33 -0
  36. package/src/channels/adapters/github/channel-resolver.ts +30 -0
  37. package/src/channels/adapters/github/dedup.ts +26 -0
  38. package/src/channels/adapters/github/event-allowlist.ts +8 -0
  39. package/src/channels/adapters/github/fetch-attachment.ts +5 -0
  40. package/src/channels/adapters/github/history.ts +63 -0
  41. package/src/channels/adapters/github/inbound.ts +286 -0
  42. package/src/channels/adapters/github/index.ts +370 -0
  43. package/src/channels/adapters/github/managed-path.ts +54 -0
  44. package/src/channels/adapters/github/membership.ts +35 -0
  45. package/src/channels/adapters/github/outbound.ts +145 -0
  46. package/src/channels/adapters/github/webhook-register.ts +349 -0
  47. package/src/channels/manager.ts +94 -9
  48. package/src/channels/router.ts +194 -28
  49. package/src/channels/schema.ts +31 -1
  50. package/src/channels/tunnel-bridge.ts +51 -0
  51. package/src/channels/types.ts +3 -1
  52. package/src/cli/builtins.ts +28 -0
  53. package/src/cli/channel.ts +511 -25
  54. package/src/cli/container-command-client.ts +244 -0
  55. package/src/cli/cron.ts +173 -0
  56. package/src/cli/host-command-runner.ts +150 -0
  57. package/src/cli/index.ts +42 -1
  58. package/src/cli/init.ts +400 -67
  59. package/src/cli/model.ts +14 -4
  60. package/src/cli/oauth-callbacks.ts +49 -0
  61. package/src/cli/plugin-command-help.ts +49 -0
  62. package/src/cli/plugin-commands-dispatch.ts +112 -0
  63. package/src/cli/plugin-commands.ts +118 -0
  64. package/src/cli/provider.ts +3 -20
  65. package/src/cli/tui.ts +10 -2
  66. package/src/cli/tunnel.ts +533 -0
  67. package/src/cli/ui.ts +8 -3
  68. package/src/config/config.ts +134 -24
  69. package/src/config/models-mutation.ts +42 -8
  70. package/src/config/providers-mutation.ts +12 -8
  71. package/src/container/start.ts +48 -4
  72. package/src/cron/bridge.ts +136 -0
  73. package/src/cron/consumer.ts +174 -48
  74. package/src/cron/index.ts +19 -2
  75. package/src/cron/list.ts +105 -0
  76. package/src/cron/scheduler.ts +12 -3
  77. package/src/cron/schema.ts +11 -3
  78. package/src/doctor/checks.ts +0 -50
  79. package/src/init/dockerfile.ts +165 -13
  80. package/src/init/ensure-deps.ts +15 -4
  81. package/src/init/github-webhook-install.ts +109 -0
  82. package/src/init/hatching.ts +2 -2
  83. package/src/init/index.ts +519 -12
  84. package/src/init/oauth-login.ts +17 -3
  85. package/src/init/run-bun-install.ts +17 -3
  86. package/src/init/run-owner-claim.ts +11 -2
  87. package/src/permissions/builtins.ts +29 -2
  88. package/src/permissions/match-rule.ts +24 -2
  89. package/src/permissions/permissions.ts +24 -7
  90. package/src/permissions/resolve.ts +1 -0
  91. package/src/plugin/define.ts +44 -1
  92. package/src/plugin/index.ts +18 -3
  93. package/src/plugin/manager.ts +16 -0
  94. package/src/plugin/registry.ts +85 -3
  95. package/src/plugin/types.ts +144 -1
  96. package/src/plugin/zod-introspect.ts +100 -0
  97. package/src/role-claim/match-rule.ts +2 -1
  98. package/src/run/index.ts +112 -4
  99. package/src/secrets/index.ts +1 -1
  100. package/src/secrets/schema.ts +21 -0
  101. package/src/server/command-runner.ts +476 -0
  102. package/src/server/index.ts +388 -5
  103. package/src/shared/index.ts +8 -0
  104. package/src/shared/protocol.ts +80 -1
  105. package/src/skills/typeclaw-channel-github/SKILL.md +24 -0
  106. package/src/skills/typeclaw-config/SKILL.md +27 -26
  107. package/src/skills/typeclaw-cron/SKILL.md +234 -3
  108. package/src/skills/typeclaw-memory/SKILL.md +25 -15
  109. package/src/skills/typeclaw-monorepo/SKILL.md +2 -2
  110. package/src/skills/typeclaw-permissions/SKILL.md +35 -16
  111. package/src/skills/typeclaw-plugins/SKILL.md +251 -5
  112. package/src/skills/typeclaw-tunnels/SKILL.md +111 -0
  113. package/src/test-helpers/wait-for.ts +50 -0
  114. package/src/tui/index.ts +70 -7
  115. package/src/tunnels/__fixtures__/cloudflared-quick-stderr.txt +11 -0
  116. package/src/tunnels/events.ts +14 -0
  117. package/src/tunnels/index.ts +12 -0
  118. package/src/tunnels/log-ring.ts +54 -0
  119. package/src/tunnels/manager.ts +139 -0
  120. package/src/tunnels/providers/cloudflare-quick.ts +189 -0
  121. package/src/tunnels/providers/external.ts +53 -0
  122. package/src/tunnels/quick-url-parser.ts +5 -0
  123. package/src/tunnels/types.ts +43 -0
  124. package/src/usage/report.ts +15 -12
  125. package/typeclaw.schema.json +311 -26
@@ -92,7 +92,58 @@ export type PluginExecCronJob = {
92
92
  timezone?: string
93
93
  }
94
94
 
95
- export type PluginCronJob = PluginPromptCronJob | PluginExecCronJob
95
+ // In-process handler. Invoked directly by the cron consumer; no shell-out, no
96
+ // WS round-trip, no Bun.spawn. Use this when the cron job needs imperative
97
+ // control flow (probe → maybe prompt → write file) and lives in the same
98
+ // plugin as the logic — there is no need to dress the work up as a CLI
99
+ // command just to schedule it from yourself.
100
+ //
101
+ // `handler` is a TypeScript function reference, so this kind CANNOT appear in
102
+ // cron.json (only plugin-contributed cron registrations can carry it). The
103
+ // schema gate (`parseCronFile` in src/cron/schema.ts) keeps user files limited
104
+ // to `prompt` and `exec`.
105
+ export type PluginHandlerCronJob = {
106
+ schedule: string
107
+ kind: 'handler'
108
+ handler: (ctx: CronHandlerContext) => Promise<void>
109
+ enabled?: boolean
110
+ timezone?: string
111
+ }
112
+
113
+ export type PluginCronJob = PluginPromptCronJob | PluginExecCronJob | PluginHandlerCronJob
114
+
115
+ // Surface passed into a plugin cron handler. Mirrors the LLM-call capabilities
116
+ // of `ContainerCommandContext` (`prompt`, `subagent`, `exec`) without the
117
+ // CLI-shaped fields (stdin/stdout/stderr, args, exit code) because cron has
118
+ // no caller to pipe to.
119
+ //
120
+ // `signal` is reserved for future cancellation and is currently never aborted
121
+ // by the runtime — the consumer matches the existing prompt/exec cron jobs
122
+ // which also let in-flight work finish on container shutdown. The signal IS
123
+ // already threaded into `ctx.prompt` and `ctx.exec`, so the moment the
124
+ // runtime starts aborting it (e.g. via a future graceful-shutdown path),
125
+ // propagation works without handler-author changes. Handler authors who
126
+ // want to respect future cancellation should still check `ctx.signal.aborted`
127
+ // in long-running loops; nothing fires it today.
128
+ //
129
+ // `origin` is a cron-shaped SessionOrigin so any session the handler spawns
130
+ // via `ctx.prompt` carries the cron job's provenance — same role-inheritance
131
+ // semantics as a `kind: 'exec'` job that shells out to `typeclaw <cmd>`.
132
+ export type CronHandlerContext = {
133
+ readonly jobId: string
134
+ // The plugin that registered this cron job (e.g. 'inbox-watch'). Matches
135
+ // `ContainerCommandContext.name`. Useful for log lines that should be
136
+ // attributable to the plugin, not just the cron id.
137
+ readonly name: string
138
+ readonly agentDir: string
139
+ readonly logger: PluginLogger
140
+ readonly signal: AbortSignal
141
+ readonly permissions: PermissionService
142
+ readonly origin: SessionOrigin
143
+ readonly prompt: (text: string) => Promise<string>
144
+ readonly subagent: (name: string, payload?: unknown) => Promise<void>
145
+ readonly exec: (cmd: TemplateStringsArray, ...values: unknown[]) => Promise<CommandExecResult>
146
+ }
96
147
 
97
148
  export type PluginSkill = {
98
149
  description: string
@@ -267,5 +318,97 @@ export type PluginFixResult = {
267
318
  export type DefinedPlugin<TConfig = never> = {
268
319
  readonly configSchema?: z.ZodType<TConfig>
269
320
  readonly permissions?: readonly string[]
321
+ // Permission strings the owner wildcard sentinel MUST NOT auto-expand
322
+ // to. Used by the bundled security plugin to keep audience-leak
323
+ // (high-tier) bypasses off the owner role unless an operator grants
324
+ // them explicitly in roles.owner.permissions[]. Generic by design so
325
+ // any future plugin can carve specific permissions out of the wildcard.
326
+ readonly ownerWildcardExclusions?: readonly string[]
327
+ // Declared by-value (not built inside the factory) so the host-stage CLI
328
+ // can dispatch commands without booting plugin runtime state.
329
+ readonly commands?: Record<string, PluginCommand>
270
330
  readonly plugin: (ctx: PluginContext<TConfig>) => Promise<PluginExports>
271
331
  }
332
+
333
+ // `surface` controls where a plugin command may run: `'container'` requires
334
+ // the agent runtime (prompt/subagent/exec); `'host'` runs on the user's
335
+ // machine with no agent runtime; `'either'` accepts the intersection ctx
336
+ // and runs on whichever stage the user invoked it from.
337
+ export type PluginCommand = ContainerCommand | HostCommand | EitherCommand
338
+
339
+ export type ContainerCommand<A = unknown> = {
340
+ readonly surface: 'container'
341
+ readonly description: string
342
+ // v1 constraint: `z.object({...})` with primitive (string/number/boolean/
343
+ // literal/enum) leaves so `--help` can render `--<name>=<type>`.
344
+ readonly args?: z.ZodObject<z.ZodRawShape>
345
+ readonly permissions?: readonly string[]
346
+ // When true, runtime spawns a fresh Bun subprocess instead of dispatching
347
+ // in-process. Costs ~150ms cold-start; trade for isolation from the agent.
348
+ readonly isolated?: boolean
349
+ readonly run: (ctx: ContainerCommandContext, args: A) => Promise<number>
350
+ }
351
+
352
+ export type HostCommand<A = unknown> = {
353
+ readonly surface: 'host'
354
+ readonly description: string
355
+ readonly args?: z.ZodObject<z.ZodRawShape>
356
+ readonly run: (ctx: HostCommandContext, args: A) => Promise<number>
357
+ }
358
+
359
+ export type EitherCommand<A = unknown> = {
360
+ readonly surface: 'either'
361
+ readonly description: string
362
+ readonly args?: z.ZodObject<z.ZodRawShape>
363
+ readonly run: (ctx: EitherCommandContext, args: A) => Promise<number>
364
+ }
365
+
366
+ export type CommandStreams = {
367
+ readonly stdin: ReadableStream<Uint8Array>
368
+ readonly stdout: WritableStream<Uint8Array>
369
+ readonly stderr: WritableStream<Uint8Array>
370
+ }
371
+
372
+ export type CommandExecResult = {
373
+ readonly stdout: string
374
+ readonly stderr: string
375
+ readonly exitCode: number
376
+ }
377
+
378
+ export type ContainerCommandContext = CommandStreams & {
379
+ // The plugin name (e.g. `'my-utilities'`), NOT the command name. Matches
380
+ // `PluginContext.name`. Use the command's own static name if you need it.
381
+ readonly name: string
382
+ readonly version: string | undefined
383
+ readonly agentDir: string
384
+ readonly logger: PluginLogger
385
+ readonly permissions: PermissionService
386
+ // Caller's origin (cron job, TUI op, parent session). Drives permission
387
+ // resolution inside the command. Dispatcher refuses to run without one.
388
+ readonly origin: SessionOrigin
389
+ readonly signal: AbortSignal
390
+ readonly prompt: (text: string) => Promise<string>
391
+ readonly subagent: (name: string, payload?: unknown) => Promise<void>
392
+ readonly exec: (cmd: TemplateStringsArray, ...values: unknown[]) => Promise<CommandExecResult>
393
+ }
394
+
395
+ export type HostCommandContext = CommandStreams & {
396
+ // The plugin name, NOT the command name. See `ContainerCommandContext.name`.
397
+ readonly name: string
398
+ readonly version: string | undefined
399
+ // Host path of the agent folder (e.g. the absolute path to the agent
400
+ // folder), NOT `/agent`.
401
+ readonly agentDir: string
402
+ readonly logger: PluginLogger
403
+ readonly signal: AbortSignal
404
+ }
405
+
406
+ export type EitherCommandContext = CommandStreams & {
407
+ // The plugin name, NOT the command name. See `ContainerCommandContext.name`.
408
+ readonly name: string
409
+ readonly version: string | undefined
410
+ // Resolves to `/agent` in container, host path on host — same author code.
411
+ readonly agentDir: string
412
+ readonly logger: PluginLogger
413
+ readonly signal: AbortSignal
414
+ }
@@ -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,7 +273,48 @@ export async function startAgent({
244
273
  const cronConsumer = createCronConsumer({
245
274
  stream,
246
275
  cwd,
247
- createSessionForCron: async (job) => {
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
+ },
317
+ createSessionForCron: async (job, refOverride) => {
248
318
  const snap = pluginRuntime.get()
249
319
  const sessionManager = SessionManager.create(cwd, sessionFactory.sessionDir())
250
320
  const sessionId = sessionManager.getSessionId()
@@ -266,6 +336,7 @@ export async function startAgent({
266
336
  channelRouter: channelManager.router,
267
337
  origin: cronOrigin,
268
338
  permissions: pluginsLoaded.permissions,
339
+ ...(refOverride !== undefined ? { refOverride } : {}),
269
340
  ...(snap.hasAnyPluginContent
270
341
  ? {
271
342
  plugins: {
@@ -310,10 +381,15 @@ export async function startAgent({
310
381
  )
311
382
  }
312
383
 
384
+ const tunnelBridge: TunnelBridge = createTunnelBridge({ stream, channelManager })
385
+
313
386
  reloadRegistry.register(createChannelsReloadable({ manager: channelManager }))
314
387
  await channelManager.start()
315
388
 
316
- pluginsLoaded.setSpawnSubagent(async (name, payload, options) => {
389
+ // Captured separately from setSpawnSubagent so both the plugin context and
390
+ // the plugin-command runner can dispatch through the same path. The setter
391
+ // returns void, so without this local binding we couldn't reuse the fn.
392
+ const dispatchSpawnSubagent: CommandSpawnSubagent = async (name, payload, options) => {
317
393
  // Resolve the spawning session's role from its origin so the subagent
318
394
  // inherits it. Callers (hooks like session.idle) pass the parent origin
319
395
  // verbatim; we look up the role rather than letting the caller forge it,
@@ -333,7 +409,8 @@ export async function startAgent({
333
409
  ...(spawnedByRole !== undefined ? { spawnedByRole } : {}),
334
410
  ...(options?.spawnedByOrigin !== undefined ? { spawnedByOrigin: options.spawnedByOrigin } : {}),
335
411
  })
336
- })
412
+ }
413
+ pluginsLoaded.setSpawnSubagent(dispatchSpawnSubagent)
337
414
  pluginsLoaded.markBooted()
338
415
 
339
416
  if (pluginsLoaded.loadedPlugins.length > 0) {
@@ -365,6 +442,17 @@ export async function startAgent({
365
442
  : undefined
366
443
  const containerBrokerOpt = containerBroker ? { containerBroker } : {}
367
444
 
445
+ const commandRunnerFactory = (outbound: import('@/server/command-runner').CommandOutbound): CommandRunner =>
446
+ createCommandRunner({
447
+ pluginRuntime,
448
+ permissions: pluginsLoaded.permissions,
449
+ spawnSubagent: dispatchSpawnSubagent,
450
+ agentDir: cwd,
451
+ runtimeVersion: CLI_VERSION,
452
+ containerName,
453
+ outbound,
454
+ })
455
+
368
456
  const server = createServer({
369
457
  port,
370
458
  reloadAll: () => reloadRegistry.reloadAll(),
@@ -375,12 +463,21 @@ export async function startAgent({
375
463
  agentDir: cwd,
376
464
  pluginRuntime,
377
465
  claimController,
466
+ commandRunnerFactory,
467
+ tunnelManager,
378
468
  ...containerNameOpt,
379
469
  ...runtimeVersionOpt,
380
470
  ...tuiTokenOpt,
381
471
  ...containerBrokerOpt,
382
472
  }).start()
383
473
 
474
+ // Tunnel manager starts AFTER the WS server is up so a slow/hanging
475
+ // provider (PR 2's cloudflared first-URL wait) cannot block TUI, reload,
476
+ // or channel adapter availability. External providers resolve URLs
477
+ // synchronously; future managed providers will resolve asynchronously
478
+ // and broadcast URL events when ready.
479
+ await tunnelManager.start()
480
+
384
481
  let stopped = false
385
482
  const stop = async () => {
386
483
  if (stopped) return
@@ -390,6 +487,8 @@ export async function startAgent({
390
487
  subagentConsumer.stop()
391
488
  server.stop(true)
392
489
  void disposeMaterializedSkills(pluginRuntime)
490
+ tunnelBridge.stop()
491
+ await tunnelManager.stop()
393
492
  await channelManager.stop()
394
493
  }
395
494
 
@@ -436,6 +535,15 @@ function buildLocalTuiUrl(port: number, token: string | null): string {
436
535
  return url.toString()
437
536
  }
438
537
 
538
+ function resolveTunnelUrlForChannel(channelName: string, tunnelManager: TunnelManager): string | null {
539
+ const tunnel = getConfig().tunnels.find((entry) => entry.for.kind === 'channel' && entry.for.name === channelName)
540
+ return tunnel ? tunnelManager.urlFor(tunnel.name) : null
541
+ }
542
+
543
+ function isTunnelConfiguredForChannel(channelName: string): boolean {
544
+ return getConfig().tunnels.some((entry) => entry.for.kind === 'channel' && entry.for.name === channelName)
545
+ }
546
+
439
547
  async function disposeMaterializedSkills(pluginRuntime: PluginRuntime): Promise<void> {
440
548
  const pending = pluginRuntime.drainPendingDisposal()
441
549
  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>