typeclaw 0.28.2 → 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 (70) 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-send.ts +1 -1
  9. package/src/agent/tools/spawn-subagent.ts +21 -0
  10. package/src/agent/tools/subagent-output.ts +7 -3
  11. package/src/agent/tools/wikipedia.ts +1 -1
  12. package/src/bundled-plugins/explorer/explorer.ts +2 -0
  13. package/src/bundled-plugins/github-cli-auth/approve-idempotency.ts +74 -0
  14. package/src/bundled-plugins/github-cli-auth/effective-approval.ts +98 -0
  15. package/src/bundled-plugins/github-cli-auth/gh-review-inline-detect.ts +130 -0
  16. package/src/bundled-plugins/github-cli-auth/index.ts +27 -2
  17. package/src/bundled-plugins/github-cli-auth/review-recorder.ts +12 -4
  18. package/src/bundled-plugins/memory/memory-logger.ts +3 -3
  19. package/src/bundled-plugins/operator/operator.ts +2 -0
  20. package/src/bundled-plugins/planner/index.ts +11 -0
  21. package/src/bundled-plugins/planner/planner.ts +282 -0
  22. package/src/bundled-plugins/planner/skills/general.ts +65 -0
  23. package/src/bundled-plugins/planner/skills/project.ts +69 -0
  24. package/src/bundled-plugins/researcher/index.ts +11 -0
  25. package/src/bundled-plugins/researcher/researcher.ts +226 -0
  26. package/src/bundled-plugins/researcher/skills/general.ts +105 -0
  27. package/src/bundled-plugins/researcher/write-report.ts +107 -0
  28. package/src/bundled-plugins/reviewer/reviewer.ts +26 -8
  29. package/src/bundled-plugins/reviewer/skills/data-review.ts +77 -0
  30. package/src/bundled-plugins/reviewer/skills/doc-review.ts +79 -0
  31. package/src/bundled-plugins/reviewer/skills/plan-review.ts +64 -0
  32. package/src/bundled-plugins/reviewer/skills/security-audit.ts +70 -0
  33. package/src/bundled-plugins/reviewer/skills/writing-review.ts +63 -0
  34. package/src/bundled-plugins/scout/scout.ts +2 -0
  35. package/src/bundled-plugins/security/policies/prompt-injection.ts +8 -4
  36. package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +3 -2
  37. package/src/channels/adapters/discord-bot.ts +38 -11
  38. package/src/channels/adapters/github/inbound.ts +68 -4
  39. package/src/channels/adapters/kakaotalk-classify.ts +2 -2
  40. package/src/channels/adapters/kakaotalk.ts +2 -2
  41. package/src/channels/adapters/slack-bot-classify.ts +1 -1
  42. package/src/channels/adapters/slack-bot.ts +3 -0
  43. package/src/channels/adapters/telegram-bot.ts +3 -0
  44. package/src/channels/engagement.ts +12 -7
  45. package/src/channels/router.ts +32 -9
  46. package/src/channels/schema.ts +1 -1
  47. package/src/channels/types.ts +6 -0
  48. package/src/cli/init.ts +13 -2
  49. package/src/cli/ui.ts +64 -0
  50. package/src/config/config.ts +21 -15
  51. package/src/container/start.ts +5 -1
  52. package/src/init/dockerfile.ts +19 -56
  53. package/src/init/hatching.ts +1 -1
  54. package/src/init/index.ts +5 -1
  55. package/src/run/bundled-plugins.ts +4 -0
  56. package/src/server/index.ts +24 -5
  57. package/src/shared/host-locale.ts +27 -0
  58. package/src/shared/protocol.ts +1 -1
  59. package/src/shared/wordmark.ts +19 -0
  60. package/src/skills/typeclaw-config/SKILL.md +32 -32
  61. package/src/skills/typeclaw-kaomoji/SKILL.md +3 -3
  62. package/src/skills/typeclaw-tunnels/SKILL.md +3 -1
  63. package/src/tui/banner.ts +19 -0
  64. package/src/tui/format.ts +34 -0
  65. package/src/tui/index.ts +121 -22
  66. package/src/tui/theme.ts +26 -1
  67. package/src/tunnels/providers/cloudflare-named.ts +15 -4
  68. package/src/tunnels/providers/cloudflare-quick.ts +15 -4
  69. package/src/tunnels/providers/cloudflared-binary.ts +11 -0
  70. package/typeclaw.schema.json +15 -7
@@ -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'
@@ -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
+ }
package/src/tui/format.ts CHANGED
@@ -37,6 +37,40 @@ export function withTimestamp(ts: number | undefined, body: string): string {
37
37
  return `${formatTimestamp(ts)} ${body}`
38
38
  }
39
39
 
40
+ // Open-box top rule for an assistant turn. Only a header (no closing rule) so
41
+ // the look survives streaming Markdown whose end is unknown at render time.
42
+ export function formatAssistantHeader(ts: number | undefined): string {
43
+ const clock = plainTimestamp(ts)
44
+ const label = `╭─ typeclaw · ${clock} `
45
+ return colors.accent(`${label}${'─'.repeat(ASSISTANT_HEADER_RULE_WIDTH)}`)
46
+ }
47
+
48
+ const ASSISTANT_HEADER_RULE_WIDTH = 8
49
+
50
+ function plainTimestamp(ts: number | undefined): string {
51
+ if (ts === undefined || ts === 0) return '--:--:--'
52
+ const d = new Date(ts)
53
+ const hh = String(d.getHours()).padStart(2, '0')
54
+ const mm = String(d.getMinutes()).padStart(2, '0')
55
+ const ss = String(d.getSeconds()).padStart(2, '0')
56
+ return `${hh}:${mm}:${ss}`
57
+ }
58
+
59
+ export function formatTokenCount(tokens: number): string {
60
+ if (tokens < 1000) return `${tokens}`
61
+ const k = tokens / 1000
62
+ const value = k < 100 ? k.toFixed(1) : String(Math.round(k))
63
+ return `${value.endsWith('.0') ? value.slice(0, -2) : value}k`
64
+ }
65
+
66
+ export function formatCost(cost: number): string {
67
+ return `$${cost.toFixed(4)}`
68
+ }
69
+
70
+ export function formatUsageSummary(usage: { totalTokens: number; cost: number }): string {
71
+ return `⚡ ${formatTokenCount(usage.totalTokens)} tok · ${formatCost(usage.cost)}`
72
+ }
73
+
40
74
  function stripHiddenBlocks(text: string): string {
41
75
  return text.replace(/<hatching>[\s\S]*?<\/hatching>\s*/g, '').trimStart()
42
76
  }