herdrchat 1.3.0 → 1.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,33 +1,36 @@
1
1
  # herdrchat architecture
2
2
 
3
- herdrchat has two kinds of plug-ins that grow over time: **chat apps** (Feishu now,
4
- Telegram next, then Slack and Discord) and **harnesses** (Claude Code now, then codex
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 harness
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, and its live event stream
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
- readers/ one reader per harness (the contract is in index.mjs)
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 a harness
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 terminal it shows (it has a `where` label) | a group named `<space> / <tab>`, tagged with the session; type there to talk to it. A tab only numbered (`2`) is named by the terminal's title instead (whatever the program sets, for any agent; Claude Code sets its session name or topic), else its hc name |
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
- | herdr itself | 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` |
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 @BotFather):
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
- | herdr itself | the chat's main thread: the overview message, edited in place; `/ls`, `/new`, `/kill`; `/` shows the commands |
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
- `skills/herdrchat/references/telegram.md`.
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 folders. `herdrchat start` and
85
- `herdrchat install` hand the bot hc's full path as `HERDRCHAT_HC`. A setting changed in
86
- `config.env` takes effect at the next `herdrchat start`.
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
- herdr-core `schema/README.md`, "Events"). From hc it learns which terminals exist, where
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 a harness".
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: `skills/herdrchat/references/telegram.md`. Feishu:
212
-
213
- Steps 1, 2, 5 and 6 are in the Feishu developer console; the herdrchat skill
214
- (`/herdrchat setup`, see "Skills") does them with an agent, or guides you through them.
215
-
216
- 1. Feishu developer console: create a custom app (企业自建应用), add the bot ability.
217
- 2. Permissions (batch import): `im:message:send_as_bot`, `im:message.p2p_msg:readonly`,
218
- `im:message.group_msg`, `im:chat:create`, `im:chat:update`,
219
- `im:message.reactions:write_only`, `im:resource` (images out),
220
- `im:message:readonly` (images in), `im:tag:write`,
221
- `im:biz_entity_tag_relation:write` (group tags).
222
- 3. `herdrchat init` makes `~/.config/herdrchat/default/config.env`; fill the app ID, the
223
- secret and `HERDR_SESSIONS`: the hc sessions on the machine the bot runs on that herdr-app shows
224
- (the machines they connect to come along, as in herdr-app). See "Where things are"
225
- for other places and more than one profile.
226
- 4. `herdrchat start` (the console checks for a live connection in the next step).
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 your `open_id`; put it in `FEISHU_OWNER_OPEN_ID` and run
233
- `herdrchat start` again.
234
- 8. Optional: the bot menu in the direct chat (`skills/herdrchat/references/feishu.md`,
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/`. To use it
287
- with Claude Code, link it where skills are read, for example
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 (one file a
293
- day, the last 14 kept) as `<time> <LEVEL> <text>`, besides its terminal: `grep ERROR
294
- ~/.local/state/herdrchat/<profile>/logs/*.log` finds
295
- what went wrong, a retry that recovered is a `WARN`, and a crash leaves its stack there
296
- before it is started again. Tokens and secrets are never logged. The
297
- skill's `references/ops.md` explains the lines.
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.
@@ -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, `Owner is not set. Put this in config.env:\n\n\`FEISHU_OWNER_OPEN_ID=${openId}\`\n\nthen restart the bot.`);
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, `Owner is not set. Put this in config.env:\n\n\`TELEGRAM_OWNER_ID=${from}\`\n\nthen restart the bot.`);
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}; turn it on in @BotFather (Bot Settings), or no room can be made`);
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,
@@ -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, terminal}.
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(`HERDRCHAT_PROFILE "${profile}": letters, digits, - and _ only`);
37
- const terminal = profile === 'default' ? 'herdrchat' : `herdrchat-${profile}`;
38
- if (env.HERDRCHAT_HOME) return { profile, configDir: env.HERDRCHAT_HOME, stateDir: path.join(env.HERDRCHAT_HOME, 'state'), terminal };
39
- const configRoot = env.XDG_CONFIG_HOME || path.join(home, '.config');
40
- const stateRoot = env.XDG_STATE_HOME || path.join(home, '.local', 'state');
41
- return { profile, configDir: path.join(configRoot, 'herdrchat', profile), stateDir: path.join(stateRoot, 'herdrchat', profile), terminal };
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
- export function loadConfig({ requires = [] } = {}) {
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(`${CONFIG_FILE}: ${missing.join(', ')} ${missing.length > 1 ? 'are' : 'is'} required`);
98
- if (!sessions.length) throw new Error(`${CONFIG_FILE}: HERDR_SESSIONS must list at least one hc session`);
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,
@@ -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
- const hint = /blocked/i.test(err.message) ? '\n\nIt waits on a dialog. Reply `/answer <text>` to answer it, or `/esc` to cancel it.' : '';
956
- await chat.reply(where, `Not delivered: ${plainError(err)}\n\n${await screen(session, address)}${hint}`);
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
- 'In a terminal\'s room: type to talk to it; `/screen`, `/answer <text>`, `/esc`, `/history` (the conversation so far, as a file).',
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': `tb${crypto.randomBytes(16).toString('hex')}`, where: `${space} / ${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
- return `Started \`${line}\` as \`${address}\` in ${session} / ${space} / ${tab} (\`${folder}\`). Its room appears in a moment.`;
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, 'Send images in a terminal\'s room.');
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, 'Not delivered: this room is not a terminal\'s. Each terminal has its own room; the direct chat takes /ls, /new and /kill.');
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
- const args = ['send', ...(force ? ['--force'] : []), address, text];
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).toISOString().slice(0, 10);
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, `${now.slice(0, 10)}.log`), `${line}\n`, { mode: 0o600 }); // it quotes your messages
36
- prune(now.slice(0, 10));
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 a harness").
19
+ // (docs/architecture.md, "Adding an agent").
20
20
  import path from 'node:path';
21
21
  import * as claude from './claude.mjs';
22
22
 
@@ -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');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "herdrchat",
3
- "version": "1.3.0",
3
+ "version": "1.3.2",
4
4
  "description": "Chat front-ends for hc: every terminal hc runs, in the chat app you use",
5
5
  "repository": {
6
6
  "type": "git",
@@ -37,6 +37,7 @@
37
37
  "devDependencies": {
38
38
  "@semantic-release/changelog": "^7.0.0",
39
39
  "@semantic-release/git": "^11.0.1",
40
- "semantic-release": "^25.0.9"
40
+ "semantic-release": "^25.0.9",
41
+ "yaml": "^2.9.1"
41
42
  }
42
43
  }