typeclaw 0.1.5 → 0.1.6

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 (128) hide show
  1. package/README.md +14 -12
  2. package/auth.schema.json +41 -0
  3. package/cron.schema.json +8 -0
  4. package/package.json +1 -1
  5. package/secrets.schema.json +41 -0
  6. package/src/agent/auth.ts +45 -22
  7. package/src/agent/index.ts +189 -19
  8. package/src/agent/multimodal/index.ts +12 -0
  9. package/src/agent/multimodal/look-at.ts +185 -0
  10. package/src/agent/multimodal/looker.ts +145 -0
  11. package/src/agent/plugin-tools.ts +30 -1
  12. package/src/agent/session-origin.ts +194 -46
  13. package/src/agent/subagents.ts +57 -1
  14. package/src/agent/system-prompt.ts +1 -1
  15. package/src/agent/tool-result-budget.ts +121 -0
  16. package/src/bundled-plugins/backup/index.ts +23 -8
  17. package/src/bundled-plugins/backup/runner.ts +22 -0
  18. package/src/bundled-plugins/memory/README.md +7 -4
  19. package/src/bundled-plugins/memory/append-tool.ts +87 -61
  20. package/src/bundled-plugins/memory/dreaming.ts +23 -9
  21. package/src/bundled-plugins/memory/find-entry-tool.ts +62 -0
  22. package/src/bundled-plugins/memory/fragment-parser.ts +19 -44
  23. package/src/bundled-plugins/memory/index.ts +91 -8
  24. package/src/bundled-plugins/memory/load-memory.ts +74 -34
  25. package/src/bundled-plugins/memory/memory-logger.ts +72 -29
  26. package/src/bundled-plugins/memory/migration.ts +276 -0
  27. package/src/bundled-plugins/memory/stream-events.ts +55 -0
  28. package/src/bundled-plugins/memory/stream-io.ts +63 -0
  29. package/src/bundled-plugins/memory/watermark.ts +48 -8
  30. package/src/bundled-plugins/security/index.ts +103 -10
  31. package/src/bundled-plugins/security/permissions.ts +12 -0
  32. package/src/bundled-plugins/security/policies/git-exfil.ts +51 -18
  33. package/src/bundled-plugins/tool-result-cap/README.md +9 -4
  34. package/src/bundled-plugins/tool-result-cap/cap-jsonl.ts +115 -0
  35. package/src/bundled-plugins/tool-result-cap/cap-result.ts +25 -13
  36. package/src/bundled-plugins/tool-result-cap/index.ts +16 -2
  37. package/src/channels/adapters/discord-bot-classify.ts +2 -6
  38. package/src/channels/adapters/discord-bot.ts +4 -45
  39. package/src/channels/adapters/kakaotalk-classify.ts +3 -7
  40. package/src/channels/adapters/kakaotalk.ts +28 -47
  41. package/src/channels/adapters/slack-bot-classify.ts +2 -6
  42. package/src/channels/adapters/slack-bot.ts +4 -50
  43. package/src/channels/adapters/telegram-bot-classify.ts +8 -10
  44. package/src/channels/adapters/telegram-bot.ts +3 -16
  45. package/src/channels/index.ts +3 -2
  46. package/src/channels/manager.ts +15 -1
  47. package/src/channels/persistence.ts +44 -10
  48. package/src/channels/router.ts +228 -19
  49. package/src/channels/schema.ts +6 -156
  50. package/src/cli/channel.ts +200 -4
  51. package/src/cli/compose-usage.ts +182 -0
  52. package/src/cli/compose.ts +33 -0
  53. package/src/cli/hostd.ts +49 -1
  54. package/src/cli/index.ts +4 -0
  55. package/src/cli/init.ts +799 -319
  56. package/src/cli/model.ts +244 -0
  57. package/src/cli/provider.ts +404 -0
  58. package/src/cli/reload.ts +6 -1
  59. package/src/cli/role.ts +156 -0
  60. package/src/cli/run.ts +3 -1
  61. package/src/cli/tui.ts +8 -1
  62. package/src/cli/usage-args.ts +47 -0
  63. package/src/cli/usage.ts +97 -0
  64. package/src/compose/index.ts +1 -0
  65. package/src/compose/usage.ts +65 -0
  66. package/src/config/config.ts +385 -12
  67. package/src/config/index.ts +7 -0
  68. package/src/config/models-mutation.ts +200 -0
  69. package/src/config/providers-mutation.ts +250 -0
  70. package/src/config/providers.ts +141 -2
  71. package/src/config/reloadable.ts +15 -4
  72. package/src/container/index.ts +5 -0
  73. package/src/container/require-running.ts +33 -0
  74. package/src/container/start.ts +39 -58
  75. package/src/cron/consumer.ts +22 -2
  76. package/src/cron/index.ts +45 -4
  77. package/src/cron/schema.ts +104 -0
  78. package/src/doctor/checks.ts +50 -33
  79. package/src/git/system-commit.ts +103 -0
  80. package/src/hostd/daemon.ts +16 -0
  81. package/src/hostd/kakao-renewal-manager.ts +223 -0
  82. package/src/hostd/paths.ts +7 -0
  83. package/src/init/dockerfile.ts +32 -6
  84. package/src/init/index.ts +183 -62
  85. package/src/init/kakaotalk-auth.ts +18 -1
  86. package/src/init/models-dev.ts +26 -1
  87. package/src/init/run-owner-claim.ts +77 -0
  88. package/src/permissions/builtins.ts +70 -0
  89. package/src/permissions/grant.ts +99 -0
  90. package/src/permissions/index.ts +29 -0
  91. package/src/permissions/match-rule.ts +305 -0
  92. package/src/permissions/permissions.ts +196 -0
  93. package/src/permissions/resolve.ts +80 -0
  94. package/src/permissions/schema.ts +79 -0
  95. package/src/plugin/context.ts +8 -4
  96. package/src/plugin/define.ts +2 -0
  97. package/src/plugin/index.ts +2 -0
  98. package/src/plugin/manager.ts +41 -0
  99. package/src/plugin/registry.ts +9 -0
  100. package/src/plugin/types.ts +35 -1
  101. package/src/role-claim/client.ts +182 -0
  102. package/src/role-claim/code.ts +53 -0
  103. package/src/role-claim/controller.ts +194 -0
  104. package/src/role-claim/index.ts +19 -0
  105. package/src/role-claim/match-rule.ts +43 -0
  106. package/src/role-claim/pending.ts +100 -0
  107. package/src/run/channel-session-factory.ts +76 -5
  108. package/src/run/index.ts +55 -6
  109. package/src/secrets/encryption.ts +116 -0
  110. package/src/secrets/kakao-renewal.ts +248 -0
  111. package/src/secrets/kakao-store.ts +66 -7
  112. package/src/secrets/keys.ts +173 -0
  113. package/src/secrets/schema.ts +23 -0
  114. package/src/secrets/storage.ts +68 -0
  115. package/src/server/index.ts +122 -11
  116. package/src/shared/index.ts +4 -0
  117. package/src/shared/protocol.ts +27 -0
  118. package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +3 -3
  119. package/src/skills/typeclaw-config/SKILL.md +38 -64
  120. package/src/skills/typeclaw-memory/SKILL.md +1 -1
  121. package/src/skills/typeclaw-permissions/SKILL.md +166 -0
  122. package/src/stream/types.ts +7 -1
  123. package/src/usage/aggregate.ts +117 -0
  124. package/src/usage/format.ts +30 -0
  125. package/src/usage/index.ts +68 -0
  126. package/src/usage/report.ts +354 -0
  127. package/src/usage/scan.ts +186 -0
  128. package/typeclaw.schema.json +57 -45
