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.
- package/LICENSE +21 -0
- package/README.md +134 -0
- package/auth.schema.json +63 -0
- package/cron.schema.json +96 -0
- package/package.json +72 -0
- package/scripts/emit-base-dockerfile.ts +5 -0
- package/scripts/generate-schema.ts +34 -0
- package/secrets.schema.json +63 -0
- package/src/agent/auth.ts +119 -0
- package/src/agent/compaction.ts +35 -0
- package/src/agent/git-nudge.ts +95 -0
- package/src/agent/index.ts +451 -0
- package/src/agent/plugin-tools.ts +269 -0
- package/src/agent/reload-tool.ts +71 -0
- package/src/agent/self.ts +45 -0
- package/src/agent/session-origin.ts +288 -0
- package/src/agent/subagents.ts +253 -0
- package/src/agent/system-prompt.ts +68 -0
- package/src/agent/tools/channel-fetch-attachment.ts +118 -0
- package/src/agent/tools/channel-history.ts +119 -0
- package/src/agent/tools/channel-reply.ts +182 -0
- package/src/agent/tools/channel-send.ts +212 -0
- package/src/agent/tools/ddg.ts +218 -0
- package/src/agent/tools/restart.ts +122 -0
- package/src/agent/tools/stream-snapshot.ts +181 -0
- package/src/agent/tools/webfetch/fetch.ts +102 -0
- package/src/agent/tools/webfetch/index.ts +1 -0
- package/src/agent/tools/webfetch/strategies/grep.ts +70 -0
- package/src/agent/tools/webfetch/strategies/jq.ts +31 -0
- package/src/agent/tools/webfetch/strategies/raw.ts +3 -0
- package/src/agent/tools/webfetch/strategies/readability.ts +30 -0
- package/src/agent/tools/webfetch/strategies/selector.ts +41 -0
- package/src/agent/tools/webfetch/strategies/snapshot.ts +135 -0
- package/src/agent/tools/webfetch/tool.ts +281 -0
- package/src/agent/tools/webfetch/types.ts +33 -0
- package/src/agent/tools/websearch.ts +96 -0
- package/src/agent/tools/wikipedia.ts +52 -0
- package/src/bundled-plugins/agent-browser/dashboard-discovery.ts +170 -0
- package/src/bundled-plugins/agent-browser/dashboard-proxy.ts +421 -0
- package/src/bundled-plugins/agent-browser/index.ts +179 -0
- package/src/bundled-plugins/agent-browser/shim-install.ts +158 -0
- package/src/bundled-plugins/agent-browser/shim.ts +152 -0
- package/src/bundled-plugins/agent-browser/skills/agent-browser/SKILL.md +113 -0
- package/src/bundled-plugins/guard/index.ts +26 -0
- package/src/bundled-plugins/guard/policies/non-workspace-write.ts +98 -0
- package/src/bundled-plugins/guard/policies/skill-authoring.ts +185 -0
- package/src/bundled-plugins/guard/policies/uncommitted-changes.ts +85 -0
- package/src/bundled-plugins/guard/policy.ts +18 -0
- package/src/bundled-plugins/memory/README.md +71 -0
- package/src/bundled-plugins/memory/append-tool.ts +84 -0
- package/src/bundled-plugins/memory/dreaming-state.ts +86 -0
- package/src/bundled-plugins/memory/dreaming.ts +470 -0
- package/src/bundled-plugins/memory/fragment-parser.ts +67 -0
- package/src/bundled-plugins/memory/index.ts +238 -0
- package/src/bundled-plugins/memory/load-memory.ts +122 -0
- package/src/bundled-plugins/memory/memory-logger.ts +257 -0
- package/src/bundled-plugins/memory/secret-detector.ts +49 -0
- package/src/bundled-plugins/memory/watermark.ts +15 -0
- package/src/bundled-plugins/security/index.ts +35 -0
- package/src/bundled-plugins/security/policies/git-exfil.ts +120 -0
- package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +167 -0
- package/src/bundled-plugins/security/policies/prompt-injection.ts +488 -0
- package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +99 -0
- package/src/bundled-plugins/security/policies/secret-exfil-read.ts +127 -0
- package/src/bundled-plugins/security/policies/session-search-secrets.ts +86 -0
- package/src/bundled-plugins/security/policies/ssrf.ts +196 -0
- package/src/bundled-plugins/security/policies/system-prompt-leak.ts +81 -0
- package/src/bundled-plugins/security/policy.ts +9 -0
- package/src/channels/adapters/discord-bot-channel-resolver.ts +77 -0
- package/src/channels/adapters/discord-bot-classify.ts +148 -0
- package/src/channels/adapters/discord-bot.ts +640 -0
- package/src/channels/adapters/kakaotalk-author-resolver.ts +78 -0
- package/src/channels/adapters/kakaotalk-channel-resolver.ts +105 -0
- package/src/channels/adapters/kakaotalk-classify.ts +77 -0
- package/src/channels/adapters/kakaotalk.ts +622 -0
- package/src/channels/adapters/slack-bot-author-resolver.ts +80 -0
- package/src/channels/adapters/slack-bot-channel-resolver.ts +84 -0
- package/src/channels/adapters/slack-bot-classify.ts +213 -0
- package/src/channels/adapters/slack-bot-dedupe.ts +51 -0
- package/src/channels/adapters/slack-bot-time.ts +10 -0
- package/src/channels/adapters/slack-bot.ts +881 -0
- package/src/channels/adapters/telegram-bot-classify.ts +155 -0
- package/src/channels/adapters/telegram-bot-format.ts +309 -0
- package/src/channels/adapters/telegram-bot.ts +604 -0
- package/src/channels/engagement.ts +227 -0
- package/src/channels/index.ts +21 -0
- package/src/channels/manager.ts +292 -0
- package/src/channels/membership-cache.ts +116 -0
- package/src/channels/membership-from-history.ts +53 -0
- package/src/channels/membership.ts +30 -0
- package/src/channels/participants.ts +47 -0
- package/src/channels/persistence.ts +209 -0
- package/src/channels/reloadable.ts +28 -0
- package/src/channels/router.ts +1570 -0
- package/src/channels/schema.ts +273 -0
- package/src/channels/types.ts +160 -0
- package/src/cli/channel.ts +403 -0
- package/src/cli/compose-status.ts +95 -0
- package/src/cli/compose.ts +240 -0
- package/src/cli/hostd.ts +163 -0
- package/src/cli/index.ts +27 -0
- package/src/cli/init.ts +592 -0
- package/src/cli/logs.ts +38 -0
- package/src/cli/reload.ts +68 -0
- package/src/cli/restart.ts +66 -0
- package/src/cli/run.ts +77 -0
- package/src/cli/shell.ts +33 -0
- package/src/cli/start.ts +57 -0
- package/src/cli/status.ts +178 -0
- package/src/cli/stop.ts +31 -0
- package/src/cli/tui.ts +35 -0
- package/src/cli/ui.ts +110 -0
- package/src/commands/index.ts +74 -0
- package/src/compose/discover.ts +43 -0
- package/src/compose/index.ts +25 -0
- package/src/compose/logs.ts +162 -0
- package/src/compose/restart.ts +69 -0
- package/src/compose/start.ts +62 -0
- package/src/compose/status.ts +28 -0
- package/src/compose/stop.ts +43 -0
- package/src/config/config.ts +424 -0
- package/src/config/index.ts +25 -0
- package/src/config/providers.ts +234 -0
- package/src/config/reloadable.ts +47 -0
- package/src/container/index.ts +27 -0
- package/src/container/logs.ts +37 -0
- package/src/container/port.ts +137 -0
- package/src/container/shared.ts +290 -0
- package/src/container/shell.ts +58 -0
- package/src/container/start.ts +670 -0
- package/src/container/status.ts +76 -0
- package/src/container/stop.ts +120 -0
- package/src/container/verify-running.ts +149 -0
- package/src/cron/consumer.ts +138 -0
- package/src/cron/index.ts +54 -0
- package/src/cron/reloadable.ts +64 -0
- package/src/cron/scheduler.ts +200 -0
- package/src/cron/schema.ts +96 -0
- package/src/hostd/client.ts +113 -0
- package/src/hostd/daemon.ts +587 -0
- package/src/hostd/index.ts +25 -0
- package/src/hostd/paths.ts +82 -0
- package/src/hostd/portbroker-manager.ts +101 -0
- package/src/hostd/protocol.ts +48 -0
- package/src/hostd/spawn.ts +224 -0
- package/src/hostd/supervisor.ts +60 -0
- package/src/hostd/tailscale.ts +172 -0
- package/src/hostd/version.ts +115 -0
- package/src/init/dockerfile.ts +327 -0
- package/src/init/ensure-deps.ts +152 -0
- package/src/init/gitignore.ts +46 -0
- package/src/init/hatching.ts +60 -0
- package/src/init/index.ts +786 -0
- package/src/init/kakaotalk-auth.ts +114 -0
- package/src/init/models-dev.ts +130 -0
- package/src/init/oauth-login.ts +74 -0
- package/src/init/packagejson.ts +94 -0
- package/src/init/paths.ts +2 -0
- package/src/init/run-bun-install.ts +20 -0
- package/src/markdown/chunk.ts +299 -0
- package/src/markdown/index.ts +1 -0
- package/src/plugin/context.ts +40 -0
- package/src/plugin/define.ts +35 -0
- package/src/plugin/hooks.ts +204 -0
- package/src/plugin/index.ts +63 -0
- package/src/plugin/loader.ts +111 -0
- package/src/plugin/manager.ts +136 -0
- package/src/plugin/registry.ts +145 -0
- package/src/plugin/skills.ts +62 -0
- package/src/plugin/types.ts +172 -0
- package/src/portbroker/bind-with-forward.ts +102 -0
- package/src/portbroker/container-server.ts +305 -0
- package/src/portbroker/forward-result-bus.ts +36 -0
- package/src/portbroker/hostd-client.ts +443 -0
- package/src/portbroker/index.ts +33 -0
- package/src/portbroker/policy.ts +24 -0
- package/src/portbroker/proc-net-tcp.ts +72 -0
- package/src/portbroker/protocol.ts +39 -0
- package/src/reload/client.ts +59 -0
- package/src/reload/index.ts +3 -0
- package/src/reload/registry.ts +60 -0
- package/src/reload/types.ts +13 -0
- package/src/run/bundled-plugins.ts +24 -0
- package/src/run/channel-session-factory.ts +105 -0
- package/src/run/index.ts +432 -0
- package/src/run/plugin-runtime.ts +43 -0
- package/src/run/schema-with-plugins.ts +14 -0
- package/src/secrets/index.ts +13 -0
- package/src/secrets/migrate.ts +95 -0
- package/src/secrets/schema.ts +75 -0
- package/src/secrets/storage.ts +231 -0
- package/src/server/index.ts +436 -0
- package/src/sessions/index.ts +23 -0
- package/src/shared/index.ts +9 -0
- package/src/shared/local-time.ts +21 -0
- package/src/shared/protocol.ts +25 -0
- package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +87 -0
- package/src/skills/typeclaw-channel-telegram-bot/SKILL.md +64 -0
- package/src/skills/typeclaw-config/SKILL.md +643 -0
- package/src/skills/typeclaw-cron/SKILL.md +159 -0
- package/src/skills/typeclaw-git/SKILL.md +89 -0
- package/src/skills/typeclaw-memory/SKILL.md +174 -0
- package/src/skills/typeclaw-monorepo/SKILL.md +175 -0
- package/src/skills/typeclaw-plugins/SKILL.md +594 -0
- package/src/skills/typeclaw-skills/SKILL.md +246 -0
- package/src/stream/broker.ts +161 -0
- package/src/stream/index.ts +16 -0
- package/src/stream/types.ts +69 -0
- package/src/tui/client.ts +45 -0
- package/src/tui/format.ts +317 -0
- package/src/tui/index.ts +225 -0
- package/src/tui/theme.ts +41 -0
- 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
|
+
}
|