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,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
|
+
}
|