typeclaw 0.1.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 (213) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +134 -0
  3. package/auth.schema.json +63 -0
  4. package/cron.schema.json +96 -0
  5. package/package.json +72 -0
  6. package/scripts/emit-base-dockerfile.ts +5 -0
  7. package/scripts/generate-schema.ts +34 -0
  8. package/secrets.schema.json +63 -0
  9. package/src/agent/auth.ts +119 -0
  10. package/src/agent/compaction.ts +35 -0
  11. package/src/agent/git-nudge.ts +95 -0
  12. package/src/agent/index.ts +451 -0
  13. package/src/agent/plugin-tools.ts +269 -0
  14. package/src/agent/reload-tool.ts +71 -0
  15. package/src/agent/self.ts +45 -0
  16. package/src/agent/session-origin.ts +288 -0
  17. package/src/agent/subagents.ts +253 -0
  18. package/src/agent/system-prompt.ts +68 -0
  19. package/src/agent/tools/channel-fetch-attachment.ts +118 -0
  20. package/src/agent/tools/channel-history.ts +119 -0
  21. package/src/agent/tools/channel-reply.ts +182 -0
  22. package/src/agent/tools/channel-send.ts +212 -0
  23. package/src/agent/tools/ddg.ts +218 -0
  24. package/src/agent/tools/restart.ts +122 -0
  25. package/src/agent/tools/stream-snapshot.ts +181 -0
  26. package/src/agent/tools/webfetch/fetch.ts +102 -0
  27. package/src/agent/tools/webfetch/index.ts +1 -0
  28. package/src/agent/tools/webfetch/strategies/grep.ts +70 -0
  29. package/src/agent/tools/webfetch/strategies/jq.ts +31 -0
  30. package/src/agent/tools/webfetch/strategies/raw.ts +3 -0
  31. package/src/agent/tools/webfetch/strategies/readability.ts +30 -0
  32. package/src/agent/tools/webfetch/strategies/selector.ts +41 -0
  33. package/src/agent/tools/webfetch/strategies/snapshot.ts +135 -0
  34. package/src/agent/tools/webfetch/tool.ts +281 -0
  35. package/src/agent/tools/webfetch/types.ts +33 -0
  36. package/src/agent/tools/websearch.ts +96 -0
  37. package/src/agent/tools/wikipedia.ts +52 -0
  38. package/src/bundled-plugins/agent-browser/dashboard-discovery.ts +170 -0
  39. package/src/bundled-plugins/agent-browser/dashboard-proxy.ts +421 -0
  40. package/src/bundled-plugins/agent-browser/index.ts +179 -0
  41. package/src/bundled-plugins/agent-browser/shim-install.ts +158 -0
  42. package/src/bundled-plugins/agent-browser/shim.ts +152 -0
  43. package/src/bundled-plugins/agent-browser/skills/agent-browser/SKILL.md +113 -0
  44. package/src/bundled-plugins/guard/index.ts +26 -0
  45. package/src/bundled-plugins/guard/policies/non-workspace-write.ts +98 -0
  46. package/src/bundled-plugins/guard/policies/skill-authoring.ts +185 -0
  47. package/src/bundled-plugins/guard/policies/uncommitted-changes.ts +85 -0
  48. package/src/bundled-plugins/guard/policy.ts +18 -0
  49. package/src/bundled-plugins/memory/README.md +71 -0
  50. package/src/bundled-plugins/memory/append-tool.ts +84 -0
  51. package/src/bundled-plugins/memory/dreaming-state.ts +86 -0
  52. package/src/bundled-plugins/memory/dreaming.ts +470 -0
  53. package/src/bundled-plugins/memory/fragment-parser.ts +67 -0
  54. package/src/bundled-plugins/memory/index.ts +238 -0
  55. package/src/bundled-plugins/memory/load-memory.ts +122 -0
  56. package/src/bundled-plugins/memory/memory-logger.ts +257 -0
  57. package/src/bundled-plugins/memory/secret-detector.ts +49 -0
  58. package/src/bundled-plugins/memory/watermark.ts +15 -0
  59. package/src/bundled-plugins/security/index.ts +35 -0
  60. package/src/bundled-plugins/security/policies/git-exfil.ts +120 -0
  61. package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +167 -0
  62. package/src/bundled-plugins/security/policies/prompt-injection.ts +488 -0
  63. package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +99 -0
  64. package/src/bundled-plugins/security/policies/secret-exfil-read.ts +127 -0
  65. package/src/bundled-plugins/security/policies/session-search-secrets.ts +86 -0
  66. package/src/bundled-plugins/security/policies/ssrf.ts +196 -0
  67. package/src/bundled-plugins/security/policies/system-prompt-leak.ts +81 -0
  68. package/src/bundled-plugins/security/policy.ts +9 -0
  69. package/src/channels/adapters/discord-bot-channel-resolver.ts +77 -0
  70. package/src/channels/adapters/discord-bot-classify.ts +148 -0
  71. package/src/channels/adapters/discord-bot.ts +640 -0
  72. package/src/channels/adapters/kakaotalk-author-resolver.ts +78 -0
  73. package/src/channels/adapters/kakaotalk-channel-resolver.ts +105 -0
  74. package/src/channels/adapters/kakaotalk-classify.ts +77 -0
  75. package/src/channels/adapters/kakaotalk.ts +622 -0
  76. package/src/channels/adapters/slack-bot-author-resolver.ts +80 -0
  77. package/src/channels/adapters/slack-bot-channel-resolver.ts +84 -0
  78. package/src/channels/adapters/slack-bot-classify.ts +213 -0
  79. package/src/channels/adapters/slack-bot-dedupe.ts +51 -0
  80. package/src/channels/adapters/slack-bot-time.ts +10 -0
  81. package/src/channels/adapters/slack-bot.ts +881 -0
  82. package/src/channels/adapters/telegram-bot-classify.ts +155 -0
  83. package/src/channels/adapters/telegram-bot-format.ts +309 -0
  84. package/src/channels/adapters/telegram-bot.ts +604 -0
  85. package/src/channels/engagement.ts +227 -0
  86. package/src/channels/index.ts +21 -0
  87. package/src/channels/manager.ts +292 -0
  88. package/src/channels/membership-cache.ts +116 -0
  89. package/src/channels/membership-from-history.ts +53 -0
  90. package/src/channels/membership.ts +30 -0
  91. package/src/channels/participants.ts +47 -0
  92. package/src/channels/persistence.ts +209 -0
  93. package/src/channels/reloadable.ts +28 -0
  94. package/src/channels/router.ts +1570 -0
  95. package/src/channels/schema.ts +273 -0
  96. package/src/channels/types.ts +160 -0
  97. package/src/cli/channel.ts +403 -0
  98. package/src/cli/compose-status.ts +95 -0
  99. package/src/cli/compose.ts +240 -0
  100. package/src/cli/hostd.ts +163 -0
  101. package/src/cli/index.ts +27 -0
  102. package/src/cli/init.ts +592 -0
  103. package/src/cli/logs.ts +38 -0
  104. package/src/cli/reload.ts +68 -0
  105. package/src/cli/restart.ts +66 -0
  106. package/src/cli/run.ts +77 -0
  107. package/src/cli/shell.ts +33 -0
  108. package/src/cli/start.ts +57 -0
  109. package/src/cli/status.ts +178 -0
  110. package/src/cli/stop.ts +31 -0
  111. package/src/cli/tui.ts +35 -0
  112. package/src/cli/ui.ts +110 -0
  113. package/src/commands/index.ts +74 -0
  114. package/src/compose/discover.ts +43 -0
  115. package/src/compose/index.ts +25 -0
  116. package/src/compose/logs.ts +162 -0
  117. package/src/compose/restart.ts +69 -0
  118. package/src/compose/start.ts +62 -0
  119. package/src/compose/status.ts +28 -0
  120. package/src/compose/stop.ts +43 -0
  121. package/src/config/config.ts +424 -0
  122. package/src/config/index.ts +25 -0
  123. package/src/config/providers.ts +234 -0
  124. package/src/config/reloadable.ts +47 -0
  125. package/src/container/index.ts +27 -0
  126. package/src/container/logs.ts +37 -0
  127. package/src/container/port.ts +137 -0
  128. package/src/container/shared.ts +290 -0
  129. package/src/container/shell.ts +58 -0
  130. package/src/container/start.ts +670 -0
  131. package/src/container/status.ts +76 -0
  132. package/src/container/stop.ts +120 -0
  133. package/src/container/verify-running.ts +149 -0
  134. package/src/cron/consumer.ts +138 -0
  135. package/src/cron/index.ts +54 -0
  136. package/src/cron/reloadable.ts +64 -0
  137. package/src/cron/scheduler.ts +200 -0
  138. package/src/cron/schema.ts +96 -0
  139. package/src/hostd/client.ts +113 -0
  140. package/src/hostd/daemon.ts +587 -0
  141. package/src/hostd/index.ts +25 -0
  142. package/src/hostd/paths.ts +82 -0
  143. package/src/hostd/portbroker-manager.ts +101 -0
  144. package/src/hostd/protocol.ts +48 -0
  145. package/src/hostd/spawn.ts +224 -0
  146. package/src/hostd/supervisor.ts +60 -0
  147. package/src/hostd/tailscale.ts +172 -0
  148. package/src/hostd/version.ts +115 -0
  149. package/src/init/dockerfile.ts +327 -0
  150. package/src/init/ensure-deps.ts +152 -0
  151. package/src/init/gitignore.ts +46 -0
  152. package/src/init/hatching.ts +60 -0
  153. package/src/init/index.ts +786 -0
  154. package/src/init/kakaotalk-auth.ts +114 -0
  155. package/src/init/models-dev.ts +130 -0
  156. package/src/init/oauth-login.ts +74 -0
  157. package/src/init/packagejson.ts +94 -0
  158. package/src/init/paths.ts +2 -0
  159. package/src/init/run-bun-install.ts +20 -0
  160. package/src/markdown/chunk.ts +299 -0
  161. package/src/markdown/index.ts +1 -0
  162. package/src/plugin/context.ts +40 -0
  163. package/src/plugin/define.ts +35 -0
  164. package/src/plugin/hooks.ts +204 -0
  165. package/src/plugin/index.ts +63 -0
  166. package/src/plugin/loader.ts +111 -0
  167. package/src/plugin/manager.ts +136 -0
  168. package/src/plugin/registry.ts +145 -0
  169. package/src/plugin/skills.ts +62 -0
  170. package/src/plugin/types.ts +172 -0
  171. package/src/portbroker/bind-with-forward.ts +102 -0
  172. package/src/portbroker/container-server.ts +305 -0
  173. package/src/portbroker/forward-result-bus.ts +36 -0
  174. package/src/portbroker/hostd-client.ts +443 -0
  175. package/src/portbroker/index.ts +33 -0
  176. package/src/portbroker/policy.ts +24 -0
  177. package/src/portbroker/proc-net-tcp.ts +72 -0
  178. package/src/portbroker/protocol.ts +39 -0
  179. package/src/reload/client.ts +59 -0
  180. package/src/reload/index.ts +3 -0
  181. package/src/reload/registry.ts +60 -0
  182. package/src/reload/types.ts +13 -0
  183. package/src/run/bundled-plugins.ts +24 -0
  184. package/src/run/channel-session-factory.ts +105 -0
  185. package/src/run/index.ts +432 -0
  186. package/src/run/plugin-runtime.ts +43 -0
  187. package/src/run/schema-with-plugins.ts +14 -0
  188. package/src/secrets/index.ts +13 -0
  189. package/src/secrets/migrate.ts +95 -0
  190. package/src/secrets/schema.ts +75 -0
  191. package/src/secrets/storage.ts +231 -0
  192. package/src/server/index.ts +436 -0
  193. package/src/sessions/index.ts +23 -0
  194. package/src/shared/index.ts +9 -0
  195. package/src/shared/local-time.ts +21 -0
  196. package/src/shared/protocol.ts +25 -0
  197. package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +87 -0
  198. package/src/skills/typeclaw-channel-telegram-bot/SKILL.md +64 -0
  199. package/src/skills/typeclaw-config/SKILL.md +643 -0
  200. package/src/skills/typeclaw-cron/SKILL.md +159 -0
  201. package/src/skills/typeclaw-git/SKILL.md +89 -0
  202. package/src/skills/typeclaw-memory/SKILL.md +174 -0
  203. package/src/skills/typeclaw-monorepo/SKILL.md +175 -0
  204. package/src/skills/typeclaw-plugins/SKILL.md +594 -0
  205. package/src/skills/typeclaw-skills/SKILL.md +246 -0
  206. package/src/stream/broker.ts +161 -0
  207. package/src/stream/index.ts +16 -0
  208. package/src/stream/types.ts +69 -0
  209. package/src/tui/client.ts +45 -0
  210. package/src/tui/format.ts +317 -0
  211. package/src/tui/index.ts +225 -0
  212. package/src/tui/theme.ts +41 -0
  213. package/typeclaw.schema.json +826 -0
