typeclaw 0.1.0 → 0.1.2

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 (57) hide show
  1. package/README.md +12 -12
  2. package/package.json +3 -2
  3. package/src/agent/auth.ts +10 -4
  4. package/src/agent/doctor.ts +173 -0
  5. package/src/agent/subagents.ts +24 -2
  6. package/src/bundled-plugins/backup/README.md +81 -0
  7. package/src/bundled-plugins/backup/index.ts +209 -0
  8. package/src/bundled-plugins/backup/runner.ts +231 -0
  9. package/src/bundled-plugins/backup/subagents.ts +200 -0
  10. package/src/bundled-plugins/memory/index.ts +42 -1
  11. package/src/bundled-plugins/security/index.ts +5 -1
  12. package/src/bundled-plugins/security/policies/git-exfil.ts +184 -4
  13. package/src/bundled-plugins/security/policies/remote-taint-state.ts +59 -0
  14. package/src/channels/adapters/kakaotalk-attachment.ts +224 -0
  15. package/src/channels/adapters/kakaotalk-channel-resolver.ts +20 -1
  16. package/src/channels/adapters/kakaotalk-fetch-attachment.ts +91 -0
  17. package/src/channels/adapters/kakaotalk.ts +58 -3
  18. package/src/channels/router.ts +40 -2
  19. package/src/cli/compose.ts +92 -1
  20. package/src/cli/doctor.ts +100 -0
  21. package/src/cli/index.ts +1 -0
  22. package/src/compose/doctor.ts +141 -0
  23. package/src/compose/index.ts +8 -0
  24. package/src/compose/logs.ts +32 -19
  25. package/src/config/config.ts +20 -0
  26. package/src/container/log-colors.ts +75 -0
  27. package/src/container/log-timestamps.ts +84 -0
  28. package/src/container/logs.ts +71 -5
  29. package/src/container/start.ts +23 -8
  30. package/src/cron/consumer.ts +29 -7
  31. package/src/doctor/checks.ts +426 -0
  32. package/src/doctor/commit.ts +71 -0
  33. package/src/doctor/index.ts +287 -0
  34. package/src/doctor/plugin-bridge.ts +147 -0
  35. package/src/doctor/report.ts +142 -0
  36. package/src/doctor/types.ts +87 -0
  37. package/src/init/cli-version.ts +81 -0
  38. package/src/init/dockerfile.ts +223 -25
  39. package/src/init/ensure-deps.ts +2 -2
  40. package/src/init/index.ts +23 -13
  41. package/src/init/run-bun-install.ts +17 -1
  42. package/src/plugin/hooks.ts +32 -0
  43. package/src/plugin/index.ts +7 -0
  44. package/src/plugin/manager.ts +2 -0
  45. package/src/plugin/registry.ts +32 -3
  46. package/src/plugin/types.ts +65 -0
  47. package/src/run/bundled-plugins.ts +8 -0
  48. package/src/run/index.ts +10 -5
  49. package/src/secrets/env.ts +43 -0
  50. package/src/secrets/index.ts +2 -0
  51. package/src/server/index.ts +103 -5
  52. package/src/shared/index.ts +3 -0
  53. package/src/shared/protocol.ts +22 -0
  54. package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +26 -3
  55. package/src/skills/typeclaw-config/SKILL.md +1 -1
  56. package/tsconfig.json +30 -0
  57. package/typeclaw.schema.json +50 -4
