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
@@ -1,12 +1,21 @@
1
1
  export type InstallResult = { ok: true } | { ok: false; reason: string }
2
2
 
3
+ export type InstallRunnerOptions = {
4
+ // Append `--force` to the bun install argv to bypass the cache for
5
+ // `file:` / `link:` deps. Bun treats name+version of a `file:` dep as a
6
+ // cache hit even after the source on disk has changed, so changes to a
7
+ // locally-linked typeclaw never propagate into <agent>/node_modules until
8
+ // either the typeclaw version is bumped or the install is forced.
9
+ force?: boolean
10
+ }
11
+
3
12
  // Signature for the function `runInit` uses to materialize the agent folder's
4
13
  // dependencies. Exposed as a named type so callers (and tests) can pass their
5
14
  // own stub without re-declaring the shape, mirroring `HatchRunner` and
6
15
  // `KakaotalkAuthRunner` in `./index.ts`.
7
- export type InstallRunner = (cwd: string) => Promise<InstallResult>
16
+ export type InstallRunner = (cwd: string, opts?: InstallRunnerOptions) => Promise<InstallResult>
8
17
 
9
- export async function runBunInstall(cwd: string): Promise<InstallResult> {
18
+ export async function runBunInstall(cwd: string, opts?: InstallRunnerOptions): Promise<InstallResult> {
10
19
  const bun = (globalThis as { Bun?: { spawn: typeof Bun.spawn } }).Bun
11
20
  if (!bun) return { ok: false, reason: 'bun runtime not available' }
12
21
  try {
@@ -21,7 +30,12 @@ export async function runBunInstall(cwd: string): Promise<InstallResult> {
21
30
  // the bug are non-trivial. Hoisted is the fallback strategy bun shipped
22
31
  // before 1.3 — slightly slower for huge monorepos, indistinguishable
23
32
  // for an agent folder, and not affected by the bug.
24
- cmd: ['bun', 'install', '--linker=hoisted'],
33
+ //
34
+ // `--force` is conditional: it bypasses the package cache so file:/link:
35
+ // deps re-copy their current on-disk source into node_modules. Bun's
36
+ // file-dep cache is keyed on name+version, so without --force, edits to
37
+ // a `file:..` typeclaw never reach the container after the first install.
38
+ cmd: opts?.force ? ['bun', 'install', '--linker=hoisted', '--force'] : ['bun', 'install', '--linker=hoisted'],
25
39
  cwd,
26
40
  stdout: 'pipe',
27
41
  stderr: 'pipe',
@@ -8,6 +8,7 @@ import type { ChannelKind } from './index'
8
8
  const CHANNEL_LABELS: Record<ChannelKind, string> = {
9
9
  'slack-bot': 'Slack',
10
10
  'discord-bot': 'Discord',
11
+ github: 'GitHub',
11
12
  'telegram-bot': 'Telegram',
12
13
  kakaotalk: 'KakaoTalk',
13
14
  }
@@ -25,9 +26,17 @@ export type RunOwnerClaimOptions = {
25
26
  // normal hatching path so the TUI still opens — the operator can run
26
27
  // `typeclaw role claim` later.
27
28
  export async function runOwnerClaim({ url, configuredChannels }: RunOwnerClaimOptions): Promise<void> {
28
- if (configuredChannels.length === 0) return
29
+ // GitHub has no DM affordance, and the github adapter doesn't yet route
30
+ // claim codes (the `typeclaw role claim` CLI explicitly lists only the
31
+ // four chat adapters as --channel values). Owner authorization for github
32
+ // is handled by the `roles.member.match[]` repo allowlist that
33
+ // runAddChannel writes during init. Filtering here keeps the auto-claim
34
+ // working for chat channels mixed with github, and skips the flow
35
+ // entirely when github is the only wired channel.
36
+ const claimable = configuredChannels.filter((c) => c !== 'github')
37
+ if (claimable.length === 0) return
29
38
 
30
- const channelList = configuredChannels.map((c) => CHANNEL_LABELS[c] ?? c).join(', ')
39
+ const channelList = claimable.map((c) => CHANNEL_LABELS[c] ?? c).join(', ')
31
40
 
32
41
  const proceed = await confirm({
33
42
  message: `Claim owner role on ${channelList} now?`,
@@ -37,7 +37,12 @@ export const BUILTIN_ROLES: Readonly<Record<BuiltinRoleName, BuiltinRoleSpec>> =
37
37
  },
38
38
  trusted: {
39
39
  match: [],
40
- permissions: [CORE_PERMISSIONS.channelRespond, CORE_PERMISSIONS.cronSchedule, 'security.bypass.secretExfilBash'],
40
+ permissions: [
41
+ CORE_PERMISSIONS.channelRespond,
42
+ CORE_PERMISSIONS.cronSchedule,
43
+ 'security.bypass.secretExfilBash',
44
+ 'security.bypass.gitExfil',
45
+ ],
41
46
  },
42
47
  member: {
43
48
  match: [],
@@ -16,7 +16,7 @@
16
16
  // error messages with typo suggestions; a single big regex would only ever
17
17
  // say "didn't match".
18
18
 
19
- export const PLATFORMS = ['slack', 'discord', 'telegram', 'kakao'] as const
19
+ export const PLATFORMS = ['slack', 'discord', 'telegram', 'kakao', 'github'] as const
20
20
  export type Platform = (typeof PLATFORMS)[number]
21
21
 
22
22
  const SUBAGENT_NAME = /^[a-z][a-z0-9-]*$/
@@ -50,7 +50,7 @@ export type ParseMatchRuleResult = { ok: true; value: MatchRule } | { ok: false;
50
50
  // this to `author:` only, the JSON schema would reject typos with a generic
51
51
  // "did not match pattern" error and the user would lose the actionable hint.
52
52
  export const MATCH_RULE_REGEX_SOURCE =
53
- '^(tui|cron|subagent(:[a-z][a-z0-9-]*)?|\\*|(slack|discord|telegram|kakao):[^\\s]+)(\\s+[a-zA-Z][a-zA-Z0-9_]*:[^\\s]+)*$'
53
+ '^(tui|cron|subagent(:[a-z][a-z0-9-]*)?|\\*|(slack|discord|telegram|kakao|github):[^\\s]+)(\\s+[a-zA-Z][a-zA-Z0-9_]*:[^\\s]+)*$'
54
54
 
55
55
  export function parseMatchRule(input: string): ParseMatchRuleResult {
56
56
  if (input !== input.trim() || input.length === 0) {
@@ -138,6 +138,8 @@ function parseChannelScope(platform: Platform, rest: string, author: string | un
138
138
  return { ok: true, value: buildChannelRule(platform, { author }) }
139
139
  }
140
140
 
141
+ if (platform === 'github') return parseGithubChannelScope(rest, author)
142
+
141
143
  // Bucket scopes: `dm/*`, `dm/<id>`, `group/*`, `open/*`. Slack's `im` is
142
144
  // renamed to `dm`; that mapping is enforced by the legacy-prefix table at
143
145
  // the top of parseMatchRule for unprefixed forms — here we just refuse the
@@ -203,6 +205,26 @@ function parseChannelScope(platform: Platform, rest: string, author: string | un
203
205
  return { ok: true, value: buildChannelRule(platform, { workspace: rest, author }) }
204
206
  }
205
207
 
208
+ function parseGithubChannelScope(rest: string, author: string | undefined): ParseMatchRuleResult {
209
+ const [owner, repo, ...chatParts] = rest.split('/')
210
+ if (owner === undefined || owner === '' || repo === undefined || repo === '') {
211
+ return { ok: false, error: "github scope requires 'owner/repo' format" }
212
+ }
213
+ if (repo === '*') {
214
+ return {
215
+ ok: false,
216
+ error: `'github:${owner}/*' is not supported; use 'github:${owner}/repo' for a specific repo or 'github:*' for all github events`,
217
+ }
218
+ }
219
+ const workspace = `${owner}/${repo}`
220
+ if (chatParts.length === 0) return { ok: true, value: buildChannelRule('github', { workspace, author }) }
221
+ const chat = chatParts.join('/')
222
+ if (chat === '' || chat.includes('/')) {
223
+ return { ok: false, error: "github chat scope must be a single segment like 'issue:42'" }
224
+ }
225
+ return { ok: true, value: buildChannelRule('github', { workspace, chat, author }) }
226
+ }
227
+
206
228
  function buildChannelRule(
207
229
  platform: Platform,
208
230
  parts: {
@@ -5,6 +5,7 @@ import type { MatchRule, Platform } from './match-rule'
5
5
  const ADAPTER_TO_PLATFORM: Record<AdapterId, Platform> = {
6
6
  'slack-bot': 'slack',
7
7
  'discord-bot': 'discord',
8
+ github: 'github',
8
9
  'telegram-bot': 'telegram',
9
10
  kakaotalk: 'kakao',
10
11
  }
@@ -1,16 +1,29 @@
1
1
  import type { z } from 'zod'
2
2
 
3
- import type { BuiltinToolRef, DefinedPlugin, PluginContext, PluginExports, Subagent, Tool } from './types'
3
+ import type {
4
+ BuiltinToolRef,
5
+ ContainerCommand,
6
+ DefinedPlugin,
7
+ EitherCommand,
8
+ HostCommand,
9
+ PluginCommand,
10
+ PluginContext,
11
+ PluginExports,
12
+ Subagent,
13
+ Tool,
14
+ } from './types'
4
15
 
5
16
  type DefinePluginSpec<S extends z.ZodType<unknown> | undefined> =
6
17
  S extends z.ZodType<infer T>
7
18
  ? {
8
19
  configSchema: S
9
20
  permissions?: readonly string[]
21
+ commands?: Record<string, PluginCommand>
10
22
  plugin: (ctx: PluginContext<T>) => Promise<PluginExports>
11
23
  }
12
24
  : {
13
25
  permissions?: readonly string[]
26
+ commands?: Record<string, PluginCommand>
14
27
  plugin: (ctx: PluginContext<unknown>) => Promise<PluginExports>
15
28
  }
16
29
 
@@ -28,6 +41,34 @@ export function defineSubagent<P>(subagent: Subagent<P>): Subagent<P> {
28
41
  return subagent
29
42
  }
30
43
 
44
+ type ContainerCommandSpec<S extends z.ZodObject<z.ZodRawShape> | undefined> =
45
+ S extends z.ZodObject<z.ZodRawShape>
46
+ ? Omit<ContainerCommand<z.infer<S>>, 'args'> & { args: S }
47
+ : Omit<ContainerCommand<unknown>, 'args'> & { args?: undefined }
48
+
49
+ type HostCommandSpec<S extends z.ZodObject<z.ZodRawShape> | undefined> =
50
+ S extends z.ZodObject<z.ZodRawShape>
51
+ ? Omit<HostCommand<z.infer<S>>, 'args'> & { args: S }
52
+ : Omit<HostCommand<unknown>, 'args'> & { args?: undefined }
53
+
54
+ type EitherCommandSpec<S extends z.ZodObject<z.ZodRawShape> | undefined> =
55
+ S extends z.ZodObject<z.ZodRawShape>
56
+ ? Omit<EitherCommand<z.infer<S>>, 'args'> & { args: S }
57
+ : Omit<EitherCommand<unknown>, 'args'> & { args?: undefined }
58
+
59
+ export function defineCommand<S extends z.ZodObject<z.ZodRawShape> | undefined = undefined>(
60
+ cmd: ContainerCommandSpec<S>,
61
+ ): ContainerCommand<S extends z.ZodObject<z.ZodRawShape> ? z.infer<S> : unknown>
62
+ export function defineCommand<S extends z.ZodObject<z.ZodRawShape> | undefined = undefined>(
63
+ cmd: HostCommandSpec<S>,
64
+ ): HostCommand<S extends z.ZodObject<z.ZodRawShape> ? z.infer<S> : unknown>
65
+ export function defineCommand<S extends z.ZodObject<z.ZodRawShape> | undefined = undefined>(
66
+ cmd: EitherCommandSpec<S>,
67
+ ): EitherCommand<S extends z.ZodObject<z.ZodRawShape> ? z.infer<S> : unknown>
68
+ export function defineCommand(cmd: PluginCommand): PluginCommand {
69
+ return cmd
70
+ }
71
+
31
72
  export const readTool: BuiltinToolRef = { __builtinTool: 'read' }
32
73
  export const bashTool: BuiltinToolRef = { __builtinTool: 'bash' }
33
74
  export const editTool: BuiltinToolRef = { __builtinTool: 'edit' }
@@ -1,8 +1,9 @@
1
1
  export {
2
2
  bashTool,
3
- defineTool,
3
+ defineCommand,
4
4
  definePlugin,
5
5
  defineSubagent,
6
+ defineTool,
6
7
  editTool,
7
8
  findTool,
8
9
  grepTool,
@@ -13,14 +14,24 @@ export {
13
14
 
14
15
  export type {
15
16
  BuiltinToolRef,
17
+ CommandExecResult,
18
+ CommandStreams,
19
+ ContainerCommand,
20
+ ContainerCommandContext,
16
21
  ContentPart,
17
22
  DefinedPlugin,
23
+ EitherCommand,
24
+ EitherCommandContext,
18
25
  HookContext,
19
26
  HookName,
20
27
  Hooks,
28
+ HostCommand,
29
+ HostCommandContext,
21
30
  PluginCheckResult,
22
31
  PluginCheckStatus,
32
+ PluginCommand,
23
33
  PluginContext,
34
+ CronHandlerContext,
24
35
  PluginCronJob,
25
36
  PluginDoctorCheck,
26
37
  PluginDoctorContext,
@@ -28,6 +39,7 @@ export type {
28
39
  PluginExports,
29
40
  PluginFixResult,
30
41
  PluginFixSuggestion,
42
+ PluginHandlerCronJob,
31
43
  PluginLogger,
32
44
  PluginPromptCronJob,
33
45
  PluginSkill,
@@ -61,12 +73,15 @@ export { loadPluginEntry, derivePluginNameFromPackage } from './loader'
61
73
  export { materializeSkills, type MaterializedSkills, type SkillEntry } from './skills'
62
74
  export {
63
75
  buildPluginCronGlobalId,
76
+ RESERVED_COMMAND_NAMES,
77
+ validateCommandDeclaration,
64
78
  type PluginRegistry,
79
+ type RegisteredCommand,
65
80
  type RegisteredCronJob,
66
81
  type RegisteredDoctorCheck,
82
+ type RegisteredSkillDir,
83
+ type RegisteredSkillEntry,
67
84
  type RegisteredSubagent,
68
85
  type RegisteredTool,
69
- type RegisteredSkillEntry,
70
- type RegisteredSkillDir,
71
86
  } from './registry'
72
87
  export { createHookBus, type HookBus } from './hooks'
@@ -117,6 +117,7 @@ export async function loadPlugins(opts: LoadPluginsOptions): Promise<LoadPlugins
117
117
  pluginName: resolved.name,
118
118
  logger,
119
119
  exports,
120
+ ...(resolved.defined.commands !== undefined ? { commands: resolved.defined.commands } : {}),
120
121
  registry,
121
122
  hooks,
122
123
  agentDir: opts.agentDir,
@@ -166,6 +167,7 @@ export function summarizeLoaded(loaded: LoadPluginsResult['loadedPlugins'], regi
166
167
  `${registry.skills.length} skill(s)`,
167
168
  `${registry.skillsDirs.length} skills dir(s)`,
168
169
  `${registry.doctorChecks.length} doctor check(s)`,
170
+ `${registry.commands.length} command(s)`,
169
171
  ].join(', ')
170
172
  return `${loaded.length} plugin(s): ${head} [${counts}]`
171
173
  }
@@ -1,9 +1,11 @@
1
1
  import { existsSync } from 'node:fs'
2
2
 
3
+ import { BUILTIN_COMMAND_NAMES } from '@/cli/builtins'
3
4
  import type { CronJob, PromptJob } from '@/cron'
4
5
 
5
6
  import type { HookBus } from './hooks'
6
7
  import type {
8
+ PluginCommand,
7
9
  PluginCronJob,
8
10
  PluginDoctorCheck,
9
11
  PluginExports,
@@ -12,6 +14,7 @@ import type {
12
14
  Subagent,
13
15
  Tool,
14
16
  } from './types'
17
+ import { isPrimitiveZodObject } from './zod-introspect'
15
18
 
16
19
  export type RegisteredTool = { pluginName: string; toolName: string; tool: Tool<any>; logger: PluginLogger }
17
20
  export type RegisteredSubagent = { pluginName: string; subagentName: string; subagent: Subagent<any> }
@@ -25,6 +28,12 @@ export type RegisteredDoctorCheck = {
25
28
  logger: PluginLogger
26
29
  check: PluginDoctorCheck
27
30
  }
31
+ export type RegisteredCommand = {
32
+ pluginName: string
33
+ commandName: string
34
+ command: PluginCommand
35
+ logger: PluginLogger
36
+ }
28
37
 
29
38
  export type PluginRegistry = {
30
39
  tools: RegisteredTool[]
@@ -33,18 +42,28 @@ export type PluginRegistry = {
33
42
  skills: RegisteredSkillEntry[]
34
43
  skillsDirs: RegisteredSkillDir[]
35
44
  doctorChecks: RegisteredDoctorCheck[]
45
+ commands: RegisteredCommand[]
36
46
  }
37
47
 
38
48
  export type RegisterContributionsOptions = {
39
49
  pluginName: string
40
50
  logger: PluginLogger
41
51
  exports: PluginExports
52
+ // Static commands declared on `DefinedPlugin.commands`. Passed alongside
53
+ // `exports` because they live outside the factory's return value.
54
+ commands?: Record<string, PluginCommand>
42
55
  registry: PluginRegistry
43
56
  hooks: HookBus
44
57
  agentDir: string
45
58
  pluginConfig: unknown
46
59
  }
47
60
 
61
+ const COMMAND_NAME_REGEX = /^[a-z][a-z0-9-]*$/
62
+
63
+ // CLI subcommands plugins MUST NOT shadow. Derived from BUILTIN_COMMAND_NAMES
64
+ // so cli/index.ts and registry.ts cannot drift apart.
65
+ export const RESERVED_COMMAND_NAMES: ReadonlySet<string> = new Set(BUILTIN_COMMAND_NAMES)
66
+
48
67
  export function buildPluginCronGlobalId(pluginName: string, localId: string): string {
49
68
  return `__plugin_${pluginName}_${localId}`
50
69
  }
@@ -127,6 +146,19 @@ export function registerContributions(opts: RegisterContributionsOptions): void
127
146
  registry.doctorChecks.push({ pluginName, checkName, pluginConfig, logger, check })
128
147
  }
129
148
  }
149
+
150
+ if (opts.commands) {
151
+ for (const [commandName, command] of Object.entries(opts.commands)) {
152
+ validateCommandDeclaration(pluginName, commandName, command)
153
+ const conflict = registry.commands.find((c) => c.commandName === commandName)
154
+ if (conflict) {
155
+ throw new Error(
156
+ `plugin ${pluginName}: command "${commandName}" already registered by plugin ${conflict.pluginName}`,
157
+ )
158
+ }
159
+ registry.commands.push({ pluginName, commandName, command, logger })
160
+ }
161
+ }
130
162
  }
131
163
 
132
164
  export function discardRegistrationsBy(pluginName: string, registry: PluginRegistry, hooks: HookBus): void {
@@ -136,11 +168,20 @@ export function discardRegistrationsBy(pluginName: string, registry: PluginRegis
136
168
  registry.skills = registry.skills.filter((s) => s.pluginName !== pluginName)
137
169
  registry.skillsDirs = registry.skillsDirs.filter((d) => d.pluginName !== pluginName)
138
170
  registry.doctorChecks = registry.doctorChecks.filter((d) => d.pluginName !== pluginName)
171
+ registry.commands = registry.commands.filter((c) => c.pluginName !== pluginName)
139
172
  hooks.unregisterAll(pluginName)
140
173
  }
141
174
 
142
175
  export function emptyRegistry(): PluginRegistry {
143
- return { tools: [], subagents: [], cronJobs: [], skills: [], skillsDirs: [], doctorChecks: [] }
176
+ return {
177
+ tools: [],
178
+ subagents: [],
179
+ cronJobs: [],
180
+ skills: [],
181
+ skillsDirs: [],
182
+ doctorChecks: [],
183
+ commands: [],
184
+ }
144
185
  }
145
186
 
146
187
  function assertNotEmpty(kind: string, value: string, pluginName: string): void {
@@ -149,6 +190,36 @@ function assertNotEmpty(kind: string, value: string, pluginName: string): void {
149
190
  }
150
191
  }
151
192
 
193
+ function assertValidCommandArgsSchema(pluginName: string, commandName: string, command: PluginCommand): void {
194
+ if (command.args === undefined) return
195
+ if (!isPrimitiveZodObject(command.args)) {
196
+ throw new Error(
197
+ `plugin ${pluginName}: command "${commandName}" args must be a z.object({...}) with primitive (string/number/boolean) leaves`,
198
+ )
199
+ }
200
+ }
201
+
202
+ // Reuses the same checks `registerContributions` runs at boot, so host-stage
203
+ // discovery and runtime registration agree on what is a valid command. Throws
204
+ // a precise error referencing the plugin and command; callers translate the
205
+ // error into a discovery `loadError` rather than failing the whole CLI.
206
+ export function validateCommandDeclaration(pluginName: string, commandName: string, command: PluginCommand): void {
207
+ if (commandName.length === 0) {
208
+ throw new Error(`plugin ${pluginName}: empty command name`)
209
+ }
210
+ if (!COMMAND_NAME_REGEX.test(commandName)) {
211
+ throw new Error(
212
+ `plugin ${pluginName}: command "${commandName}" does not match ${COMMAND_NAME_REGEX.source} (lowercase letters, digits, dashes; must start with a letter)`,
213
+ )
214
+ }
215
+ if (RESERVED_COMMAND_NAMES.has(commandName)) {
216
+ throw new Error(
217
+ `plugin ${pluginName}: command "${commandName}" shadows a built-in typeclaw subcommand and cannot be registered`,
218
+ )
219
+ }
220
+ assertValidCommandArgsSchema(pluginName, commandName, command)
221
+ }
222
+
152
223
  function toCronJob(globalId: string, spec: PluginCronJob): CronJob {
153
224
  // Plugin-contributed jobs default to `owner` because they are part of the
154
225
  // agent's bundled (or operator-installed) runtime, not user-channel
@@ -171,12 +242,23 @@ function toCronJob(globalId: string, spec: PluginCronJob): CronJob {
171
242
  }
172
243
  return job
173
244
  }
245
+ if (spec.kind === 'exec') {
246
+ return {
247
+ id: globalId,
248
+ schedule: spec.schedule,
249
+ enabled: spec.enabled ?? true,
250
+ kind: 'exec',
251
+ command: spec.command,
252
+ scheduledByRole,
253
+ ...(spec.timezone !== undefined ? { timezone: spec.timezone } : {}),
254
+ }
255
+ }
174
256
  return {
175
257
  id: globalId,
176
258
  schedule: spec.schedule,
177
259
  enabled: spec.enabled ?? true,
178
- kind: 'exec',
179
- command: spec.command,
260
+ kind: 'handler',
261
+ handler: spec.handler,
180
262
  scheduledByRole,
181
263
  ...(spec.timezone !== undefined ? { timezone: spec.timezone } : {}),
182
264
  }
@@ -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,91 @@ export type PluginFixResult = {
267
318
  export type DefinedPlugin<TConfig = never> = {
268
319
  readonly configSchema?: z.ZodType<TConfig>
269
320
  readonly permissions?: readonly string[]
321
+ // Declared by-value (not built inside the factory) so the host-stage CLI
322
+ // can dispatch commands without booting plugin runtime state.
323
+ readonly commands?: Record<string, PluginCommand>
270
324
  readonly plugin: (ctx: PluginContext<TConfig>) => Promise<PluginExports>
271
325
  }
326
+
327
+ // `surface` controls where a plugin command may run: `'container'` requires
328
+ // the agent runtime (prompt/subagent/exec); `'host'` runs on the user's
329
+ // machine with no agent runtime; `'either'` accepts the intersection ctx
330
+ // and runs on whichever stage the user invoked it from.
331
+ export type PluginCommand = ContainerCommand | HostCommand | EitherCommand
332
+
333
+ export type ContainerCommand<A = unknown> = {
334
+ readonly surface: 'container'
335
+ readonly description: string
336
+ // v1 constraint: `z.object({...})` with primitive (string/number/boolean/
337
+ // literal/enum) leaves so `--help` can render `--<name>=<type>`.
338
+ readonly args?: z.ZodObject<z.ZodRawShape>
339
+ readonly permissions?: readonly string[]
340
+ // When true, runtime spawns a fresh Bun subprocess instead of dispatching
341
+ // in-process. Costs ~150ms cold-start; trade for isolation from the agent.
342
+ readonly isolated?: boolean
343
+ readonly run: (ctx: ContainerCommandContext, args: A) => Promise<number>
344
+ }
345
+
346
+ export type HostCommand<A = unknown> = {
347
+ readonly surface: 'host'
348
+ readonly description: string
349
+ readonly args?: z.ZodObject<z.ZodRawShape>
350
+ readonly run: (ctx: HostCommandContext, args: A) => Promise<number>
351
+ }
352
+
353
+ export type EitherCommand<A = unknown> = {
354
+ readonly surface: 'either'
355
+ readonly description: string
356
+ readonly args?: z.ZodObject<z.ZodRawShape>
357
+ readonly run: (ctx: EitherCommandContext, args: A) => Promise<number>
358
+ }
359
+
360
+ export type CommandStreams = {
361
+ readonly stdin: ReadableStream<Uint8Array>
362
+ readonly stdout: WritableStream<Uint8Array>
363
+ readonly stderr: WritableStream<Uint8Array>
364
+ }
365
+
366
+ export type CommandExecResult = {
367
+ readonly stdout: string
368
+ readonly stderr: string
369
+ readonly exitCode: number
370
+ }
371
+
372
+ export type ContainerCommandContext = CommandStreams & {
373
+ // The plugin name (e.g. `'my-utilities'`), NOT the command name. Matches
374
+ // `PluginContext.name`. Use the command's own static name if you need it.
375
+ readonly name: string
376
+ readonly version: string | undefined
377
+ readonly agentDir: string
378
+ readonly logger: PluginLogger
379
+ readonly permissions: PermissionService
380
+ // Caller's origin (cron job, TUI op, parent session). Drives permission
381
+ // resolution inside the command. Dispatcher refuses to run without one.
382
+ readonly origin: SessionOrigin
383
+ readonly signal: AbortSignal
384
+ readonly prompt: (text: string) => Promise<string>
385
+ readonly subagent: (name: string, payload?: unknown) => Promise<void>
386
+ readonly exec: (cmd: TemplateStringsArray, ...values: unknown[]) => Promise<CommandExecResult>
387
+ }
388
+
389
+ export type HostCommandContext = CommandStreams & {
390
+ // The plugin name, NOT the command name. See `ContainerCommandContext.name`.
391
+ readonly name: string
392
+ readonly version: string | undefined
393
+ // Host path of the agent folder (e.g. the absolute path to the agent
394
+ // folder), NOT `/agent`.
395
+ readonly agentDir: string
396
+ readonly logger: PluginLogger
397
+ readonly signal: AbortSignal
398
+ }
399
+
400
+ export type EitherCommandContext = CommandStreams & {
401
+ // The plugin name, NOT the command name. See `ContainerCommandContext.name`.
402
+ readonly name: string
403
+ readonly version: string | undefined
404
+ // Resolves to `/agent` in container, host path on host — same author code.
405
+ readonly agentDir: string
406
+ readonly logger: PluginLogger
407
+ readonly signal: AbortSignal
408
+ }