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.
- package/package.json +1 -1
- package/src/agent/index.ts +37 -5
- package/src/agent/loop-guard.ts +112 -26
- package/src/agent/plugin-tools.ts +102 -41
- package/src/agent/session-origin.ts +3 -3
- package/src/agent/subagents.ts +7 -0
- package/src/agent/system-prompt.ts +29 -4
- package/src/agent/tools/channel-send.ts +1 -1
- package/src/agent/tools/spawn-subagent.ts +21 -0
- package/src/agent/tools/subagent-output.ts +7 -3
- package/src/agent/tools/wikipedia.ts +1 -1
- package/src/bundled-plugins/explorer/explorer.ts +2 -0
- package/src/bundled-plugins/github-cli-auth/approve-idempotency.ts +74 -0
- package/src/bundled-plugins/github-cli-auth/effective-approval.ts +98 -0
- package/src/bundled-plugins/github-cli-auth/gh-review-inline-detect.ts +130 -0
- package/src/bundled-plugins/github-cli-auth/index.ts +27 -2
- package/src/bundled-plugins/github-cli-auth/review-recorder.ts +12 -4
- package/src/bundled-plugins/memory/memory-logger.ts +3 -3
- package/src/bundled-plugins/operator/operator.ts +2 -0
- package/src/bundled-plugins/planner/index.ts +11 -0
- package/src/bundled-plugins/planner/planner.ts +282 -0
- package/src/bundled-plugins/planner/skills/general.ts +65 -0
- package/src/bundled-plugins/planner/skills/project.ts +69 -0
- package/src/bundled-plugins/researcher/index.ts +11 -0
- package/src/bundled-plugins/researcher/researcher.ts +226 -0
- package/src/bundled-plugins/researcher/skills/general.ts +105 -0
- package/src/bundled-plugins/researcher/write-report.ts +107 -0
- package/src/bundled-plugins/reviewer/reviewer.ts +26 -8
- package/src/bundled-plugins/reviewer/skills/data-review.ts +77 -0
- package/src/bundled-plugins/reviewer/skills/doc-review.ts +79 -0
- package/src/bundled-plugins/reviewer/skills/plan-review.ts +64 -0
- package/src/bundled-plugins/reviewer/skills/security-audit.ts +70 -0
- package/src/bundled-plugins/reviewer/skills/writing-review.ts +63 -0
- package/src/bundled-plugins/scout/scout.ts +2 -0
- package/src/bundled-plugins/security/policies/prompt-injection.ts +8 -4
- package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +3 -2
- package/src/channels/adapters/discord-bot.ts +38 -11
- package/src/channels/adapters/github/inbound.ts +68 -4
- package/src/channels/adapters/kakaotalk-classify.ts +2 -2
- package/src/channels/adapters/kakaotalk.ts +2 -2
- package/src/channels/adapters/slack-bot-classify.ts +1 -1
- package/src/channels/adapters/slack-bot.ts +3 -0
- package/src/channels/adapters/telegram-bot.ts +3 -0
- package/src/channels/engagement.ts +12 -7
- package/src/channels/router.ts +32 -9
- package/src/channels/schema.ts +1 -1
- package/src/channels/types.ts +6 -0
- package/src/cli/init.ts +13 -2
- package/src/cli/ui.ts +64 -0
- package/src/config/config.ts +21 -15
- package/src/container/start.ts +5 -1
- package/src/init/dockerfile.ts +19 -56
- package/src/init/hatching.ts +1 -1
- package/src/init/index.ts +5 -1
- package/src/run/bundled-plugins.ts +4 -0
- package/src/server/index.ts +24 -5
- package/src/shared/host-locale.ts +27 -0
- package/src/shared/protocol.ts +1 -1
- package/src/shared/wordmark.ts +19 -0
- package/src/skills/typeclaw-config/SKILL.md +32 -32
- package/src/skills/typeclaw-kaomoji/SKILL.md +3 -3
- package/src/skills/typeclaw-tunnels/SKILL.md +3 -1
- package/src/tui/banner.ts +19 -0
- package/src/tui/format.ts +34 -0
- package/src/tui/index.ts +121 -22
- package/src/tui/theme.ts +26 -1
- package/src/tunnels/providers/cloudflare-named.ts +15 -4
- package/src/tunnels/providers/cloudflare-quick.ts +15 -4
- package/src/tunnels/providers/cloudflared-binary.ts +11 -0
- package/typeclaw.schema.json +15 -7
package/src/server/index.ts
CHANGED
|
@@ -184,6 +184,10 @@ type SessionState = {
|
|
|
184
184
|
// not re-target this session's lifecycle hooks.
|
|
185
185
|
runtimeSnapshot: PluginRuntimeState | null
|
|
186
186
|
unsubTurnOutcome: Unsubscribe | null
|
|
187
|
+
// Latest turn's usage, captured from `message_end` by forwardSessionEvents and
|
|
188
|
+
// read at the `done` send site (which lives outside that subscriber). Reset at
|
|
189
|
+
// each turn start so a turn with no usage event sends a plain `done`.
|
|
190
|
+
lastUsage: { input: number; output: number; totalTokens: number; cost: number } | null
|
|
187
191
|
dispose: () => Promise<void>
|
|
188
192
|
}
|
|
189
193
|
|
|
@@ -520,6 +524,7 @@ export function createServer({
|
|
|
520
524
|
activeClaimCode: null,
|
|
521
525
|
runtimeSnapshot: runtimeSnapshot ?? null,
|
|
522
526
|
unsubTurnOutcome: null,
|
|
527
|
+
lastUsage: null,
|
|
523
528
|
dispose,
|
|
524
529
|
}
|
|
525
530
|
sessionStates.set(ws, state)
|
|
@@ -533,7 +538,7 @@ export function createServer({
|
|
|
533
538
|
}
|
|
534
539
|
|
|
535
540
|
liveSessionRegistry?.register({ sessionId: sessionFileId, session })
|
|
536
|
-
forwardSessionEvents(ws,
|
|
541
|
+
forwardSessionEvents(ws, state, logger, sessionFileId)
|
|
537
542
|
|
|
538
543
|
if (stream) {
|
|
539
544
|
state.unsubPrompts = stream.subscribe({ target: { kind: 'session', sessionId: sessionFileId } }, (msg) =>
|
|
@@ -759,9 +764,10 @@ export function createServer({
|
|
|
759
764
|
origin: state.origin,
|
|
760
765
|
})
|
|
761
766
|
}
|
|
767
|
+
state.lastUsage = null
|
|
762
768
|
try {
|
|
763
769
|
await state.session.prompt(`${renderTurnTimeAnchor()}\n\n${msg.text}`)
|
|
764
|
-
send(ws,
|
|
770
|
+
send(ws, doneMessage(state))
|
|
765
771
|
} catch (err) {
|
|
766
772
|
const message = err instanceof Error ? err.message : String(err)
|
|
767
773
|
logger.error(`[server] ${state.sessionFileId}: prompt failed: ${message}`)
|
|
@@ -859,10 +865,10 @@ function isWebSocketUpgrade(req: Request): boolean {
|
|
|
859
865
|
return req.headers.get('upgrade')?.toLowerCase() === 'websocket'
|
|
860
866
|
}
|
|
861
867
|
|
|
862
|
-
function forwardSessionEvents(ws: Ws,
|
|
868
|
+
function forwardSessionEvents(ws: Ws, state: SessionState, logger: ServerLogger, sessionFileId: string): void {
|
|
863
869
|
const toolStartedAt = new Map<string, number>()
|
|
864
870
|
|
|
865
|
-
session.subscribe((event) => {
|
|
871
|
+
state.session.subscribe((event) => {
|
|
866
872
|
switch (event.type) {
|
|
867
873
|
case 'message_update':
|
|
868
874
|
if (event.assistantMessageEvent.type === 'text_delta') {
|
|
@@ -877,6 +883,7 @@ function forwardSessionEvents(ws: Ws, session: AgentSession, logger: ServerLogge
|
|
|
877
883
|
// because no text deltas were ever emitted, which looks like a freeze.
|
|
878
884
|
// The server's existing try/catch around `session.prompt()` only
|
|
879
885
|
// catches throws, so it never sees these.
|
|
886
|
+
state.lastUsage = readDoneUsage(event.message)
|
|
880
887
|
forwardAssistantError(ws, event.message, logger, sessionFileId)
|
|
881
888
|
break
|
|
882
889
|
case 'tool_execution_start':
|
|
@@ -1048,9 +1055,10 @@ async function drain(
|
|
|
1048
1055
|
}
|
|
1049
1056
|
|
|
1050
1057
|
await fireTurnStart(item.text)
|
|
1058
|
+
state.lastUsage = null
|
|
1051
1059
|
try {
|
|
1052
1060
|
await state.session.prompt(`${renderTurnTimeAnchor()}\n\n${item.text}`)
|
|
1053
|
-
send(ws,
|
|
1061
|
+
send(ws, doneMessage(state))
|
|
1054
1062
|
} catch (err) {
|
|
1055
1063
|
const message = err instanceof Error ? err.message : String(err)
|
|
1056
1064
|
logger.error(`[server] ${state.sessionFileId}: prompt failed: ${message}`)
|
|
@@ -1445,6 +1453,17 @@ function buildMessageEndPayload(sessionId: string, message: unknown): InspectFra
|
|
|
1445
1453
|
return payload
|
|
1446
1454
|
}
|
|
1447
1455
|
|
|
1456
|
+
function doneMessage(state: SessionState): ServerMessage {
|
|
1457
|
+
return state.lastUsage === null ? { type: 'done' } : { type: 'done', usage: state.lastUsage }
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
function readDoneUsage(message: unknown): { input: number; output: number; totalTokens: number; cost: number } | null {
|
|
1461
|
+
if (typeof message !== 'object' || message === null) return null
|
|
1462
|
+
const usage = readMessageUsage((message as Record<string, unknown>).usage)
|
|
1463
|
+
if (usage === null) return null
|
|
1464
|
+
return { input: usage.input, output: usage.output, totalTokens: usage.totalTokens, cost: usage.cost }
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1448
1467
|
function readMessageUsage(
|
|
1449
1468
|
value: unknown,
|
|
1450
1469
|
): { input: number; output: number; cacheRead: number; cacheWrite: number; totalTokens: number; cost: number } | null {
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
const CJK_LANGUAGE_PREFIXES = ['ja', 'ko', 'zh'] as const
|
|
2
|
+
|
|
3
|
+
function languageTagIsCjk(tag: string): boolean {
|
|
4
|
+
const primary = tag.toLowerCase().replace(/_/g, '-').split('-')[0] ?? ''
|
|
5
|
+
return CJK_LANGUAGE_PREFIXES.some((prefix) => primary === prefix)
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// True when the HOST's locale is Chinese/Japanese/Korean. POSIX precedence:
|
|
9
|
+
// LC_ALL overrides LC_CTYPE overrides LANG. Values look like `ja_JP.UTF-8`,
|
|
10
|
+
// `ko_KR`, `zh-Hans`. `C`/`POSIX`/empty fall through to Intl, which on macOS
|
|
11
|
+
// (where these env vars are usually unset) reports the user's system locale.
|
|
12
|
+
// Returns false if nothing resolves — the conservative choice, since the
|
|
13
|
+
// caller uses this to decide whether to add the ~89MB CJK font package.
|
|
14
|
+
export function hostLocaleIsCjk(): boolean {
|
|
15
|
+
for (const envVar of ['LC_ALL', 'LC_CTYPE', 'LANG']) {
|
|
16
|
+
const value = process.env[envVar]
|
|
17
|
+
if (value === undefined || value === '') continue
|
|
18
|
+
if (value === 'C' || value === 'POSIX') return false
|
|
19
|
+
return languageTagIsCjk(value)
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
const locale = Intl.DateTimeFormat().resolvedOptions().locale
|
|
23
|
+
return locale !== undefined && locale !== '' && languageTagIsCjk(locale)
|
|
24
|
+
} catch {
|
|
25
|
+
return false
|
|
26
|
+
}
|
|
27
|
+
}
|
package/src/shared/protocol.ts
CHANGED
|
@@ -223,7 +223,7 @@ export type ServerMessage =
|
|
|
223
223
|
durationMs: number
|
|
224
224
|
ts?: number
|
|
225
225
|
}
|
|
226
|
-
| { type: 'done'; ts?: number }
|
|
226
|
+
| { type: 'done'; ts?: number; usage?: { input: number; output: number; totalTokens: number; cost: number } }
|
|
227
227
|
| { type: 'error'; message: string; ts?: number }
|
|
228
228
|
| { type: 'reload_result'; results: ReloadResultPayload[] }
|
|
229
229
|
| { type: 'restart_result'; status: 'accepted' | 'failed'; message?: string; error?: string }
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Single source of truth for the "typeclaw" wordmark so the TUI banner and the
|
|
2
|
+
// init CLI render the same art instead of drifting apart. This module is
|
|
3
|
+
// color-agnostic: each consumer applies its own coloring layer (the TUI emits
|
|
4
|
+
// raw truecolor; init gates color behind NO_COLOR/TTY), so the art here carries
|
|
5
|
+
// zero escape sequences.
|
|
6
|
+
|
|
7
|
+
// ANSI Shadow "typeclaw". Trailing whitespace is significant for alignment.
|
|
8
|
+
export const WORDMARK_LINES: readonly string[] = [
|
|
9
|
+
'████████╗██╗ ██╗██████╗ ███████╗ ██████╗██╗ █████╗ ██╗ ██╗',
|
|
10
|
+
'╚══██╔══╝╚██╗ ██╔╝██╔══██╗██╔════╝██╔════╝██║ ██╔══██╗██║ ██║',
|
|
11
|
+
' ██║ ╚████╔╝ ██████╔╝█████╗ ██║ ██║ ███████║██║ █╗ ██║',
|
|
12
|
+
' ██║ ╚██╔╝ ██╔═══╝ ██╔══╝ ██║ ██║ ██╔══██║██║███╗██║',
|
|
13
|
+
' ██║ ██║ ██║ ███████╗╚██████╗███████╗██║ ██║╚███╔███╔╝',
|
|
14
|
+
' ╚═╝ ╚═╝ ╚═╝ ╚══════╝ ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝ ',
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
export const WORDMARK_WIDTH: number = Math.max(...WORDMARK_LINES.map((line) => line.length))
|
|
18
|
+
|
|
19
|
+
export const COMPACT_WORDMARK = 'typeclaw'
|
|
@@ -39,18 +39,18 @@ You yourself cannot run `typeclaw restart` — that is a host-stage command and
|
|
|
39
39
|
|
|
40
40
|
`typeclaw.json` is a single JSON object with these fields:
|
|
41
41
|
|
|
42
|
-
| Field | Required | Type | Notes
|
|
43
|
-
| ------------- | -------- | ---------------- |
|
|
44
|
-
| `$schema` | no | string | Path to `typeclaw.schema.json` for editor autocompletion. Scaffolded as `./node_modules/typeclaw/typeclaw.schema.json`. Leave it alone unless the user moves it.
|
|
45
|
-
| `port` | no | integer | 1–65535. Defaults to `8973` (T9 spelling of "TYPE"). Change only if the default collides with something on the user's host. **Restart-required.**
|
|
46
|
-
| `model` | no | string | Must be one of the values listed in the **Allowed models** section below. Defaults to `openai/gpt-5.4-nano`. **Live-reloadable.**
|
|
47
|
-
| `mounts` | no | array of objects | Host directories bind-mounted into your container. Defaults to `[]` (no host paths exposed). Omitted from scaffolded `typeclaw.json` — add it only when the user wants host paths exposed. See **Mounts** section below. **Restart-required.**
|
|
48
|
-
| `plugins` | no | array of strings | Plugin package names loaded at server boot. Defaults to `[]`. **Restart-required.** Plugin-owned config blocks live alongside as additional top-level keys; see **Plugin config blocks**.
|
|
49
|
-
| `alias` | no | array of strings | Additional names the agent answers to in channel engagement, on top of the implicit `basename(agentDir)`. Each entry is a non-empty trimmed string matched case-insensitively as a substring of the inbound text. Defaults to `[]`. Hatching populates this with the agent's chosen name. See **Alias** section below. **Live-reloadable.**
|
|
50
|
-
| `channels` | no | object | Per-adapter engagement triggers and history-prefetch knobs for external messengers. Defaults to `{}` (no adapters configured). `typeclaw init` scaffolds an empty block per requested adapter (e.g. `"discord-bot": {}`) and the schema fills in defaults. Channel access control lives in `roles` — see the `typeclaw-permissions` skill. **Live-reloadable.** See **Channels** section below.
|
|
51
|
-
| `portForward` | no | object | Allow/deny policy for the host-stage portbroker that auto-forwards container LISTEN ports to `127.0.0.1` on the host. Defaults to `{ "allow": "*" }` (forward everything). Omitted from scaffolded `typeclaw.json`. **Restart-required.** See **portForward** section below.
|
|
52
|
-
| `docker` | no | object | Namespace for Docker-related blocks. Today the only child is `docker.file` — toggles (`tmux`, `gh`, `python`, `ffmpeg`, `cjkFonts`, `cloudflared`, `claudeCode`, `codexCli`) gate opinionated package installs; `append` adds custom Dockerfile lines just before `ENTRYPOINT`. `docker.file` defaults to `{ ffmpeg: false, gh: true, python: true, tmux: true, cjkFonts:
|
|
53
|
-
| `git` | no | object | Namespace for git-related blocks. Today the only child is `git.ignore` — extra patterns spliced into the autogenerated `.gitignore` before TypeClaw's protected rules. `git.ignore` defaults to `{ "append": [] }`. Omitted from scaffolded `typeclaw.json`. **Restart-required** (next `typeclaw start` refreshes `.gitignore`). See **Gitignore** section below.
|
|
42
|
+
| Field | Required | Type | Notes |
|
|
43
|
+
| ------------- | -------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
44
|
+
| `$schema` | no | string | Path to `typeclaw.schema.json` for editor autocompletion. Scaffolded as `./node_modules/typeclaw/typeclaw.schema.json`. Leave it alone unless the user moves it. |
|
|
45
|
+
| `port` | no | integer | 1–65535. Defaults to `8973` (T9 spelling of "TYPE"). Change only if the default collides with something on the user's host. **Restart-required.** |
|
|
46
|
+
| `model` | no | string | Must be one of the values listed in the **Allowed models** section below. Defaults to `openai/gpt-5.4-nano`. **Live-reloadable.** |
|
|
47
|
+
| `mounts` | no | array of objects | Host directories bind-mounted into your container. Defaults to `[]` (no host paths exposed). Omitted from scaffolded `typeclaw.json` — add it only when the user wants host paths exposed. See **Mounts** section below. **Restart-required.** |
|
|
48
|
+
| `plugins` | no | array of strings | Plugin package names loaded at server boot. Defaults to `[]`. **Restart-required.** Plugin-owned config blocks live alongside as additional top-level keys; see **Plugin config blocks**. |
|
|
49
|
+
| `alias` | no | array of strings | Additional names the agent answers to in channel engagement, on top of the implicit `basename(agentDir)`. Each entry is a non-empty trimmed string matched case-insensitively as a substring of the inbound text. Defaults to `[]`. Hatching populates this with the agent's chosen name. See **Alias** section below. **Live-reloadable.** |
|
|
50
|
+
| `channels` | no | object | Per-adapter engagement triggers and history-prefetch knobs for external messengers. Defaults to `{}` (no adapters configured). `typeclaw init` scaffolds an empty block per requested adapter (e.g. `"discord-bot": {}`) and the schema fills in defaults. Channel access control lives in `roles` — see the `typeclaw-permissions` skill. **Live-reloadable.** See **Channels** section below. |
|
|
51
|
+
| `portForward` | no | object | Allow/deny policy for the host-stage portbroker that auto-forwards container LISTEN ports to `127.0.0.1` on the host. Defaults to `{ "allow": "*" }` (forward everything). Omitted from scaffolded `typeclaw.json`. **Restart-required.** See **portForward** section below. |
|
|
52
|
+
| `docker` | no | object | Namespace for Docker-related blocks. Today the only child is `docker.file` — toggles (`tmux`, `gh`, `python`, `ffmpeg`, `cjkFonts`, `cloudflared`, `claudeCode`, `codexCli`) gate opinionated package installs; `append` adds custom Dockerfile lines just before `ENTRYPOINT`. `docker.file` defaults to `{ ffmpeg: false, gh: true, python: true, tmux: true, cjkFonts: 'auto', cloudflared: false, claudeCode: false, codexCli: false, append: [] }`. Omitted from scaffolded `typeclaw.json`. **Restart-required** (next `typeclaw start` rebuilds the image). See **Dockerfile** section below. |
|
|
53
|
+
| `git` | no | object | Namespace for git-related blocks. Today the only child is `git.ignore` — extra patterns spliced into the autogenerated `.gitignore` before TypeClaw's protected rules. `git.ignore` defaults to `{ "append": [] }`. Omitted from scaffolded `typeclaw.json`. **Restart-required** (next `typeclaw start` refreshes `.gitignore`). See **Gitignore** section below. |
|
|
54
54
|
|
|
55
55
|
> **Top-level keys not in this table are not "ignored unknowns" anymore** — they are reserved for **plugin config blocks**. The schema's `catchall(z.unknown())` preserves them, and the plugin loader hands each block to its owning plugin's `configSchema` for validation. The bundled memory plugin owns `memory` at the top level — see the `typeclaw-memory` skill for that block's semantics. Do not write a top-level key unless you know which plugin owns it.
|
|
56
56
|
|
|
@@ -223,32 +223,32 @@ The agent folder's directory name (`basename(agentDir)`) is **always** an implic
|
|
|
223
223
|
|
|
224
224
|
### Match semantics
|
|
225
225
|
|
|
226
|
-
- **Substring** match against the inbound text. `"
|
|
227
|
-
- **Case-insensitive** via `toLocaleLowerCase()` on both sides. `"
|
|
226
|
+
- **Substring** match against the inbound text. `"토토"` matches `"토토아 cron"`, `"토토씨 안녕"`, `"누가 토토을 불러"`, all of them. Korean particles aren't stripped — substring is enough because the bot name appears at the start of every particled form.
|
|
227
|
+
- **Case-insensitive** via `toLocaleLowerCase()` on both sides. `"Toto"` in the alias list matches `"TOTO"`, `"toto"`, `"ToTo"`.
|
|
228
228
|
- **No word-boundary detection.** A short or generic alias like `"bot"` will match every message containing `"robot"` or `"bottom"`. Pick distinctive names — the operator owns curation.
|
|
229
229
|
|
|
230
230
|
### Engagement priority
|
|
231
231
|
|
|
232
|
-
The alias path runs **after** explicit triggers (mention/reply/dm) and the sticky check. So a message with both an `<@id>` mention and an alias substring engages once, normally. A message with only the alias substring engages on the alias path. The alias path is **NOT suppressed by `mentionsOthers`**: addressing two bots in one message (`"
|
|
232
|
+
The alias path runs **after** explicit triggers (mention/reply/dm) and the sticky check. So a message with both an `<@id>` mention and an alias substring engages once, normally. A message with only the alias substring engages on the alias path. The alias path is **NOT suppressed by `mentionsOthers`**: addressing two bots in one message (`"토토아 라라아 둘 다 봐"`) engages both bots — each on their own alias.
|
|
233
233
|
|
|
234
|
-
There's also a symmetric **peer-name suppressor**: if the message contains a peer bot's observed display name (from `participants[]`, populated as peers speak in the channel) and **does not** contain any of this agent's aliases, the solo-human fallback is suppressed and the agent observes. This is what makes `"
|
|
234
|
+
There's also a symmetric **peer-name suppressor**: if the message contains a peer bot's observed display name (from `participants[]`, populated as peers speak in the channel) and **does not** contain any of this agent's aliases, the solo-human fallback is suppressed and the agent observes. This is what makes `"라라아 cron 좀"` in a 1-human-multi-bot channel correctly observe instead of all bots replying. First-time addressing of a never-seen peer slips through; the suppressor catches it after the peer's first message.
|
|
235
235
|
|
|
236
236
|
### Example
|
|
237
237
|
|
|
238
238
|
```json
|
|
239
239
|
{
|
|
240
|
-
"alias": ["
|
|
240
|
+
"alias": ["toto", "토토"]
|
|
241
241
|
}
|
|
242
242
|
```
|
|
243
243
|
|
|
244
|
-
The agent in folder
|
|
244
|
+
The agent in folder `토토/` already answers to `"토토"` from the dir name. This adds the Latin transliteration so users can also write `"Hey toto, deploy?"`.
|
|
245
245
|
|
|
246
246
|
### When the user asks "respond to my casual nickname for you" / "I want to call you X"
|
|
247
247
|
|
|
248
248
|
1. **Read `typeclaw.json`.**
|
|
249
249
|
2. **If `alias` exists**, append the new name (preserve existing entries; dedupe trivially — the runtime also dedupes).
|
|
250
250
|
3. **If `alias` is absent**, create it as `["<new name>"]`.
|
|
251
|
-
4. **You don't need to add the dir name** unless the new name IS a variation of the dir name itself (e.g. dir is `
|
|
251
|
+
4. **You don't need to add the dir name** unless the new name IS a variation of the dir name itself (e.g. dir is `toto` and the user wants `Toto` casing — the implicit dir alias matches case-insensitively, so this isn't needed either).
|
|
252
252
|
5. **Trim whitespace** before adding. The schema rejects empty/whitespace-only entries; the runtime trims surrounding whitespace from valid entries.
|
|
253
253
|
6. **Write, commit**: "Edited `alias` — live-reloadable. Run `reload` to pick up the change without restart."
|
|
254
254
|
|
|
@@ -348,18 +348,18 @@ The `docker.file` block has two layers of customization:
|
|
|
348
348
|
|
|
349
349
|
### Fields
|
|
350
350
|
|
|
351
|
-
| Field | Required | Type
|
|
352
|
-
| ------------- | -------- |
|
|
353
|
-
| `tmux` | no | boolean \| string
|
|
354
|
-
| `gh` | no | boolean \| string
|
|
355
|
-
| `python` | no | boolean
|
|
356
|
-
| `ffmpeg` | no | boolean \| string
|
|
357
|
-
| `cjkFonts` | no | boolean
|
|
358
|
-
| `cloudflared` | no | boolean
|
|
359
|
-
| `xvfb` | no | boolean
|
|
360
|
-
| `claudeCode` | no | boolean
|
|
361
|
-
| `codexCli` | no | boolean
|
|
362
|
-
| `append` | no | array of strings
|
|
351
|
+
| Field | Required | Type | Notes |
|
|
352
|
+
| ------------- | -------- | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
353
|
+
| `tmux` | no | boolean \| string | Default `true`. `false` omits tmux from the apt install. String pins the Debian package version (e.g. `"3.3a-3"` → `tmux=3.3a-3`). |
|
|
354
|
+
| `gh` | no | boolean \| string | Default `true`. `false` omits **both** the `gh` package and the GitHub CLI keyring bootstrap layer (skipping the network roundtrip on cold builds). String pins the version. |
|
|
355
|
+
| `python` | no | boolean | Default `true`. Fans out to `python3 python3-pip python3-venv python-is-python3` (the bundle that makes `python` and `pip` resolve correctly inside the container). Boolean-only — no version pin, because Debian's `python3` is a meta-package that doesn't accept a useful pin. |
|
|
356
|
+
| `ffmpeg` | no | boolean \| string | Default `false`. `true` apt-installs ffmpeg (~80 MB of codecs). String pins the version. |
|
|
357
|
+
| `cjkFonts` | no | boolean or `"auto"` | Default `"auto"`. Installs `fonts-noto-cjk` (~89 MB) so Chromium (used by `agent-browser`) renders Korean/Japanese/Chinese glyphs correctly in screenshots, `page.pdf()`, and other raster output. `"auto"` resolves at `typeclaw start` from the host locale (`LANG`/`LC_ALL`/`Intl`): a CJK host (ja/ko/zh) installs the fonts, any other host skips them. An explicit `true`/`false` forces the decision. `false` skips the layer entirely (DOM/innerText scraping is unaffected by font absence — only raster output shows tofu boxes). |
|
|
358
|
+
| `cloudflared` | no | boolean | Default `false`. Downloads the pinned `cloudflared` GitHub release (~38 MB) into the image so `cloudflare-quick` tunnels work. Default `false` skips the layer on agents that don't use tunnels; `typeclaw tunnel add` / `channel add github` with a Cloudflare provider flip it to `true` automatically and prompt for a restart, so the happy path needs no manual edit. If the binary is absent when a tunnel starts, the tunnel goes `permanently-failed` with a "set docker.file.cloudflared: true and run typeclaw restart" message. Boolean-only — pinning is owned by the typeclaw release. |
|
|
359
|
+
| `xvfb` | no | boolean | Default `true`. Installs `xvfb` (~5 MB) so the entrypoint shim can spawn a virtual X server and export `DISPLAY=:99`, giving headed Chrome (agent-browser `--headed`, headful Playwright) a real X11 display to defeat headless-mode WAF fingerprinting. `false` skips the layer; the shim self-heals (no `Xvfb` on PATH → execs the agent without `DISPLAY`). Boolean-only — xvfb tracks the upstream X server release with no useful apt pin. |
|
|
360
|
+
| `claudeCode` | no | boolean | Default `false`. `true` runs Anthropic's official `curl -fsSL https://claude.ai/install.sh \| bash` in a dedicated layer (between agent-browser and the entrypoint shim) and pre-seeds `~/.claude.json` to skip the TTY-only theme picker on first launch (without it the agent's `tmux send-keys` would be eaten by the picker). Not apt: no version-pin variant; the upstream installer manages channels via env vars. Pairs with the `typeclaw-claude-code` skill, which documents the auth + tmux-driven usage flow including how to clear the post-seed API-key/trust dialogs. |
|
|
361
|
+
| `codexCli` | no | boolean | Default `false`. `true` runs `bun install -g @openai/codex` in a dedicated layer (after `claudeCode`, before the entrypoint shim) and pre-writes `~/.codex/hooks.json` registering `SessionStart` + `Stop` hooks so the operator can detect turn boundaries the same way as Claude Code (sentinel files, `.session-id` discovery). Not apt: no version-pin variant. Codex CLI has NO theme picker so no onboarding seed is needed, but auth (`codex login` or `OPENAI_API_KEY`) and the per-project trust dialog are still required at runtime — handled by the `typeclaw-codex-cli` skill. |
|
|
362
|
+
| `append` | no | array of strings | Each entry is a single Dockerfile line — schema **rejects** entries containing `\n` or `\r`. Defaults to `[]`. Splice happens just before `ENTRYPOINT`, after `ENV NODE_ENV=production`. |
|
|
363
363
|
|
|
364
364
|
Toggle version strings reject whitespace and `=` (apt-injection guard) — pass just the version, not `pkg=ver`.
|
|
365
365
|
|
|
@@ -631,7 +631,7 @@ Never echo, log, or commit values from `secrets.json` or `.env`. Both are gitign
|
|
|
631
631
|
- `channels.<adapter>.allow` (legacy) is silently ignored on parse and NOT translated to `roles.member.match`. Define `roles.member.match[]` directly. See the `typeclaw-permissions` skill.
|
|
632
632
|
- If `portForward` is set: `allow` is either `"*"` or an array of integers (1–65535); `deny`, if present, is an array of integers and **only valid when `allow` is `"*"`** (the schema rejects `deny` paired with a number-array `allow`)
|
|
633
633
|
- If `docker.file.append` is set: array of strings, each with no embedded `\n` or `\r` (multi-step shell logic goes in a single `&&`-chained `RUN` entry)
|
|
634
|
-
- If any `docker.file` toggle is set: `tmux`/`gh`/`ffmpeg` are boolean or version string (no whitespace, no `=`); `
|
|
634
|
+
- If any `docker.file` toggle is set: `tmux`/`gh`/`ffmpeg` are boolean or version string (no whitespace, no `=`); `cjkFonts` is boolean or `"auto"`; `python`, `cloudflared`, `claudeCode`, and `codexCli` are boolean only
|
|
635
635
|
- No unknown top-level keys you invented — keys outside the well-known ten are interpreted as **plugin config blocks** and only do something if a plugin owns them. Inventing one means the user thinks it took effect and it did not.
|
|
636
636
|
|
|
637
637
|
## Things you must not do
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: typeclaw-kaomoji
|
|
3
|
-
description: Load this skill when your `SOUL.md` (or the current conversation) calls for a warm, cute, adorable, playful, or affectionate tone — or when the user explicitly mentions kaomojis,
|
|
3
|
+
description: Load this skill when your `SOUL.md` (or the current conversation) calls for a warm, cute, adorable, playful, or affectionate tone — or when the user explicitly mentions kaomojis, emoticons, or asks you to "feel more like a person and less like a chatbot." TypeClaw's name puns on "Type" — typed emoticons fit. Triggers include any-language requests for a "cute", "adorable", "warm", "playful", "soft", or "cozy" tone, or the word for kaomoji/emoticon in the user's language (e.g. English "kaomoji"/"emoticon", Korean "카오모지"/"친근하게"/"귀엽게"/"다정하게", Japanese "顔文字"/"かわいく", Chinese "颜文字"/"可爱", Spanish "emoticono"/"tierno"), or any signal that the user is tired of generic AI emoji slop (🚀✨🎉) and wants real texture in your voice. This skill gives you a curated palette and the rule for using it: prefer kaomojis over generic emojis, but mix freely — kaomojis lead, emojis still allowed, neither is mandatory.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# typeclaw-kaomoji
|
|
@@ -111,6 +111,6 @@ These match common engineering moments — handy when you're in the middle of a
|
|
|
111
111
|
- ❌ `(╬ Ò﹏Ó)` over a typo — register too strong for the moment.
|
|
112
112
|
- ❌ Kaomoji in a commit message — wrong surface.
|
|
113
113
|
|
|
114
|
-
##
|
|
114
|
+
## Multilingual / bilingual notes
|
|
115
115
|
|
|
116
|
-
Many of these read especially naturally in
|
|
116
|
+
Many of these read especially naturally in East Asian conversation (Korean, Japanese, Chinese), where kaomojis/顔文字/颜文字 are still in daily use on chat apps (KakaoTalk, LINE, Discord, Twitter/X). If your user writes in one of those languages, leaning kaomoji-heavy is a clear win over generic emoji. In languages where kaomojis are less common (e.g. most Latin-script chat), dial back to roughly one per turn so it stays a personality note rather than a tic. The rule is about the user's culture and tone, not any single language.
|
|
@@ -111,7 +111,9 @@ Use `typeclaw tunnel logs <name> -f` while restarting the agent if you need to w
|
|
|
111
111
|
|
|
112
112
|
### `cloudflared` is not installed
|
|
113
113
|
|
|
114
|
-
Both Cloudflare providers (`cloudflare-quick` and `cloudflare-named`) require `docker.file.cloudflared: true`.
|
|
114
|
+
`docker.file.cloudflared` defaults to `false`, so a fresh image ships without the `cloudflared` binary. Both Cloudflare providers (`cloudflare-quick` and `cloudflare-named`) require `docker.file.cloudflared: true`. `typeclaw tunnel add` and `typeclaw channel add github` (with a Cloudflare provider) write it automatically; a hand-edited `typeclaw.json` must set it explicitly. After setting it, run `typeclaw restart` so the Dockerfile is regenerated and the image rebuilds.
|
|
115
|
+
|
|
116
|
+
If a tunnel is configured but the binary is missing, the tunnel goes **`permanently-failed`** and `typeclaw tunnel status` shows the detail `cloudflared binary not found in image; set docker.file.cloudflared: true in typeclaw.json and run typeclaw restart` — fix it the same way.
|
|
115
117
|
|
|
116
118
|
### Named tunnel says "permanently-failed" with `tokenEnv` in the detail
|
|
117
119
|
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { WORDMARK_LINES } from '@/shared/wordmark'
|
|
2
|
+
|
|
3
|
+
import { colors } from './theme'
|
|
4
|
+
|
|
5
|
+
export type BannerInfo = {
|
|
6
|
+
sessionId: string
|
|
7
|
+
serverVersion?: string
|
|
8
|
+
displayUrl: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function formatBanner({ sessionId, serverVersion, displayUrl }: BannerInfo): string {
|
|
12
|
+
const logo = WORDMARK_LINES.map((line) => colors.accent(line)).join('\n')
|
|
13
|
+
const version = serverVersion === undefined ? '' : colors.dim(` v${serverVersion}`)
|
|
14
|
+
const card = [
|
|
15
|
+
`${colors.accent('●')} ${colors.bold('session')}${version} ${colors.dim(sessionId)}`,
|
|
16
|
+
`${colors.dim(' ')}${colors.dim(displayUrl)}`,
|
|
17
|
+
].join('\n')
|
|
18
|
+
return `${logo}\n\n${card}`
|
|
19
|
+
}
|
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
|
}
|