@@ -0,0 +1,81 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ // Single source of truth for "what version of typeclaw is this agent on,
5
+ // and where does that mean we should pin the base image / write the dep
6
+ // spec." Sync I/O at module load — relative paths are stable in both a dev
7
+ // checkout and a real install, so the parent-walk an earlier draft used
8
+ // was unnecessary side effect. See AGENTS.md "Rules of thumb" for the
9
+ // install-vs-dev distinction this module encodes.
10
+
11
+ export const GHCR_BASE_IMAGE_REPO = 'ghcr.io/typeclaw/typeclaw-base'
12
+
13
+ const CLI_PACKAGE_JSON_PATH = join(import.meta.dir, '..', '..', 'package.json')
14
+
15
+ const cliPkg = JSON.parse(readFileSync(CLI_PACKAGE_JSON_PATH, 'utf8')) as { name?: string; version?: string }
16
+ if (cliPkg.name !== 'typeclaw' || typeof cliPkg.version !== 'string') {
17
+ throw new Error(`Expected typeclaw package.json at ${CLI_PACKAGE_JSON_PATH}, got name=${cliPkg.name}`)
18
+ }
19
+
20
+ export const CLI_VERSION = cliPkg.version
21
+
22
+ const NODE_MODULES_SEGMENT = `${join('/', 'node_modules', '/')}`
23
+
24
+ function isInstalledCli(): boolean {
25
+ return CLI_PACKAGE_JSON_PATH.includes(NODE_MODULES_SEGMENT)
26
+ }
27
+
28
+ // `^X.Y.Z` when the invoking CLI is itself an installed copy of typeclaw
29
+ // (suitable for writing into a freshly-scaffolded agent's package.json),
30
+ // `null` when the CLI is running from the source repo (caller falls back
31
+ // to `file:` so the agent tracks the local checkout).
32
+ export function resolveScaffoldVersion(): string | null {
33
+ if (!isInstalledCli()) return null
34
+ return `^${CLI_VERSION}`
35
+ }
36
+
37
+ // The version of typeclaw the AGENT will actually run inside the container.
38
+ // Prefers `<agent>/node_modules/typeclaw/package.json#version` because that
39
+ // is what the bind-mount exposes to the container at /agent/node_modules,
40
+ // and we want the base image's CLI version to match the runtime's. Falls
41
+ // back to parsing the agent's `dependencies.typeclaw` spec for fresh inits
42
+ // where `bun install` hasn't run yet, and to `null` when neither maps to
43
+ // a release version (dev mode, ranges, dist-tags, etc.).
44
+ export function resolveBaseImageVersion(agentDir: string): string | null {
45
+ return readInstalledTypeclawVersion(agentDir) ?? readVersionFromDepSpec(agentDir)
46
+ }
47
+
48
+ function readInstalledTypeclawVersion(agentDir: string): string | null {
49
+ try {
50
+ const raw = readFileSync(join(agentDir, 'node_modules', 'typeclaw', 'package.json'), 'utf8')
51
+ const parsed = JSON.parse(raw) as { version?: string }
52
+ if (typeof parsed.version === 'string' && isReleaseVersion(parsed.version)) return parsed.version
53
+ } catch {}
54
+ return null
55
+ }
56
+
57
+ function readVersionFromDepSpec(agentDir: string): string | null {
58
+ try {
59
+ const raw = readFileSync(join(agentDir, 'package.json'), 'utf8')
60
+ const parsed = JSON.parse(raw) as { dependencies?: Record<string, string> }
61
+ const spec = parsed.dependencies?.typeclaw
62
+ if (typeof spec !== 'string') return null
63
+ return extractReleaseVersionFromSpec(spec)
64
+ } catch {
65
+ return null
66
+ }
67
+ }
68
+
69
+ // Accept only specs that name an exact release version we can map 1:1 to a
70
+ // GHCR tag (`X.Y.Z`, `^X.Y.Z`, `~X.Y.Z`, `=X.Y.Z`). Reject ranges, `latest`,
71
+ // `*`, dist-tags, `workspace:` / `git:` / `portal:` / `npm:` aliases. Being
72
+ // strict here delays versioned pinning rather than silently picking the
73
+ // wrong tag — the installed-typeclaw check above is the primary path.
74
+ function extractReleaseVersionFromSpec(spec: string): string | null {
75
+ const match = spec.trim().match(/^[\^~=]?(\d+\.\d+\.\d+)$/)
76
+ return match ? (match[1] ?? null) : null
77
+ }
78
+
79
+ function isReleaseVersion(version: string): boolean {
80
+ return /^\d+\.\d+\.\d+$/.test(version)
81
+ }
@@ -1,12 +1,33 @@
1
1
  import type { DockerfileConfig, DockerfileFeatureToggle } from '@/config/config'
2
2
 
3
+ import { GHCR_BASE_IMAGE_REPO } from './cli-version'
4
+
3
5
  export const DOCKERFILE = 'Dockerfile'
4
6
 
7
+ export type BuildDockerfileOptions = {
8
+ // Null or omitted = emit the full inline heavy stack (dev mode, tests).
9
+ baseImageVersion?: string | null
10
+ }
11
+
5
12
  // Apt packages that EVERY image must have — git for the agent runtime,
6
13
  // curl/ca-certificates/gnupg for HTTPS and key fetches that downstream layers
7
- // (e.g. the gh keyring) depend on. Listed first in the apt-get install line so
8
- // the package set is self-documenting at a glance.
9
- const BASELINE_APT_PACKAGES = ['git', 'ca-certificates', 'curl', 'gnupg'] as const
14
+ // (e.g. the gh keyring) depend on. `iptables` and `util-linux` back the
15
+ // network egress entrypoint shim, installed unconditionally so that flipping
16
+ // `typeclaw.json#network.blockInternal` is a runtime toggle (re-run
17
+ // `typeclaw restart`) and not an image rebuild.
18
+ //
19
+ // On Debian trixie the single `iptables` package ships both the IPv4 nft
20
+ // frontend (`iptables-nft`, available as `iptables` through
21
+ // update-alternatives) and the IPv6 nft frontend (`ip6tables-nft`, available
22
+ // as `ip6tables`). The standalone `iptables-nft`/`ip6tables-nft` package
23
+ // names do NOT exist on trixie — `apt install iptables-nft` fails with
24
+ // "Unable to locate package". The shim invokes `iptables` and `ip6tables`
25
+ // which alternatives resolves to the nft variants.
26
+ //
27
+ // `util-linux` carries `setpriv`, which the shim uses to drop CAP_NET_ADMIN
28
+ // from the bounding set before exec'ing the agent. Listed first in the
29
+ // apt-get install line so the package set is self-documenting at a glance.
30
+ const BASELINE_APT_PACKAGES = ['git', 'ca-certificates', 'curl', 'gnupg', 'iptables', 'util-linux'] as const
10
31
 
11
32
  // curl-impersonate is the only currently-working way to query DuckDuckGo from
12
33
  // a non-browser client on residential IPs in 2026. DDG fingerprints incoming
@@ -35,6 +56,132 @@ export const CURL_IMPERSONATE_SHA256_ARM64 = '6766bc67fd3e8e2313875f32b36b5a3fab
35
56
  // the impersonation to whatever `curl_chrome` resolves to.
36
57
  export const CURL_IMPERSONATE_PROFILE = 'chrome136'
37
58
 
