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,101 @@
1
+ import type { PortForward } from '@/config'
2
+ import { resolveHostPort } from '@/container'
3
+ import { type Broker, createBroker, type BrokerOptions, type PortForwardEvent } from '@/portbroker'
4
+
5
+ import type { PortbrokerCallbacks, PortbrokerStartInput } from './daemon'
6
+ import { createTailscaleServeManager, type TailscaleExec, type TailscaleServeManager } from './tailscale'
7
+
8
+ export type PortbrokerManagerOptions = {
9
+ resolveHostPortFor?: (input: { containerName: string; cwd: string }) => Promise<number | null>
10
+ onLog?: (msg: string) => void
11
+ tailscaleExec?: TailscaleExec
12
+ createBrokerFor?: (opts: BrokerOptions) => Broker
13
+ }
14
+
15
+ // Glue between hostd's daemon and the portbroker package. Owns a Broker
16
+ // instance per registered containerName. The daemon calls start()/stop()
17
+ // through this manager. Reconnect after container restart works because the
18
+ // resolver is re-invoked on each connect attempt — see portbroker hostd-client.
19
+ export function createPortbrokerManager(opts: PortbrokerManagerOptions = {}): PortbrokerCallbacks & {
20
+ drain: () => Promise<void>
21
+ } {
22
+ const brokers = new Map<string, Broker>()
23
+ const tailscaleManagers = new Map<string, TailscaleServeManager>()
24
+ const resolver = opts.resolveHostPortFor ?? defaultResolveHostPort
25
+ const log = opts.onLog ?? (() => {})
26
+ const brokerFactory = opts.createBrokerFor ?? createBroker
27
+
28
+ return {
29
+ start(input: PortbrokerStartInput) {
30
+ const existing = brokers.get(input.containerName)
31
+ if (existing) {
32
+ void existing.stop().catch(() => {})
33
+ }
34
+ const existingTailscale = tailscaleManagers.get(input.containerName)
35
+ if (existingTailscale) {
36
+ tailscaleManagers.delete(input.containerName)
37
+ void existingTailscale.stopAll().catch(() => {})
38
+ }
39
+ const tailscale = createTailscaleServeManager({
40
+ containerName: input.containerName,
41
+ exec: opts.tailscaleExec,
42
+ onEvent: input.onTailscaleServeEvent,
43
+ onLog: (msg) => log(`[tailscale:${input.containerName}] ${msg}`),
44
+ })
45
+ tailscaleManagers.set(input.containerName, tailscale)
46
+ const broker = brokerFactory({
47
+ containerName: input.containerName,
48
+ cwd: input.cwd,
49
+ policy: input.policy,
50
+ resolveHostPort: () => resolver({ containerName: input.containerName, cwd: input.cwd }),
51
+ brokerToken: input.brokerToken,
52
+ onEvent: (event) => {
53
+ input.onEvent(event)
54
+ if (event.kind === 'port-forward-opened') tailscale.servePort(event.port)
55
+ else if (event.kind === 'port-forward-closed') tailscale.stopPort(event.port)
56
+ },
57
+ onLog: (msg) => log(`[portbroker:${input.containerName}] ${msg}`),
58
+ })
59
+ brokers.set(input.containerName, broker)
60
+ broker.start()
61
+ },
62
+
63
+ async stop(containerName, reason) {
64
+ const broker = brokers.get(containerName)
65
+ if (!broker) return
66
+ brokers.delete(containerName)
67
+ await broker.stop()
68
+ const tailscale = tailscaleManagers.get(containerName)
69
+ if (tailscale) {
70
+ tailscaleManagers.delete(containerName)
71
+ await tailscale.stopAll()
72
+ }
73
+ log(`[portbroker:${containerName}] stopped (${reason})`)
74
+ },
75
+
76
+ forwardedPorts(containerName) {
77
+ const broker = brokers.get(containerName)
78
+ if (!broker) return []
79
+ return broker.forwardedPorts()
80
+ },
81
+
82
+ async drain() {
83
+ const all = Array.from(brokers.values())
84
+ const tailscale = Array.from(tailscaleManagers.values())
85
+ brokers.clear()
86
+ tailscaleManagers.clear()
87
+ await Promise.allSettled(all.map((b) => b.stop()))
88
+ await Promise.allSettled(tailscale.map((t) => t.stopAll()))
89
+ },
90
+ }
91
+ }
92
+
93
+ async function defaultResolveHostPort(input: { containerName: string; cwd: string }): Promise<number | null> {
94
+ try {
95
+ return await resolveHostPort({ cwd: input.cwd, retryMs: 500, intervalMs: 50 })
96
+ } catch {
97
+ return null
98
+ }
99
+ }
100
+
101
+ export type { PortForward, PortForwardEvent }
@@ -0,0 +1,48 @@
1
+ import type { PortForward } from '@/config'
2
+
3
+ export type Request =
4
+ | {
5
+ kind: 'register'
6
+ containerName: string
7
+ cwd: string
8
+ restartToken?: string
9
+ wsHostPort?: number
10
+ portForward?: PortForward
11
+ brokerToken?: string
12
+ }
13
+ | { kind: 'deregister'; containerName: string }
14
+ | { kind: 'list' }
15
+ | { kind: 'status'; containerName: string }
16
+ | { kind: 'restart'; containerName: string; build?: boolean }
17
+ | { kind: 'http-info' }
18
+ | { kind: 'version' }
19
+ | { kind: 'shutdown' }
20
+
21
+ export type Response = { ok: true; result?: unknown } | { ok: false; reason: string }
22
+
23
+ export type ListResult = {
24
+ registrations: Array<{ containerName: string; cwd: string }>
25
+ }
26
+
27
+ export type StatusResult = {
28
+ containerName: string
29
+ cwd: string
30
+ forwardedPorts: number[]
31
+ }
32
+
33
+ export type RestartResult = {
34
+ containerName: string
35
+ scheduled: true
36
+ }
37
+
38
+ export type HttpInfoResult = {
39
+ port: number
40
+ }
41
+
42
+ export type VersionResult = {
43
+ version: string
44
+ }
45
+
46
+ export type ShutdownResult = {
47
+ scheduled: true
48
+ }
@@ -0,0 +1,224 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { open, readFile, unlink, writeFile } from 'node:fs/promises'
3
+
4
+ import { isDaemonReachable, send } from './client'
5
+ import { ensureDirs, lockfilePath, logfilePath, pidfilePath, socketPath } from './paths'
6
+ import type { HttpInfoResult, VersionResult } from './protocol'
7
+ import { computeSourceVersion, resolveSrcRoot, UNVERSIONED_SENTINEL } from './version'
8
+
9
+ export type EnsureDaemonOptions = {
10
+ cliEntry: string
11
+ spawnTimeoutMs?: number
12
+ // Test seam: tests inject a deterministic version probe + respawn so the
13
+ // unit test can exercise the drift path without spawning a real daemon.
14
+ expectedVersion?: string
15
+ }
16
+
17
+ export type EnsureDaemonResult =
18
+ | { ok: true; pid: number; spawned: boolean; respawned: boolean; httpPort: number }
19
+ | { ok: false; reason: string }
20
+
21
+ const DEFAULT_SPAWN_TIMEOUT_MS = 5_000
22
+ const SHUTDOWN_TIMEOUT_MS = 5_000
23
+ const POLL_INTERVAL_MS = 50
24
+
25
+ export async function ensureDaemon(opts: EnsureDaemonOptions): Promise<EnsureDaemonResult> {
26
+ if (await isDaemonReachable()) {
27
+ const expected = opts.expectedVersion ?? (await deriveExpectedVersion(opts.cliEntry))
28
+ const httpPort = await readHttpPort()
29
+ if ((await daemonVersionMatches(expected)) && httpPort !== null) {
30
+ return { ok: true, pid: await readPidQuiet(), spawned: false, respawned: false, httpPort }
31
+ }
32
+ const shutdownOk = await requestShutdownAndWait()
33
+ if (!shutdownOk) {
34
+ return { ok: false, reason: 'daemon version drifted but shutdown request did not complete' }
35
+ }
36
+ await ensureDirs()
37
+ const respawn = await ensureDaemonWithRetry(opts, 1)
38
+ if (!respawn.ok) return respawn
39
+ return { ...respawn, respawned: true }
40
+ }
41
+
42
+ await ensureDirs()
43
+ const result = await ensureDaemonWithRetry(opts, 1)
44
+ if (!result.ok) return result
45
+ return { ...result, respawned: false }
46
+ }
47
+
48
+ async function deriveExpectedVersion(cliEntry: string): Promise<string> {
49
+ const srcRoot = resolveSrcRoot(cliEntry)
50
+ if (srcRoot === null) return UNVERSIONED_SENTINEL
51
+ return computeSourceVersion({ srcRoot })
52
+ }
53
+
54
+ // A `version` reply that doesn't deserialize cleanly (e.g. a pre-feature
55
+ // daemon that doesn't recognize the kind) is treated as a mismatch. Same for
56
+ // any non-ok response. Conservative: it's safer to over-respawn than to keep
57
+ // running stale code.
58
+ async function daemonVersionMatches(expected: string): Promise<boolean> {
59
+ const reply = await send({ kind: 'version' }, { timeoutMs: 1_000 })
60
+ if (!reply.ok) return false
61
+ const result = reply.result as VersionResult | undefined
62
+ if (!result || typeof result.version !== 'string') return false
63
+ return result.version === expected
64
+ }
65
+
66
+ async function readHttpPort(): Promise<number | null> {
67
+ const reply = await send({ kind: 'http-info' }, { timeoutMs: 1_000 })
68
+ if (!reply.ok) return null
69
+ const result = reply.result as HttpInfoResult | undefined
70
+ return typeof result?.port === 'number' && result.port > 0 && result.port <= 65535 ? result.port : null
71
+ }
72
+
73
+ async function requestShutdownAndWait(): Promise<boolean> {
74
+ const reply = await send({ kind: 'shutdown' }, { timeoutMs: 1_000 })
75
+ if (!reply.ok) return false
76
+ const deadline = Date.now() + SHUTDOWN_TIMEOUT_MS
77
+ while (Date.now() < deadline) {
78
+ if (!existsSync(socketPath())) return true
79
+ await sleep(POLL_INTERVAL_MS)
80
+ }
81
+ return false
82
+ }
83
+
84
+ type SpawnAttemptResult = { ok: true; pid: number; spawned: boolean; httpPort: number } | { ok: false; reason: string }
85
+
86
+ async function ensureDaemonWithRetry(opts: EnsureDaemonOptions, retriesLeft: number): Promise<SpawnAttemptResult> {
87
+ const lock = await acquireLockOrWait(opts.spawnTimeoutMs ?? DEFAULT_SPAWN_TIMEOUT_MS)
88
+ if (lock.kind === 'daemon-reachable') {
89
+ const httpPort = await readHttpPort()
90
+ if (httpPort === null) return { ok: false, reason: 'daemon did not report an HTTP control port' }
91
+ return { ok: true, pid: await readPidQuiet(), spawned: false, httpPort }
92
+ }
93
+ if (lock.kind === 'stale-lock-cleared') {
94
+ if (retriesLeft > 0) return ensureDaemonWithRetry(opts, retriesLeft - 1)
95
+ return { ok: false, reason: 'stale lockfile cleared but retry budget exhausted' }
96
+ }
97
+ if (lock.kind === 'timeout') {
98
+ return { ok: false, reason: lock.reason }
99
+ }
100
+
101
+ try {
102
+ if (await isDaemonReachable()) {
103
+ const httpPort = await readHttpPort()
104
+ if (httpPort === null) return { ok: false, reason: 'daemon did not report an HTTP control port' }
105
+ return { ok: true, pid: await readPidQuiet(), spawned: false, httpPort }
106
+ }
107
+ return await spawnDaemonDetached(opts)
108
+ } finally {
109
+ await releaseLock(lock.token)
110
+ }
111
+ }
112
+
113
+ async function spawnDaemonDetached(opts: EnsureDaemonOptions): Promise<SpawnAttemptResult> {
114
+ // Bun.spawn() with `stdout: <number>` consumes the file descriptor by
115
+ // dup()-ing it into the child; the parent's handle remains valid until we
116
+ // close it. Closing too early would race the dup. We hold the FileHandle
117
+ // open across spawn() and close it only after the child has been launched.
118
+ let handle: Awaited<ReturnType<typeof open>>
119
+ try {
120
+ handle = await open(logfilePath(), 'a')
121
+ } catch (error) {
122
+ return { ok: false, reason: `failed to open daemon log: ${stringify(error)}` }
123
+ }
124
+
125
+ let proc: ReturnType<typeof Bun.spawn>
126
+ try {
127
+ proc = Bun.spawn({
128
+ cmd: [process.execPath, opts.cliEntry, '_hostd'],
129
+ stdin: 'ignore',
130
+ stdout: handle.fd,
131
+ stderr: handle.fd,
132
+ env: { ...process.env },
133
+ })
134
+ } catch (error) {
135
+ handle.close().catch(() => {})
136
+ return { ok: false, reason: `failed to spawn daemon: ${stringify(error)}` }
137
+ }
138
+ proc.unref()
139
+ handle.close().catch(() => {})
140
+
141
+ try {
142
+ await writeFile(pidfilePath(), `${proc.pid}\n`)
143
+ } catch (error) {
144
+ try {
145
+ proc.kill('SIGTERM')
146
+ } catch {}
147
+ return { ok: false, reason: `failed to write daemon pidfile: ${stringify(error)}` }
148
+ }
149
+
150
+ const deadline = Date.now() + (opts.spawnTimeoutMs ?? DEFAULT_SPAWN_TIMEOUT_MS)
151
+ while (Date.now() < deadline) {
152
+ if (await isDaemonReachable()) {
153
+ const httpPort = await readHttpPort()
154
+ if (httpPort !== null) return { ok: true, pid: proc.pid, spawned: true, httpPort }
155
+ }
156
+ await sleep(POLL_INTERVAL_MS)
157
+ }
158
+
159
+ // Daemon failed to come up. Reap the orphan and clean the pidfile so the
160
+ // next ensureDaemon() doesn't observe a dangling pidfile pointing at our
161
+ // dead child.
162
+ try {
163
+ proc.kill('SIGTERM')
164
+ } catch {}
165
+ try {
166
+ const raw = await readFile(pidfilePath(), 'utf8').catch(() => '')
167
+ if (raw.trim() === String(proc.pid)) await unlink(pidfilePath())
168
+ } catch {}
169
+ return { ok: false, reason: 'daemon spawned but did not become reachable' }
170
+ }
171
+
172
+ type LockToken = { path: string }
173
+ type LockResult =
174
+ | { kind: 'acquired'; token: LockToken }
175
+ | { kind: 'daemon-reachable' }
176
+ | { kind: 'stale-lock-cleared' }
177
+ | { kind: 'timeout'; reason: string }
178
+
179
+ async function acquireLockOrWait(timeoutMs: number): Promise<LockResult> {
180
+ const path = lockfilePath()
181
+ const deadline = Date.now() + timeoutMs
182
+ while (Date.now() < deadline) {
183
+ try {
184
+ const handle = await open(path, 'wx')
185
+ await handle.write(`${process.pid}\n`)
186
+ await handle.close()
187
+ return { kind: 'acquired', token: { path } }
188
+ } catch {
189
+ if (await isDaemonReachable()) return { kind: 'daemon-reachable' }
190
+ await sleep(POLL_INTERVAL_MS)
191
+ }
192
+ }
193
+ // Lock held by something that never finished. Clear it so the caller can
194
+ // retry once. Rare in practice (only happens if a previous ensureDaemon
195
+ // process was killed mid-spawn).
196
+ try {
197
+ await unlink(path)
198
+ } catch {}
199
+ return { kind: 'stale-lock-cleared' }
200
+ }
201
+
202
+ async function releaseLock(token: LockToken): Promise<void> {
203
+ try {
204
+ await unlink(token.path)
205
+ } catch {}
206
+ }
207
+
208
+ async function readPidQuiet(): Promise<number> {
209
+ try {
210
+ const raw = await readFile(pidfilePath(), 'utf8')
211
+ const pid = Number.parseInt(raw.trim(), 10)
212
+ return Number.isFinite(pid) ? pid : 0
213
+ } catch {
214
+ return 0
215
+ }
216
+ }
217
+
218
+ function sleep(ms: number): Promise<void> {
219
+ return new Promise((resolve) => setTimeout(resolve, ms))
220
+ }
221
+
222
+ function stringify(err: unknown): string {
223
+ return err instanceof Error ? err.message : String(err)
224
+ }
@@ -0,0 +1,60 @@
1
+ import type { Response } from './protocol'
2
+
3
+ export type SupervisorRestart = (input: {
4
+ containerName: string
5
+ cwd: string
6
+ // When true, the underlying `start()` runs with `forceBuild: true`, which
7
+ // regenerates the Dockerfile from the current CLI template AND rebuilds the
8
+ // image even if it already exists. Default false matches the host-side
9
+ // `typeclaw restart` (no `--build` flag) behavior.
10
+ build?: boolean
11
+ }) => Promise<{ ok: true } | { ok: false; reason: string }>
12
+
13
+ export type SupervisorOptions = {
14
+ restart?: SupervisorRestart
15
+ }
16
+
17
+ export type SupervisorLogEvent =
18
+ | { kind: 'restart-scheduled'; containerName: string; build: boolean }
19
+ | { kind: 'restart-completed'; containerName: string }
20
+ | { kind: 'restart-failed'; containerName: string; reason: string }
21
+
22
+ export type Supervisor = {
23
+ scheduleRestart: (input: { containerName: string; cwd: string; build?: boolean }) => Response
24
+ }
25
+
26
+ export type SupervisorBuildOptions = {
27
+ restart: SupervisorRestart
28
+ onLog: (event: SupervisorLogEvent) => void
29
+ isStopped: () => boolean
30
+ }
31
+
32
+ // The daemon ACKs the agent immediately so the container can exit cleanly,
33
+ // then runs stop+start in the background. If we ran them inline the agent's
34
+ // own RPC connection would die when its container stopped — guaranteed to
35
+ // race because `docker stop` is the very thing we're about to do. Errors are
36
+ // surfaced via the log channel; there is no connected client to receive them.
37
+ export function buildSupervisor({ restart, onLog, isStopped }: SupervisorBuildOptions): Supervisor {
38
+ return {
39
+ scheduleRestart: ({ containerName, cwd, build = false }): Response => {
40
+ if (isStopped()) return { ok: false, reason: 'daemon stopping' }
41
+ onLog({ kind: 'restart-scheduled', containerName, build })
42
+ void runRestart()
43
+ return { ok: true }
44
+
45
+ async function runRestart(): Promise<void> {
46
+ try {
47
+ const result = await restart({ containerName, cwd, build })
48
+ if (result.ok) onLog({ kind: 'restart-completed', containerName })
49
+ else onLog({ kind: 'restart-failed', containerName, reason: result.reason })
50
+ } catch (error) {
51
+ onLog({
52
+ kind: 'restart-failed',
53
+ containerName,
54
+ reason: error instanceof Error ? error.message : String(error),
55
+ })
56
+ }
57
+ }
58
+ },
59
+ }
60
+ }
@@ -0,0 +1,172 @@
1
+ import { getBun } from '@/container/shared'
2
+
3
+ export type TailscaleExecResult = { exitCode: number; stdout: string; stderr: string }
4
+ export type TailscaleExec = (args: string[]) => Promise<TailscaleExecResult>
5
+
6
+ export type TailscaleServeEvent =
7
+ | { kind: 'tailscale-serve-opened'; containerName: string; port: number }
8
+ | { kind: 'tailscale-serve-closed'; containerName: string; port: number }
9
+ | { kind: 'tailscale-serve-skipped'; containerName: string; port: number; reason: string }
10
+ | {
11
+ kind: 'tailscale-serve-failed'
12
+ containerName: string
13
+ port: number
14
+ command: 'status' | 'serve' | 'off'
15
+ reason: string
16
+ }
17
+
18
+ export type TailscaleServeManager = {
19
+ servePort: (port: number) => void
20
+ stopPort: (port: number) => void
21
+ stopAll: () => Promise<void>
22
+ }
23
+
24
+ export type TailscaleServeManagerOptions = {
25
+ containerName: string
26
+ exec?: TailscaleExec
27
+ onEvent: (event: TailscaleServeEvent) => void
28
+ onLog?: (msg: string) => void
29
+ }
30
+
31
+ type TailscaleStatus = {
32
+ BackendState?: unknown
33
+ }
34
+
35
+ const MACOS_APP_CLI = '/Applications/Tailscale.app/Contents/MacOS/Tailscale'
36
+
37
+ export function createTailscaleServeManager(opts: TailscaleServeManagerOptions): TailscaleServeManager {
38
+ const exec = opts.exec ?? defaultTailscaleExec
39
+ const log = opts.onLog ?? (() => {})
40
+ const ownedPorts = new Set<number>()
41
+ const pending = new Set<Promise<void>>()
42
+
43
+ const track = (work: Promise<void>): void => {
44
+ pending.add(work)
45
+ work.finally(() => pending.delete(work)).catch(() => {})
46
+ }
47
+
48
+ const emit = (event: TailscaleServeEvent): void => {
49
+ try {
50
+ opts.onEvent(event)
51
+ } catch (error) {
52
+ log(`tailscale serve event handler threw: ${stringifyError(error)}`)
53
+ }
54
+ }
55
+
56
+ const servePort = (port: number): void => {
57
+ if (ownedPorts.has(port)) return
58
+ track(
59
+ (async () => {
60
+ const ready = await checkRunning(exec)
61
+ if (!ready.ok) {
62
+ emit({ kind: 'tailscale-serve-skipped', containerName: opts.containerName, port, reason: ready.reason })
63
+ return
64
+ }
65
+
66
+ const result = await exec(['serve', '--bg', `--tcp=${port}`, String(port)])
67
+ if (result.exitCode !== 0) {
68
+ emit({
69
+ kind: 'tailscale-serve-failed',
70
+ containerName: opts.containerName,
71
+ port,
72
+ command: 'serve',
73
+ reason: commandError(result),
74
+ })
75
+ return
76
+ }
77
+
78
+ ownedPorts.add(port)
79
+ emit({ kind: 'tailscale-serve-opened', containerName: opts.containerName, port })
80
+ })(),
81
+ )
82
+ }
83
+
84
+ const stopPort = (port: number): void => {
85
+ if (!ownedPorts.has(port)) return
86
+ track(stopOwnedPort(port))
87
+ }
88
+
89
+ const stopOwnedPort = async (port: number): Promise<void> => {
90
+ const result = await exec(['serve', `--tcp=${port}`, 'off'])
91
+ if (result.exitCode !== 0) {
92
+ emit({
93
+ kind: 'tailscale-serve-failed',
94
+ containerName: opts.containerName,
95
+ port,
96
+ command: 'off',
97
+ reason: commandError(result),
98
+ })
99
+ return
100
+ }
101
+
102
+ ownedPorts.delete(port)
103
+ emit({ kind: 'tailscale-serve-closed', containerName: opts.containerName, port })
104
+ }
105
+
106
+ return {
107
+ servePort,
108
+ stopPort,
109
+ async stopAll() {
110
+ await Promise.allSettled(Array.from(pending))
111
+ await Promise.allSettled(Array.from(ownedPorts).map((port) => stopOwnedPort(port)))
112
+ },
113
+ }
114
+ }
115
+
116
+ async function checkRunning(exec: TailscaleExec): Promise<{ ok: true } | { ok: false; reason: string }> {
117
+ const result = await exec(['status', '--json'])
118
+ if (result.exitCode !== 0) return { ok: false, reason: commandError(result) }
119
+
120
+ let parsed: TailscaleStatus
121
+ try {
122
+ parsed = JSON.parse(result.stdout) as TailscaleStatus
123
+ } catch (error) {
124
+ return { ok: false, reason: `invalid tailscale status JSON: ${stringifyError(error)}` }
125
+ }
126
+
127
+ if (parsed.BackendState !== 'Running')
128
+ return { ok: false, reason: `tailscale backend is ${String(parsed.BackendState)}` }
129
+ return { ok: true }
130
+ }
131
+
132
+ export const defaultTailscaleExec: TailscaleExec = async (args) => {
133
+ const candidates = process.platform === 'darwin' ? ['tailscale', MACOS_APP_CLI] : ['tailscale']
134
+ let lastError = 'tailscale command not found'
135
+
136
+ for (const candidate of candidates) {
137
+ const result = await runTailscale(candidate, args)
138
+ if (result.exitCode !== 127) return result
139
+ lastError = result.stderr || lastError
140
+ }
141
+
142
+ return { exitCode: 127, stdout: '', stderr: lastError }
143
+ }
144
+
145
+ async function runTailscale(bin: string, args: string[]): Promise<TailscaleExecResult> {
146
+ const bun = getBun()
147
+ if (!bun) return { exitCode: 127, stdout: '', stderr: 'bun runtime not available' }
148
+ try {
149
+ const proc = bun.spawn({
150
+ cmd: [bin, ...args],
151
+ stdout: 'pipe',
152
+ stderr: 'pipe',
153
+ env: bin === MACOS_APP_CLI ? { ...process.env, TAILSCALE_BE_CLI: '1' } : process.env,
154
+ })
155
+ const exitCode = await proc.exited
156
+ const stdout = await new Response(proc.stdout).text()
157
+ const stderr = await new Response(proc.stderr).text()
158
+ return { exitCode, stdout, stderr }
159
+ } catch (error) {
160
+ const code = typeof error === 'object' && error !== null && 'code' in error ? String(error.code) : ''
161
+ const exitCode = code === 'ENOENT' ? 127 : 1
162
+ return { exitCode, stdout: '', stderr: stringifyError(error) }
163
+ }
164
+ }
165
+
166
+ function commandError(result: TailscaleExecResult): string {
167
+ return (result.stderr || result.stdout || `exit ${result.exitCode}`).trim()
168
+ }
169
+
170
+ function stringifyError(error: unknown): string {
171
+ return error instanceof Error ? error.message : String(error)
172
+ }