typeclaw 0.1.1 → 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.
- package/README.md +12 -12
- package/package.json +1 -1
- package/src/agent/doctor.ts +173 -0
- package/src/agent/subagents.ts +24 -2
- package/src/bundled-plugins/backup/README.md +81 -0
- package/src/bundled-plugins/backup/index.ts +209 -0
- package/src/bundled-plugins/backup/runner.ts +231 -0
- package/src/bundled-plugins/backup/subagents.ts +200 -0
- package/src/bundled-plugins/memory/index.ts +42 -1
- package/src/channels/router.ts +29 -0
- package/src/cli/compose.ts +92 -1
- package/src/cli/doctor.ts +100 -0
- package/src/cli/index.ts +1 -0
- package/src/compose/doctor.ts +141 -0
- package/src/compose/index.ts +8 -0
- package/src/compose/logs.ts +32 -19
- package/src/config/config.ts +20 -0
- package/src/container/log-colors.ts +75 -0
- package/src/container/log-timestamps.ts +84 -0
- package/src/container/logs.ts +71 -5
- package/src/container/start.ts +23 -8
- package/src/cron/consumer.ts +29 -7
- package/src/doctor/checks.ts +426 -0
- package/src/doctor/commit.ts +71 -0
- package/src/doctor/index.ts +287 -0
- package/src/doctor/plugin-bridge.ts +147 -0
- package/src/doctor/report.ts +142 -0
- package/src/doctor/types.ts +87 -0
- package/src/init/cli-version.ts +81 -0
- package/src/init/dockerfile.ts +223 -25
- package/src/init/index.ts +18 -10
- package/src/plugin/hooks.ts +32 -0
- package/src/plugin/index.ts +7 -0
- package/src/plugin/manager.ts +2 -0
- package/src/plugin/registry.ts +32 -3
- package/src/plugin/types.ts +65 -0
- package/src/run/bundled-plugins.ts +8 -0
- package/src/run/index.ts +10 -5
- package/src/server/index.ts +103 -5
- package/src/shared/index.ts +3 -0
- package/src/shared/protocol.ts +22 -0
- package/src/skills/typeclaw-config/SKILL.md +1 -1
- package/typeclaw.schema.json +50 -0
|
@@ -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
|
+
}
|
package/src/init/dockerfile.ts
CHANGED
|
@@ -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.
|
|
8
|
-
//
|
|
9
|
-
|
|
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(
|
|
94
|
-
|
|
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
|
-
${
|
|
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
|
-
${
|
|
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
|
-
|
|
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
|
|
284
|
-
const args: string[] = [
|
|
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
|
}
|
package/src/init/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ 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'
|
|
@@ -387,24 +388,29 @@ export async function scaffold(root: string, options: ScaffoldOptions = {}): Pro
|
|
|
387
388
|
const AGENT_BROWSER_VERSION = '^0.26.0'
|
|
388
389
|
|
|
389
390
|
function buildPackageJson(root: string, name: string): Record<string, unknown> {
|
|
390
|
-
const typeclawRoot = findTypeclawRoot()
|
|
391
|
-
// FIXME: temporary dev-stage wiring. Switch to a published version range
|
|
392
|
-
// (e.g. "typeclaw": "^x.y.z") once typeclaw is released. The `file:` spec is
|
|
393
|
-
// computed relative to the agent root because `file:` resolves relative to
|
|
394
|
-
// the consuming package.
|
|
395
|
-
const fileSpec = typeclawRoot ? `file:${toFileSpec(relative(root, typeclawRoot))}` : 'file:../typeclaw'
|
|
396
391
|
return {
|
|
397
392
|
name,
|
|
398
393
|
private: true,
|
|
399
394
|
type: 'module',
|
|
400
395
|
workspaces: [`${PACKAGES_DIR}/*`],
|
|
401
396
|
dependencies: {
|
|
402
|
-
typeclaw:
|
|
397
|
+
typeclaw: resolveTypeclawSpec(root),
|
|
403
398
|
'agent-browser': AGENT_BROWSER_VERSION,
|
|
404
399
|
},
|
|
405
400
|
}
|
|
406
401
|
}
|
|
407
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
|
+
|
|
408
414
|
function toFileSpec(rel: string): string {
|
|
409
415
|
if (rel === '') return '.'
|
|
410
416
|
// bun/npm accept POSIX-style paths in file: specifiers; normalize separators.
|
|
@@ -434,9 +440,11 @@ export async function writeDockerAssets(root: string): Promise<DockerAssetsResul
|
|
|
434
440
|
const devMode = typeclawSpec.startsWith('file:')
|
|
435
441
|
|
|
436
442
|
const typeclawConfig = await readTypeclawConfig(root)
|
|
437
|
-
await writeFile(
|
|
438
|
-
|
|
439
|
-
|
|
443
|
+
await writeFile(
|
|
444
|
+
join(root, DOCKERFILE),
|
|
445
|
+
buildDockerfile(typeclawConfig.dockerfile, { baseImageVersion: resolveBaseImageVersion(root) }),
|
|
446
|
+
{ flag: 'wx' },
|
|
447
|
+
).catch(ignoreExists)
|
|
440
448
|
|
|
441
449
|
return { ok: true, devMode }
|
|
442
450
|
} catch (error) {
|
package/src/plugin/hooks.ts
CHANGED
|
@@ -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.
|
package/src/plugin/index.ts
CHANGED
|
@@ -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,
|
package/src/plugin/manager.ts
CHANGED
|
@@ -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
|
}
|
package/src/plugin/registry.ts
CHANGED
|
@@ -3,13 +3,28 @@ import { existsSync } from 'node:fs'
|
|
|
3
3
|
import type { CronJob, PromptJob } from '@/cron'
|
|
4
4
|
|
|
5
5
|
import type { HookBus } from './hooks'
|
|
6
|
-
import type {
|
|
6
|
+
import type {
|
|
7
|
+
PluginCronJob,
|
|
8
|
+
PluginDoctorCheck,
|
|
9
|
+
PluginExports,
|
|
10
|
+
PluginLogger,
|
|
11
|
+
PluginSkill,
|
|
12
|
+
Subagent,
|
|
13
|
+
Tool,
|
|
14
|
+
} from './types'
|
|
7
15
|
|
|
8
16
|
export type RegisteredTool = { pluginName: string; toolName: string; tool: Tool<any>; logger: PluginLogger }
|
|
9
17
|
export type RegisteredSubagent = { pluginName: string; subagentName: string; subagent: Subagent<any> }
|
|
10
18
|
export type RegisteredCronJob = { pluginName: string; localId: string; globalId: string; job: CronJob }
|
|
11
19
|
export type RegisteredSkillEntry = { pluginName: string; localName: string; skill: PluginSkill }
|
|
12
20
|
export type RegisteredSkillDir = { pluginName: string; path: string }
|
|
21
|
+
export type RegisteredDoctorCheck = {
|
|
22
|
+
pluginName: string
|
|
23
|
+
checkName: string
|
|
24
|
+
pluginConfig: unknown
|
|
25
|
+
logger: PluginLogger
|
|
26
|
+
check: PluginDoctorCheck
|
|
27
|
+
}
|
|
13
28
|
|
|
14
29
|
export type PluginRegistry = {
|
|
15
30
|
tools: RegisteredTool[]
|
|
@@ -17,6 +32,7 @@ export type PluginRegistry = {
|
|
|
17
32
|
cronJobs: RegisteredCronJob[]
|
|
18
33
|
skills: RegisteredSkillEntry[]
|
|
19
34
|
skillsDirs: RegisteredSkillDir[]
|
|
35
|
+
doctorChecks: RegisteredDoctorCheck[]
|
|
20
36
|
}
|
|
21
37
|
|
|
22
38
|
export type RegisterContributionsOptions = {
|
|
@@ -26,6 +42,7 @@ export type RegisterContributionsOptions = {
|
|
|
26
42
|
registry: PluginRegistry
|
|
27
43
|
hooks: HookBus
|
|
28
44
|
agentDir: string
|
|
45
|
+
pluginConfig: unknown
|
|
29
46
|
}
|
|
30
47
|
|
|
31
48
|
export function buildPluginCronGlobalId(pluginName: string, localId: string): string {
|
|
@@ -33,7 +50,7 @@ export function buildPluginCronGlobalId(pluginName: string, localId: string): st
|
|
|
33
50
|
}
|
|
34
51
|
|
|
35
52
|
export function registerContributions(opts: RegisterContributionsOptions): void {
|
|
36
|
-
const { pluginName, logger, exports: ex, registry, hooks, agentDir } = opts
|
|
53
|
+
const { pluginName, logger, exports: ex, registry, hooks, agentDir, pluginConfig } = opts
|
|
37
54
|
|
|
38
55
|
if (ex.tools) {
|
|
39
56
|
for (const [toolName, tool] of Object.entries(ex.tools)) {
|
|
@@ -99,6 +116,17 @@ export function registerContributions(opts: RegisterContributionsOptions): void
|
|
|
99
116
|
if (ex.hooks) {
|
|
100
117
|
hooks.registerAll(pluginName, agentDir, logger, ex.hooks)
|
|
101
118
|
}
|
|
119
|
+
|
|
120
|
+
if (ex.doctorChecks) {
|
|
121
|
+
for (const [checkName, check] of Object.entries(ex.doctorChecks)) {
|
|
122
|
+
assertNotEmpty('doctor check name', checkName, pluginName)
|
|
123
|
+
const conflict = registry.doctorChecks.find((c) => c.pluginName === pluginName && c.checkName === checkName)
|
|
124
|
+
if (conflict) {
|
|
125
|
+
throw new Error(`plugin ${pluginName}: doctor check "${checkName}" already registered`)
|
|
126
|
+
}
|
|
127
|
+
registry.doctorChecks.push({ pluginName, checkName, pluginConfig, logger, check })
|
|
128
|
+
}
|
|
129
|
+
}
|
|
102
130
|
}
|
|
103
131
|
|
|
104
132
|
export function discardRegistrationsBy(pluginName: string, registry: PluginRegistry, hooks: HookBus): void {
|
|
@@ -107,11 +135,12 @@ export function discardRegistrationsBy(pluginName: string, registry: PluginRegis
|
|
|
107
135
|
registry.cronJobs = registry.cronJobs.filter((j) => j.pluginName !== pluginName)
|
|
108
136
|
registry.skills = registry.skills.filter((s) => s.pluginName !== pluginName)
|
|
109
137
|
registry.skillsDirs = registry.skillsDirs.filter((d) => d.pluginName !== pluginName)
|
|
138
|
+
registry.doctorChecks = registry.doctorChecks.filter((d) => d.pluginName !== pluginName)
|
|
110
139
|
hooks.unregisterAll(pluginName)
|
|
111
140
|
}
|
|
112
141
|
|
|
113
142
|
export function emptyRegistry(): PluginRegistry {
|
|
114
|
-
return { tools: [], subagents: [], cronJobs: [], skills: [], skillsDirs: [] }
|
|
143
|
+
return { tools: [], subagents: [], cronJobs: [], skills: [], skillsDirs: [], doctorChecks: [] }
|
|
115
144
|
}
|
|
116
145
|
|
|
117
146
|
function assertNotEmpty(kind: string, value: string, pluginName: string): void {
|