typeclaw 0.1.3 → 0.1.5

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.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: typeclaw-config
3
- description: "Read or edit typeclaw.json: model, port, mounts, plugins, channels (Discord allow rules + engagement), portForward (auto port forwarding policy), dockerfile (tmux/gh/python/ffmpeg toggles + append), gitignore.append. Also: any question about a default value or whether a behavior is already on by default — port forwarding, channel visibility, model choice, container packages (tmux/gh/python on by default; ffmpeg off), anything ending in 'by default', 'automatically', 'out of the box', 'do I need to configure', 'is X on', 'what does X default to', '기본값', '기본적으로', '자동으로', '디폴트'. MUST load before saying you do not know what X defaults to, or proposing to add a field whose default the user is asking about — most fields already default to the behavior the user expects (portForward defaults to forwarding every container LISTEN; tmux/gh/python are pre-installed in the container; no edit needed). Read it before touching typeclaw.json — strict schema, mix of live-reloadable and restart-required fields."
3
+ description: "Read or edit typeclaw.json: model, port, mounts, plugins, channels (Discord allow rules + engagement), portForward (auto port forwarding policy), docker.file (tmux/gh/python/ffmpeg toggles + append), git.ignore.append. Also: any question about a default value or whether a behavior is already on by default — port forwarding, channel visibility, model choice, container packages (tmux/gh/python on by default; ffmpeg off), anything ending in 'by default', 'automatically', 'out of the box', 'do I need to configure', 'is X on', 'what does X default to', '기본값', '기본적으로', '자동으로', '디폴트'. MUST load before saying you do not know what X defaults to, or proposing to add a field whose default the user is asking about — most fields already default to the behavior the user expects (portForward defaults to forwarding every container LISTEN; tmux/gh/python are pre-installed in the container; no edit needed). Read it before touching typeclaw.json — strict schema, mix of live-reloadable and restart-required fields."
4
4
  ---
5
5
 
6
6
  # typeclaw-config
@@ -19,8 +19,8 @@ The runtime reads `typeclaw.json` at container startup. Some fields are picked u
19
19
  - `plugins` — array of plugin package names loaded at server boot. **Restart-required.**
20
20
  - `alias` — additional names the agent answers to when a channel message contains its name in plain text (no `<@id>` mention). The agent folder's directory name (`basename(agentDir)`) is always implicit; `alias` adds further forms (Latin transliteration, nicknames, Korean particles, etc.). Used by the channel engagement layer alongside the structural mention/reply/dm triggers. **Live-reloadable.**
21
21
  - `channels` — per-adapter allow rules and engagement triggers that gate which external messenger channels (today: Discord) you can read from and post to. **Live-reloadable** — edits take effect on the next `reload` without a container restart.
22
- - `dockerfile` — controls what ships in the autogenerated container image. Two layers: (1) **toggles** for opinionated apt packages (`tmux`, `gh`, `python` default `true`; `ffmpeg` defaults `false`) — set the toggle to `false` to omit, or to a version string like `"2.40.0"` to apt-pin (`python` is boolean-only). (2) **`append`** — extra Dockerfile lines spliced in right before `ENTRYPOINT` for anything the toggles don't cover. The whole Dockerfile is rewritten on every `start` from the typeclaw template. **Restart-required** (next `typeclaw start` rebuilds the image).
23
- - `gitignore.append` — extra `.gitignore` patterns `typeclaw start` splices into the TypeClaw-owned `.gitignore` before the protected TypeClaw rules. The whole `.gitignore` is rewritten and auto-committed on every `start` when it changes; `append` is the supported escape hatch for local ignore patterns without editing the managed file by hand. **Restart-required** (next `typeclaw start` refreshes and commits `.gitignore`).
22
+ - `docker.file` — controls what ships in the autogenerated container image. Two layers: (1) **toggles** for opinionated apt packages (`tmux`, `gh`, `python` default `true`; `ffmpeg` defaults `false`) — set the toggle to `false` to omit, or to a version string like `"2.40.0"` to apt-pin (`python` is boolean-only). (2) **`append`** — extra Dockerfile lines spliced in right before `ENTRYPOINT` for anything the toggles don't cover. The whole Dockerfile is rewritten on every `start` from the typeclaw template. Lives under the `docker` namespace alongside future Docker-related blocks (e.g. `docker.compose`). **Restart-required** (next `typeclaw start` rebuilds the image).
23
+ - `git.ignore.append` — extra `.gitignore` patterns `typeclaw start` splices into the TypeClaw-owned `.gitignore` before the protected TypeClaw rules. The whole `.gitignore` is rewritten and auto-committed on every `start` when it changes; `append` is the supported escape hatch for local ignore patterns without editing the managed file by hand. Lives under the `git` namespace. **Restart-required** (next `typeclaw start` refreshes and commits `.gitignore`).
24
24
  - `portForward` — allow/deny policy for the auto port-forwarder (the host-stage `_hostd` daemon's portbroker). When the agent runs a server inside the container that LISTENs on a TCP port, the broker proxies it to the same port number on `127.0.0.1` of the host so the user can hit it directly. `portForward` decides which ports are allowed through. **Restart-required** — the broker captures the policy at register time on `typeclaw start`.
25
25
 
26
26
  ### Reload vs. restart
@@ -28,7 +28,7 @@ The runtime reads `typeclaw.json` at container startup. Some fields are picked u
28
28
  There is no file watcher, but there is a `reload` mechanism. When `typeclaw.json` changes:
29
29
 
30
30
  - **Live-reloadable fields** (`model`, `alias`, `channels`) take effect on the next `reload` — no container restart.
31
- - **Restart-required fields** (`port`, `mounts`, `plugins`, `portForward`, `dockerfile`, `gitignore`) are reported as "reload landed but change won't apply until restart". The diff returns success; the runtime still has the old value in memory. Tell the user explicitly which one they're hitting. `dockerfile` additionally requires an image rebuild — that happens automatically on the next `typeclaw start`, no extra flag needed. `gitignore` refreshes the managed `.gitignore` and auto-commits it on the next `typeclaw start` if content changed.
31
+ - **Restart-required fields** (`port`, `mounts`, `plugins`, `portForward`, `docker.file`, `git.ignore`) are reported as "reload landed but change won't apply until restart". The diff returns success; the runtime still has the old value in memory. Tell the user explicitly which one they're hitting. `docker.file` additionally requires an image rebuild — that happens automatically on the next `typeclaw start`, no extra flag needed. `git.ignore` refreshes the managed `.gitignore` and auto-commits it on the next `typeclaw start` if content changed.
32
32
  - **`$schema`** changes are ignored.
33
33
 
34
34
  When you edit `typeclaw.json`, name the effect: "Edited `channels` — live-reloadable, takes effect on the next `reload`." vs. "Edited `port` — restart-required, run `typeclaw restart` (host stage) to pick up the change." Conflating the two misleads the user into restarting unnecessarily, or worse, into believing a restart-required edit took effect when it did not.
@@ -39,22 +39,22 @@ 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 allow rules and engagement triggers for external messengers. Defaults to `{}` (no adapters configured). `typeclaw init` scaffolds a `discord-bot` block only if the user said yes to "Wire a Discord bot?" during the wizard and supplied a token. **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
- | `dockerfile` | no | object | Customizations for the autogenerated container image build. Toggles (`tmux`, `gh`, `python`, `ffmpeg`) gate opinionated apt packages; `append` adds custom Dockerfile lines just before `ENTRYPOINT`. Defaults to `{ ffmpeg: false, gh: true, python: true, tmux: true, append: [] }`. Omitted from scaffolded `typeclaw.json`. **Restart-required** (next `typeclaw start` rebuilds the image). See **Dockerfile** section below. |
53
- | `gitignore` | no | object | Customizations for the autogenerated `.gitignore`. Today the only field is `append` — extra patterns spliced in before TypeClaw's protected ignore rules. 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 allow rules and engagement triggers for external messengers. Defaults to `{}` (no adapters configured). `typeclaw init` scaffolds a `discord-bot` block only if the user said yes to "Wire a Discord bot?" during the wizard and supplied a token. **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`) gate opinionated apt packages; `append` adds custom Dockerfile lines just before `ENTRYPOINT`. `docker.file` defaults to `{ ffmpeg: false, gh: true, python: true, tmux: true, 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
 
57
- Within the well-known ten (`$schema`, `port`, `model`, `mounts`, `plugins`, `alias`, `channels`, `portForward`, `dockerfile`, `gitignore`), **fields the schema doesn't predeclare are silently dropped**. Do not invent runtime fields like `provider`, `apiKey`, `temperature`, `maxTokens`, `systemPrompt`, `tools`, `timeout`, etc. — those are not plugin blocks, they are imaginary. If the user asks for one, say it is not yet supported and (if it makes sense) suggest they file a request.
57
+ Within the well-known ten (`$schema`, `port`, `model`, `mounts`, `plugins`, `alias`, `channels`, `portForward`, `docker`, `git`), **fields the schema doesn't predeclare are silently dropped**. Legacy top-level `dockerfile` and `gitignore` keys are migrated to `docker.file` / `git.ignore` automatically the first time the CLI loads the file — see **Legacy migration** below. Do not invent runtime fields like `provider`, `apiKey`, `temperature`, `maxTokens`, `systemPrompt`, `tools`, `timeout`, etc. — those are not plugin blocks, they are imaginary. If the user asks for one, say it is not yet supported and (if it makes sense) suggest they file a request.
58
58
 
59
59
  A scaffolded `typeclaw.json` looks like:
60
60
 
@@ -65,7 +65,7 @@ A scaffolded `typeclaw.json` looks like:
65
65
  }