@@ -0,0 +1,60 @@
1
+ import type { Reloadable, ReloadAllResult, ReloadResult } from './types'
2
+
3
+ export class ReloadRegistry {
4
+ private items = new Map<string, Reloadable>()
5
+
6
+ register(item: Reloadable): void {
7
+ if (this.items.has(item.scope)) {
8
+ throw new Error(`reload scope "${item.scope}" is already registered`)
9
+ }
10
+ this.items.set(item.scope, item)
11
+ }
12
+
13
+ has(scope: string): boolean {
14
+ return this.items.has(scope)
15
+ }
16
+
17
+ get(scope: string): Reloadable | undefined {
18
+ return this.items.get(scope)
19
+ }
20
+
21
+ list(): Reloadable[] {
22
+ return Array.from(this.items.values())
23
+ }
24
+
25
+ // Runs serially in registration order. Reloadables observe the side
26
+ // effects of earlier ones — e.g. cron reload reads the freshly swapped
27
+ // config when it runs after the config reloadable. Manual reload is rare,
28
+ // so deterministic ordering wins over parallelism.
29
+ async reloadAll(): Promise<ReloadAllResult> {
30
+ const results: ReloadResult[] = []
31
+ for (const item of this.list()) {
32
+ try {
33
+ results.push(await item.reload())
34
+ } catch (err) {
35
+ results.push({ scope: item.scope, ok: false, reason: errorMessage(err) })
36
+ }
37
+ }
38
+ return { results }
39
+ }
40
+
41
+ async reloadOne(scope: string): Promise<ReloadResult> {
42
+ const item = this.items.get(scope)
43
+ if (!item) return { scope, ok: false, reason: `unknown scope: ${scope}` }
44
+ try {
45
+ return await item.reload()
46
+ } catch (err) {
47
+ return { scope, ok: false, reason: errorMessage(err) }
48
+ }
49
+ }
50
+ }
51
+
52
+ function errorMessage(err: unknown): string {
53
+ if (err instanceof Error) return err.message
54
+ if (typeof err === 'string') return err
55
+ try {
56
+ return JSON.stringify(err)
57
+ } catch {
58
+ return String(err)
59
+ }
60
+ }
@@ -0,0 +1,13 @@
1
+ export type ReloadResult =
2
+ | { scope: string; ok: true; summary: string; details?: unknown }
3
+ | { scope: string; ok: false; reason: string }
4
+
5
+ export type Reloadable = {
6
+ scope: string
7
+ description: string
8
+ reload: () => Promise<ReloadResult>
9
+ }
10
+
11
+ export type ReloadAllResult = {
12
+ results: ReloadResult[]
13
+ }
@@ -0,0 +1,24 @@
1
+ import agentBrowserPlugin from '@/bundled-plugins/agent-browser'
2
+ import guardPlugin from '@/bundled-plugins/guard'
3
+ import memoryPlugin from '@/bundled-plugins/memory'
4
+ import securityPlugin from '@/bundled-plugins/security'
5
+ import type { ResolvedPlugin } from '@/plugin'
6
+
7
+ // Consumed by both `startAgent` (auto-loaded before user plugins) AND
8
+ // `scripts/generate-schema.ts` (each entry's `defined.configSchema` is merged
9
+ // into `typeclaw.schema.json` keyed by plugin name). Adding a bundled plugin
10
+ // here automatically extends the JSON schema; core `configSchema` does not
11
+ // need to know about plugin-owned blocks.
12
+ //
13
+ // Order matters: `security` is listed first because its `tool.before` hook
14
+ // must get first refusal on every tool call (HookBus runs hooks in
15
+ // registration order and short-circuits on the first `{ block: true }`).
16
+ // Letting `guard` run first would still work today since the two plugins
17
+ // guard disjoint surfaces, but seeding the order now means future overlap
18
+ // (e.g. a security policy on writes) blocks before guard's softer advice.
19
+ export const BUNDLED_PLUGINS: ResolvedPlugin[] = [
20
+ { name: 'security', version: undefined, source: '<bundled>', defined: securityPlugin },
21
+ { name: 'guard', version: undefined, source: '<bundled>', defined: guardPlugin },
22
+ { name: 'memory', version: undefined, source: '<bundled>', defined: memoryPlugin },
23
+ { name: 'agent-browser', version: undefined, source: '<bundled>', defined: agentBrowserPlugin },
24
+ ]
@@ -0,0 +1,105 @@
1
+ import { SessionManager } from '@mariozechner/pi-coding-agent'
2
+
3
+ import { createSession as defaultCreateSession } from '@/agent'
4
+ import type { CreateSessionForChannel, ChannelRouter } from '@/channels'
5
+ import type { ReloadRegistry } from '@/reload'
6
+ import type { SessionFactory } from '@/sessions'
7
+ import type { Stream } from '@/stream'
8
+
9
+ import type { PluginRuntime } from './plugin-runtime'
10
+
11
+ export type BuildChannelSessionFactoryDeps = {
12
+ cwd: string
13
+ sessionFactory: SessionFactory
14
+ stream: Stream
15
+ reloadRegistry: ReloadRegistry
16
+ pluginRuntime: PluginRuntime
17
+ // Late-bound: the router is constructed by the channel manager which itself
18
+ // takes this factory. Reading the router lazily breaks the construction
19
+ // cycle while still ensuring the factory's sessions get the same router
20
+ // their inbound messages came from.
21
+ getChannelRouter: () => ChannelRouter
22
+ containerName?: string
23
+ // Test seam: lets a fake stand in for the agent session creator so tests
24
+ // can assert exactly which CreateSessionOptions the factory builds without
25
+ // needing a live LLM, plugin runtime, or session manager on disk.
26
+ createSession?: typeof defaultCreateSession
27
+ }
28
+
29
+ // The production wiring for channel-routed sessions. Channel inbounds arrive
30
+ // at the router, the router calls this factory to get an AgentSession, and
31
+ // the agent uses `channel_send` to reply. If `channelRouter` is missing here
32
+ // the agent has no `channel_send` tool and cannot reply — silently. That was
33
+ // the bug this factory exists to prevent. The shape of these options must
34
+ // stay aligned with createSessionForCron in src/run/index.ts; both are
35
+ // "channel-aware" sessions that need the same full plumbing.
36
+ export function buildChannelSessionFactory(deps: BuildChannelSessionFactoryDeps): CreateSessionForChannel {
37
+ const createSession = deps.createSession ?? defaultCreateSession
38
+ return async ({ existingSessionId, existingSessionFile, origin }) => {
39
+ const sessionDir = deps.sessionFactory.sessionDir()
40
+ const sessionManager =
41
+ existingSessionId !== undefined
42
+ ? tryReopenOrCreate(deps.cwd, sessionDir, existingSessionId, existingSessionFile)
43
+ : SessionManager.create(deps.cwd, sessionDir)
44
+
45
+ const snap = deps.pluginRuntime.get()
46
+ const session = await createSession({
47
+ reloadRegistry: deps.reloadRegistry,
48
+ sessionManager,
49
+ stream: deps.stream,
50
+ channelRouter: deps.getChannelRouter(),
51
+ origin,
52
+ ...(snap.hasAnyPluginContent
53
+ ? {
54
+ plugins: {
55
+ registry: snap.registry,
56
+ hooks: snap.hooks,
57
+ sessionId: sessionManager.getSessionId(),
58
+ agentDir: deps.cwd,
59
+ },
60
+ }
61
+ : {}),
62
+ ...(deps.containerName !== undefined ? { containerName: deps.containerName } : {}),
63
+ })
64
+
65
+ return {
66
+ session,
67
+ sessionId: sessionManager.getSessionId(),
68
+ dispose: async () => {
69
+ session.dispose()
70
+ },
71
+ ...(snap.hasAnyPluginContent ? { hooks: snap.hooks } : {}),
72
+ getTranscriptPath: () => sessionManager.getSessionFile(),
73
+ }
74
+ }
75
+ }
76
+
77
+ // Reopen the persisted session manager when possible so the agent picks up
78
+ // where it left off. We use the persisted basename (sessionFile) directly
79
+ // because pi-coding-agent prefixes filenames with an ISO timestamp at write
80
+ // time that is not derivable from sessionId alone. Failure to reopen
81
+ // (corruption, missing file, schema drift, or v2 mapping with no sessionFile)
82
+ // falls back to a fresh session — matching the router's existing best-effort
83
+ // durability for channel sessions.
84
+ function tryReopenOrCreate(
85
+ cwd: string,
86
+ sessionDir: string,
87
+ existingSessionId: string,
88
+ existingSessionFile: string | undefined,
89
+ ): SessionManager {
90
+ if (existingSessionFile === undefined) {
91
+ console.warn(
92
+ `[channels] session ${existingSessionId} has no sessionFile (v2 mapping not yet migrated); creating new`,
93
+ )
94
+ return SessionManager.create(cwd, sessionDir)
95
+ }
96
+ try {
97
+ return SessionManager.open(`${sessionDir}/${existingSessionFile}`)
98
+ } catch (err) {
99
+ const reason = err instanceof Error ? err.message : String(err)
100
+ console.warn(
101
+ `[channels] could not rehydrate session ${existingSessionId} from ${existingSessionFile}: ${reason}; creating new`,
102
+ )
103
+ return SessionManager.create(cwd, sessionDir)
104
+ }
105
+ }
@@ -0,0 +1,432 @@
1
+ import { SessionManager } from '@mariozechner/pi-coding-agent'
2
+
3
+ import { createSession, createSessionWithDispose } from '@/agent'
4
+ import {
5
+ createSubagentConsumer,
6
+ defaultCreateSessionForSubagent,
7
+ invokeSubagent,
8
+ type Subagent as InternalSubagent,
9
+ type SubagentConsumer,
10
+ type SubagentRegistry,
11
+ } from '@/agent/subagents'
12
+ import { createChannelManager, createChannelsReloadable, type ChannelManager } from '@/channels'
13
+ import { createConfigReloadable, getConfig, loadConfigSync, loadPluginConfigsSync } from '@/config'
14
+ import {
15
+ type CronConsumer,
16
+ type CronJob,
17
+ type CronFile,
18
+ createCronConsumer,
19
+ createCronReloadable,
20
+ createScheduler,
21
+ type LoadCronResult,
22
+ loadCron as loadCronDefault,
23
+ type Scheduler,
24
+ } from '@/cron'
25
+ import { loadPlugins, type LoadPluginsResult, pluginCronJobs, type PluginRegistry, summarizeLoaded } from '@/plugin'
26
+ import { createContainerBroker, publishForwardResult } from '@/portbroker'
27
+ import { ReloadRegistry } from '@/reload'
28
+ import { createServer, type Server } from '@/server'
29
+ import { createSessionFactory, type SessionFactory } from '@/sessions'
30
+ import { createStream, type Stream } from '@/stream'
31
+ import { createTui as createTuiDefault, type TuiOptions } from '@/tui'
32
+
33
+ import { BUNDLED_PLUGINS } from './bundled-plugins'
34
+ import { buildChannelSessionFactory } from './channel-session-factory'
35
+ import { createPluginRuntime, type PluginRuntime, type PluginSubagentEntry } from './plugin-runtime'
36
+
37
+ type BunServer = ReturnType<Server['start']>
38
+
39
+ export type TuiFactory = (options: TuiOptions) => { run: () => Promise<void> }
40
+
41
+ export type LoadCronFn = (agentDir: string, options?: { subagents?: SubagentRegistry }) => Promise<LoadCronResult>
42
+ export type SchedulerFactory = (options: { cwd: string; file: CronFile; onFire: (job: CronJob) => void }) => Scheduler
43
+
44
+ export type StartAgentOptions = {
45
+ port: number
46
+ attachTui: boolean
47
+ initialPrompt?: string
48
+ cwd?: string
49
+ createTui?: TuiFactory
50
+ loadCron?: LoadCronFn
51
+ createSchedulerFor?: SchedulerFactory
52
+ sessionFactory?: SessionFactory
53
+ stream?: Stream
54
+ }
55
+
56
+ export type StartAgentResult = {
57
+ server: BunServer
58
+ tuiPromise: Promise<void> | null
59
+ scheduler: Scheduler | null
60
+ cronConsumer: CronConsumer | null
61
+ subagentConsumer: SubagentConsumer
62
+ reloadRegistry: ReloadRegistry
63
+ stream: Stream
64
+ pluginRuntime: PluginRuntime
65
+ loadedPlugins: LoadPluginsResult['loadedPlugins']
66
+ channelManager: ChannelManager
67
+ stop: () => void | Promise<void>
68
+ }
69
+
70
+ export async function startAgent({
71
+ port,
72
+ attachTui,
73
+ initialPrompt,
74
+ cwd = process.cwd(),
75
+ createTui = createTuiDefault,
76
+ loadCron = loadCronDefault,
77
+ createSchedulerFor,
78
+ sessionFactory = createSessionFactory({ agentDir: cwd }),
79
+ stream = createStream(),
80
+ }: StartAgentOptions): Promise<StartAgentResult> {
81
+ const reloadRegistry = new ReloadRegistry()
82
+
83
+ // The host CLI sets TYPECLAW_CONTAINER_NAME when it `docker run`s us. When
84
+ // running outside a typeclaw container (tests, ad-hoc `bun run typeclaw run`
85
+ // outside docker), the env var is absent and the `restart` tool is omitted —
86
+ // which is what we want, since there is no host daemon to honor it anyway.
87
+ const containerName = process.env.TYPECLAW_CONTAINER_NAME
88
+ const containerNameOpt = containerName !== undefined ? { containerName } : {}
89
+ reloadRegistry.register(createConfigReloadable({ cwd }))
90
+
91
+ const pluginConfigsByName = loadPluginConfigsSync(cwd)
92
+ const cwdConfig = loadConfigSync(cwd)
93
+ const pluginsLoaded = await loadPlugins({
94
+ entries: cwdConfig.plugins,
95
+ agentDir: cwd,
96
+ configsByName: pluginConfigsByName,
97
+ bundled: BUNDLED_PLUGINS,
98
+ })
99
+ const pluginRegistry = pluginsLoaded.registry
100
+ const pluginHooks = pluginsLoaded.hooks
101
+
102
+ const { registry: subagents, pluginSubagentByShim, pluginSubagentByName } = mergeSubagents(pluginRegistry)
103
+
104
+ const hasAnyPluginContent =
105
+ pluginRegistry.tools.length > 0 ||
106
+ pluginRegistry.subagents.length > 0 ||
107
+ pluginRegistry.cronJobs.length > 0 ||
108
+ pluginRegistry.skills.length > 0 ||
109
+ pluginRegistry.skillsDirs.length > 0 ||
110
+ pluginsLoaded.loadedPlugins.length > 0
111
+
112
+ const pluginRuntime = createPluginRuntime({
113
+ registry: pluginRegistry,
114
+ hooks: pluginHooks,
115
+ subagents,
116
+ pluginSubagentByShim,
117
+ hasAnyPluginContent,
118
+ loadedPlugins: pluginsLoaded.loadedPlugins,
119
+ materializedSkills: null,
120
+ })
121
+
122
+ const channelManager = createChannelManager({
123
+ agentDir: cwd,
124
+ channelsConfigRef: () => getConfig().channels,
125
+ aliasesRef: () => getConfig().alias,
126
+ createSessionForChannel: buildChannelSessionFactory({
127
+ cwd,
128
+ sessionFactory,
129
+ stream,
130
+ reloadRegistry,
131
+ pluginRuntime,
132
+ getChannelRouter: () => channelManager.router,
133
+ ...containerNameOpt,
134
+ }),
135
+ })
136
+
137
+ const createSessionForSubagent: import('@/agent/subagents').CreateSessionForSubagent = async (
138
+ subagent,
139
+ subagentOptions,
140
+ ) => {
141
+ const snap = pluginRuntime.get()
142
+ const entry = snap.pluginSubagentByShim.get(subagent)
143
+ if (entry) {
144
+ const sessionId = `subagent-${entry.pluginName}-${crypto.randomUUID()}`
145
+ const created = await createSessionWithDispose({
146
+ systemPromptOverride: entry.pluginSubagent.systemPrompt,
147
+ channelRouter: channelManager.router,
148
+ origin: {
149
+ kind: 'subagent',
150
+ subagent: subagentOptions?.name ?? entry.subagentName,
151
+ parentSessionId: subagentOptions?.parentSessionId ?? '<unknown>',
152
+ },
153
+ plugins: {
154
+ registry: snap.registry,
155
+ hooks: snap.hooks,
156
+ sessionId,
157
+ agentDir: cwd,
158
+ },
159
+ pluginSubagent: {
160
+ pluginName: entry.pluginName,
161
+ ...(entry.pluginSubagent.tools ? { toolRefs: entry.pluginSubagent.tools } : {}),
162
+ ...(entry.pluginSubagent.customTools ? { customTools: entry.pluginSubagent.customTools } : {}),
163
+ toolNamePrefix: `__plugin_${entry.pluginName}_${entry.subagentName}`,
164
+ },
165
+ })
166
+ return {
167
+ ...created,
168
+ hooks: snap.hooks,
169
+ sessionId,
170
+ }
171
+ }
172
+ return defaultCreateSessionForSubagent(subagent, subagentOptions)
173
+ }
174
+
175
+ const subagentConsumer = createSubagentConsumer({
176
+ stream,
177
+ getRegistry: () => pluginRuntime.get().subagents,
178
+ agentDir: cwd,
179
+ createSessionForSubagent,
180
+ inFlightKey: (name, payload) => {
181
+ const entry = pluginSubagentByName.get(name)
182
+ const fn = entry?.pluginSubagent.inFlightKey
183
+ if (fn !== undefined) {
184
+ try {
185
+ return `${name}:${fn(payload)}`
186
+ } catch {
187
+ return name
188
+ }
189
+ }
190
+ return name
191
+ },
192
+ })
193
+ subagentConsumer.start()
194
+
195
+ const cronConsumer = createCronConsumer({
196
+ stream,
197
+ cwd,
198
+ createSessionForCron: async (job) => {
199
+ const snap = pluginRuntime.get()
200
+ const sessionManager = SessionManager.create(cwd, sessionFactory.sessionDir())
201
+ const sessionId = sessionManager.getSessionId()
202
+ const session = await createSession({
203
+ reloadRegistry,
204
+ sessionManager,
205
+ stream,
206
+ channelRouter: channelManager.router,
207
+ origin: { kind: 'cron', jobId: job.id, jobKind: 'prompt' },
208
+ ...(snap.hasAnyPluginContent
209
+ ? {
210
+ plugins: {
211
+ registry: snap.registry,
212
+ hooks: snap.hooks,
213
+ sessionId,
214
+ agentDir: cwd,
215
+ },
216
+ }
217
+ : {}),
218
+ ...containerNameOpt,
219
+ })
220
+ return {
221
+ prompt: (text) => session.prompt(text),
222
+ dispose: () => session.dispose(),
223
+ sessionId,
224
+ ...(snap.hasAnyPluginContent ? { hooks: snap.hooks } : {}),
225
+ getTranscriptPath: () => sessionManager.getSessionFile(),
226
+ }
227
+ },
228
+ })
229
+
230
+ const internalJobs = () => pluginCronJobs(pluginRuntime.get().registry)
231
+ const factory = createSchedulerFor ?? makeDefaultSchedulerFactory(internalJobs)
232
+ const scheduler = await startScheduler({
233
+ cwd,
234
+ loadCron,
235
+ createSchedulerFor: factory,
236
+ stream,
237
+ hasInternalJobs: internalJobs().length > 0,
238
+ getSubagents: () => pluginRuntime.get().subagents,
239
+ })
240
+
241
+ if (scheduler) {
242
+ cronConsumer.start()
243
+ reloadRegistry.register(
244
+ createCronReloadable({ cwd, scheduler, internalJobs, getSubagents: () => pluginRuntime.get().subagents }),
245
+ )
246
+ }
247
+
248
+ reloadRegistry.register(createChannelsReloadable({ manager: channelManager }))
249
+ await channelManager.start()
250
+
251
+ pluginsLoaded.setSpawnSubagent(async (name, payload) => {
252
+ await invokeSubagent(name, {
253
+ registry: pluginRuntime.get().subagents,
254
+ createSessionForSubagent,
255
+ agentDir: cwd,
256
+ userPrompt: '',
257
+ payload,
258
+ })
259
+ })
260
+ pluginsLoaded.markBooted()
261
+
262
+ if (pluginsLoaded.loadedPlugins.length > 0) {
263
+ console.log(`[plugin] loaded ${summarizeLoaded(pluginsLoaded.loadedPlugins, pluginRegistry)}`)
264
+ }
265
+
266
+ // Container-side portbroker is instantiated only when the host plumbed a
267
+ // broker token in via env var. Outside the container (tests, ad-hoc dev
268
+ // runs), the env var is absent and the broker stays off — same fence as
269
+ // TYPECLAW_CONTAINER_NAME guards the restart tool.
270
+ const brokerTokenEnv = process.env.TYPECLAW_HOSTD_BROKER_TOKEN
271
+ const containerBroker =
272
+ brokerTokenEnv !== undefined && brokerTokenEnv.length > 0
273
+ ? createContainerBroker({
274
+ expectedToken: brokerTokenEnv,
275
+ onLog: (event) => {
276
+ if (event.kind === 'subscribed') return
277
+ stream.publish({
278
+ target: { kind: 'broadcast' },
279
+ payload: { kind: 'portbroker-log', event },
280
+ })
281
+ },
282
+ // Re-publish to the in-process bus so consumers (today: the
283
+ // agent-browser plugin's bind-with-forward retry loop) can subscribe
284
+ // without holding a reference to the broker. See src/portbroker/
285
+ // forward-result-bus.ts for the contract.
286
+ onForwardResult: (event) => publishForwardResult(event),
287
+ })
288
+ : undefined
289
+ const containerBrokerOpt = containerBroker ? { containerBroker } : {}
290
+
291
+ const server = createServer({
292
+ port,
293
+ reloadAll: () => reloadRegistry.reloadAll(),
294
+ reloadRegistry,
295
+ sessionFactory,
296
+ stream,
297
+ channelRouter: channelManager.router,
298
+ agentDir: cwd,
299
+ pluginRuntime,
300
+ ...containerNameOpt,
301
+ ...containerBrokerOpt,
302
+ }).start()
303
+
304
+ let stopped = false
305
+ const stop = async () => {
306
+ if (stopped) return
307
+ stopped = true
308
+ scheduler?.stop()
309
+ cronConsumer.stop()
310
+ subagentConsumer.stop()
311
+ server.stop(true)
312
+ void disposeMaterializedSkills(pluginRuntime)
313
+ await channelManager.stop()
314
+ }
315
+
316
+ if (!attachTui) {
317
+ return {
318
+ server,
319
+ tuiPromise: null,
320
+ scheduler,
321
+ cronConsumer: scheduler ? cronConsumer : null,
322
+ subagentConsumer,
323
+ reloadRegistry,
324
+ stream,
325
+ pluginRuntime,
326
+ loadedPlugins: pluginsLoaded.loadedPlugins,
327
+ channelManager,
328
+ stop,
329
+ }
330
+ }
331
+
332
+ const url = `ws://localhost:${server.port}`
333
+ const tui = createTui({ url, initialPrompt })
334
+ const tuiPromise = tui.run()
335
+ return {
336
+ server,
337
+ tuiPromise,
338
+ scheduler,
339
+ cronConsumer: scheduler ? cronConsumer : null,
340
+ subagentConsumer,
341
+ reloadRegistry,
342
+ stream,
343
+ pluginRuntime,
344
+ loadedPlugins: pluginsLoaded.loadedPlugins,
345
+ channelManager,
346
+ stop,
347
+ }
348
+ }
349
+
350
+ async function disposeMaterializedSkills(pluginRuntime: PluginRuntime): Promise<void> {
351
+ const pending = pluginRuntime.drainPendingDisposal()
352
+ const current = pluginRuntime.get().materializedSkills
353
+ const all = current ? [...pending, current] : pending
354
+ await Promise.allSettled(all.map((m) => m.dispose()))
355
+ }
356
+
357
+ async function startScheduler({
358
+ cwd,
359
+ loadCron,
360
+ createSchedulerFor,
361
+ stream,
362
+ hasInternalJobs,
363
+ getSubagents,
364
+ }: {
365
+ cwd: string
366
+ loadCron: LoadCronFn
367
+ createSchedulerFor: SchedulerFactory
368
+ stream: Stream
369
+ hasInternalJobs: boolean
370
+ getSubagents?: () => SubagentRegistry
371
+ }): Promise<Scheduler | null> {
372
+ let result: LoadCronResult
373
+ const subagents = getSubagents?.()
374
+ try {
375
+ result = await loadCron(cwd, subagents !== undefined ? { subagents } : {})
376
+ } catch (err) {
377
+ console.error(`[cron] load failed: ${err instanceof Error ? err.message : err}`)
378
+ return null
379
+ }
380
+ if (!result.ok) {
381
+ console.error(`[cron] failed to load cron.json: ${result.reason}`)
382
+ return null
383
+ }
384
+ const file: CronFile = result.file ?? { jobs: [] }
385
+ if (!result.file && !hasInternalJobs) return null
386
+
387
+ const onFire = (job: CronJob) => {
388
+ stream.publish({ target: { kind: 'cron', jobId: job.id }, payload: job })
389
+ }
390
+ const scheduler = createSchedulerFor({ cwd, file, onFire })
391
+ scheduler.start()
392
+ return scheduler
393
+ }
394
+
395
+ function makeDefaultSchedulerFactory(internalJobs: () => CronJob[]): SchedulerFactory {
396
+ return ({ file, onFire }) => createScheduler({ jobs: [...file.jobs, ...internalJobs()], onFire })
397
+ }
398
+
399
+ function mergeSubagents(pluginRegistry: PluginRegistry): {
400
+ registry: SubagentRegistry
401
+ pluginSubagentByShim: WeakMap<InternalSubagent<any>, PluginSubagentEntry>
402
+ pluginSubagentByName: Map<string, PluginSubagentEntry>
403
+ } {
404
+ const merged: Record<string, InternalSubagent<any>> = {}
405
+ const pluginSubagentByShim = new WeakMap<InternalSubagent<any>, PluginSubagentEntry>()
406
+ const pluginSubagentByName = new Map<string, PluginSubagentEntry>()
407
+ for (const reg of pluginRegistry.subagents) {
408
+ if (merged[reg.subagentName] !== undefined) {
409
+ throw new Error(
410
+ `plugin ${reg.pluginName}: subagent name "${reg.subagentName}" already registered (across plugins)`,
411
+ )
412
+ }
413
+ const shim = pluginSubagentShim(reg.subagent)
414
+ merged[reg.subagentName] = shim
415
+ const entry: PluginSubagentEntry = {
416
+ pluginName: reg.pluginName,
417
+ subagentName: reg.subagentName,
418
+ pluginSubagent: reg.subagent,
419
+ }
420
+ pluginSubagentByShim.set(shim, entry)
421
+ pluginSubagentByName.set(reg.subagentName, entry)
422
+ }
423
+ return { registry: merged, pluginSubagentByShim, pluginSubagentByName }
424
+ }
425
+
426
+ function pluginSubagentShim(subagent: import('@/plugin').Subagent<any>): InternalSubagent<any> {
427
+ return {
428
+ systemPrompt: subagent.systemPrompt,
429
+ ...(subagent.payloadSchema ? { payloadSchema: subagent.payloadSchema } : {}),
430
+ ...(subagent.handler ? { handler: subagent.handler as InternalSubagent<any>['handler'] } : {}),
431
+ }
432
+ }
@@ -0,0 +1,43 @@
1
+ import type { Subagent as InternalSubagent, SubagentRegistry } from '@/agent/subagents'
2
+ import type { HookBus, MaterializedSkills, PluginRegistry, Subagent as PluginSubagent } from '@/plugin'
3
+
4
+ export type PluginSubagentEntry = {
5
+ pluginName: string
6
+ subagentName: string
7
+ pluginSubagent: PluginSubagent<any>
8
+ }
9
+
10
+ export type PluginRuntimeState = {
11
+ registry: PluginRegistry
12
+ hooks: HookBus
13
+ subagents: SubagentRegistry
14
+ pluginSubagentByShim: WeakMap<InternalSubagent<any>, PluginSubagentEntry>
15
+ hasAnyPluginContent: boolean
16
+ loadedPlugins: { name: string; version: string | undefined; source: string }[]
17
+ materializedSkills: MaterializedSkills | null
18
+ }
19
+
20
+ export type PluginRuntime = {
21
+ get: () => PluginRuntimeState
22
+ swap: (next: PluginRuntimeState) => PluginRuntimeState
23
+ trackPendingDisposal: (skills: MaterializedSkills) => void
24
+ drainPendingDisposal: () => MaterializedSkills[]
25
+ }
26
+
27
+ export function createPluginRuntime(initial: PluginRuntimeState): PluginRuntime {
28
+ let current = initial
29
+ const pendingDisposal: MaterializedSkills[] = []
30
+
31
+ return {
32
+ get: () => current,
33
+ swap: (next) => {
34
+ const prev = current
35
+ current = next
36
+ return prev
37
+ },
38
+ trackPendingDisposal: (skills) => {
39
+ pendingDisposal.push(skills)
40
+ },
41
+ drainPendingDisposal: () => pendingDisposal.splice(0, pendingDisposal.length),
42
+ }
43
+ }
@@ -0,0 +1,14 @@
1
+ import { z } from 'zod'
2
+
3
+ import { BUNDLED_PLUGINS } from './bundled-plugins'
4
+
5
+ export function buildConfigSchemaWithBundledPlugins(coreSchema: z.ZodObject): z.ZodObject {
6
+ const pluginShape: Record<string, z.ZodType> = {}
7
+ for (const plugin of BUNDLED_PLUGINS) {
8
+ const schema = plugin.defined.configSchema
9
+ if (schema !== undefined) {
10
+ pluginShape[plugin.name] = schema as z.ZodType
11
+ }
12
+ }
13
+ return coreSchema.extend(pluginShape)
14
+ }