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.
Files changed (78) hide show
  1. package/package.json +1 -1
  2. package/src/agent/index.ts +37 -5
  3. package/src/agent/loop-guard.ts +112 -26
  4. package/src/agent/plugin-tools.ts +102 -41
  5. package/src/agent/session-origin.ts +3 -3
  6. package/src/agent/subagents.ts +7 -0
  7. package/src/agent/system-prompt.ts +29 -4
  8. package/src/agent/tools/channel-reply.ts +1 -0
  9. package/src/agent/tools/channel-send.ts +2 -1
  10. package/src/agent/tools/spawn-subagent.ts +21 -0
  11. package/src/agent/tools/subagent-output.ts +7 -3
  12. package/src/agent/tools/wikipedia.ts +1 -1
  13. package/src/bundled-plugins/explorer/explorer.ts +2 -0
  14. package/src/bundled-plugins/github-cli-auth/approve-idempotency.ts +74 -0
  15. package/src/bundled-plugins/github-cli-auth/effective-approval.ts +98 -0
  16. package/src/bundled-plugins/github-cli-auth/gh-review-inline-detect.ts +130 -0
  17. package/src/bundled-plugins/github-cli-auth/index.ts +27 -2
  18. package/src/bundled-plugins/github-cli-auth/review-recorder.ts +12 -4
  19. package/src/bundled-plugins/memory/memory-logger.ts +3 -3
  20. package/src/bundled-plugins/operator/operator.ts +2 -0
  21. package/src/bundled-plugins/planner/index.ts +11 -0
  22. package/src/bundled-plugins/planner/planner.ts +282 -0
  23. package/src/bundled-plugins/planner/skills/general.ts +65 -0
  24. package/src/bundled-plugins/planner/skills/project.ts +69 -0
  25. package/src/bundled-plugins/researcher/index.ts +11 -0
  26. package/src/bundled-plugins/researcher/researcher.ts +226 -0
  27. package/src/bundled-plugins/researcher/skills/general.ts +105 -0
  28. package/src/bundled-plugins/researcher/write-report.ts +107 -0
  29. package/src/bundled-plugins/reviewer/reviewer.ts +29 -11
  30. package/src/bundled-plugins/reviewer/skills/data-review.ts +77 -0
  31. package/src/bundled-plugins/reviewer/skills/doc-review.ts +79 -0
  32. package/src/bundled-plugins/reviewer/skills/general.ts +1 -1
  33. package/src/bundled-plugins/reviewer/skills/plan-review.ts +64 -0
  34. package/src/bundled-plugins/reviewer/skills/security-audit.ts +70 -0
  35. package/src/bundled-plugins/reviewer/skills/writing-review.ts +63 -0
  36. package/src/bundled-plugins/scout/scout.ts +2 -0
  37. package/src/bundled-plugins/security/policies/prompt-injection.ts +8 -4
  38. package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +3 -2
  39. package/src/channels/adapters/discord-bot.ts +38 -11
  40. package/src/channels/adapters/github/inbound.ts +74 -9
  41. package/src/channels/adapters/github/index.ts +36 -11
  42. package/src/channels/adapters/github/reconcile-open-prs.ts +306 -0
  43. package/src/channels/adapters/github/review-state.ts +71 -2
  44. package/src/channels/adapters/kakaotalk-classify.ts +2 -2
  45. package/src/channels/adapters/kakaotalk.ts +2 -2
  46. package/src/channels/adapters/slack-bot-classify.ts +1 -1
  47. package/src/channels/adapters/slack-bot.ts +3 -0
  48. package/src/channels/adapters/telegram-bot.ts +3 -0
  49. package/src/channels/engagement.ts +12 -7
  50. package/src/channels/github-rereview-guard.ts +32 -8
  51. package/src/channels/github-review-claim.ts +53 -6
  52. package/src/channels/router.ts +44 -9
  53. package/src/channels/schema.ts +4 -3
  54. package/src/channels/types.ts +17 -6
  55. package/src/cli/init.ts +13 -2
  56. package/src/cli/ui.ts +64 -0
  57. package/src/config/config.ts +21 -15
  58. package/src/container/start.ts +5 -1
  59. package/src/init/dockerfile.ts +19 -56
  60. package/src/init/hatching.ts +1 -1
  61. package/src/init/index.ts +5 -1
  62. package/src/run/bundled-plugins.ts +4 -0
  63. package/src/server/index.ts +24 -5
  64. package/src/shared/host-locale.ts +27 -0
  65. package/src/shared/protocol.ts +1 -1
  66. package/src/shared/wordmark.ts +19 -0
  67. package/src/skills/typeclaw-channel-github/SKILL.md +1 -1
  68. package/src/skills/typeclaw-config/SKILL.md +32 -32
  69. package/src/skills/typeclaw-kaomoji/SKILL.md +3 -3
  70. package/src/skills/typeclaw-tunnels/SKILL.md +3 -1
  71. package/src/tui/banner.ts +19 -0
  72. package/src/tui/format.ts +34 -0
  73. package/src/tui/index.ts +121 -22
  74. package/src/tui/theme.ts +26 -1
  75. package/src/tunnels/providers/cloudflare-named.ts +15 -4
  76. package/src/tunnels/providers/cloudflare-quick.ts +15 -4
  77. package/src/tunnels/providers/cloudflared-binary.ts +11 -0
  78. package/typeclaw.schema.json +15 -7
