typeclaw 0.28.1 → 0.29.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 +1 -1
- package/src/agent/index.ts +37 -5
- package/src/agent/loop-guard.ts +112 -26
- package/src/agent/plugin-tools.ts +102 -41
- package/src/agent/session-origin.ts +3 -3
- package/src/agent/subagents.ts +7 -0
- package/src/agent/system-prompt.ts +29 -4
- package/src/agent/tools/channel-reply.ts +1 -0
- package/src/agent/tools/channel-send.ts +2 -1
- package/src/agent/tools/spawn-subagent.ts +21 -0
- package/src/agent/tools/subagent-output.ts +7 -3
- package/src/agent/tools/wikipedia.ts +1 -1
- package/src/bundled-plugins/explorer/explorer.ts +2 -0
- package/src/bundled-plugins/github-cli-auth/approve-idempotency.ts +74 -0
- package/src/bundled-plugins/github-cli-auth/effective-approval.ts +98 -0
- package/src/bundled-plugins/github-cli-auth/gh-review-inline-detect.ts +130 -0
- package/src/bundled-plugins/github-cli-auth/index.ts +27 -2
- package/src/bundled-plugins/github-cli-auth/review-recorder.ts +12 -4
- package/src/bundled-plugins/memory/memory-logger.ts +3 -3
- package/src/bundled-plugins/operator/operator.ts +2 -0
- package/src/bundled-plugins/planner/index.ts +11 -0
- package/src/bundled-plugins/planner/planner.ts +282 -0
- package/src/bundled-plugins/planner/skills/general.ts +65 -0
- package/src/bundled-plugins/planner/skills/project.ts +69 -0
- package/src/bundled-plugins/researcher/index.ts +11 -0
- package/src/bundled-plugins/researcher/researcher.ts +226 -0
- package/src/bundled-plugins/researcher/skills/general.ts +105 -0
- package/src/bundled-plugins/researcher/write-report.ts +107 -0
- package/src/bundled-plugins/reviewer/reviewer.ts +29 -11
- package/src/bundled-plugins/reviewer/skills/data-review.ts +77 -0
- package/src/bundled-plugins/reviewer/skills/doc-review.ts +79 -0
- package/src/bundled-plugins/reviewer/skills/general.ts +1 -1
- package/src/bundled-plugins/reviewer/skills/plan-review.ts +64 -0
- package/src/bundled-plugins/reviewer/skills/security-audit.ts +70 -0
- package/src/bundled-plugins/reviewer/skills/writing-review.ts +63 -0
- package/src/bundled-plugins/scout/scout.ts +2 -0
- package/src/bundled-plugins/security/policies/prompt-injection.ts +8 -4
- package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +3 -2
- package/src/channels/adapters/discord-bot.ts +38 -11
- package/src/channels/adapters/github/inbound.ts +74 -9
- package/src/channels/adapters/github/index.ts +36 -11
- package/src/channels/adapters/github/reconcile-open-prs.ts +306 -0
- package/src/channels/adapters/github/review-state.ts +71 -2
- package/src/channels/adapters/kakaotalk-classify.ts +2 -2
- package/src/channels/adapters/kakaotalk.ts +2 -2
- package/src/channels/adapters/slack-bot-classify.ts +1 -1
- package/src/channels/adapters/slack-bot.ts +3 -0
- package/src/channels/adapters/telegram-bot.ts +3 -0
- package/src/channels/engagement.ts +12 -7
- package/src/channels/github-rereview-guard.ts +32 -8
- package/src/channels/github-review-claim.ts +53 -6
- package/src/channels/router.ts +44 -9
- package/src/channels/schema.ts +4 -3
- package/src/channels/types.ts +17 -6
- package/src/cli/init.ts +13 -2
- package/src/cli/ui.ts +64 -0
- package/src/config/config.ts +21 -15
- package/src/container/start.ts +5 -1
- package/src/init/dockerfile.ts +19 -56
- package/src/init/hatching.ts +1 -1
- package/src/init/index.ts +5 -1
- package/src/run/bundled-plugins.ts +4 -0
- package/src/server/index.ts +24 -5
- package/src/shared/host-locale.ts +27 -0
- package/src/shared/protocol.ts +1 -1
- package/src/shared/wordmark.ts +19 -0
- package/src/skills/typeclaw-channel-github/SKILL.md +1 -1
- package/src/skills/typeclaw-config/SKILL.md +32 -32
- package/src/skills/typeclaw-kaomoji/SKILL.md +3 -3
- package/src/skills/typeclaw-tunnels/SKILL.md +3 -1
- package/src/tui/banner.ts +19 -0
- package/src/tui/format.ts +34 -0
- package/src/tui/index.ts +121 -22
- package/src/tui/theme.ts +26 -1
- package/src/tunnels/providers/cloudflare-named.ts +15 -4
- package/src/tunnels/providers/cloudflare-quick.ts +15 -4
- package/src/tunnels/providers/cloudflared-binary.ts +11 -0
- package/typeclaw.schema.json +15 -7
package/src/init/dockerfile.ts
CHANGED
|
@@ -12,6 +12,12 @@ export const DOCKERFILE = 'Dockerfile'
|
|
|
12
12
|
export type BuildDockerfileOptions = {
|
|
13
13
|
// Null or omitted = emit the full inline heavy stack (dev mode, tests).
|
|
14
14
|
baseImageVersion?: string | null
|
|
15
|
+
// Host-locale decision for `cjkFonts: 'auto'`. Callers that have a host
|
|
16
|
+
// locale (start/init) pass the resolved boolean; when omitted, `'auto'`
|
|
17
|
+
// falls back to NOT installing (the slim default) so a context without a
|
|
18
|
+
// host signal — e.g. a bare `buildDockerfile()` in tests — stays small and
|
|
19
|
+
// deterministic.
|
|
20
|
+
cjkFontsAuto?: boolean
|
|
15
21
|
}
|
|
16
22
|
|
|
17
23
|
// Apt packages that EVERY image must have — git for the agent runtime,
|
|
@@ -98,33 +104,6 @@ export const CURL_IMPERSONATE_SHA256_ARM64 = '6766bc67fd3e8e2313875f32b36b5a3fab
|
|
|
98
104
|
// the impersonation to whatever `curl_chrome` resolves to.
|
|
99
105
|
export const CURL_IMPERSONATE_PROFILE = 'chrome136'
|
|
100
106
|
|
|
101
|
-
// yq is the YAML/JSON/XML processor the `explorer` and `reviewer` subagent
|
|
102
|
-
// prompts advertise alongside `jq` as a sanctioned one-shot read-only
|
|
103
|
-
// pipeline tool (`... | yq`). Like `jq` (shipped in baseline), a binary that
|
|
104
|
-
// is not in the container base image is invisible inside the per-tool bwrap
|
|
105
|
-
// sandbox that wraps agent bash calls — the sandbox only `--ro-bind`s `/usr`
|
|
106
|
-
// + `/bin` from the container, and there is no per-call install path. So `yq`
|
|
107
|
-
// ships unconditionally, same rationale as `jq`/`bubblewrap`/`util-linux`.
|
|
108
|
-
//
|
|
109
|
-
// This is Mike Farah's Go `yq` (https://github.com/mikefarah/yq), NOT the
|
|
110
|
-
// Python jq-wrapper of the same name in Debian's `yq` apt package. The
|
|
111
|
-
// prompts pair `yq` with `jq` for jq-style expression pipelines, which is
|
|
112
|
-
// Farah's syntax — the Python tool's CLI is incompatible. Distributed as a
|
|
113
|
-
// per-arch static binary (no apt package on trixie for the Go variant), so
|
|
114
|
-
// it follows the pinned-version + per-arch SHA256 + `sha256sum -c` pattern of
|
|
115
|
-
// curl-impersonate and cloudflared rather than the apt baseline list.
|
|
116
|
-
//
|
|
117
|
-
// To bump: pick a release from https://github.com/mikefarah/yq/releases,
|
|
118
|
-
// then for each arch download yq_linux_<arch> and `shasum -a 256` it (or read
|
|
119
|
-
// the SHA-256 column from the release's `checksums` file, cross-indexed via
|
|
120
|
-
// `checksums_hashes_order`). Update all three constants in the same commit;
|
|
121
|
-
// the build fails loudly at `sha256sum -c` on a mismatch. Version literal is
|
|
122
|
-
// the release tag exactly as it appears on GitHub (with `v` prefix).
|
|
123
|
-
export const YQ_VERSION = 'v4.53.2'
|
|
124
|
-
export const YQ_SHA256_AMD64 = 'd56bf5c6819e8e696340c312bd70f849dc1678a7cda9c2ad63eebd906371d56b'
|
|
125
|
-
export const YQ_SHA256_ARM64 = '03061b2a50c7a498de2bbb92d7cb078ce433011f085a4994117c2726be4106ea'
|
|
126
|
-
export const YQ_RELEASE_URL_BASE = 'https://github.com/mikefarah/yq/releases/download'
|
|
127
|
-
|
|
128
107
|
// cloudflared powers `cloudflare-quick` tunnels. Pinned-version + per-arch
|
|
129
108
|
// SHA256 mirrors the curl-impersonate pattern above: bumping requires updating
|
|
130
109
|
// all three constants in the same commit, and the build fails loudly at
|
|
@@ -1068,22 +1047,26 @@ type AptFeature = {
|
|
|
1068
1047
|
toAptArgs: (toggle: DockerfileFeatureToggle) => string[]
|
|
1069
1048
|
}
|
|
1070
1049
|
|
|
1071
|
-
const APT_FEATURES: Record<'ffmpeg' | 'gh' | 'tmux' | 'python' | '
|
|
1050
|
+
const APT_FEATURES: Record<'ffmpeg' | 'gh' | 'tmux' | 'python' | 'xvfb', AptFeature> = {
|
|
1072
1051
|
ffmpeg: { toAptArgs: (v) => singlePackageArgs('ffmpeg', v) },
|
|
1073
1052
|
gh: { toAptArgs: (v) => singlePackageArgs('gh', v) },
|
|
1074
1053
|
tmux: { toAptArgs: (v) => singlePackageArgs('tmux', v) },
|
|
1075
1054
|
python: {
|
|
1076
1055
|
toAptArgs: (v) => (v === true ? ['python3', 'python3-pip', 'python3-venv', 'python-is-python3'] : []),
|
|
1077
1056
|
},
|
|
1078
|
-
cjkFonts: { toAptArgs: (v) => (v === true ? [CJK_FONTS_PACKAGE] : []) },
|
|
1079
1057
|
xvfb: { toAptArgs: (v) => (v === true ? ['xvfb'] : []) },
|
|
1080
1058
|
}
|
|
1081
1059
|
|
|
1060
|
+
function resolveCjkFonts(value: boolean | 'auto', auto: boolean): boolean {
|
|
1061
|
+
return value === 'auto' ? auto : value
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1082
1064
|
export function buildDockerfile(
|
|
1083
1065
|
config: DockerfileConfig = defaultConfig(),
|
|
1084
1066
|
options: BuildDockerfileOptions = {},
|
|
1085
1067
|
): string {
|
|
1086
|
-
const
|
|
1068
|
+
const cjkFonts = resolveCjkFonts(config.cjkFonts, options.cjkFontsAuto ?? false)
|
|
1069
|
+
const toggleAptArgs = collectToggleAptArgs(config, cjkFonts)
|
|
1087
1070
|
const ghKeyringLayer = renderGhKeyringLayer(config.gh)
|
|
1088
1071
|
const cloudflaredLayer = renderCloudflaredLayer(config.cloudflared)
|
|
1089
1072
|
const customLines = renderCustomDockerfileLines(config.append)
|
|
@@ -1215,8 +1198,6 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \\
|
|
|
1215
1198
|
|
|
1216
1199
|
${LAYER_2_5_CURL_IMPERSONATE}
|
|
1217
1200
|
|
|
1218
|
-
${LAYER_2_6_YQ}
|
|
1219
|
-
|
|
1220
1201
|
${LAYER_3_AGENT_BROWSER_ARM64_CONFIG}
|
|
1221
1202
|
|
|
1222
1203
|
${LAYER_4_AGENT_BROWSER_INSTALL}
|
|
@@ -1296,8 +1277,6 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \\
|
|
|
1296
1277
|
|
|
1297
1278
|
${LAYER_2_5_CURL_IMPERSONATE}
|
|
1298
1279
|
|
|
1299
|
-
${LAYER_2_6_YQ}
|
|
1300
|
-
|
|
1301
1280
|
${LAYER_3_AGENT_BROWSER_ARM64_CONFIG}
|
|
1302
1281
|
|
|
1303
1282
|
${LAYER_4_AGENT_BROWSER_INSTALL}
|
|
@@ -1352,24 +1331,6 @@ RUN ARCH_TARBALL="$(if [ "$TARGETARCH" = "arm64" ]; then echo aarch64-linux-gnu;
|
|
|
1352
1331
|
&& rm curl-impersonate.tar.gz \\
|
|
1353
1332
|
&& /usr/local/bin/curl_${CURL_IMPERSONATE_PROFILE} --version > /dev/null`
|
|
1354
1333
|
|
|
1355
|
-
// Layer 2.6: install pinned Mike Farah `yq` (Go) so the explorer/reviewer
|
|
1356
|
-
// subagents' advertised `... | yq` pipelines resolve inside the bwrap
|
|
1357
|
-
// sandbox. Unconditional like the apt baseline (jq, git): a missing binary
|
|
1358
|
-
// is invisible to sandboxed bash, so this is not behind a toggle. Placed
|
|
1359
|
-
// after curl-impersonate (curl + ca-certificates from baseline guaranteed
|
|
1360
|
-
// present) and before agent-browser so an agent-browser bump doesn't
|
|
1361
|
-
// invalidate this layer. See the YQ_* constants above for the bump recipe
|
|
1362
|
-
// and the Go-vs-Python `yq` rationale.
|
|
1363
|
-
const LAYER_2_6_YQ = `# Layer 2.6 (stable): pinned Mike Farah yq for sandbox-visible YAML pipelines.
|
|
1364
|
-
RUN ARCH_BIN="$(if [ "$TARGETARCH" = "arm64" ]; then echo arm64; else echo amd64; fi)" \\
|
|
1365
|
-
&& ARCH_SHA="$(if [ "$TARGETARCH" = "arm64" ]; then echo ${YQ_SHA256_ARM64}; else echo ${YQ_SHA256_AMD64}; fi)" \\
|
|
1366
|
-
&& cd /tmp \\
|
|
1367
|
-
&& curl -fsSL -o yq "${YQ_RELEASE_URL_BASE}/${YQ_VERSION}/yq_linux_\${ARCH_BIN}" \\
|
|
1368
|
-
&& echo "\${ARCH_SHA} yq" | sha256sum -c - \\
|
|
1369
|
-
&& chmod +x yq \\
|
|
1370
|
-
&& mv yq /usr/local/bin/yq \\
|
|
1371
|
-
&& /usr/local/bin/yq --version > /dev/null`
|
|
1372
|
-
|
|
1373
1334
|
const LAYER_3_AGENT_BROWSER_ARM64_CONFIG = `# Layer 3 (stable, arm64 only): point agent-browser at the apt-installed
|
|
1374
1335
|
# chromium. Independent of the npm install below so it stays cached across
|
|
1375
1336
|
# agent-browser version bumps.
|
|
@@ -1510,8 +1471,8 @@ function defaultConfig(): DockerfileConfig {
|
|
|
1510
1471
|
gh: true,
|
|
1511
1472
|
python: true,
|
|
1512
1473
|
tmux: true,
|
|
1513
|
-
cjkFonts:
|
|
1514
|
-
cloudflared:
|
|
1474
|
+
cjkFonts: 'auto',
|
|
1475
|
+
cloudflared: false,
|
|
1515
1476
|
xvfb: true,
|
|
1516
1477
|
claudeCode: false,
|
|
1517
1478
|
codexCli: false,
|
|
@@ -1519,11 +1480,13 @@ function defaultConfig(): DockerfileConfig {
|
|
|
1519
1480
|
}
|
|
1520
1481
|
}
|
|
1521
1482
|
|
|
1522
|
-
function collectToggleAptArgs(config: DockerfileConfig): string[] {
|
|
1483
|
+
function collectToggleAptArgs(config: DockerfileConfig, cjkFonts: boolean): string[] {
|
|
1523
1484
|
const args: string[] = []
|
|
1524
|
-
for (const key of ['ffmpeg', 'gh', 'python', 'tmux'
|
|
1485
|
+
for (const key of ['ffmpeg', 'gh', 'python', 'tmux'] as const) {
|
|
1525
1486
|
args.push(...APT_FEATURES[key].toAptArgs(config[key]))
|
|
1526
1487
|
}
|
|
1488
|
+
if (cjkFonts) args.push(CJK_FONTS_PACKAGE)
|
|
1489
|
+
args.push(...APT_FEATURES.xvfb.toAptArgs(config.xvfb))
|
|
1527
1490
|
return args
|
|
1528
1491
|
}
|
|
1529
1492
|
|
package/src/init/hatching.ts
CHANGED
|
@@ -38,7 +38,7 @@ Routing answers:
|
|
|
38
38
|
1. \`write\` your name into \`IDENTITY.md\` (a first-person one-liner is fine: "I am <name>.").
|
|
39
39
|
2. ${q1AliasStep} The agent folder's directory name is already an implicit alias — only add the answered name explicitly when it differs from the dir name (different casing, a different word, or extra forms like "<name>" plus a Latin transliteration). This wires plain-text addressing in channels: when a user writes your name in chat without an @-mention, the engagement layer will recognize it. \`alias\` is live-reloadable.
|
|
40
40
|
2. **Q2 — the user's name.** Ask what to call them. After the answer: \`write\` it to both \`IDENTITY.md\` and \`USER.md\`.
|
|
41
|
-
3. **Q3 — tone/personality.** Ask how they want you to show up (tone, language, formality). After the answer: \`write\` it into \`SOUL.md\`. If they shrug or don't care: **default to warm, friendly, and easygoing** — a kind colleague who genuinely likes the person they work with, uses contractions, makes small jokes, never stiff. Write that as the default into \`SOUL.md\`. **Kaomoji affinity** — if their answer leans cute, adorable, warm, playful, soft, cozy, or
|
|
41
|
+
3. **Q3 — tone/personality.** Ask how they want you to show up (tone, language, formality). After the answer: \`write\` it into \`SOUL.md\`. If they shrug or don't care: **default to warm, friendly, and easygoing** — a kind colleague who genuinely likes the person they work with, uses contractions, makes small jokes, never stiff. Write that as the default into \`SOUL.md\`. **Kaomoji affinity** — if their answer leans cute, adorable, warm, playful, soft, cozy, or asks for that kind of tone in any language (e.g. Korean 친근/귀엽/다정한, Japanese かわいい/親しみやすい, Chinese 可爱/亲切, Spanish tierno/cariñoso), append a line to \`SOUL.md\` like: \`I lean on kaomojis like (◕‿◕✿) and (。・ω・。) to carry warmth — emojis still welcome when they actually mean something, but kaomojis lead.\` This makes the bundled \`typeclaw-kaomoji\` skill auto-load later when you need it. Do not force this line if the user asked for a neutral, professional, or terse tone.
|
|
42
42
|
|
|
43
43
|
**Do not ask what they want you to do, what project you'll work on, or why they installed you.** That reveals itself when they give you a real task. Probing here makes the tool feel heavy for someone just trying it out.
|
|
44
44
|
|
package/src/init/index.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
import { checkDockerAvailable, type DockerAvailability, type DockerExec, start } from '@/container'
|
|
15
15
|
import { commitSystemFile } from '@/git/system-commit'
|
|
16
16
|
import { createSecretsStoreForAgent, type Channels, type Secret, SecretsBackend } from '@/secrets'
|
|
17
|
+
import { hostLocaleIsCjk } from '@/shared/host-locale'
|
|
17
18
|
import { createTui } from '@/tui'
|
|
18
19
|
|
|
19
20
|
import { resolveBaseImageVersion, resolveScaffoldVersion } from './cli-version'
|
|
@@ -660,7 +661,10 @@ export async function writeDockerAssets(root: string): Promise<DockerAssetsResul
|
|
|
660
661
|
const typeclawConfig = await readTypeclawConfig(root)
|
|
661
662
|
await writeFile(
|
|
662
663
|
join(root, DOCKERFILE),
|
|
663
|
-
buildDockerfile(typeclawConfig.docker.file, {
|
|
664
|
+
buildDockerfile(typeclawConfig.docker.file, {
|
|
665
|
+
baseImageVersion: resolveBaseImageVersion(root),
|
|
666
|
+
cjkFontsAuto: hostLocaleIsCjk(),
|
|
667
|
+
}),
|
|
664
668
|
{ flag: 'wx' },
|
|
665
669
|
).catch(ignoreExists)
|
|
666
670
|
|
|
@@ -6,6 +6,8 @@ import githubCliAuthPlugin from '@/bundled-plugins/github-cli-auth'
|
|
|
6
6
|
import guardPlugin from '@/bundled-plugins/guard'
|
|
7
7
|
import memoryPlugin from '@/bundled-plugins/memory'
|
|
8
8
|
import operatorPlugin from '@/bundled-plugins/operator'
|
|
9
|
+
import plannerPlugin from '@/bundled-plugins/planner'
|
|
10
|
+
import researcherPlugin from '@/bundled-plugins/researcher'
|
|
9
11
|
import reviewerPlugin from '@/bundled-plugins/reviewer'
|
|
10
12
|
import scoutPlugin from '@/bundled-plugins/scout'
|
|
11
13
|
import securityPlugin from '@/bundled-plugins/security'
|
|
@@ -59,5 +61,7 @@ export const BUNDLED_PLUGINS: ResolvedPlugin[] = [
|
|
|
59
61
|
{ name: 'explorer', version: undefined, source: '<bundled>', defined: explorerPlugin },
|
|
60
62
|
{ name: 'scout', version: undefined, source: '<bundled>', defined: scoutPlugin },
|
|
61
63
|
{ name: 'reviewer', version: undefined, source: '<bundled>', defined: reviewerPlugin },
|
|
64
|
+
{ name: 'researcher', version: undefined, source: '<bundled>', defined: researcherPlugin },
|
|
65
|
+
{ name: 'planner', version: undefined, source: '<bundled>', defined: plannerPlugin },
|
|
62
66
|
{ name: 'operator', version: undefined, source: '<bundled>', defined: operatorPlugin },
|
|
63
67
|
]
|
package/src/server/index.ts
CHANGED
|
@@ -184,6 +184,10 @@ type SessionState = {
|
|
|
184
184
|
// not re-target this session's lifecycle hooks.
|
|
185
185
|
runtimeSnapshot: PluginRuntimeState | null
|
|
186
186
|
unsubTurnOutcome: Unsubscribe | null
|
|
187
|
+
// Latest turn's usage, captured from `message_end` by forwardSessionEvents and
|
|
188
|
+
// read at the `done` send site (which lives outside that subscriber). Reset at
|
|
189
|
+
// each turn start so a turn with no usage event sends a plain `done`.
|
|
190
|
+
lastUsage: { input: number; output: number; totalTokens: number; cost: number } | null
|
|
187
191
|
dispose: () => Promise<void>
|
|
188
192
|
}
|
|
189
193
|
|
|
@@ -520,6 +524,7 @@ export function createServer({
|
|
|
520
524
|
activeClaimCode: null,
|
|
521
525
|
runtimeSnapshot: runtimeSnapshot ?? null,
|
|
522
526
|
unsubTurnOutcome: null,
|
|
527
|
+
lastUsage: null,
|
|
523
528
|
dispose,
|
|
524
529
|
}
|
|
525
530
|
sessionStates.set(ws, state)
|
|
@@ -533,7 +538,7 @@ export function createServer({
|
|
|
533
538
|
}
|
|
534
539
|
|
|
535
540
|
liveSessionRegistry?.register({ sessionId: sessionFileId, session })
|
|
536
|
-
forwardSessionEvents(ws,
|
|
541
|
+
forwardSessionEvents(ws, state, logger, sessionFileId)
|
|
537
542
|
|
|
538
543
|
if (stream) {
|
|
539
544
|
state.unsubPrompts = stream.subscribe({ target: { kind: 'session', sessionId: sessionFileId } }, (msg) =>
|
|
@@ -759,9 +764,10 @@ export function createServer({
|
|
|
759
764
|
origin: state.origin,
|
|
760
765
|
})
|
|
761
766
|
}
|
|
767
|
+
state.lastUsage = null
|
|
762
768
|
try {
|
|
763
769
|
await state.session.prompt(`${renderTurnTimeAnchor()}\n\n${msg.text}`)
|
|
764
|
-
send(ws,
|
|
770
|
+
send(ws, doneMessage(state))
|
|
765
771
|
} catch (err) {
|
|
766
772
|
const message = err instanceof Error ? err.message : String(err)
|
|
767
773
|
logger.error(`[server] ${state.sessionFileId}: prompt failed: ${message}`)
|
|
@@ -859,10 +865,10 @@ function isWebSocketUpgrade(req: Request): boolean {
|
|
|
859
865
|
return req.headers.get('upgrade')?.toLowerCase() === 'websocket'
|
|
860
866
|
}
|
|
861
867
|
|
|
862
|
-
function forwardSessionEvents(ws: Ws,
|
|
868
|
+
function forwardSessionEvents(ws: Ws, state: SessionState, logger: ServerLogger, sessionFileId: string): void {
|
|
863
869
|
const toolStartedAt = new Map<string, number>()
|
|
864
870
|
|
|
865
|
-
session.subscribe((event) => {
|
|
871
|
+
state.session.subscribe((event) => {
|
|
866
872
|
switch (event.type) {
|
|
867
873
|
case 'message_update':
|
|
868
874
|
if (event.assistantMessageEvent.type === 'text_delta') {
|
|
@@ -877,6 +883,7 @@ function forwardSessionEvents(ws: Ws, session: AgentSession, logger: ServerLogge
|
|
|
877
883
|
// because no text deltas were ever emitted, which looks like a freeze.
|
|
878
884
|
// The server's existing try/catch around `session.prompt()` only
|
|
879
885
|
// catches throws, so it never sees these.
|
|
886
|
+
state.lastUsage = readDoneUsage(event.message)
|
|
880
887
|
forwardAssistantError(ws, event.message, logger, sessionFileId)
|
|
881
888
|
break
|
|
882
889
|
case 'tool_execution_start':
|
|
@@ -1048,9 +1055,10 @@ async function drain(
|
|
|
1048
1055
|
}
|
|
1049
1056
|
|
|
1050
1057
|
await fireTurnStart(item.text)
|
|
1058
|
+
state.lastUsage = null
|
|
1051
1059
|
try {
|
|
1052
1060
|
await state.session.prompt(`${renderTurnTimeAnchor()}\n\n${item.text}`)
|
|
1053
|
-
send(ws,
|
|
1061
|
+
send(ws, doneMessage(state))
|
|
1054
1062
|
} catch (err) {
|
|
1055
1063
|
const message = err instanceof Error ? err.message : String(err)
|
|
1056
1064
|
logger.error(`[server] ${state.sessionFileId}: prompt failed: ${message}`)
|
|
@@ -1445,6 +1453,17 @@ function buildMessageEndPayload(sessionId: string, message: unknown): InspectFra
|
|
|
1445
1453
|
return payload
|
|
1446
1454
|
}
|
|
1447
1455
|
|
|
1456
|
+
function doneMessage(state: SessionState): ServerMessage {
|
|
1457
|
+
return state.lastUsage === null ? { type: 'done' } : { type: 'done', usage: state.lastUsage }
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
function readDoneUsage(message: unknown): { input: number; output: number; totalTokens: number; cost: number } | null {
|
|
1461
|
+
if (typeof message !== 'object' || message === null) return null
|
|
1462
|
+
const usage = readMessageUsage((message as Record<string, unknown>).usage)
|
|
1463
|
+
if (usage === null) return null
|
|
1464
|
+
return { input: usage.input, output: usage.output, totalTokens: usage.totalTokens, cost: usage.cost }
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1448
1467
|
function readMessageUsage(
|
|
1449
1468
|
value: unknown,
|
|
1450
1469
|
): { input: number; output: number; cacheRead: number; cacheWrite: number; totalTokens: number; cost: number } | null {
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
const CJK_LANGUAGE_PREFIXES = ['ja', 'ko', 'zh'] as const
|
|
2
|
+
|
|
3
|
+
function languageTagIsCjk(tag: string): boolean {
|
|
4
|
+
const primary = tag.toLowerCase().replace(/_/g, '-').split('-')[0] ?? ''
|
|
5
|
+
return CJK_LANGUAGE_PREFIXES.some((prefix) => primary === prefix)
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// True when the HOST's locale is Chinese/Japanese/Korean. POSIX precedence:
|
|
9
|
+
// LC_ALL overrides LC_CTYPE overrides LANG. Values look like `ja_JP.UTF-8`,
|
|
10
|
+
// `ko_KR`, `zh-Hans`. `C`/`POSIX`/empty fall through to Intl, which on macOS
|
|
11
|
+
// (where these env vars are usually unset) reports the user's system locale.
|
|
12
|
+
// Returns false if nothing resolves — the conservative choice, since the
|
|
13
|
+
// caller uses this to decide whether to add the ~89MB CJK font package.
|
|
14
|
+
export function hostLocaleIsCjk(): boolean {
|
|
15
|
+
for (const envVar of ['LC_ALL', 'LC_CTYPE', 'LANG']) {
|
|
16
|
+
const value = process.env[envVar]
|
|
17
|
+
if (value === undefined || value === '') continue
|
|
18
|
+
if (value === 'C' || value === 'POSIX') return false
|
|
19
|
+
return languageTagIsCjk(value)
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
const locale = Intl.DateTimeFormat().resolvedOptions().locale
|
|
23
|
+
return locale !== undefined && locale !== '' && languageTagIsCjk(locale)
|
|
24
|
+
} catch {
|
|
25
|
+
return false
|
|
26
|
+
}
|
|
27
|
+
}
|
package/src/shared/protocol.ts
CHANGED
|
@@ -223,7 +223,7 @@ export type ServerMessage =
|
|
|
223
223
|
durationMs: number
|
|
224
224
|
ts?: number
|
|
225
225
|
}
|
|
226
|
-
| { type: 'done'; ts?: number }
|
|
226
|
+
| { type: 'done'; ts?: number; usage?: { input: number; output: number; totalTokens: number; cost: number } }
|
|
227
227
|
| { type: 'error'; message: string; ts?: number }
|
|
228
228
|
| { type: 'reload_result'; results: ReloadResultPayload[] }
|
|
229
229
|
| { type: 'restart_result'; status: 'accepted' | 'failed'; message?: string; error?: string }
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Single source of truth for the "typeclaw" wordmark so the TUI banner and the
|
|
2
|
+
// init CLI render the same art instead of drifting apart. This module is
|
|
3
|
+
// color-agnostic: each consumer applies its own coloring layer (the TUI emits
|
|
4
|
+
// raw truecolor; init gates color behind NO_COLOR/TTY), so the art here carries
|
|
5
|
+
// zero escape sequences.
|
|
6
|
+
|
|
7
|
+
// ANSI Shadow "typeclaw". Trailing whitespace is significant for alignment.
|
|
8
|
+
export const WORDMARK_LINES: readonly string[] = [
|
|
9
|
+
'████████╗██╗ ██╗██████╗ ███████╗ ██████╗██╗ █████╗ ██╗ ██╗',
|
|
10
|
+
'╚══██╔══╝╚██╗ ██╔╝██╔══██╗██╔════╝██╔════╝██║ ██╔══██╗██║ ██║',
|
|
11
|
+
' ██║ ╚████╔╝ ██████╔╝█████╗ ██║ ██║ ███████║██║ █╗ ██║',
|
|
12
|
+
' ██║ ╚██╔╝ ██╔═══╝ ██╔══╝ ██║ ██║ ██╔══██║██║███╗██║',
|
|
13
|
+
' ██║ ██║ ██║ ███████╗╚██████╗███████╗██║ ██║╚███╔███╔╝',
|
|
14
|
+
' ╚═╝ ╚═╝ ╚═╝ ╚══════╝ ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝ ',
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
export const WORDMARK_WIDTH: number = Math.max(...WORDMARK_LINES.map((line) => line.length))
|
|
18
|
+
|
|
19
|
+
export const COMPACT_WORDMARK = 'typeclaw'
|
|
@@ -189,7 +189,7 @@ The `reviewer` subagent is the analyst; you are the integration layer between it
|
|
|
189
189
|
|
|
190
190
|
A finding is "actionable" if its severity is `blocker`, `concern`, or `nit`. The inline-review post in step 4 applies whenever the actionable count is **at least one**. When the reviewer returns **exactly zero** actionable findings (only `praise`, or none), there is nothing to anchor inline — handle by verdict:
|
|
191
191
|
|
|
192
|
-
- `approve` → post a plain `APPROVE` with the `<summary>` as the review body (no `comments[]` array). **This is still a formal review via `POST /pulls/<N>/reviews`, NOT a `channel_reply`.** A zero-findings approval is the single most common place this goes wrong: with nothing to anchor inline, the model is tempted to just `channel_reply({ text: "Approved …" })` and end the turn. That posts a plain PR comment and leaves the PR **"awaiting review"** with no approval — the verdict never reaches GitHub's review API. Never start a top-level `channel_reply` on a `pr:N` with "Approved" / "LGTM" / "Request changes": those are verdicts, and verdicts are always formal reviews. Submit the `APPROVE` via `gh api`, confirm it landed (step 5), then `skip_response`. **If the operator approval policy above disabled approval, submit a `COMMENT` review instead — same `<summary>` as the review body, `event: "COMMENT"`, no `comments[]` array. Keep it a formal review, not a top-level issue comment, so the review metadata and flow are preserved.** (Re-review caveat: a `COMMENT` review does **not** clear a sticky `CHANGES_REQUESTED` block. If this is a re-review under approval-disabled policy, follow the step-4 re-review branch — dismiss your prior review — instead of relying on this `COMMENT`.)
|
|
192
|
+
- `approve` → post a plain `APPROVE` with the `<summary>` as the review body (no `comments[]` array). **Post the `<summary>` verbatim — do not pad it back into a play-by-play.** The reviewer's contract already makes the summary a terse, author-facing verdict justification (no process narration, no "I loaded the X skill", no recap of what the PR does); your job is to forward it, not re-expand it. **This is still a formal review via `POST /pulls/<N>/reviews`, NOT a `channel_reply`.** A zero-findings approval is the single most common place this goes wrong: with nothing to anchor inline, the model is tempted to just `channel_reply({ text: "Approved …" })` and end the turn. That posts a plain PR comment and leaves the PR **"awaiting review"** with no approval — the verdict never reaches GitHub's review API. Never start a top-level `channel_reply` on a `pr:N` with "Approved" / "LGTM" / "Request changes": those are verdicts, and verdicts are always formal reviews. Submit the `APPROVE` via `gh api`, confirm it landed (step 5), then `skip_response`. **If the operator approval policy above disabled approval, submit a `COMMENT` review instead — same `<summary>` as the review body, `event: "COMMENT"`, no `comments[]` array. Keep it a formal review, not a top-level issue comment, so the review metadata and flow are preserved.** (Re-review caveat: a `COMMENT` review does **not** clear a sticky `CHANGES_REQUESTED` block. If this is a re-review under approval-disabled policy, follow the step-4 re-review branch — dismiss your prior review — instead of relying on this `COMMENT`.)
|
|
193
193
|
- `comment` → post the summary as a top-level PR comment via `gh api -X POST /repos/.../issues/<N>/comments` instead of submitting an empty review. **Exception — re-reviews:** if this is a re-review (you have an unresolved blocking obligation — a formal `CHANGES_REQUESTED` **or** an unretracted flat-comment blocker), a top-level comment discharges neither. Do not use this branch; resolve it via the step-4 re-review branch (`APPROVE` if resolved and approval is enabled, the dismissal endpoint if a formal block is resolved but approval is disabled, `REQUEST_CHANGES` if not resolved).
|
|
194
194
|
- `request-changes` → submit `REQUEST_CHANGES` with the `<summary>` as the review body and no `comments[]` array. This combination is rare (the reviewer's contract says `request-changes` requires at least one blocker or load-bearing concern); if it happens, faithfully encode the verdict and trust the reviewer's reasoning is in the summary.
|
|
195
195
|
|
|
@@ -39,18 +39,18 @@ You yourself cannot run `typeclaw restart` — that is a host-stage command and
|
|
|
39
39
|
|
|
40
40
|
`typeclaw.json` is a single JSON object with these fields:
|
|
41
41
|
|
|
42
|
-
| Field | Required | Type | Notes
|
|
43
|
-
| ------------- | -------- | ---------------- |
|
|
44
|
-
| `$schema` | no | string | Path to `typeclaw.schema.json` for editor autocompletion. Scaffolded as `./node_modules/typeclaw/typeclaw.schema.json`. Leave it alone unless the user moves it.
|
|
45
|
-
| `port` | no | integer | 1–65535. Defaults to `8973` (T9 spelling of "TYPE"). Change only if the default collides with something on the user's host. **Restart-required.**
|
|
46
|
-
| `model` | no | string | Must be one of the values listed in the **Allowed models** section below. Defaults to `openai/gpt-5.4-nano`. **Live-reloadable.**
|
|
47
|
-
| `mounts` | no | array of objects | Host directories bind-mounted into your container. Defaults to `[]` (no host paths exposed). Omitted from scaffolded `typeclaw.json` — add it only when the user wants host paths exposed. See **Mounts** section below. **Restart-required.**
|
|
48
|
-
| `plugins` | no | array of strings | Plugin package names loaded at server boot. Defaults to `[]`. **Restart-required.** Plugin-owned config blocks live alongside as additional top-level keys; see **Plugin config blocks**.
|
|
49
|
-
| `alias` | no | array of strings | Additional names the agent answers to in channel engagement, on top of the implicit `basename(agentDir)`. Each entry is a non-empty trimmed string matched case-insensitively as a substring of the inbound text. Defaults to `[]`. Hatching populates this with the agent's chosen name. See **Alias** section below. **Live-reloadable.**
|
|
50
|
-
| `channels` | no | object | Per-adapter engagement triggers and history-prefetch knobs for external messengers. Defaults to `{}` (no adapters configured). `typeclaw init` scaffolds an empty block per requested adapter (e.g. `"discord-bot": {}`) and the schema fills in defaults. Channel access control lives in `roles` — see the `typeclaw-permissions` skill. **Live-reloadable.** See **Channels** section below.
|
|
51
|
-
| `portForward` | no | object | Allow/deny policy for the host-stage portbroker that auto-forwards container LISTEN ports to `127.0.0.1` on the host. Defaults to `{ "allow": "*" }` (forward everything). Omitted from scaffolded `typeclaw.json`. **Restart-required.** See **portForward** section below.
|
|
52
|
-
| `docker` | no | object | Namespace for Docker-related blocks. Today the only child is `docker.file` — toggles (`tmux`, `gh`, `python`, `ffmpeg`, `cjkFonts`, `cloudflared`, `claudeCode`, `codexCli`) gate opinionated package installs; `append` adds custom Dockerfile lines just before `ENTRYPOINT`. `docker.file` defaults to `{ ffmpeg: false, gh: true, python: true, tmux: true, cjkFonts:
|
|
53
|
-
| `git` | no | object | Namespace for git-related blocks. Today the only child is `git.ignore` — extra patterns spliced into the autogenerated `.gitignore` before TypeClaw's protected rules. `git.ignore` defaults to `{ "append": [] }`. Omitted from scaffolded `typeclaw.json`. **Restart-required** (next `typeclaw start` refreshes `.gitignore`). See **Gitignore** section below.
|
|
42
|
+
| Field | Required | Type | Notes |
|
|
43
|
+
| ------------- | -------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
44
|
+
| `$schema` | no | string | Path to `typeclaw.schema.json` for editor autocompletion. Scaffolded as `./node_modules/typeclaw/typeclaw.schema.json`. Leave it alone unless the user moves it. |
|
|
45
|
+
| `port` | no | integer | 1–65535. Defaults to `8973` (T9 spelling of "TYPE"). Change only if the default collides with something on the user's host. **Restart-required.** |
|
|
46
|
+
| `model` | no | string | Must be one of the values listed in the **Allowed models** section below. Defaults to `openai/gpt-5.4-nano`. **Live-reloadable.** |
|
|
47
|
+
| `mounts` | no | array of objects | Host directories bind-mounted into your container. Defaults to `[]` (no host paths exposed). Omitted from scaffolded `typeclaw.json` — add it only when the user wants host paths exposed. See **Mounts** section below. **Restart-required.** |
|
|
48
|
+
| `plugins` | no | array of strings | Plugin package names loaded at server boot. Defaults to `[]`. **Restart-required.** Plugin-owned config blocks live alongside as additional top-level keys; see **Plugin config blocks**. |
|
|
49
|
+
| `alias` | no | array of strings | Additional names the agent answers to in channel engagement, on top of the implicit `basename(agentDir)`. Each entry is a non-empty trimmed string matched case-insensitively as a substring of the inbound text. Defaults to `[]`. Hatching populates this with the agent's chosen name. See **Alias** section below. **Live-reloadable.** |
|
|
50
|
+
| `channels` | no | object | Per-adapter engagement triggers and history-prefetch knobs for external messengers. Defaults to `{}` (no adapters configured). `typeclaw init` scaffolds an empty block per requested adapter (e.g. `"discord-bot": {}`) and the schema fills in defaults. Channel access control lives in `roles` — see the `typeclaw-permissions` skill. **Live-reloadable.** See **Channels** section below. |
|
|
51
|
+
| `portForward` | no | object | Allow/deny policy for the host-stage portbroker that auto-forwards container LISTEN ports to `127.0.0.1` on the host. Defaults to `{ "allow": "*" }` (forward everything). Omitted from scaffolded `typeclaw.json`. **Restart-required.** See **portForward** section below. |
|
|
52
|
+
| `docker` | no | object | Namespace for Docker-related blocks. Today the only child is `docker.file` — toggles (`tmux`, `gh`, `python`, `ffmpeg`, `cjkFonts`, `cloudflared`, `claudeCode`, `codexCli`) gate opinionated package installs; `append` adds custom Dockerfile lines just before `ENTRYPOINT`. `docker.file` defaults to `{ ffmpeg: false, gh: true, python: true, tmux: true, cjkFonts: 'auto', cloudflared: false, claudeCode: false, codexCli: false, append: [] }`. Omitted from scaffolded `typeclaw.json`. **Restart-required** (next `typeclaw start` rebuilds the image). See **Dockerfile** section below. |
|
|
53
|
+
| `git` | no | object | Namespace for git-related blocks. Today the only child is `git.ignore` — extra patterns spliced into the autogenerated `.gitignore` before TypeClaw's protected rules. `git.ignore` defaults to `{ "append": [] }`. Omitted from scaffolded `typeclaw.json`. **Restart-required** (next `typeclaw start` refreshes `.gitignore`). See **Gitignore** section below. |
|
|
54
54
|
|
|
55
55
|
> **Top-level keys not in this table are not "ignored unknowns" anymore** — they are reserved for **plugin config blocks**. The schema's `catchall(z.unknown())` preserves them, and the plugin loader hands each block to its owning plugin's `configSchema` for validation. The bundled memory plugin owns `memory` at the top level — see the `typeclaw-memory` skill for that block's semantics. Do not write a top-level key unless you know which plugin owns it.
|
|
56
56
|
|
|
@@ -223,32 +223,32 @@ The agent folder's directory name (`basename(agentDir)`) is **always** an implic
|
|
|
223
223
|
|
|
224
224
|
### Match semantics
|
|
225
225
|
|
|
226
|
-
- **Substring** match against the inbound text. `"
|
|
227
|
-
- **Case-insensitive** via `toLocaleLowerCase()` on both sides. `"
|
|
226
|
+
- **Substring** match against the inbound text. `"토토"` matches `"토토아 cron"`, `"토토씨 안녕"`, `"누가 토토을 불러"`, all of them. Korean particles aren't stripped — substring is enough because the bot name appears at the start of every particled form.
|
|
227
|
+
- **Case-insensitive** via `toLocaleLowerCase()` on both sides. `"Toto"` in the alias list matches `"TOTO"`, `"toto"`, `"ToTo"`.
|
|
228
228
|
- **No word-boundary detection.** A short or generic alias like `"bot"` will match every message containing `"robot"` or `"bottom"`. Pick distinctive names — the operator owns curation.
|
|
229
229
|
|
|
230
230
|
### Engagement priority
|
|
231
231
|
|
|
232
|
-
The alias path runs **after** explicit triggers (mention/reply/dm) and the sticky check. So a message with both an `<@id>` mention and an alias substring engages once, normally. A message with only the alias substring engages on the alias path. The alias path is **NOT suppressed by `mentionsOthers`**: addressing two bots in one message (`"
|
|
232
|
+
The alias path runs **after** explicit triggers (mention/reply/dm) and the sticky check. So a message with both an `<@id>` mention and an alias substring engages once, normally. A message with only the alias substring engages on the alias path. The alias path is **NOT suppressed by `mentionsOthers`**: addressing two bots in one message (`"토토아 라라아 둘 다 봐"`) engages both bots — each on their own alias.
|
|
233
233
|
|
|
234
|
-
There's also a symmetric **peer-name suppressor**: if the message contains a peer bot's observed display name (from `participants[]`, populated as peers speak in the channel) and **does not** contain any of this agent's aliases, the solo-human fallback is suppressed and the agent observes. This is what makes `"
|
|
234
|
+
There's also a symmetric **peer-name suppressor**: if the message contains a peer bot's observed display name (from `participants[]`, populated as peers speak in the channel) and **does not** contain any of this agent's aliases, the solo-human fallback is suppressed and the agent observes. This is what makes `"라라아 cron 좀"` in a 1-human-multi-bot channel correctly observe instead of all bots replying. First-time addressing of a never-seen peer slips through; the suppressor catches it after the peer's first message.
|
|
235
235
|
|
|
236
236
|
### Example
|
|
237
237
|
|
|
238
238
|
```json
|
|
239
239
|
{
|
|
240
|
-
"alias": ["
|
|
240
|
+
"alias": ["toto", "토토"]
|
|
241
241
|
}
|
|
242
242
|
```
|
|
243
243
|
|
|
244
|
-
The agent in folder
|
|
244
|
+
The agent in folder `토토/` already answers to `"토토"` from the dir name. This adds the Latin transliteration so users can also write `"Hey toto, deploy?"`.
|
|
245
245
|
|
|
246
246
|
### When the user asks "respond to my casual nickname for you" / "I want to call you X"
|
|
247
247
|
|
|
248
248
|
1. **Read `typeclaw.json`.**
|
|
249
249
|
2. **If `alias` exists**, append the new name (preserve existing entries; dedupe trivially — the runtime also dedupes).
|
|
250
250
|
3. **If `alias` is absent**, create it as `["<new name>"]`.
|
|
251
|
-
4. **You don't need to add the dir name** unless the new name IS a variation of the dir name itself (e.g. dir is `
|
|
251
|
+
4. **You don't need to add the dir name** unless the new name IS a variation of the dir name itself (e.g. dir is `toto` and the user wants `Toto` casing — the implicit dir alias matches case-insensitively, so this isn't needed either).
|
|
252
252
|
5. **Trim whitespace** before adding. The schema rejects empty/whitespace-only entries; the runtime trims surrounding whitespace from valid entries.
|
|
253
253
|
6. **Write, commit**: "Edited `alias` — live-reloadable. Run `reload` to pick up the change without restart."
|
|
254
254
|
|
|
@@ -348,18 +348,18 @@ The `docker.file` block has two layers of customization:
|
|
|
348
348
|
|
|
349
349
|
### Fields
|
|
350
350
|
|
|
351
|
-
| Field | Required | Type
|
|
352
|
-
| ------------- | -------- |
|
|
353
|
-
| `tmux` | no | boolean \| string
|
|
354
|
-
| `gh` | no | boolean \| string
|
|
355
|
-
| `python` | no | boolean
|
|
356
|
-
| `ffmpeg` | no | boolean \| string
|
|
357
|
-
| `cjkFonts` | no | boolean
|
|
358
|
-
| `cloudflared` | no | boolean
|
|
359
|
-
| `xvfb` | no | boolean
|
|
360
|
-
| `claudeCode` | no | boolean
|
|
361
|
-
| `codexCli` | no | boolean
|
|
362
|
-
| `append` | no | array of strings
|
|
351
|
+
| Field | Required | Type | Notes |
|
|
352
|
+
| ------------- | -------- | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
353
|
+
| `tmux` | no | boolean \| string | Default `true`. `false` omits tmux from the apt install. String pins the Debian package version (e.g. `"3.3a-3"` → `tmux=3.3a-3`). |
|
|
354
|
+
| `gh` | no | boolean \| string | Default `true`. `false` omits **both** the `gh` package and the GitHub CLI keyring bootstrap layer (skipping the network roundtrip on cold builds). String pins the version. |
|
|
355
|
+
| `python` | no | boolean | Default `true`. Fans out to `python3 python3-pip python3-venv python-is-python3` (the bundle that makes `python` and `pip` resolve correctly inside the container). Boolean-only — no version pin, because Debian's `python3` is a meta-package that doesn't accept a useful pin. |
|
|
356
|
+
| `ffmpeg` | no | boolean \| string | Default `false`. `true` apt-installs ffmpeg (~80 MB of codecs). String pins the version. |
|
|
357
|
+
| `cjkFonts` | no | boolean or `"auto"` | Default `"auto"`. Installs `fonts-noto-cjk` (~89 MB) so Chromium (used by `agent-browser`) renders Korean/Japanese/Chinese glyphs correctly in screenshots, `page.pdf()`, and other raster output. `"auto"` resolves at `typeclaw start` from the host locale (`LANG`/`LC_ALL`/`Intl`): a CJK host (ja/ko/zh) installs the fonts, any other host skips them. An explicit `true`/`false` forces the decision. `false` skips the layer entirely (DOM/innerText scraping is unaffected by font absence — only raster output shows tofu boxes). |
|
|
358
|
+
| `cloudflared` | no | boolean | Default `false`. Downloads the pinned `cloudflared` GitHub release (~38 MB) into the image so `cloudflare-quick` tunnels work. Default `false` skips the layer on agents that don't use tunnels; `typeclaw tunnel add` / `channel add github` with a Cloudflare provider flip it to `true` automatically and prompt for a restart, so the happy path needs no manual edit. If the binary is absent when a tunnel starts, the tunnel goes `permanently-failed` with a "set docker.file.cloudflared: true and run typeclaw restart" message. Boolean-only — pinning is owned by the typeclaw release. |
|
|
359
|
+
| `xvfb` | no | boolean | Default `true`. Installs `xvfb` (~5 MB) so the entrypoint shim can spawn a virtual X server and export `DISPLAY=:99`, giving headed Chrome (agent-browser `--headed`, headful Playwright) a real X11 display to defeat headless-mode WAF fingerprinting. `false` skips the layer; the shim self-heals (no `Xvfb` on PATH → execs the agent without `DISPLAY`). Boolean-only — xvfb tracks the upstream X server release with no useful apt pin. |
|
|
360
|
+
| `claudeCode` | no | boolean | Default `false`. `true` runs Anthropic's official `curl -fsSL https://claude.ai/install.sh \| bash` in a dedicated layer (between agent-browser and the entrypoint shim) and pre-seeds `~/.claude.json` to skip the TTY-only theme picker on first launch (without it the agent's `tmux send-keys` would be eaten by the picker). Not apt: no version-pin variant; the upstream installer manages channels via env vars. Pairs with the `typeclaw-claude-code` skill, which documents the auth + tmux-driven usage flow including how to clear the post-seed API-key/trust dialogs. |
|
|
361
|
+
| `codexCli` | no | boolean | Default `false`. `true` runs `bun install -g @openai/codex` in a dedicated layer (after `claudeCode`, before the entrypoint shim) and pre-writes `~/.codex/hooks.json` registering `SessionStart` + `Stop` hooks so the operator can detect turn boundaries the same way as Claude Code (sentinel files, `.session-id` discovery). Not apt: no version-pin variant. Codex CLI has NO theme picker so no onboarding seed is needed, but auth (`codex login` or `OPENAI_API_KEY`) and the per-project trust dialog are still required at runtime — handled by the `typeclaw-codex-cli` skill. |
|
|
362
|
+
| `append` | no | array of strings | Each entry is a single Dockerfile line — schema **rejects** entries containing `\n` or `\r`. Defaults to `[]`. Splice happens just before `ENTRYPOINT`, after `ENV NODE_ENV=production`. |
|
|
363
363
|
|
|
364
364
|
Toggle version strings reject whitespace and `=` (apt-injection guard) — pass just the version, not `pkg=ver`.
|
|
365
365
|
|
|
@@ -631,7 +631,7 @@ Never echo, log, or commit values from `secrets.json` or `.env`. Both are gitign
|
|
|
631
631
|
- `channels.<adapter>.allow` (legacy) is silently ignored on parse and NOT translated to `roles.member.match`. Define `roles.member.match[]` directly. See the `typeclaw-permissions` skill.
|
|
632
632
|
- If `portForward` is set: `allow` is either `"*"` or an array of integers (1–65535); `deny`, if present, is an array of integers and **only valid when `allow` is `"*"`** (the schema rejects `deny` paired with a number-array `allow`)
|
|
633
633
|
- If `docker.file.append` is set: array of strings, each with no embedded `\n` or `\r` (multi-step shell logic goes in a single `&&`-chained `RUN` entry)
|
|
634
|
-
- If any `docker.file` toggle is set: `tmux`/`gh`/`ffmpeg` are boolean or version string (no whitespace, no `=`); `
|
|
634
|
+
- If any `docker.file` toggle is set: `tmux`/`gh`/`ffmpeg` are boolean or version string (no whitespace, no `=`); `cjkFonts` is boolean or `"auto"`; `python`, `cloudflared`, `claudeCode`, and `codexCli` are boolean only
|
|
635
635
|
- No unknown top-level keys you invented — keys outside the well-known ten are interpreted as **plugin config blocks** and only do something if a plugin owns them. Inventing one means the user thinks it took effect and it did not.
|
|
636
636
|
|
|
637
637
|
## Things you must not do
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: typeclaw-kaomoji
|
|
3
|
-
description: Load this skill when your `SOUL.md` (or the current conversation) calls for a warm, cute, adorable, playful, or affectionate tone — or when the user explicitly mentions kaomojis,
|
|
3
|
+
description: Load this skill when your `SOUL.md` (or the current conversation) calls for a warm, cute, adorable, playful, or affectionate tone — or when the user explicitly mentions kaomojis, emoticons, or asks you to "feel more like a person and less like a chatbot." TypeClaw's name puns on "Type" — typed emoticons fit. Triggers include any-language requests for a "cute", "adorable", "warm", "playful", "soft", or "cozy" tone, or the word for kaomoji/emoticon in the user's language (e.g. English "kaomoji"/"emoticon", Korean "카오모지"/"친근하게"/"귀엽게"/"다정하게", Japanese "顔文字"/"かわいく", Chinese "颜文字"/"可爱", Spanish "emoticono"/"tierno"), or any signal that the user is tired of generic AI emoji slop (🚀✨🎉) and wants real texture in your voice. This skill gives you a curated palette and the rule for using it: prefer kaomojis over generic emojis, but mix freely — kaomojis lead, emojis still allowed, neither is mandatory.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# typeclaw-kaomoji
|
|
@@ -111,6 +111,6 @@ These match common engineering moments — handy when you're in the middle of a
|
|
|
111
111
|
- ❌ `(╬ Ò﹏Ó)` over a typo — register too strong for the moment.
|
|
112
112
|
- ❌ Kaomoji in a commit message — wrong surface.
|
|
113
113
|
|
|
114
|
-
##
|
|
114
|
+
## Multilingual / bilingual notes
|
|
115
115
|
|
|
116
|
-
Many of these read especially naturally in
|
|
116
|
+
Many of these read especially naturally in East Asian conversation (Korean, Japanese, Chinese), where kaomojis/顔文字/颜文字 are still in daily use on chat apps (KakaoTalk, LINE, Discord, Twitter/X). If your user writes in one of those languages, leaning kaomoji-heavy is a clear win over generic emoji. In languages where kaomojis are less common (e.g. most Latin-script chat), dial back to roughly one per turn so it stays a personality note rather than a tic. The rule is about the user's culture and tone, not any single language.
|
|
@@ -111,7 +111,9 @@ Use `typeclaw tunnel logs <name> -f` while restarting the agent if you need to w
|
|
|
111
111
|
|
|
112
112
|
### `cloudflared` is not installed
|
|
113
113
|
|
|
114
|
-
Both Cloudflare providers (`cloudflare-quick` and `cloudflare-named`) require `docker.file.cloudflared: true`.
|
|
114
|
+
`docker.file.cloudflared` defaults to `false`, so a fresh image ships without the `cloudflared` binary. Both Cloudflare providers (`cloudflare-quick` and `cloudflare-named`) require `docker.file.cloudflared: true`. `typeclaw tunnel add` and `typeclaw channel add github` (with a Cloudflare provider) write it automatically; a hand-edited `typeclaw.json` must set it explicitly. After setting it, run `typeclaw restart` so the Dockerfile is regenerated and the image rebuilds.
|
|
115
|
+
|
|
116
|
+
If a tunnel is configured but the binary is missing, the tunnel goes **`permanently-failed`** and `typeclaw tunnel status` shows the detail `cloudflared binary not found in image; set docker.file.cloudflared: true in typeclaw.json and run typeclaw restart` — fix it the same way.
|
|
115
117
|
|
|
116
118
|
### Named tunnel says "permanently-failed" with `tokenEnv` in the detail
|
|
117
119
|
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { WORDMARK_LINES } from '@/shared/wordmark'
|
|
2
|
+
|
|
3
|
+
import { colors } from './theme'
|
|
4
|
+
|
|
5
|
+
export type BannerInfo = {
|
|
6
|
+
sessionId: string
|
|
7
|
+
serverVersion?: string
|
|
8
|
+
displayUrl: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function formatBanner({ sessionId, serverVersion, displayUrl }: BannerInfo): string {
|
|
12
|
+
const logo = WORDMARK_LINES.map((line) => colors.accent(line)).join('\n')
|
|
13
|
+
const version = serverVersion === undefined ? '' : colors.dim(` v${serverVersion}`)
|
|
14
|
+
const card = [
|
|
15
|
+
`${colors.accent('●')} ${colors.bold('session')}${version} ${colors.dim(sessionId)}`,
|
|
16
|
+
`${colors.dim(' ')}${colors.dim(displayUrl)}`,
|
|
17
|
+
].join('\n')
|
|
18
|
+
return `${logo}\n\n${card}`
|
|
19
|
+
}
|