@ghostlytools/cli 1.0.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/README.md +405 -0
- package/SKILL.md +282 -0
- package/dist/assets/argon2id-088uQfnH.js +645 -0
- package/dist/assets/argon2id-088uQfnH.js.map +1 -0
- package/dist/assets/bark_ffi_wasm_bg-Bxru_BVW.wasm +0 -0
- package/dist/assets/bark_ffi_wasm_bg-EY2uc0Bx.js +6 -0
- package/dist/assets/bark_ffi_wasm_bg-EY2uc0Bx.js.map +1 -0
- package/dist/assets/bitcoindevkit_bg-B-iLWrmX.js +6 -0
- package/dist/assets/bitcoindevkit_bg-B-iLWrmX.js.map +1 -0
- package/dist/assets/bitcoindevkit_bg-CRkZTSbO.wasm +0 -0
- package/dist/assets/breez_sdk_spark_wasm_bg.wasm +0 -0
- package/dist/assets/endpoint-h3cqRyRz.js +185 -0
- package/dist/assets/endpoint-h3cqRyRz.js.map +1 -0
- package/dist/assets/fedimintWorker.mjs +95 -0
- package/dist/assets/fedimint_client_wasm_bg-BtHHlFpp.js +6 -0
- package/dist/assets/fedimint_client_wasm_bg-BtHHlFpp.js.map +1 -0
- package/dist/assets/fedimint_client_wasm_bg-DeFtkIv6.wasm +0 -0
- package/dist/assets/fileBytes-ByS16quP.js +154 -0
- package/dist/assets/fileBytes-ByS16quP.js.map +1 -0
- package/dist/assets/fileBytesIdb-COzoilo9.js +230 -0
- package/dist/assets/fileBytesIdb-COzoilo9.js.map +1 -0
- package/dist/assets/fileBytesOpfs-B27wAMUh.js +145 -0
- package/dist/assets/fileBytesOpfs-B27wAMUh.js.map +1 -0
- package/dist/assets/ghostly_iroh_web-CPa_YEfb.js +1154 -0
- package/dist/assets/ghostly_iroh_web-CPa_YEfb.js.map +1 -0
- package/dist/assets/ghostly_iroh_web_bg.wasm +0 -0
- package/dist/assets/hyperdhtRelay-BKsFpPjr.js +342 -0
- package/dist/assets/hyperdhtRelay-BKsFpPjr.js.map +1 -0
- package/dist/assets/legacy_ciphers-JJOu0JXL.js +5185 -0
- package/dist/assets/legacy_ciphers-JJOu0JXL.js.map +1 -0
- package/dist/assets/mainline-DOncVbJN.js +176 -0
- package/dist/assets/mainline-DOncVbJN.js.map +1 -0
- package/dist/assets/nacl-fast-CDhNCYHs.js +1512 -0
- package/dist/assets/nacl-fast-CDhNCYHs.js.map +1 -0
- package/dist/assets/noble_curves-DDWn5AH0.js +2978 -0
- package/dist/assets/noble_curves-DDWn5AH0.js.map +1 -0
- package/dist/assets/noble_hashes-B7AZyFyS.js +427 -0
- package/dist/assets/noble_hashes-B7AZyFyS.js.map +1 -0
- package/dist/assets/openpgp-BBFr-zHQ.js +16573 -0
- package/dist/assets/openpgp-BBFr-zHQ.js.map +1 -0
- package/dist/assets/server-5T5l8hfy.js +28293 -0
- package/dist/assets/server-5T5l8hfy.js.map +1 -0
- package/dist/assets/sha512-BYId-G-T.js +1034 -0
- package/dist/assets/sha512-BYId-G-T.js.map +1 -0
- package/dist/assets/src-DptDr2et.js +20971 -0
- package/dist/assets/src-DptDr2et.js.map +1 -0
- package/dist/assets/storage-Bya5b2Xt.js +1648 -0
- package/dist/assets/storage-Bya5b2Xt.js.map +1 -0
- package/dist/assets/tree-store-N362nuzZ.js +1031 -0
- package/dist/assets/tree-store-N362nuzZ.js.map +1 -0
- package/dist/assets/unbzip2-stream-B1C-JX9L.js +681 -0
- package/dist/assets/unbzip2-stream-B1C-JX9L.js.map +1 -0
- package/dist/assets/web-OUQl4Q7L.js +3802 -0
- package/dist/assets/web-OUQl4Q7L.js.map +1 -0
- package/dist/ghostly.mjs +17087 -0
- package/dist/ghostly.mjs.map +1 -0
- package/package.json +76 -0
package/README.md
ADDED
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
# ghostly: Ghostly for bots and terminals
|
|
2
|
+
|
|
3
|
+
`ghostly` runs **the Ghostly app's own engine** without a screen: the same chats, groups and protocol as the web app,
|
|
4
|
+
the extension and the Desktop, on Node. A bot keeps a profile online with `ghostly daemon`, reads what happens with
|
|
5
|
+
`ghostly listen` (JSON lines), and acts with the other commands (JSON answers). The contract behind it, and why it
|
|
6
|
+
is built this way, is [WISP 11xx](../../docs/wisps/11xx-headless.md). A guided tour: [docs/CLI.md](../../docs/CLI.md).
|
|
7
|
+
|
|
8
|
+
> Status: phases 1 to 5 (profiles, pictures and backups, invites, one chat, private and community groups with their
|
|
9
|
+
> admin tools and hubs, the event stream, hooks and agent turns, wallets and payments, files and voice notes, identity
|
|
10
|
+
> proofs, shared web apps, voice calls, typing, replies, edits, reactions and forwards, Pkarr over the Mainline DHT;
|
|
11
|
+
> the npm package `@ghostlytools/cli`, published from 1.0); every engine call is already
|
|
12
|
+
> reachable through `ghostly engine <method>`. The older Rust `ghostly-cli` (the `cli/` folder) stays as the
|
|
13
|
+
> compatibility client for v0.4 chats; it is not this package, and from 1.0 the release no longer ships it.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
From 1.0: `npm install -g @ghostlytools/cli`. Until then, from this repository:
|
|
18
|
+
<!-- release-1.0: "From 1.0:" becomes "Install:", and "Until then, from this repository" becomes "Or from this repository". -->
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm install
|
|
22
|
+
npm run build -w @ghostlytools/cli
|
|
23
|
+
npm pack -w @ghostlytools/cli # ghostlytools-cli-<version>.tgz: the bundle, its WebAssembly, README and SKILL.md
|
|
24
|
+
npm install -g ./ghostlytools-cli-*.tgz # the `ghostly` command, with its dependencies from npm
|
|
25
|
+
ghostly --version
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Node 22.12 or newer. WebRTC comes from `node-datachannel` (a native
|
|
29
|
+
module with prebuilt binaries for Linux, macOS and Windows); without it the CLI still runs, over HyperDHT, Iroh and
|
|
30
|
+
the DHT, and groups are unavailable.
|
|
31
|
+
|
|
32
|
+
The bundle holds the app's engine (`@ghostly/browser`, `@ghostly/core`, Iroh's and Breez's WebAssembly); every other
|
|
33
|
+
package it imports is a dependency in package.json, which `test/packageDeps.test.ts` keeps true.
|
|
34
|
+
|
|
35
|
+
## Five minutes
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
ghostly profile set --name "Echo bot" # the name contacts see
|
|
39
|
+
ghostly daemon --detach # keep the profile online
|
|
40
|
+
ghostly invite create --label alice # {"chat":"…","invite":"ghostly1…","link":"https://ghostly.tools/#ghostly1…"}
|
|
41
|
+
ghostly chat wait alice --until live # once Alice joined (in the app, or another ghostly)
|
|
42
|
+
ghostly send alice "hello" # {"chat":"…","messageId":"me_…","delivery":"sending"}
|
|
43
|
+
ghostly listen # one JSON event per line, until stopped
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
An echo bot ([examples/echo-bot.sh](examples/echo-bot.sh); the event arrives on the command's stdin, never in its
|
|
47
|
+
arguments, so a contact's text cannot reach the shell):
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
ghostly listen --type message.received --cursor ~/.ghostly/echo.cursor --exec '
|
|
51
|
+
event="$(cat)"
|
|
52
|
+
printf "echo: %s" "$(jq -r .message.text <<<"$event")" | ghostly send "$(jq -r .chat <<<"$event")" --stdin'
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The same bot on the socket, without jq: [examples/echo-bot.mjs](examples/echo-bot.mjs). A payment bot that takes
|
|
56
|
+
requests, tips and "balance" in a chat, with test coins: [examples/payment-bot.mjs](examples/payment-bot.mjs).
|
|
57
|
+
A Claude Code agent on `listen --turns` with an allowlist (it will not start without one), "thinking" while it works
|
|
58
|
+
and `send --reply` for its answer: [examples/claude-code-agent.sh](examples/claude-code-agent.sh).
|
|
59
|
+
|
|
60
|
+
## Profiles
|
|
61
|
+
|
|
62
|
+
Each profile is a folder, `~/.ghostly/profiles/<name>/` (0700; files 0600), with its own chats, keys and wallets.
|
|
63
|
+
`--profile <name>` (or `GHOSTLY_PROFILE`) picks one; `ghostly profile use <name>` sets the current one;
|
|
64
|
+
`--home <dir>` (or `GHOSTLY_HOME`) moves the whole folder. The `default` profile is made on first use, others with
|
|
65
|
+
`ghostly profile create <name>`.
|
|
66
|
+
|
|
67
|
+
Only one process opens a profile at a time. When a daemon runs it, every command goes through the daemon's socket.
|
|
68
|
+
When none does, a command runs the profile itself for as long as it needs (a **one-shot**) and leaves: `send` then
|
|
69
|
+
waits until the message went out. Bots should run the daemon: a one-shot is offline between commands.
|
|
70
|
+
|
|
71
|
+
## Commands
|
|
72
|
+
|
|
73
|
+
Every command prints one JSON object on stdout. A failure prints `{"error":{"code","message"}}` and exits with:
|
|
74
|
+
`1` failed (the engine or the network refused), `2` usage, `3` not found, `4` timed out, `5` needs a confirmation
|
|
75
|
+
(`--force`, `--yes`, `--confirm-real`). A `<chat>` is its id, a unique prefix of it, or its name; a `<group>` too.
|
|
76
|
+
|
|
77
|
+
The rows are in alphabetical order of their first command, and every command of `ghostly help` has one
|
|
78
|
+
(`test/readme.test.ts` checks both).
|
|
79
|
+
|
|
80
|
+
| Command | What it does |
|
|
81
|
+
|---|---|
|
|
82
|
+
| `call auto [on\|off] [--from <chat>]… [--rate n]` | Answer calls by themselves, from anyone or the chats named (kept in the profile) |
|
|
83
|
+
| `call hangup [<chat\|call>]`, `call list`, `call flush [<chat\|call>]` | Hang up (or decline); calls on now; drop the audio queued and not played yet |
|
|
84
|
+
| `call pipe [<chat\|call>]` | A call's audio on stdin and stdout, for shell pipelines (sox, ffmpeg) |
|
|
85
|
+
| `call start <chat> [--rate 48000]`, `call answer [<chat\|call>] [--rate n]` | Voice calls (daemon): the result names the call's audio socket ([Calls](#calls)) |
|
|
86
|
+
| `chat accept <chat> <method> [--off] [--networks mainnet,testnet]` | Which ways of paying the chat takes |
|
|
87
|
+
| `chat connect <chat>`, `chat disconnect <chat> [--hold <minutes>]` | Reconnect now (ends a hold); close the live session, or with `--hold` stay off the direct link that long ([Staying off the direct link](#staying-off-the-direct-link)) |
|
|
88
|
+
| `chat history <chat> [--limit n] [--before x] [--after x]` | Messages, oldest first; `x` is a message id or a time in ms |
|
|
89
|
+
| `chat list`, `chat show <chat>` | Chats, and one chat's connection: transports, last attempt, comparison code |
|
|
90
|
+
| `chat pay <chat> <sats> [--memo t] [--network n] [--confirm-real]` | Ecash to a contact |
|
|
91
|
+
| `chat rename <chat> <name>`, `chat remove <chat> --yes` | Name it here; delete it (keys and history) here |
|
|
92
|
+
| `chat request <chat> <sats> [--memo t] [--method m] [--rail r]`, `chat pay-request <chat> <payment>` | Ask a contact to pay; pay the contact's request |
|
|
93
|
+
| `chat transport <chat> <auto\|dht\|webrtc\|iroh\|hyperdht>` | What carries the chat |
|
|
94
|
+
| `chat verify <chat> --code <code>` | Mark the contact verified after comparing the codes out of band |
|
|
95
|
+
| `chat wait <chat> [--until live\|text\|paired] [--timeout s]` | Wait for a chat to go live, carry text, or see its contact |
|
|
96
|
+
| `daemon [--detach]`, `daemon status`, `daemon stop`, `daemon restart` | Keep the profile online; `restart` stops it and starts this release's code in the background ([After an upgrade](#after-an-upgrade)). Ctrl-C, SIGTERM and the terminal closing (SIGHUP) stop it cleanly |
|
|
97
|
+
| `edit <chat> <message> [text… \| --text <text> \| --stdin] [--force] [--wait none\|confirmed] [--timeout s]` | Replace the text of a message you sent (1:1 chats, `group edit` for a group; `<message>` is the `messageId` `send` gave, or its wire id). The contact sees it in place, marked edited; `--wait confirmed` waits for its app to confirm (the default without a daemon). At most 100 edits a message, no time limit; an older contact app gets it once it shows edits |
|
|
98
|
+
| `engine <method> [json \| -] [--confirm-real] [--show-secret]`, `engine --list` | Any call of the app's engine |
|
|
99
|
+
| `events [--since seq]` | What the event journal holds, without following |
|
|
100
|
+
| `file list <chat>`, `file accept\|decline\|pause\|resume\|cancel [<chat>] <file>` | Transfers; a file over 25 MiB waits for `file accept` (files/3). A file's id names its chat, so `<chat>` may be left out |
|
|
101
|
+
| `file resend [<chat>] <file>`, `file request [<chat>] <file>` | A file that stopped moving: sent again from here, or asked for again from the contact; either goes on from the bytes the receiver holds (files/3) |
|
|
102
|
+
| `file save [<chat>] <file> [--dir d \| --path p] [--force] [--wait [--timeout s]]` | Write a received file to disk (never over one without `--force`; an unfinished one says how many bytes are here; `--wait` waits for it first) |
|
|
103
|
+
| `file send <chat> <path> [--name n] [--mime t] [--voice [ms] [--peaks …]] [--reply <message>]` | A file, or a voice note (its length and waveform measured from the file unless given); `--reply` quotes a message, as `send --reply` does |
|
|
104
|
+
| `file wait [<chat>] <file> [--timeout s]` | Wait until a transfer ends: exit `0` when the file is all here, `1` with the transfer's error when it failed (`details.retry`: `file resend` can go on), `4` on timeout (default 300 s) |
|
|
105
|
+
| `forward <chat\|group> <message>… --to <chat\|group>… [--force] [--wait none\|sent] [--timeout s]` | Forward messages to up to 5 chats and groups (`--to` again for each; `group:<id>` names a group when a chat has the same name; an id that starts with a dash is taken as it is, `--to -Ab…` or `--to=-Ab…`). Each is a new message of yours that says it was forwarded (`forwarded`, the hop count) and nothing of who wrote it. Files go from the bytes this profile holds, never fetched again; a group takes texts only. A text that looks like a seed, a key or ecash needs `--force`. Answers `{from, results: [{to, kind, messageIds, error}]}`; any refusal fails the command (exit 1) with the results in `details`. `--wait sent`: each text on its way (a group's taken by an edge), each file's transfer done |
|
|
106
|
+
| `group create <name> [--mesh]`, `group join <link>` | A community (a link anyone can open), or a private mesh |
|
|
107
|
+
| `group edit <group> <message> [text… \| --text <text> \| --stdin] [--mention <member>]… [--force]` | Replace the text of a message you sent to the group (`<message>` is the `messageId` `group send` gave). Every member sees it in place, marked edited; mentions whose `@name` is still in the text stay, `--mention` adds more. Answers `{group, messageId, edits, sent, edges}`; `--wait sent` and `--timeout` as for `group send`. A private group's members whose edge is down get it when it opens, a community's with the catch-up; a member on an older app keeps the old text |
|
|
108
|
+
| `group hub <group> <member> [--pin \| --exclude \| --auto]` | Past 16 members: pin a member as a hub, keep one from being a hub, or leave it to their app (a daemon offers itself) |
|
|
109
|
+
| `group invite <group> <chat>`, `group remove <group> <member>`, `group admin <group> <member>` | Membership, for the admin (each prints the group; `--show-secret` for its link) |
|
|
110
|
+
| `group leave <group>`, `group forget <group> --yes`, `group accept\|decline <group>` | Membership |
|
|
111
|
+
| `group list [--show-secret]`, `group show <group> [--show-secret]`, `group history <group>` | Groups, members, history; a message names its author (`member` key, `nick` from the roster). The entry link prints as `<hidden>` without `--show-secret` |
|
|
112
|
+
| `group react <group> <message> <emoji> [--remove]` | React to a group's message |
|
|
113
|
+
| `group rename <group> <name>` | A new name, for the admin (1 to 64 characters on one line); the picture stays. Members see it once it reaches them; until then, and on apps from before names, the group keeps the name it had when they got in |
|
|
114
|
+
| `group rotate <group>`, `group link <group> [--off] [--reset]`, `group picture <group> <jpeg> \| --clear` | A fresh secret; the link (printed: asking for it is asking for the secret); the picture |
|
|
115
|
+
| `group send <group> [text…] [--mention <member>]… [--reply <message>] [--wait none\|sent] [--timeout s]` | Send; each mentioned member is written as `@name` in the text. Answers `{group, messageId, sent, edges}`: the id `--reply`, `group react` and `group edit` take, and how many edges took it so far. A group has no receipts: `--wait sent` waits until at least one edge took it (a member's in a private group, one of your hubs' in a community), and exits 4 after `--timeout` (default 30 s) if none did; the message still goes when an edge opens, while the profile is online. Use it for a one-shot send with no daemon |
|
|
116
|
+
| `group typing <group> [--kind typing\|recording\|thinking] [--status "<text>"] [--for s] [--stop]` | `typing` in a private group: the members whose edge is open see "Ana is typing…" (or recording, thinking, or your status line), with the same rules, 6 s hold and `--for`. Answers `{group, typing, kind, status, reached, sendTyping}` (`reached`: members with an open edge). A community does not carry typing yet (exit 1, `unavailable`) |
|
|
117
|
+
| `identity add <provider> [subject] [--signer id] [--field name=value]… [--days n]` | A proof: an in-app signer (NIP-46 and the like) finishes here; a tool or a published record answers with the statement |
|
|
118
|
+
| `identity complete <draft> [--evidence-file f \| --stdin]`, `identity cancel <draft>` | Finish it with the tool's output (or nothing, for a published record); needs the daemon that began it. Cancel drops one not finished |
|
|
119
|
+
| `identity providers`, `identity list` | Kinds of proof and their signers; this profile's proofs |
|
|
120
|
+
| `identity share\|withdraw <chat> <id>`, `identity contact <chat>`, `identity recheck <chat> <id>`, `identity remove <id>` | Show a proof to a contact; what a contact showed, as checked here |
|
|
121
|
+
| `invite create [--label <name>]` | A new chat's `ghostly1…` invite and its link |
|
|
122
|
+
| `invite join <invite-or-link> [--label <name>]` | Join a chat (your own invite is refused) |
|
|
123
|
+
| `lightning default <card>`, `lightning rename <card> <name>` | Lightning cards: which receives by default |
|
|
124
|
+
| `listen [--since seq] [--cursor file] [--type t]… [--turns] [--from <chat\|key>]… [--group <group>]… [--exec cmd] [--webhook url] [--print]` | The event stream; `--from` and `--group` an allowlist, `--turns` one `agent.turn` per message to answer ([Events](#events)) |
|
|
125
|
+
| `message retry\|delete\|details <chat> <message>` | One message |
|
|
126
|
+
| `pay <invoice\|address\|lnurl> [--amount sats] [--network n] [--max-fee sats] [--confirm-real]` | Pay over Lightning (Testnet unless `--network mainnet`) |
|
|
127
|
+
| `payment list [--chat c]`, `payment check <chat> <payment>`, `payment reclaim <payment>` | Payments and requests |
|
|
128
|
+
| `pin <chat\|group> <message> [--remove]` | Pin a message at the top of a chat or group, for everyone in it: one per chat, a new pin replaces it, `--remove` unpins. In a community only the admin pins. A 1:1 pin reaches the contact on the next live session; the other side hears `chat.pinned` or `group.pinned` |
|
|
129
|
+
| `profile backup --out <file>`, `profile restore <file> <new profile>` | An encrypted backup (WISP 05 envelope); the passphrase from `--passphrase-file` or `GHOSTLY_BACKUP_PASSPHRASE` |
|
|
130
|
+
| `profile create <name> [--use] [--name <shown>]`, `profile list`, `profile use <name>` | Profiles |
|
|
131
|
+
| `profile picture <jpeg> \| --clear` | The picture contacts see (a JPEG within 512 px; 128 px is what the app sends) |
|
|
132
|
+
| `profile show`, `profile set [--name <name>] [--share-profile \| --no-share-profile]` | The name contacts see |
|
|
133
|
+
| `react <chat> <message> <emoji> [--remove]` | React to a message with one emoji; a new one replaces yours, `--remove` takes it back |
|
|
134
|
+
| `send <chat> [text…] [--reply <message>] [--stdin] [--force] [--wait none\|sent\|delivered] [--timeout s]` | Send text (arguments, or stdin); `--reply` quotes a message of the chat |
|
|
135
|
+
| `service add <name> <http://127.0.0.1:port>`, `service share <service> <chat> [--off]`, `service list`, `service remove\|enable` | Share a web app on this machine, per contact |
|
|
136
|
+
| `service peer <chat>`, `service open <chat> <service> [--port p]`, `service close <chat> <service>` | A contact's app on a loopback port here (daemon). `url` is a link for your browser, `http://<random>.localhost:<port>/.ghostly-open/<token>`: it sets the service's cookie and shows the app on a host name of its own; any other host name, or a request without that cookie, gets 404. Chrome, Edge and curl reach `*.localhost` names by themselves; Safari and Node's `fetch` on macOS may not (add the name to `/etc/hosts` for them) |
|
|
137
|
+
| `settings get [--show-secret]`, `settings set <key> <json>` | Relays, Iroh relays, the HyperDHT relay, ICE servers, `sendTyping` (false: contacts are never told you type), … |
|
|
138
|
+
| `status` | The profile, its chats, whether WebRTC and calls run, the last event seq |
|
|
139
|
+
| `typing <chat> [--kind typing\|recording\|thinking] [--status "<text>"] [--for s] [--stop]` | Show the contact you are writing, recording or thinking, or a short status line in its place ("Transcribing your audio…", 40 characters, no links): live chats only, it holds 6 s there; `--for s` keeps it on that long (up to 600 s; a one-shot stays that long); a new kind or status shows at once; a message to the chat or `--stop` ends it |
|
|
140
|
+
| `wallet create <type> [--network testnet] [--provider id] [--value name=value]… [--stdin] [--invite code] [--api-key key]` | A wallet: `cashu`, `lightning` (a card: its source's form in `--value`), `arkade`, `spark` (on Mainnet, `--api-key` is your Breez API key), `bitcoin` (BDK), `fedimint` (`--invite` is the federation's invite code), `usdt`. With `--stdin`, secret fields come as `name=value` lines on stdin (`api-key=…` too), out of `ps` and the shell's history |
|
|
141
|
+
| `wallet faucet <type>`, `wallet add-mint <url> [--primary]` | Test coins; another Cashu mint |
|
|
142
|
+
| `wallet list [--network n]` | Wallets and balances, and what `wallet create` can make on each network |
|
|
143
|
+
| `wallet receive <sats>`, `wallet address <type>`, `wallet redeem [<token>]`, `wallet history` | Receive, and what came and went. `wallet redeem` reads the token from stdin when none is given, which keeps it out of `ps` and the shell's history |
|
|
144
|
+
| `wallet remove <type> [--network n] [--card id] [--accept-loss]` | Refused while it holds money or waits for some, unless `--accept-loss` |
|
|
145
|
+
|
|
146
|
+
Help: `ghostly help` lists every command; `ghostly help file` (or `ghostly file --help`) a group; `ghostly help file
|
|
147
|
+
save` (or `ghostly file save --help`) one command, with its options. `-h` works too, except after `--`.
|
|
148
|
+
|
|
149
|
+
Arguments: an option's value is taken as is, even when it starts with `-`. So is an id in a positional that takes
|
|
150
|
+
one (`<chat>`, `<group>`, `<message>`, `<file>`, `<payment>`, `<draft>`, `<id>`, …): drafts, payments and groups
|
|
151
|
+
are base64url, which starts with `-` one time in 64. Any other positional that starts with `-` is refused (a
|
|
152
|
+
mistyped flag must not reach a contact as text): put `--` before a message that starts with a dash.
|
|
153
|
+
|
|
154
|
+
### Voice notes
|
|
155
|
+
|
|
156
|
+
`file send <chat> <path> --voice` sends an audio file as a voice note. Its length (`--voice <ms>`) and its 64
|
|
157
|
+
waveform bars (`--peaks 0,40,…`, 0-255) may be given; whatever is not given is measured from the file with the
|
|
158
|
+
app's own meter (`voicePeaksOf` in packages/core `voice.ts`: a reading every 50 ms, the loudest per bar), so the
|
|
159
|
+
bars look like the app's recordings. WAV, Opus in WebM or Ogg and MP3 are decoded in the CLI (wasm, no native code);
|
|
160
|
+
AAC in MP4/M4A and other kinds need `ffmpeg` on the PATH (`GHOSTLY_FFMPEG` names another). When the sound cannot be
|
|
161
|
+
read, a note with a given length goes out flat with a warning on stderr (the socket API answers `warning`), and one
|
|
162
|
+
without is refused (`bad_request`). `--voice` takes the next word only when it is a number.
|
|
163
|
+
|
|
164
|
+
### Files a bot receives
|
|
165
|
+
|
|
166
|
+
`message.received` carries the file: `message.file` is `{id, name, size, mime, voice?}`, and a voice note's `voice`
|
|
167
|
+
is `{duration, peaks?}` (milliseconds, and the 0-255 bars; releases before this one said only `voice: true`). The
|
|
168
|
+
`file.*` events name the file's message and chat (`messageId`, `chat`). So a bot answers a voice note without polling:
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
ghostly listen --type message.received --exec '
|
|
172
|
+
file="$(jq -r ".message.file.id // empty")"
|
|
173
|
+
[ -n "$file" ] && ghostly file save "$file" --dir ./inbox --wait --timeout 120'
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
`file wait <file>` alone does the waiting: exit `0` when done, `1` with the error when it failed, `4` on timeout.
|
|
177
|
+
|
|
178
|
+
### Staying off the direct link
|
|
179
|
+
|
|
180
|
+
`chat disconnect <chat>` closes the live session, but the contact's app dials again within seconds.
|
|
181
|
+
`chat disconnect <chat> --hold <minutes>` (up to a week) keeps the chat off its direct link for that long: it is the
|
|
182
|
+
chat's own "DHT only" choice (WISP 400: either side choosing it keeps both off the live link), taken back when the
|
|
183
|
+
time is up. Meanwhile text still goes over the DHT (up to 256 bytes a message; longer ones wait), files, calls and
|
|
184
|
+
typing wait, and the contact's app shows the chat as DHT only. `chat connect`, or `--hold 0`, ends it early. The
|
|
185
|
+
hold is kept in the profile folder: a daemon that restarts takes it back when its time is up, and a DHT only the chat
|
|
186
|
+
had chosen itself (`chat transport dht`) is never undone by it. `chat show` answers `heldUntil` (a time in ms, or
|
|
187
|
+
null; `hold` there is the store-and-forward state, something else), and the stream says `chat.held` and
|
|
188
|
+
`chat.released` (`reason`: expired, connect or lifted).
|
|
189
|
+
|
|
190
|
+
`settings set online false` is different: the whole profile goes offline, every chat, DHT included, and nothing
|
|
191
|
+
arrives until it is back online.
|
|
192
|
+
|
|
193
|
+
### After an upgrade
|
|
194
|
+
|
|
195
|
+
A daemon keeps running the code it started with. After upgrading the CLI, every command that goes through an older
|
|
196
|
+
(or newer) daemon still answers, and says on stderr `ghostly: the daemon runs <version> and this command is
|
|
197
|
+
<version>`. `ghostly daemon restart` stops the running daemon (if one runs) and starts this release's in the
|
|
198
|
+
background, as `daemon --detach` does. A daemon kept by a service manager (systemd, launchd) is restarted with the
|
|
199
|
+
service manager instead.
|
|
200
|
+
|
|
201
|
+
### The secret guard
|
|
202
|
+
|
|
203
|
+
`send` and `group send` refuse text that looks like a recovery phrase, a private key (nsec, WIF, xprv, 64 hex
|
|
204
|
+
characters named as a key) or a Cashu token (money anyone who reads it can take), with exit `5`, unless `--force`.
|
|
205
|
+
It is the app's own detector.
|
|
206
|
+
|
|
207
|
+
### Real money
|
|
208
|
+
|
|
209
|
+
A spend on Mainnet needs the engine's `confirmedReal` (the app asks the person). The CLI sets it only with
|
|
210
|
+
`--confirm-real`, and refuses a `confirmedReal` it was not given that flag for. Test networks need nothing.
|
|
211
|
+
|
|
212
|
+
### Identities on Node
|
|
213
|
+
|
|
214
|
+
SSH (`ssh-keygen`), OpenPGP (`gpg`) and Bitcoin (a wallet's "sign message") proofs are made with the tool: `identity
|
|
215
|
+
add` prints the statement and the command, `identity complete` takes its output. A domain or a DID publishes a record,
|
|
216
|
+
then `identity complete` asks the engine to look it up. In-app signers (a Nostr signer over NIP-46, Pubky, AT
|
|
217
|
+
Protocol) run in one `identity add`, with their fields in `--field`, and print what they wait on (a link, a code) to
|
|
218
|
+
stderr. OpenID Connect needs a browser window: make that proof in the app.
|
|
219
|
+
|
|
220
|
+
### Wallets on Node
|
|
221
|
+
|
|
222
|
+
Cashu (and Lightning through the mints), Lightning cards (NWC, LND, Core Lightning, Breez, Fedimint; a Lightning address or LNURL is paid with `pay`), Arkade,
|
|
223
|
+
Spark, BDK, Fedimint and USDT run as in the app. One does not yet: **Bark** (its SDK ships a browser build only);
|
|
224
|
+
`wallet list` offers it as unavailable, with the reason. A Fedimint wallet joins a federation by its invite
|
|
225
|
+
(`wallet create fedimint --invite fed1…`); its Lightning card is `wallet create lightning --provider fedimint --value
|
|
226
|
+
federation=<id>` (`engine fedimintPreview '{"invite":"fed1…"}'` shows the id first). Its client runs in a worker
|
|
227
|
+
thread, with one database file per federation in the profile's `fedimint/` folder. No wallet is made by itself: a bot has exactly the wallets it created. A Cashu test mint's invoice (from
|
|
228
|
+
`wallet receive`) is credited once its payer vouches for it in a chat, as in the app (a test mint says every invoice
|
|
229
|
+
is paid); on a real mint, the mint's answer decides. A Testnet wallet pays a Bitcoin (`lnbc`) invoice only through
|
|
230
|
+
the public test mint, as in the app: a mint on this machine may have real sats behind it. A local test mint with a fake
|
|
231
|
+
Lightning backend (a regtest or e2e stack) is declared with `GHOSTLY_TEST_MINTS=<url,…>`.
|
|
232
|
+
|
|
233
|
+
### Secrets
|
|
234
|
+
|
|
235
|
+
Nothing prints a seed, a key, a wallet phrase or ecash unless asked with `--show-secret`: `settings get` masks
|
|
236
|
+
credentials, and engine calls that answer with secrets (`exportLinks`, `walletExport`, the wallets' backups) are
|
|
237
|
+
refused without it. A group's entry link lets anyone who reads it join: `group show`, `group list`, the admin
|
|
238
|
+
commands, `group.created` events and `engine getState` print it as `<hidden>`; `--show-secret`, `group create` and
|
|
239
|
+
`group link` print it.
|
|
240
|
+
|
|
241
|
+
## Events
|
|
242
|
+
|
|
243
|
+
`ghostly listen` prints one event per line:
|
|
244
|
+
|
|
245
|
+
```json
|
|
246
|
+
{"seq":6,"id":"message.received:f3gg…:peer_jY7N…","type":"message.received","at":1790450767762,"chat":"f3gg…","message":{"id":"peer_jY7N…","chat":"f3gg…","from":"peer","text":"hello bob","timestamp":1790450767735,"delivery":null,"deliveryError":null,"via":"datalink","nick":null}}
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
- `seq` grows by one per event in the profile, across restarts; `id` is the same whenever the same fact is reported.
|
|
250
|
+
Dedupe by `id`; resume with `--since <seq>`, or let `--cursor <file>` remember the last event handled.
|
|
251
|
+
- Types: `daemon.started`, `chat.created`, `chat.removed`, `chat.renamed`, `chat.pairing` (`stage`: publishing,
|
|
252
|
+
waiting, resolving, on-dht, live, …), `chat.connection` (`live`, `transport`), `typing.started` (with `kind` and, when given, `status`; again when either changes) and `typing.stopped` (the contact is writing, or stopped: a message, a stop, or 6 s of silence), `chat.joined` (the contact's app
|
|
253
|
+
announced itself; not a message), `chat.announced`, `message.received`, `message.sent`, `message.delivery`
|
|
254
|
+
(`delivery`: sending, queued, waiting, held, sent, delivered, failed), `message.edited` (a text changed in place, the
|
|
255
|
+
contact's or mine: once per edit number, with `edits` and the message as it is now; `group.message.edited` in a group), `message.deleted`, `group.created`,
|
|
256
|
+
`group.status`, `group.members` (`joined`, `left`), `group.typing.started` (`member`, `kind`, and `status` when given; again when either changes) and `group.typing.stopped` (`member`: a member of a private group is writing, or stopped), `group.message` (`message.member` is the author's key,
|
|
257
|
+
`message.nick` their name from the roster; `message.mentioned` when it names this profile), `group.sent`, `group.event`, `file.offered` (a file over 25 MiB waits for `file accept`),
|
|
258
|
+
`file.stage`, `file.done`, `file.failed` (each with `chat`, `file`, `messageId`), `chat.held` and
|
|
259
|
+
`chat.released` (`chat disconnect --hold`), `identity.received` and `identity.status` (what a contact
|
|
260
|
+
showed, as checked here), `identity.approval` and `identity.progress` (a signer waits on a link or a code), `group.deleted`, `group.removed`, `payment.created` and
|
|
261
|
+
`payment.updated` (`payment`: id, chat, kind request|payment, direction in|out, amount, memo, state pending|
|
|
262
|
+
settled|failed, network, method), `call.incoming`, `call.outgoing`, `call.connected` and `call.ended` (voice
|
|
263
|
+
calls: see [Calls](#calls)), `events.gap` (the journal no longer holds what `--since` asked for).
|
|
264
|
+
- A message that answers another carries `replyTo`: `{id, snippet, from, member?, found}`. `id` is the original's
|
|
265
|
+
message id in this chat when it is here (`found: true`, and `snippet` and `from` come from that copy), else the id
|
|
266
|
+
the reply named; `from` is `me`, `peer` or null (only the id came, over the DHT). Answer one with
|
|
267
|
+
`ghostly send <chat> --reply <message id> "…"` (`group send` too): the id from history or from the event.
|
|
268
|
+
- Reactions (WISP 400 § Reactions): `message.reaction` (a chat) and `group.reaction` (a group) say each change once:
|
|
269
|
+
`{messageId, by, emoji, removed, mine}`, with `by` `me`, `peer` or a member key, `emoji` "" when taken back, `mine`
|
|
270
|
+
when the message is this profile's. `chat history` lists each message's `reactions`: `[{by, emoji, at}]`.
|
|
271
|
+
- Pins (WISP 400 § Pinned message): `chat.pinned` (with `chat`) and `group.pinned` (with `group`) when someone else pins
|
|
272
|
+
or unpins: `{messageId, ref, by, removed}`, with `messageId` the message's id here (null when it is not here, or
|
|
273
|
+
unpinned), `ref` the id both sides know it by, and `by` `peer` or a member key.
|
|
274
|
+
- `--type message.received` keeps one type; `--type message.` (or `message.*`) a family.
|
|
275
|
+
- `--from <chat|key>` and `--group <group>` (again for more): an allowlist. Only the chats (id, prefix, name, or the
|
|
276
|
+
contact's `peer` key, which also matches a chat made later) and groups named get through; any other chat's or
|
|
277
|
+
group's event stops before `--exec`, `--webhook` and stdout (the message stays in the chat; the cursor moves past
|
|
278
|
+
it). The profile's own events (`daemon.started`, `events.gap`, `identity.approval`) pass. Names become ids once,
|
|
279
|
+
at the start. A flag, not profile state: each listener (each agent) has its own ([docs/CLI.md](../../docs/CLI.md#allowlist)).
|
|
280
|
+
- `--turns`: one `agent.turn` event per `message.received`, and per `group.message` that mentions this profile:
|
|
281
|
+
`{seq, id: "agent.turn:<source id>", type, at, source, chat | group + member, messageId, timestamp, untrusted:
|
|
282
|
+
{text, name, replyTo?: {id, snippet}, file?: {id, name, size, mime, voice}}}`. `seq` is the source's (cursors
|
|
283
|
+
work), `id` stable per message. What the sender wrote is under `untrusted` only: give it to an agent as data, never
|
|
284
|
+
as its instructions ([docs/CLI.md](../../docs/CLI.md#agent-turns)). Takes the place of `--type`.
|
|
285
|
+
- `--exec <cmd>` runs the command through the shell once per event, in order, with the event on stdin and
|
|
286
|
+
`GHOSTLY_EVENT_TYPE`, `GHOSTLY_EVENT_ID`, `GHOSTLY_EVENT_SEQ` in its environment.
|
|
287
|
+
- `--webhook <url>` POSTs each event (JSON) to a local bridge: `127.0.0.1`, `localhost` or `[::1]` only.
|
|
288
|
+
- With no daemon running, `listen` becomes the daemon (socket included), so a hook can answer with `ghostly send`.
|
|
289
|
+
|
|
290
|
+
As the app's chat screen does, the side that joined a chat says `👋 <name> joined` once it first goes live, and the
|
|
291
|
+
side that invited answers once.
|
|
292
|
+
|
|
293
|
+
## The socket API
|
|
294
|
+
|
|
295
|
+
The daemon listens on a Unix socket in the profile's folder (`daemon.sock`, 0600; in `/tmp/ghostly-<hash>/`, a
|
|
296
|
+
folder of the user's alone, when the folder's path is too long for a socket). One JSON object per line each way:
|
|
297
|
+
|
|
298
|
+
```json
|
|
299
|
+
{"id":1,"method":"chat.send","params":{"chat":"alice","text":"hi","wait":"sent"}}
|
|
300
|
+
{"id":1,"result":{"chat":"f3gg…","messageId":"me_…","delivery":"sent"}}
|
|
301
|
+
{"id":2,"method":"events.subscribe","params":{"since":40}}
|
|
302
|
+
{"event":{"seq":41,"type":"message.received",…}}
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
Methods: `status`, `profile.get|set`, `settings.get|set`, `invite.create|join`, `chat.list|get|history|send|retry|
|
|
306
|
+
delete|details|rename|remove|transport|connect|disconnect|verify|wait|pay|request|payRequest|accept|edit|react|pin|forward|typing`,
|
|
307
|
+
`group.create|join|list|get|history|send|edit|react|leave|forget|accept|decline`, `wallet.list|create|remove|faucet|history|
|
|
308
|
+
receive|address|redeem`, `wallet.mint.add`, `lightning.default|rename`, `pay`, `payment.list|check|reclaim`,
|
|
309
|
+
`file.send|list|action|wait|save`, `group.invite|remove|admin|rotate|link|picture|hub`, `profile.picture|backup`,
|
|
310
|
+
`identity.providers|list|add|complete|cancel|remove|share|withdraw|contact|recheck`, `service.list|add|remove|enable|share|peer|open|close`,
|
|
311
|
+
`call.start|answer|hangup|list|get|flush|auto`,
|
|
312
|
+
`events.replay`, `events.subscribe`, `daemon.stop`, and `engine.call` with
|
|
313
|
+
`{"method":"<engine call>","params":{…},"confirmReal":false}` for anything else the app does. Parameters are the
|
|
314
|
+
commands' (see `src/api.ts`). Errors: `{"id":…,"error":{"code","message","details"?}}` with the codes above.
|
|
315
|
+
|
|
316
|
+
## Calls
|
|
317
|
+
|
|
318
|
+
A bot or an agent can take part in a voice call with the apps (calls/1, WISP 601): the CLI runs the call's media and
|
|
319
|
+
hands its audio to a program of yours, over a Unix socket per call. Speech-to-text, a model or text-to-speech are the
|
|
320
|
+
program's business, not Ghostly's. Audio only: a video call is answered as a voice call (the contact's camera is not
|
|
321
|
+
shown, and nothing is sent on its video lane). Calls need the daemon.
|
|
322
|
+
|
|
323
|
+
```sh
|
|
324
|
+
ghostly daemon --detach
|
|
325
|
+
ghostly call auto on --from alice # or answer each one: ghostly call answer alice
|
|
326
|
+
ghostly listen --type call. # call.incoming … call.connected {"audio":{"socket":…}}
|
|
327
|
+
node examples/call-echo.mjs greeting.wav # a program that answers, greets, then echoes after 1 s
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
**The audio.** The socket is `<profile>/calls/<call>.sock` (0600), or `/tmp/ghostly-calls-<hash>/<call>.sock` (in a
|
|
331
|
+
folder of yours alone, 0700) when that path is too long; `call start`, `call answer`, `call list` and the
|
|
332
|
+
`call.connected` event name it. It carries raw PCM,
|
|
333
|
+
with no framing:
|
|
334
|
+
|
|
335
|
+
- s16le, mono, at the call's rate: 48000 by default, or 24000, 16000, 12000, 8000 with `--rate`. Opus runs at that
|
|
336
|
+
rate itself, so nothing is resampled.
|
|
337
|
+
- From the call: the contact's audio in 20 ms frames (1920 bytes at 48 kHz, 640 at 16 kHz), as they are decoded.
|
|
338
|
+
- To the call: any amount, at any time. Writing faster than real time is fine: the CLI queues it (up to 5 minutes),
|
|
339
|
+
sends a 20 ms frame per tick at real time, and silence when the queue is empty. A partial frame waits 60 ms for the
|
|
340
|
+
rest of it, then plays padded.
|
|
341
|
+
- Barge-in: `ghostly call flush` (or `call.flush` on the socket API) drops everything queued, at once. Stop writing
|
|
342
|
+
before you flush.
|
|
343
|
+
- The socket exists from `call start`/`call answer`, so the program may connect before the media is up. One program
|
|
344
|
+
at a time: a new connection replaces the old one. When the call ends, the program reads EOF and the socket is
|
|
345
|
+
removed.
|
|
346
|
+
- Added latency inside the CLI: at most one 20 ms frame going out; coming in, a frame as soon as its packet is
|
|
347
|
+
decoded (up to 40 ms more when a packet is late; a lost one becomes 20 ms of silence).
|
|
348
|
+
|
|
349
|
+
**Events.** `call.incoming` `{call, chat, name, video, auto}`, `call.outgoing` `{call, chat, name, audio}`,
|
|
350
|
+
`call.connected` `{call, chat, direction, audio: {socket, rate, channels, format, frameMs}}`, and `call.ended`
|
|
351
|
+
`{call, chat, direction, reason, duration?}` with `reason`:
|
|
352
|
+
|
|
353
|
+
| `reason` | |
|
|
354
|
+
|---|---|
|
|
355
|
+
| `hangup` | This side hung up, or declined a call that rang |
|
|
356
|
+
| `remote-hangup` | The contact hung up a connected call |
|
|
357
|
+
| `missed` | An incoming call stopped ringing unanswered (the contact gave up, or its offer went stale after 120 s) |
|
|
358
|
+
| `rejected` | The contact declined this side's call |
|
|
359
|
+
| `unanswered` | This side's call rang 60 s with no answer |
|
|
360
|
+
| `failed` | The media did not connect within 30 s, or dropped |
|
|
361
|
+
| `stopped` | The daemon stopped (its calls are hung up first) |
|
|
362
|
+
|
|
363
|
+
**Rules**, as in the apps: one call per chat, several chats may each have one; a call needs the chat live (the call
|
|
364
|
+
buttons of the contact's app say why when not); an offer that is older than 120 s does not ring. `call auto` is
|
|
365
|
+
kept in the profile's `calls.json`. The media is libdatachannel (node-datachannel, as for chats) and libopus built to
|
|
366
|
+
WebAssembly (`opusscript`); `status` says `calls: false` and `call list` says why where they cannot run.
|
|
367
|
+
`GHOSTLY_CALL_BIND=127.0.0.1` binds the call's media to one address (the tests use loopback).
|
|
368
|
+
|
|
369
|
+
**When a call does not connect** (it stays `connecting`, then ends `failed`): the daemon's log has a line per call for
|
|
370
|
+
the candidates each side offered, every ICE state, and the pair it connected over; `call list` shows the ICE state
|
|
371
|
+
and the pair as `stats.ice`. The macOS app and Safari hide their addresses behind mDNS names (`<uuid>.local`) that
|
|
372
|
+
often resolve nowhere, so such a call connects only when the app's own checks reach the CLI. A VPN that is the
|
|
373
|
+
default route (seen with NordVPN on the same Mac as the CLI) makes the app send from the tunnel's address, which
|
|
374
|
+
nothing on that machine can answer: allow local network traffic in the VPN, or pause it.
|
|
375
|
+
|
|
376
|
+
**A TURN relay.** Calls use the apps' STUN servers, then the profile's own ICE servers:
|
|
377
|
+
`ghostly settings set iceServers '[{"urls":"turn:turn.example.org:3478","username":"…","credential":"…"}]'` (the
|
|
378
|
+
same setting chats use). A relay connects the calls no direct path can: a VPN that takes every packet through its
|
|
379
|
+
tunnel, a NAT that maps each destination apart. The media stays encrypted end to end; the relay sees both
|
|
380
|
+
addresses and when and how much is sent.
|
|
381
|
+
|
|
382
|
+
## Transports
|
|
383
|
+
|
|
384
|
+
The engine picks as the app does: WebRTC (libdatachannel), HyperDHT (native, in process), Iroh (the wasm build,
|
|
385
|
+
relay only, as in the web app), and the DHT floor through the Pkarr relays in the settings and the Mainline DHT
|
|
386
|
+
directly (every packet to both; reads from the DHT when every relay fails). Tests and private networks:
|
|
387
|
+
`GHOSTLY_DHT=0` leaves the Mainline DHT out, `GHOSTLY_DHT_BOOTSTRAP=host:port,…` replaces its bootstrap routers,
|
|
388
|
+
`GHOSTLY_HYPERDHT_BOOTSTRAP=host:port,…` replaces HyperDHT's bootstrap nodes, `settings set relays
|
|
389
|
+
'["http://…"]'` the Pkarr relays, `settings set irohRelays '["https://…"]'` the Iroh relays. `GHOSTLY_WEBRTC=0`
|
|
390
|
+
turns WebRTC off. A daemon offers to be a hub of the private groups past 16 members it is in, since it stays online;
|
|
391
|
+
`GHOSTLY_HUB=0` keeps it a plain member (the admin can still pin it).
|
|
392
|
+
|
|
393
|
+
## Not yet
|
|
394
|
+
|
|
395
|
+
- **Bark** wallets (see above), **OpenID Connect** proofs (a browser window), and video in calls
|
|
396
|
+
(calls are voice only).
|
|
397
|
+
- **A single binary**: the CLI needs Node.
|
|
398
|
+
- Link previews made by the sender, and holding messages for an away contact.
|
|
399
|
+
|
|
400
|
+
## Tests
|
|
401
|
+
|
|
402
|
+
`npm test -w @ghostlytools/cli` builds the CLI and runs the unit tests and a two-bot end-to-end test on loopback (a Pkarr
|
|
403
|
+
relay in the test process and a HyperDHT testnet), a voice call between them included.
|
|
404
|
+
`e2e/web/headless-chat.spec.ts` puts a bot and the web app in one chat; `e2e/web/headless-call.spec.ts` has them
|
|
405
|
+
call each other, with a tone each way.
|