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,47 @@
1
+ import type { Reloadable, ReloadResult } from '@/reload'
2
+
3
+ import { type ConfigReloadDiff, reloadConfig, validateConfig } from './config'
4
+
5
+ export type CreateConfigReloadableOptions = {
6
+ cwd: string
7
+ }
8
+
9
+ export function createConfigReloadable({ cwd }: CreateConfigReloadableOptions): Reloadable {
10
+ return {
11
+ scope: 'config',
12
+ description: 'typeclaw.json runtime config',
13
+ reload: async () => doReload(cwd),
14
+ }
15
+ }
16
+
17
+ async function doReload(cwd: string): Promise<ReloadResult> {
18
+ // Mount accessibility belongs to the validation surface, not loadConfigSync —
19
+ // validateConfig is the single gate that every host-side caller goes through.
20
+ // Run it before swapping the live config pointer so a mount that vanished
21
+ // between starts surfaces as a reload failure (`mounts` is restart-required
22
+ // anyway, so the user has to restart to pick up changes; better to flag the
23
+ // problem now than to let restart fail later).
24
+ const validated = validateConfig(cwd)
25
+ if (!validated.ok) {
26
+ return { scope: 'config', ok: false, reason: validated.reason }
27
+ }
28
+
29
+ let diff: ConfigReloadDiff
30
+ try {
31
+ diff = reloadConfig(cwd)
32
+ } catch (err) {
33
+ const message = err instanceof Error ? err.message : String(err)
34
+ return { scope: 'config', ok: false, reason: message }
35
+ }
36
+
37
+ return {
38
+ scope: 'config',
39
+ ok: true,
40
+ summary: formatSummary(diff),
41
+ details: diff,
42
+ }
43
+ }
44
+
45
+ function formatSummary(diff: ConfigReloadDiff): string {
46
+ return `${diff.applied.length} applied, ${diff.restartRequired.length} restart-required, ${diff.ignored.length} ignored`
47
+ }
@@ -0,0 +1,27 @@
1
+ export { logs, planLogs, type LogsPlan, type LogsResult } from './logs'
2
+ export { CONTAINER_PORT, findFreePort, resolveHostPort } from './port'
3
+ export { planShell, shell, type ShellPlan, type ShellResult } from './shell'
4
+ export { status, type ContainerStatus, type StatusOptions } from './status'
5
+ export {
6
+ checkDockerAvailable,
7
+ containerExists,
8
+ containerNameFromCwd,
9
+ defaultDockerExec,
10
+ DOCKER_NOT_FOUND_STDERR,
11
+ imageTagFromCwd,
12
+ inspectContainer,
13
+ type ContainerState,
14
+ type DockerAvailability,
15
+ type DockerExec,
16
+ type DockerExecResult,
17
+ } from './shared'
18
+ export {
19
+ planStart,
20
+ start,
21
+ type HostDaemonStatus,
22
+ type PlanStartOptions,
23
+ type StartOptions,
24
+ type StartPlan,
25
+ type StartResult,
26
+ } from './start'
27
+ export { planStop, stop, type StopOptions, type StopPlan, type StopResult } from './stop'
@@ -0,0 +1,37 @@
1
+ import { containerExists, containerNameFromCwd, getBun } from './shared'
2
+
3
+ export type LogsPlan = {
4
+ containerName: string
5
+ follow: boolean
6
+ }
7
+
8
+ export type LogsResult = { ok: true; containerName: string; exitCode: number } | { ok: false; reason: string }
9
+
10
+ export async function logs({ cwd, follow }: { cwd: string; follow: boolean }): Promise<LogsResult> {
11
+ const bun = getBun()
12
+ if (!bun) return { ok: false, reason: 'bun runtime not available' }
13
+
14
+ const { containerName } = planLogs(cwd, { follow })
15
+
16
+ try {
17
+ if (!(await containerExists(containerName))) {
18
+ return { ok: false, reason: `Container ${containerName} not found. Run \`typeclaw start\` first.` }
19
+ }
20
+
21
+ const cmd = ['docker', 'logs']
22
+ if (follow) cmd.push('-f')
23
+ cmd.push(containerName)
24
+
25
+ // Inherit stdio so logs stream live and Ctrl+C reaches `docker logs`,
26
+ // which exits cleanly on SIGINT in follow mode.
27
+ const proc = bun.spawn({ cmd, cwd, stdout: 'inherit', stderr: 'inherit' })
28
+ const exitCode = await proc.exited
29
+ return { ok: true, containerName, exitCode }
30
+ } catch (error) {
31
+ return { ok: false, reason: error instanceof Error ? error.message : String(error) }
32
+ }
33
+ }
34
+
35
+ export function planLogs(cwd: string, { follow }: { follow: boolean }): LogsPlan {
36
+ return { containerName: containerNameFromCwd(cwd), follow }
37
+ }
@@ -0,0 +1,137 @@
1
+ import { createServer } from 'node:net'
2
+
3
+ import { loadConfigSync } from '@/config'
4
+
5
+ import { containerNameFromCwd, defaultDockerExec, type DockerExec } from './shared'
6
+
7
+ // The port the agent's WebSocket server binds to *inside* the container. Host
8
+ // publishing maps a host-side port (chosen at `typeclaw start` time) to this
9
+ // fixed internal port. Decoupling the two lets multiple agents coexist on a
10
+ // single host without colliding on 8973 — see issue #1.
11
+ //
12
+ // Kept identical to the legacy DEFAULT_PORT so a containerful upgrade path
13
+ // works: containers started before this change used `-p 8973:8973`, and after
14
+ // the upgrade `docker port <name> 8973/tcp` still resolves correctly.
15
+ export const CONTAINER_PORT = 8973
16
+
17
+ // Asks the kernel for a free TCP port. When `preferred` is supplied, tries
18
+ // that port first; if it's already bound, falls back to a kernel-assigned
19
+ // ephemeral port via `listen(0)`. The returned port is *not* held — the test
20
+ // server is closed before resolving, so a different process could grab it
21
+ // before we hand it to Docker. Callers that pipe the result into `docker run`
22
+ // should treat docker's bind error as authoritative and retry on conflict.
23
+ export async function findFreePort(preferred?: number): Promise<number> {
24
+ if (preferred !== undefined && preferred > 0) {
25
+ if (await isPortFree(preferred)) return preferred
26
+ }
27
+ return listenEphemeral()
28
+ }
29
+
30
+ async function isPortFree(port: number): Promise<boolean> {
31
+ return new Promise((resolve) => {
32
+ const server = createServer()
33
+ server.unref()
34
+ server.once('error', () => resolve(false))
35
+ server.listen({ port, host: '0.0.0.0', exclusive: true }, () => {
36
+ server.close(() => resolve(true))
37
+ })
38
+ })
39
+ }
40
+
41
+ async function listenEphemeral(): Promise<number> {
42
+ return new Promise((resolve, reject) => {
43
+ const server = createServer()
44
+ server.unref()
45
+ server.once('error', reject)
46
+ server.listen({ port: 0, host: '0.0.0.0', exclusive: true }, () => {
47
+ const address = server.address()
48
+ if (typeof address !== 'object' || address === null) {
49
+ server.close()
50
+ reject(new Error('failed to obtain ephemeral port'))
51
+ return
52
+ }
53
+ const port = address.port
54
+ server.close((err) => {
55
+ if (err) reject(err)
56
+ else resolve(port)
57
+ })
58
+ })
59
+ })
60
+ }
61
+
62
+ // Docker's bind-conflict error from `docker run -p`. Used by `start` to decide
63
+ // whether to retry with a fresh ephemeral port or surface the failure as-is.
64
+ export function isPortAllocatedError(stderr: string): boolean {
65
+ const lower = stderr.toLowerCase()
66
+ return (
67
+ lower.includes('port is already allocated') ||
68
+ lower.includes('address already in use') ||
69
+ lower.includes('bind for') // catches "Bind for :::8973 failed: port is already allocated"
70
+ )
71
+ }
72
+
73
+ export type ResolveHostPortOptions = {
74
+ cwd: string
75
+ exec?: DockerExec
76
+ retryMs?: number
77
+ intervalMs?: number
78
+ fallbackPort?: number
79
+ }
80
+
81
+ // Returns the host port that `typeclaw tui` / `typeclaw reload` should connect
82
+ // to. Docker is the source of truth for a running container; we ask
83
+ // `docker port <name> ${CONTAINER_PORT}/tcp` and parse the host-side port out
84
+ // of the mapping. If the container isn't running (or we're on an old
85
+ // pre-fix container that doesn't expose CONTAINER_PORT internally), we fall
86
+ // back to the config's `port` field as a best-effort guess.
87
+ export async function resolveHostPort(options: ResolveHostPortOptions): Promise<number> {
88
+ const exec = options.exec ?? defaultDockerExec
89
+ const containerName = containerNameFromCwd(options.cwd)
90
+ const retryMs = options.retryMs ?? 1500
91
+ const intervalMs = options.intervalMs ?? 100
92
+
93
+ const deadline = Date.now() + retryMs
94
+ while (true) {
95
+ const port = await queryDockerHostPort(exec, containerName)
96
+ if (port !== null) return port
97
+ if (Date.now() >= deadline) break
98
+ await sleep(intervalMs)
99
+ }
100
+
101
+ if (options.fallbackPort !== undefined) return options.fallbackPort
102
+ return loadConfigSync(options.cwd).port
103
+ }
104
+
105
+ async function queryDockerHostPort(exec: DockerExec, containerName: string): Promise<number | null> {
106
+ const result = await exec(['port', containerName, `${CONTAINER_PORT}/tcp`])
107
+ if (result.exitCode !== 0) return null
108
+ return parseDockerPortOutput(result.stdout)
109
+ }
110
+
111
+ // `docker port` prints one mapping per line, e.g.:
112
+ // 0.0.0.0:49160
113
+ // :::49160
114
+ // [::]:49160
115
+ // We pick the last numeric segment after the final colon. If multiple lines
116
+ // are present we prefer an IPv4 mapping (most localhost connects resolve to
117
+ // IPv4 first on macOS/Linux), falling back to whatever parses cleanly.
118
+ export function parseDockerPortOutput(stdout: string): number | null {
119
+ const lines = stdout
120
+ .split('\n')
121
+ .map((line) => line.trim())
122
+ .filter((line) => line.length > 0)
123
+ if (lines.length === 0) return null
124
+
125
+ const ipv4 = lines.find((line) => /^\d{1,3}(\.\d{1,3}){3}:\d+$/.test(line))
126
+ const candidate = ipv4 ?? lines[0]!
127
+ const lastColon = candidate.lastIndexOf(':')
128
+ if (lastColon < 0) return null
129
+ const portStr = candidate.slice(lastColon + 1)
130
+ const port = Number(portStr)
131
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) return null
132
+ return port
133
+ }
134
+
135
+ function sleep(ms: number): Promise<void> {
136
+ return new Promise((resolve) => setTimeout(resolve, ms))
137
+ }
@@ -0,0 +1,290 @@
1
+ import { basename, resolve } from 'node:path'
2
+
3
+ export type DockerExecResult = { exitCode: number; stdout: string; stderr: string }
4
+
5
+ export type DockerExec = (
6
+ args: string[],
7
+ options?: { cwd?: string; inheritStdio?: boolean; signal?: AbortSignal },
8
+ ) => Promise<DockerExecResult>
9
+
10
+ export const defaultDockerExec: DockerExec = async (args, options) => {
11
+ const bun = getBun()
12
+ if (!bun) return { exitCode: -1, stdout: '', stderr: 'bun runtime not available' }
13
+ // Bun.spawn throws synchronously with code 'ENOENT' when docker isn't on
14
+ // $PATH (rather than returning a non-zero exit). Two overloads (pipe vs
15
+ // inherit) so each spawn call site has the literal stdout/stderr type
16
+ // attached — that's what lets `new Response(proc.stdout)` typecheck on
17
+ // the piped path. `signal` is forwarded to Bun.spawn so callers can bound
18
+ // long-running docker subcommands (e.g. `docker logs` on a stuck daemon).
19
+ if (options?.inheritStdio) {
20
+ try {
21
+ const proc = bun.spawn({
22
+ cmd: ['docker', ...args],
23
+ cwd: options.cwd,
24
+ stdout: 'inherit',
25
+ stderr: 'inherit',
26
+ signal: options.signal,
27
+ })
28
+ return { exitCode: await proc.exited, stdout: '', stderr: '' }
29
+ } catch (error) {
30
+ if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') {
31
+ return { exitCode: -1, stdout: '', stderr: DOCKER_NOT_FOUND_STDERR }
32
+ }
33
+ throw error
34
+ }
35
+ }
36
+ try {
37
+ const proc = bun.spawn({
38
+ cmd: ['docker', ...args],
39
+ cwd: options?.cwd,
40
+ stdout: 'pipe',
41
+ stderr: 'pipe',
42
+ signal: options?.signal,
43
+ })
44
+ const exitCode = await proc.exited
45
+ const stdout = await new Response(proc.stdout).text()
46
+ const stderr = await new Response(proc.stderr).text()
47
+ return { exitCode, stdout, stderr }
48
+ } catch (error) {
49
+ if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') {
50
+ return { exitCode: -1, stdout: '', stderr: DOCKER_NOT_FOUND_STDERR }
51
+ }
52
+ throw error
53
+ }
54
+ }
55
+
56
+ // Sentinel stderr from defaultDockerExec when Bun.spawn throws ENOENT.
57
+ // checkDockerAvailable matches on this exact string to distinguish
58
+ // "binary missing" from "daemon down".
59
+ export const DOCKER_NOT_FOUND_STDERR = 'docker: command not found in $PATH'
60
+
61
+ // Collapse a multi-line `docker` CLI stderr into a single readable clause
62
+ // suitable for inline `reason:` strings. The motivating case is `compose
63
+ // restart`, which prints one row per agent — raw stderr from a failed
64
+ // `docker run` is 3-5 lines (leading `docker: ` prefix, daemon error body,
65
+ // blank line, "Run 'docker run --help' for more information" tail) and
66
+ // turns each failing row into an ASCII wall. Strip the boilerplate, drop
67
+ // the help-pointer tail (it's noise in a programmatic context — users who
68
+ // hit it can run `docker run --help` themselves), and join any remaining
69
+ // detail lines with "; " so the result fits on the same row as the
70
+ // `✖ [name] failed:` prefix that wraps it.
71
+ export function sanitizeDockerStderr(stderr: string): string {
72
+ const withoutHelpTail = stderr.replace(/\n*\s*Run '[^']+--help' for more information\s*\n?/g, '')
73
+ const lines = withoutHelpTail
74
+ .split('\n')
75
+ .map((line) => line.trim())
76
+ .filter((line) => line.length > 0)
77
+ .map((line) => line.replace(/^docker:\s*/, '').replace(/^Error response from daemon:\s*/, ''))
78
+ .filter((line) => line.length > 0)
79
+ return lines.join('; ')
80
+ }
81
+
82
+ export type DockerAvailability = { ok: true } | { ok: false; reason: 'binary-missing' | 'daemon-down'; detail: string }
83
+
84
+ // `docker info --format {{.ServerVersion}}` is the probe of choice because it
85
+ // requires both the client AND a reachable daemon. `docker --version` would
86
+ // miss the "Docker Desktop installed but not running" case, which is the
87
+ // common failure mode on macOS.
88
+ export async function checkDockerAvailable(exec: DockerExec = defaultDockerExec): Promise<DockerAvailability> {
89
+ const result = await exec(['info', '--format', '{{.ServerVersion}}'])
90
+ if (result.exitCode === 0) return { ok: true }
91
+ if (result.stderr === DOCKER_NOT_FOUND_STDERR) {
92
+ return { ok: false, reason: 'binary-missing', detail: result.stderr }
93
+ }
94
+ return {
95
+ ok: false,
96
+ reason: 'daemon-down',
97
+ detail: result.stderr.trim() || `docker info exited with code ${result.exitCode}`,
98
+ }
99
+ }
100
+
101
+ export function containerNameFromCwd(cwd: string): string {
102
+ return sanitizeContainerName(basename(resolve(cwd)))
103
+ }
104
+
105
+ // `docker rm` failures we treat as recoverable. The kind matters for the
106
+ // caller's next step:
107
+ // - 'gone' — "No such container". The container is already removed
108
+ // (peer `typeclaw stop`, manual `docker rm`, or async
109
+ // post-stop cleanup that finished first). The name is
110
+ // free to reuse immediately.
111
+ // - 'in-progress' — "removal of container … is already in progress". Docker
112
+ // accepted a prior remove and is still draining it. The
113
+ // container is STILL PRESENT in `docker inspect` until
114
+ // the drain completes, so a `docker run --name <same>`
115
+ // fired right now would collide. Callers that follow
116
+ // `rm` with `run` MUST wait for the container to
117
+ // actually disappear — see waitForRemoval.
118
+ // - null — non-benign failure; surface as an error.
119
+ export type BenignRmKind = 'gone' | 'in-progress' | null
120
+
121
+ export function classifyRmStderr(stderr: string): BenignRmKind {
122
+ const lower = stderr.toLowerCase()
123
+ if (lower.includes('no such container')) return 'gone'
124
+ if (lower.includes('removal of container')) return 'in-progress'
125
+ return null
126
+ }
127
+
128
+ // Detects Docker's name-conflict response from `docker run --name <X>`:
129
+ // docker: Error response from daemon: Conflict. The container name
130
+ // "/<X>" is already in use by container "<id>". You have to remove
131
+ // (or rename) that container to be able to reuse that name.
132
+ //
133
+ // The dominant cause of this error in `typeclaw compose restart` is NOT a
134
+ // transient name-reservation drain (PR #121's hypothesis) but a concrete
135
+ // corpse left behind by an earlier `docker run` in the same start() call
136
+ // that failed AFTER Docker created the container record. The canonical
137
+ // path: compose restart fires N agents in parallel via Promise.all; they
138
+ // race for the preferred host port; the loser's `docker run -p <busy>:...`
139
+ // fails with "port is already allocated", and depending on the daemon
140
+ // version Docker may have already created the container record before the
141
+ // port bind failed. start()'s port-TOCTOU retry then re-runs `docker run`
142
+ // with a fresh ephemeral port but the SAME --name, and hits this conflict
143
+ // against the corpse from the previous attempt. The corpse is stable —
144
+ // sleep-only retries cannot make it go away.
145
+ //
146
+ // The fix is destructive: when this error fires for a non-running same-name
147
+ // container, force-remove it before retrying. See cleanupRunCorpse for the
148
+ // safety contract (only force-remove containers that are NOT running, so a
149
+ // concurrent legitimate start of the same name is never killed).
150
+ //
151
+ // Matches case-insensitively on the canonical phrasing across Docker
152
+ // Engine, Docker Desktop, and OrbStack. The (or rename) clause is the
153
+ // most stable substring across vendor message variants.
154
+ export function isContainerNameConflict(stderr: string): boolean {
155
+ const lower = stderr.toLowerCase()
156
+ return lower.includes('container name') && lower.includes('is already in use')
157
+ }
158
+
159
+ // Result of probing whether a previous `docker run --name <X>` left a corpse
160
+ // blocking the next run:
161
+ // - 'gone' — no container with that name. Safe to `docker run --name`.
162
+ // - 'removed' — corpse existed and was force-removed (and waitForRemoval
163
+ // confirmed it disappeared). Safe to `docker run --name`.
164
+ // - 'running' — a container with that name is currently RUNNING. We did
165
+ // NOT remove it. Caller must NOT proceed with `docker run
166
+ // --name <same>`: that would either fail again or imply a
167
+ // concurrent legitimate start that we should not kill.
168
+ // - 'stuck' — corpse existed but did not disappear within waitForRemoval
169
+ // budget. Caller should surface a clear error rather than
170
+ // loop forever.
171
+ export type CorpseCleanupOutcome = 'gone' | 'removed' | 'running' | 'stuck'
172
+
173
+ // Inspects the named container; if a non-running corpse is holding the
174
+ // name (the failure mode behind `typeclaw compose restart`'s persistent
175
+ // Conflict errors), force-removes it and waits for the removal to drain.
176
+ // Explicitly refuses to touch a RUNNING container so that a concurrent
177
+ // legitimate start of the same name (or a foreign-but-named container the
178
+ // user wants kept alive) is never killed by this cleanup path. Errors from
179
+ // the rm itself are folded into 'stuck' so the caller can surface a single
180
+ // "still here" reason rather than chase docker stderr variants.
181
+ //
182
+ // The rm is keyed on the container ID we read from the same inspect call,
183
+ // NOT on the name. This closes the TOCTOU window where another process
184
+ // could create a live container with the same name between our inspect
185
+ // (saw a non-running corpse with ID A) and our rm: removing by name would
186
+ // kill the new live container with ID B, but removing by ID A targets the
187
+ // specific corpse we measured. If ID A is already gone by the time rm
188
+ // fires (e.g. a concurrent cleanup beat us), the rm returns "No such
189
+ // container" which classifyRmStderr folds into 'gone'. waitForRemoval
190
+ // is still keyed on name because that's what the caller's next
191
+ // `docker run --name <name>` will actually collide on.
192
+ export async function cleanupRunCorpse(exec: DockerExec, name: string): Promise<CorpseCleanupOutcome> {
193
+ const probe = await exec(['inspect', '--format', '{{.Id}}|{{.State.Running}}', name])
194
+ if (probe.exitCode !== 0) return 'gone'
195
+ const [id = '', running = ''] = probe.stdout.trim().split('|')
196
+ if (running === 'true') return 'running'
197
+ if (id === '') return 'stuck'
198
+ const rm = await exec(['rm', '-f', id])
199
+ if (rm.exitCode !== 0) {
200
+ const kind = classifyRmStderr(rm.stderr)
201
+ if (kind === 'gone') return 'gone'
202
+ if (kind === null) return 'stuck'
203
+ }
204
+ return (await waitForRemoval(exec, name)) ? 'removed' : 'stuck'
205
+ }
206
+
207
+ // Polls `docker inspect` until the named container is gone or the deadline
208
+ // elapses. Required after a `docker rm` that returned "removal of container
209
+ // … is already in progress": Docker has committed to removal but has not
210
+ // finished, so the name is briefly still taken. Without this wait, the
211
+ // caller's subsequent `docker run --name <same>` races the daemon's
212
+ // removal-drain and intermittently fails with a name conflict (the
213
+ // user-visible symptom under `typeclaw compose restart` and any restart of
214
+ // a container that hostd's GC tick raced ahead on). Returns true if the
215
+ // container disappeared before the deadline, false on timeout.
216
+ //
217
+ // NOTE: `inspect` reporting "gone" is necessary but NOT sufficient for
218
+ // `docker run --name <same>` to succeed — Docker's name-reservation table
219
+ // drains independently. waitForRemoval is the fast path; the retry on
220
+ // `docker run` (see isContainerNameConflict) is the safety net.
221
+ export async function waitForRemoval(
222
+ exec: DockerExec,
223
+ name: string,
224
+ options: { timeoutMs?: number; intervalMs?: number } = {},
225
+ ): Promise<boolean> {
226
+ const timeoutMs = options.timeoutMs ?? 10_000
227
+ const intervalMs = options.intervalMs ?? 100
228
+ const deadline = Date.now() + timeoutMs
229
+ while (Date.now() < deadline) {
230
+ const result = await exec(['inspect', '--format', '{{.State.Running}}', name])
231
+ if (result.exitCode !== 0) return true
232
+ await new Promise((resolve) => setTimeout(resolve, intervalMs))
233
+ }
234
+ const final = await exec(['inspect', '--format', '{{.State.Running}}', name])
235
+ return final.exitCode !== 0
236
+ }
237
+
238
+ export function imageTagFromCwd(cwd: string): string {
239
+ return `typeclaw-${containerNameFromCwd(cwd)}`
240
+ }
241
+
242
+ // Docker container names must match [a-zA-Z0-9][a-zA-Z0-9_.-]*.
243
+ function sanitizeContainerName(name: string): string {
244
+ const cleaned = name.replace(/[^a-zA-Z0-9_.-]/g, '-')
245
+ if (cleaned === '' || !/^[a-zA-Z0-9]/.test(cleaned)) {
246
+ return `tc-${cleaned || 'agent'}`
247
+ }
248
+ return cleaned
249
+ }
250
+
251
+ export async function imageExists(tag: string): Promise<boolean> {
252
+ const bun = getBun()
253
+ if (!bun) return false
254
+ const proc = bun.spawn({
255
+ cmd: ['docker', 'image', 'inspect', tag],
256
+ stdout: 'pipe',
257
+ stderr: 'pipe',
258
+ })
259
+ return (await proc.exited) === 0
260
+ }
261
+
262
+ export async function containerExists(name: string): Promise<boolean> {
263
+ return (await inspectContainer(name)).exists
264
+ }
265
+
266
+ export type ContainerState = { exists: false } | { exists: true; running: boolean }
267
+
268
+ // `docker inspect` is the canonical way to ask Docker about a single container
269
+ // by name. It returns exit 0 with `true`/`false` when the container exists (in
270
+ // any state: running, exited, dead, or being removed) and exit 1 otherwise.
271
+ // We deliberately do NOT use `docker ps` / `docker ps -a` here because the
272
+ // State.Running boolean — not mere presence in `ps -a` — is what callers need
273
+ // to distinguish a live agent from a corpse left over after a crash (which,
274
+ // since we run without `--rm`, sticks around in `ps -a` until the next start).
275
+ export async function inspectContainer(name: string): Promise<ContainerState> {
276
+ const bun = getBun()
277
+ if (!bun) return { exists: false }
278
+ const proc = bun.spawn({
279
+ cmd: ['docker', 'inspect', '--format', '{{.State.Running}}', name],
280
+ stdout: 'pipe',
281
+ stderr: 'pipe',
282
+ })
283
+ if ((await proc.exited) !== 0) return { exists: false }
284
+ const out = (await new Response(proc.stdout).text()).trim()
285
+ return { exists: true, running: out === 'true' }
286
+ }
287
+
288
+ export function getBun(): { spawn: typeof Bun.spawn } | undefined {
289
+ return (globalThis as { Bun?: { spawn: typeof Bun.spawn } }).Bun
290
+ }
@@ -0,0 +1,58 @@
1
+ import { containerNameFromCwd, getBun, inspectContainer, type ContainerState } from './shared'
2
+
3
+ export type ShellPlan = {
4
+ containerName: string
5
+ shell: string
6
+ }
7
+
8
+ export type ShellResult = { ok: true; containerName: string; exitCode: number } | { ok: false; reason: string }
9
+
10
+ type ShellDeps = {
11
+ inspect?: (name: string) => Promise<ContainerState>
12
+ spawn?: InteractiveSpawn
13
+ }
14
+
15
+ type InteractiveSpawn = (options: {
16
+ cmd: string[]
17
+ cwd: string
18
+ stdin: 'inherit'
19
+ stdout: 'inherit'
20
+ stderr: 'inherit'
21
+ }) => { exited: Promise<number> }
22
+
23
+ export async function shell(
24
+ { cwd, shell: shellPath = '/bin/bash' }: { cwd: string; shell?: string },
25
+ deps: ShellDeps = {},
26
+ ): Promise<ShellResult> {
27
+ const bun = getBun()
28
+ const spawn = deps.spawn ?? bun?.spawn
29
+ if (!spawn) return { ok: false, reason: 'bun runtime not available' }
30
+
31
+ const { containerName, shell: plannedShell } = planShell(cwd, { shell: shellPath })
32
+
33
+ try {
34
+ const state = await (deps.inspect ?? inspectContainer)(containerName)
35
+ if (!state.exists) {
36
+ return { ok: false, reason: `Container ${containerName} not found. Run \`typeclaw start\` first.` }
37
+ }
38
+ if (!state.running) {
39
+ return { ok: false, reason: `Container ${containerName} is not running. Run \`typeclaw start\` first.` }
40
+ }
41
+
42
+ const proc = spawn({
43
+ cmd: ['docker', 'exec', '-it', containerName, plannedShell],
44
+ cwd,
45
+ stdin: 'inherit',
46
+ stdout: 'inherit',
47
+ stderr: 'inherit',
48
+ })
49
+ const exitCode = await proc.exited
50
+ return { ok: true, containerName, exitCode }
51
+ } catch (error) {
52
+ return { ok: false, reason: error instanceof Error ? error.message : String(error) }
53
+ }
54
+ }
55
+
56
+ export function planShell(cwd: string, { shell: shellPath = '/bin/bash' }: { shell?: string } = {}): ShellPlan {
57
+ return { containerName: containerNameFromCwd(cwd), shell: shellPath }
58
+ }