66
66
  ```
67
67
 
68
- The runtime fills in defaults for any omitted field: `port` → `8973`, `mounts` → `[]` (no host paths exposed), `plugins` → `[]`, `channels` → `{}` (no adapters configured), `portForward` → `{ "allow": "*" }` (forward every container LISTEN port), `dockerfile` → `{ "ffmpeg": false, "gh": true, "python": true, "tmux": true, "append": [] }` (tmux/gh/python pre-installed, ffmpeg off, no custom build steps), `gitignore` → `{ "append": [] }` (no custom ignore patterns). `typeclaw init` deliberately omits any field whose default is owned elsewhere — `mounts`, `portForward`, `dockerfile`, and `gitignore` default via `configSchema`, and the bundled memory plugin owns its own `memory` defaults — so the scaffolded file stays minimal and the user sees only fields they actually need to think about. Add a `memory` block (a **plugin config block** owned by the bundled memory plugin) only when overriding its defaults; see the `typeclaw-memory` skill for the schema.
68
+ The runtime fills in defaults for any omitted field: `port` → `8973`, `mounts` → `[]` (no host paths exposed), `plugins` → `[]`, `channels` → `{}` (no adapters configured), `portForward` → `{ "allow": "*" }` (forward every container LISTEN port), `docker` → `{ "file": { "ffmpeg": false, "gh": true, "python": true, "tmux": true, "append": [] } }` (tmux/gh/python pre-installed, ffmpeg off, no custom build steps), `git` → `{ "ignore": { "append": [] } }` (no custom ignore patterns). `typeclaw init` deliberately omits any field whose default is owned elsewhere — `mounts`, `portForward`, `docker`, and `git` default via `configSchema`, and the bundled memory plugin owns its own `memory` defaults — so the scaffolded file stays minimal and the user sees only fields they actually need to think about. Add a `memory` block (a **plugin config block** owned by the bundled memory plugin) only when overriding its defaults; see the `typeclaw-memory` skill for the schema.
69
69
 
70
70
  If the user said yes to "Wire a Discord bot?" during `typeclaw init`, the scaffold also includes:
71
71
 
@@ -361,10 +361,10 @@ Off switch — the broker is constructed but never opens a WS, no LISTEN gets fo
361
361
 
362
362
  `typeclaw start` rewrites the agent folder's `Dockerfile` from a template baked into the typeclaw CLI on **every** invocation — not just on `init`. The Dockerfile is in the truly-ignored `.gitignore` category specifically because it's regenerated; the source of truth for the template is `src/init/dockerfile.ts` in the typeclaw repo, not the agent folder. This means: editing the Dockerfile by hand inside the agent folder is pointless (the next `typeclaw start` overwrites it), and a clean clone of an agent folder onto a fresh machine works only because `start` materializes the Dockerfile before `docker build` reads it.
363
363
 
364
- The `dockerfile` block has two layers of customization:
364
+ The `docker.file` block has two layers of customization:
365
365
 
366
366
  1. **Toggles** for opinionated apt packages typeclaw knows how to install with proper layer caching (`tmux`, `gh`, `python`, `ffmpeg`). Boolean for on/off, version string for an apt pin (e.g. `"gh": "2.40.0"` → `gh=2.40.0`). Use these whenever they cover what the user wants — they get BuildKit cache-mount benefits and, for `gh`, automatic keyring layer gating.
367
- 2. **`append`** is the escape hatch for everything the toggles don't cover. An array of single-line Dockerfile instructions spliced in right before `ENTRYPOINT`, prefixed with a `# Custom lines from typeclaw.json#dockerfile.append.` comment.
367
+ 2. **`append`** is the escape hatch for everything the toggles don't cover. An array of single-line Dockerfile instructions spliced in right before `ENTRYPOINT`, prefixed with a `# Custom lines from typeclaw.json#docker.file.append.` comment.
368
368
 