@@ -0,0 +1,80 @@
1
+ import type { AdapterId } from '@/channels/schema'
2
+
3
+ import type { MatchRule, Platform } from './match-rule'
4
+
5
+ const ADAPTER_TO_PLATFORM: Record<AdapterId, Platform> = {
6
+ 'slack-bot': 'slack',
7
+ 'discord-bot': 'discord',
8
+ 'telegram-bot': 'telegram',
9
+ kakaotalk: 'kakao',
10
+ }
11
+
12
+ export type MatchableOrigin =
13
+ | { kind: 'tui'; sessionId?: string }
14
+ | { kind: 'cron'; jobId?: string }
15
+ | { kind: 'subagent'; subagent?: string }
16
+ | {
17
+ kind: 'channel'
18
+ adapter: AdapterId
19
+ workspace: string
20
+ chat: string
21
+ lastInboundAuthorId?: string
22
+ }
23
+
24
+ export function matchesOrigin(rule: MatchRule, origin: MatchableOrigin): boolean {
25
+ switch (rule.kind) {
26
+ case 'wildcard':
27
+ return origin.kind === 'channel'
28
+ case 'tui':
29
+ return origin.kind === 'tui'
30
+ case 'cron':
31
+ return origin.kind === 'cron'
32
+ case 'subagent':
33
+ if (origin.kind !== 'subagent') return false
34
+ if (rule.subagent === undefined) return true
35
+ return origin.subagent === rule.subagent
36
+ case 'channel':
37
+ return matchesChannel(rule, origin)
38
+ }
39
+ }
40
+
41
+ function matchesChannel(rule: Extract<MatchRule, { kind: 'channel' }>, origin: MatchableOrigin): boolean {
42
+ if (origin.kind !== 'channel') return false
43
+ if (ADAPTER_TO_PLATFORM[origin.adapter] !== rule.platform) return false
44
+
45
+ if (rule.bucket !== undefined) {
46
+ if (!matchesBucket(rule.bucket, origin)) return false
47
+ if (rule.chat !== undefined && rule.chat !== origin.chat) return false
48
+ } else {
49
+ if (rule.workspace !== undefined && rule.workspace !== origin.workspace) return false
50
+ if (rule.chat !== undefined && rule.chat !== origin.chat) return false
51
+ }
52
+
53
+ if (rule.author !== undefined && rule.author !== origin.lastInboundAuthorId) return false
54
+ return true
55
+ }
56
+
57
+ // DM and group buckets are inferred from the workspace/chat shape of the
58
+ // inbound origin. Different adapters mark DMs differently — Slack uses an
59
+ // `@dm` workspace marker today, Discord uses a `dm` workspace marker, and
60
+ // KakaoTalk preserves group/open/dm explicitly in its workspace field. We
61
+ // keep the heuristic narrow: a rule that says `slack:dm/*` matches only when
62
+ // the origin's workspace is `@dm` (Slack) or `dm` (Discord). KakaoTalk uses
63
+ // the workspace prefix itself.
64
+ function matchesBucket(
65
+ bucket: 'dm' | 'group' | 'open',
66
+ origin: Extract<MatchableOrigin, { kind: 'channel' }>,
67
+ ): boolean {
68
+ const platform = ADAPTER_TO_PLATFORM[origin.adapter]
69
+ if (platform === 'kakao') {
70
+ if (bucket === 'dm') return origin.workspace === '@kakao-dm'
71
+ if (bucket === 'group') return origin.workspace === '@kakao-group'
72
+ if (bucket === 'open') return origin.workspace === '@kakao-open'
73
+ return false
74
+ }
75
+ if (bucket !== 'dm') return false
76
+ if (platform === 'slack') return origin.workspace === '@dm'
77
+ if (platform === 'discord') return origin.workspace === 'dm' || origin.workspace === '@dm'
78
+ if (platform === 'telegram') return origin.workspace === 'dm' || origin.workspace.startsWith('@')
79
+ return false
80
+ }
@@ -0,0 +1,79 @@
1
+ import { z } from 'zod'
2
+
3
+ import { isBuiltinRoleName } from './builtins'
4
+ import { type MatchRule, MATCH_RULE_REGEX_SOURCE, parseMatchRule } from './match-rule'
5
+
6
+ // The `.regex()` is added so the generated JSON Schema emits a `pattern`
7
+ // for editor-time validation (catches typos like `tem:T0123` before the
8
+ // agent ever boots). The pattern is intentionally a permissive
9
+ // over-approximation of what `parseMatchRule` accepts; the parser owns
10
+ // the precise semantic errors (typo suggestions, redundant-form rejection,
11
+ // etc.) at boot time. Layering the two means editors flag obvious shape
12
+ // mistakes immediately and the parser flags the rest with actionable
13
+ // messages.
14
+ const matchRuleSchema: z.ZodType<MatchRule> = z
15
+ .string()
16
+ .regex(new RegExp(MATCH_RULE_REGEX_SOURCE), {
17
+ message: "match rule must look like 'tui' / 'slack:T0123' / 'discord:9999 author:U_X'",
18
+ })
19
+ .transform((raw, ctx) => {
20
+ const parsed = parseMatchRule(raw)
21
+ if (!parsed.ok) {
22
+ ctx.addIssue({ code: 'custom', message: parsed.error })
23
+ return z.NEVER
24
+ }
25
+ return parsed.value
26
+ })
27
+
28
+ export const MATCH_RULE_JSON_SCHEMA_PATTERN = MATCH_RULE_REGEX_SOURCE
29
+
30
+ const PERMISSION_NAME = /^[a-z][a-z0-9]*(\.[a-z][a-zA-Z0-9]*)+$/
31
+
32
+ const permissionSchema = z.string().min(1).regex(PERMISSION_NAME, {
33
+ message: "permission must be lowercase dotted form like 'cron.schedule' or 'security.bypass.foo'",
34
+ })
35
+
36
+ const roleConfigSchema = z
37
+ .object({
38
+ match: z.array(matchRuleSchema).default([]),
39
+ permissions: z.array(permissionSchema).optional(),
40
+ })
41
+ .strict()
42
+
43
+ export type RoleConfig = z.infer<typeof roleConfigSchema>
44
+
45
+ export const rolesConfigSchema = z.record(z.string(), roleConfigSchema).superRefine((roles, ctx) => {
46
+ for (const [name, role] of Object.entries(roles)) {
47
+ if (!isValidRoleName(name)) {
48
+ ctx.addIssue({
49
+ code: 'custom',
50
+ path: [name],
51
+ message: `role name '${name}' must match /^[a-z][a-z0-9-]*$/`,
52
+ })
53
+ continue
54
+ }
55
+ if (!isBuiltinRoleName(name)) {
56
+ if (role.permissions === undefined) {
57
+ ctx.addIssue({
58
+ code: 'custom',
59
+ path: [name, 'permissions'],
60
+ message: `custom role '${name}' must declare 'permissions' (built-in defaults apply only to built-in role names)`,
61
+ })
62
+ }
63
+ if (role.match.length === 0) {
64
+ ctx.addIssue({
65
+ code: 'custom',
66
+ path: [name, 'match'],
67
+ message: `custom role '${name}' must declare at least one 'match' rule`,
68
+ })
69
+ }
70
+ }
71
+ }
72
+ })
73
+
74
+ export type RolesConfig = z.infer<typeof rolesConfigSchema>
75
+
76
+ const ROLE_NAME = /^[a-z][a-z0-9-]*$/
77
+ function isValidRoleName(name: string): boolean {
78
+ return ROLE_NAME.test(name)
79
+ }
@@ -1,6 +1,8 @@
1
- import type { PluginContext, PluginLogger } from './types'
1
+ import type { PermissionService } from '@/permissions'
2
2
 
