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,670 @@
1
+ import { randomBytes } from 'node:crypto'
2
+ import { existsSync } from 'node:fs'
3
+ import { readFile, writeFile } from 'node:fs/promises'
4
+ import { isAbsolute, join, resolve } from 'node:path'
5
+
6
+ import { configSchema, expandMountPath, type Config } from '@/config/config'
7
+ import { send as sendToDaemon } from '@/hostd/client'
8
+ import type { HttpInfoResult } from '@/hostd/protocol'
9
+ import { ensureDaemon } from '@/hostd/spawn'
10
+ import { buildDockerfile, DOCKERFILE } from '@/init/dockerfile'
11
+ import { ensureDepsInstalled, type EnsureDepsResult } from '@/init/ensure-deps'
12
+ import { buildGitignore, GITIGNORE_FILE } from '@/init/gitignore'
13
+ import { refreshPackageJson } from '@/init/packagejson'
14
+
15
+ import { CONTAINER_PORT, findFreePort, isPortAllocatedError } from './port'
16
+ import {
17
+ classifyRmStderr,
18
+ cleanupRunCorpse,
19
+ containerNameFromCwd,
20
+ defaultDockerExec,
21
+ type DockerExec,
22
+ type DockerExecResult,
23
+ getBun,
24
+ imageTagFromCwd,
25
+ isContainerNameConflict,
26
+ sanitizeDockerStderr,
27
+ waitForRemoval,
28
+ } from './shared'
29
+ import { buildCrashReason, createVerifyRunning, type VerifyRunningFn } from './verify-running'
30
+
31
+ const PACKAGE_FILE = 'package.json'
32
+ const BUN_LOCK_FILE = 'bun.lock'
33
+ const DEPENDENCY_FILES = [PACKAGE_FILE, BUN_LOCK_FILE] as const
34
+ const CONFIG_FILE = 'typeclaw.json'
35
+ const ENV_FILE = '.env'
36
+ const COMPOSE_PROJECT = 'typeclaw'
37
+ const CONTAINER_HOSTD_HOST = 'host.docker.internal'
38
+ const HOST_GATEWAY_ALIAS = `${CONTAINER_HOSTD_HOST}:host-gateway`
39
+
40
+ const MOUNT_TARGET_PREFIX = '/agent/mounts'
41
+
42
+ export type StartPlan = {
43
+ containerName: string
44
+ imageTag: string
45
+ buildContext: string
46
+ dockerfile: string
47
+ runArgs: string[]
48
+ needsBuild: boolean
49
+ hostPort: number
50
+ }
51
+
52
+ export type PlanStartOptions = {
53
+ cwd: string
54
+ hostPort: number
55
+ imageExists: boolean
56
+ forceBuild?: boolean
57
+ hostdControl?: HostDaemonControl
58
+ }
59
+
60
+ export type HostDaemonControl = {
61
+ url: string
62
+ token: string
63
+ brokerToken: string
64
+ }
65
+
66
+ export type StartOptions = {
67
+ cwd: string
68
+ preferredHostPort: number
69
+ forceBuild?: boolean
70
+ exec?: DockerExec
71
+ // Test seam: allows tests to inject a deterministic port allocator. In
72
+ // production we go through the real kernel via `findFreePort`.
73
+ allocatePort?: (preferred: number) => Promise<number>
74
+ cliEntry?: string
75
+ // Hostd's supervisor restart callback already runs inside the daemon process.
76
+ // Reusing that daemon avoids a self-shutdown when disk source has drifted.
77
+ reuseCurrentHostDaemon?: boolean
78
+ ensureDeps?: (cwd: string) => Promise<EnsureDepsResult>
79
+ // Post-`docker run` verifier. `docker run -d` returns exit 0 the moment the
80
+ // container is created, even if its entrypoint crashes milliseconds later.
81
+ // The default verifier polls `docker inspect` for 1.5s and converts crashes
82
+ // (or unrecoverable daemon errors) into start failures, with the crashed
83
+ // container's `docker logs` captured into the failure reason. Pass a custom
84
+ // function to override the wait window or to bypass verification entirely
85
+ // (e.g. a no-op `async () => ({ ok: true })` for unit tests that don't care).
86
+ verifyRunning?: VerifyRunningFn
87
+ }
88
+
89
+ export type HostDaemonStatus =
90
+ | { state: 'registered' }
91
+ | { state: 'unavailable'; reason: string }
92
+ | { state: 'disabled' }
93
+
94
+ export type StartResult =
95
+ | {
96
+ ok: true
97
+ plan: StartPlan
98
+ containerId: string
99
+ built: boolean
100
+ hostPort: number
101
+ hostd: HostDaemonStatus
102
+ // True when the container was already running and start() became a no-op.
103
+ // Callers that want to distinguish "I just launched it" from "it was up
104
+ // already" (CLI output, compose summaries) gate on this flag. False on
105
+ // every fresh launch, including the post-stale-corpse `--rm` recovery
106
+ // path — that one rebuilds the container from scratch.
107
+ alreadyRunning: boolean
108
+ }
109
+ | { ok: false; reason: string }
110
+
111
+ export async function start({
112
+ cwd,
113
+ preferredHostPort,
114
+ forceBuild = false,
115
+ exec = defaultDockerExec,
116
+ allocatePort = findFreePort,
117
+ cliEntry,
118
+ reuseCurrentHostDaemon = false,
119
+ ensureDeps = (dir) => ensureDepsInstalled({ cwd: dir }),
120
+ verifyRunning = createVerifyRunning({ exec }),
121
+ }: StartOptions): Promise<StartResult> {
122
+ try {
123
+ const containerName = containerNameFromCwd(cwd)
124
+ const imageTagValue = imageTagFromCwd(cwd)
125
+
126
+ // Probe container state BEFORE refreshing Dockerfile/.gitignore: when the
127
+ // container is already running, start() is a no-op and must not produce
128
+ // side effects (template writes, .gitignore commits, package.json migration)
129
+ // that would surprise a user invoking `compose start` against a partially-up
130
+ // tree.
131
+ const state = await inspectContainer(exec, containerName)
132
+ if (state.exists && state.running) {
133
+ return await reportAlreadyRunning(exec, cwd, containerName)
134
+ }
135
+
136
+ // TypeClaw owns Dockerfile, .gitignore, and the bun-workspaces shape of
137
+ // package.json. Refresh them from the current CLI templates on every fresh
138
+ // start (not just --build) so version drift between the agent folder and
139
+ // the CLI is corrected automatically. The Dockerfile is gitignored
140
+ // (regenerated on every start, never tracked), so only .gitignore and the
141
+ // package.json migration land in git. The package.json migration is
142
+ // one-shot and idempotent — once `workspaces` is set, refreshPackageJson
143
+ // is a no-op, so users who never edit their agent folder pay zero cost on
144
+ // subsequent starts and users who customized `workspaces` are not clobbered.
145
+ await refreshDockerfile(cwd)
146
+ await refreshGitignore(cwd)
147
+ const pkgRefresh = await refreshPackageJson(cwd)
148
+ await commitSystemFile(cwd, GITIGNORE_FILE, 'Update .gitignore')
149
+ if (pkgRefresh.changed) {
150
+ await commitSystemFile(cwd, pkgRefresh.files, 'Enable bun workspaces (packages/*)')
151
+ }
152
+ // Run `bun install` BEFORE the dependency-drift commit so the lockfile
153
+ // changes the install produces are caught by the same commit. Without
154
+ // this, upgrading the typeclaw CLI to a version that adds a new dep
155
+ // (e.g. a new transitive dep that needs hoisting) leaves the agent's
156
+ // node_modules/ partially populated. The container then crashes with
157
+ // `Cannot find package 'x'` because the agent folder is bind-mounted into
158
+ // /agent and the container has no node_modules of its own.
159
+ const deps = await ensureDeps(cwd)
160
+ if (!deps.ok) {
161
+ return { ok: false, reason: `dependency install failed: ${deps.reason}` }
162
+ }
163
+ await commitSystemFile(cwd, DEPENDENCY_FILES, 'Update dependencies')
164
+
165
+ if (state.exists) {
166
+ // Container holds the name but is not running. Without `--rm`, this is
167
+ // now the normal post-stop / post-crash state: the corpse stays around
168
+ // for `docker logs` so users can debug a crashed agent. Force-remove
169
+ // before `docker run --name <same>` so the new launch doesn't collide
170
+ // on the name. See classifyRmStderr for the benign-failure contract:
171
+ // 'gone' means the name is already free; 'in-progress' means Docker is
172
+ // still draining a prior removal and we must wait it out before docker
173
+ // run, or we'd hit `Conflict. The container name "/<name>" is already
174
+ // in use` even though our rm "succeeded".
175
+ //
176
+ // Even when `docker rm -f` returns exit 0 we MUST wait for the inspect
177
+ // probe to confirm the name is free. On OrbStack (and occasionally
178
+ // Docker Desktop) under concurrent load — the canonical case being
179
+ // `typeclaw compose restart`, which fires N parallel stop→start pairs
180
+ // — `rm -f` acknowledges the request before the daemon has finished
181
+ // draining the removal. The container is still listed by `docker ps -a`
182
+ // (with the same ID Docker reports back in the "Conflict. The container
183
+ // name … is already in use by container <ID>" error) for tens to
184
+ // hundreds of milliseconds, and `docker run --name <same>` issued
185
+ // inside that window deterministically loses the race. waitForRemoval
186
+ // returns on the first inspect probe in the happy path (one extra
187
+ // `docker inspect` per start when there was a corpse), so the cost
188
+ // here is bounded and small.
189
+ const rm = await exec(['rm', '-f', containerName])
190
+ if (rm.exitCode !== 0) {
191
+ const kind = classifyRmStderr(rm.stderr)
192
+ if (kind === null) {
193
+ return {
194
+ ok: false,
195
+ reason: `Container ${containerName} exists but is not running, and could not be removed: ${sanitizeDockerStderr(rm.stderr) || 'no stderr'}`,
196
+ }
197
+ }
198
+ if (kind === 'in-progress' && !(await waitForRemoval(exec, containerName))) {
199
+ return {
200
+ ok: false,
201
+ reason: `Container ${containerName} is still being removed by docker after 10s; refusing to docker run --name to avoid a name conflict.`,
202
+ }
203
+ }
204
+ } else if (!(await waitForRemoval(exec, containerName))) {
205
+ return {
206
+ ok: false,
207
+ reason: `Container ${containerName} is still being removed by docker after 10s; refusing to docker run --name to avoid a name conflict.`,
208
+ }
209
+ }
210
+ }
211
+
212
+ const imageExisted = await imageExists(exec, imageTagValue)
213
+
214
+ // First attempt uses the user's preferred host port (8973 by default, or
215
+ // whatever they passed via --port / typeclaw.json). If it's already bound
216
+ // we fall through to a kernel-assigned ephemeral port. The container's
217
+ // internal port stays fixed at CONTAINER_PORT regardless.
218
+ let hostPort = await allocatePort(preferredHostPort)
219
+
220
+ // Register AFTER port allocation so the daemon's portbroker has the right
221
+ // wsHostPort. Re-register on TOCTOU retry below if the port changes.
222
+ let hostd: PreparedHostDaemonStatus = cliEntry
223
+ ? await registerWithDaemon({ cwd, containerName, cliEntry, hostPort, reuseCurrentHostDaemon })
224
+ : { state: 'disabled' as const }
225
+ let hostdControl = hostd.state === 'registered' ? hostd.control : undefined
226
+
227
+ let plan = await planStart({ cwd, hostPort, imageExists: imageExisted, forceBuild, hostdControl })
228
+
229
+ let built = false
230
+ if (plan.needsBuild) {
231
+ const build = await exec(['build', '-t', plan.imageTag, plan.buildContext], { cwd, inheritStdio: true })
232
+ if (build.exitCode !== 0) {
233
+ await cleanupHostDaemonRegistration(containerName, hostd)
234
+ return { ok: false, reason: 'docker build failed' }
235
+ }
236
+ built = true
237
+ }
238
+
239
+ let run = await execRunWithConflictRetry(exec, plan.runArgs, cwd, containerName)
240
+
241
+ // TOCTOU: another process may have grabbed the port between our probe and
242
+ // `docker run`, or the kernel-assigned port may itself have been claimed.
243
+ // Treat docker as the authority and retry once with a fresh ephemeral port.
244
+ // Skip rebuild on retry: the image is already on disk from the first attempt.
245
+ // Re-register so the daemon's broker resolver returns the new port.
246
+ //
247
+ // Failed `docker run -p` calls can leave a created-but-not-running
248
+ // container record behind: depending on daemon version, Docker creates
249
+ // the container before binding the port, so the bind failure aborts
250
+ // start but leaves the corpse holding the name. The port-TOCTOU retry
251
+ // would then re-run `docker run --name <same>` and hit a name conflict
252
+ // against that corpse. Clean it up before the retry so the new run sees
253
+ // a free name. cleanupRunCorpse is safe (only force-removes non-running
254
+ // same-name containers) and a no-op when the name is already free.
255
+ if (run.exitCode !== 0 && isPortAllocatedError(run.stderr)) {
256
+ const cleanup = await cleanupRunCorpse(exec, containerName)
257
+ if (cleanup === 'running') {
258
+ await cleanupHostDaemonRegistration(containerName, hostd)
259
+ return {
260
+ ok: false,
261
+ reason: `docker run failed (port bind) but cleanup found ${containerName} now running — refusing to retry against a live container.`,
262
+ }
263
+ }
264
+ if (cleanup === 'stuck') {
265
+ await cleanupHostDaemonRegistration(containerName, hostd)
266
+ return {
267
+ ok: false,
268
+ reason: `docker run failed (${sanitizeDockerStderr(run.stderr) || 'port bind'}) and the failed-run corpse for ${containerName} did not disappear within 10s; refusing to retry.`,
269
+ }
270
+ }
271
+ hostPort = await allocatePort(0)
272
+ if (cliEntry) {
273
+ hostd = await registerWithDaemon({ cwd, containerName, cliEntry, hostPort, reuseCurrentHostDaemon })
274
+ hostdControl = hostd.state === 'registered' ? hostd.control : undefined
275
+ }
276
+ plan = await planStart({ cwd, hostPort, imageExists: true, forceBuild: false, hostdControl })
277
+ run = await execRunWithConflictRetry(exec, plan.runArgs, cwd, containerName)
278
+ }
279
+
280
+ if (run.exitCode !== 0) {
281
+ await cleanupHostDaemonRegistration(containerName, hostd)
282
+ return { ok: false, reason: `docker run failed: ${sanitizeDockerStderr(run.stderr) || 'no stderr'}` }
283
+ }
284
+
285
+ const containerId = run.stdout.trim()
286
+
287
+ const verification = await verifyRunning(containerName)
288
+ if (!verification.ok) {
289
+ await cleanupHostDaemonRegistration(containerName, hostd)
290
+ return { ok: false, reason: buildCrashReason(containerName, verification) }
291
+ }
292
+
293
+ return {
294
+ ok: true,
295
+ plan,
296
+ containerId,
297
+ built,
298
+ hostPort,
299
+ hostd: stripHostDaemonControl(hostd),
300
+ alreadyRunning: false,
301
+ }
302
+ } catch (error) {
303
+ return { ok: false, reason: error instanceof Error ? error.message : String(error) }
304
+ }
305
+ }
306
+
307
+ export async function planStart({
308
+ cwd,
309
+ hostPort,
310
+ imageExists,
311
+ forceBuild = false,
312
+ hostdControl,
313
+ }: PlanStartOptions): Promise<StartPlan> {
314
+ const containerName = containerNameFromCwd(cwd)
315
+ const imageTag = imageTagFromCwd(cwd)
316
+
317
+ const devSourcePath = await detectDevSource(cwd)
318
+ const mounts = await loadMounts(cwd)
319
+
320
+ // No `--rm`: a crashed container's logs MUST survive past exit so users can
321
+ // debug the failure. `typeclaw stop` removes the container explicitly, and
322
+ // the start() preflight force-removes any lingering corpse before the next
323
+ // launch — so the only state Docker ever sees in `docker ps -a` is either
324
+ // a running container or one the user has not started again yet.
325
+ const runArgs = ['run', '-d', '--name', containerName, '-p', `127.0.0.1:${hostPort}:${CONTAINER_PORT}`]
326
+
327
+ if (hostdControl) {
328
+ runArgs.push('--add-host', HOST_GATEWAY_ALIAS)
329
+ }
330
+
331
+ for (const [key, value] of Object.entries(composeLabels(cwd, containerName))) {
332
+ runArgs.push('--label', `${key}=${value}`)
333
+ }
334
+
335
+ if (existsSync(join(cwd, ENV_FILE))) {
336
+ runArgs.push('--env-file', join(cwd, ENV_FILE))
337
+ }
338
+
339
+ // Propagate the host timezone so cron schedules in typeclaw.json (and
340
+ // cron.json jobs without an explicit `timezone`) fire at wall-clock times
341
+ // the user expects. oven/bun:1-slim ships tzdata, so just setting TZ is
342
+ // enough — no Dockerfile change required.
343
+ const hostTz = resolveHostTimezone()
344
+ if (hostTz) {
345
+ runArgs.push('-e', `TZ=${hostTz}`)
346
+ }
347
+
348
+ // The agent's `restart` tool needs to identify itself to hostd. Inside the
349
+ // container, cwd is `/agent` and basename(cwd) loses the host folder name,
350
+ // so we cannot derive containerName from cwd at runtime. Inject it as an
351
+ // env var — same way TZ is plumbed.
352
+ runArgs.push('-e', `TYPECLAW_CONTAINER_NAME=${containerName}`)
353
+
354
+ if (hostdControl) {
355
+ runArgs.push('-e', `TYPECLAW_HOSTD_URL=${hostdControl.url}`)
356
+ runArgs.push('-e', `TYPECLAW_HOSTD_TOKEN=${hostdControl.token}`)
357
+ runArgs.push('-e', `TYPECLAW_HOSTD_BROKER_TOKEN=${hostdControl.brokerToken}`)
358
+ }
359
+
360
+ runArgs.push('-v', `${cwd}:/agent`)
361
+
362
+ // Dev mode: node_modules/typeclaw is a symlink to an absolute host path
363
+ // outside /agent. Mirror-mount that path so the symlink resolves in-container.
364
+ if (devSourcePath && !devSourcePath.startsWith(cwd)) {
365
+ runArgs.push('-v', `${devSourcePath}:${devSourcePath}:ro`)
366
+ }
367
+
368
+ for (const mount of mounts) {
369
+ const hostPath = expandMountPath(mount.path, cwd)
370
+ const target = `${MOUNT_TARGET_PREFIX}/${mount.name}`
371
+ runArgs.push('-v', mount.readOnly ? `${hostPath}:${target}:ro` : `${hostPath}:${target}`)
372
+ }
373
+
374
+ runArgs.push(imageTag)
375
+
376
+ return {
377
+ containerName,
378
+ imageTag,
379
+ buildContext: cwd,
380
+ dockerfile: join(cwd, DOCKERFILE),
381
+ runArgs,
382
+ needsBuild: forceBuild || !imageExists,
383
+ hostPort,
384
+ }
385
+ }
386
+
387
+ export async function refreshDockerfile(cwd: string): Promise<void> {
388
+ const cfg = await loadTypeclawConfig(cwd)
389
+ await writeFile(join(cwd, DOCKERFILE), buildDockerfile(cfg.dockerfile))
390
+ }
391
+
392
+ export async function refreshGitignore(cwd: string): Promise<void> {
393
+ const cfg = await loadTypeclawConfig(cwd)
394
+ await writeFile(join(cwd, GITIGNORE_FILE), buildGitignore(cfg.gitignore))
395
+ }
396
+
397
+ // Commits TypeClaw-owned system file(s) if any are dirty in git. Skips silently
398
+ // when the agent folder is not a git repo, when bun is unavailable, or when
399
+ // every named file is clean (no changes since last commit). Uses the user's
400
+ // global git config for authorship — TypeClaw does not impersonate the user
401
+ // here. Accepts a single file or an array; the array form produces a single
402
+ // atomic commit covering all listed paths, used for migrations that touch
403
+ // multiple files together (e.g. enabling bun workspaces writes both
404
+ // package.json and packages/.gitkeep in one commit).
405
+ export async function commitSystemFile(cwd: string, file: string | readonly string[], message: string): Promise<void> {
406
+ const files = typeof file === 'string' ? [file] : file
407
+ if (files.length === 0) return
408
+
409
+ const bun = getBun()
410
+ if (!bun) return
411
+ if (!existsSync(join(cwd, '.git'))) return
412
+
413
+ const status = bun.spawn({
414
+ cmd: ['git', 'status', '--porcelain', '--', ...files],
415
+ cwd,
416
+ stdout: 'pipe',
417
+ stderr: 'pipe',
418
+ })
419
+ if ((await status.exited) !== 0) return
420
+ const dirty = (await new Response(status.stdout).text()).trim().length > 0
421
+ if (!dirty) return
422
+
423
+ const add = bun.spawn({ cmd: ['git', 'add', '--', ...files], cwd, stdout: 'pipe', stderr: 'pipe' })
424
+ if ((await add.exited) !== 0) return
425
+
426
+ const commit = bun.spawn({
427
+ cmd: ['git', 'commit', '-m', message, '--only', '--', ...files],
428
+ cwd,
429
+ stdout: 'pipe',
430
+ stderr: 'pipe',
431
+ })
432
+ await commit.exited
433
+ }
434
+
435
+ async function imageExists(exec: DockerExec, tag: string): Promise<boolean> {
436
+ const result = await exec(['image', 'inspect', tag])
437
+ return result.exitCode === 0
438
+ }
439
+
440
+ type InspectedState = { exists: false } | { exists: true; running: boolean }
441
+
442
+ async function inspectContainer(exec: DockerExec, name: string): Promise<InspectedState> {
443
+ const result = await exec(['inspect', '--format', '{{.State.Running}}', name])
444
+ if (result.exitCode !== 0) return { exists: false }
445
+ return { exists: true, running: result.stdout.trim() === 'true' }
446
+ }
447
+
448
+ // Retries `docker run` on name-conflict responses by FIRST force-removing
449
+ // the non-running same-name corpse that's blocking the name. Sleep-only
450
+ // retries (PR #121's earlier approach) cannot recover when the corpse is
451
+ // stable — see isContainerNameConflict's comment for why corpses survive
452
+ // the preflight (port-bind-after-create leaves a created-but-not-running
453
+ // container record behind, and start()'s own port-TOCTOU retry triggers
454
+ // this path against that corpse).
455
+ //
456
+ // cleanupRunCorpse refuses to touch a running container, so a concurrent
457
+ // legitimate start of the same name (or a foreign-but-named container the
458
+ // user wants alive) is surfaced as a hard failure rather than silently
459
+ // killed. 'stuck' likewise surfaces — a wedged daemon that won't drain a
460
+ // removal needs the user to act (`docker rm -f <name>` manually, or restart
461
+ // Docker) instead of looping forever.
462
+ //
463
+ // A small bounded backoff (100/200/400ms) follows each cleanup before the
464
+ // next `docker run`. waitForRemoval polls `docker inspect`, which can
465
+ // report the container gone BEFORE Docker's internal name-reservation
466
+ // table has fully released the name. Without the backoff, the three
467
+ // retries can all fire inside the same daemon drain window and exhaust
468
+ // uselessly. The cumulative ~700ms is small next to the docker run RTT
469
+ // itself and dwarfed by the user-visible cost of a failed start.
470
+ //
471
+ // Only the name-conflict path engages this destructive retry. Any other
472
+ // non-zero exit (port-allocated, image-not-found, permission-denied) is
473
+ // returned unchanged so the existing port-conflict TOCTOU retry and
474
+ // surfacing keep working without being shadowed.
475
+ async function execRunWithConflictRetry(
476
+ exec: DockerExec,
477
+ runArgs: string[],
478
+ cwd: string,
479
+ containerName: string,
480
+ ): Promise<DockerExecResult> {
481
+ let last = await exec(runArgs, { cwd })
482
+ for (const backoffMs of CONFLICT_RETRY_BACKOFFS_MS) {
483
+ if (last.exitCode === 0) return last
484
+ if (!isContainerNameConflict(last.stderr)) return last
485
+ const outcome = await cleanupRunCorpse(exec, containerName)
486
+ if (outcome === 'running' || outcome === 'stuck') return last
487
+ await new Promise((resolve) => setTimeout(resolve, backoffMs))
488
+ last = await exec(runArgs, { cwd })
489
+ }
490
+ return last
491
+ }
492
+
493
+ const CONFLICT_RETRY_BACKOFFS_MS = [100, 200, 400] as const
494
+
495
+ // Idempotent path for `start()`: the named container is already up. Reflect
496
+ // the live container's identity (id) and host port in the result so callers
497
+ // (CLI, compose) can render an accurate "already running on port X" message
498
+ // and stay symmetric with the fresh-launch result shape. We do NOT touch
499
+ // hostd here — the existing container was registered (or not) at its original
500
+ // launch; re-registering would generate a new restart token that the running
501
+ // agent process does not have.
502
+ async function reportAlreadyRunning(exec: DockerExec, cwd: string, containerName: string): Promise<StartResult> {
503
+ const containerId = await queryContainerId(exec, containerName)
504
+ const hostPort = await queryPublishedHostPort(exec, containerName)
505
+ if (hostPort === null) {
506
+ return {
507
+ ok: false,
508
+ reason: `Container ${containerName} is running but its published host port could not be resolved.`,
509
+ }
510
+ }
511
+ const plan = await planStart({ cwd, hostPort, imageExists: true, forceBuild: false })
512
+ return {
513
+ ok: true,
514
+ plan,
515
+ containerId,
516
+ built: false,
517
+ hostPort,
518
+ hostd: { state: 'disabled' },
519
+ alreadyRunning: true,
520
+ }
521
+ }
522
+
523
+ async function queryContainerId(exec: DockerExec, name: string): Promise<string> {
524
+ const result = await exec(['inspect', '--format', '{{.Id}}', name])
525
+ if (result.exitCode !== 0) return ''
526
+ return result.stdout.trim()
527
+ }
528
+
529
+ // Mirrors `resolveHostPort` from ./port (which we cannot reuse directly because
530
+ // it goes through `defaultDockerExec` and would defeat the test seam).
531
+ async function queryPublishedHostPort(exec: DockerExec, name: string): Promise<number | null> {
532
+ const result = await exec(['port', name, `${CONTAINER_PORT}/tcp`])
533
+ if (result.exitCode !== 0) return null
534
+ const lines = result.stdout
535
+ .split('\n')
536
+ .map((line) => line.trim())
537
+ .filter((line) => line.length > 0)
538
+ if (lines.length === 0) return null
539
+ const ipv4 = lines.find((line) => /^\d{1,3}(\.\d{1,3}){3}:\d+$/.test(line))
540
+ const candidate = ipv4 ?? lines[0]!
541
+ const lastColon = candidate.lastIndexOf(':')
542
+ if (lastColon < 0) return null
543
+ const port = Number(candidate.slice(lastColon + 1))
544
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) return null
545
+ return port
546
+ }
547
+
548
+ // Mirror the canonical labels `docker compose up` sets so Docker Desktop groups
549
+ // all typeclaw agents under a single "typeclaw" project, and `docker compose ls`
550
+ // recognizes the project. Each agent shows up as a service named after its folder.
551
+ function composeLabels(cwd: string, service: string): Record<string, string> {
552
+ return {
553
+ 'com.docker.compose.project': COMPOSE_PROJECT,
554
+ 'com.docker.compose.service': service,
555
+ 'com.docker.compose.project.working_dir': cwd,
556
+ 'com.docker.compose.container-number': '1',
557
+ 'com.docker.compose.oneoff': 'False',
558
+ 'com.docker.compose.config-hash': 'manual',
559
+ }
560
+ }
561
+
562
+ async function detectDevSource(cwd: string): Promise<string | null> {
563
+ try {
564
+ const raw = await readFile(join(cwd, PACKAGE_FILE), 'utf8')
565
+ const pkg = JSON.parse(raw) as { dependencies?: Record<string, string> }
566
+ const spec = pkg.dependencies?.typeclaw
567
+ if (!spec || !spec.startsWith('file:')) return null
568
+ const target = spec.slice('file:'.length)
569
+ return isAbsolute(target) ? resolve(target) : resolve(cwd, target)
570
+ } catch {
571
+ return null
572
+ }
573
+ }
574
+
575
+ // A missing typeclaw.json is tolerated (e.g. test fixtures, freshly-cloned
576
+ // folder mid-init). Anything else — malformed JSON, schema-invalid config,
577
+ // invalid mount entry — must surface so the user sees they configured a mount
578
+ // that won't be applied.
579
+ async function loadMounts(cwd: string): Promise<Config['mounts']> {
580
+ const cfg = await loadTypeclawConfig(cwd)
581
+ return cfg.mounts
582
+ }
583
+
584
+ async function loadTypeclawConfig(cwd: string): Promise<Config> {
585
+ return configSchema.parse(await loadConfigJson(cwd))
586
+ }
587
+
588
+ async function registerWithDaemon({
589
+ cwd,
590
+ containerName,
591
+ cliEntry,
592
+ hostPort,
593
+ reuseCurrentHostDaemon,
594
+ }: {
595
+ cwd: string
596
+ containerName: string
597
+ cliEntry: string
598
+ hostPort: number
599
+ reuseCurrentHostDaemon: boolean
600
+ }): Promise<PreparedHostDaemonStatus> {
601
+ const prepared = reuseCurrentHostDaemon ? await useCurrentHostDaemon() : await ensureDaemon({ cliEntry })
602
+ if (!prepared.ok) return { state: 'unavailable', reason: prepared.reason }
603
+ const token = randomBytes(32).toString('base64url')
604
+ const brokerToken = randomBytes(32).toString('base64url')
605
+ const cfg = await loadTypeclawConfig(cwd)
606
+ const reply = await sendToDaemon({
607
+ kind: 'register',
608
+ containerName,
609
+ cwd,
610
+ restartToken: token,
611
+ wsHostPort: hostPort,
612
+ portForward: cfg.portForward,
613
+ brokerToken,
614
+ })
615
+ if (!reply.ok) return { state: 'unavailable', reason: reply.reason }
616
+ return {
617
+ state: 'registered',
618
+ control: { url: `http://${CONTAINER_HOSTD_HOST}:${prepared.httpPort}`, token, brokerToken },
619
+ }
620
+ }
621
+
622
+ async function useCurrentHostDaemon(): Promise<{ ok: true; httpPort: number } | { ok: false; reason: string }> {
623
+ const reply = await sendToDaemon({ kind: 'http-info' })
624
+ if (!reply.ok) return { ok: false, reason: reply.reason }
625
+ const result = reply.result as HttpInfoResult | undefined
626
+ if (typeof result?.port !== 'number' || result.port <= 0 || result.port > 65_535) {
627
+ return { ok: false, reason: 'daemon did not report an HTTP control port' }
628
+ }
629
+ return { ok: true, httpPort: result.port }
630
+ }
631
+
632
+ async function loadConfigJson(cwd: string): Promise<unknown> {
633
+ let raw: string
634
+ try {
635
+ raw = await readFile(join(cwd, CONFIG_FILE), 'utf8')
636
+ } catch {
637
+ return {}
638
+ }
639
+ return JSON.parse(raw)
640
+ }
641
+
642
+ type PreparedHostDaemonStatus =
643
+ | { state: 'registered'; control: HostDaemonControl }
644
+ | { state: 'unavailable'; reason: string }
645
+ | { state: 'disabled' }
646
+
647
+ function stripHostDaemonControl(status: PreparedHostDaemonStatus): HostDaemonStatus {
648
+ if (status.state === 'registered') return { state: 'registered' }
649
+ return status
650
+ }
651
+
652
+ async function cleanupHostDaemonRegistration(containerName: string, status: PreparedHostDaemonStatus): Promise<void> {
653
+ if (status.state !== 'registered') return
654
+ await sendToDaemon({ kind: 'deregister', containerName }).catch(() => {})
655
+ }
656
+
657
+ // process.env.TZ is honored first because users who explicitly set it (e.g.
658
+ // `TZ=UTC typeclaw start` for testing) expect that to win over their system
659
+ // default. Falls back to Intl, which works reliably on macOS where TZ is
660
+ // usually unset. Returns null if neither yields an IANA zone name.
661
+ function resolveHostTimezone(): string | null {
662
+ const explicit = process.env.TZ
663
+ if (explicit && explicit.length > 0) return explicit
664
+ try {
665
+ const detected = Intl.DateTimeFormat().resolvedOptions().timeZone
666
+ return detected && detected.length > 0 ? detected : null
667
+ } catch {
668
+ return null
669
+ }
670
+ }