369
369
  ### Fields
370
370
 
@@ -383,11 +383,13 @@ Toggle version strings reject whitespace and `=` (apt-injection guard) — pass
383
383
  Each entry of `append` must be one Dockerfile instruction's worth of source — a `RUN`, `ENV`, `COPY`, `ARG`, etc. The schema enforces "no embedded newlines" because a multiline string in the JSON would silently break Dockerfile syntax (Dockerfile line continuations require backslashes at end-of-line, and a JSON multiline doesn't carry those). If the user wants a logically multi-step instruction, give them two entries:
384
384
 
385
385
  ```json
386
- "dockerfile": {
387
- "append": [
388
- "RUN apt-get update && apt-get install -y --no-install-recommends ripgrep fd-find",
389
- "ENV CUSTOM_TOOL=1"
390
- ]
386
+ "docker": {
387
+ "file": {
388
+ "append": [
389
+ "RUN apt-get update && apt-get install -y --no-install-recommends ripgrep fd-find",
390
+ "ENV CUSTOM_TOOL=1"
391
+ ]
392
+ }
391
393
  }
392
394
  ```
393
395
 
@@ -401,7 +403,7 @@ The template's last layers are roughly:
401
403
  RUN apt-get install ... <baseline + enabled toggle packages> ← toggles fan out into this line
402
404
  ...
403
405
  ENV NODE_ENV=production
404
- # Custom lines from typeclaw.json#dockerfile.append. ← only emitted when append is non-empty
406
+ # Custom lines from typeclaw.json#docker.file.append. ← only emitted when append is non-empty
405
407
  <your appended lines>
406
408
  ENTRYPOINT ["/usr/local/bin/typeclaw-entrypoint"]
407
409
  CMD ["run"]