3
- export type SpawnSubagentFn = (name: string, payload?: unknown) => Promise<void>
3
+ import type { PluginContext, PluginLogger, SpawnSubagentOptions } from './types'
4
+
5
+ export type SpawnSubagentFn = (name: string, payload?: unknown, options?: SpawnSubagentOptions) => Promise<void>
4
6
 
5
7
  export type CreatePluginContextOptions<TConfig> = {
6
8
  name: string
@@ -8,6 +10,7 @@ export type CreatePluginContextOptions<TConfig> = {
8
10
  agentDir: string
9
11
  config: TConfig
10
12
  logger: PluginLogger
13
+ permissions: PermissionService
11
14
  spawnSubagent: SpawnSubagentFn
12
15
  isBooted: () => boolean
13
16
  }
@@ -19,13 +22,14 @@ export function createPluginContext<TConfig>(opts: CreatePluginContextOptions<TC
19
22
  agentDir: opts.agentDir,
20
23
  config: opts.config,
21
24
  logger: opts.logger,
22
- spawnSubagent: async (name: string, payload?: unknown) => {
25
+ permissions: opts.permissions,
26
+ spawnSubagent: async (name: string, payload?: unknown, options?: SpawnSubagentOptions) => {
23
27
  if (!opts.isBooted()) {
24
28
  throw new Error(
25
29
  `plugin ${opts.name}: spawnSubagent("${name}") called before boot completed; subagent registry is not yet wired`,
26
30
  )
27
31
  }
28
- await opts.spawnSubagent(name, payload)
32
+ await opts.spawnSubagent(name, payload, options)
29
33
  },
30
34
  })
31
35
  }