@@ -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' | 'cjkFonts' | 'xvfb', AptFeature> = {
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 toggleAptArgs = collectToggleAptArgs(config)
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: true,
1514
- cloudflared: true,
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', 'cjkFonts', 'xvfb'] as const) {
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
 
@@ -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 is in Korean asking for 친근/귀엽/다정한 tone, 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.
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, { baseImageVersion: resolveBaseImageVersion(root) }),
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
  ]
@@ -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, session, logger, sessionFileId)
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, { type: 'done' })
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, session: AgentSession, logger: ServerLogger, sessionFileId: string): void {
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, { type: 'done' })
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
+ }
@@ -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: true, cloudflared: true, 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. |
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. `"봉봉"` 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. `"Bongbong"` in the alias list matches `"BONGBONG"`, `"bongbong"`, `"BongBong"`.
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 (`"봉봉아 펭펭아 둘 다 봐"`) engages both bots — each on their own alias.
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 `"펭펭아 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.
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": ["bongbong", "봉봉"]
240
+ "alias": ["toto", "토토"]
241
241
  }
242
242
  ```
243
243
 
244
- The agent in folder `봉봉/` already answers to `"봉봉"` from the dir name. This adds the Latin transliteration so users can also write `"Hey bongbong, deploy?"`.
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 `bongbong` and the user wants `Bongbong` casing — the implicit dir alias matches case-insensitively, so this isn't needed either).
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 | 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 | Default `true`. Installs `fonts-noto-cjk` (~56 MB) so Chromium (used by `agent-browser`) renders Korean/Japanese/Chinese glyphs correctly in screenshots, `page.pdf()`, and other raster output. `false` skips the layer entirely (DOM/innerText scraping is unaffected by font absence — only raster output shows tofu boxes). Boolean-only: the package is a metapackage tracking upstream Noto, no useful apt pin. |
358
- | `cloudflared` | no | boolean | Default `true`. Downloads the pinned `cloudflared` GitHub release (~35 MB) into the image so `cloudflare-quick` tunnels work on the next `start` without a separate Dockerfile edit. `false` skips the layer entirely on agents that don't use tunnels. 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`. |
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 `=`); `python`, `cjkFonts`, `cloudflared`, `claudeCode`, and `codexCli` are boolean only
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, 카오모지, ASCII 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 the words "cute", "adorable", "warm", "playful", "soft", "cozy", "친근하게", "귀엽게", "다정하게", "카오모지", 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.
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
- ## Korean / bilingual notes
114
+ ## Multilingual / bilingual notes
115
115
 
116
- Many of these read especially naturally in Korean conversation, where kaomojis are still in daily use (KakaoTalk, Discord, Twitter). If your user writes in Korean, leaning kaomoji-heavy is a clear win over generic emoji. If they write in English, dial back to roughly one per turn so it stays a personality note rather than a tic.
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`. If it is missing, `typeclaw tunnel add` writes it automatically; otherwise add it to `typeclaw.json` by hand and run `typeclaw restart` so the Dockerfile is regenerated and the image rebuilds.
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
+ }