@@ -413,23 +415,23 @@ The toggle-driven apt install benefits from BuildKit `--mount=type=cache` on `/v
413
415
 
414
416
  ### Restart and rebuild semantics
415
417
 
416
- - **Restart-required.** `dockerfile` is in `FIELD_EFFECTS` as restart-required. `reload` reports the change as `restartRequired` and the live container keeps running on the old image.
417
- - **The next `typeclaw start` rebuilds the image automatically.** No `--build` flag is needed; the CLI re-runs `docker build` whenever the Dockerfile content has changed (it rewrites the file from the current template + current `dockerfile` block every start). Tell the user: "Edited `dockerfile` — restart-required. The next `typeclaw start` will rewrite the Dockerfile and rebuild the image."
418
+ - **Restart-required.** `docker.file` is in `FIELD_EFFECTS` as restart-required. `reload` reports the change as `restartRequired` and the live container keeps running on the old image.
419
+ - **The next `typeclaw start` rebuilds the image automatically.** No `--build` flag is needed; the CLI re-runs `docker build` whenever the Dockerfile content has changed (it rewrites the file from the current template + current `docker.file` block every start). Tell the user: "Edited `docker.file` — restart-required. The next `typeclaw start` will rewrite the Dockerfile and rebuild the image."
418
420
  - **Pre-existing host-side edits to the Dockerfile are clobbered.** If the user manually edited the Dockerfile before, the next `start` overwrites it and (if the working tree was dirty) auto-commits the cleanup. This is by design; don't try to preserve manual edits.
419
421
 
420
422
  ### When the user asks "install <package> in the container" / "add a Dockerfile line"
421
423
 
422
424
  1. **Read `typeclaw.json`.**
423
- 2. **Check if a toggle covers it.** If the package is `tmux`, `gh`, `python`, or `ffmpeg`, prefer the toggle: `"dockerfile": { "ffmpeg": true }`. For a pinned version, pass the version string: `"gh": "2.40.0"`. This is faster (BuildKit cache mount) and clearer than `append`.
425
+ 2. **Check if a toggle covers it.** If the package is `tmux`, `gh`, `python`, or `ffmpeg`, prefer the toggle: `"docker": { "file": { "ffmpeg": true } }`. For a pinned version, pass the version string: `"gh": "2.40.0"`. This is faster (BuildKit cache mount) and clearer than `append`.
424
426
  3. **Otherwise, use `append`.** Decide on a single-line entry — for apt installs, prefer one `RUN apt-get update && apt-get install -y --no-install-recommends <pkg> && rm -rf /var/lib/apt/lists/*` line. For env vars, one `ENV` line per variable.
425
427
  4. **Validate no embedded newlines** (`append` only). Multi-step logic must be `&&`-chained on one line, not split across array entries unless those entries are independent Dockerfile instructions.
426
- 5. **Append to `dockerfile.append`** (creating the field if it doesn't exist). Preserve existing entries.
427
- 6. **Write, commit, restart-required**: "Edited `dockerfile` — restart-required. The next `typeclaw start` will rewrite the Dockerfile and rebuild the image. The new layer will be at the end of the build, so unrelated cache layers stay valid."
428
+ 5. **Append to `docker.file.append`** (creating the field if it doesn't exist). Preserve existing entries.
429
+ 6. **Write, commit, restart-required**: "Edited `docker.file` — restart-required. The next `typeclaw start` will rewrite the Dockerfile and rebuild the image. The new layer will be at the end of the build, so unrelated cache layers stay valid."
428
430
 
429
431
  ### When the user asks "uninstall <package>" / "make the image smaller"
430
432
 
431
433
  1. **Read `typeclaw.json`.**
432
- 2. **If the package is one of the toggles**, set it to `false`: `"dockerfile": { "tmux": false }`. Don't try to remove it via `append` — the toggle is the only way to omit a baseline package from the apt install line.
434
+ 2. **If the package is one of the toggles**, set it to `false`: `"docker": { "file": { "tmux": false } }`. Don't try to remove it via `append` — the toggle is the only way to omit a baseline package from the apt install line.
433
435
  3. **If it's an `append` entry**, remove that entry from the array.
434
436
  4. **Write, commit, restart-required.** Same rebuild story.
435
437
 
@@ -442,14 +444,14 @@ The toggle-driven apt install benefits from BuildKit `--mount=type=cache` on `/v
442
444
  ### When the user asks "remove that custom Dockerfile line"
443
445
 
444
446
  1. **Read `typeclaw.json`.**
445
- 2. **Remove the entry from `dockerfile.append`.** If the resulting array is empty AND no toggles are overridden, you may either leave it as `"append": []` or drop the whole `dockerfile` block — both are equivalent. Dropping it keeps the file minimal and matches the scaffold convention.
447
+ 2. **Remove the entry from `docker.file.append`.** If the resulting array is empty AND no toggles are overridden, you may either leave it as `"append": []` or drop the whole `docker` block — both are equivalent. Dropping it keeps the file minimal and matches the scaffold convention.
446
448
  3. **Write, commit, restart-required.** Same restart story as adding: next `typeclaw start` rebuilds.
447
449
 
448
450
  ## Gitignore
449
451
 
450
452
  `typeclaw start` rewrites the agent folder's `.gitignore` from a template baked into the typeclaw CLI on **every** invocation, then auto-commits it when the agent folder is a git repo and the file changed. The template protects two categories: truly-ignored paths (`.env`, `node_modules/`, `workspace/`, `mounts/`, `Dockerfile`, `.DS_Store`) and system-managed runtime state (`sessions/`, `memory/`, `channels/`) that TypeClaw, not the agent, commits on its own schedule. Editing `.gitignore` by hand is temporary; the next `typeclaw start` overwrites it.
451
453
 
452
- The `gitignore.append` field is the supported escape hatch for additional local ignore patterns. It is an array of strings, each treated as a single `.gitignore` line. The CLI splices them into the autogenerated `.gitignore` before TypeClaw's protected rules, prefixed with a `# Custom entries from typeclaw.json#gitignore.append.` comment.
454
+ The `git.ignore.append` field (introduced when the legacy top-level `gitignore` key was nested under the `git` namespace for future extensibility — see **Legacy migration**) is the supported escape hatch for additional local ignore patterns. It is an array of strings, each treated as a single `.gitignore` line. The CLI splices them into the autogenerated `.gitignore` before TypeClaw's protected rules, prefixed with a `# Custom entries from typeclaw.json#git.ignore.append.` comment.
453
455
 
454
456
  ### Field
455
457
 
@@ -459,12 +461,12 @@ The `gitignore.append` field is the supported escape hatch for additional local
459
461
 
460
462
  ### Ordering and protected paths
461
463
 
462
- `.gitignore` is order-sensitive: later `!` negation rules can unignore earlier ignore rules. TypeClaw therefore renders `gitignore.append` **before** its own truly-ignored and system-managed entries, so even a custom `!sessions/` or `!.env` cannot override TypeClaw's protections. Custom ordinary ignore patterns still work because they add additional ignores; they just do not get the final word over TypeClaw-owned paths.
464
+ `.gitignore` is order-sensitive: later `!` negation rules can unignore earlier ignore rules. TypeClaw therefore renders `git.ignore.append` **before** its own truly-ignored and system-managed entries, so even a custom `!sessions/` or `!.env` cannot override TypeClaw's protections. Custom ordinary ignore patterns still work because they add additional ignores; they just do not get the final word over TypeClaw-owned paths.
463
465
 
464
466
  Materialized shape when `append` is non-empty:
465
467
 
466
468
  ```gitignore
467
- # Custom entries from typeclaw.json#gitignore.append.
469
+ # Custom entries from typeclaw.json#git.ignore.append.
468
470
  scratch/
469
471
  *.local.log
470
472
 
@@ -480,26 +482,42 @@ channels/
480
482
 
481
483
  ### Restart semantics
482
484
 
483
- - **Restart-required.** `gitignore` is in `FIELD_EFFECTS` as restart-required. `reload` reports the change as `restartRequired`; the already-materialized `.gitignore` on disk remains unchanged until the next host-stage start.
484
- - **The next `typeclaw start` refreshes and auto-commits `.gitignore`.** Tell the user: "Edited `gitignore.append` — restart-required. The next `typeclaw start` will rewrite `.gitignore` and TypeClaw will auto-commit it if the file changes."
485
+ - **Restart-required.** `git.ignore` is in `FIELD_EFFECTS` as restart-required. `reload` reports the change as `restartRequired`; the already-materialized `.gitignore` on disk remains unchanged until the next host-stage start.
486
+ - **The next `typeclaw start` refreshes and auto-commits `.gitignore`.** Tell the user: "Edited `git.ignore.append` — restart-required. The next `typeclaw start` will rewrite `.gitignore` and TypeClaw will auto-commit it if the file changes."
485
487
 
486
488
  ### When the user asks "ignore this path" / "add a gitignore entry"
487
489
 
488
490
  1. **Read `typeclaw.json`.**
489
491
  2. **Decide on single-line patterns.** Use one array entry per `.gitignore` pattern. Do not embed newlines.
490
- 3. **Append to `gitignore.append`** (creating the field if it doesn't exist). Preserve existing entries.
492
+ 3. **Append to `git.ignore.append`** (creating the field if it doesn't exist). Preserve existing entries.
491
493
  4. **Do not edit `.gitignore` directly.** It is managed and will be overwritten on `typeclaw start`.
492
- 5. **Write, commit, restart-required**: "Edited `gitignore.append` — restart-required. The next `typeclaw start` will rewrite and auto-commit `.gitignore`."
494
+ 5. **Write, commit, restart-required**: "Edited `git.ignore.append` — restart-required. The next `typeclaw start` will rewrite and auto-commit `.gitignore`."
493
495
 
494
496
  ### When the user asks "remove that custom ignore entry"
495
497
 
496
498
  1. **Read `typeclaw.json`.**
497
- 2. **Remove the entry from `gitignore.append`.** If the resulting array is empty, you may either leave it as `"append": []` or drop the whole `gitignore` block — both are equivalent. Dropping it keeps the file minimal and matches the scaffold convention.
499
+ 2. **Remove the entry from `git.ignore.append`.** If the resulting array is empty, you may either leave it as `"append": []` or drop the whole `git` block — both are equivalent. Dropping it keeps the file minimal and matches the scaffold convention.
498
500
  3. **Write, commit, restart-required.** Same refresh story as adding: next `typeclaw start` rewrites and auto-commits `.gitignore` if content changed.
499
501
 
502
+ ## Legacy migration
503
+
504
+ Pre-namespace `typeclaw.json` files carried `dockerfile` and `gitignore` as top-level keys. The current schema nests them under `docker.file` and `git.ignore` so the `docker` and `git` namespaces stay free for future siblings (`docker.compose`, `git.attributes`, etc.) without a second rename.
505
+
506
+ The first time `validateConfig` or `loadConfigSync` reads a legacy file:
507
+
508
+ 1. The in-memory JSON is rewritten: top-level `dockerfile` → `docker.file`, top-level `gitignore` → `git.ignore`.
509
+ 2. The same rewrite is persisted back to `typeclaw.json` on disk (pretty-printed, trailing newline). Best-effort: a read-only filesystem just retries next start.
510
+ 3. If the file already has a `docker` or `git` block AND the legacy key, the new shape wins — the legacy duplicate is dropped silently. The new shape would have shadowed the legacy at parse time anyway.
511
+
512
+ What this means for you:
513
+
514
+ - **Do not write top-level `dockerfile` or `gitignore` keys** when editing `typeclaw.json`. They'll be migrated away on the next CLI invocation; meanwhile the file is briefly in an inconsistent shape.
515
+ - **Old documentation or examples that still mention `typeclaw.json#dockerfile.append` are stale.** The current path is `typeclaw.json#docker.file.append`. Same for `git.ignore.append`.
516
+ - **An auto-commit may appear** the next time `typeclaw start` runs against a freshly-migrated agent folder. The diff is mechanical (top-level rename → nested) — surface it to the user as a one-time migration, not a behavior change.
517
+
500
518
  ## Plugin config blocks
501
519
 
502
- Top-level keys in `typeclaw.json` that are **not** in the well-known ten (`$schema`, `port`, `model`, `mounts`, `plugins`, `alias`, `channels`, `portForward`, `dockerfile`, `gitignore`) are treated as plugin config blocks. The schema preserves them via `catchall(z.unknown())`, and `extractPluginConfigs` hands each block to the owning plugin's `configSchema` for validation at boot.
520
+ Top-level keys in `typeclaw.json` that are **not** in the well-known ten (`$schema`, `port`, `model`, `mounts`, `plugins`, `alias`, `channels`, `portForward`, `docker`, `git`) are treated as plugin config blocks. The schema preserves them via `catchall(z.unknown())`, and `extractPluginConfigs` hands each block to the owning plugin's `configSchema` for validation at boot.
503
521
 
504
522
  This skill does **not** document individual plugin blocks. For schema, defaults, and reload semantics of a specific plugin's config, defer to that plugin's own skill:
505
523
 
@@ -598,14 +616,14 @@ Never echo, log, or commit values from `.env` or `secrets.json`. Both are gitign
598
616
 
599
617
  1. **Read the whole file first** with the `read` tool. Don't assume what's in it — the user may have customized it.
600
618
  2. **Modify in memory.** Change only the field(s) the user asked about. Leave `$schema` alone.
601
- 3. **Write the whole file back** with the `write` tool. Always pretty-printed (2-space indent), trailing newline, fields in stable order: `$schema` first, then alphabetical for the rest (`alias`, `channels`, `dockerfile`, `gitignore`, `model`, `mounts`, `plugins`, `port`, `portForward`, then any plugin config blocks like `memory`).
619
+ 3. **Write the whole file back** with the `write` tool. Always pretty-printed (2-space indent), trailing newline, fields in stable order: `$schema` first, then alphabetical for the rest (`alias`, `channels`, `docker`, `git`, `model`, `mounts`, `plugins`, `port`, `portForward`, then any plugin config blocks like `memory`).
602
620
  4. **Validate before declaring done.** A malformed `typeclaw.json` will refuse to boot the agent on next restart, and a malformed reload-time edit will be rejected by `reload`. Sanity-check your JSON manually or with `bash` (`cat typeclaw.json | jq .`) before considering the edit done.
603
621
  5. **Commit the change.** See the `typeclaw-git` skill for the commit-message rule (decision context required). `typeclaw.json` is not gitignored, so an uncommitted edit will pollute your next commit.
604
622
  6. **Tell the user the right next step.** Match the field's effect class:
605
623
  - `model`, `alias`, `channels` → "Live-reloadable, takes effect on the next `reload`."
606
624
  - `port`, `mounts`, `plugins`, `portForward` → "Restart-required. Run `typeclaw restart` (host stage) to pick up the change."
607
- - `dockerfile` → "Restart-required, and the next `typeclaw start` will rebuild the image automatically (no `--build` flag needed)."
608
- - `gitignore` → "Restart-required, and the next `typeclaw start` will rewrite and auto-commit `.gitignore` if content changed."
625
+ - `docker.file` → "Restart-required, and the next `typeclaw start` will rebuild the image automatically (no `--build` flag needed)."
626
+ - `git.ignore` → "Restart-required, and the next `typeclaw start` will rewrite and auto-commit `.gitignore` if content changed."
609
627
  - Plugin config blocks (e.g. `memory`) → restart-required by convention because plugins read their config once at boot. Defer to the plugin's own skill for the exact semantics.
610
628
  - Mixed edits in one go → spell out which is which; do not collapse to "restart" if part of the change is live.
611
629
 
@@ -624,8 +642,8 @@ Never echo, log, or commit values from `.env` or `secrets.json`. Both are gitign
624
642
  - If `channels.discord-bot.engagement.trigger` is set: array of `"mention"`, `"reply"`, `"dm"` (any subset, including empty)
625
643
  - If `channels.discord-bot.engagement.stickiness` is set: either the literal `"off"` or `{ "perReply": { "window": <int 1..86400000> } }`
626
644
  - 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`)
627
- - If `dockerfile.append` is set: array of strings, each with no embedded `\n` or `\r` (multi-step shell logic goes in a single `&&`-chained `RUN` entry)
628
- - If any `dockerfile` toggle is set: `tmux`/`gh`/`ffmpeg` are boolean or version string (no whitespace, no `=`); `python` is boolean only
645
+ - 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)
646
+ - If any `docker.file` toggle is set: `tmux`/`gh`/`ffmpeg` are boolean or version string (no whitespace, no `=`); `python` is boolean only
629
647
  - 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.
630
648
 
631
649
  ## Things you must not do
@@ -643,10 +661,10 @@ Never echo, log, or commit values from `.env` or `secrets.json`. Both are gitign
643
661
  - **Do not promise the user that an allow-rule edit took effect immediately just because you wrote the file.** Live-reloadable means "applied on the next `reload`", not "applied the instant the file changes". Until `reload` runs (or the container restarts), the runtime is still using the old `channels` config.
644
662
  - **Do not promise to post to a channel that isn't in `allow`.** `channel_send` will refuse with `{ ok: false, error }` regardless of what you tell the user. If they want you to post somewhere new, the prerequisite is an allow-rule edit, not a retry.
645
663
  - **Do not conflate "stop replying" with "remove allow rule".** Removing the allow rule cuts off both inbound visibility and outbound posting. If the user just wants quieter behavior, edit `engagement` instead.
646
- - **Do not edit the `Dockerfile` directly.** It is autogenerated and rewritten on every `typeclaw start` from `src/init/dockerfile.ts` in the typeclaw repo. Manual edits will be silently overwritten (and auto-committed away if the working tree is dirty). Customizations belong in the `dockerfile` block (toggles or `append`).
647
- - **Do not reach for `dockerfile.append` when a toggle covers it.** If the user wants tmux, gh, python, or ffmpeg installed (or removed, or pinned), use the toggle — it's the cache-mounted path. `append` for these is slower and harder to read.
648
- - **Do not use `dockerfile.append` for things that belong in the template.** If the user wants a system package _every_ typeclaw user should have, that's a typeclaw release, not a per-agent `append`. Suggest filing an issue.
649
- - **Do not put multiline strings in `dockerfile.append`.** The schema rejects entries with embedded `\n`/`\r`. Use one entry per Dockerfile instruction; chain shell logic with `&&` on one line.
664
+ - **Do not edit the `Dockerfile` directly.** It is autogenerated and rewritten on every `typeclaw start` from `src/init/dockerfile.ts` in the typeclaw repo. Manual edits will be silently overwritten (and auto-committed away if the working tree is dirty). Customizations belong in the `docker.file` block (toggles or `append`).
665
+ - **Do not reach for `docker.file.append` when a toggle covers it.** If the user wants tmux, gh, python, or ffmpeg installed (or removed, or pinned), use the toggle — it's the cache-mounted path. `append` for these is slower and harder to read.
666
+ - **Do not use `docker.file.append` for things that belong in the template.** If the user wants a system package _every_ typeclaw user should have, that's a typeclaw release, not a per-agent `append`. Suggest filing an issue.
667
+ - **Do not put multiline strings in `docker.file.append`.** The schema rejects entries with embedded `\n`/`\r`. Use one entry per Dockerfile instruction; chain shell logic with `&&` on one line.
650
668
  - **Do not pass `pkg=ver` as a toggle version string.** The schema rejects `=` in version strings. Pass just the version (`"gh": "2.40.0"`); the renderer prepends `pkg=` itself. Same for whitespace — version strings cannot contain spaces.
651
669
  - **Do not list `8973` (or whatever `port` is set to) in `portForward.allow`/`deny`.** That port is owned by `docker run -p`; the broker quietly excludes it regardless. Listing it is misleading.
652
670
  - **Do not combine `portForward.deny` with a number-array `allow`.** The schema rejects this; the deny rule would have no effect even if the schema allowed it. `deny` is only meaningful with `allow: "*"`.
@@ -678,5 +696,5 @@ Never echo, log, or commit values from `.env` or `secrets.json`. Both are gitign
678
696
  - **Identity files** (`IDENTITY.md`, `SOUL.md`, `USER.md`, `AGENTS.md`) — these are not runtime config; they are _you_. Edit them directly when relevant; no skill needed.
679
697
  - **`MEMORY.md` and `memory/`** — explicit exception to the line above. `MEMORY.md` is **dreaming-owned** and you must not write to it directly; the `memory/` directory holds runtime-managed daily streams and muscle-memory skills. See the `typeclaw-memory` skill before touching anything memory-shaped.
680
698
  - **Skills directories** (`.agents/skills/`, `memory/skills/`, the bundled `src/skills/`) — these are loaded from disk by the runtime; they are not driven by `typeclaw.json`. See the `typeclaw-skills` skill for the three layers, the `bunx skills` CLI, and the lockfile-based "downloaded vs hand-authored" rule.
681
- - **The Dockerfile template itself** (the autogenerated layers in `Dockerfile`: bun base image, apt setup, GitHub CLI, `agent-browser`, Chrome for Testing) — that is host-stage, controlled by `src/init/dockerfile.ts` in the typeclaw repo, not by `typeclaw.json`. `typeclaw.json#dockerfile.append` (covered above) is the only piece of the build customizable per-agent; everything else requires a typeclaw release.
699
+ - **The Dockerfile template itself** (the autogenerated layers in `Dockerfile`: bun base image, apt setup, GitHub CLI, `agent-browser`, Chrome for Testing) — that is host-stage, controlled by `src/init/dockerfile.ts` in the typeclaw repo, not by `typeclaw.json`. `typeclaw.json#docker.file.append` (covered above) is the only piece of the build customizable per-agent; everything else requires a typeclaw release.
682
700
  - **The host-stage launcher's invocation flags** (`docker run` arguments synthesized by `typeclaw start`, the `_hostd` daemon's lifecycle, the host port allocation that maps to `port` inside the container) — those are host-stage code, not config. The pieces of that flow that **are** user-configurable through `typeclaw.json` (`port`, `mounts`, `portForward`) are documented above; the rest is not.
@@ -45,7 +45,7 @@ When dreaming fires, it reads:
45
45
  1. `MEMORY.md`
46
46
  2. The **undreamed tail** of every `memory/yyyy-MM-dd.md` (the runtime tells it the exact line range — earlier lines are already consolidated into `MEMORY.md` and must NOT be re-read)
47
47
 
48
- It rewrites `MEMORY.md` with the merged result, advances the per-day watermark in `memory/.dreaming-state.json`, optionally writes muscle-memory skills under `memory/skills/<name>/SKILL.md`, then `git commit -m "Dream"` the snapshot. After the commit, the runtime sets the `skip-worktree` index flag on the tracked memory artifacts so the user's `git status` and `git diff` stay clean. The flag is cleared and re-applied around every commit.
48
+ It rewrites `MEMORY.md` with the merged result, advances the per-day watermark in `memory/.dreaming-state.json`, optionally writes muscle-memory skills under `memory/skills/<name>/SKILL.md`, then commits the snapshot with a message shaped like `dream: <summary> <emoji>` — e.g. `dream: 3 fragments + new skill 'pr-review' 🔮`. The summary is derived from the staged diff (line additions in daily streams, newly-added skills, etc.), and the emoji is a random pick from a small thematic pool. After the commit, the runtime sets the `skip-worktree` index flag on the tracked memory artifacts so the user's `git status` and `git diff` stay clean. The flag is cleared and re-applied around every commit.
49
49
 
50
50
  The dreaming subagent has only three tools: `read`, `write`, `ls`. No `bash`. No `edit`. It cannot run shell commands.
51
51
 
@@ -115,19 +115,19 @@ You cannot remove a fragment cleanly. The right response depends on what X is:
115
115
 
116
116
  ## When the user asks "what did you dream?" / "when do you dream next?"
117
117
 
118
- 1. **What you dreamed**: read the most recent `Dream` git commit on your agent folder (`git log --grep='^Dream' -1`) and show the diff against `MEMORY.md` if useful. The commit timestamp tells you when dreaming last ran. If the answer is "no `Dream` commits yet", say that — `MEMORY.md` may exist but be the auto-created empty file from the first dreaming attempt.
118
+ 1. **What you dreamed**: read the most recent `dream:` git commit on your agent folder (`git log --grep='^dream:' -1`) and show the diff against `MEMORY.md` if useful. The commit timestamp tells you when dreaming last ran. If the answer is "no `dream:` commits yet", say that — `MEMORY.md` may exist but be the auto-created empty file from the first dreaming attempt.
119
119
  2. **When you dream next**: read `memory.dreaming.schedule` from `typeclaw.json` (default `"*/30 * * * *"` — every 30 minutes). Translate the cron expression to a wall-clock time in the agent's `TZ`. The dreaming cron job is **always registered** even when `memory.dreaming` is omitted; the default schedule applies. Tell the user honestly when the next fire is in the agent's local time.