@@ -6,9 +6,11 @@ type DefinePluginSpec<S extends z.ZodType<unknown> | undefined> =
6
6
  S extends z.ZodType<infer T>
7
7
  ? {
8
8
  configSchema: S
9
+ permissions?: readonly string[]
9
10
  plugin: (ctx: PluginContext<T>) => Promise<PluginExports>
10
11
  }
11
12
  : {
13
+ permissions?: readonly string[]
12
14
  plugin: (ctx: PluginContext<unknown>) => Promise<PluginExports>
13
15
  }
14
16
 
@@ -36,6 +36,7 @@ export type {
36
36
  SessionIdleEvent,
37
37
  SessionPromptEvent,
38
38
  SessionStartEvent,
39
+ SpawnSubagentOptions,
39
40
  Subagent,
40
41
  SubagentContext,
41
42
  Tool,
@@ -54,6 +55,7 @@ export {
54
55
  type LoadPluginsOptions,
55
56
  type LoadPluginsResult,
56
57
  } from './manager'
58
+ export type { PermissionService } from '@/permissions'
57
59
  export type { LoadPluginEntryFn, ResolvedPlugin } from './loader'
58
60
  export { loadPluginEntry, derivePluginNameFromPackage } from './loader'
59
61
  export { materializeSkills, type MaterializedSkills, type SkillEntry } from './skills'
@@ -1,6 +1,12 @@
1
1
  import { z } from 'zod'
2
2
 
3
3
  import type { CronJob } from '@/cron'
4
+ import {
5
+ createPermissionService,
6
+ findUnknownPermissions,
7
+ type PermissionService,
8
+ type RolesConfig,
9
+ } from '@/permissions'
4
10
 
5
11
  import { createPluginContext, createPluginLogger, type SpawnSubagentFn } from './context'
6
12
  import { createHookBus, type HookBus } from './hooks'
@@ -13,6 +19,7 @@ export type LoadPluginsOptions = {
13
19
  agentDir: string
14
20
  configsByName: Record<string, unknown>
15
21
  loadEntry?: LoadPluginEntryFn
22
+ roles?: RolesConfig
16
23
  // Bundled plugins resolved by the runtime (not from typeclaw.json). Loaded
17
24
  // before user-declared `entries` so a config block named after a bundled
18
25
  // plugin (e.g. "memory") is consumed by the bundled plugin, and so plugin-
@@ -23,6 +30,8 @@ export type LoadPluginsOptions = {
23
30
  export type LoadPluginsResult = {
24
31
  registry: PluginRegistry
25
32
  hooks: HookBus
33
+ permissions: PermissionService
34
+ declaredPermissions: readonly string[]
26
35
  loadedPlugins: { name: string; version: string | undefined; source: string }[]
27
36
  markBooted: () => void
28
37
  setSpawnSubagent: (fn: SpawnSubagentFn) => void
@@ -46,6 +55,23 @@ export async function loadPlugins(opts: LoadPluginsOptions): Promise<LoadPlugins
46
55
  )),
47
56
  ]
48
57
 
58
+ const declaredPermissions = collectDeclaredPermissions(allPlugins)
59
+ const permissions = createPermissionService({
60
+ ...(opts.roles !== undefined ? { roles: opts.roles } : {}),
61
+ pluginPermissions: declaredPermissions,
62
+ })
63
+
64
+ // Non-fatal: surface user-declared `permissions[]` strings that aren't in
65
+ // the known set, so a typo like `security.bypass.secretExfilBach` is
66
+ // visible at boot rather than silently failing to bypass the matching
67
+ // guard. We log instead of throw because the runtime still functions --
68
+ // the unknown string just never matches anything.
69
+ for (const warning of findUnknownPermissions(opts.roles, declaredPermissions)) {
70
+ console.warn(
71
+ `[permissions] role "${warning.role}" declares unknown permission "${warning.permission}" — ${warning.hint}`,
72
+ )
73
+ }
74
+
49
75
  for (const { entry, resolved } of allPlugins) {
50
76
  if (loaded.find((l) => l.name === resolved.name)) {
51
77
  throw new Error(`plugin name conflict: ${resolved.name} (entry ${entry}) already loaded`)
@@ -72,6 +98,7 @@ export async function loadPlugins(opts: LoadPluginsOptions): Promise<LoadPlugins
72
98
  agentDir: opts.agentDir,
73
99
  config: validatedConfig as never,
74
100
  logger,
101
+ permissions,
75
102
  spawnSubagent: (name, payload) => spawnSubagentImpl(name, payload),
76
103
  isBooted: () => booted,
77
104
  })
@@ -106,6 +133,8 @@ export async function loadPlugins(opts: LoadPluginsOptions): Promise<LoadPlugins
106
133
  return {
107
134
  registry,
108
135
  hooks,
136
+ permissions,
137
+ declaredPermissions,
109
138
  loadedPlugins: loaded,
110
139
  markBooted: () => {
111
140
  booted = true
@@ -116,6 +145,18 @@ export async function loadPlugins(opts: LoadPluginsOptions): Promise<LoadPlugins
116
145
  }
117
146
  }
118
147
 
148
+ function collectDeclaredPermissions(
149
+ plugins: readonly { entry: string; resolved: ResolvedPlugin }[],
150
+ ): readonly string[] {
151
+ const out: string[] = []
152
+ for (const { resolved } of plugins) {
153
+ for (const perm of resolved.defined.permissions ?? []) {
154
+ if (!out.includes(perm)) out.push(perm)
155
+ }
156
+ }
157
+ return out
158
+ }
159
+
119
160
  export function summarizeLoaded(loaded: LoadPluginsResult['loadedPlugins'], registry: PluginRegistry): string {
120
161
  const head = loaded.map((p) => (p.version !== undefined ? `${p.name} v${p.version}` : p.name)).join(', ')
121
162
  const counts = [
@@ -150,6 +150,13 @@ function assertNotEmpty(kind: string, value: string, pluginName: string): void {
150
150
  }
151
151
 
152
152
  function toCronJob(globalId: string, spec: PluginCronJob): CronJob {
153
+ // Plugin-contributed jobs default to `owner` because they are part of the
154
+ // agent's bundled (or operator-installed) runtime, not user-channel
155
+ // schedules. Without this default they would resolve to `guest` and the
156
+ // bundled memory dreaming cron (which writes MEMORY.md, runs git, etc.)
157
+ // would lose every security bypass. Hand-authored cron.json entries take
158
+ // a different path and must declare scheduledByRole explicitly.
159
+ const scheduledByRole: PromptJob['scheduledByRole'] = 'owner'
153
160
  if (spec.kind === 'prompt') {
154
161
  const job: PromptJob = {
155
162
  id: globalId,
@@ -157,6 +164,7 @@ function toCronJob(globalId: string, spec: PluginCronJob): CronJob {
157
164
  enabled: spec.enabled ?? true,
158
165
  kind: 'prompt',
159
166
  prompt: spec.prompt,
167
+ scheduledByRole,
160
168
  ...(spec.timezone !== undefined ? { timezone: spec.timezone } : {}),
161
169
  ...(spec.subagent !== undefined ? { subagent: spec.subagent } : {}),
162
170
  ...(spec.payload !== undefined ? { payload: spec.payload } : {}),
@@ -169,6 +177,7 @@ function toCronJob(globalId: string, spec: PluginCronJob): CronJob {
169
177
  enabled: spec.enabled ?? true,
170
178
  kind: 'exec',
171
179
  command: spec.command,
180
+ scheduledByRole,
172
181
  ...(spec.timezone !== undefined ? { timezone: spec.timezone } : {}),
173
182
  }
174
183
  }
@@ -1,6 +1,8 @@
1
1
  import type { z } from 'zod'
2
2
 
3
3
  import type { SessionOrigin } from '@/agent/session-origin'
4
+ import type { ToolResultBudget } from '@/agent/tool-result-budget'
5
+ import type { PermissionService } from '@/permissions'
4
6
 
5
7
  export type ContentPart = { type: 'text'; text: string } | { type: 'image'; mimeType: string; data: string }
6
8
 
@@ -40,6 +42,13 @@ export type RunSession = (override?: { userPrompt?: string }) => Promise<void>
40
42
 
41
43
  export type Subagent<P = unknown> = {
42
44
  systemPrompt: string
45
+ // Model profile this subagent prefers. Resolved against `models` in
46
+ // typeclaw.json at session construction. Unknown profile names fall back to
47
+ // `default` with a warning. Well-known names: `default`, `fast`, `deep`,
48
+ // `vision`. Subagents that want a specific tier (e.g. memory-logger wants
49
+ // `fast`, dreaming wants `deep`) declare it here so the user only has to
50
+ // map tier → model in config rather than wire each subagent individually.
51
+ profile?: string
43
52
  tools?: BuiltinToolRef[]
44
53
  customTools?: Tool<any>[]
45
54
  payloadSchema?: z.ZodType<P>
@@ -50,6 +59,16 @@ export type Subagent<P = unknown> = {
50
59
  // parentSessionId so different parent sessions run in parallel while
51
60
  // duplicate runs against the same session deduplicate.
52
61
  inFlightKey?: (payload: P) => string
62
+ // Defensive ceiling on cumulative bytes of tool-result text per subagent
63
+ // run, applied to the named tools only. Once exceeded, subsequent calls to
64
+ // those tools short-circuit with a fixed message instructing the agent to
65
+ // stop reading. See `src/agent/tool-result-budget.ts` for the full
66
+ // rationale; the short version is: a single broken tool (e.g. find_entry
67
+ // failing because of a schema mismatch) can cause an agent to fall back to
68
+ // chunked reads of huge files, ballooning subagent token cost. The budget
69
+ // bounds the blast radius without changing per-call semantics for healthy
70
+ // runs.
71
+ toolResultBudget?: ToolResultBudget
53
72
  }
54
73
 
55
74
  // Cron job map keys are local; the runtime prefixes with `__plugin_<plugin-name>_`
@@ -88,6 +107,7 @@ export type SessionStartEvent = {
88
107
 
89
108
  export type SessionEndEvent = {
90
109
  sessionId: string
110
+ origin?: SessionOrigin
91
111
  }
92
112
 
93
113
  export type SessionIdleEvent = {
@@ -132,6 +152,7 @@ export type ToolBeforeEvent = {
132
152
  sessionId: string
133
153
  callId: string
134
154
  args: Record<string, unknown>
155
+ origin?: SessionOrigin
135
156
  }
136
157
 
137
158
  export type ToolBeforeResult = void | undefined | { block: true; reason: string }
@@ -168,13 +189,25 @@ export type PluginLogger = {
168
189
  error: (msg: string) => void
169
190
  }
170
191
 
192
+ export type SpawnSubagentOptions = {
193
+ // Identifies the spawning session so the subagent's session origin carries
194
+ // parent provenance. Hook handlers that own this context (e.g. session.idle,
195
+ // session.turn.end) should pass at minimum `parentSessionId` and
196
+ // `spawnedByOrigin: event.origin`. The runtime resolves `spawnedByRole`
197
+ // from the origin via the PermissionService, so the spawning session's
198
+ // role is inherited rather than forged from outside.
199
+ parentSessionId?: string
200
+ spawnedByOrigin?: SessionOrigin
201
+ }
202
+
171
203
  export type PluginContext<TConfig = never> = {
172
204
  readonly name: string
173
205
  readonly version: string | undefined
174
206
  readonly agentDir: string
175
207
  readonly config: TConfig
176
208
  readonly logger: PluginLogger
177
- spawnSubagent: (name: string, payload?: unknown) => Promise<void>
209
+ readonly permissions: PermissionService
210
+ spawnSubagent: (name: string, payload?: unknown, options?: SpawnSubagentOptions) => Promise<void>
178
211
  }
179
212
 
180
213
  export type PluginExports = {
@@ -233,5 +266,6 @@ export type PluginFixResult = {
233
266
 
234
267
  export type DefinedPlugin<TConfig = never> = {
235
268
  readonly configSchema?: z.ZodType<TConfig>
269
+ readonly permissions?: readonly string[]
236
270
  readonly plugin: (ctx: PluginContext<TConfig>) => Promise<PluginExports>
237
271
  }
@@ -0,0 +1,182 @@
1
+ import type {
2
+ ClaimCompletedPayload,
3
+ ClaimErrorPayload,
4
+ ClaimStartedPayload,
5
+ ClientMessage,
6
+ ServerMessage,
7
+ } from '@/shared'
8
+
9
+ import { generateClaimCode } from './code'
10
+
11
+ export type ClaimSessionOptions = {
12
+ url: string
13
+ role: string
14
+ channel?: string
15
+ ttlMs?: number
16
+ connectTimeoutMs?: number
17
+ onStarted?: (payload: ClaimStartedPayload) => void
18
+ }
19
+
20
+ export type ClaimSessionResult =
21
+ | { kind: 'completed'; payload: ClaimCompletedPayload }
22
+ | { kind: 'error'; payload: ClaimErrorPayload }
23
+ | { kind: 'timeout' }
24
+
25
+ const DEFAULT_TTL_MS = 10 * 60 * 1000
26
+ const DEFAULT_CONNECT_TIMEOUT_MS = 30_000
27
+
28
+ export async function runClaimSession(opts: ClaimSessionOptions): Promise<ClaimSessionResult> {
29
+ const ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS
30
+ const connectTimeoutMs = opts.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS
31
+ const code = generateClaimCode()
32
+
33
+ const ws = new WebSocket(opts.url)
34
+ const displayUrl = redactUrl(opts.url)
35
+ await waitForOpen(ws, displayUrl, connectTimeoutMs)
36
+ await waitForConnected(ws, displayUrl, connectTimeoutMs)
37
+
38
+ try {
39
+ const request: ClientMessage = {
40
+ type: 'claim_start',
41
+ code,
42
+ role: opts.role,
43
+ ttlMs,
44
+ ...(opts.channel !== undefined ? { channel: opts.channel } : {}),
45
+ }
46
+ ws.send(JSON.stringify(request))
47
+
48
+ return await waitForOutcome(ws, code, ttlMs, opts.onStarted)
49
+ } finally {
50
+ try {
51
+ const cancel: ClientMessage = { type: 'claim_cancel' }
52
+ ws.send(JSON.stringify(cancel))
53
+ } catch {}
54
+ ws.close()
55
+ }
56
+ }
57
+
58
+ async function waitForOpen(ws: WebSocket, displayUrl: string, timeoutMs: number): Promise<void> {
59
+ await new Promise<void>((resolve, reject) => {
60
+ const timer = setTimeout(() => {
61
+ cleanup()
62
+ ws.close()
63
+ reject(new Error(`timed out connecting to ${displayUrl} after ${timeoutMs}ms`))
64
+ }, timeoutMs)
65
+ const onOpen = () => {
66
+ cleanup()
67
+ resolve()
68
+ }
69
+ const onError = (err: unknown) => {
70
+ cleanup()
71
+ reject(new Error(`failed to connect to ${displayUrl}: ${err instanceof Error ? err.message : String(err)}`))
72
+ }
73
+ const onClose = () => {
74
+ cleanup()
75
+ reject(new Error(`connection to ${displayUrl} closed before opening`))
76
+ }
77
+ const cleanup = () => {
78
+ clearTimeout(timer)
79
+ ws.removeEventListener('open', onOpen)
80
+ ws.removeEventListener('error', onError)
81
+ ws.removeEventListener('close', onClose)
82
+ }
83
+ ws.addEventListener('open', onOpen, { once: true })
84
+ ws.addEventListener('error', onError, { once: true })
85
+ ws.addEventListener('close', onClose, { once: true })
86
+ })
87
+ }
88
+
89
+ // The server's WS `open` handler is async (it awaits createSession) and only
90
+ // registers per-ws state and sends `connected` after that resolves. Bun
91
+ // delivers any client messages received before `open` completes, but the
92
+ // server's `claim_start` handler silently drops messages when state is null.
93
+ // Waiting here mirrors what the TUI client does (see src/tui/index.ts) and
94
+ // fixes the hatching-time hang where the spinner stayed on "Generating your
95
+ // claim code..." until the ttl expired.
96
+ async function waitForConnected(ws: WebSocket, displayUrl: string, timeoutMs: number): Promise<void> {
97
+ await new Promise<void>((resolve, reject) => {
98
+ const timer = setTimeout(() => {
99
+ cleanup()
100
+ ws.close()
101
+ reject(new Error(`timed out waiting for connected message from ${displayUrl} after ${timeoutMs}ms`))
102
+ }, timeoutMs)
103
+ const onMessage = (event: MessageEvent): void => {
104
+ let msg: ServerMessage
105
+ try {
106
+ msg = JSON.parse(String(event.data)) as ServerMessage
107
+ } catch {
108
+ return
109
+ }
110
+ if (msg.type === 'connected') {
111
+ cleanup()
112
+ resolve()
113
+ return
114
+ }
115
+ if (msg.type === 'error') {
116
+ cleanup()
117
+ reject(new Error(`server rejected connection to ${displayUrl}: ${msg.message}`))
118
+ }
119
+ }
120
+ const onClose = () => {
121
+ cleanup()
122
+ reject(new Error(`connection to ${displayUrl} closed before the session was ready`))
123
+ }
124
+ const cleanup = () => {
125
+ clearTimeout(timer)
126
+ ws.removeEventListener('message', onMessage)
127
+ ws.removeEventListener('close', onClose)
128
+ }
129
+ ws.addEventListener('message', onMessage)
130
+ ws.addEventListener('close', onClose, { once: true })
131
+ })
132
+ }
133
+
134
+ async function waitForOutcome(
135
+ ws: WebSocket,
136
+ code: string,
137
+ ttlMs: number,
138
+ onStarted?: (payload: ClaimStartedPayload) => void,
139
+ ): Promise<ClaimSessionResult> {
140
+ return new Promise<ClaimSessionResult>((resolve) => {
141
+ const timer = setTimeout(() => {
142
+ ws.removeEventListener('message', onMessage)
143
+ resolve({ kind: 'timeout' })
144
+ }, ttlMs + 5_000)
145
+
146
+ const onMessage = (event: MessageEvent): void => {
147
+ let msg: ServerMessage
148
+ try {
149
+ msg = JSON.parse(String(event.data)) as ServerMessage
150
+ } catch {
151
+ return
152
+ }
153
+ if (msg.type === 'claim_started' && msg.payload.code === code) {
154
+ onStarted?.(msg.payload)
155
+ return
156
+ }
157
+ if (msg.type === 'claim_completed' && msg.payload.code === code) {
158
+ clearTimeout(timer)
159
+ ws.removeEventListener('message', onMessage)
160
+ resolve({ kind: 'completed', payload: msg.payload })
161
+ return
162
+ }
163
+ if (msg.type === 'claim_error' && msg.payload.code === code) {
164
+ clearTimeout(timer)
165
+ ws.removeEventListener('message', onMessage)
166
+ resolve({ kind: 'error', payload: msg.payload })
167
+ return
168
+ }
169
+ }
170
+ ws.addEventListener('message', onMessage)
171
+ })
172
+ }
173
+
174
+ function redactUrl(url: string): string {
175
+ try {
176
+ const parsed = new URL(url)
177
+ if (parsed.searchParams.has('token')) parsed.searchParams.set('token', '<redacted>')
178
+ return parsed.toString()
179
+ } catch {
180
+ return url
181
+ }
182
+ }
@@ -0,0 +1,53 @@
1
+ import { randomBytes } from 'node:crypto'
2
+
3
+ // Role-claim codes are short, human-typeable tokens the operator sends from
4
+ // their host CLI to the bot via a channel DM to prove ownership of that
5
+ // channel identity. Shape: `claim-XXXX-YYYY` where each block is 4 chars
6
+ // from a Crockford-style base32 alphabet (0-9 + A-Z minus I, L, O, U to
7
+ // dodge OCR-confusable / profane shapes). 8 chars * 5 bits = 40 bits of
8
+ // entropy, which is overkill for a TTL'd in-memory window but cheap to
9
+ // display and dictate over voice.
10
+ //
11
+ // The `claim-` prefix lets the channel router recognize potential claim
12
+ // attempts in a DM body without scanning the whole text for hex blocks,
13
+ // and distinguishes claim DMs from normal first-message text like "hi"
14
+ // which would otherwise need a regex of its own to disambiguate.
15
+
16
+ export const CLAIM_CODE_PREFIX = 'claim-'
17
+
18
+ const ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'
19
+ const BLOCK_SIZE = 4
20
+ const BLOCK_COUNT = 2
21
+
22
+ export function generateClaimCode(): string {
23
+ const bytes = randomBytes(BLOCK_SIZE * BLOCK_COUNT)
24
+ const chars: string[] = []
25
+ for (let i = 0; i < bytes.length; i++) {
26
+ chars.push(ALPHABET[bytes[i]! % ALPHABET.length]!)
27
+ }
28
+ const blocks: string[] = []
29
+ for (let b = 0; b < BLOCK_COUNT; b++) {
30
+ blocks.push(chars.slice(b * BLOCK_SIZE, (b + 1) * BLOCK_SIZE).join(''))
31
+ }
32
+ return `${CLAIM_CODE_PREFIX}${blocks.join('-')}`
33
+ }
34
+
35
+ // Extracts the first claim-code-shaped token from inbound text. Returns
36
+ // the canonical-case (upper) code, or null. Tolerates surrounding
37
+ // whitespace, punctuation, and case — chat clients may auto-correct case
38
+ // or surround pastes with quotes/backticks.
39
+ export function extractClaimCode(text: string): string | null {
40
+ const pattern = new RegExp(
41
+ `${CLAIM_CODE_PREFIX}([0-9a-zA-Z]{${BLOCK_SIZE}}(?:-[0-9a-zA-Z]{${BLOCK_SIZE}}){${BLOCK_COUNT - 1}})`,
42
+ 'i',
43
+ )
44
+ const match = pattern.exec(text)
45
+ if (!match) return null
46
+ return `${CLAIM_CODE_PREFIX}${match[1]!.toUpperCase()}`
47
+ }
48
+
49
+ export function normalizeClaimCode(code: string): string {
50
+ const trimmed = code.trim()
51
+ if (!trimmed.toLowerCase().startsWith(CLAIM_CODE_PREFIX)) return trimmed.toUpperCase()
52
+ return `${CLAIM_CODE_PREFIX}${trimmed.slice(CLAIM_CODE_PREFIX.length).toUpperCase()}`
53
+ }