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,587 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { chmod, readdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'
3
+ import { join } from 'node:path'
4
+
5
+ import type { Socket, UnixSocketListener } from 'bun'
6
+
7
+ import type { PortForward } from '@/config'
8
+ import { defaultDockerExec, type DockerExec } from '@/container'
9
+ import type { PortForwardEvent } from '@/portbroker'
10
+
11
+ import { isDaemonReachable } from './client'
12
+ import { ensureDirs, registrationFilePath, registrationsDir, socketPath } from './paths'
13
+ import type {
14
+ HttpInfoResult,
15
+ ListResult,
16
+ Request,
17
+ Response as RpcResponse,
18
+ RestartResult,
19
+ ShutdownResult,
20
+ StatusResult,
21
+ VersionResult,
22
+ } from './protocol'
23
+ import { buildSupervisor, type SupervisorLogEvent, type SupervisorRestart } from './supervisor'
24
+ import type { TailscaleServeEvent } from './tailscale'
25
+ import { UNVERSIONED_SENTINEL } from './version'
26
+
27
+ export type DaemonOptions = {
28
+ exec?: DockerExec
29
+ onLog?: (event: DaemonLogEvent | SupervisorLogEvent) => void
30
+ gcIntervalMs?: number
31
+ gcMissesToDeregister?: number
32
+ socket?: string
33
+ // When provided, the daemon honors `restart` RPCs by invoking this with the
34
+ // (containerName, cwd) it captured at register time. Omit to disable the
35
+ // capability in tests.
36
+ restart?: SupervisorRestart
37
+ restartPreflight?: RestartPreflight
38
+ // Source-tree fingerprint captured at daemon boot. Reported via the
39
+ // `version` RPC so the CLI can detect when its on-disk source has drifted
40
+ // from what the running daemon loaded, and trigger a respawn over the
41
+ // `shutdown` RPC. Omit to advertise as unversioned (drift detection
42
+ // disabled — both peers compare equal on the sentinel).
43
+ version?: string
44
+ // Invoked after the daemon finishes its self-initiated stop in response to
45
+ // a `shutdown` RPC. Production wiring exits the process here so the host
46
+ // can spawn a fresh daemon; tests omit it to keep the process alive.
47
+ onShutdown?: () => void
48
+ httpHost?: string
49
+ httpPort?: number
50
+ // Port-broker capability. When provided, register-RPC's portForward/wsHostPort
51
+ // fields trigger broker spawn alongside supervisor registration. Tests omit
52
+ // it to keep the broker out of unrelated suites.
53
+ portbroker?: PortbrokerCallbacks
54
+ }
55
+
56
+ export type RestartPreflight = (input: {
57
+ containerName: string
58
+ cwd: string
59
+ build?: boolean
60
+ }) => Promise<RpcResponse | null>
61
+
62
+ export type PortbrokerCallbacks = {
63
+ start: (input: PortbrokerStartInput) => void
64
+ stop: (containerName: string, reason: 'deregistered' | 'broker-stopped') => Promise<void>
65
+ // Returns ports the broker is currently exposing on the host for this
66
+ // container. Empty array when the container is unregistered, when the broker
67
+ // is disabled (`portForward.allow: []`), or when nothing inside the
68
+ // container has bound a forwardable port yet. Read-only — used by the
69
+ // `status` RPC to surface live forward state.
70
+ forwardedPorts: (containerName: string) => number[]
71
+ }
72
+
73
+ export type PortbrokerStartInput = {
74
+ containerName: string
75
+ cwd: string
76
+ policy: PortForward
77
+ wsHostPort: number
78
+ brokerToken: string
79
+ onEvent: (event: PortForwardEvent) => void
80
+ onTailscaleServeEvent: (event: TailscaleServeEvent) => void
81
+ }
82
+
83
+ export type DaemonLogEvent =
84
+ | { kind: 'daemon-listening'; socket: string }
85
+ | { kind: 'daemon-http-listening'; host: string; port: number }
86
+ | { kind: 'daemon-http-port-fallback'; preferred: number; actual: number }
87
+ | { kind: 'daemon-stopping' }
88
+ | { kind: 'register'; containerName: string }
89
+ | { kind: 'deregister'; containerName: string; reason: 'requested' | 'gone' }
90
+ | { kind: 'registration-skipped'; containerName: string; reason: string }
91
+ | { kind: 'shutdown-requested' }
92
+ | { kind: 'port-forward-event'; event: PortForwardEvent }
93
+ | { kind: 'tailscale-serve-event'; event: TailscaleServeEvent }
94
+
95
+ export type Daemon = {
96
+ registered: () => string[]
97
+ stop: () => Promise<void>
98
+ }
99
+
100
+ const DEFAULT_GC_INTERVAL_MS = 30_000
101
+ const DEFAULT_GC_MISSES_TO_DEREGISTER = 3
102
+ const MAX_REQUEST_BUFFER_BYTES = 64 * 1024
103
+ const MAX_HTTP_REQUEST_BYTES = 64 * 1024
104
+
105
+ // Preferred port for the HTTP control surface. Adjacent to CONTAINER_PORT
106
+ // (8973) for mnemonics. Stability matters: containers cache the URL in
107
+ // TYPECLAW_HOSTD_URL at `docker run` time, so a respawn that picks a fresh
108
+ // random port would leave running containers with stale URLs and no way to
109
+ // reach hostd. We try 8974 first and only fall back to an ephemeral port if
110
+ // it's already in use by some other local service.
111
+ const STABLE_HTTP_PORT = 8974
112
+
113
+ type ServerState = { buf: string }
114
+
115
+ function json(response: RpcResponse, status = 200): globalThis.Response {
116
+ return new Response(JSON.stringify(response), {
117
+ status,
118
+ headers: { 'content-type': 'application/json' },
119
+ })
120
+ }
121
+
122
+ function bearerToken(value: string | null): string | null {
123
+ if (!value) return null
124
+ const prefix = 'Bearer '
125
+ if (!value.startsWith(prefix)) return null
126
+ return value.slice(prefix.length)
127
+ }
128
+
129
+ type RestoredPayload = {
130
+ containerName: string
131
+ cwd: string
132
+ restartToken?: string
133
+ wsHostPort?: number
134
+ portForward?: PortForward
135
+ brokerToken?: string
136
+ }
137
+
138
+ function isValidRestoredPayload(value: unknown, expectedName: string): value is RestoredPayload {
139
+ if (!value || typeof value !== 'object') return false
140
+ const v = value as Record<string, unknown>
141
+ if (v.containerName !== expectedName) return false
142
+ if (typeof v.cwd !== 'string') return false
143
+ if (v.restartToken !== undefined && typeof v.restartToken !== 'string') return false
144
+ if (v.wsHostPort !== undefined && (typeof v.wsHostPort !== 'number' || !Number.isFinite(v.wsHostPort))) return false
145
+ if (v.brokerToken !== undefined && typeof v.brokerToken !== 'string') return false
146
+ return true
147
+ }
148
+
149
+ async function restorePersistedRegistrations(
150
+ apply: (payload: RestoredPayload) => void,
151
+ log: (event: DaemonLogEvent | SupervisorLogEvent) => void,
152
+ ): Promise<void> {
153
+ let entries: string[]
154
+ try {
155
+ entries = await readdir(registrationsDir())
156
+ } catch {
157
+ return
158
+ }
159
+ for (const entry of entries) {
160
+ if (!entry.endsWith('.json')) continue
161
+ const expectedName = entry.slice(0, -'.json'.length)
162
+ const filePath = join(registrationsDir(), entry)
163
+ let parsed: unknown
164
+ try {
165
+ parsed = JSON.parse(await readFile(filePath, 'utf8'))
166
+ } catch (error) {
167
+ log({ kind: 'registration-skipped', containerName: expectedName, reason: stringifyError(error) })
168
+ continue
169
+ }
170
+ if (!isValidRestoredPayload(parsed, expectedName)) {
171
+ log({ kind: 'registration-skipped', containerName: expectedName, reason: 'schema mismatch' })
172
+ continue
173
+ }
174
+ apply(parsed)
175
+ }
176
+ }
177
+
178
+ function stringifyError(error: unknown): string {
179
+ return error instanceof Error ? error.message : String(error)
180
+ }
181
+
182
+ export async function startDaemon(opts: DaemonOptions = {}): Promise<Daemon> {
183
+ await ensureDirs()
184
+ const path = opts.socket ?? socketPath()
185
+
186
+ if (existsSync(path)) {
187
+ if (await isDaemonReachable(500)) {
188
+ throw new Error(`another typeclaw host daemon is already listening at ${path}`)
189
+ }
190
+ try {
191
+ await unlink(path)
192
+ } catch {}
193
+ }
194
+
195
+ const log = opts.onLog ?? (() => {})
196
+ const exec = opts.exec ?? defaultDockerExec
197
+ const gcIntervalMs = opts.gcIntervalMs ?? DEFAULT_GC_INTERVAL_MS
198
+ const gcMissesToDeregister = opts.gcMissesToDeregister ?? DEFAULT_GC_MISSES_TO_DEREGISTER
199
+ const version = opts.version ?? UNVERSIONED_SENTINEL
200
+ const cwds = new Map<string, string>()
201
+ const restartTokens = new Map<string, string>()
202
+ const perContainerSerial = new Map<string, Promise<unknown>>()
203
+ const gcMisses = new Map<string, number>()
204
+ let stopped = false
205
+ let httpPort = 0
206
+
207
+ const supervisor = opts.restart
208
+ ? buildSupervisor({
209
+ restart: opts.restart,
210
+ onLog: (event) => log(event),
211
+ isStopped: () => stopped,
212
+ })
213
+ : null
214
+
215
+ // Per-container serialization: register/deregister chains through the same
216
+ // promise per containerName, so a deregister arriving mid-register cannot
217
+ // observe a partial state.
218
+ const runSerially = <T>(name: string, op: () => Promise<T>): Promise<T> => {
219
+ const prev = perContainerSerial.get(name) ?? Promise.resolve()
220
+ const next = prev.then(op, op)
221
+ perContainerSerial.set(
222
+ name,
223
+ next.catch(() => {}),
224
+ )
225
+ return next
226
+ }
227
+
228
+ type RegisterPayload = {
229
+ containerName: string
230
+ cwd: string
231
+ restartToken?: string
232
+ wsHostPort?: number
233
+ portForward?: PortForward
234
+ brokerToken?: string
235
+ }
236
+
237
+ // Atomic write: temp + rename within registrationsDir() so a crash mid-write
238
+ // never leaves a half-written file that boot-time restore would misparse.
239
+ const persistRegistration = async (payload: RegisterPayload): Promise<void> => {
240
+ const final = registrationFilePath(payload.containerName)
241
+ const tmp = `${final}.${process.pid}.tmp`
242
+ const record = {
243
+ containerName: payload.containerName,
244
+ cwd: payload.cwd,
245
+ restartToken: payload.restartToken,
246
+ wsHostPort: payload.wsHostPort,
247
+ portForward: payload.portForward,
248
+ brokerToken: payload.brokerToken,
249
+ }
250
+ await writeFile(tmp, JSON.stringify(record), { mode: 0o600 })
251
+ await rename(tmp, final)
252
+ }
253
+
254
+ const removeRegistrationFile = async (containerName: string): Promise<void> => {
255
+ try {
256
+ await unlink(registrationFilePath(containerName))
257
+ } catch {}
258
+ }
259
+
260
+ const applyRegistration = (payload: RegisterPayload): void => {
261
+ const alreadyRegistered = cwds.has(payload.containerName)
262
+ cwds.set(payload.containerName, payload.cwd)
263
+ if (payload.restartToken) restartTokens.set(payload.containerName, payload.restartToken)
264
+ else restartTokens.delete(payload.containerName)
265
+ if (!alreadyRegistered) {
266
+ log({ kind: 'register', containerName: payload.containerName })
267
+ }
268
+ if (
269
+ opts.portbroker &&
270
+ payload.wsHostPort !== undefined &&
271
+ payload.portForward !== undefined &&
272
+ payload.brokerToken !== undefined
273
+ ) {
274
+ opts.portbroker.start({
275
+ containerName: payload.containerName,
276
+ cwd: payload.cwd,
277
+ policy: payload.portForward,
278
+ wsHostPort: payload.wsHostPort,
279
+ brokerToken: payload.brokerToken,
280
+ onEvent: (event) => log({ kind: 'port-forward-event', event }),
281
+ onTailscaleServeEvent: (event) => log({ kind: 'tailscale-serve-event', event }),
282
+ })
283
+ }
284
+ }
285
+
286
+ const handleRegister = async (req: RegisterPayload): Promise<RpcResponse> => {
287
+ if (stopped) return { ok: false, reason: 'daemon stopping' }
288
+ return runSerially(req.containerName, async () => {
289
+ if (stopped) return { ok: false, reason: 'daemon stopping' }
290
+ try {
291
+ await persistRegistration(req)
292
+ } catch (error) {
293
+ return {
294
+ ok: false,
295
+ reason: `failed to persist registration: ${error instanceof Error ? error.message : String(error)}`,
296
+ }
297
+ }
298
+ applyRegistration(req)
299
+ return { ok: true }
300
+ })
301
+ }
302
+
303
+ const handleDeregister = async (req: { containerName: string }): Promise<RpcResponse> =>
304
+ runSerially(req.containerName, async () => {
305
+ const hadCwd = cwds.delete(req.containerName)
306
+ restartTokens.delete(req.containerName)
307
+ gcMisses.delete(req.containerName)
308
+ if (opts.portbroker) await opts.portbroker.stop(req.containerName, 'deregistered').catch(() => {})
309
+ await removeRegistrationFile(req.containerName)
310
+ if (hadCwd) log({ kind: 'deregister', containerName: req.containerName, reason: 'requested' })
311
+ return { ok: true }
312
+ })
313
+
314
+ const handleList = (): RpcResponse => {
315
+ const result: ListResult = {
316
+ registrations: Array.from(cwds.entries()).map(([containerName, cwd]) => ({ containerName, cwd })),
317
+ }
318
+ return { ok: true, result }
319
+ }
320
+
321
+ const handleStatus = (req: { containerName: string }): RpcResponse => {
322
+ const cwd = cwds.get(req.containerName)
323
+ if (!cwd) return { ok: false, reason: `not registered: ${req.containerName}` }
324
+ const result: StatusResult = {
325
+ containerName: req.containerName,
326
+ cwd,
327
+ forwardedPorts: opts.portbroker?.forwardedPorts(req.containerName) ?? [],
328
+ }
329
+ return { ok: true, result }
330
+ }
331
+
332
+ // Auth: only restart containers that registered with this daemon. The
333
+ // socket is 0o600 + UID-bound, but inside a container any process that
334
+ // reaches the mounted socket could otherwise restart any peer container on
335
+ // the host. Scoping by registered name limits the blast radius to the set
336
+ // of containers this user already started.
337
+ const handleRestart = async (req: { containerName: string; build?: boolean }): Promise<RpcResponse> => {
338
+ if (!supervisor) return { ok: false, reason: 'restart capability not enabled on this daemon' }
339
+ if (req.build !== undefined && typeof req.build !== 'boolean') {
340
+ return { ok: false, reason: 'restart.build must be a boolean if provided' }
341
+ }
342
+ const cwd = cwds.get(req.containerName)
343
+ if (!cwd) return { ok: false, reason: `not registered: ${req.containerName}` }
344
+ const preflight = opts.restartPreflight
345
+ ? await opts.restartPreflight({ containerName: req.containerName, cwd, build: req.build })
346
+ : null
347
+ if (preflight) return preflight
348
+ const ack = supervisor.scheduleRestart({ containerName: req.containerName, cwd, build: req.build })
349
+ if (!ack.ok) return ack
350
+ const result: RestartResult = { containerName: req.containerName, scheduled: true }
351
+ return { ok: true, result }
352
+ }
353
+
354
+ const handleHttpInfo = (): RpcResponse => {
355
+ const result: HttpInfoResult = { port: httpPort }
356
+ return { ok: true, result }
357
+ }
358
+
359
+ const handleVersion = (): RpcResponse => {
360
+ const result: VersionResult = { version }
361
+ return { ok: true, result }
362
+ }
363
+
364
+ // Honors a `shutdown` RPC by ACKing first, then tearing the daemon down on
365
+ // the next tick so the reply has time to drain over the socket. The CLI's
366
+ // respawn flow polls the socket file's disappearance to know when it can
367
+ // safely spawn a fresh daemon, which is why teardown must complete (and
368
+ // unlink the socket) before exit. Why an RPC instead of the pidfile-based
369
+ // SIGTERM the AGENTS.md "PID-reuse safety" rule warns about: the socket
370
+ // round-trip itself proves we are talking to the daemon we just registered
371
+ // with, so a stale pidfile cannot redirect the kill to an unrelated process.
372
+ const handleShutdown = (): RpcResponse => {
373
+ if (stopped) return { ok: true, result: { scheduled: true } satisfies ShutdownResult }
374
+ log({ kind: 'shutdown-requested' })
375
+ setTimeout(() => {
376
+ void daemonHandle.stop().then(() => {
377
+ if (opts.onShutdown) opts.onShutdown()
378
+ })
379
+ }, 0)
380
+ return { ok: true, result: { scheduled: true } satisfies ShutdownResult }
381
+ }
382
+
383
+ const dispatch = async (req: Request): Promise<RpcResponse> => {
384
+ switch (req.kind) {
385
+ case 'register':
386
+ return handleRegister(req)
387
+ case 'deregister':
388
+ return handleDeregister(req)
389
+ case 'list':
390
+ return handleList()
391
+ case 'status':
392
+ return handleStatus(req)
393
+ case 'restart':
394
+ return handleRestart(req)
395
+ case 'http-info':
396
+ return handleHttpInfo()
397
+ case 'version':
398
+ return handleVersion()
399
+ case 'shutdown':
400
+ return handleShutdown()
401
+ }
402
+ }
403
+
404
+ const respond = (sock: Socket<ServerState>, response: RpcResponse): void => {
405
+ try {
406
+ sock.write(`${JSON.stringify(response)}\n`)
407
+ } catch {}
408
+ try {
409
+ sock.end()
410
+ } catch {}
411
+ }
412
+
413
+ const handleData = (sock: Socket<ServerState>, chunk: Buffer): void => {
414
+ sock.data.buf += chunk.toString('utf8')
415
+ if (sock.data.buf.length > MAX_REQUEST_BUFFER_BYTES) {
416
+ respond(sock, { ok: false, reason: 'request exceeds buffer limit' })
417
+ return
418
+ }
419
+ let newline = sock.data.buf.indexOf('\n')
420
+ while (newline >= 0) {
421
+ const line = sock.data.buf.slice(0, newline)
422
+ sock.data.buf = sock.data.buf.slice(newline + 1)
423
+ let req: Request
424
+ try {
425
+ req = JSON.parse(line) as Request
426
+ } catch {
427
+ respond(sock, { ok: false, reason: 'invalid request json' })
428
+ return
429
+ }
430
+ void dispatch(req).then(
431
+ (response) => respond(sock, response),
432
+ (error) => respond(sock, { ok: false, reason: error instanceof Error ? error.message : String(error) }),
433
+ )
434
+ newline = sock.data.buf.indexOf('\n')
435
+ }
436
+ }
437
+
438
+ const httpFetch = async (req: globalThis.Request): Promise<globalThis.Response> => {
439
+ const url = new URL(req.url)
440
+ if (req.method !== 'POST' || url.pathname !== '/rpc') {
441
+ return json({ ok: false, reason: 'not found' }, 404)
442
+ }
443
+ const token = bearerToken(req.headers.get('authorization'))
444
+ if (!token) return json({ ok: false, reason: 'missing bearer token' }, 401)
445
+ const contentLength = Number(req.headers.get('content-length') ?? '0')
446
+ if (Number.isFinite(contentLength) && contentLength > MAX_HTTP_REQUEST_BYTES) {
447
+ return json({ ok: false, reason: 'request exceeds buffer limit' }, 413)
448
+ }
449
+ let rpc: Request
450
+ try {
451
+ const body = await req.text()
452
+ if (body.length > MAX_HTTP_REQUEST_BYTES) return json({ ok: false, reason: 'request exceeds buffer limit' }, 413)
453
+ rpc = JSON.parse(body) as Request
454
+ } catch {
455
+ return json({ ok: false, reason: 'invalid request json' }, 400)
456
+ }
457
+ if (rpc.kind !== 'restart') {
458
+ return json({ ok: false, reason: 'http transport only supports restart' }, 403)
459
+ }
460
+ if (restartTokens.get(rpc.containerName) !== token) {
461
+ return json({ ok: false, reason: 'invalid restart token' }, 403)
462
+ }
463
+ return json(await handleRestart(rpc))
464
+ }
465
+
466
+ const httpHostname = opts.httpHost ?? '0.0.0.0'
467
+ // Try the stable port first so containers' cached TYPECLAW_HOSTD_URL stays
468
+ // valid across hostd respawns. EADDRINUSE means another local service holds
469
+ // it — fall back to ephemeral so the daemon still comes up. The fallback
470
+ // doesn't break NEW container starts (the URL is captured fresh from
471
+ // httpServer.port), but it does break the URL of containers that started
472
+ // when 8974 was free and are still running. That trade-off favors keeping
473
+ // hostd alive over preserving every URL — fail-hard would brick the whole
474
+ // dev workflow whenever a port collision is hit.
475
+ const tryServe = (port: number): ReturnType<typeof Bun.serve> | { error: 'EADDRINUSE' } => {
476
+ try {
477
+ return Bun.serve({ hostname: httpHostname, port, fetch: httpFetch })
478
+ } catch (error) {
479
+ if (error instanceof Error && (error as Error & { code?: string }).code === 'EADDRINUSE') {
480
+ return { error: 'EADDRINUSE' }
481
+ }
482
+ throw error
483
+ }
484
+ }
485
+ const preferredPort = opts.httpPort ?? STABLE_HTTP_PORT
486
+ const stableAttempt = tryServe(preferredPort)
487
+ const httpServer =
488
+ 'error' in stableAttempt ? Bun.serve({ hostname: httpHostname, port: 0, fetch: httpFetch }) : stableAttempt
489
+ if ('error' in stableAttempt) {
490
+ log({ kind: 'daemon-http-port-fallback', preferred: preferredPort, actual: httpServer.port ?? 0 })
491
+ }
492
+ httpPort = httpServer.port ?? 0
493
+ log({ kind: 'daemon-http-listening', host: httpHostname, port: httpPort })
494
+
495
+ // Boot-time restore: replay every persisted registration into the in-memory
496
+ // maps and revive portbroker for it. Runs before Bun.listen so the socket
497
+ // is never accepting RPCs against a half-restored registry. A bad file
498
+ // (parse error, schema mismatch) is logged-and-skipped — one corrupt
499
+ // registration must not gate every other container's recovery.
500
+ await restorePersistedRegistrations(applyRegistration, log)
501
+
502
+ const listener: UnixSocketListener<ServerState> = Bun.listen<ServerState>({
503
+ unix: path,
504
+ socket: {
505
+ open: (sock) => {
506
+ sock.data = { buf: '' }
507
+ },
508
+ data: handleData,
509
+ close: () => {},
510
+ error: () => {},
511
+ },
512
+ })
513
+ // Restrict socket to the owning user; ~/.typeclaw/run is also 0700.
514
+ await chmod(path, 0o600).catch(() => {})
515
+ log({ kind: 'daemon-listening', socket: path })
516
+
517
+ // GC tick distinguishes "container confirmed gone" from "docker call failed":
518
+ // a `docker ps` blip should not deregister a live container registration, so
519
+ // we require gcMissesToDeregister consecutive confirmed absences.
520
+ const probeContainerAlive = async (name: string): Promise<'alive' | 'gone' | 'unknown'> => {
521
+ try {
522
+ const result = await exec(['ps', '-a', '--filter', `name=^${name}$`, '--format', '{{.Names}}'])
523
+ if (result.exitCode !== 0) return 'unknown'
524
+ const names = result.stdout
525
+ .trim()
526
+ .split('\n')
527
+ .filter((s) => s.length > 0)
528
+ return names.includes(name) ? 'alive' : 'gone'
529
+ } catch {
530
+ return 'unknown'
531
+ }
532
+ }
533
+
534
+ const runGc = async (): Promise<void> => {
535
+ for (const name of Array.from(cwds.keys())) {
536
+ const status = await probeContainerAlive(name)
537
+ if (status === 'alive') {
538
+ gcMisses.delete(name)
539
+ continue
540
+ }
541
+ if (status === 'unknown') continue
542
+ const misses = (gcMisses.get(name) ?? 0) + 1
543
+ if (misses < gcMissesToDeregister) {
544
+ gcMisses.set(name, misses)
545
+ continue
546
+ }
547
+ gcMisses.delete(name)
548
+ void runSerially(name, async () => {
549
+ const hadCwd = cwds.delete(name)
550
+ restartTokens.delete(name)
551
+ if (opts.portbroker) await opts.portbroker.stop(name, 'deregistered').catch(() => {})
552
+ await removeRegistrationFile(name)
553
+ if (hadCwd) log({ kind: 'deregister', containerName: name, reason: 'gone' })
554
+ return { ok: true }
555
+ })
556
+ }
557
+ }
558
+
559
+ const gcTimer = setInterval(() => {
560
+ if (stopped || cwds.size === 0) return
561
+ void runGc()
562
+ }, gcIntervalMs)
563
+
564
+ const daemonHandle: Daemon = {
565
+ registered: () => Array.from(cwds.keys()),
566
+ stop: async () => {
567
+ if (stopped) return
568
+ stopped = true
569
+ log({ kind: 'daemon-stopping' })
570
+ clearInterval(gcTimer)
571
+ try {
572
+ listener.stop(true)
573
+ } catch {}
574
+ httpServer.stop(true)
575
+ if (opts.portbroker) {
576
+ const names = Array.from(cwds.keys())
577
+ await Promise.allSettled(names.map((n) => opts.portbroker!.stop(n, 'broker-stopped')))
578
+ }
579
+ cwds.clear()
580
+ restartTokens.clear()
581
+ try {
582
+ if (existsSync(path)) await unlink(path)
583
+ } catch {}
584
+ },
585
+ }
586
+ return daemonHandle
587
+ }
@@ -0,0 +1,25 @@
1
+ export { isDaemonReachable, send } from './client'
2
+ export { startDaemon, type Daemon, type DaemonLogEvent, type DaemonOptions } from './daemon'
3
+ export {
4
+ containerSocketPath,
5
+ ensureDirs,
6
+ homeRoot,
7
+ lockfilePath,
8
+ logDir,
9
+ logfilePath,
10
+ pidfilePath,
11
+ runDir,
12
+ socketPath,
13
+ } from './paths'
14
+ export type {
15
+ ListResult,
16
+ Request,
17
+ Response,
18
+ RestartResult,
19
+ ShutdownResult,
20
+ StatusResult,
21
+ VersionResult,
22
+ } from './protocol'
23
+ export { ensureDaemon, type EnsureDaemonOptions, type EnsureDaemonResult } from './spawn'
24
+ export type { SupervisorOptions } from './supervisor'
25
+ export { computeSourceVersion, resolveSrcRoot, UNVERSIONED_SENTINEL, type SourceVersion } from './version'
@@ -0,0 +1,82 @@
1
+ import { chmod, mkdir } from 'node:fs/promises'
2
+ import { homedir } from 'node:os'
3
+ import { join } from 'node:path'
4
+
5
+ // Fixed in-container path where the host daemon's run dir is bind-mounted.
6
+ // The agent uses this to reach the host daemon (e.g. for the `restart` tool).
7
+ // Kept stable so the agent never has to discover the host's `~/.typeclaw`
8
+ // location at runtime.
9
+ const CONTAINER_HOST_RUN_DIR = '/run/typeclaw-host'
10
+ const SOCKET_FILE = 'hostd.sock'
11
+ const REGISTRATIONS_DIR = 'registrations'
12
+
13
+ // Defense-in-depth: containerName arrives from RPC payloads (some of which
14
+ // originate inside the container). Docker already forbids slashes and most
15
+ // punctuation in names, but we don't want to trust the wire to enforce that.
16
+ // The character class mirrors Docker's container-naming rules; anything
17
+ // else is rejected so a malicious payload can't escape registrationsDir().
18
+ const SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/
19
+
20
+ export function homeRoot(): string {
21
+ const override = process.env.TYPECLAW_HOME
22
+ if (override && override.length > 0) return override
23
+ return join(homedir(), '.typeclaw')
24
+ }
25
+
26
+ export function runDir(): string {
27
+ return join(homeRoot(), 'run')
28
+ }
29
+
30
+ export function logDir(): string {
31
+ return join(homeRoot(), 'log')
32
+ }
33
+
34
+ export function socketPath(): string {
35
+ return join(runDir(), SOCKET_FILE)
36
+ }
37
+
38
+ export function pidfilePath(): string {
39
+ return join(runDir(), 'hostd.pid')
40
+ }
41
+
42
+ export function lockfilePath(): string {
43
+ return join(runDir(), 'hostd.lock')
44
+ }
45
+
46
+ export function logfilePath(): string {
47
+ return join(logDir(), 'hostd.log')
48
+ }
49
+
50
+ export function registrationsDir(): string {
51
+ return join(runDir(), REGISTRATIONS_DIR)
52
+ }
53
+
54
+ // Throws on any name that could traverse out of registrationsDir() or
55
+ // confuse the filesystem. Caller's responsibility to handle the error;
56
+ // don't catch-and-ignore — an invalid name is a protocol violation.
57
+ export function registrationFilePath(containerName: string): string {
58
+ if (!SAFE_NAME.test(containerName)) {
59
+ throw new Error(`invalid container name for registration file: ${JSON.stringify(containerName)}`)
60
+ }
61
+ return join(registrationsDir(), `${containerName}.json`)
62
+ }
63
+
64
+ // In-container path to the same socket the host daemon listens on. The
65
+ // container-stage agent tool dials this path; the host bind-mounts the host
66
+ // run dir at CONTAINER_HOST_RUN_DIR so the socket is reachable.
67
+ export function containerSocketPath(): string {
68
+ return join(CONTAINER_HOST_RUN_DIR, SOCKET_FILE)
69
+ }
70
+
71
+ export function containerHostRunDir(): string {
72
+ return CONTAINER_HOST_RUN_DIR
73
+ }
74
+
75
+ export async function ensureDirs(): Promise<void> {
76
+ await mkdir(runDir(), { recursive: true })
77
+ await mkdir(logDir(), { recursive: true })
78
+ await mkdir(registrationsDir(), { recursive: true })
79
+ await chmod(runDir(), 0o700).catch(() => {})
80
+ await chmod(logDir(), 0o700).catch(() => {})
81
+ await chmod(registrationsDir(), 0o700).catch(() => {})
82
+ }