120
120
 
121
121
  ## When the user asks "what's a daily stream?" / "where is your memory stored?"
122
122
 
123
123
  Stay concrete. Use this map:
124
124
 
125
- | File / dir | What it is | Who writes it | Tracked in git |
126
- | ------------------------------- | ----------------------------------------------------------------------------- | -------------------------------------------------------------- | ----------------------------------------------------------- |
127
- | `MEMORY.md` | Long-term memory, consolidated topics with fragment citations. | Dreaming subagent (rewrites in full on each run). | Yes (force-committed under `Dream` commits, skip-worktree). |
128
- | `memory/yyyy-MM-dd.md` | Daily fragment streams. Append-only during the day. | Memory-logger subagent (one fragment ≈ one prompt completion). | Gitignored, but force-committed in the dreaming snapshot. |
129
- | `memory/skills/<name>/SKILL.md` | Muscle-memory skills distilled from recurring procedures. | Dreaming subagent only. | Gitignored, force-committed in the dreaming snapshot. |
130
- | `memory/.dreaming-state.json` | Per-day watermarks (line counts already consolidated). Plain JSON, fail-open. | Dreaming subagent. | Gitignored, force-committed in the dreaming snapshot. |
125
+ | File / dir | What it is | Who writes it | Tracked in git |
126
+ | ------------------------------- | ----------------------------------------------------------------------------- | -------------------------------------------------------------- | ------------------------------------------------------------ |
127
+ | `MEMORY.md` | Long-term memory, consolidated topics with fragment citations. | Dreaming subagent (rewrites in full on each run). | Yes (force-committed under `dream:` commits, skip-worktree). |
128
+ | `memory/yyyy-MM-dd.md` | Daily fragment streams. Append-only during the day. | Memory-logger subagent (one fragment ≈ one prompt completion). | Gitignored, but force-committed in the dreaming snapshot. |
129
+ | `memory/skills/<name>/SKILL.md` | Muscle-memory skills distilled from recurring procedures. | Dreaming subagent only. | Gitignored, force-committed in the dreaming snapshot. |
130
+ | `memory/.dreaming-state.json` | Per-day watermarks (line counts already consolidated). Plain JSON, fail-open. | Dreaming subagent. | Gitignored, force-committed in the dreaming snapshot. |
131
131
 
