typeclaw 0.9.1 → 0.10.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/package.json +2 -2
- package/scripts/require-parallel.ts +41 -15
- package/src/agent/index.ts +9 -7
- package/src/agent/live-subagents.ts +0 -1
- package/src/agent/session-origin.ts +10 -0
- package/src/agent/subagent-completion-reminder.ts +4 -1
- package/src/agent/system-prompt.ts +5 -5
- package/src/agent/tools/restart.ts +13 -2
- package/src/agent/tools/spawn-subagent.ts +0 -1
- package/src/agent/tools/subagent-output.ts +3 -51
- package/src/bundled-plugins/memory/dreaming-state.ts +51 -2
- package/src/bundled-plugins/memory/index.ts +55 -25
- package/src/bundled-plugins/memory/memory-retrieval.ts +1 -1
- package/src/bundled-plugins/memory/migration.ts +21 -17
- package/src/bundled-plugins/memory/stream-io.ts +71 -1
- package/src/bundled-plugins/security/index.ts +19 -17
- package/src/bundled-plugins/security/permissions.ts +9 -8
- package/src/bundled-plugins/security/policies/cron-promotion.ts +26 -9
- package/src/bundled-plugins/security/policies/git-exfil.ts +23 -15
- package/src/bundled-plugins/security/policies/role-promotion.ts +25 -18
- package/src/channels/manager.ts +7 -0
- package/src/channels/router.ts +267 -14
- package/src/channels/schema.ts +22 -1
- package/src/cli/compose.ts +23 -2
- package/src/cli/cron.ts +1 -1
- package/src/cli/inspect.ts +105 -12
- package/src/cli/logs.ts +17 -2
- package/src/cli/role.ts +2 -2
- package/src/compose/logs.ts +8 -4
- package/src/config/config.ts +8 -0
- package/src/config/providers.ts +18 -0
- package/src/container/index.ts +1 -1
- package/src/container/logs.ts +38 -11
- package/src/cron/bridge.ts +25 -4
- package/src/hostd/daemon.ts +44 -24
- package/src/hostd/portbroker-manager.ts +19 -3
- package/src/init/dockerfile.ts +199 -4
- package/src/init/gitignore.ts +8 -0
- package/src/inspect/index.ts +42 -5
- package/src/inspect/live.ts +32 -1
- package/src/inspect/loop.ts +20 -0
- package/src/inspect/render.ts +32 -0
- package/src/inspect/replay.ts +14 -0
- package/src/inspect/types.ts +26 -0
- package/src/permissions/builtins.ts +29 -21
- package/src/permissions/permissions.ts +32 -5
- package/src/role-claim/code.ts +9 -9
- package/src/role-claim/controller.ts +3 -2
- package/src/role-claim/match-rule.ts +14 -19
- package/src/role-claim/pending.ts +2 -2
- package/src/run/index.ts +1 -0
- package/src/server/index.ts +59 -19
- package/src/shared/protocol.ts +30 -0
- package/src/skills/typeclaw-codex-cli/SKILL.md +324 -0
- package/src/skills/typeclaw-codex-cli/references/auth-flow.md +144 -0
- package/src/skills/typeclaw-codex-cli/references/stop-hook.md +92 -0
- package/src/skills/typeclaw-codex-cli/references/tmux-driving.md +239 -0
- package/src/skills/typeclaw-config/SKILL.md +39 -32
- package/src/skills/typeclaw-config/references/recommended-mounts.md +233 -0
- package/src/skills/typeclaw-permissions/SKILL.md +24 -18
- package/src/test-helpers/wait-for.ts +15 -7
- package/typeclaw.schema.json +111 -10
package/src/init/dockerfile.ts
CHANGED
|
@@ -281,6 +281,56 @@ set -eu
|
|
|
281
281
|
# -nolisten tcp refuse TCP connections (Unix socket only).
|
|
282
282
|
# Defense-in-depth — we are in a netns with
|
|
283
283
|
# no inbound exposure anyway.
|
|
284
|
+
# link_persistent_home_files symlinks credential files that tools write
|
|
285
|
+
# to $HOME into a bind-mounted location so they survive container
|
|
286
|
+
# restarts. The canonical case is Codex CLI's ~/.codex/auth.json: codex
|
|
287
|
+
# rewrites the file in place to rotate OAuth tokens, and the official
|
|
288
|
+
# CI/CD guidance is to persist auth.json so refresh-token state
|
|
289
|
+
# compounds across runs. The container's $HOME (/root by default) lives
|
|
290
|
+
# on Docker's writable overlay and is wiped on every \`stop\`+\`start\`
|
|
291
|
+
# cycle, so without this symlink the operator would have to re-paste
|
|
292
|
+
# auth.json after every restart.
|
|
293
|
+
#
|
|
294
|
+
# The persist root lives under /agent/.typeclaw/home/ (bind-mounted
|
|
295
|
+
# from the agent folder via the -v <cwd>:/agent flag in start.ts).
|
|
296
|
+
# Namespacing under .typeclaw/ keeps the agent's top-level layout clean and reserves
|
|
297
|
+
# a system-owned subtree we can extend later (e.g. ~/.gemini/,
|
|
298
|
+
# ~/.config/<tool>/) without colliding with user files. The directory
|
|
299
|
+
# is gitignored by buildGitignore() so credentials never enter history.
|
|
300
|
+
#
|
|
301
|
+
# Three invariants this function enforces:
|
|
302
|
+
#
|
|
303
|
+
# 1. Symlink is unconditional and idempotent. We never check whether
|
|
304
|
+
# auth.json exists before linking — \`ln -sfn\` creates a dangling
|
|
305
|
+
# symlink on first boot, and the first \`codex login\` write goes
|
|
306
|
+
# through it to land at the persistent location. -f replaces an
|
|
307
|
+
# existing symlink; -n stops ln from dereferencing into a directory
|
|
308
|
+
# if a previous container life happened to write a real ~/.codex/
|
|
309
|
+
# dir before this code shipped.
|
|
310
|
+
#
|
|
311
|
+
# 2. We symlink the FILE, not the directory. Codex writes other state
|
|
312
|
+
# to ~/.codex/ over time (history.jsonl, log/, config.toml). Linking
|
|
313
|
+
# only auth.json keeps the persistence scope tight to credentials;
|
|
314
|
+
# history/logs stay ephemeral by design. Future credentials get
|
|
315
|
+
# added file-by-file here, not by widening to a directory link.
|
|
316
|
+
#
|
|
317
|
+
# 3. We mkdir -p the target's parent on every boot. /agent is bind-
|
|
318
|
+
# mounted, so the host-side path may exist or not depending on
|
|
319
|
+
# whether the operator ever started the container before this code
|
|
320
|
+
# shipped. mkdir -p is idempotent and cheap.
|
|
321
|
+
#
|
|
322
|
+
# 4. The root is overridable via TYPECLAW_PERSIST_HOME_ROOT, which only
|
|
323
|
+
# the shim's executable tests set. Production never sets it, so the
|
|
324
|
+
# in-container path is always /agent/.typeclaw/home/. The override
|
|
325
|
+
# lets the shim's behavioral tests verify symlink semantics against
|
|
326
|
+
# a real tmpdir on the host without touching /agent (which doesn't
|
|
327
|
+
# exist on developer machines and CI runners).
|
|
328
|
+
link_persistent_home_files() {
|
|
329
|
+
persist_root="\${TYPECLAW_PERSIST_HOME_ROOT:-/agent/.typeclaw/home}"
|
|
330
|
+
mkdir -p "$persist_root/.codex" "$HOME/.codex"
|
|
331
|
+
ln -sfn "$persist_root/.codex/auth.json" "$HOME/.codex/auth.json"
|
|
332
|
+
}
|
|
333
|
+
|
|
284
334
|
start_xvfb() {
|
|
285
335
|
if ! command -v Xvfb >/dev/null 2>&1; then
|
|
286
336
|
return 0
|
|
@@ -314,6 +364,7 @@ start_xvfb() {
|
|
|
314
364
|
}
|
|
315
365
|
|
|
316
366
|
if [ "\${TYPECLAW_NETWORK_BLOCK_INTERNAL:-0}" != "1" ]; then
|
|
367
|
+
link_persistent_home_files
|
|
317
368
|
start_xvfb
|
|
318
369
|
exec bun run typeclaw "$@"
|
|
319
370
|
fi
|
|
@@ -359,6 +410,7 @@ ip6tables -A OUTPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
|
|
|
359
410
|
ip6tables -A OUTPUT -o lo -j ACCEPT
|
|
360
411
|
${ipv6Rules.join('\n')}
|
|
361
412
|
|
|
413
|
+
link_persistent_home_files
|
|
362
414
|
start_xvfb
|
|
363
415
|
exec setpriv --bounding-set -net_admin --inh-caps -net_admin --ambient-caps -net_admin -- bun run typeclaw "$@"
|
|
364
416
|
`
|
|
@@ -755,6 +807,136 @@ RUN chmod +x ${TYPECLAW_CC_SESSION_START_HOOK_PATH} ${TYPECLAW_CC_STOP_HOOK_PATH
|
|
|
755
807
|
&& printf '%s\\n' '${CLAUDE_CODE_ONBOARDING_SEED}' > "$HOME/.claude.json"`
|
|
756
808
|
}
|
|
757
809
|
|
|
810
|
+
// Codex CLI's official distribution channel is the npm package
|
|
811
|
+
// \`@openai/codex\` (https://www.npmjs.com/package/@openai/codex). Unlike
|
|
812
|
+
// Claude Code's \`curl | bash\` install, Codex installs cleanly via the same
|
|
813
|
+
// bun-global path agent-browser uses, so we layer it under the existing
|
|
814
|
+
// \`bun install -g\` invariant: cache-mount keyed install, no \$HOME/.local/bin
|
|
815
|
+
// symlink dance, no installer scratch.
|
|
816
|
+
//
|
|
817
|
+
// Hook architecture: Codex CLI ships a hook system with the SAME event names
|
|
818
|
+
// as Claude Code (SessionStart, Stop, PreToolUse, PostToolUse, etc.) and the
|
|
819
|
+
// SAME JSON shape (\`{ session_id, transcript_path, cwd, hook_event_name }\` on
|
|
820
|
+
// stdin). The two CLIs are NOT interoperable — Codex's config file is
|
|
821
|
+
// \`~/.codex/hooks.json\` (or inline \`[hooks]\` in \`config.toml\`), and the trust
|
|
822
|
+
// model differs (Codex prompts for hook trust on first run, gated by
|
|
823
|
+
// \`--dangerously-bypass-hook-trust\`). But the hook script body, the
|
|
824
|
+
// per-session filenames (\`.done-<sid>\`, \`sentinel-<sid>.json\`), the
|
|
825
|
+
// \`.session-id\` fast path, and the \`malformed\` fallback are byte-for-byte
|
|
826
|
+
// reusable. We deliberately use distinct paths (\`typeclaw-cx-*\` instead of
|
|
827
|
+
// \`typeclaw-cc-*\`) and a distinct settings file (\`~/.codex/hooks.json\` vs
|
|
828
|
+
// \`~/.claude/settings.json\`) so an agent with BOTH toggles on doesn't have
|
|
829
|
+
// the two CLIs racing on the same sentinel files. The skill side
|
|
830
|
+
// (\`typeclaw-codex-cli\`) documents which UUID-bearing artifacts belong to
|
|
831
|
+
// which CLI.
|
|
832
|
+
//
|
|
833
|
+
// Onboarding: Codex has NO theme picker, NO telemetry consent dialog, NO
|
|
834
|
+
// terms-of-service prompt — verified against
|
|
835
|
+
// codex-rs/tui/src/onboarding/onboarding_screen.rs in the upstream repo.
|
|
836
|
+
// The TUI's onboarding state machine has exactly three steps: Welcome,
|
|
837
|
+
// Auth, TrustDirectory. We CAN'T pre-seed past Welcome (it's an animation,
|
|
838
|
+
// not a prompt), and we DELIBERATELY don't pre-seed past Auth (the user
|
|
839
|
+
// must paste their OPENAI_API_KEY or run \`codex login\` themselves) or past
|
|
840
|
+
// TrustDirectory (trusting an arbitrary worktree silently widens the trust
|
|
841
|
+
// surface in ways the operator hasn't consented to — exact same reasoning
|
|
842
|
+
// as Claude Code's "don't preseed hasTrustDialogAccepted"). The
|
|
843
|
+
// \`typeclaw-codex-cli\` skill handles all three at runtime via dialog
|
|
844
|
+
// polling, the same shape Claude Code's skill uses for its post-seed
|
|
845
|
+
// modals.
|
|
846
|
+
//
|
|
847
|
+
// Smoke test: \`codex --version\` is supported per codex-rs/cli/src/main.rs
|
|
848
|
+
// (clap \`version\` attribute), so we use it as the build-time install check.
|
|
849
|
+
const TYPECLAW_CX_STOP_HOOK_PATH = '/usr/local/bin/typeclaw-cx-stop-hook'
|
|
850
|
+
const TYPECLAW_CX_SESSION_START_HOOK_PATH = '/usr/local/bin/typeclaw-cx-session-start-hook'
|
|
851
|
+
|
|
852
|
+
const TYPECLAW_CX_SESSION_START_HOOK_SCRIPT = `#!/bin/sh
|
|
853
|
+
# typeclaw SessionStart-hook for Codex CLI. Stdin carries the SessionStart
|
|
854
|
+
# event JSON. Writes \$PWD/.session-id with the session UUID as a fast-path
|
|
855
|
+
# optimization (the operator falls back to discovering session_id from the
|
|
856
|
+
# first Stop sentinel if .session-id never appears). Rationale lives in
|
|
857
|
+
# src/init/dockerfile.ts.
|
|
858
|
+
set -eu
|
|
859
|
+
tmp_out="\${PWD}/.session-id.\$\$.tmp"
|
|
860
|
+
trap 'rm -f "\$tmp_out"' EXIT
|
|
861
|
+
sid=\$(bun -e 'try { const j = await new Response(Bun.stdin.stream()).json(); process.stdout.write(String(j.session_id ?? "")) } catch { process.stdout.write("") }')
|
|
862
|
+
case "\$sid" in
|
|
863
|
+
[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]-[0-9a-f][0-9a-f][0-9a-f][0-9a-f]-[0-9a-f][0-9a-f][0-9a-f][0-9a-f]-[0-9a-f][0-9a-f][0-9a-f][0-9a-f]-[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]) ;;
|
|
864
|
+
*) sid=malformed ;;
|
|
865
|
+
esac
|
|
866
|
+
printf '%s\\n' "\$sid" > "\$tmp_out"
|
|
867
|
+
mv "\$tmp_out" "\${PWD}/.session-id"
|
|
868
|
+
trap - EXIT
|
|
869
|
+
`
|
|
870
|
+
|
|
871
|
+
const TYPECLAW_CX_STOP_HOOK_SCRIPT = `#!/bin/sh
|
|
872
|
+
# typeclaw Stop-hook for Codex CLI. Stdin carries the Stop event JSON.
|
|
873
|
+
# Writes per-session sentinel/.done files into \$PWD. Rationale (the
|
|
874
|
+
# security model, \$PWD semantics, and why bun-not-sed for JSON
|
|
875
|
+
# extraction) lives in src/init/dockerfile.ts.
|
|
876
|
+
set -eu
|
|
877
|
+
tmp_in="\${PWD}/.cx-stop-hook-in.\$\$"
|
|
878
|
+
trap 'rm -f "\$tmp_in"' EXIT
|
|
879
|
+
cat > "\$tmp_in"
|
|
880
|
+
sid=\$(bun -e 'try { const j = await Bun.file(process.argv[1]).json(); process.stdout.write(String(j.session_id ?? "")) } catch { process.stdout.write("") }' "\$tmp_in")
|
|
881
|
+
case "\$sid" in
|
|
882
|
+
[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]-[0-9a-f][0-9a-f][0-9a-f][0-9a-f]-[0-9a-f][0-9a-f][0-9a-f][0-9a-f]-[0-9a-f][0-9a-f][0-9a-f][0-9a-f]-[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]) ;;
|
|
883
|
+
*) sid=malformed ;;
|
|
884
|
+
esac
|
|
885
|
+
mv "\$tmp_in" "\${PWD}/sentinel-\${sid}.json"
|
|
886
|
+
trap - EXIT
|
|
887
|
+
touch "\${PWD}/.done-\${sid}"
|
|
888
|
+
`
|
|
889
|
+
|
|
890
|
+
// Codex CLI's hook config file lives at ~/.codex/hooks.json. The JSON shape
|
|
891
|
+
// mirrors Claude Code's settings.json hooks block exactly — same nesting,
|
|
892
|
+
// same matcher syntax, same exec-form via \`args: []\`. Built via
|
|
893
|
+
// JSON.stringify so a shape edit fails the JSON.parse regression test, not
|
|
894
|
+
// the docker build (or the first failed delegation).
|
|
895
|
+
//
|
|
896
|
+
// SessionStart matcher \`startup|resume\`: Codex documents \`startup\` and
|
|
897
|
+
// \`resume\` as the two SessionStart sources (vs Claude Code's
|
|
898
|
+
// \`startup|resume|clear|compact\` — Codex has no \`/clear\` command and no
|
|
899
|
+
// auto-compaction in the same shape). Claude's broader matcher would not
|
|
900
|
+
// be wrong but would match against events Codex never fires.
|
|
901
|
+
const TYPECLAW_CX_GLOBAL_HOOKS = JSON.stringify({
|
|
902
|
+
hooks: {
|
|
903
|
+
SessionStart: [
|
|
904
|
+
{
|
|
905
|
+
matcher: 'startup|resume',
|
|
906
|
+
hooks: [{ type: 'command', command: TYPECLAW_CX_SESSION_START_HOOK_PATH, args: [] }],
|
|
907
|
+
},
|
|
908
|
+
],
|
|
909
|
+
Stop: [
|
|
910
|
+
{
|
|
911
|
+
matcher: '*',
|
|
912
|
+
hooks: [{ type: 'command', command: TYPECLAW_CX_STOP_HOOK_PATH, args: [] }],
|
|
913
|
+
},
|
|
914
|
+
],
|
|
915
|
+
},
|
|
916
|
+
})
|
|
917
|
+
|
|
918
|
+
function renderCodexCliInstallLayer(enabled: boolean): string {
|
|
919
|
+
if (!enabled) return ''
|
|
920
|
+
return `# Layer 5.7 (toggle): install OpenAI's Codex CLI. Opt-in via
|
|
921
|
+
# typeclaw.json#docker.file.codexCli. The skill \`typeclaw-codex-cli\`
|
|
922
|
+
# documents the auth + usage flow. Codex ships as the npm package
|
|
923
|
+
# \`@openai/codex\`, so we install via \`bun install -g\` reusing the same
|
|
924
|
+
# cache mount agent-browser uses. Hook scripts and ~/.codex/hooks.json
|
|
925
|
+
# are pre-written at build time so the operator subagent never has to
|
|
926
|
+
# construct that JSON itself — same load-bearing reason as the Claude
|
|
927
|
+
# Code layer above.
|
|
928
|
+
RUN --mount=type=cache,target=/root/.bun/install/cache,sharing=locked \\
|
|
929
|
+
bun install -g @openai/codex \\
|
|
930
|
+
&& codex --version > /dev/null \\
|
|
931
|
+
&& cat > ${TYPECLAW_CX_SESSION_START_HOOK_PATH} <<'TYPECLAW_CX_SESSION_START_HOOK_EOF'
|
|
932
|
+
${TYPECLAW_CX_SESSION_START_HOOK_SCRIPT}TYPECLAW_CX_SESSION_START_HOOK_EOF
|
|
933
|
+
RUN cat > ${TYPECLAW_CX_STOP_HOOK_PATH} <<'TYPECLAW_CX_STOP_HOOK_EOF'
|
|
934
|
+
${TYPECLAW_CX_STOP_HOOK_SCRIPT}TYPECLAW_CX_STOP_HOOK_EOF
|
|
935
|
+
RUN chmod +x ${TYPECLAW_CX_SESSION_START_HOOK_PATH} ${TYPECLAW_CX_STOP_HOOK_PATH} \\
|
|
936
|
+
&& mkdir -p "$HOME/.codex" \\
|
|
937
|
+
&& printf '%s\\n' '${TYPECLAW_CX_GLOBAL_HOOKS}' > "$HOME/.codex/hooks.json"`
|
|
938
|
+
}
|
|
939
|
+
|
|
758
940
|
// Shared-library runtime deps Chrome for Testing needs to launch on amd64
|
|
759
941
|
// Debian trixie (base of `oven/bun:1-slim`). `agent-browser install
|
|
760
942
|
// --with-deps` (v0.27.0) is supposed to install these but silently no-ops:
|
|
@@ -833,10 +1015,18 @@ export function buildDockerfile(
|
|
|
833
1015
|
const baseImageVersion = options.baseImageVersion ?? null
|
|
834
1016
|
|
|
835
1017
|
const claudeCodeLayer = renderClaudeCodeInstallLayer(config.claudeCode)
|
|
1018
|
+
const codexCliLayer = renderCodexCliInstallLayer(config.codexCli)
|
|
836
1019
|
const fromAndHeavyLayers =
|
|
837
1020
|
baseImageVersion !== null
|
|
838
|
-
? renderVersionedHead(
|
|
839
|
-
|
|
1021
|
+
? renderVersionedHead(
|
|
1022
|
+
baseImageVersion,
|
|
1023
|
+
ghKeyringLayer,
|
|
1024
|
+
toggleAptArgs,
|
|
1025
|
+
cloudflaredLayer,
|
|
1026
|
+
claudeCodeLayer,
|
|
1027
|
+
codexCliLayer,
|
|
1028
|
+
)
|
|
1029
|
+
: renderInlineHead(ghKeyringLayer, toggleAptArgs, cloudflaredLayer, claudeCodeLayer, codexCliLayer)
|
|
840
1030
|
|
|
841
1031
|
return `${BUILDKIT_HEADER}
|
|
842
1032
|
# AUTOGENERATED by typeclaw — do not edit.
|
|
@@ -884,17 +1074,19 @@ function renderVersionedHead(
|
|
|
884
1074
|
toggleAptArgs: string[],
|
|
885
1075
|
cloudflaredLayer: string,
|
|
886
1076
|
claudeCodeLayer: string,
|
|
1077
|
+
codexCliLayer: string,
|
|
887
1078
|
): string {
|
|
888
1079
|
const toggleAptLayer = toggleAptArgs.length === 0 ? '' : `${renderToggleAptInstallLayer(toggleAptArgs)}\n\n`
|
|
889
1080
|
const cloudflaredBlock = cloudflaredLayer === '' ? '' : `${cloudflaredLayer}\n\n`
|
|
890
1081
|
const claudeCodeBlock = claudeCodeLayer === '' ? '' : `${claudeCodeLayer}\n\n`
|
|
1082
|
+
const codexCliBlock = codexCliLayer === '' ? '' : `${codexCliLayer}\n\n`
|
|
891
1083
|
return `FROM ${GHCR_BASE_IMAGE_REPO}:${baseImageVersion}
|
|
892
1084
|
|
|
893
1085
|
WORKDIR /agent
|
|
894
1086
|
|
|
895
1087
|
ARG TARGETARCH
|
|
896
1088
|
|
|
897
|
-
${ghKeyringLayer}${toggleAptLayer}${cloudflaredBlock}${claudeCodeBlock}${renderEntrypointShimLayer()}
|
|
1089
|
+
${ghKeyringLayer}${toggleAptLayer}${cloudflaredBlock}${claudeCodeBlock}${codexCliBlock}${renderEntrypointShimLayer()}
|
|
898
1090
|
|
|
899
1091
|
`
|
|
900
1092
|
}
|
|
@@ -908,10 +1100,12 @@ function renderInlineHead(
|
|
|
908
1100
|
toggleAptArgs: string[],
|
|
909
1101
|
cloudflaredLayer: string,
|
|
910
1102
|
claudeCodeLayer: string,
|
|
1103
|
+
codexCliLayer: string,
|
|
911
1104
|
): string {
|
|
912
1105
|
const baselineAndToggleArgs = [...BASELINE_APT_PACKAGES, ...toggleAptArgs]
|
|
913
1106
|
const cloudflaredBlock = cloudflaredLayer === '' ? '' : `${cloudflaredLayer}\n\n`
|
|
914
1107
|
const claudeCodeBlock = claudeCodeLayer === '' ? '' : `${claudeCodeLayer}\n\n`
|
|
1108
|
+
const codexCliBlock = codexCliLayer === '' ? '' : `${codexCliLayer}\n\n`
|
|
915
1109
|
return `${FROM_AND_WORKDIR}
|
|
916
1110
|
|
|
917
1111
|
# Layers are ordered most-stable first to maximize Docker layer cache hits on
|
|
@@ -954,7 +1148,7 @@ ${LAYER_4_5_AGENT_BROWSER_HEADED_WRAPPER}
|
|
|
954
1148
|
|
|
955
1149
|
${LAYER_5_CHROME_FOR_TESTING}
|
|
956
1150
|
|
|
957
|
-
${cloudflaredBlock}${claudeCodeBlock}${renderEntrypointShimLayer()}
|
|
1151
|
+
${cloudflaredBlock}${claudeCodeBlock}${codexCliBlock}${renderEntrypointShimLayer()}
|
|
958
1152
|
|
|
959
1153
|
`
|
|
960
1154
|
}
|
|
@@ -1223,6 +1417,7 @@ function defaultConfig(): DockerfileConfig {
|
|
|
1223
1417
|
cloudflared: true,
|
|
1224
1418
|
xvfb: true,
|
|
1225
1419
|
claudeCode: false,
|
|
1420
|
+
codexCli: false,
|
|
1226
1421
|
append: [],
|
|
1227
1422
|
}
|
|
1228
1423
|
}
|
package/src/init/gitignore.ts
CHANGED
|
@@ -17,10 +17,18 @@ export function buildGitignore(config: GitignoreConfig = { append: [] }): string
|
|
|
17
17
|
# as a safety net so an agent folder cloned from a pre-rename machine never
|
|
18
18
|
# stages credentials by accident, even if its agent boot hasn't yet run the
|
|
19
19
|
# auth.json -> secrets.json migration.
|
|
20
|
+
#
|
|
21
|
+
# .typeclaw/home/ is the persistent-$HOME overlay populated by the
|
|
22
|
+
# entrypoint shim's \`link_persistent_home_files\` (see
|
|
23
|
+
# src/init/dockerfile.ts). It mirrors selected files from the container's
|
|
24
|
+
# $HOME (e.g. ~/.codex/auth.json) into the bind-mounted agent folder so
|
|
25
|
+
# tool credentials survive container restarts. Always credentials; never
|
|
26
|
+
# commit.
|
|
20
27
|
.env
|
|
21
28
|
.env.local
|
|
22
29
|
secrets.json
|
|
23
30
|
auth.json
|
|
31
|
+
.typeclaw/home/
|
|
24
32
|
node_modules/
|
|
25
33
|
packages/*/node_modules/
|
|
26
34
|
workspace/
|
package/src/inspect/index.ts
CHANGED
|
@@ -16,6 +16,8 @@ export { replayJsonl } from './replay'
|
|
|
16
16
|
export { streamLive } from './live'
|
|
17
17
|
export { parseDuration, parseFilter } from './types'
|
|
18
18
|
export type { InspectCategory, InspectEvent, InspectFilter } from './types'
|
|
19
|
+
export { runInspectLoop } from './loop'
|
|
20
|
+
export type { RunInspectLoopOptions } from './loop'
|
|
19
21
|
|
|
20
22
|
export type RunInspectOptions = {
|
|
21
23
|
agentDir: string
|
|
@@ -29,6 +31,10 @@ export type RunInspectOptions = {
|
|
|
29
31
|
stderr: (line: string) => void
|
|
30
32
|
liveSource?: LiveSourceFactory
|
|
31
33
|
signal?: AbortSignal
|
|
34
|
+
// Aborting escSignal (and only escSignal) returns escToPicker=true so a
|
|
35
|
+
// caller-side loop can re-open the picker; signal still means process exit.
|
|
36
|
+
escSignal?: AbortSignal
|
|
37
|
+
liveHint?: string
|
|
32
38
|
}
|
|
33
39
|
|
|
34
40
|
export type SelectSession = (sessions: SessionSummary[]) => Promise<SessionSummary | null>
|
|
@@ -40,7 +46,9 @@ export type LiveSourceFactory = (opts: {
|
|
|
40
46
|
onSubscribed?: (sessionLive: boolean) => void
|
|
41
47
|
}) => AsyncIterable<InspectEvent>
|
|
42
48
|
|
|
43
|
-
export type RunInspectResult =
|
|
49
|
+
export type RunInspectResult =
|
|
50
|
+
| { ok: true; exitCode: 0; escToPicker?: boolean }
|
|
51
|
+
| { ok: false; exitCode: number; reason: string }
|
|
44
52
|
|
|
45
53
|
export async function runInspect(opts: RunInspectOptions): Promise<RunInspectResult> {
|
|
46
54
|
const filterResult = parseFilter(opts.filter)
|
|
@@ -59,7 +67,7 @@ export async function runInspect(opts: RunInspectOptions): Promise<RunInspectRes
|
|
|
59
67
|
const summary = await chooseSession(opts, sessionsDir, sinceMs)
|
|
60
68
|
if (!summary.ok) return summary
|
|
61
69
|
|
|
62
|
-
await streamSession({
|
|
70
|
+
const streamResult = await streamSession({
|
|
63
71
|
summary: summary.summary,
|
|
64
72
|
filter,
|
|
65
73
|
sinceMs,
|
|
@@ -69,7 +77,10 @@ export async function runInspect(opts: RunInspectOptions): Promise<RunInspectRes
|
|
|
69
77
|
stderr: opts.stderr,
|
|
70
78
|
...(opts.liveSource !== undefined ? { liveSource: opts.liveSource } : {}),
|
|
71
79
|
...(opts.signal !== undefined ? { signal: opts.signal } : {}),
|
|
80
|
+
...(opts.escSignal !== undefined ? { escSignal: opts.escSignal } : {}),
|
|
81
|
+
...(opts.liveHint !== undefined ? { liveHint: opts.liveHint } : {}),
|
|
72
82
|
})
|
|
83
|
+
if (streamResult.escToPicker) return { ok: true, exitCode: 0, escToPicker: true }
|
|
73
84
|
return { ok: true, exitCode: 0 }
|
|
74
85
|
}
|
|
75
86
|
|
|
@@ -132,7 +143,9 @@ async function streamSession(opts: {
|
|
|
132
143
|
stderr: (line: string) => void
|
|
133
144
|
liveSource?: LiveSourceFactory
|
|
134
145
|
signal?: AbortSignal
|
|
135
|
-
|
|
146
|
+
escSignal?: AbortSignal
|
|
147
|
+
liveHint?: string
|
|
148
|
+
}): Promise<{ escToPicker: boolean }> {
|
|
136
149
|
if (!opts.json) writeHeader(opts.summary, opts.color, opts.stdout)
|
|
137
150
|
const emit = (event: InspectEvent): void => {
|
|
138
151
|
if (opts.sinceMs !== undefined && event.ts > 0 && event.ts < opts.sinceMs) return
|
|
@@ -144,20 +157,26 @@ async function streamSession(opts: {
|
|
|
144
157
|
}
|
|
145
158
|
}
|
|
146
159
|
|
|
160
|
+
const escAborted = (): boolean => opts.escSignal?.aborted === true
|
|
161
|
+
|
|
147
162
|
for await (const event of replayJsonl(opts.summary.sessionFile, { onWarn: opts.stderr })) {
|
|
163
|
+
if (escAborted()) return { escToPicker: true }
|
|
148
164
|
emit(event)
|
|
149
165
|
}
|
|
150
166
|
|
|
151
167
|
if (opts.liveSource === undefined) {
|
|
152
168
|
if (!opts.json) opts.stdout('─── end of transcript ───')
|
|
153
|
-
return
|
|
169
|
+
return { escToPicker: escAborted() }
|
|
154
170
|
}
|
|
155
171
|
|
|
172
|
+
if (escAborted()) return { escToPicker: true }
|
|
173
|
+
|
|
174
|
+
const combinedSignal = combineSignals(opts.signal, opts.escSignal)
|
|
156
175
|
let sessionLive = false
|
|
157
176
|
const liveIter = opts.liveSource({
|
|
158
177
|
sessionId: opts.summary.sessionId,
|
|
159
178
|
...(opts.sinceMs !== undefined ? { sinceMs: opts.sinceMs } : {}),
|
|
160
|
-
...(
|
|
179
|
+
...(combinedSignal !== undefined ? { signal: combinedSignal } : {}),
|
|
161
180
|
onSubscribed: (live) => {
|
|
162
181
|
sessionLive = live
|
|
163
182
|
},
|
|
@@ -170,6 +189,9 @@ async function streamSession(opts: {
|
|
|
170
189
|
opts.stdout(
|
|
171
190
|
divider(opts.color, sessionLive ? '─── live ───' : '─── live (session not in registry; broadcasts only) ───'),
|
|
172
191
|
)
|
|
192
|
+
if (opts.liveHint !== undefined && opts.liveHint !== '') {
|
|
193
|
+
opts.stdout(divider(opts.color, opts.liveHint))
|
|
194
|
+
}
|
|
173
195
|
liveAnnounced = true
|
|
174
196
|
}
|
|
175
197
|
emit(event)
|
|
@@ -178,6 +200,21 @@ async function streamSession(opts: {
|
|
|
178
200
|
opts.stderr(`live tail ended: ${err instanceof Error ? err.message : String(err)}`)
|
|
179
201
|
}
|
|
180
202
|
if (!opts.json) opts.stdout('─── end of transcript ───')
|
|
203
|
+
return { escToPicker: escAborted() && opts.signal?.aborted !== true }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function combineSignals(a: AbortSignal | undefined, b: AbortSignal | undefined): AbortSignal | undefined {
|
|
207
|
+
if (a === undefined) return b
|
|
208
|
+
if (b === undefined) return a
|
|
209
|
+
if (a.aborted) return a
|
|
210
|
+
if (b.aborted) return b
|
|
211
|
+
const ctrl = new AbortController()
|
|
212
|
+
const onAbort = (): void => {
|
|
213
|
+
ctrl.abort()
|
|
214
|
+
}
|
|
215
|
+
a.addEventListener('abort', onAbort, { once: true })
|
|
216
|
+
b.addEventListener('abort', onAbort, { once: true })
|
|
217
|
+
return ctrl.signal
|
|
181
218
|
}
|
|
182
219
|
|
|
183
220
|
function divider(color: boolean, text: string): string {
|
package/src/inspect/live.ts
CHANGED
|
@@ -21,6 +21,7 @@ export async function* streamLive(opts: StreamLiveOptions): AsyncGenerator<Inspe
|
|
|
21
21
|
let pendingError: string | null = null
|
|
22
22
|
|
|
23
23
|
const accumulators = new Map<string, string>()
|
|
24
|
+
const thinkingAccumulators = new Map<string, string>()
|
|
24
25
|
|
|
25
26
|
const wake = (): void => {
|
|
26
27
|
if (resolveNext !== null) {
|
|
@@ -55,7 +56,7 @@ export async function* streamLive(opts: StreamLiveOptions): AsyncGenerator<Inspe
|
|
|
55
56
|
return
|
|
56
57
|
}
|
|
57
58
|
if (msg.type !== 'frame') return
|
|
58
|
-
const event = frameToEvent(msg.payload, msg.ts, accumulators)
|
|
59
|
+
const event = frameToEvent(msg.payload, msg.ts, accumulators, thinkingAccumulators)
|
|
59
60
|
if (event !== null) {
|
|
60
61
|
buffer.push(event)
|
|
61
62
|
wake()
|
|
@@ -134,6 +135,7 @@ function frameToEvent(
|
|
|
134
135
|
payload: InspectFramePayload,
|
|
135
136
|
ts: number,
|
|
136
137
|
accumulators: Map<string, string>,
|
|
138
|
+
thinkingAccumulators: Map<string, string>,
|
|
137
139
|
): InspectEvent | null {
|
|
138
140
|
switch (payload.kind) {
|
|
139
141
|
case 'text_delta': {
|
|
@@ -141,6 +143,18 @@ function frameToEvent(
|
|
|
141
143
|
accumulators.set(payload.sessionId, existing + payload.delta)
|
|
142
144
|
return null
|
|
143
145
|
}
|
|
146
|
+
case 'thinking_delta': {
|
|
147
|
+
const existing = thinkingAccumulators.get(payload.sessionId) ?? ''
|
|
148
|
+
thinkingAccumulators.set(payload.sessionId, existing + payload.delta)
|
|
149
|
+
return null
|
|
150
|
+
}
|
|
151
|
+
case 'thinking_end': {
|
|
152
|
+
const accumulated = thinkingAccumulators.get(payload.sessionId) ?? ''
|
|
153
|
+
thinkingAccumulators.delete(payload.sessionId)
|
|
154
|
+
const text = accumulated !== '' ? accumulated : payload.text
|
|
155
|
+
if (text === '' && payload.redacted !== true) return null
|
|
156
|
+
return { cat: 'thinking', ts, text, ...(payload.redacted === true ? { redacted: true } : {}) }
|
|
157
|
+
}
|
|
144
158
|
case 'tool_start':
|
|
145
159
|
return {
|
|
146
160
|
cat: 'tool',
|
|
@@ -172,6 +186,23 @@ function frameToEvent(
|
|
|
172
186
|
}
|
|
173
187
|
case 'cron-fire':
|
|
174
188
|
return { cat: 'cron-fire', ts, jobId: payload.jobId, payload: payload.payload }
|
|
189
|
+
case 'channel_inbound':
|
|
190
|
+
return {
|
|
191
|
+
cat: 'inbound',
|
|
192
|
+
ts: payload.ts > 0 ? payload.ts : ts,
|
|
193
|
+
adapter: payload.adapter,
|
|
194
|
+
workspace: payload.workspace,
|
|
195
|
+
chat: payload.chat,
|
|
196
|
+
thread: payload.thread,
|
|
197
|
+
authorId: payload.authorId,
|
|
198
|
+
authorName: payload.authorName,
|
|
199
|
+
authorIsBot: payload.authorIsBot,
|
|
200
|
+
isDm: payload.isDm,
|
|
201
|
+
isBotMention: payload.isBotMention,
|
|
202
|
+
text: payload.text,
|
|
203
|
+
externalMessageId: payload.externalMessageId,
|
|
204
|
+
decision: payload.decision,
|
|
205
|
+
}
|
|
175
206
|
default:
|
|
176
207
|
return null
|
|
177
208
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { runInspect, type RunInspectOptions, type RunInspectResult } from './index'
|
|
2
|
+
|
|
3
|
+
export type RunInspectLoopOptions = Omit<RunInspectOptions, 'escSignal'> & {
|
|
4
|
+
newEscSignal: () => AbortSignal
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export async function runInspectLoop(opts: RunInspectLoopOptions): Promise<RunInspectResult> {
|
|
8
|
+
let sessionArg = opts.sessionIdOrPrefix
|
|
9
|
+
while (true) {
|
|
10
|
+
const escSignal = opts.newEscSignal()
|
|
11
|
+
const callOpts: RunInspectOptions = { ...opts, escSignal }
|
|
12
|
+
if (sessionArg !== undefined) callOpts.sessionIdOrPrefix = sessionArg
|
|
13
|
+
else delete (callOpts as { sessionIdOrPrefix?: string }).sessionIdOrPrefix
|
|
14
|
+
|
|
15
|
+
const result = await runInspect(callOpts)
|
|
16
|
+
if (!result.ok) return result
|
|
17
|
+
if (result.escToPicker !== true) return result
|
|
18
|
+
sessionArg = undefined
|
|
19
|
+
}
|
|
20
|
+
}
|
package/src/inspect/render.ts
CHANGED
|
@@ -34,6 +34,8 @@ function renderTag(event: InspectEvent, opts: RenderOptions): string {
|
|
|
34
34
|
return tint(opts, 'cyan', padEnd('user', 9))
|
|
35
35
|
case 'assistant':
|
|
36
36
|
return tint(opts, 'green', padEnd('assist', 9))
|
|
37
|
+
case 'thinking':
|
|
38
|
+
return tint(opts, 'gray', padEnd('think', 9))
|
|
37
39
|
case 'tool':
|
|
38
40
|
return tint(opts, 'yellow', padEnd(event.phase === 'start' ? 'tool ▸' : 'tool ◂', 9))
|
|
39
41
|
case 'error':
|
|
@@ -44,6 +46,8 @@ function renderTag(event: InspectEvent, opts: RenderOptions): string {
|
|
|
44
46
|
return tint(opts, 'magenta', padEnd('bcast', 9))
|
|
45
47
|
case 'cron-fire':
|
|
46
48
|
return tint(opts, 'magenta', padEnd('cron', 9))
|
|
49
|
+
case 'inbound':
|
|
50
|
+
return tint(opts, 'cyan', padEnd('inbound', 9))
|
|
47
51
|
}
|
|
48
52
|
}
|
|
49
53
|
|
|
@@ -55,6 +59,11 @@ function renderBody(event: InspectEvent, opts: RenderOptions): string {
|
|
|
55
59
|
return truncate(singleLine(event.text), opts.maxTextLength ?? DEFAULT_MAX_TEXT)
|
|
56
60
|
case 'assistant':
|
|
57
61
|
return truncate(singleLine(event.text), opts.maxTextLength ?? DEFAULT_MAX_TEXT)
|
|
62
|
+
case 'thinking': {
|
|
63
|
+
const prefix = event.redacted === true ? `${tint(opts, 'dim', '[redacted]')} ` : ''
|
|
64
|
+
const body = event.text === '' ? '' : truncate(singleLine(event.text), opts.maxTextLength ?? DEFAULT_MAX_TEXT)
|
|
65
|
+
return `${prefix}${tint(opts, 'dim', body)}`
|
|
66
|
+
}
|
|
58
67
|
case 'tool': {
|
|
59
68
|
if (event.phase === 'start') {
|
|
60
69
|
return `${event.name}(${renderArgs(event.args)})`
|
|
@@ -72,6 +81,29 @@ function renderBody(event: InspectEvent, opts: RenderOptions): string {
|
|
|
72
81
|
return renderBroadcastBody(event.payload, opts.maxTextLength ?? DEFAULT_MAX_TEXT)
|
|
73
82
|
case 'cron-fire':
|
|
74
83
|
return `${event.jobId} fired`
|
|
84
|
+
case 'inbound':
|
|
85
|
+
return renderInboundBody(event, opts)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function renderInboundBody(event: Extract<InspectEvent, { cat: 'inbound' }>, opts: RenderOptions): string {
|
|
90
|
+
const coord = `${event.adapter}:${event.workspace}/${event.chat}${event.thread === null ? '' : `#${event.thread}`}`
|
|
91
|
+
const who = event.authorName !== '' ? event.authorName : event.authorId
|
|
92
|
+
const decisionTag = tint(opts, decisionColor(event.decision), `[${event.decision}]`)
|
|
93
|
+
const text = truncate(singleLine(event.text), opts.maxTextLength ?? DEFAULT_MAX_TEXT)
|
|
94
|
+
return `${decisionTag} ${tint(opts, 'dim', coord)} ${who}: ${text}`
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function decisionColor(decision: Extract<InspectEvent, { cat: 'inbound' }>['decision']): ColorName {
|
|
98
|
+
switch (decision) {
|
|
99
|
+
case 'engage':
|
|
100
|
+
return 'green'
|
|
101
|
+
case 'observe':
|
|
102
|
+
return 'dim'
|
|
103
|
+
case 'denied':
|
|
104
|
+
return 'red'
|
|
105
|
+
case 'claim':
|
|
106
|
+
return 'magenta'
|
|
75
107
|
}
|
|
76
108
|
}
|
|
77
109
|
|
package/src/inspect/replay.ts
CHANGED
|
@@ -113,6 +113,7 @@ function* assistantEvents(
|
|
|
113
113
|
ts: number,
|
|
114
114
|
pending: Map<string, { name: string; startTs: number }>,
|
|
115
115
|
): Iterable<InspectEvent> {
|
|
116
|
+
yield* readThinkingEvents(message.content, ts)
|
|
116
117
|
const text = readTextContent(message.content)
|
|
117
118
|
if (text !== null && text !== '') {
|
|
118
119
|
const ev: InspectEvent = {
|
|
@@ -218,6 +219,19 @@ function readUsage(value: unknown): {
|
|
|
218
219
|
}
|
|
219
220
|
}
|
|
220
221
|
|
|
222
|
+
function* readThinkingEvents(content: unknown, ts: number): Iterable<InspectEvent> {
|
|
223
|
+
if (!Array.isArray(content)) return
|
|
224
|
+
for (const block of content) {
|
|
225
|
+
if (typeof block !== 'object' || block === null) continue
|
|
226
|
+
const b = block as Record<string, unknown>
|
|
227
|
+
if (b.type !== 'thinking') continue
|
|
228
|
+
const text = typeof b.thinking === 'string' ? b.thinking : ''
|
|
229
|
+
const redacted = b.redacted === true
|
|
230
|
+
if (text === '' && !redacted) continue
|
|
231
|
+
yield { cat: 'thinking', ts, text, ...(redacted ? { redacted: true } : {}) }
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
221
235
|
function readTextContent(content: unknown): string | null {
|
|
222
236
|
if (typeof content === 'string') return content
|
|
223
237
|
if (!Array.isArray(content)) return null
|
package/src/inspect/types.ts
CHANGED
|
@@ -4,18 +4,28 @@ export const INSPECT_CATEGORIES = [
|
|
|
4
4
|
'meta',
|
|
5
5
|
'user',
|
|
6
6
|
'assistant',
|
|
7
|
+
'thinking',
|
|
7
8
|
'tool',
|
|
8
9
|
'error',
|
|
9
10
|
'done',
|
|
10
11
|
'broadcast',
|
|
11
12
|
'cron-fire',
|
|
13
|
+
'inbound',
|
|
12
14
|
] as const
|
|
13
15
|
export type InspectCategory = (typeof INSPECT_CATEGORIES)[number]
|
|
14
16
|
|
|
17
|
+
export type InboundDecision = 'engage' | 'observe' | 'denied' | 'claim'
|
|
18
|
+
|
|
15
19
|
export type InspectEvent =
|
|
16
20
|
| { cat: 'meta'; ts: number; origin: MinimalSessionOrigin }
|
|
17
21
|
| { cat: 'user'; ts: number; text: string }
|
|
18
22
|
| { cat: 'assistant'; ts: number; text: string; provider?: string; model?: string }
|
|
23
|
+
// Reasoning trace from the model (Claude extended thinking, OpenAI reasoning
|
|
24
|
+
// summary, Gemini thoughts, etc.). Surfaced for debugging — why the model
|
|
25
|
+
// picked the next tool / wrote the next thing. `redacted` is true when the
|
|
26
|
+
// upstream provider hid the content behind a safety filter and only the
|
|
27
|
+
// opaque continuation payload survives; in that case `text` is empty.
|
|
28
|
+
| { cat: 'thinking'; ts: number; text: string; redacted?: boolean }
|
|
19
29
|
| {
|
|
20
30
|
cat: 'tool'
|
|
21
31
|
ts: number
|
|
@@ -41,6 +51,22 @@ export type InspectEvent =
|
|
|
41
51
|
}
|
|
42
52
|
| { cat: 'broadcast'; ts: number; payload: unknown; meta?: Record<string, string> }
|
|
43
53
|
| { cat: 'cron-fire'; ts: number; jobId: string; payload: unknown }
|
|
54
|
+
| {
|
|
55
|
+
cat: 'inbound'
|
|
56
|
+
ts: number
|
|
57
|
+
adapter: string
|
|
58
|
+
workspace: string
|
|
59
|
+
chat: string
|
|
60
|
+
thread: string | null
|
|
61
|
+
authorId: string
|
|
62
|
+
authorName: string
|
|
63
|
+
authorIsBot: boolean
|
|
64
|
+
isDm: boolean
|
|
65
|
+
isBotMention: boolean
|
|
66
|
+
text: string
|
|
67
|
+
externalMessageId: string
|
|
68
|
+
decision: InboundDecision
|
|
69
|
+
}
|
|
44
70
|
|
|
45
71
|
export type InspectFilter = {
|
|
46
72
|
include?: ReadonlySet<InspectCategory>
|