59
+ export const TYPECLAW_ENTRYPOINT_PATH = '/usr/local/bin/typeclaw-entrypoint'
60
+
61
+ // IPv4 networks the container is forbidden to egress to when
62
+ // `network.blockInternal` is true. Loopback (127/8) is NOT here — loopback
63
+ // traffic uses the `lo` interface, which the shim's first ACCEPT rule
64
+ // short-circuits. The agent inside the container needs loopback to dogfood
65
+ // its own `bun run dev` server. RFC1918 (10/8, 172.16/12, 192.168/16) covers
66
+ // router admin panels and home/office LANs. 169.254/16 covers cloud
67
+ // metadata (169.254.169.254 IMDS, 169.254.170.2 ECS task role) and Windows
68
+ // APIPA. 100.64/10 is CGNAT. 224/4 multicast and 240/4 reserved are belt-
69
+ // and-suspenders against creative exfil targets. host.docker.internal (in
70
+ // 172.16/12 on Docker Desktop/Linux) is re-allowed by the shim at runtime
71
+ // via getent so the agent's `restart` tool can still reach hostd.
72
+ export const NETWORK_BLOCK_IPV4_NETS = [
73
+ '10.0.0.0/8',
74
+ '172.16.0.0/12',
75
+ '192.168.0.0/16',
76
+ '169.254.0.0/16',
77
+ '100.64.0.0/10',
78
+ '224.0.0.0/4',
79
+ '240.0.0.0/4',
80
+ ] as const
81
+
82
+ // IPv6 mirrors of the IPv4 block list. fc00::/7 is unique-local (the IPv6
83
+ // equivalent of RFC1918), fe80::/10 is link-local (incl. SLAAC + IPv6 cloud
84
+ // metadata in fd00:ec2::/64 which fits inside fc00::/7), ff00::/8 is
85
+ // multicast, ::ffff:0:0/96 is IPv4-mapped IPv6 (an attacker could otherwise
86
+ // reach 192.168.x.x via [::ffff:192.168.0.1]).
87
+ export const NETWORK_BLOCK_IPV6_NETS = ['fc00::/7', 'fe80::/10', 'ff00::/8', '::ffff:0:0/96'] as const
88
+
89
+ // Renders the shell script that runs as PID 1 inside the container. Two
90
+ // modes, picked at boot time from `$TYPECLAW_NETWORK_BLOCK_INTERNAL`:
91
+ //
92
+ // off (default, blockInternal=false or env unset): no rules installed,
93
+ // no setpriv. Just exec `bun run typeclaw "$@"`. Identical observable
94
+ // behavior to the pre-feature container.
95
+ //
96
+ // on (blockInternal=true): walks IPv4 + IPv6 block lists and installs
97
+ // REJECT rules in the OUTPUT chain. Loopback (`-o lo`) is ACCEPT'd first
98
+ // so dev-server dogfooding still works. The hostd HTTP control port on
99
+ // `host.docker.internal` is re-allowed at runtime — narrowly, single
100
+ // TCP destport, only when hostd is configured — so the agent's `restart`
101
+ // tool can still reach the daemon. The shim then drops CAP_NET_ADMIN
102
+ // from the bounding set AND from the inheritable + ambient sets via
103
+ // setpriv before exec'ing the agent. Bounding set is the hard ceiling
104
+ // enforced by execve; inheritable + ambient are cleared defensively to
105
+ // match setpriv(1)'s explicit warning about not dropping the bounding
106
+ // set alone.
107
+ //
108
+ // Carve-out is intentionally narrow: ACCEPT only `tcp --dport <hostd-port>`
109
+ // to the host gateway, never the gateway IP wholesale. Without the dport
110
+ // scope, a compromised agent could reach any host service via
111
+ // `host.docker.internal:22` (SSH), `:53` (DNS), `:5432` (postgres), etc.
112
+ // The gateway IP itself sits inside `172.16.0.0/12`, which the IPv4 reject
113
+ // rules below DROP — the narrow ACCEPT here is the only path through.
114
+ // When hostd is not configured (`TYPECLAW_HOSTD_URL` unset or unparseable),
115
+ // nothing is ACCEPT'd: the agent loses self-restart capability but the
116
+ // rest of the egress filter still works.
117
+ //
118
+ // IPv4-only carve-out uses `getent ahostsv4` to force the resolver into
119
+ // the A-record path. Without this, `getent hosts` would return whichever
120
+ // family the resolver prefers, and on systems that prefer AAAA we'd feed
121
+ // a v6 address to `iptables` and crash under `set -e`. host.docker.internal
122
+ // resolves to a bridge gateway that is IPv4-only on every Docker runtime
123
+ // we support (Docker Desktop, OrbStack, Docker on Linux with the
124
+ // `--add-host host.docker.internal:host-gateway` flag typeclaw injects).
125
+ //
126
+ // REJECT (not DROP) so the agent fails fast with an ICMP unreachable
127
+ // instead of hanging on a 30-second connect timeout — much friendlier
128
+ // debug UX and identical security posture.
129
+ //
130
+ // `set -eu` propagates rule-install failures up to PID 1 exit, which kills
131
+ // the container. Failing closed is the right thing: an unenforced
132
+ // blockInternal=true is worse than blockInternal=false.
133
+ export function buildEntrypointShim(): string {
134
+ const ipv4Rules = NETWORK_BLOCK_IPV4_NETS.map(
135
+ (net) => `iptables -A OUTPUT -d ${net} -j REJECT --reject-with icmp-port-unreachable`,
136
+ )
137
+ const ipv6Rules = NETWORK_BLOCK_IPV6_NETS.map(
138
+ (net) => `ip6tables -A OUTPUT -d ${net} -j REJECT --reject-with icmp6-port-unreachable`,
139
+ )
140
+ return `#!/bin/sh
141
+ # AUTOGENERATED by typeclaw — do not edit.
142
+ # Source: src/init/dockerfile.ts \`buildEntrypointShim()\`.
143
+ set -eu
144
+
145
+ if [ "\${TYPECLAW_NETWORK_BLOCK_INTERNAL:-0}" != "1" ]; then
146
+ exec bun run typeclaw "$@"
147
+ fi
148
+
149
+ iptables -A OUTPUT -o lo -j ACCEPT
150
+
151
+ # Hostd HTTP control carve-out: narrow ACCEPT, scoped to one TCP port on
152
+ # the host gateway. Skipped silently when hostd is not configured.
153
+ hostd_port=""
154
+ if [ -n "\${TYPECLAW_HOSTD_URL:-}" ]; then
155
+ hostd_port="$(printf '%s' "$TYPECLAW_HOSTD_URL" | sed -n 's#^https\\{0,1\\}://[^/:]\\{1,\\}:\\([0-9]\\{1,5\\}\\).*#\\1#p')"
156
+ fi
157
+ if [ -n "\${hostd_port:-}" ]; then
158
+ host_gw_ip="$(getent ahostsv4 host.docker.internal 2>/dev/null | awk '{print $1; exit}')"
159
+ if [ -n "\${host_gw_ip:-}" ]; then
160
+ iptables -A OUTPUT -p tcp -d "$host_gw_ip" --dport "$hostd_port" -j ACCEPT
161
+ fi
162
+ fi
163
+ ${ipv4Rules.join('\n')}
164
+
165
+ ip6tables -A OUTPUT -o lo -j ACCEPT
166
+ ${ipv6Rules.join('\n')}
167
+
168
+ exec setpriv --bounding-set -net_admin --inh-caps -net_admin --ambient-caps -net_admin -- bun run typeclaw "$@"
169
+ `
170
+ }
171
+
172
+ // Layer 6: install the network-egress entrypoint shim. Content is base64-
173
+ // encoded inline so the Dockerfile is fully self-contained — no second file
174
+ // in the build context, no COPY, no chicken-and-egg between init and start.
175
+ // Layer placement is intentionally late: shim source changes invalidate
176
+ // only this small layer (~1KB image impact), keeping Chrome and apt cached.
177
+ function renderEntrypointShimLayer(): string {
178
+ const encoded = Buffer.from(buildEntrypointShim(), 'utf8').toString('base64')
179
+ return `# Layer 6 (small, changes with the egress shim): install /usr/local/bin/typeclaw-entrypoint.
180
+ # The shim is a no-op unless \`network.blockInternal\` is true at runtime.
181
+ RUN echo "${encoded}" | base64 -d > ${TYPECLAW_ENTRYPOINT_PATH} \\
182
+ && chmod +x ${TYPECLAW_ENTRYPOINT_PATH}`
183
+ }
184
+
38
185
  // Shared-library runtime deps Chrome for Testing needs to launch on amd64