132
132
  `typeclaw init` does **not** scaffold any of these. They appear when needed — `MEMORY.md` and `memory/` are created by the first dreaming run; daily streams appear when the first memory-logger fires.
133
133
 
package/src/tui/client.ts CHANGED
@@ -2,9 +2,19 @@ import type { ClientMessage, ServerMessage } from '@/shared'
2
2
 
3
3
  export type Client = Awaited<ReturnType<typeof createClient>>
4
4
 
5
- export async function createClient(url: string) {
5
+ export type CreateClientOptions = {
6
+ timeoutMs?: number
7
+ }
8
+
9
+ const DEFAULT_CONNECT_TIMEOUT_MS = 30_000
10
+
11
+ export async function createClient(url: string, { timeoutMs = DEFAULT_CONNECT_TIMEOUT_MS }: CreateClientOptions = {}) {
6
12
  const ws = new WebSocket(url)
13
+ const displayUrl = redactUrl(url)
7
14
  const listeners = new Set<(msg: ServerMessage) => void>()
15
+ const closeListeners = new Set<() => void>()
16
+ const errorListeners = new Set<(err: unknown) => void>()
17
+ let closed = false
8
18
  // Buffer messages that arrive before any listener is registered. In-process
9
19
  // connections (typeclaw run's local tui) deliver the first server frame
10
20
  // before the caller has a chance to attach onMessage.
@@ -20,12 +30,43 @@ export async function createClient(url: string) {
20
30
  })
21
31
 
22
32
  ws.addEventListener('close', () => {
33
+ closed = true
23
34
  listeners.clear()
35
+ for (const fn of closeListeners) fn()
36
+ closeListeners.clear()
37
+ })
38
+
39
+ ws.addEventListener('error', (err) => {
40
+ for (const fn of errorListeners) fn(err)
24
41
  })
25
42
 
26
43
  await new Promise<void>((resolve, reject) => {
27
- ws.addEventListener('open', () => resolve(), { once: true })
28
- ws.addEventListener('error', (err) => reject(err), { once: true })
44
+ const timer = setTimeout(() => {
45
+ cleanup()
46
+ ws.close()
47
+ reject(new Error(`timed out connecting to ${displayUrl} after ${timeoutMs}ms`))
48
+ }, timeoutMs)
49
+ const cleanup = () => {
50
+ clearTimeout(timer)
51
+ ws.removeEventListener('open', onOpen)
52
+ ws.removeEventListener('error', onError)
53
+ ws.removeEventListener('close', onClose)
54
+ }
55
+ const onOpen = () => {
56
+ cleanup()
57
+ resolve()
58
+ }
59
+ const onError = (err: unknown) => {
60
+ cleanup()
61
+ reject(err)
62
+ }
63
+ const onClose = () => {
64
+ cleanup()
65
+ reject(new Error(`connection to ${displayUrl} closed before opening`))
66
+ }
67
+ ws.addEventListener('open', onOpen, { once: true })
68
+ ws.addEventListener('error', onError, { once: true })
69
+ ws.addEventListener('close', onClose, { once: true })
29
70
  })
30
71
 
31
72
  return {
@@ -37,9 +78,29 @@ export async function createClient(url: string) {
37
78
  }
38
79
  return () => listeners.delete(fn)
39
80
  },
40
- onClose: (fn: () => void) => ws.addEventListener('close', fn),
41
- onError: (fn: (err: unknown) => void) => ws.addEventListener('error', fn),
81
+ onClose: (fn: () => void) => {
82
+ if (closed) {
83
+ queueMicrotask(fn)
84
+ return () => {}
85
+ }
86
+ closeListeners.add(fn)
87
+ return () => closeListeners.delete(fn)
88
+ },
89
+ onError: (fn: (err: unknown) => void) => {
90
+ errorListeners.add(fn)
91
+ return () => errorListeners.delete(fn)
92
+ },
42
93
  send: (msg: ClientMessage) => ws.send(JSON.stringify(msg)),
43
94
  close: () => ws.close(),
44
95
  }
