herdrchat 1.3.0 → 1.3.1
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 +87 -23
- package/README.zh.md +70 -16
- package/bin/cli.mjs +125 -46
- package/bin/herdrchat.mjs +2 -2
- package/config.env.example +37 -20
- package/docs/architecture.md +13 -10
- package/docs/guide.md +84 -43
- package/lib/chats/contract.mjs +2 -1
- package/lib/chats/feishu/index.mjs +5 -1
- package/lib/chats/telegram/index.mjs +6 -2
- package/lib/core/config.mjs +28 -10
- package/lib/core/engine.mjs +14 -8
- package/lib/core/hc.mjs +57 -1
- package/lib/core/log.mjs +6 -3
- package/lib/core/readers/index.mjs +1 -1
- package/lib/core/service.mjs +1 -0
- package/package.json +1 -1
- package/skills/herdrchat/SKILL.md +9 -8
- package/skills/herdrchat/references/feishu.md +4 -3
- package/skills/herdrchat/references/ops.md +22 -52
- package/skills/herdrchat/references/telegram.md +29 -35
package/docs/architecture.md
CHANGED
|
@@ -1,33 +1,36 @@
|
|
|
1
1
|
# herdrchat architecture
|
|
2
2
|
|
|
3
|
-
herdrchat has two kinds of plug-ins that grow over time: **chat apps** (Feishu
|
|
4
|
-
Telegram
|
|
5
|
-
and pi). The core between them knows neither. This page says where everything is and
|
|
3
|
+
herdrchat has two kinds of plug-ins that grow over time: **chat apps** (Feishu and
|
|
4
|
+
Telegram now, Slack and Discord later) and **agents**, each read by its reader (Claude
|
|
5
|
+
Code now, then codex and pi). The core between them knows neither. This page says where everything is and
|
|
6
6
|
how to add one of each.
|
|
7
7
|
|
|
8
8
|
## The parts
|
|
9
9
|
|
|
10
10
|
```
|
|
11
11
|
bin/
|
|
12
|
-
cli.mjs herdrchat init|start|stop|status|logs
|
|
12
|
+
cli.mjs herdrchat init|start|stop|status|logs|install|uninstall|run|version
|
|
13
13
|
herdrchat.mjs wires the parts: config, log, the chat app, the core
|
|
14
14
|
lib/
|
|
15
|
-
core/ the same for every chat app and every
|
|
15
|
+
core/ the same for every chat app and every agent
|
|
16
16
|
engine.mjs follows hc, keeps one room per herdr-app tab, posts what agents
|
|
17
17
|
say, dialogs and clicks, what you type, the overview
|
|
18
|
-
hc.mjs events.mjs hc: commands,
|
|
18
|
+
hc.mjs events.mjs hc: commands, its live event stream, the sessions on a machine
|
|
19
19
|
views.mjs which view of a terminal is followed (hc core id + tN)
|
|
20
20
|
cursor.mjs home.mjs what was posted already; reading an agent's log files
|
|
21
21
|
menu.mjs a dialog's options, read from the screen
|
|
22
22
|
config.mjs log.mjs config.env, state.json, the daily log
|
|
23
|
-
|
|
23
|
+
service.mjs herdrchat as a launchd or systemd service
|
|
24
|
+
readers/ one reader per agent (the contract is in index.mjs)
|
|
24
25
|
chats/
|
|
25
26
|
contract.mjs what a chat app gives the core, and the check for it
|
|
26
27
|
index.mjs the list of chat apps
|
|
27
|
-
feishu/ index.mjs (the contract), client.mjs, overview.mjs,
|
|
28
|
+
feishu/ index.mjs (the contract), client.mjs, overview.mjs, avatar.mjs, userauth.mjs
|
|
29
|
+
telegram/ index.mjs (the contract), client.mjs, format.mjs
|
|
28
30
|
test/
|
|
29
31
|
fakes.mjs a fake chat app, a fake hc and a fake reader
|
|
30
|
-
engine.test.mjs the core, run on the fakes
|
|
32
|
+
engine.test.mjs the core, run on the fakes; the apps' contracts
|
|
33
|
+
*.test.mjs the parts one by one
|
|
31
34
|
```
|
|
32
35
|
|
|
33
36
|
The rules that keep it this way:
|
|
@@ -61,7 +64,7 @@ The rules that keep it this way:
|
|
|
61
64
|
`HERDRCHAT_CHAT=<app>` in that profile's config.env), so the running Feishu bot is
|
|
62
65
|
not touched.
|
|
63
66
|
|
|
64
|
-
## Adding
|
|
67
|
+
## Adding an agent
|
|
65
68
|
|
|
66
69
|
1. Read how the agent keeps its conversation on disk, from its docs or its source: where
|
|
67
70
|
the log is, how to find the one for a running terminal, how a turn's last answer is
|
package/docs/guide.md
CHANGED
|
@@ -8,17 +8,33 @@ dialogs, the overview), each behind one contract (`docs/architecture.md`); Slack
|
|
|
8
8
|
Discord may follow the same way. A profile is one chat app (`HERDRCHAT_CHAT`); to use
|
|
9
9
|
both, run a profile each.
|
|
10
10
|
|
|
11
|
+
Words used here:
|
|
12
|
+
|
|
13
|
+
| Word | Meaning |
|
|
14
|
+
|---|---|
|
|
15
|
+
| hc, herdr-core | the program that runs terminals and keeps them alive ([herdr.dev](https://herdr.dev)) |
|
|
16
|
+
| herdr-app | its desktop and phone app, which shows the terminals in spaces and tabs |
|
|
17
|
+
| session | one hc core and its terminals (`HC_SESSION`); `ls ~/.local/state/herdr-core` lists those on a machine |
|
|
18
|
+
| space, tab | how herdr-app groups terminals in its sidebar |
|
|
19
|
+
| room | where one tab is mirrored: a Feishu group or a Telegram topic |
|
|
20
|
+
| direct chat | your private chat with the bot: the overview and the commands |
|
|
21
|
+
| profile | one herdrchat setup for one chat app, with its own settings and state (`default` unless named) |
|
|
22
|
+
| reader | the part that reads an agent's own conversation log (Claude Code so far) |
|
|
23
|
+
|
|
11
24
|
## Feishu
|
|
12
25
|
|
|
13
26
|
herdr-app, moved into one Feishu bot:
|
|
14
27
|
|
|
15
28
|
| herdr-app | Feishu |
|
|
16
29
|
|---|---|
|
|
17
|
-
| a
|
|
30
|
+
| a tab | a group named `<space> / <tab>`, tagged with its session; type there to talk to it |
|
|
18
31
|
| a space | one avatar for all of its groups: the space's first letter on a color of its own |
|
|
19
32
|
| a terminal on another machine | a group named `<space> / <tab>`, tagged with the machine, made once |
|
|
20
33
|
| a terminal that is gone | its group stays, with its history, tagged `ended` (untagged if it comes back) |
|
|
21
|
-
|
|
|
34
|
+
| the sidebar | the direct chat with the bot: an overview card (every terminal's state, what waits for you first, each a link to its group), kept up to date in place; `/ls`, `/new`, `/kill` |
|
|
35
|
+
|
|
36
|
+
A tab herdr-app only numbered (`2`) is named after the terminal's title instead (what the
|
|
37
|
+
program sets; Claude Code sets its session name or topic), else its hc name.
|
|
22
38
|
|
|
23
39
|
Everything comes from hc, so it works the same for Claude Code, codex, pi or any program
|
|
24
40
|
hc shows, on this machine or on a machine hc has added. Nothing is installed in the agents.
|
|
@@ -26,18 +42,19 @@ hc shows, on this machine or on a machine hc has added. Nothing is installed in
|
|
|
26
42
|
## Telegram
|
|
27
43
|
|
|
28
44
|
A Telegram bot cannot make groups, so it uses topics in your private chat with the bot
|
|
29
|
-
(topic mode must be on in
|
|
45
|
+
(topic mode must be on: BotFather's mini app, the Open button in the BotFather chat, then
|
|
46
|
+
the bot, Threaded Mode):
|
|
30
47
|
|
|
31
48
|
| herdr-app | Telegram |
|
|
32
49
|
|---|---|
|
|
33
50
|
| a tab | a topic named `<space> / <tab>` in the bot's private chat; type there to talk to it |
|
|
34
51
|
| a space | one topic icon color for all of its topics (Telegram offers six) |
|
|
35
52
|
| a terminal that is gone | its topic stays, its name starting with 🔚 (taken off if it comes back) |
|
|
36
|
-
|
|
|
53
|
+
| the sidebar | the chat's main thread: the overview message, edited in place; `/ls`, `/new`, `/kill`; `/` shows the commands |
|
|
37
54
|
|
|
38
55
|
What differs is in "Feishu and Telegram side by side" below. Telegram takes about one
|
|
39
56
|
message a second in a chat, so the bot queues and waits as Telegram asks. Setting it up:
|
|
40
|
-
|
|
57
|
+
"Set up: Telegram" below.
|
|
41
58
|
|
|
42
59
|
## Feishu and Telegram side by side
|
|
43
60
|
|
|
@@ -79,16 +96,21 @@ In the profile's `config.env` (`herdrchat status` prints where it is):
|
|
|
79
96
|
| `TELEGRAM_BOT_TOKEN` | Telegram | (required) | the bot's token from @BotFather |
|
|
80
97
|
| `TELEGRAM_OWNER_ID` | Telegram | (empty) | you; the bot tells you yours when you message it |
|
|
81
98
|
|
|
99
|
+
Where a profile's files are: settings in `~/.config/herdrchat/<profile>/config.env`,
|
|
100
|
+
what the bot writes (state, logs, your `/auth` token) in
|
|
101
|
+
`~/.local/state/herdrchat/<profile>/`; `herdrchat status` prints both.
|
|
102
|
+
|
|
82
103
|
In the environment (not `config.env`): `HERDRCHAT_PROFILE` (or `--profile`) picks the
|
|
83
|
-
profile; `HERDRCHAT_HOME` puts a profile's `config.env` and state in one folder
|
|
84
|
-
`XDG_CONFIG_HOME` and `XDG_STATE_HOME` move the usual
|
|
85
|
-
|
|
86
|
-
|
|
104
|
+
profile; `HERDRCHAT_HOME` puts a profile's `config.env` and state in one folder of your
|
|
105
|
+
choice (give each profile its own); `XDG_CONFIG_HOME` and `XDG_STATE_HOME` move the usual
|
|
106
|
+
folders. A setup in a moved folder gets its own hc terminal and service name, so trying
|
|
107
|
+
one never touches the usual one. A key set in the environment wins over `config.env`. A
|
|
108
|
+
setting changed in `config.env` takes effect at the next `herdrchat start`.
|
|
87
109
|
|
|
88
110
|
## How it works: hc, plus a reader per agent
|
|
89
111
|
|
|
90
|
-
**hc is the backbone.** The bot follows `hc events --json` for each session (format:
|
|
91
|
-
|
|
112
|
+
**hc is the backbone.** The bot follows `hc events --json` for each session (its format:
|
|
113
|
+
`hc schema`). From hc it learns which terminals exist, where
|
|
92
114
|
herdr-app shows them, their status, and when a turn ends (`turn` event: the status left
|
|
93
115
|
`working`). Typing from a group goes through `hc send`.
|
|
94
116
|
|
|
@@ -103,7 +125,7 @@ each agent a reader reads the agent's own records. The status hc reports names t
|
|
|
103
125
|
| anything else | none | nothing by itself; what you type gets its screen in reply |
|
|
104
126
|
|
|
105
127
|
On another machine, a reader reads the same files through `hc exec --on`. To add an
|
|
106
|
-
agent: `docs/architecture.md`, "Adding
|
|
128
|
+
agent: `docs/architecture.md`, "Adding an agent".
|
|
107
129
|
|
|
108
130
|
## What a room shows
|
|
109
131
|
|
|
@@ -206,32 +228,50 @@ delivered (the terminal has ended) says so plainly.
|
|
|
206
228
|
dialog it waits on, as is), `/esc`, `/history` (the conversation so far, as a file and
|
|
207
229
|
a folded post). Dialog cards have buttons.
|
|
208
230
|
|
|
209
|
-
## Set up
|
|
210
|
-
|
|
211
|
-
Telegram
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
`
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
231
|
+
## Set up: Telegram
|
|
232
|
+
|
|
233
|
+
1. In Telegram, message **@BotFather**: `/newbot`, give it a name and a username ending
|
|
234
|
+
in `bot`. It answers with a token; keep it private.
|
|
235
|
+
2. Turn on topics for the bot: in the BotFather chat tap **Open** (its mini app), pick the
|
|
236
|
+
bot, turn on **Threaded Mode**. Without it the bot cannot make topics.
|
|
237
|
+
3. `herdrchat init --chat telegram` (with `--profile <name>` too if you also run Feishu)
|
|
238
|
+
prints where its `config.env` is. Fill in `TELEGRAM_BOT_TOKEN` and `HERDR_SESSIONS`
|
|
239
|
+
(`ls ~/.local/state/herdr-core` lists your hc sessions).
|
|
240
|
+
4. `herdrchat start`, then message the bot anything. It answers with the line to add,
|
|
241
|
+
`TELEGRAM_OWNER_ID=<your id>`; add it and run `herdrchat start` again. One topic per
|
|
242
|
+
tab appears in your private chat with the bot (Telegram takes about one message a
|
|
243
|
+
second, so many tabs take a moment).
|
|
244
|
+
5. Once it works: `herdrchat install` (see "Running as a service").
|
|
245
|
+
|
|
246
|
+
If Telegram is your only app, use the default profile with `--chat telegram`. Use
|
|
247
|
+
`--profile <name>` only for a second chat app beside the first.
|
|
248
|
+
|
|
249
|
+
## Set up: Feishu
|
|
250
|
+
|
|
251
|
+
Feishu only: Lark (the international version) is not supported yet. Steps 1, 2, 5 and 6
|
|
252
|
+
are in the Feishu developer console (`https://open.feishu.cn/app`); the herdrchat skill
|
|
253
|
+
(`/herdrchat setup`) can do them with you.
|
|
254
|
+
|
|
255
|
+
1. Create a custom app (企业自建应用) and add the bot ability (机器人).
|
|
256
|
+
2. Permissions: import the scopes listed in `skills/herdrchat/references/feishu.md`,
|
|
257
|
+
"Scopes this bot uses" (there as JSON for the batch import): sending as the bot,
|
|
258
|
+
reading the messages sent to it, making and renaming groups, reactions, images both
|
|
259
|
+
ways, and group tags.
|
|
260
|
+
3. `herdrchat init` prints where its `config.env` is. Fill in the app's App ID and App
|
|
261
|
+
Secret (the app's 凭证与基础信息 page) and `HERDR_SESSIONS` (`ls
|
|
262
|
+
~/.local/state/herdr-core` lists your hc sessions).
|
|
263
|
+
4. `herdrchat start`. The bot must be running for the next step: the console checks for
|
|
264
|
+
a live connection.
|
|
227
265
|
5. Events and callbacks (事件与回调): for events, choose the long connection mode (长连接)
|
|
228
266
|
and add `im.message.receive_v1`; for callbacks (回调配置), choose the long connection
|
|
229
267
|
mode too and add `card.action.trigger` (the dialog buttons).
|
|
230
|
-
6. Publish a version.
|
|
268
|
+
6. Publish a version. If the page does not say 本次发布免审核, your team's admin must
|
|
269
|
+
approve it first.
|
|
231
270
|
7. Open `https://applink.feishu.cn/client/bot/open?appId=<app id>` and send anything.
|
|
232
|
-
The bot answers with
|
|
233
|
-
`herdrchat start` again.
|
|
234
|
-
8. Optional: the bot
|
|
271
|
+
The bot answers with the line to add, `FEISHU_OWNER_OPEN_ID=<your open_id>`; add it
|
|
272
|
+
and run `herdrchat start` again. One group per tab appears.
|
|
273
|
+
8. Optional: send `/auth` to the bot so it can mute its groups for you ("Optional: let
|
|
274
|
+
the bot act as you"), and add the bot menu (`skills/herdrchat/references/feishu.md`,
|
|
235
275
|
"The bot menu").
|
|
236
276
|
9. Once it works: `herdrchat install` (see "Running as a service").
|
|
237
277
|
|
|
@@ -283,18 +323,18 @@ refreshed, the bot says so once and falls back.
|
|
|
283
323
|
`SKILL.md`): `/herdrchat setup`, `status`, `start`, `stop`, `install`, `logs`, `auth`,
|
|
284
324
|
`config`, `fix`, `feishu <task>` (the developer console, with Ego Lite from
|
|
285
325
|
https://lite.ego.app/ or as a guided walk-through) and `telegram <task>` (the bot, from
|
|
286
|
-
@BotFather to the first topic). Details per part are in `skills/herdrchat/references/`.
|
|
287
|
-
with
|
|
288
|
-
`ln -s "$PWD/skills/herdrchat" ~/.claude/skills/herdrchat`.
|
|
326
|
+
@BotFather to the first topic). Details per part are in `skills/herdrchat/references/`.
|
|
327
|
+
Install it with `npx skills add zhangxiangliang/herdrchat` (README); from a clone you can
|
|
328
|
+
link it instead, for example `ln -s "$PWD/skills/herdrchat" ~/.claude/skills/herdrchat`.
|
|
289
329
|
|
|
290
330
|
## Logs
|
|
291
331
|
|
|
292
|
-
Everything the bot logs goes to `logs/<YYYY-MM-DD>.log` in the state folder
|
|
293
|
-
day
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
before it is started again. Tokens and secrets are never logged. The
|
|
297
|
-
|
|
332
|
+
Everything the bot logs goes to `logs/<YYYY-MM-DD>.log` in the state folder, one file a
|
|
333
|
+
day by your local date (the last 14 kept), as `<time> <LEVEL> <text>` with the time in
|
|
334
|
+
UTC. `herdrchat logs` shows today's (`--errors` only what went wrong, `--day 2026-01-31`
|
|
335
|
+
another day); a retry that recovered is a `WARN`, and a crash leaves its stack there
|
|
336
|
+
before it is started again. Tokens and secrets are never logged. The skill's
|
|
337
|
+
`references/ops.md` explains the lines.
|
|
298
338
|
|
|
299
339
|
## Limits
|
|
300
340
|
|
|
@@ -303,6 +343,7 @@ skill's `references/ops.md` explains the lines.
|
|
|
303
343
|
until each has a reader; what you type to them gets their screen in reply.
|
|
304
344
|
- Only text, images and rich text are passed on; files, stickers and the rest are not.
|
|
305
345
|
- An image that fails to upload three times is not sent; the group says so and why.
|
|
346
|
+
- Feishu only, not Lark (the international version) yet: the bot talks to open.feishu.cn.
|
|
306
347
|
- Feishu takes tag names of at most 16 characters; a longer session or machine name is cut
|
|
307
348
|
at its last `-`, `_` or `.` that fits (`build-agent-server` becomes `build-agent`).
|
|
308
349
|
- Avatars are drawn with `sips`, so only on macOS; elsewhere groups keep Feishu's default.
|
package/lib/chats/contract.mjs
CHANGED
|
@@ -18,6 +18,7 @@ export const CHAT_MEMBERS = {
|
|
|
18
18
|
name: 'string: the app, as in HERDRCHAT_CHAT',
|
|
19
19
|
ready: '() -> boolean: the owner is known (no room is made before)',
|
|
20
20
|
help: 'string[]: lines it adds to the direct chat\'s help',
|
|
21
|
+
roomWord: 'string: what the user calls a room in this app (group, topic), for what the bot says',
|
|
21
22
|
liveBytes: 'number: how much text one editable card holds',
|
|
22
23
|
split: '(markdown) -> string[]: one message\'s worth each',
|
|
23
24
|
start: '({onMessage, onAction, onReady}): connect; onMessage({room|null, where, message, text, images}) for the owner\'s messages (room null: the direct chat), onAction({value, message, answer}) -> {toast: {type, text}, card?: {kind: done|dialog, ...}} for button clicks',
|
|
@@ -48,7 +49,7 @@ export const CHAT_MEMBERS = {
|
|
|
48
49
|
showOverview: '(rows, {fresh}): rows [{name, address, state, kind, program, since, room}]',
|
|
49
50
|
};
|
|
50
51
|
|
|
51
|
-
const TYPES = { name: 'string', help: 'object', liveBytes: 'number', foldedBytes: 'number' };
|
|
52
|
+
const TYPES = { name: 'string', help: 'object', roomWord: 'string', liveBytes: 'number', foldedBytes: 'number' };
|
|
52
53
|
|
|
53
54
|
// Throws, naming every member that is missing or of the wrong kind.
|
|
54
55
|
export function checkChat(chat) {
|
|
@@ -17,6 +17,9 @@ const DONE_EMOJI = 'OK';
|
|
|
17
17
|
const TONES = { working: 'blue', closed: 'grey', needs: 'red', done: 'grey', good: 'green', bad: 'red' };
|
|
18
18
|
|
|
19
19
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
20
|
+
// What to do after an edit to config.env, said the way this profile is run.
|
|
21
|
+
const startAgain = (cfg) => `\`herdrchat start${cfg.profile && cfg.profile !== 'default' ? ` --profile ${cfg.profile}` : ''}\``;
|
|
22
|
+
|
|
20
23
|
const inSpaces = (set, space) => set.all || set.spaces.includes(space);
|
|
21
24
|
const spaceOfName = (name) => (name || '').split(' / ')[0];
|
|
22
25
|
|
|
@@ -438,7 +441,7 @@ export function create({ cfg, store, log, logWarn, logError, stateDir, lane, sco
|
|
|
438
441
|
const openId = data.sender?.sender_id?.open_id;
|
|
439
442
|
if (!cfg.ownerOpenId) {
|
|
440
443
|
log(`owner not set; message from open_id ${openId}`);
|
|
441
|
-
if (msg.chat_type === 'p2p') await reply(msg.chat_id, `
|
|
444
|
+
if (msg.chat_type === 'p2p') await reply(msg.chat_id, `Hello! To make you this bot's owner (the only person it listens to), add this line to ${cfg.configFile || 'config.env'}:\n\n\`FEISHU_OWNER_OPEN_ID=${openId}\`\n\nthen run ${startAgain(cfg)}. Your terminals' groups appear then.`);
|
|
442
445
|
return;
|
|
443
446
|
}
|
|
444
447
|
if (openId !== cfg.ownerOpenId) {
|
|
@@ -526,6 +529,7 @@ export function create({ cfg, store, log, logWarn, logError, stateDir, lane, sco
|
|
|
526
529
|
name: 'feishu',
|
|
527
530
|
ready: () => Boolean(cfg.ownerOpenId),
|
|
528
531
|
help: ['`/auth` let the bot act as you for chat-list settings (mute, labels, pin)'],
|
|
532
|
+
roomWord: 'group',
|
|
529
533
|
liveBytes: 20000, // a card may hold 30 KB; room for JSON escaping
|
|
530
534
|
split: (text) => chunk(text),
|
|
531
535
|
start,
|
|
@@ -46,6 +46,9 @@ const COMMANDS = [
|
|
|
46
46
|
];
|
|
47
47
|
|
|
48
48
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
49
|
+
// What to do after an edit to config.env, said the way this profile is run.
|
|
50
|
+
const startAgain = (cfg) => `\`herdrchat start${cfg.profile && cfg.profile !== 'default' ? ` --profile ${cfg.profile}` : ''}\``;
|
|
51
|
+
|
|
49
52
|
const inSpaces = (set, space) => set.all || set.spaces.includes(space);
|
|
50
53
|
const spaceOfName = (name) => (name || '').split(' / ')[0];
|
|
51
54
|
const cut = (text, max) => (text.length > max ? `${text.slice(0, max - 1)}…` : text);
|
|
@@ -329,7 +332,7 @@ export function create({ cfg, store, log, logWarn, logError }) {
|
|
|
329
332
|
const where = `${m.chat.id}:${m.is_topic_message ? m.message_thread_id : ''}`;
|
|
330
333
|
if (!owner) {
|
|
331
334
|
log(`owner not set; message from user id ${from}`);
|
|
332
|
-
if (m.chat.type === 'private') await reply(where, `
|
|
335
|
+
if (m.chat.type === 'private') await reply(where, `Hello! To make you this bot's owner (the only person it listens to), add this line to ${cfg.configFile || 'config.env'}:\n\n\`TELEGRAM_OWNER_ID=${from}\`\n\nthen run ${startAgain(cfg)}. Your terminals' topics appear then.`);
|
|
333
336
|
return;
|
|
334
337
|
}
|
|
335
338
|
if (from !== owner || String(m.chat.id) !== owner) {
|
|
@@ -375,7 +378,7 @@ export function create({ cfg, store, log, logWarn, logError }) {
|
|
|
375
378
|
if (!username) {
|
|
376
379
|
const me = await tg.call('getMe');
|
|
377
380
|
username = me.username;
|
|
378
|
-
if (!me.has_topics_enabled) logWarn(`telegram: topic mode is off for @${username}
|
|
381
|
+
if (!me.has_topics_enabled) logWarn(`telegram: topic mode is off for @${username}, so no topic can be made: open @BotFather, tap Open (its mini app), pick the bot, turn on Threaded Mode, then herdrchat start again`);
|
|
379
382
|
await tg.call('setMyCommands', { commands: COMMANDS }).catch((err) => logWarn(`telegram commands: ${err.message}`));
|
|
380
383
|
}
|
|
381
384
|
// the token works and Telegram answers: in (the first poll may hold for a while)
|
|
@@ -418,6 +421,7 @@ export function create({ cfg, store, log, logWarn, logError }) {
|
|
|
418
421
|
name: 'telegram',
|
|
419
422
|
ready: () => Boolean(owner),
|
|
420
423
|
help: [],
|
|
424
|
+
roomWord: 'topic',
|
|
421
425
|
liveBytes: 3500, // a message holds 4096 characters after parsing
|
|
422
426
|
split: (text) => split(text),
|
|
423
427
|
start,
|
package/lib/core/config.mjs
CHANGED
|
@@ -26,19 +26,34 @@
|
|
|
26
26
|
// HERDR_HISTORY what a new room gets of the conversation before it:
|
|
27
27
|
// `file,folded` (default: all of it as a file, the newest
|
|
28
28
|
// part in a folded post), `file`, `folded`, or empty / `none`
|
|
29
|
+
import crypto from 'node:crypto';
|
|
29
30
|
import fs from 'node:fs';
|
|
30
31
|
import os from 'node:os';
|
|
31
32
|
import path from 'node:path';
|
|
32
33
|
|
|
33
|
-
// Where one profile lives, from the environment: {profile, configDir, stateDir,
|
|
34
|
+
// Where one profile lives, from the environment: {profile, id, configDir, stateDir,
|
|
35
|
+
// terminal}. `id` names what the system sees of it (its hc terminal, its service): the
|
|
36
|
+
// profile, plus a short tag of its folder when that is not the usual one (HERDRCHAT_HOME,
|
|
37
|
+
// XDG_*), so a setup tried in another folder never stops, replaces or removes the real one.
|
|
34
38
|
export function placesOf(env = process.env, home = os.homedir()) {
|
|
35
39
|
const profile = env.HERDRCHAT_PROFILE || 'default';
|
|
36
|
-
if (!/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(profile)) throw new Error(`
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(profile)) throw new Error(`profile "${profile}": use letters, digits, - and _ only`);
|
|
41
|
+
const usual = path.join(home, '.config', 'herdrchat', profile);
|
|
42
|
+
let configDir;
|
|
43
|
+
let stateDir;
|
|
44
|
+
if (env.HERDRCHAT_HOME) {
|
|
45
|
+
configDir = env.HERDRCHAT_HOME;
|
|
46
|
+
stateDir = path.join(env.HERDRCHAT_HOME, 'state');
|
|
47
|
+
} else {
|
|
48
|
+
configDir = path.join(env.XDG_CONFIG_HOME || path.join(home, '.config'), 'herdrchat', profile);
|
|
49
|
+
stateDir = path.join(env.XDG_STATE_HOME || path.join(home, '.local', 'state'), 'herdrchat', profile);
|
|
50
|
+
}
|
|
51
|
+
const usualState = path.join(home, '.local', 'state', 'herdrchat', profile);
|
|
52
|
+
const moved = path.resolve(configDir) !== usual || path.resolve(stateDir) !== usualState;
|
|
53
|
+
const tag = moved ? crypto.createHash('sha1').update(`${path.resolve(configDir)}\0${path.resolve(stateDir)}`).digest('hex').slice(0, 6) : '';
|
|
54
|
+
const id = tag ? `${profile}-${tag}` : profile;
|
|
55
|
+
const terminal = id === 'default' ? 'herdrchat' : `herdrchat-${id}`;
|
|
56
|
+
return { profile, id, configDir, stateDir, terminal };
|
|
42
57
|
}
|
|
43
58
|
|
|
44
59
|
const places = placesOf();
|
|
@@ -90,13 +105,16 @@ export function onOff(value, fallback) {
|
|
|
90
105
|
|
|
91
106
|
// `requires`: config keys the chat app cannot start without (its module's `requires`).
|
|
92
107
|
// `env` is everything config.env and the environment hold, for the app's own keys.
|
|
93
|
-
|
|
108
|
+
// `sessions: false`: an empty HERDR_SESSIONS is not an error (status shows what is set).
|
|
109
|
+
export function loadConfig({ requires = [], sessions: needSessions = true } = {}) {
|
|
94
110
|
const env = { ...parseEnv(CONFIG_FILE), ...process.env };
|
|
95
111
|
const sessions = (env.HERDR_SESSIONS || '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
96
112
|
const missing = requires.filter((k) => !env[k]);
|
|
97
|
-
if (missing.length) throw new Error(`${
|
|
98
|
-
if (!sessions.length) throw new Error(
|
|
113
|
+
if (missing.length) throw new Error(`${missing.join(', ')} ${missing.length > 1 ? 'are' : 'is'} empty in ${CONFIG_FILE}: fill ${missing.length > 1 ? 'them' : 'it'} in (the comment above each says where it comes from), then start again.`);
|
|
114
|
+
if (needSessions && !sessions.length) throw new Error(`HERDR_SESSIONS is empty in ${CONFIG_FILE}: put the hc sessions to mirror there, comma-separated (\`ls ~/.local/state/herdr-core\` lists the sessions on this machine), then start again.`);
|
|
99
115
|
return {
|
|
116
|
+
profile: places.profile,
|
|
117
|
+
configFile: CONFIG_FILE,
|
|
100
118
|
chat: env.HERDRCHAT_CHAT || 'feishu',
|
|
101
119
|
env,
|
|
102
120
|
sessions,
|
package/lib/core/engine.mjs
CHANGED
|
@@ -58,7 +58,8 @@ const ENDED_REPLY = 'Not delivered: this terminal has ended. Nothing you send he
|
|
|
58
58
|
const WAIT = Symbol('wait');
|
|
59
59
|
const FILE_BYTES = 20 * 1000 * 1000;
|
|
60
60
|
|
|
61
|
-
// hc: {followEvents, cores, machines, getTerminal, screenTail, send, keys, put, run, kill
|
|
61
|
+
// hc: {followEvents, cores, machines, getTerminal, screenTail, send, keys, put, run, kill,
|
|
62
|
+
// nameTab?(tabId, name): name a tab in herdr-app, when it runs here};
|
|
62
63
|
// readers: {agentOf, readerFor}; store: {read, write} of state.json; inbox: a folder for
|
|
63
64
|
// images on their way to a terminal; quick: shorter waits (tests).
|
|
64
65
|
export function createEngine({ cfg, chat, hc, readers, store, log, logWarn, logError, startedAt = new Date().toISOString(), inbox = path.join(os.tmpdir(), 'herdrchat-inbox'), quick = false }) {
|
|
@@ -952,8 +953,9 @@ export function createEngine({ cfg, chat, hc, readers, store, log, logWarn, logE
|
|
|
952
953
|
log(`-> ${session}/${address}: ${text.slice(0, 80)}`);
|
|
953
954
|
await markWorking(k, message);
|
|
954
955
|
} catch (err) {
|
|
955
|
-
|
|
956
|
-
|
|
956
|
+
// the screen only when it helps: it waits on something you can answer from there
|
|
957
|
+
const waits = /blocked/i.test(err.message);
|
|
958
|
+
await chat.reply(where, `Not delivered: ${plainError(err)}${waits ? `\n\n${await screen(session, address)}\n\nIt waits on a dialog. Reply \`/answer <text>\` to answer it, or \`/esc\` to cancel it.` : ''}`);
|
|
957
959
|
return;
|
|
958
960
|
}
|
|
959
961
|
// Anything but an agent posts nothing by itself: what you typed gets one look at its
|
|
@@ -1028,7 +1030,7 @@ export function createEngine({ cfg, chat, hc, readers, store, log, logWarn, logE
|
|
|
1028
1030
|
'`/new <session> <space>/<tab> [command]` start a terminal (default `claude`); `--cwd <dir>` first to pick its folder',
|
|
1029
1031
|
'`/kill <session> <address>` end a terminal',
|
|
1030
1032
|
...(chat.help || []),
|
|
1031
|
-
|
|
1033
|
+
`In a terminal's ${chat.roomWord}: type to talk to it; \`/screen\`, \`/answer <text>\`, \`/esc\`, \`/history\` (the conversation so far, as a file).`,
|
|
1032
1034
|
].join('\n');
|
|
1033
1035
|
|
|
1034
1036
|
async function newTerminal(args) {
|
|
@@ -1047,13 +1049,17 @@ export function createEngine({ cfg, chat, hc, readers, store, log, logWarn, logE
|
|
|
1047
1049
|
const spaceId = sibling?.labels['herdr.space'] || `sp${crypto.randomBytes(16).toString('hex')}`;
|
|
1048
1050
|
const folder = cwd || sibling?.foreground?.cwd || sibling?.cwd || os.homedir();
|
|
1049
1051
|
const line = command.length ? command.join(' ') : 'claude';
|
|
1052
|
+
const tabId = `tb${crypto.randomBytes(16).toString('hex')}`;
|
|
1050
1053
|
const out = await hc.run(session, {
|
|
1051
1054
|
cwd: folder,
|
|
1052
|
-
labels: { 'herdr.space': spaceId, 'herdr.tab':
|
|
1055
|
+
labels: { 'herdr.space': spaceId, 'herdr.tab': tabId, where: `${space} / ${tab}` },
|
|
1053
1056
|
argv: ['/bin/zsh', '-lc', `exec ${line}`], // login shell for PATH; exec so no bare shell is left behind
|
|
1054
1057
|
});
|
|
1055
1058
|
const address = JSON.parse(out).address;
|
|
1056
|
-
|
|
1059
|
+
// herdr-app makes the tab and numbers it, whatever `where` says: it is named as asked,
|
|
1060
|
+
// by the tab id it keeps (nothing happens where herdr-app is not running)
|
|
1061
|
+
if (hc.nameTab) hc.nameTab(tabId, tab).catch((err) => logWarn(`tab ${tab} not named in herdr-app: ${err.message}`));
|
|
1062
|
+
return `Started \`${line}\` as \`${address}\` in ${session} / ${space} / ${tab} (\`${folder}\`). Its ${chat.roomWord} appears in a moment.`;
|
|
1057
1063
|
}
|
|
1058
1064
|
|
|
1059
1065
|
async function direct(where, text) {
|
|
@@ -1073,14 +1079,14 @@ export function createEngine({ cfg, chat, hc, readers, store, log, logWarn, logE
|
|
|
1073
1079
|
async function onMessage({ room, where, message, text, images = [] }) {
|
|
1074
1080
|
try {
|
|
1075
1081
|
if (!room) {
|
|
1076
|
-
if (images.length) await chat.reply(where,
|
|
1082
|
+
if (images.length) await chat.reply(where, `Send images in a terminal's ${chat.roomWord}.`);
|
|
1077
1083
|
else await direct(where, text);
|
|
1078
1084
|
return;
|
|
1079
1085
|
}
|
|
1080
1086
|
const target = terminalOfRoom(room);
|
|
1081
1087
|
if (!target) {
|
|
1082
1088
|
log(`message in a room this bot did not make: ${room}`);
|
|
1083
|
-
await chat.reply(where,
|
|
1089
|
+
await chat.reply(where, `Not delivered: this ${chat.roomWord} is not a terminal's. Each terminal has its own ${chat.roomWord}; the direct chat takes /ls, /new and /kill.`);
|
|
1084
1090
|
return;
|
|
1085
1091
|
}
|
|
1086
1092
|
if (views.get(target.session)?.terminals.get(target.address)?.state !== 'live') {
|
package/lib/core/hc.mjs
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
// Thin wrapper over the hc CLI. Every call names its session (HC_SESSION),
|
|
2
2
|
// never the default core.
|
|
3
3
|
import { execFile } from 'node:child_process';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import net from 'node:net';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
4
8
|
|
|
5
9
|
// The bot runs in an hc terminal itself. Called with that terminal's identity, hc would
|
|
6
10
|
// wrap what the bot types in <hc from="tN"> tags, as a note from another agent; without
|
|
@@ -68,7 +72,8 @@ export function run(session, { name, cwd, labels = {}, argv }) {
|
|
|
68
72
|
|
|
69
73
|
// force: type even when the program is blocked on a dialog (it answers the dialog).
|
|
70
74
|
export function send(session, address, text, { force = false } = {}) {
|
|
71
|
-
|
|
75
|
+
// `--`: what you typed is never taken for an option, even when it starts with "-"
|
|
76
|
+
const args = ['send', ...(force ? ['--force'] : []), address, '--', text];
|
|
72
77
|
return hc(session, args, { timeoutMs: 60000 });
|
|
73
78
|
}
|
|
74
79
|
|
|
@@ -96,6 +101,57 @@ export function isUnavailable(err) {
|
|
|
96
101
|
return /"kind":"unavailable"/.test(err?.message || '');
|
|
97
102
|
}
|
|
98
103
|
|
|
104
|
+
// The hc sessions on this machine, from hc's own folder (asking hc about a session that
|
|
105
|
+
// does not exist would start one: a typo must not make a session).
|
|
106
|
+
export const hcStateRoot = (env = process.env) => path.join(env.XDG_STATE_HOME || path.join(os.homedir(), '.local', 'state'), 'herdr-core');
|
|
107
|
+
export function sessionsHere() {
|
|
108
|
+
try {
|
|
109
|
+
return fs.readdirSync(hcStateRoot(), { withFileTypes: true }).filter((d) => d.isDirectory() && fs.existsSync(path.join(hcStateRoot(), d.name, 'core.json'))).map((d) => d.name).sort();
|
|
110
|
+
} catch {
|
|
111
|
+
return [];
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Whether a session's hc core runs, without starting it: its socket (named in core.lock)
|
|
116
|
+
// takes a connection.
|
|
117
|
+
export function coreUp(session, timeoutMs = 1000) {
|
|
118
|
+
let socket;
|
|
119
|
+
try {
|
|
120
|
+
socket = fs.readFileSync(path.join(hcStateRoot(), session, 'core.lock'), 'utf8').trim().split('\n')[0];
|
|
121
|
+
} catch {
|
|
122
|
+
return Promise.resolve(false);
|
|
123
|
+
}
|
|
124
|
+
return new Promise((resolve) => {
|
|
125
|
+
const c = net.connect(socket);
|
|
126
|
+
const done = (up) => { c.destroy(); resolve(up); };
|
|
127
|
+
c.once('connect', () => done(true));
|
|
128
|
+
c.once('error', () => done(false));
|
|
129
|
+
setTimeout(() => done(false), timeoutMs);
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// herdr-app names the tab with this id (it may take a few seconds to show it), when
|
|
134
|
+
// herdr-app runs on this machine; resolves false when it is not there or never shows it.
|
|
135
|
+
const TAB_TRIES = 10;
|
|
136
|
+
export async function nameTab(tabId, name, { tries = TAB_TRIES, waitMs = 1000 } = {}) {
|
|
137
|
+
const app = (args) => new Promise((resolve, reject) => execFile('herdr-app', args, { env: ENV, timeout: 10000 }, (err, stdout) => (err ? reject(err) : resolve(stdout))));
|
|
138
|
+
for (let i = 0; i < tries; i += 1) {
|
|
139
|
+
let spaces;
|
|
140
|
+
try {
|
|
141
|
+
spaces = JSON.parse(await app(['ls'])).spaces || [];
|
|
142
|
+
} catch {
|
|
143
|
+
return false; // no herdr-app here, or no window
|
|
144
|
+
}
|
|
145
|
+
const found = spaces.flatMap((s) => s.tabs || []).find((t) => t.id === tabId || t.id.endsWith(`/${tabId}`));
|
|
146
|
+
if (found) {
|
|
147
|
+
if (found.name !== name) await app(['do', 'tab.rename', '--id', found.id, '--name', name]);
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
await new Promise((r) => setTimeout(r, waitMs));
|
|
151
|
+
}
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
|
|
99
155
|
// A failed hc call as a person reads it: hc's own message, without the command line or
|
|
100
156
|
// its JSON wrapping.
|
|
101
157
|
export function plainError(err) {
|
package/lib/core/log.mjs
CHANGED
|
@@ -9,6 +9,9 @@ import path from 'node:path';
|
|
|
9
9
|
import util from 'node:util';
|
|
10
10
|
|
|
11
11
|
const KEEP_DAYS = 14;
|
|
12
|
+
|
|
13
|
+
// A log file per local day (what "today" means to you); the time on each line is UTC.
|
|
14
|
+
export const localDay = (d = new Date()) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
12
15
|
const CRASH_WAIT_MS = 3000;
|
|
13
16
|
|
|
14
17
|
export function createLog(dir) {
|
|
@@ -19,7 +22,7 @@ export function createLog(dir) {
|
|
|
19
22
|
const prune = (today) => {
|
|
20
23
|
if (pruned === today) return;
|
|
21
24
|
pruned = today;
|
|
22
|
-
const oldest = new Date(Date.now() - KEEP_DAYS * 86400 * 1000)
|
|
25
|
+
const oldest = localDay(new Date(Date.now() - KEEP_DAYS * 86400 * 1000));
|
|
23
26
|
for (const f of fs.readdirSync(dir)) {
|
|
24
27
|
if (/^\d{4}-\d{2}-\d{2}\.log$/.test(f) && f.slice(0, 10) < oldest) fs.rmSync(path.join(dir, f), { force: true });
|
|
25
28
|
}
|
|
@@ -32,8 +35,8 @@ export function createLog(dir) {
|
|
|
32
35
|
const line = `${now} ${level} ${text}`;
|
|
33
36
|
(level === 'ERROR' ? out.error : out.log)(line);
|
|
34
37
|
try {
|
|
35
|
-
fs.appendFileSync(path.join(dir, `${
|
|
36
|
-
prune(
|
|
38
|
+
fs.appendFileSync(path.join(dir, `${localDay()}.log`), `${line}\n`, { mode: 0o600 }); // it quotes your messages
|
|
39
|
+
prune(localDay());
|
|
37
40
|
} catch {
|
|
38
41
|
// the disk is the last resort; stdout has the line
|
|
39
42
|
}
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// Cursors are in ../cursor.mjs.
|
|
17
17
|
//
|
|
18
18
|
// To add an agent: write lib/core/readers/<agent>.mjs and list it in LISTED below
|
|
19
|
-
// (docs/architecture.md, "Adding
|
|
19
|
+
// (docs/architecture.md, "Adding an agent").
|
|
20
20
|
import path from 'node:path';
|
|
21
21
|
import * as claude from './claude.mjs';
|
|
22
22
|
|
package/lib/core/service.mjs
CHANGED
|
@@ -76,6 +76,7 @@ const run = (cmd, args) => execFileSync(cmd, args, { encoding: 'utf8', stdio: ['
|
|
|
76
76
|
|
|
77
77
|
// {kind, name, file, content, write, remove, installed, load, unload, restart, state,
|
|
78
78
|
// nodeGone}, or null on a system with neither launchd nor systemd.
|
|
79
|
+
// `profile` here is the profile's id (placesOf: the profile, tagged when its folder is moved).
|
|
79
80
|
export function serviceOf({ profile, root, node, env, stateDir, platform = process.platform, home = os.homedir() }) {
|
|
80
81
|
const script = path.join(root, 'bin', 'herdrchat.mjs');
|
|
81
82
|
const out = path.join(stateDir, 'logs', 'service.out.log');
|