39
186
  // Debian trixie (base of `oven/bun:1-slim`). `agent-browser install
40
187
  // --with-deps` (v0.27.0) is supposed to install these but silently no-ops:
@@ -90,10 +237,19 @@ const APT_FEATURES: Record<'ffmpeg' | 'gh' | 'tmux' | 'python', AptFeature> = {
90
237
  },
91
238
  }
92
239
 
93
- export function buildDockerfile(config: DockerfileConfig = defaultConfig()): string {
94
- const aptArgs = collectAptArgs(config)
240
+ export function buildDockerfile(
241
+ config: DockerfileConfig = defaultConfig(),
242
+ options: BuildDockerfileOptions = {},
243
+ ): string {
244
+ const toggleAptArgs = collectToggleAptArgs(config)
95
245
  const ghKeyringLayer = renderGhKeyringLayer(config.gh)
96
246
  const customLines = renderCustomDockerfileLines(config.append)
247
+ const baseImageVersion = options.baseImageVersion ?? null
248
+
249
+ const fromAndHeavyLayers =
250
+ baseImageVersion !== null
251
+ ? renderVersionedHead(baseImageVersion, ghKeyringLayer, toggleAptArgs)
252
+ : renderInlineHead(ghKeyringLayer, toggleAptArgs)
97
253
 
98
254
  return `${BUILDKIT_HEADER}
99
255
  # AUTOGENERATED by typeclaw — do not edit.
@@ -101,7 +257,51 @@ export function buildDockerfile(config: DockerfileConfig = defaultConfig()): str
101
257
  # in the typeclaw repo. Local edits will be overwritten (and committed away if
102
258
  # the working tree is dirty). To change the template, edit dockerfile.ts there.
103
259
 
104
- ${FROM_AND_WORKDIR}
260
+ ${fromAndHeavyLayers}
261
+ # The agent folder (including node_modules) is bind-mounted at runtime by
262
+ # \`typeclaw start\`, so we do not COPY or install here. This keeps the image
263
+ # tiny and lets edits on the host take effect without rebuilds.
264
+
265
+ ENV NODE_ENV=production
266
+
267
+ # Pin agent-messenger's config dir into the agent's workspace/ so KakaoTalk
268
+ # (and any future agent-messenger-backed channel) reads/writes credentials
269
+ # inside the bind-mounted agent folder. Without this, the SDK would default
270
+ # to /root/.config/agent-messenger inside the container, which doesn't
271
+ # survive container restarts and isn't visible from the host. The agent
272
+ # folder's bind-mount maps /agent → host's agent dir, so the credentials
273
+ # end up at <agentDir>/workspace/.agent-messenger/ on the host.
274
+ ENV AGENT_MESSENGER_CONFIG_DIR=/agent/workspace/.agent-messenger
275
+
276
+ ${customLines}ENTRYPOINT ["${TYPECLAW_ENTRYPOINT_PATH}"]
277
+ CMD ["run"]
278
+ `
279
+ }
280
+
281
+ // FROMs the prebuilt typeclaw-base image at the pinned version. Heavy
282
+ // layers (apt baseline, Chrome runtime libs, curl-impersonate, agent-browser,
283
+ // Chrome for Testing) are already in that image, so the per-agent head only
284
+ // re-runs the toggle apt install and (optionally) the gh keyring bootstrap.
285
+ // When no toggle adds packages, the head is empty after the FROM/WORKDIR/ARG
286
+ // trio — rebuild cost ≈ zero for users who don't change typeclaw.json#dockerfile.
287
+ function renderVersionedHead(baseImageVersion: string, ghKeyringLayer: string, toggleAptArgs: string[]): string {
288
+ const toggleAptLayer = toggleAptArgs.length === 0 ? '' : `${renderToggleAptInstallLayer(toggleAptArgs)}\n\n`
289
+ return `FROM ${GHCR_BASE_IMAGE_REPO}:${baseImageVersion}
290
+
291
+ WORKDIR /agent
292
+
293
+ ARG TARGETARCH
294
+
295
+ ${ghKeyringLayer}${toggleAptLayer}`
296
+ }
297
+
298
+ // FROMs oven/bun:1-slim and rebuilds the full heavy stack inline. Used by
299
+ // dev-mode runs (typeclaw installed via file: / link: spec) where the
300
+ // matching :version GHCR tag does not yet exist, and by the test suite to
301
+ // keep coverage of the full-stack layers independent of GHCR availability.
302
+ function renderInlineHead(ghKeyringLayer: string, toggleAptArgs: string[]): string {
303
+ const baselineAndToggleArgs = [...BASELINE_APT_PACKAGES, ...toggleAptArgs]
304
+ return `${FROM_AND_WORKDIR}
105
305
 
106
306
  # Layers are ordered most-stable first to maximize Docker layer cache hits on
107
307
  # rebuilds. Anything that pulls from npm (volatile) sits below anything that
@@ -125,7 +325,7 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \\
125
325
  --mount=type=cache,target=/var/lib/apt/lists,sharing=locked \\
126
326
  apt-get update \\
127
327
  && apt-get install -y --no-install-recommends \\
128
- ${aptArgs.join(' ')} \\
328
+ ${baselineAndToggleArgs.join(' ')} \\
129
329
  && if [ "$TARGETARCH" = "arm64" ]; then \\
130
330
  apt-get install -y --no-install-recommends chromium; \\
131
331
  else \\
@@ -141,26 +341,22 @@ ${LAYER_4_AGENT_BROWSER_INSTALL}
141
341
 
142
342
  ${LAYER_5_CHROME_FOR_TESTING}
143
343
 
144
- # The agent folder (including node_modules) is bind-mounted at runtime by
145
- # \`typeclaw start\`, so we do not COPY or install here. This keeps the image
146
- # tiny and lets edits on the host take effect without rebuilds.
147
-
148
- ENV NODE_ENV=production
149
-
150
- # Pin agent-messenger's config dir into the agent's workspace/ so KakaoTalk
151
- # (and any future agent-messenger-backed channel) reads/writes credentials
152
- # inside the bind-mounted agent folder. Without this, the SDK would default
153
- # to /root/.config/agent-messenger inside the container, which doesn't
154
- # survive container restarts and isn't visible from the host. The agent
155
- # folder's bind-mount maps /agent → host's agent dir, so the credentials
156
- # end up at <agentDir>/workspace/.agent-messenger/ on the host.
157
- ENV AGENT_MESSENGER_CONFIG_DIR=/agent/workspace/.agent-messenger
344
+ ${renderEntrypointShimLayer()}
158
345
 
159
- ${customLines}ENTRYPOINT ["bun", "run", "typeclaw"]
160
- CMD ["run"]
161
346
  `
162
347
  }
163
348
 
349
+ function renderToggleAptInstallLayer(toggleAptArgs: string[]): string {
350
+ return `# Layer 1 (toggle apt install): packages requested via typeclaw.json
351
+ # #dockerfile toggles. Baseline + Chrome runtime libs are already in the
352
+ # base image; this layer only adds gh/tmux/python/ffmpeg if enabled.
353
+ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \\
354
+ --mount=type=cache,target=/var/lib/apt/lists,sharing=locked \\
355
+ apt-get update \\
356
+ && apt-get install -y --no-install-recommends \\
357
+ ${toggleAptArgs.join(' ')}`
358
+ }
359
+
164
360
  // Recipe for the prebuilt typeclaw-base image published to
165
361
  // ghcr.io/typeclaw/typeclaw-base by .github/workflows/base-image.yml. Built
166
362
  // from the same constants and layer templates as buildDockerfile() so the
@@ -205,6 +401,8 @@ ${LAYER_3_AGENT_BROWSER_ARM64_CONFIG}
205
401
  ${LAYER_4_AGENT_BROWSER_INSTALL}
206
402
 
207
403
  ${LAYER_5_CHROME_FOR_TESTING}
404
+
405
+ ${renderEntrypointShimLayer()}
208
406
  `
209
407
  }
210
408
 
@@ -280,8 +478,8 @@ function defaultConfig(): DockerfileConfig {
280
478
  return { ffmpeg: false, gh: true, python: true, tmux: true, append: [] }
281
479
  }
282
480
 
283
- function collectAptArgs(config: DockerfileConfig): string[] {
284
- const args: string[] = [...BASELINE_APT_PACKAGES]
481
+ function collectToggleAptArgs(config: DockerfileConfig): string[] {
482
+ const args: string[] = []
285
483
  for (const key of ['ffmpeg', 'gh', 'python', 'tmux'] as const) {
286
484
  args.push(...APT_FEATURES[key].toAptArgs(config[key]))
287
485
  }
@@ -2,7 +2,7 @@ import { existsSync, realpathSync } from 'node:fs'
2
2
  import { readFile } from 'node:fs/promises'
3
3
  import { dirname, join, parse as parsePath } from 'node:path'
4
4
 
5
- import { runBunInstall } from './run-bun-install'
5
+ import { type InstallRunner, runBunInstall } from './run-bun-install'
6
6
 
7
7
  const PACKAGE_FILE = 'package.json'
8
8
  const NODE_MODULES = 'node_modules'
@@ -13,7 +13,7 @@ export type EnsureDepsResult =
13
13
 
14
14
  export type EnsureDepsOptions = {
15
15
  cwd: string
16
- install?: (cwd: string) => Promise<{ ok: true } | { ok: false; reason: string }>
16
+ install?: InstallRunner
17
17
  detect?: (cwd: string) => Promise<readonly string[]>
18
18
  }
19
19
 
package/src/init/index.ts CHANGED
@@ -8,14 +8,15 @@ import { DEFAULT_MODEL_REF, KNOWN_PROVIDERS, providerForModelRef, type KnownMode
8
8
  import { checkDockerAvailable, type DockerAvailability, type DockerExec, start } from '@/container'
9
9
  import { createTui } from '@/tui'
10
10
 
11
+ import { resolveBaseImageVersion, resolveScaffoldVersion } from './cli-version'
11
12
  import { buildDockerfile, DOCKERFILE } from './dockerfile'
12
13
  import { buildGitignore, GITIGNORE_FILE } from './gitignore'
13
14
  import { HATCHING_PROMPT } from './hatching'
14
15
  import type { OAuthLoginRunner, OAuthLoginResult } from './oauth-login'
15
16
  import { GITKEEP_FILE, PACKAGES_DIR } from './paths'
16
- import { runBunInstall, type InstallResult } from './run-bun-install'
17
+ import { type InstallResult, type InstallRunner, runBunInstall } from './run-bun-install'
17
18
 
18
- export { runBunInstall, type InstallResult } from './run-bun-install'
19
+ export { type InstallResult, type InstallRunner, runBunInstall } from './run-bun-install'
19
20
 
20
21
  export { GITKEEP_FILE, PACKAGES_DIR } from './paths'
21
22
 
@@ -101,6 +102,7 @@ export type InitOptions = {
101
102
  runKakaotalkAuth?: KakaotalkAuthRunner
102
103
  onProgress?: (event: InitStepEvent) => void
103
104
  runHatching?: HatchRunner
105
+ runBunInstall?: InstallRunner
104
106
  dockerExec?: DockerExec
105
107
  }
106
108
 
@@ -121,6 +123,7 @@ export async function runInit({
121
123
  runKakaotalkAuth,
122
124
  onProgress,
123
125
  runHatching = defaultRunHatching,
126
+ runBunInstall: installRunner = runBunInstall,
124
127
  dockerExec,
125
128
  }: InitOptions): Promise<void> {
126
129
  const emit = onProgress ?? (() => {})
@@ -202,7 +205,7 @@ export async function runInit({
202
205
  }
203
206
 
204
207
  emit({ step: 'install', phase: 'start' })
205
- const install = await runBunInstall(cwd)
208
+ const install = await installRunner(cwd)
206
209
  emit({ step: 'install', phase: 'done', result: install })
207
210
 
208
211
  emit({ step: 'dockerfile', phase: 'start' })
@@ -385,24 +388,29 @@ export async function scaffold(root: string, options: ScaffoldOptions = {}): Pro
385
388
  const AGENT_BROWSER_VERSION = '^0.26.0'
386
389
 
387
390
  function buildPackageJson(root: string, name: string): Record<string, unknown> {
388
- const typeclawRoot = findTypeclawRoot()
389
- // FIXME: temporary dev-stage wiring. Switch to a published version range
390
- // (e.g. "typeclaw": "^x.y.z") once typeclaw is released. The `file:` spec is
391
- // computed relative to the agent root because `file:` resolves relative to
392
- // the consuming package.
393
- const fileSpec = typeclawRoot ? `file:${toFileSpec(relative(root, typeclawRoot))}` : 'file:../typeclaw'
394
391
  return {
395
392
  name,
396
393
  private: true,
397
394
  type: 'module',
398
395
  workspaces: [`${PACKAGES_DIR}/*`],
399
396
  dependencies: {
400
- typeclaw: fileSpec,
397
+ typeclaw: resolveTypeclawSpec(root),
401
398
  'agent-browser': AGENT_BROWSER_VERSION,
402
399
  },
403
400
  }
404
401
  }
405
402
 
403
+ // Prefer the registry-style range (`^X.Y.Z`) when typeclaw is itself an
404
+ // installed package — that's what lets `bun install` in the agent resolve
405
+ // typeclaw from npm. Fall back to `file:` against the local checkout for
406
+ // dev contributors running `bun run src/cli/index.ts init` from the repo.
407
+ function resolveTypeclawSpec(agentRoot: string): string {
408
+ const scaffoldVersion = resolveScaffoldVersion()
409
+ if (scaffoldVersion !== null) return scaffoldVersion
410
+ const typeclawRoot = findTypeclawRoot()
411
+ return typeclawRoot ? `file:${toFileSpec(relative(agentRoot, typeclawRoot))}` : 'file:../typeclaw'
412
+ }
413
+
406
414
  function toFileSpec(rel: string): string {
407
415
  if (rel === '') return '.'
408
416
  // bun/npm accept POSIX-style paths in file: specifiers; normalize separators.
@@ -432,9 +440,11 @@ export async function writeDockerAssets(root: string): Promise<DockerAssetsResul
432
440
  const devMode = typeclawSpec.startsWith('file:')
433
441
 
434
442
  const typeclawConfig = await readTypeclawConfig(root)
435
- await writeFile(join(root, DOCKERFILE), buildDockerfile(typeclawConfig.dockerfile), { flag: 'wx' }).catch(
436
- ignoreExists,
437
- )
443
+ await writeFile(
444
+ join(root, DOCKERFILE),
445
+ buildDockerfile(typeclawConfig.dockerfile, { baseImageVersion: resolveBaseImageVersion(root) }),
446
+ { flag: 'wx' },
447
+ ).catch(ignoreExists)
438
448
 
439
449
  return { ok: true, devMode }
440
450
  } catch (error) {
@@ -1,11 +1,27 @@
1
1
  export type InstallResult = { ok: true } | { ok: false; reason: string }
2
2
 
3
+ // Signature for the function `runInit` uses to materialize the agent folder's
4
+ // dependencies. Exposed as a named type so callers (and tests) can pass their
5
+ // own stub without re-declaring the shape, mirroring `HatchRunner` and
6
+ // `KakaotalkAuthRunner` in `./index.ts`.
7
+ export type InstallRunner = (cwd: string) => Promise<InstallResult>
8
+
3
9
  export async function runBunInstall(cwd: string): Promise<InstallResult> {
4
10
  const bun = (globalThis as { Bun?: { spawn: typeof Bun.spawn } }).Bun
5
11
  if (!bun) return { ok: false, reason: 'bun runtime not available' }
6
12
  try {
7
13
  const proc = bun.spawn({
8
- cmd: ['bun', 'install'],
14
+ // `--linker=hoisted` sidesteps a deadlock in Bun 1.3.x's isolated linker
15
+ // (the default since 1.3.0). When any single package fetch fails — 401,
16
+ // SHA-512 mismatch, transient registry 5xx, the kind of flake that's
17
+ // routine on GitHub Actions shared-IP runners — the isolated linker
18
+ // hangs the process indefinitely instead of erroring out
19
+ // (oven-sh/bun#26341, oven-sh/bun#29646). `bun install` runs here over
20
+ // ~500 transitive packages with no lockfile, so the odds of triggering
21
+ // the bug are non-trivial. Hoisted is the fallback strategy bun shipped
22
+ // before 1.3 — slightly slower for huge monorepos, indistinguishable
23
+ // for an agent folder, and not affected by the bug.
24
+ cmd: ['bun', 'install', '--linker=hoisted'],
9
25
  cwd,
10
26
  stdout: 'pipe',
11
27
  stderr: 'pipe',
@@ -6,6 +6,8 @@ import type {
6
6
  SessionIdleEvent,
7
7
  SessionPromptEvent,
8
8
  SessionStartEvent,
9
+ SessionTurnEndEvent,
10
+ SessionTurnStartEvent,
9
11
  ToolAfterEvent,
10
12
  ToolBeforeEvent,
11
13
  ToolBeforeResult,
@@ -43,6 +45,8 @@ export type HookBus = {
43
45
  runSessionEnd: (event: SessionEndEvent) => Promise<void>
44
46
  runSessionIdle: (event: SessionIdleEvent) => Promise<void>
45
47
  runSessionPrompt: (event: SessionPromptEvent) => Promise<void>
48
+ runSessionTurnStart: (event: SessionTurnStartEvent) => Promise<void>
49
+ runSessionTurnEnd: (event: SessionTurnEndEvent) => Promise<void>
46
50
  runToolBefore: (event: ToolBeforeEvent) => Promise<{ block: true; reason: string } | undefined>
47
51
  runToolAfter: (event: ToolAfterEvent) => Promise<void>
48
52
  count: (name: keyof Hooks) => number
@@ -62,6 +66,8 @@ type Registries = {
62
66
  'session.end': RegisteredHook<'session.end'>[]
63
67
  'session.idle': RegisteredHook<'session.idle'>[]
64
68
  'session.prompt': RegisteredHook<'session.prompt'>[]
69
+ 'session.turn.start': RegisteredHook<'session.turn.start'>[]
70
+ 'session.turn.end': RegisteredHook<'session.turn.end'>[]
65
71
  'tool.before': RegisteredHook<'tool.before'>[]
66
72
  'tool.after': RegisteredHook<'tool.after'>[]
67
73
  }
@@ -74,6 +80,8 @@ export function createHookBus(options: CreateHookBusOptions = {}): HookBus {
74
80
  'session.end': [],
75
81
  'session.idle': [],
76
82
  'session.prompt': [],
83
+ 'session.turn.start': [],
84
+ 'session.turn.end': [],
77
85
  'tool.before': [],
78
86
  'tool.after': [],
79
87
  }
@@ -89,6 +97,8 @@ export function createHookBus(options: CreateHookBusOptions = {}): HookBus {
89
97
  if (hooks['session.end']) r['session.end'].push({ ...base, handler: hooks['session.end'] })
90
98
  if (hooks['session.idle']) r['session.idle'].push({ ...base, handler: hooks['session.idle'] })
91
99
  if (hooks['session.prompt']) r['session.prompt'].push({ ...base, handler: hooks['session.prompt'] })
100
+ if (hooks['session.turn.start']) r['session.turn.start'].push({ ...base, handler: hooks['session.turn.start'] })
101
+ if (hooks['session.turn.end']) r['session.turn.end'].push({ ...base, handler: hooks['session.turn.end'] })
92
102
  if (hooks['tool.before']) r['tool.before'].push({ ...base, handler: hooks['tool.before'] })
93
103
  if (hooks['tool.after']) r['tool.after'].push({ ...base, handler: hooks['tool.after'] })
94
104
  },
@@ -98,6 +108,8 @@ export function createHookBus(options: CreateHookBusOptions = {}): HookBus {
98
108
  r['session.end'] = r['session.end'].filter((h) => h.pluginName !== pluginName)
99
109
  r['session.idle'] = r['session.idle'].filter((h) => h.pluginName !== pluginName)
100
110
  r['session.prompt'] = r['session.prompt'].filter((h) => h.pluginName !== pluginName)
111
+ r['session.turn.start'] = r['session.turn.start'].filter((h) => h.pluginName !== pluginName)
112
+ r['session.turn.end'] = r['session.turn.end'].filter((h) => h.pluginName !== pluginName)
101
113
  r['tool.before'] = r['tool.before'].filter((h) => h.pluginName !== pluginName)
102
114
  r['tool.after'] = r['tool.after'].filter((h) => h.pluginName !== pluginName)
103
115
  },
@@ -150,6 +162,26 @@ export function createHookBus(options: CreateHookBusOptions = {}): HookBus {
150
162
  }
151
163
  },
152
164
 
165
+ async runSessionTurnStart(event) {
166
+ for (const reg of r['session.turn.start']) {
167
+ try {
168
+ await reg.handler(event, ctx(reg))
169
+ } catch (err) {
170
+ reportHookError(reg, 'session.turn.start', err)
171
+ }
172
+ }
173
+ },
174
+
175
+ async runSessionTurnEnd(event) {
176
+ for (const reg of r['session.turn.end']) {
177
+ try {
178
+ await reg.handler(event, ctx(reg))
179
+ } catch (err) {
180
+ reportHookError(reg, 'session.turn.end', err)
181
+ }
182
+ }
183
+ },
184
+
153
185
  // First plugin to return `{ block: true, reason }` short-circuits. Earlier
154
186
  // plugins' arg mutations remain visible to later plugins via the shared
155
187
  // event.args object.
@@ -18,10 +18,16 @@ export type {
18
18
  HookContext,
19
19
  HookName,
20
20
  Hooks,
21
+ PluginCheckResult,
22
+ PluginCheckStatus,
21
23
  PluginContext,
22
24
  PluginCronJob,
25
+ PluginDoctorCheck,
26
+ PluginDoctorContext,
23
27
  PluginExecCronJob,
24
28
  PluginExports,
29
+ PluginFixResult,
30
+ PluginFixSuggestion,
25
31
  PluginLogger,
26
32
  PluginPromptCronJob,
27
33
  PluginSkill,
@@ -55,6 +61,7 @@ export {
55
61
  buildPluginCronGlobalId,
56
62
  type PluginRegistry,
57
63
  type RegisteredCronJob,
64
+ type RegisteredDoctorCheck,
58
65
  type RegisteredSubagent,
59
66
  type RegisteredTool,
60
67
  type RegisteredSkillEntry,
@@ -93,6 +93,7 @@ export async function loadPlugins(opts: LoadPluginsOptions): Promise<LoadPlugins
93
93
  registry,
94
94
  hooks,
95
95
  agentDir: opts.agentDir,
96
+ pluginConfig: validatedConfig,
96
97
  })
97
98
  } catch (err) {
98
99
  discardRegistrationsBy(resolved.name, registry, hooks)
@@ -123,6 +124,7 @@ export function summarizeLoaded(loaded: LoadPluginsResult['loadedPlugins'], regi
123
124
  `${registry.cronJobs.length} cron job(s)`,
124
125
  `${registry.skills.length} skill(s)`,
125
126
  `${registry.skillsDirs.length} skills dir(s)`,
127
+ `${registry.doctorChecks.length} doctor check(s)`,
126
128
  ].join(', ')
127
129
  return `${loaded.length} plugin(s): ${head} [${counts}]`
128
130
  }