45
96
  }
97
+
98
+ function redactUrl(url: string): string {
99
+ try {
100
+ const parsed = new URL(url)
101
+ if (parsed.searchParams.has('token')) parsed.searchParams.set('token', '<redacted>')
102
+ return parsed.toString()
103
+ } catch {
104
+ return url
105
+ }
106
+ }
package/src/tui/index.ts CHANGED
@@ -7,11 +7,14 @@ import { colors, editorTheme, markdownTheme } from './theme'
7
7
  export type ClientFactory = (url: string) => Promise<Client>
8
8
  export type TerminalFactory = () => Terminal
9
9
 
10
+ const DEFAULT_HANDSHAKE_TIMEOUT_MS = 30_000
11
+
10
12
  export type TuiOptions = {
11
13
  url: string
12
14
  initialPrompt?: string
13
15
  createClient?: ClientFactory
14
16
  createTerminal?: TerminalFactory
17
+ handshakeTimeoutMs?: number
15
18
  exit?: (code: number) => void
16
19
  }
17
20
 
@@ -20,13 +23,15 @@ export function createTui({
20
23
  initialPrompt,
21
24
  createClient = createClientDefault,
22
25
  createTerminal = () => new ProcessTerminal(),
26
+ handshakeTimeoutMs = DEFAULT_HANDSHAKE_TIMEOUT_MS,
23
27
  exit = process.exit.bind(process),
24
28
  }: TuiOptions) {
25
29
  async function run(): Promise<void> {
26
30
  const terminal = createTerminal()
27
31
  const tui = new TUI(terminal)
32
+ const displayUrl = redactUrl(url)
28
33
 
29
- const status = new Text(colors.dim(`connecting to ${url}...`), 0, 0)
34
+ const status = new Text(colors.dim(`connecting to ${displayUrl}...`), 0, 0)
30
35
  tui.addChild(status)
31
36
  tui.start()
32
37
  tui.requestRender()
@@ -39,14 +44,13 @@ export function createTui({
39
44
  throw err
40
45
  })
41
46
 
42
- const sessionId = await new Promise<string>((resolve) => {
43
- let off: (() => void) | undefined
44
- off = client.onMessage((msg) => {
45
- if (msg.type === 'connected') {
46
- off?.()
47
- resolve(msg.sessionId)
48
- }
49
- })
47
+ const sessionId = await waitForConnected(client, displayUrl, handshakeTimeoutMs).catch((err) => {
48
+ status.setText(colors.red(`connection error: ${err instanceof Error ? err.message : String(err)}`))
49
+ tui.requestRender()
50
+ client.close()
51
+ tui.stop()
52
+ exit(1)
53
+ throw err
50
54
  })
51
55
  status.setText(colors.dim(`session: ${sessionId}`))
52
56
  tui.requestRender()
@@ -223,3 +227,51 @@ export function createTui({
223
227
 
224
228
  return { run }
225
229
  }
230
+
231
+ function redactUrl(url: string): string {
232
+ try {
233
+ const parsed = new URL(url)
234
+ if (parsed.searchParams.has('token')) parsed.searchParams.set('token', '<redacted>')
235
+ return parsed.toString()
236
+ } catch {
237
+ return url
238
+ }
239
+ }
240
+
241
+ async function waitForConnected(client: Client, url: string, timeoutMs: number): Promise<string> {
242
+ return await new Promise<string>((resolve, reject) => {
243
+ const timer = setTimeout(() => {
244
+ cleanup()
245
+ reject(new Error(`timed out waiting for connected message from ${url} after ${timeoutMs}ms`))
246
+ }, timeoutMs)
247
+ const cleanupFns: Array<() => void> = []
248
+ const cleanup = () => {
249
+ clearTimeout(timer)
250
+ for (const fn of cleanupFns.splice(0)) fn()
251
+ }
252
+ cleanupFns.push(
253
+ client.onMessage((msg) => {
254
+ if (msg.type === 'connected') {
255
+ cleanup()
256
+ resolve(msg.sessionId)
257
+ }
258
+ if (msg.type === 'error') {
259
+ cleanup()
260
+ reject(new Error(msg.message))
261
+ }
262
+ }),
263
+ )
264
+ cleanupFns.push(
265
+ client.onClose(() => {
266
+ cleanup()
267
+ reject(new Error(`connection to ${url} closed before the session was ready`))
268
+ }),
269
+ )
270
+ cleanupFns.push(
271
+ client.onError((err) => {
272
+ cleanup()
273
+ reject(err instanceof Error ? err : new Error(`connection to ${url} failed`))
274
+ }),
275
+ )
276
+ })
277
+ }