typeclaw 0.1.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/LICENSE +21 -0
- package/README.md +134 -0
- package/auth.schema.json +63 -0
- package/cron.schema.json +96 -0
- package/package.json +72 -0
- package/scripts/emit-base-dockerfile.ts +5 -0
- package/scripts/generate-schema.ts +34 -0
- package/secrets.schema.json +63 -0
- package/src/agent/auth.ts +119 -0
- package/src/agent/compaction.ts +35 -0
- package/src/agent/git-nudge.ts +95 -0
- package/src/agent/index.ts +451 -0
- package/src/agent/plugin-tools.ts +269 -0
- package/src/agent/reload-tool.ts +71 -0
- package/src/agent/self.ts +45 -0
- package/src/agent/session-origin.ts +288 -0
- package/src/agent/subagents.ts +253 -0
- package/src/agent/system-prompt.ts +68 -0
- package/src/agent/tools/channel-fetch-attachment.ts +118 -0
- package/src/agent/tools/channel-history.ts +119 -0
- package/src/agent/tools/channel-reply.ts +182 -0
- package/src/agent/tools/channel-send.ts +212 -0
- package/src/agent/tools/ddg.ts +218 -0
- package/src/agent/tools/restart.ts +122 -0
- package/src/agent/tools/stream-snapshot.ts +181 -0
- package/src/agent/tools/webfetch/fetch.ts +102 -0
- package/src/agent/tools/webfetch/index.ts +1 -0
- package/src/agent/tools/webfetch/strategies/grep.ts +70 -0
- package/src/agent/tools/webfetch/strategies/jq.ts +31 -0
- package/src/agent/tools/webfetch/strategies/raw.ts +3 -0
- package/src/agent/tools/webfetch/strategies/readability.ts +30 -0
- package/src/agent/tools/webfetch/strategies/selector.ts +41 -0
- package/src/agent/tools/webfetch/strategies/snapshot.ts +135 -0
- package/src/agent/tools/webfetch/tool.ts +281 -0
- package/src/agent/tools/webfetch/types.ts +33 -0
- package/src/agent/tools/websearch.ts +96 -0
- package/src/agent/tools/wikipedia.ts +52 -0
- package/src/bundled-plugins/agent-browser/dashboard-discovery.ts +170 -0
- package/src/bundled-plugins/agent-browser/dashboard-proxy.ts +421 -0
- package/src/bundled-plugins/agent-browser/index.ts +179 -0
- package/src/bundled-plugins/agent-browser/shim-install.ts +158 -0
- package/src/bundled-plugins/agent-browser/shim.ts +152 -0
- package/src/bundled-plugins/agent-browser/skills/agent-browser/SKILL.md +113 -0
- package/src/bundled-plugins/guard/index.ts +26 -0
- package/src/bundled-plugins/guard/policies/non-workspace-write.ts +98 -0
- package/src/bundled-plugins/guard/policies/skill-authoring.ts +185 -0
- package/src/bundled-plugins/guard/policies/uncommitted-changes.ts +85 -0
- package/src/bundled-plugins/guard/policy.ts +18 -0
- package/src/bundled-plugins/memory/README.md +71 -0
- package/src/bundled-plugins/memory/append-tool.ts +84 -0
- package/src/bundled-plugins/memory/dreaming-state.ts +86 -0
- package/src/bundled-plugins/memory/dreaming.ts +470 -0
- package/src/bundled-plugins/memory/fragment-parser.ts +67 -0
- package/src/bundled-plugins/memory/index.ts +238 -0
- package/src/bundled-plugins/memory/load-memory.ts +122 -0
- package/src/bundled-plugins/memory/memory-logger.ts +257 -0
- package/src/bundled-plugins/memory/secret-detector.ts +49 -0
- package/src/bundled-plugins/memory/watermark.ts +15 -0
- package/src/bundled-plugins/security/index.ts +35 -0
- package/src/bundled-plugins/security/policies/git-exfil.ts +120 -0
- package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +167 -0
- package/src/bundled-plugins/security/policies/prompt-injection.ts +488 -0
- package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +99 -0
- package/src/bundled-plugins/security/policies/secret-exfil-read.ts +127 -0
- package/src/bundled-plugins/security/policies/session-search-secrets.ts +86 -0
- package/src/bundled-plugins/security/policies/ssrf.ts +196 -0
- package/src/bundled-plugins/security/policies/system-prompt-leak.ts +81 -0
- package/src/bundled-plugins/security/policy.ts +9 -0
- package/src/channels/adapters/discord-bot-channel-resolver.ts +77 -0
- package/src/channels/adapters/discord-bot-classify.ts +148 -0
- package/src/channels/adapters/discord-bot.ts +640 -0
- package/src/channels/adapters/kakaotalk-author-resolver.ts +78 -0
- package/src/channels/adapters/kakaotalk-channel-resolver.ts +105 -0
- package/src/channels/adapters/kakaotalk-classify.ts +77 -0
- package/src/channels/adapters/kakaotalk.ts +622 -0
- package/src/channels/adapters/slack-bot-author-resolver.ts +80 -0
- package/src/channels/adapters/slack-bot-channel-resolver.ts +84 -0
- package/src/channels/adapters/slack-bot-classify.ts +213 -0
- package/src/channels/adapters/slack-bot-dedupe.ts +51 -0
- package/src/channels/adapters/slack-bot-time.ts +10 -0
- package/src/channels/adapters/slack-bot.ts +881 -0
- package/src/channels/adapters/telegram-bot-classify.ts +155 -0
- package/src/channels/adapters/telegram-bot-format.ts +309 -0
- package/src/channels/adapters/telegram-bot.ts +604 -0
- package/src/channels/engagement.ts +227 -0
- package/src/channels/index.ts +21 -0
- package/src/channels/manager.ts +292 -0
- package/src/channels/membership-cache.ts +116 -0
- package/src/channels/membership-from-history.ts +53 -0
- package/src/channels/membership.ts +30 -0
- package/src/channels/participants.ts +47 -0
- package/src/channels/persistence.ts +209 -0
- package/src/channels/reloadable.ts +28 -0
- package/src/channels/router.ts +1570 -0
- package/src/channels/schema.ts +273 -0
- package/src/channels/types.ts +160 -0
- package/src/cli/channel.ts +403 -0
- package/src/cli/compose-status.ts +95 -0
- package/src/cli/compose.ts +240 -0
- package/src/cli/hostd.ts +163 -0
- package/src/cli/index.ts +27 -0
- package/src/cli/init.ts +592 -0
- package/src/cli/logs.ts +38 -0
- package/src/cli/reload.ts +68 -0
- package/src/cli/restart.ts +66 -0
- package/src/cli/run.ts +77 -0
- package/src/cli/shell.ts +33 -0
- package/src/cli/start.ts +57 -0
- package/src/cli/status.ts +178 -0
- package/src/cli/stop.ts +31 -0
- package/src/cli/tui.ts +35 -0
- package/src/cli/ui.ts +110 -0
- package/src/commands/index.ts +74 -0
- package/src/compose/discover.ts +43 -0
- package/src/compose/index.ts +25 -0
- package/src/compose/logs.ts +162 -0
- package/src/compose/restart.ts +69 -0
- package/src/compose/start.ts +62 -0
- package/src/compose/status.ts +28 -0
- package/src/compose/stop.ts +43 -0
- package/src/config/config.ts +424 -0
- package/src/config/index.ts +25 -0
- package/src/config/providers.ts +234 -0
- package/src/config/reloadable.ts +47 -0
- package/src/container/index.ts +27 -0
- package/src/container/logs.ts +37 -0
- package/src/container/port.ts +137 -0
- package/src/container/shared.ts +290 -0
- package/src/container/shell.ts +58 -0
- package/src/container/start.ts +670 -0
- package/src/container/status.ts +76 -0
- package/src/container/stop.ts +120 -0
- package/src/container/verify-running.ts +149 -0
- package/src/cron/consumer.ts +138 -0
- package/src/cron/index.ts +54 -0
- package/src/cron/reloadable.ts +64 -0
- package/src/cron/scheduler.ts +200 -0
- package/src/cron/schema.ts +96 -0
- package/src/hostd/client.ts +113 -0
- package/src/hostd/daemon.ts +587 -0
- package/src/hostd/index.ts +25 -0
- package/src/hostd/paths.ts +82 -0
- package/src/hostd/portbroker-manager.ts +101 -0
- package/src/hostd/protocol.ts +48 -0
- package/src/hostd/spawn.ts +224 -0
- package/src/hostd/supervisor.ts +60 -0
- package/src/hostd/tailscale.ts +172 -0
- package/src/hostd/version.ts +115 -0
- package/src/init/dockerfile.ts +327 -0
- package/src/init/ensure-deps.ts +152 -0
- package/src/init/gitignore.ts +46 -0
- package/src/init/hatching.ts +60 -0
- package/src/init/index.ts +786 -0
- package/src/init/kakaotalk-auth.ts +114 -0
- package/src/init/models-dev.ts +130 -0
- package/src/init/oauth-login.ts +74 -0
- package/src/init/packagejson.ts +94 -0
- package/src/init/paths.ts +2 -0
- package/src/init/run-bun-install.ts +20 -0
- package/src/markdown/chunk.ts +299 -0
- package/src/markdown/index.ts +1 -0
- package/src/plugin/context.ts +40 -0
- package/src/plugin/define.ts +35 -0
- package/src/plugin/hooks.ts +204 -0
- package/src/plugin/index.ts +63 -0
- package/src/plugin/loader.ts +111 -0
- package/src/plugin/manager.ts +136 -0
- package/src/plugin/registry.ts +145 -0
- package/src/plugin/skills.ts +62 -0
- package/src/plugin/types.ts +172 -0
- package/src/portbroker/bind-with-forward.ts +102 -0
- package/src/portbroker/container-server.ts +305 -0
- package/src/portbroker/forward-result-bus.ts +36 -0
- package/src/portbroker/hostd-client.ts +443 -0
- package/src/portbroker/index.ts +33 -0
- package/src/portbroker/policy.ts +24 -0
- package/src/portbroker/proc-net-tcp.ts +72 -0
- package/src/portbroker/protocol.ts +39 -0
- package/src/reload/client.ts +59 -0
- package/src/reload/index.ts +3 -0
- package/src/reload/registry.ts +60 -0
- package/src/reload/types.ts +13 -0
- package/src/run/bundled-plugins.ts +24 -0
- package/src/run/channel-session-factory.ts +105 -0
- package/src/run/index.ts +432 -0
- package/src/run/plugin-runtime.ts +43 -0
- package/src/run/schema-with-plugins.ts +14 -0
- package/src/secrets/index.ts +13 -0
- package/src/secrets/migrate.ts +95 -0
- package/src/secrets/schema.ts +75 -0
- package/src/secrets/storage.ts +231 -0
- package/src/server/index.ts +436 -0
- package/src/sessions/index.ts +23 -0
- package/src/shared/index.ts +9 -0
- package/src/shared/local-time.ts +21 -0
- package/src/shared/protocol.ts +25 -0
- package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +87 -0
- package/src/skills/typeclaw-channel-telegram-bot/SKILL.md +64 -0
- package/src/skills/typeclaw-config/SKILL.md +643 -0
- package/src/skills/typeclaw-cron/SKILL.md +159 -0
- package/src/skills/typeclaw-git/SKILL.md +89 -0
- package/src/skills/typeclaw-memory/SKILL.md +174 -0
- package/src/skills/typeclaw-monorepo/SKILL.md +175 -0
- package/src/skills/typeclaw-plugins/SKILL.md +594 -0
- package/src/skills/typeclaw-skills/SKILL.md +246 -0
- package/src/stream/broker.ts +161 -0
- package/src/stream/index.ts +16 -0
- package/src/stream/types.ts +69 -0
- package/src/tui/client.ts +45 -0
- package/src/tui/format.ts +317 -0
- package/src/tui/index.ts +225 -0
- package/src/tui/theme.ts +41 -0
- package/typeclaw.schema.json +826 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: typeclaw-skills
|
|
3
|
+
description: Use this skill whenever the user asks you to install, find, list, update, or remove an agent skill, whenever you yourself want to add a new capability via a skill, or whenever you are about to edit any file under `.agents/skills/`. Triggers include "install skill", "add a skill", "find a skill", "update skills", "remove skill", "skill from <repo>", any mention of the `skills` CLI / `bunx skills`, any reference to `SKILL.md`, `skills-lock.json`, `.skill-lock.json`, and any time you read or write under `.agents/skills/<name>/`. Read it before you touch a skill — TypeClaw has three skill layers with different ownership rules, and editing a skill that the `skills` CLI manages will silently get overwritten on the next `bunx skills update`.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# typeclaw-skills
|
|
7
|
+
|
|
8
|
+
You operate inside an agent folder. Skills — markdown files with YAML frontmatter — are how this folder teaches you new procedures, conventions, and APIs without changing your code. The runtime discovers them on session start, parses each `SKILL.md`'s frontmatter, and surfaces the `name` + `description` to you so you can decide when to read the body. **You do not import or invoke skills; you read them when their description matches the current request.**
|
|
9
|
+
|
|
10
|
+
This skill exists so you (a) understand which skills you can edit and which you must not, (b) can install new skills cleanly when the user asks, and (c) can author your own skills without colliding with the rest of the system.
|
|
11
|
+
|
|
12
|
+
## The three skill layers
|
|
13
|
+
|
|
14
|
+
Skills live in three places. The runtime loads them in this order, **first wins on name collisions**:
|
|
15
|
+
|
|
16
|
+
1. **System skills** — bundled with TypeClaw, ship inside the `typeclaw` package itself.
|
|
17
|
+
- **Path**: `<typeclaw-package>/src/skills/<name>/SKILL.md` (resolved via `getBundledSkillsDir()` in `src/agent/index.ts`).
|
|
18
|
+
- **Naming**: every system skill is prefixed `typeclaw-` (with a few legacy exceptions). The prefix is reserved — don't use it for your own skills.
|
|
19
|
+
- **Ownership**: TypeClaw maintainers. Ship via typeclaw releases.
|
|
20
|
+
- **You must not edit these.** They live inside `node_modules/typeclaw/` (or the symlinked dev repo). Edits would be lost on the next `bun install`. If a system skill is wrong, the fix is a typeclaw PR, not a local edit.
|
|
21
|
+
|
|
22
|
+
2. **User skills** — anything under `.agents/skills/<name>/SKILL.md` in the agent folder.
|
|
23
|
+
- **Path**: `.agents/skills/` is created by `typeclaw init` and added to the resource loader by `src/agent/index.ts` only if the directory exists.
|
|
24
|
+
- **Two sub-categories** (see "User-created vs user-downloaded" below — the distinction is critical):
|
|
25
|
+
- **User-created**: the user (or you, on the user's request) hand-authored the skill in this agent folder.
|
|
26
|
+
- **User-downloaded**: the skill was fetched by the upstream `skills` CLI (vercel-labs/skills) from a remote source.
|
|
27
|
+
- **Ownership**: depends on the sub-category. **Get it wrong and you destroy work.**
|
|
28
|
+
|
|
29
|
+
3. **Memory skills** — _muscle memory_. Skills the dreaming subagent distilled from procedures it kept seeing in your daily memory streams.
|
|
30
|
+
- **Path**: `memory/skills/<name>/SKILL.md`.
|
|
31
|
+
- **Author**: the dreaming subagent, every time it consolidates a daily stream. Bar for promoting a fragment-pattern into a skill: multi-step, recurred across at least two distinct fragments, and the trigger conditions are statable as a "Use when..." description.
|
|
32
|
+
- **Loading**: `src/agent/index.ts` adds `<agentDir>/memory/skills/` to `additionalSkillPaths` (existence-gated), so the resource loader auto-discovers every `SKILL.md` there on session start, identical to `.agents/skills/`.
|
|
33
|
+
- **Persistence**: `memory/` is gitignored at the agent level, but the dreaming subagent force-commits its outputs (`MEMORY.md` plus everything under `memory/`, including `memory/skills/`) and applies `skip-worktree` so the human's `git status` stays clean.
|
|
34
|
+
- **You must not write to `memory/skills/` manually.** It is owned by the dreaming subagent. Hand-authored content there will be ignored by the part of the system that dreaming reads (it consolidates from `memory/yyyy-MM-dd.md`, not from existing skill files), and the dreaming subagent may overwrite the same path on a future run. If you want a hand-authored skill, put it in `.agents/skills/`.
|
|
35
|
+
|
|
36
|
+
The collision rule (first wins) means: if a downloaded skill happens to share a name with a bundled one, the bundled one still wins and the downloaded copy is silently dropped with a collision diagnostic. Useful as a safety net, but do not rely on it — pick non-colliding names.
|
|
37
|
+
|
|
38
|
+
## The skill format
|
|
39
|
+
|
|
40
|
+
Every `SKILL.md` is a YAML frontmatter block followed by markdown body:
|
|
41
|
+
|
|
42
|
+
```markdown
|
|
43
|
+
---
|
|
44
|
+
name: my-skill
|
|
45
|
+
description: One paragraph telling you when to read this skill. Triggers and example phrases live here.
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
# my-skill
|
|
49
|
+
|
|
50
|
+
(body)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
**Required frontmatter fields**: `name`, `description`.
|
|
54
|
+
|
|
55
|
+
The runtime parses the frontmatter to populate the `<available_skills>` system-prompt section. Only `name` and `description` are surfaced to you up front — the body is loaded only when you decide to read it. **A weak description is the most common reason a skill never gets activated**; spell out triggers verbatim. Look at any of the bundled `typeclaw-*` skills for the tone.
|
|
56
|
+
|
|
57
|
+
Other frontmatter fields (`metadata.version`, `metadata.author`, `license`, etc.) may be present on downloaded skills because their upstream authors chose to include them. The runtime ignores anything beyond `name` / `description`. Their presence does **not** prove the skill is downloaded — see the next section for the actual heuristic.
|
|
58
|
+
|
|
59
|
+
## User-created vs user-downloaded — the rule that protects you from destroying work
|
|
60
|
+
|
|
61
|
+
Both categories live in `.agents/skills/<name>/`. You cannot tell them apart by looking at the directory contents alone — and the distinction matters because **editing a downloaded skill will be silently overwritten the next time the user (or you) runs `bunx skills update`.**
|
|
62
|
+
|
|
63
|
+
### The reliable signal: the lockfile
|
|
64
|
+
|
|
65
|
+
The `skills` CLI tracks every skill it installs in a lockfile. **A skill is downloaded (managed by the CLI) if and only if its name appears as a key in one of these lockfiles**:
|
|
66
|
+
|
|
67
|
+
- **Project-scoped install** → `./skills-lock.json` at the agent folder root.
|
|
68
|
+
- Schema: `{ "version": 1, "skills": { "<name>": { "source", "ref?", "sourceType", "skillPath?", "computedHash" } } }`
|
|
69
|
+
- **Global install** → `~/.agents/.skill-lock.json` (or `$XDG_STATE_HOME/skills/.skill-lock.json` if the user has XDG configured).
|
|
70
|
+
- Schema: `{ "version": <n>, "skills": { "<name>": { "source", "sourceType", "sourceUrl", "ref?", "skillPath?", "skillFolderHash", "installedAt", "updatedAt", "pluginName?" } }, "dismissed?", "lastSelectedAgents?" }`
|
|
71
|
+
|
|
72
|
+
If the skill name is in either lockfile → **downloaded** → do not edit. If the skill name is in neither lockfile → **user-created** → safe to edit.
|
|
73
|
+
|
|
74
|
+
This is reliable because `bunx skills update` only touches skills it finds in the lockfile. Anything outside the lockfile is invisible to the CLI's update path and will never be overwritten.
|
|
75
|
+
|
|
76
|
+
### Heuristics that look reliable but are not — do not use them
|
|
77
|
+
|
|
78
|
+
- **`metadata.version` in frontmatter.** The `skills` CLI does **not** inject this on install. It only appears because some upstream repos (notably `vercel-labs/agent-skills`) author it into their source. Other registries skip it, and a user can trivially add `version: "1.0.0"` to their hand-authored skill. False negatives and false positives both common.
|
|
79
|
+
- **Symlink detection.** On many agents the CLI uses symlinks, so the agent-specific dir is a symlink back to a canonical copy. **For TypeClaw, this fails entirely** — TypeClaw is treated as a "universal" agent by the CLI (its skills dir already is `.agents/skills/`), so the CLI writes a real directory directly with no symlink to inspect.
|
|
80
|
+
- **Timestamps, file count, or directory layout.** None of these is set by the installer in any consistent way.
|
|
81
|
+
|
|
82
|
+
**Use the lockfile. Nothing else.**
|
|
83
|
+
|
|
84
|
+
### Workflow before editing any skill in `.agents/skills/`
|
|
85
|
+
|
|
86
|
+
1. Note the skill's directory name (e.g. `web-design-guidelines`).
|
|
87
|
+
2. Check the project lockfile: `cat ./skills-lock.json 2>/dev/null | jq '.skills | has("<name>")'`. If `true` → downloaded.
|
|
88
|
+
3. If the project lockfile didn't have it, check the global lockfile: `cat ~/.agents/.skill-lock.json 2>/dev/null | jq '.skills | has("<name>")'`. If `true` → downloaded.
|
|
89
|
+
4. If both are `false` (or the files don't exist), the skill is user-created → safe to edit.
|
|
90
|
+
5. **If downloaded, refuse the edit and tell the user**: "`<name>` is managed by `bunx skills`. Editing it would be lost on the next `bunx skills update`. Options: (a) fork it as a user-created skill under a new name, (b) propose the change upstream at `<source>` from the lockfile entry, or (c) `bunx skills remove <name>` first if you want to take ownership locally."
|
|
91
|
+
6. If user-created, edit normally and **commit immediately** (see `typeclaw-git` skill).
|
|
92
|
+
|
|
93
|
+
## When the user asks "install a skill"
|
|
94
|
+
|
|
95
|
+
The upstream tool is `vercel-labs/skills`, published on npm as `skills`. It has no SDK — it is CLI-only. Always invoke via `bunx skills <command>` so you don't depend on a global install and you don't pollute the container. Bun is already available; `bunx` resolves and caches the binary on first call.
|
|
96
|
+
|
|
97
|
+
### Two TypeClaw rules that override the CLI's defaults
|
|
98
|
+
|
|
99
|
+
Before the workflow: two non-negotiable rules for every install.
|
|
100
|
+
|
|
101
|
+
1. **Always pin `--agent universal`.** The `skills` CLI tries to detect the host agent and writes to that agent's directory by default. Inside the typeclaw container that detection is unreliable, and the wrong choice means the skill lands in a path the typeclaw runtime does not load. `--agent universal` writes directly to `.agents/skills/<name>/`, which is exactly the directory typeclaw's resource loader scans. Hard-code this on every `add` and every `remove`.
|
|
102
|
+
|
|
103
|
+
2. **Always specify `--skill <name>`.** Most skill repos (especially `vercel-labs/agent-skills`) are bundles of many skills, not a single one. Installing without `--skill` runs `@clack/prompts` interactively, and even with `-y` the no-flag default may install everything (`--all`-like behavior depending on the source layout). Either way, the user almost never wants the entire repo. **Refuse to install without an explicit skill name in hand.** If the user named only a repo, list the skills first (`-l`), show the names + descriptions, and ask the user which one(s) to install. Loop until they pick — do not guess, do not install "the obvious one", do not pick the most-starred. The user's intent is the only valid signal.
|
|
104
|
+
|
|
105
|
+
The canonical install command therefore is:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
bunx skills add <source> --agent universal --skill <name> -y
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Never drop `--agent universal`. Never drop `--skill <name>`. Never drop `-y`.
|
|
112
|
+
|
|
113
|
+
### Workflow
|
|
114
|
+
|
|
115
|
+
1. **Identify the source.** The user gave you a repo (`vercel-labs/agent-skills`), a URL, or a name (`skill-creator`). If only a name, search first with `bunx skills find <query>` (see "Searching the registry") and confirm the source with the user.
|
|
116
|
+
2. **List the skills in the source.** Run:
|
|
117
|
+
```bash
|
|
118
|
+
bunx skills add <source> --agent universal -l
|
|
119
|
+
```
|
|
120
|
+
The `-l` flag lists without installing. Output includes each skill's `name` and `description`.
|
|
121
|
+
3. **Show the list to the user and ask which to install.** Do not pick on their behalf, even if the list has only one entry — confirm explicitly. Do not install if they said something ambiguous like "the design one"; ask for the exact name.
|
|
122
|
+
4. **Install with the canonical command** (one skill at a time unless the user explicitly asked for several):
|
|
123
|
+
```bash
|
|
124
|
+
bunx skills add <source> --agent universal --skill <name> -y
|
|
125
|
+
```
|
|
126
|
+
5. **Commit** per "Commit policy" below.
|
|
127
|
+
6. **Tell the user when the skill takes effect.** Skills are loaded on session start, not mid-session. The new skill becomes visible to you on the next prompt the user starts after the install commits.
|
|
128
|
+
|
|
129
|
+
### `<source>` accepts
|
|
130
|
+
|
|
131
|
+
- GitHub shorthand: `vercel-labs/agent-skills`
|
|
132
|
+
- GitHub URL with subpath: `https://github.com/vercel-labs/agent-skills/tree/main/skills/web-design-guidelines`
|
|
133
|
+
- GitLab URL: `https://gitlab.com/<org>/<repo>`
|
|
134
|
+
- Generic git URL: `git@github.com:<org>/<repo>.git`
|
|
135
|
+
- Local path: `./my-skills` (handy for testing user-authored skill bundles)
|
|
136
|
+
|
|
137
|
+
### Other flags — when (not) to use them
|
|
138
|
+
|
|
139
|
+
- `-l, --list` — list available skills in the source without installing. **Always use this in step 2 of the workflow.** Never skip it; the user must see the names before picking.
|
|
140
|
+
- `--all` — install every discovered skill from the source. **Only when the user verbatim said "install all of them" after seeing the list.** Never use `--all` as a shortcut around the listing step.
|
|
141
|
+
- Do **not** pass `-g, --global` from inside the container. Global installs go to `~/.agents/skills/`, which inside the container is the container's ephemeral home — not the user's host. Project-scoped (default) writes to `.agents/skills/` in the agent folder, which is the bind-mounted host directory.
|
|
142
|
+
- Do **not** pass `--copy` unless the user has a specific reason. Default mode is correct for TypeClaw under `--agent universal`.
|
|
143
|
+
- Do **not** pass `--dangerously-accept-openclaw-risks` ever. If a source is flagged, refuse and tell the user.
|
|
144
|
+
|
|
145
|
+
### Searching the registry
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
bunx skills find <query>
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
This hits `https://skills.sh/api/search` — a central registry curated by the `skills` project. Useful when the user says "find me a skill for X" without naming a repo. Returns matches with `name`, `slug`, `source`, `installs`. Pick the most relevant; do not auto-install — read the description first and confirm with the user.
|
|
152
|
+
|
|
153
|
+
### Listing what's installed
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
bunx skills list
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Lists every installed skill in the project. Useful when the user asks "what skills are installed" — prefer this over `ls .agents/skills/` because `list` reads the lockfile and shows source/version metadata too.
|
|
160
|
+
|
|
161
|
+
### Updating
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
bunx skills update --agent universal [<name>...]
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
With no name arguments: updates every skill in the lockfile. With names: updates only those.
|
|
168
|
+
|
|
169
|
+
Update will overwrite any local edits to managed skills. Before running it, if the user has been editing skills, double-check none of those skills are in `skills-lock.json` (use the lockfile heuristic above).
|
|
170
|
+
|
|
171
|
+
### Removing
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
bunx skills remove <name> --agent universal -y
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Removes the skill directory and the lockfile entry. After this, the name is free to reuse for a user-created skill. Pin `--agent universal` here too, for the same reason as install — the CLI's auto-detection is unreliable inside the container.
|
|
178
|
+
|
|
179
|
+
## Commit policy
|
|
180
|
+
|
|
181
|
+
`.agents/skills/` and `skills-lock.json` are **tracked** in git, not gitignored. Every install/update/remove mutates the working tree, and `typeclaw-git` says: every edit gets committed immediately with decision context.
|
|
182
|
+
|
|
183
|
+
The `skills` CLI does not commit on your behalf. After every successful mutating call, you commit:
|
|
184
|
+
|
|
185
|
+
```bash
|
|
186
|
+
git add .agents/skills/ skills-lock.json && \
|
|
187
|
+
git commit -m "skills: install <source>" -m "<why the user wanted this skill>"
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Subject convention: `skills: install <source>`, `skills: update <names>`, `skills: remove <names>`, `skills: author <name>` (for hand-written ones).
|
|
191
|
+
|
|
192
|
+
If the CLI exited non-zero, do **not** commit. The working tree may be partially mutated. Either:
|
|
193
|
+
|
|
194
|
+
1. Inspect with `git status` and decide if anything useful landed,
|
|
195
|
+
2. Reset the affected paths: `git checkout -- .agents/skills skills-lock.json && git clean -fd .agents/skills` and tell the user the install failed cleanly,
|
|
196
|
+
3. Surface the CLI stderr to the user so they can decide.
|
|
197
|
+
|
|
198
|
+
Do not invent a reason for a half-applied install. The lockfile and the working tree should agree at the moment of every commit.
|
|
199
|
+
|
|
200
|
+
If the agent folder is not a git repo, `bunx skills` still works — it just means there's no commit to make. Tell the user once: "Heads up, this folder isn't a git repo, so I can't snapshot the install."
|
|
201
|
+
|
|
202
|
+
## Authoring a user-created skill
|
|
203
|
+
|
|
204
|
+
When the user says "write me a skill for X" or you decide a recurring procedure deserves to be a skill:
|
|
205
|
+
|
|
206
|
+
1. **Pick a name that does not collide.** Check both `<typeclaw-package>/src/skills/` (system) and `.agents/skills/` (user). Prefer specific names (`postgres-backups`, not `db`). Do not prefix with `typeclaw-` — that prefix is reserved for system skills shipped by typeclaw.
|
|
207
|
+
2. **Create the directory**: `mkdir -p .agents/skills/<name>`.
|
|
208
|
+
3. **Write `SKILL.md`** with YAML frontmatter:
|
|
209
|
+
|
|
210
|
+
```markdown
|
|
211
|
+
---
|
|
212
|
+
name: <name>
|
|
213
|
+
description: One paragraph. Spell out triggers verbatim — phrases the user is likely to type, file types, error messages. The runtime decides whether to surface this skill to you based on the description match. A vague description means the skill never activates.
|
|
214
|
+
---
|
|
215
|
+
|
|
216
|
+
# <name>
|
|
217
|
+
|
|
218
|
+
(body — purpose, workflow steps, examples, things-you-must-not-do)
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
4. **Match the bundled-skill voice.** Read one of the `typeclaw-*` skills first. Decision-grounded, evidence-pinned, present-tense, "Things you must not do" section near the bottom.
|
|
222
|
+
5. **Do not add `version` or `metadata` fields.** They are ignored by the runtime and would only confuse the lockfile heuristic for future you.
|
|
223
|
+
6. **Commit** with `typeclaw-git`'s rule: `skills: author <name>` subject, body explains why the procedure deserved to be a skill.
|
|
224
|
+
7. The skill takes effect on the **next session** — the resource loader scans on session start, not mid-session. Tell the user: "Authored `<name>`. It loads on the next session — start a fresh prompt to use it."
|
|
225
|
+
|
|
226
|
+
## Things you must not do
|
|
227
|
+
|
|
228
|
+
- **Do not edit a skill listed in `skills-lock.json` or `~/.agents/.skill-lock.json`.** It is managed by `bunx skills` and your edit will be silently overwritten by `bunx skills update`. If the user wants to change a downloaded skill, the right answer is one of: fork it locally under a new name, propose the change upstream, or remove it from the lockfile first.
|
|
229
|
+
- **Do not edit anything under `<typeclaw-package>/src/skills/`** (system skills). They live inside `node_modules/` (or the dev symlink target) and edits do not survive `bun install`. If a system skill is wrong, escalate to a typeclaw PR.
|
|
230
|
+
- **Do not write to `memory/skills/`** manually. That directory is owned by the dreaming subagent (the muscle-memory layer). Hand-authored skills go in `.agents/skills/`. If you need to remove a stale muscle-memory skill, `rm -rf memory/skills/<name>/` is the user's call — surface the request to the user, do not delete on their behalf.
|
|
231
|
+
- **Do not run `skills` without `bunx`.** A bare `skills add ...` call relies on a global install that may not be present in the container; `bunx` resolves and caches it on demand without polluting global state.
|
|
232
|
+
- **Do not omit `-y` from `bunx skills` calls.** Without it, `@clack/prompts` blocks waiting for TTY input that never arrives, and the call hangs forever.
|
|
233
|
+
- **Do not run `bunx skills add/remove/update` without `--agent universal`.** The CLI's auto-detection is unreliable in the container; without the flag the skill may land in a directory typeclaw does not load, and the user sees nothing happen even though the install reported success.
|
|
234
|
+
- **Do not run `bunx skills add` without an explicit `--skill <name>`.** Most repos hold many skills. Installing without naming one means installing everything the source happens to contain, polluting `.agents/skills/` with skills the user never asked for. If you don't know which skill to install, list first with `-l`, show the names to the user, and ask. Loop until they pick. Never guess.
|
|
235
|
+
- **Do not pass `-g, --global` from inside the container.** Global writes go to the container's `~/.agents/`, which is ephemeral — the user expects skills to land in their bind-mounted agent folder.
|
|
236
|
+
- **Do not commit a half-finished install.** If `bunx skills` exited non-zero, the working tree is in an unknown state. Inspect, reset, or surface the error — do not paper over it with a commit.
|
|
237
|
+
- **Do not invent a `metadata.version` or other frontmatter to mark "this is mine".** It does not affect runtime behavior, and it actively misleads the lockfile heuristic.
|
|
238
|
+
- **Do not use the `typeclaw-` prefix for user-authored skills.** That namespace is for skills shipped inside the typeclaw package. Use a domain-specific name.
|
|
239
|
+
- **Do not auto-install a skill `bunx skills find` returned without showing it to the user.** Always read the description and confirm before `bunx skills add`. Skill bodies become part of your context — installing one is granting it a procedural channel into your behavior.
|
|
240
|
+
|
|
241
|
+
## What this skill does _not_ cover
|
|
242
|
+
|
|
243
|
+
- **Plugin authoring** (`definePlugin`, contributing tools/subagents/cron jobs, plugin-owned config blocks) — see `typeclaw-plugins`. Plugins are code; skills are markdown.
|
|
244
|
+
- **Cron-driven skill installation** — if the user wants `bunx skills update` to run on a schedule, that's an `exec` cron job; see `typeclaw-cron`.
|
|
245
|
+
- **Authoring muscle-memory skills directly** — that is the dreaming subagent's job, not yours. The layer is documented under "Memory skills" above so you know where the files come from and that you must not edit them; how dreaming decides what to promote lives in the dreaming subagent's own system prompt (`src/bundled-plugins/memory/dreaming.ts`).
|
|
246
|
+
- **The `skills` CLI's own internals** — schema details, alternate install modes, registry implementation. Defer to `bunx skills --help` and the upstream README at https://github.com/vercel-labs/skills.
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CreateStreamOptions,
|
|
3
|
+
Stream,
|
|
4
|
+
StreamMessage,
|
|
5
|
+
StreamMessageId,
|
|
6
|
+
StreamMessageInput,
|
|
7
|
+
SubscribeFilter,
|
|
8
|
+
SubscribeListener,
|
|
9
|
+
TargetFilter,
|
|
10
|
+
Unsubscribe,
|
|
11
|
+
} from './types'
|
|
12
|
+
import { StreamTimeoutError } from './types'
|
|
13
|
+
|
|
14
|
+
type Subscription = {
|
|
15
|
+
filter: SubscribeFilter
|
|
16
|
+
listener: SubscribeListener
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const DEFAULT_AWAIT_TIMEOUT_MS = 30_000
|
|
20
|
+
const DEFAULT_HISTORY_SIZE = 1000
|
|
21
|
+
|
|
22
|
+
export function createStream(opts: CreateStreamOptions = {}): Stream {
|
|
23
|
+
const subscriptions = new Set<Subscription>()
|
|
24
|
+
const historySize = Math.max(0, opts.historySize ?? DEFAULT_HISTORY_SIZE)
|
|
25
|
+
const history: StreamMessage[] = []
|
|
26
|
+
let counter = 0
|
|
27
|
+
|
|
28
|
+
function generateId(): StreamMessageId {
|
|
29
|
+
counter++
|
|
30
|
+
return `s_${Date.now().toString(36)}_${counter.toString(36)}_${Math.random().toString(36).slice(2, 8)}`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function record(msg: StreamMessage): void {
|
|
34
|
+
if (historySize === 0) return
|
|
35
|
+
history.push(msg)
|
|
36
|
+
if (history.length > historySize) history.shift()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function deliver(msg: StreamMessage): void {
|
|
40
|
+
for (const sub of subscriptions) {
|
|
41
|
+
if (!matchesFilter(sub.filter, msg)) continue
|
|
42
|
+
try {
|
|
43
|
+
const result = sub.listener(msg)
|
|
44
|
+
if (result instanceof Promise) result.catch((err) => logListenerError(msg, err))
|
|
45
|
+
} catch (err) {
|
|
46
|
+
logListenerError(msg, err)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function publishMessage(input: StreamMessageInput): StreamMessage {
|
|
52
|
+
const msg: StreamMessage = {
|
|
53
|
+
id: generateId(),
|
|
54
|
+
ts: Date.now(),
|
|
55
|
+
target: input.target,
|
|
56
|
+
payload: input.payload,
|
|
57
|
+
...(input.replyTo !== undefined ? { replyTo: input.replyTo } : {}),
|
|
58
|
+
...(input.meta !== undefined ? { meta: input.meta } : {}),
|
|
59
|
+
}
|
|
60
|
+
record(msg)
|
|
61
|
+
deliver(msg)
|
|
62
|
+
return msg
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
publish(input) {
|
|
67
|
+
return publishMessage(input).id
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
publishAndAwait(input, opts) {
|
|
71
|
+
const timeoutMs = opts?.timeoutMs ?? DEFAULT_AWAIT_TIMEOUT_MS
|
|
72
|
+
return new Promise<StreamMessage>((resolve, reject) => {
|
|
73
|
+
const requestId = generateId()
|
|
74
|
+
const requestMessage: StreamMessage = {
|
|
75
|
+
id: requestId,
|
|
76
|
+
ts: Date.now(),
|
|
77
|
+
target: input.target,
|
|
78
|
+
payload: input.payload,
|
|
79
|
+
...(input.replyTo !== undefined ? { replyTo: input.replyTo } : {}),
|
|
80
|
+
...(input.meta !== undefined ? { meta: input.meta } : {}),
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const timer = setTimeout(() => {
|
|
84
|
+
unsub()
|
|
85
|
+
reject(new StreamTimeoutError(requestId, timeoutMs))
|
|
86
|
+
}, timeoutMs)
|
|
87
|
+
|
|
88
|
+
const subscription: Subscription = {
|
|
89
|
+
filter: { replyTo: requestId },
|
|
90
|
+
listener: (reply) => {
|
|
91
|
+
clearTimeout(timer)
|
|
92
|
+
unsub()
|
|
93
|
+
resolve(reply)
|
|
94
|
+
},
|
|
95
|
+
}
|
|
96
|
+
subscriptions.add(subscription)
|
|
97
|
+
const unsub = () => subscriptions.delete(subscription)
|
|
98
|
+
|
|
99
|
+
record(requestMessage)
|
|
100
|
+
deliver(requestMessage)
|
|
101
|
+
})
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
reply(toStreamMessageId, payload) {
|
|
105
|
+
return publishMessage({
|
|
106
|
+
target: { kind: 'broadcast' },
|
|
107
|
+
payload,
|
|
108
|
+
replyTo: toStreamMessageId,
|
|
109
|
+
}).id
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
subscribe(filter, listener) {
|
|
113
|
+
const subscription: Subscription = { filter, listener }
|
|
114
|
+
subscriptions.add(subscription)
|
|
115
|
+
const unsubscribe: Unsubscribe = () => {
|
|
116
|
+
subscriptions.delete(subscription)
|
|
117
|
+
}
|
|
118
|
+
return unsubscribe
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
scan(filter) {
|
|
122
|
+
const since = filter?.sinceTs
|
|
123
|
+
const limit = filter?.limit
|
|
124
|
+
const filtered: StreamMessage[] = []
|
|
125
|
+
for (let i = history.length - 1; i >= 0; i--) {
|
|
126
|
+
const msg = history[i]
|
|
127
|
+
if (!msg) continue
|
|
128
|
+
if (since !== undefined && msg.ts < since) break
|
|
129
|
+
if (filter && !matchesFilter(filter, msg)) continue
|
|
130
|
+
filtered.push(msg)
|
|
131
|
+
if (limit !== undefined && filtered.length >= limit) break
|
|
132
|
+
}
|
|
133
|
+
return filtered.reverse()
|
|
134
|
+
},
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function matchesFilter(filter: SubscribeFilter, msg: StreamMessage): boolean {
|
|
139
|
+
if (filter.replyTo !== undefined && msg.replyTo !== filter.replyTo) return false
|
|
140
|
+
if (filter.target !== undefined && !matchesTarget(filter.target, msg)) return false
|
|
141
|
+
return true
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function matchesTarget(filter: TargetFilter, msg: StreamMessage): boolean {
|
|
145
|
+
if (filter.kind !== msg.target.kind) return false
|
|
146
|
+
switch (filter.kind) {
|
|
147
|
+
case 'broadcast':
|
|
148
|
+
return true
|
|
149
|
+
case 'session':
|
|
150
|
+
return filter.sessionId === undefined || filter.sessionId === (msg.target as { sessionId: string }).sessionId
|
|
151
|
+
case 'new-session':
|
|
152
|
+
return filter.subagent === undefined || filter.subagent === (msg.target as { subagent: string }).subagent
|
|
153
|
+
case 'cron':
|
|
154
|
+
return filter.jobId === undefined || filter.jobId === (msg.target as { jobId: string }).jobId
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function logListenerError(msg: StreamMessage, err: unknown): void {
|
|
159
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
160
|
+
console.error(`[stream] subscriber error for message ${msg.id}: ${message}`)
|
|
161
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export { createStream } from './broker'
|
|
2
|
+
export {
|
|
3
|
+
type CreateStreamOptions,
|
|
4
|
+
type PublishAndAwaitOptions,
|
|
5
|
+
type ScanFilter,
|
|
6
|
+
type Stream,
|
|
7
|
+
type StreamMessage,
|
|
8
|
+
type StreamMessageId,
|
|
9
|
+
type StreamMessageInput,
|
|
10
|
+
type StreamTarget,
|
|
11
|
+
type SubscribeFilter,
|
|
12
|
+
type SubscribeListener,
|
|
13
|
+
type TargetFilter,
|
|
14
|
+
StreamTimeoutError,
|
|
15
|
+
type Unsubscribe,
|
|
16
|
+
} from './types'
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
export type StreamMessageId = string
|
|
2
|
+
|
|
3
|
+
export type StreamTarget =
|
|
4
|
+
| { kind: 'broadcast' }
|
|
5
|
+
| { kind: 'session'; sessionId: string }
|
|
6
|
+
| { kind: 'new-session'; subagent: string }
|
|
7
|
+
| { kind: 'cron'; jobId: string }
|
|
8
|
+
|
|
9
|
+
export type StreamMessage = {
|
|
10
|
+
id: StreamMessageId
|
|
11
|
+
ts: number
|
|
12
|
+
target: StreamTarget
|
|
13
|
+
payload: unknown
|
|
14
|
+
replyTo?: StreamMessageId
|
|
15
|
+
meta?: Record<string, string>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type StreamMessageInput = {
|
|
19
|
+
target: StreamTarget
|
|
20
|
+
payload: unknown
|
|
21
|
+
replyTo?: StreamMessageId
|
|
22
|
+
meta?: Record<string, string>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type SubscribeFilter = {
|
|
26
|
+
target?: TargetFilter
|
|
27
|
+
replyTo?: StreamMessageId
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type ScanFilter = SubscribeFilter & {
|
|
31
|
+
sinceTs?: number
|
|
32
|
+
limit?: number
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type TargetFilter =
|
|
36
|
+
| { kind: 'broadcast' }
|
|
37
|
+
| { kind: 'session'; sessionId?: string }
|
|
38
|
+
| { kind: 'new-session'; subagent?: string }
|
|
39
|
+
| { kind: 'cron'; jobId?: string }
|
|
40
|
+
|
|
41
|
+
export type Unsubscribe = () => void
|
|
42
|
+
|
|
43
|
+
export type SubscribeListener = (msg: StreamMessage) => unknown
|
|
44
|
+
|
|
45
|
+
export type PublishAndAwaitOptions = {
|
|
46
|
+
timeoutMs?: number
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type Stream = {
|
|
50
|
+
publish(message: StreamMessageInput): StreamMessageId
|
|
51
|
+
publishAndAwait(message: StreamMessageInput, opts?: PublishAndAwaitOptions): Promise<StreamMessage>
|
|
52
|
+
reply(toStreamMessageId: StreamMessageId, payload: unknown): StreamMessageId
|
|
53
|
+
subscribe(filter: SubscribeFilter, onMessage: SubscribeListener): Unsubscribe
|
|
54
|
+
scan(filter?: ScanFilter): StreamMessage[]
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type CreateStreamOptions = {
|
|
58
|
+
historySize?: number
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export class StreamTimeoutError extends Error {
|
|
62
|
+
constructor(
|
|
63
|
+
public readonly requestId: StreamMessageId,
|
|
64
|
+
public readonly timeoutMs: number,
|
|
65
|
+
) {
|
|
66
|
+
super(`stream: timed out after ${timeoutMs}ms waiting for reply to ${requestId}`)
|
|
67
|
+
this.name = 'StreamTimeoutError'
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { ClientMessage, ServerMessage } from '@/shared'
|
|
2
|
+
|
|
3
|
+
export type Client = Awaited<ReturnType<typeof createClient>>
|
|
4
|
+
|
|
5
|
+
export async function createClient(url: string) {
|
|
6
|
+
const ws = new WebSocket(url)
|
|
7
|
+
const listeners = new Set<(msg: ServerMessage) => void>()
|
|
8
|
+
// Buffer messages that arrive before any listener is registered. In-process
|
|
9
|
+
// connections (typeclaw run's local tui) deliver the first server frame
|
|
10
|
+
// before the caller has a chance to attach onMessage.
|
|
11
|
+
const pending: ServerMessage[] = []
|
|
12
|
+
|
|
13
|
+
ws.addEventListener('message', (event) => {
|
|
14
|
+
const msg = JSON.parse(String(event.data)) as ServerMessage
|
|
15
|
+
if (listeners.size === 0) {
|
|
16
|
+
pending.push(msg)
|
|
17
|
+
return
|
|
18
|
+
}
|
|
19
|
+
for (const fn of listeners) fn(msg)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
ws.addEventListener('close', () => {
|
|
23
|
+
listeners.clear()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
await new Promise<void>((resolve, reject) => {
|
|
27
|
+
ws.addEventListener('open', () => resolve(), { once: true })
|
|
28
|
+
ws.addEventListener('error', (err) => reject(err), { once: true })
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
onMessage: (fn: (msg: ServerMessage) => void) => {
|
|
33
|
+
listeners.add(fn)
|
|
34
|
+
if (pending.length > 0) {
|
|
35
|
+
const buffered = pending.splice(0)
|
|
36
|
+
for (const msg of buffered) fn(msg)
|
|
37
|
+
}
|
|
38
|
+
return () => listeners.delete(fn)
|
|
39
|
+
},
|
|
40
|
+
onClose: (fn: () => void) => ws.addEventListener('close', fn),
|
|
41
|
+
onError: (fn: (err: unknown) => void) => ws.addEventListener('error', fn),
|
|
42
|
+
send: (msg: ClientMessage) => ws.send(JSON.stringify(msg)),
|
|
43
|
+
close: () => ws.close(),
|
|
44
|
+
}
|
|
45
|
+
}
|