herdrchat 1.1.2 → 1.2.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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  # herdrchat
4
4
 
5
- Every terminal hc runs, in the chat app you already use. Feishu first.
5
+ Every terminal hc runs, in the chat app you already use: Feishu or Telegram.
6
6
 
7
7
  [English](README.md) · [简体中文](README.zh.md)
8
8
 
@@ -16,13 +16,15 @@ herdr-app has official phone and desktop apps. But to use them away from your de
16
16
  phone has to reach the machine that runs hc, so you first need a VPN or a private network.
17
17
  And on a small phone screen, working with an agent is clumsy.
18
18
 
19
- Feishu is already on your phone. herdrchat connects out to Feishu by itself, so there is
20
- nothing to set up on your network: no public IP, no VPN. Each terminal becomes a Feishu
21
- group. You read what the agent says while it works, tap buttons to answer its questions,
22
- send and receive images, and see all terminals on one overview card. Your phone rings
23
- only when an agent needs you or is done. It works with any agent hc runs.
19
+ Your chat app is already on your phone. herdrchat connects out to it by itself, so there
20
+ is nothing to set up on your network: no public IP, no VPN. Each herdr-app tab becomes a
21
+ Feishu group, or a topic in your private chat with the Telegram bot. You read what the
22
+ agent says while it works, tap buttons to answer its questions, send and receive images,
23
+ and see all terminals on one overview. A new room starts with the conversation so far.
24
+ Your phone rings only when an agent needs you or is done. It works with any agent hc
25
+ runs.
24
26
 
25
- Feishu comes first. When we have time, Slack, Discord and Telegram may follow.
27
+ Use one app or both at once (a profile each). Slack and Discord may follow.
26
28
 
27
29
  ## Install
28
30
 
@@ -35,18 +37,22 @@ Then ask your agent:
35
37
 
36
38
  | Command | What it does |
37
39
  |---|---|
38
- | `/herdrchat setup` | Sets everything up with you: the Feishu app, the settings, the first start. |
40
+ | `/herdrchat setup` | Sets everything up with you: the Feishu app or the Telegram bot, the settings, the first start. |
39
41
  | `/herdrchat status` | Says whether it runs, and what is wrong if anything is. |
40
42
  | `/herdrchat fix` | Finds the cause of a problem and fixes it. |
41
43
  | `/herdrchat config` | Changes a setting: which terminals, which groups stay quiet, tags. |
42
44
  | `/herdrchat feishu <task>` | Does a task in the Feishu developer console for you. |
45
+ | `/herdrchat telegram <task>` | The Telegram bot, from @BotFather to the first topic. |
43
46
 
44
- On the command line, without an agent: `herdrchat init`, `start`, `stop`, `status`, `logs`.
47
+ On the command line, without an agent: `herdrchat init`, `start`, `stop`, `status`, `logs`;
48
+ `herdrchat install` runs it as a service that starts at login and comes back after a
49
+ crash. Add `--profile <name>` for a second chat app.
45
50
 
46
51
  ## Where things are
47
52
 
48
- Settings in `~/.config/herdrchat/default/config.env`, everything the bot writes in
49
- `~/.local/state/herdrchat/default/`. Updating or reinstalling herdrchat never touches them.
53
+ Settings in `~/.config/herdrchat/<profile>/config.env`, everything the bot writes in
54
+ `~/.local/state/herdrchat/<profile>/` (the profile is `default` unless you name one).
55
+ Updating or reinstalling herdrchat never touches them.
50
56
 
51
57
  How it works and every setting: [docs/guide.md](docs/guide.md).
52
58
 
package/README.zh.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  # herdrchat
4
4
 
5
- hc 跑的每个终端,都能在你常用的聊天软件里看到和回复。先支持飞书。
5
+ hc 跑的每个终端,都能在你常用的聊天软件里看到和回复:飞书或 Telegram。
6
6
 
7
7
  [English](README.md) · [简体中文](README.zh.md)
8
8
 
@@ -15,12 +15,13 @@ hc 跑的每个终端,都能在你常用的聊天软件里看到和回复。
15
15
  herdr-app 官方有手机端和电脑端。但想在外面用,手机得能连到跑 hc 的机器,
16
16
  所以要先折腾 VPN 或内网环境;而且在手机的小屏幕上和 agent 交互,体验并不好。
17
17
 
18
- 飞书本来就在你手机上。herdrchat 自己主动连到飞书,网络上什么都不用配:不要公网 IP,也不要 VPN。
19
- 每个终端就是一个飞书群:agent 干活时说的话实时可见,它问你的问题点按钮就能回答,
20
- 图片双向都能传,所有终端汇总在一张总览卡片上。只有 agent 需要你、或者做完的时候手机才会响。
18
+ 聊天软件本来就在你手机上。herdrchat 自己主动连过去,网络上什么都不用配:不要公网 IP,也不要 VPN。
19
+ herdr-app 的每个标签页,在飞书里是一个群,在 Telegram 里是和 bot 私聊中的一个话题:
20
+ agent 干活时说的话实时可见,它问你的问题点按钮就能回答,图片双向都能传,所有终端汇总在一张总览里。
21
+ 新开的群或话题会先补上之前的对话。只有 agent 需要你、或者做完的时候手机才会响。
21
22
  hc 能跑的 agent 都能用,我们自己用下来非常顺畅。
22
23
 
23
- 先支持飞书。后面有精力的话,也会考虑接入 Slack、Discord、Telegram 等其他聊天软件。
24
+ 可以只用一个,也可以两个同时用(各开一个 profile)。后面有精力的话,也会考虑接入 Slack、Discord。
24
25
 
25
26
  ## 安装
26
27
 
@@ -33,17 +34,19 @@ npm install -g herdrchat # skill 要用到的 herdrchat 命令
33
34
 
34
35
  | 命令 | 做什么 |
35
36
  |---|---|
36
- | `/herdrchat setup` | 和你一起从零配好:飞书应用、配置、第一次启动。 |
37
+ | `/herdrchat setup` | 和你一起从零配好:飞书应用或 Telegram bot、配置、第一次启动。 |
37
38
  | `/herdrchat status` | 告诉你它在不在跑,有问题的话问题在哪。 |
38
39
  | `/herdrchat fix` | 找出问题的原因并修好。 |
39
40
  | `/herdrchat config` | 改配置:接哪些终端、哪些群保持安静、标签。 |
40
41
  | `/herdrchat feishu <任务>` | 替你在飞书开发者后台完成操作。 |
42
+ | `/herdrchat telegram <任务>` | Telegram bot:从 @BotFather 一直到第一个话题。 |
41
43
 
42
- 不用 agent 也可以直接在命令行里用:`herdrchat init`、`start`、`stop`、`status`、`logs`。
44
+ 不用 agent 也可以直接在命令行里用:`herdrchat init`、`start`、`stop`、`status`、`logs`;
45
+ `herdrchat install` 把它装成系统服务,开机自动启动,崩溃后自动重启。第二个聊天软件加 `--profile <名字>`。
43
46
 
44
47
  ## 文件在哪
45
48
 
46
- 配置在 `~/.config/herdrchat/default/config.env`,bot 写的东西都在 `~/.local/state/herdrchat/default/`。
49
+ 配置在 `~/.config/herdrchat/<profile>/config.env`,bot 写的东西都在 `~/.local/state/herdrchat/<profile>/`(没指定 profile 时就是 `default`)。
47
50
  更新或重装 herdrchat 都不会动它们。
48
51
 
49
52
  工作原理和全部配置:[docs/guide.md](docs/guide.md)(英文)。
package/bin/cli.mjs CHANGED
@@ -2,12 +2,15 @@
2
2
  // herdrchat's command line: set up, start, stop and look at one profile.
3
3
  //
4
4
  // herdrchat <command> [--profile <name>]
5
- // init make the profile's config.env from the example when it is missing
6
- // start start (or restart) it in its own hc terminal, kept running
7
- // stop stop it
8
- // status running or not, its folders, sessions, /auth, errors today
9
- // logs today's log (-n <lines>, --errors for ERROR and WARN only)
10
- // run run in the foreground (what start keeps running)
5
+ // init make the profile's config.env from the example when it is missing
6
+ // install run it as a service of the system (launchd, systemd): it starts at
7
+ // login and comes back after a crash; start, stop, status go through it
8
+ // uninstall no service any more (it is stopped)
9
+ // start start (or restart) it: the service, or else its own hc terminal
10
+ // stop stop it
11
+ // status how it runs, its folders, sessions, its chat app, errors today
12
+ // logs today's log (-n <lines>, --errors for ERROR and WARN only)
13
+ // run run in the foreground (what start keeps running)
11
14
  //
12
15
  // The profile is --profile or HERDRCHAT_PROFILE (default "default"); where its files are
13
16
  // is in lib/core/config.mjs.
@@ -18,12 +21,14 @@ import { fileURLToPath } from 'node:url';
18
21
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
19
22
  const USAGE = `herdrchat <command> [--profile <name>]
20
23
 
21
- init make the profile's config.env from the example when it is missing
22
- start start (or restart) it in its own hc terminal, kept running
23
- stop stop it
24
- status running or not, its folders, sessions, /auth, errors today
25
- logs today's log (-n <lines>, --errors for ERROR and WARN only)
26
- run run in the foreground (what start keeps running)`;
24
+ init make the profile's config.env from the example when it is missing
25
+ install run it as a service of the system: starts at login, back after a crash
26
+ uninstall no service any more (it is stopped)
27
+ start start (or restart) it: the service, or else its own hc terminal
28
+ stop stop it
29
+ status how it runs, its folders, sessions, its chat app, errors today
30
+ logs today's log (-n <lines>, --errors for ERROR and WARN only)
31
+ run run in the foreground (what start keeps running)`;
27
32
 
28
33
  const argv = process.argv.slice(2);
29
34
  const flag = (name) => {
@@ -42,6 +47,7 @@ const command = argv.find((a) => !a.startsWith('-'));
42
47
  // after the profile is set: lib/core/config.mjs reads it when it loads
43
48
  const { CONFIG_FILE, STATE_DIR, loadConfig, makeDirs, placesOf } = await import('../lib/core/config.mjs');
44
49
  const { hc } = await import('../lib/core/hc.mjs');
50
+ const { serviceOf } = await import('../lib/core/service.mjs');
45
51
  const places = placesOf();
46
52
 
47
53
  const shellQuote = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
@@ -86,6 +92,26 @@ async function waitConnected(since) {
86
92
  return { ok: false, errors: [...new Set(errors.map((l) => l.slice(25)))].slice(-5) };
87
93
  }
88
94
 
95
+ // What the bot needs wherever it runs (hc starts a program with the core's environment,
96
+ // and a service with almost none): hc's full path, our PATH, the profile and the folders.
97
+ // Nothing else, so no agent's variables come along.
98
+ function botEnv() {
99
+ const hcPath = findHc();
100
+ if (!hcPath) throw new Error('hc is not on PATH: install herdr-core first (https://herdr.app)');
101
+ const env = { HERDRCHAT_HC: hcPath, HERDRCHAT_PROFILE: places.profile };
102
+ for (const k of ['PATH', 'HERDRCHAT_HOME', 'XDG_CONFIG_HOME', 'XDG_STATE_HOME']) if (process.env[k]) env[k] = process.env[k];
103
+ return env;
104
+ }
105
+
106
+ const service = () => serviceOf({ profile: places.profile, root: ROOT, node: process.execPath, env: botEnv(), stateDir: STATE_DIR });
107
+
108
+ function failedToConnect(where, result) {
109
+ console.error(`${where} but did not reach its chat app within ${CONNECT_WAIT_MS / 1000} s.`);
110
+ if (result.errors.length) console.error(`Its errors:\n ${result.errors.join('\n ')}`);
111
+ console.error('It keeps retrying; `herdrchat logs --errors` shows more.');
112
+ process.exitCode = 1;
113
+ }
114
+
89
115
  async function terminalState(session) {
90
116
  try {
91
117
  return JSON.parse(await hc(session, ['get', places.terminal, '--json'])).state;
@@ -106,53 +132,97 @@ const commands = {
106
132
  console.log(`Made ${CONFIG_FILE}: fill in the app id, the secret and HERDR_SESSIONS.`);
107
133
  },
108
134
 
135
+ // The service file is written again each time (a moved node or hc is picked up), and
136
+ // the bot in an hc terminal is stopped first: two bots would answer every message.
137
+ async install() {
138
+ const svc = service();
139
+ if (!svc) throw new Error(`no launchd or systemd on ${process.platform}: run \`herdrchat start\` instead`);
140
+ loadConfig(); // a config that cannot start the bot fails here, not in the service
141
+ await stopEverywhere();
142
+ svc.unload();
143
+ svc.write(svc.content);
144
+ const since = new Date().toISOString();
145
+ svc.load();
146
+ const result = await waitConnected(since);
147
+ if (!result.ok) return failedToConnect(`The ${svc.kind} service ${svc.name} is installed`, result);
148
+ console.log(`Installed the ${svc.kind} service ${svc.name} (${svc.file}); it is running, connected to its chat app, starts at login and comes back after a crash.`);
149
+ if (svc.kind === 'systemd') console.log('To keep it running after you log out: loginctl enable-linger');
150
+ },
151
+
152
+ async uninstall() {
153
+ const svc = service();
154
+ if (!svc?.installed()) {
155
+ console.log('No herdrchat service is installed for this profile.');
156
+ return;
157
+ }
158
+ svc.unload();
159
+ svc.remove();
160
+ console.log(`Removed the ${svc.kind} service ${svc.name}; the bot is stopped. \`herdrchat start\` runs it in an hc terminal again.`);
161
+ },
162
+
109
163
  async start() {
164
+ await stopEverywhere();
165
+ const svc = service();
166
+ const since = new Date().toISOString();
167
+ if (svc?.installed()) {
168
+ if (svc.loaded()) svc.restart();
169
+ else svc.load();
170
+ const result = await waitConnected(since);
171
+ if (!result.ok) return failedToConnect(`The ${svc.kind} service ${svc.name} started`, result);
172
+ console.log(`Started the ${svc.kind} service ${svc.name} (profile ${places.profile}); connected to its chat app.`);
173
+ return;
174
+ }
110
175
  const session = firstSession();
111
- const hcPath = findHc();
112
- if (!hcPath) throw new Error('hc is not on PATH: install herdr-core first (https://herdr.app)');
113
- // hc starts a program with the core's environment, not ours, and a core started by a
114
- // service may not have hc on its PATH: hc's full path, our PATH, the profile and the
115
- // folders go along
116
- const env = [['HERDRCHAT_HC', hcPath], ...['PATH', 'HERDRCHAT_PROFILE', 'HERDRCHAT_HOME', 'XDG_CONFIG_HOME', 'XDG_STATE_HOME'].map((k) => [k, process.env[k]])]
117
- .filter(([, v]) => v)
118
- .map(([k, v]) => `${k}=${shellQuote(v)}`)
119
- .join(' ');
176
+ const env = Object.entries(botEnv()).map(([k, v]) => `${k}=${shellQuote(v)}`).join(' ');
120
177
  const run = `env ${env} ${shellQuote(process.execPath)} ${shellQuote(path.join(ROOT, 'bin', 'herdrchat.mjs'))}`;
121
178
  const loop = `while true; do ${run}; echo 'herdrchat exited, restarting in 3 s'; sleep 3; done`;
122
- await stopEverywhere();
123
- const since = new Date().toISOString();
124
179
  await hc(session, ['run', '--bg', '--no-parent', '--name', places.terminal, '--cwd', ROOT, '--', 'sh', '-c', loop]);
125
180
  const result = await waitConnected(since);
126
- if (!result.ok) {
127
- console.error(`${places.terminal} started in hc session ${session} but did not reach its chat app within ${CONNECT_WAIT_MS / 1000} s.`);
128
- if (result.errors.length) console.error(`Its errors:\n ${result.errors.join('\n ')}`);
129
- console.error('It keeps retrying; `herdrchat logs --errors` shows more.');
130
- process.exitCode = 1;
131
- return;
132
- }
133
- console.log(`Started ${places.terminal} (profile ${places.profile}) in hc session ${session}; connected to its chat app.`);
181
+ if (!result.ok) return failedToConnect(`${places.terminal} started in hc session ${session}`, result);
182
+ console.log(`Started ${places.terminal} (profile ${places.profile}) in hc session ${session}; connected to its chat app. \`herdrchat install\` makes it a service that starts at login and comes back after a crash.`);
134
183
  },
135
184
 
136
185
  async stop() {
137
186
  await stopEverywhere();
138
- console.log(`Stopped ${places.terminal}. Until it starts again, nothing reaches Feishu.`);
187
+ const svc = service();
188
+ if (svc?.installed()) svc.unload(); // stays installed: \`herdrchat start\` loads it again; at login it starts again
189
+ console.log(`Stopped herdrchat (profile ${places.profile}). Until it starts again, nothing reaches its chat app.`);
139
190
  },
140
191
 
141
192
  async status() {
142
- console.log(`profile ${places.profile} (hc terminal ${places.terminal})`);
193
+ console.log(`profile ${places.profile}`);
143
194
  console.log(`settings ${CONFIG_FILE}${fs.existsSync(CONFIG_FILE) ? '' : ' (missing: run `herdrchat init`)'}`);
144
195
  console.log(`state ${STATE_DIR}`);
145
196
  if (!fs.existsSync(CONFIG_FILE)) return;
146
197
  const cfg = loadConfig();
147
198
  console.log(`sessions ${cfg.sessions.join(', ')}`);
148
- console.log(`running ${(await terminalState(cfg.sessions[0])) === 'live' ? 'yes' : 'no (`herdrchat start`)'}`);
149
- let token = null;
150
- try {
151
- token = JSON.parse(fs.readFileSync(path.join(STATE_DIR, 'user-token.json'), 'utf8'));
152
- } catch {
153
- // no /auth yet
199
+ // how it runs: the service, an hc terminal, or not at all
200
+ const svc = service();
201
+ const inTerminal = (await terminalState(cfg.sessions[0])) === 'live';
202
+ if (svc?.installed()) {
203
+ const st = svc.state();
204
+ console.log(`running ${st.running ? `yes, as the ${svc.kind} service ${svc.name}${st.pid ? ` (pid ${st.pid})` : ''}` : `no: the ${svc.kind} service ${svc.name} is stopped (\`herdrchat start\`)`}`);
205
+ const gone = svc.nodeGone();
206
+ if (gone) console.log(` the service runs ${gone}, which is gone (node moved): \`herdrchat install\` again`);
207
+ if (inTerminal) console.log(` also in hc terminal ${places.terminal}: two bots; \`herdrchat start\` keeps one`);
208
+ } else {
209
+ console.log(`running ${inTerminal ? `yes, in hc terminal ${places.terminal} (session ${cfg.sessions[0]}); \`herdrchat install\` makes it a service` : 'no (`herdrchat start`, or `herdrchat install` for a service)'}`);
210
+ }
211
+ // what its chat app needs, without printing a secret
212
+ console.log(`chat ${cfg.chat}`);
213
+ if (cfg.chat === 'feishu') {
214
+ let token = null;
215
+ try {
216
+ token = JSON.parse(fs.readFileSync(path.join(STATE_DIR, 'user-token.json'), 'utf8'));
217
+ } catch {
218
+ // no /auth yet
219
+ }
220
+ console.log(`owner ${cfg.ownerOpenId ? 'set' : 'not set: message the bot, it says what to put in config.env'}`);
221
+ console.log(`/auth ${!token ? 'not done (muting, labels and pins need it)' : (token.refreshUntil || 0) > Date.now() ? 'done' : 'expired: send /auth again'}`);
222
+ } else if (cfg.chat === 'telegram') {
223
+ console.log(`token ${cfg.env.TELEGRAM_BOT_TOKEN ? 'set' : 'missing: TELEGRAM_BOT_TOKEN from @BotFather'}`);
224
+ console.log(`owner ${cfg.env.TELEGRAM_OWNER_ID ? 'set' : 'not set: message the bot, it says what to put in config.env'}`);
154
225
  }
155
- console.log(`/auth ${!token ? 'not done' : (token.refreshUntil || 0) > Date.now() ? 'done' : 'expired: send /auth again'}`);
156
226
  const log = path.join(STATE_DIR, 'logs', `${new Date().toISOString().slice(0, 10)}.log`);
157
227
  if (fs.existsSync(log)) {
158
228
  const text = fs.readFileSync(log, 'utf8');
package/docs/guide.md CHANGED
@@ -2,9 +2,11 @@
2
2
 
3
3
  How herdrchat works and every setting, for when the README is not enough.
4
4
 
5
- herdrchat is a chat front-end for hc: every terminal hc runs, followed and answered in a
6
- chat app. Feishu is the first (one bot); Slack, Discord and Telegram may follow, on the
7
- same core (hc events, a reader per agent, dialogs, the overview) with an adapter each.
5
+ herdrchat is a chat front-end for hc: every herdr-app tab, followed and answered in a chat
6
+ app, Feishu or Telegram. Both sit on the same core (hc events, a reader per agent,
7
+ dialogs, the overview), each behind one contract (`docs/architecture.md`); Slack and
8
+ Discord may follow the same way. A profile is one chat app (`HERDRCHAT_CHAT`); to use
9
+ both, run a profile each.
8
10
 
9
11
  ## Feishu
10
12
 
@@ -21,6 +23,24 @@ herdr-app, moved into one Feishu bot:
21
23
  Everything comes from hc, so it works the same for Claude Code, codex, pi or any program
22
24
  hc shows, on this machine or on a machine hc has added. Nothing is installed in the agents.
23
25
 
26
+ ## Telegram
27
+
28
+ 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):
30
+
31
+ | herdr-app | Telegram |
32
+ |---|---|
33
+ | a tab | a topic named `<space> / <tab>` in the bot's private chat; type there to talk to it |
34
+ | a space | one topic icon color for all of its topics (Telegram offers six) |
35
+ | 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 |
37
+
38
+ What differs: an image goes as its own photo after the text (a message cannot hold one);
39
+ a dialog's free answer is typed in the topic (a message has no text box); there are no
40
+ tags, labels, pins or `/auth` (a bot cannot act as you). Telegram takes about one message
41
+ a second in a chat, so the bot queues and waits as Telegram asks. Setting it up:
42
+ `skills/herdrchat/references/telegram.md`.
43
+
24
44
  ## How it works: hc, plus a reader per agent
25
45
 
26
46
  **hc is the backbone.** The bot follows `hc events --json` for each session (format:
@@ -36,13 +56,14 @@ each agent a reader reads the agent's own records. The status hc reports names t
36
56
  | `status.app` | reader | reads |
37
57
  |---|---|---|
38
58
  | `claude-code` | `lib/core/readers/claude.mjs` | its transcript: the terminal's foreground pid names the session in `~/.claude/sessions/<pid>.json` |
39
- | anything else | none (default) | the screen, through `hc read` |
59
+ | anything else | none | nothing by itself; what you type gets its screen in reply |
40
60
 
41
61
  On another machine, a reader reads the same files through `hc exec --on`. To add an
42
- agent, write `lib/core/readers/<agent>.mjs` (the contract is at the top of
43
- `lib/core/readers/index.mjs`) and list it there.
62
+ agent: `docs/architecture.md`, "Adding a harness".
63
+
64
+ ## What a room shows
44
65
 
45
- ## What a group shows
66
+ A room is a Feishu group or a Telegram topic.
46
67
 
47
68
  - A program with a reader:
48
69
  - while it works: one "⌛️ Working…" card per turn (then "✅ Worked"), edited every 5
@@ -50,9 +71,12 @@ agent, write `lib/core/readers/<agent>.mjs` (the contract is at the top of
50
71
  mid-turn) as **You:**, and each tool it calls as one line (`▸ Bash: Run the tests`).
51
72
  It shows the program is alive, as herdr-app does, and since an edit is not a new
52
73
  message, the phone does not ring every few seconds;
53
- - when the turn ends: its last answer, as a new message. Without `/auth` you are
54
- notified when a turn starts and when it is done; with it, groups are muted and only
55
- an @ under the answer rings (see "Optional: let the bot act as you").
74
+ - when the turn ends: its last answer, as a new message. In a space of
75
+ `HERDR_MUTED_SPACES` (all, by default) only "needs you" and "done" ring: in Feishu
76
+ the groups are muted (after `/auth`) and an @ under the answer rings; in Telegram
77
+ the posts go silently and a short reply rings. `HERDR_SILENT_SPACES` rings nothing.
78
+ - A message another terminal sent with `hc send` shows as **From t7 review**, not as
79
+ yours (hc wraps it in `<hc from="…">`, for any agent).
56
80
  - Images too: ones it looked at (a screenshot, an image file it read) and ones you
57
81
  pasted in the terminal, shown in the card.
58
82
  - What you sent from the group is not repeated.
@@ -63,15 +87,21 @@ agent, write `lib/core/readers/<agent>.mjs` (the contract is at the top of
63
87
  a red "⚠️ Needs you" card. When the screen shows a numbered menu (as Claude Code's
64
88
  permission and question dialogs do), the card shows only the dialog, as text, with a
65
89
  button per option; a multi-select also has "Next ⇥", a "Type something" option a box to
66
- type in, and every card "Esc". Without a menu the card shows the screen and how to
67
- answer by typing. Once the dialog is gone the card turns to "✅ Done".
68
-
69
- ## Answering a dialog from Feishu
70
-
71
- A button presses that option's number key in the terminal (Next presses tab, Esc esc).
90
+ type in (Feishu; in Telegram you type the answer in the topic), and every card "Esc".
91
+ Without a menu the card shows the last lines of the screen and how to answer by typing.
92
+ Once the dialog is gone the card turns to "✅ Done". A card is posted only for a dialog
93
+ that still waits: one answered while its screen was read gets none.
94
+
95
+ ## Answering a dialog from the chat
96
+
97
+ A button presses that option's number key in the terminal (Esc presses esc). A question
98
+ with previews (a box beside its options) takes no number, so there the button moves with
99
+ the arrows from the cursor to that option and presses Enter. In a multi-select, a box is
100
+ ticked on the card only, at once; Next sends them all: each box that differs from the
101
+ screen is pressed, then tab.
72
102
  Before it presses anything, the bot reads the screen again and checks that it still
73
- shows the same dialog (a hash of it, from its top border to its last option) and the
74
- same option, so an old card can never answer a newer dialog that looks alike. After the
103
+ shows the same dialog (a hash of it, from its top border to its last option, without the
104
+ cursor, the check marks and the preview box) and the same option, so an old card can never answer a newer dialog that looks alike. After the
75
105
  key, the card turns into the dialog's next step (a toggled box, the next question, the
76
106
  review) or says what was picked. Text you type in the group while it waits goes into its
77
107
  "Type something" box when it has one.
@@ -123,14 +153,17 @@ delivered (the terminal has ended) says so plainly.
123
153
  recalled), `/new <session> <space>/<tab> [command]` (start one; default `claude`;
124
154
  `--cwd <dir>` first to pick its folder), `/kill <session> <address>`, `/auth` (see
125
155
  below).
126
- - In a terminal's group: any text goes to it (marked THINKING once typed in, then OK when
127
- the turn that took it is over); an image (alone or in a rich text message) is sent
156
+ - In a terminal's room: any text goes to it (marked once typed in, and again when the
157
+ turn that took it is over); an image (alone or in a rich text message) is sent
128
158
  beside the terminal with `hc put`, on its machine, and its path is typed in with your
129
159
  text, so an agent like Claude Code opens it; `/screen`, `/answer <text>` (type into the
130
- dialog it waits on, as is), `/esc`. Dialog cards have buttons.
160
+ dialog it waits on, as is), `/esc`, `/history` (the conversation so far, as a file and
161
+ a folded post). Dialog cards have buttons.
131
162
 
132
163
  ## Set up
133
164
 
165
+ Telegram: `skills/herdrchat/references/telegram.md`. Feishu:
166
+
134
167
  Steps 1, 2, 5 and 6 are in the Feishu developer console; the herdrchat skill
135
168
  (`/herdrchat setup`, see "Skills") does them with an agent, or guides you through them.
136
169
 
@@ -152,6 +185,24 @@ Steps 1, 2, 5 and 6 are in the Feishu developer console; the herdrchat skill
152
185
  7. Open `https://applink.feishu.cn/client/bot/open?appId=<app id>` and send anything.
153
186
  The bot answers with your `open_id`; put it in `FEISHU_OWNER_OPEN_ID` and run
154
187
  `herdrchat start` again.
188
+ 8. Once it works: `herdrchat install` (see "Running as a service").
189
+
190
+ ## Running as a service
191
+
192
+ `herdrchat start` runs the bot in an hc terminal, which ends when that hc core stops,
193
+ and nothing starts it after a reboot. `herdrchat install` makes it a service of the
194
+ system instead: a launchd agent on macOS, a systemd user unit on Linux. It starts at
195
+ login, comes back by itself after a crash, and does not depend on any hc core. It runs
196
+ node and the bot by their full paths with only the variables it needs (hc's path, PATH,
197
+ the profile, the folders), so no agent's variables come along. With it installed,
198
+ `start`, `stop` and `status` go through the service; `herdrchat uninstall` removes it.
199
+ Each profile is its own service (`herdrchat install --profile telegram`).
200
+
201
+ - After a node upgrade that moves node, run `herdrchat install` again; `herdrchat status`
202
+ says when the service's node is gone.
203
+ - On Linux, `loginctl enable-linger` keeps it running after you log out.
204
+ - What the process prints before its log is open (a crash at start) goes to
205
+ `logs/service.out.log` in the state folder.
155
206
 
156
207
  ## Optional: let the bot act as you
157
208
 
@@ -181,9 +232,10 @@ refreshed, the bot says so once and falls back.
181
232
  ## Skills
182
233
 
183
234
  `skills/herdrchat/` is one skill for an agent (Claude Code or another that reads
184
- `SKILL.md`): `/herdrchat setup`, `status`, `start`, `stop`, `logs`, `auth`, `config`, `fix`
185
- and `feishu <task>` (the developer console, with Ego Lite from https://lite.ego.app/ or as
186
- a guided walk-through). Details per part are in `skills/herdrchat/references/`. To use it
235
+ `SKILL.md`): `/herdrchat setup`, `status`, `start`, `stop`, `install`, `logs`, `auth`,
236
+ `config`, `fix`, `feishu <task>` (the developer console, with Ego Lite from
237
+ https://lite.ego.app/ or as a guided walk-through) and `telegram <task>` (the bot, from
238
+ @BotFather to the first topic). Details per part are in `skills/herdrchat/references/`. To use it
187
239
  with Claude Code, link it where skills are read, for example
188
240
  `ln -s "$PWD/skills/herdrchat" ~/.claude/skills/herdrchat`.
189
241
 
@@ -191,7 +243,7 @@ with Claude Code, link it where skills are read, for example
191
243
 
192
244
  Everything the bot logs goes to `logs/<YYYY-MM-DD>.log` in the state folder (one file a
193
245
  day, the last 14 kept) as `<time> <LEVEL> <text>`, besides its terminal: `grep ERROR
194
- ~/.local/state/herdrchat/default/logs/*.log` finds
246
+ ~/.local/state/herdrchat/<profile>/logs/*.log` finds
195
247
  what went wrong, a retry that recovered is a `WARN`, and a crash leaves its stack there
196
248
  before it is started again. Tokens and secrets are never logged. The
197
249
  skill's `references/ops.md` explains the lines.
@@ -199,12 +251,15 @@ skill's `references/ops.md` explains the lines.
199
251
  ## Limits
200
252
 
201
253
  - Narration blocks are a condensed version of the text Claude showed, not the exact words.
202
- - Only Claude Code has a reader so far. codex, pi and the rest get their screen, at turn
203
- end only, until each has a reader.
254
+ - Only Claude Code has a reader so far. codex, pi and the rest post nothing by themselves
255
+ until each has a reader; what you type to them gets their screen in reply.
204
256
  - Only text, images and rich text are passed on; files, stickers and the rest are not.
205
257
  - An image that fails to upload three times is not sent; the group says so and why.
206
258
  - Feishu takes tag names of at most 16 characters; a longer session or machine name is cut
207
259
  at its last `-`, `_` or `.` that fits (`build-agent-server` becomes `build-agent`).
208
260
  - Avatars are drawn with `sips`, so only on macOS; elsewhere groups keep Feishu's default.
261
+ - Telegram: a bot cannot mute a topic for you; a topic you mute in Telegram stays fully
262
+ quiet, "needs you" included. Closing a topic is documented for supergroups only, so an
263
+ ended topic is marked by its name.
209
264
  - Dialog buttons come from the screen's numbered menu; a dialog drawn some other way gets
210
265
  the screen and `/answer`, `/esc`.
@@ -258,7 +258,8 @@ export async function renameChat(client, chatId, name) {
258
258
  // Messages in (im.message.receive_v1) and card clicks (card.action.trigger), both over
259
259
  // the long connection. A click must be answered within 3 seconds; what `onCardAction`
260
260
  // returns ({toast, card}) is that answer.
261
- export function startEvents(cfg, onMessage, onCardAction, onReady = () => {}) {
261
+ // onConnect runs at every connection, the first and each one after a drop.
262
+ export function startEvents(cfg, onMessage, onCardAction, onReady = () => {}, onConnect = () => {}) {
262
263
  const ws = new Lark.WSClient({
263
264
  appId: cfg.appId,
264
265
  appSecret: cfg.appSecret,
@@ -266,9 +267,13 @@ export function startEvents(cfg, onMessage, onCardAction, onReady = () => {}) {
266
267
  onReady: () => {
267
268
  console.log('[feishu] connected');
268
269
  onReady();
270
+ onConnect();
269
271
  },
270
272
  onReconnecting: () => console.warn('[feishu] reconnecting'),
271
- onReconnected: () => console.log('[feishu] reconnected'),
273
+ onReconnected: () => {
274
+ console.log('[feishu] reconnected');
275
+ onConnect();
276
+ },
272
277
  onError: (err) => console.error(`[feishu] connection failed: ${err.message}`),
273
278
  });
274
279
  ws.start({
@@ -401,13 +401,40 @@ export function create({ cfg, store, log, logWarn, logError, stateDir, lane, sco
401
401
 
402
402
  // Messages (deduplicated, the owner's only) and card clicks, turned into the core's
403
403
  // shape: {room (null: the direct chat), where, message, text, images}.
404
+ //
405
+ // Feishu wants an event handled within 3 seconds or it pushes it again, so a message is
406
+ // taken at once and then handled in its chat's own queue (in order). Whether Feishu
407
+ // sends again what came while the bot was offline is not documented, so the bot looks
408
+ // itself: per chat it keeps the time of the last message it handled (state.json
409
+ // feishuRead), and at every connection it lists what came after that and handles what
410
+ // it missed.
404
411
  const seen = new Set();
412
+ const queues = new Map(); // chat -> its last queued message
413
+ function queued(chatId, fn) {
414
+ const next = (queues.get(chatId) || Promise.resolve()).then(fn).catch((err) => logError(`feishu message in ${chatId}:`, err));
415
+ queues.set(chatId, next);
416
+ next.then(() => { if (queues.get(chatId) === next) queues.delete(chatId); });
417
+ }
418
+ function markRead(chatId, at) {
419
+ const state = read();
420
+ if ((state.feishuRead?.[chatId] || 0) >= at) return;
421
+ state.feishuRead = { ...(state.feishuRead || {}), [chatId]: at };
422
+ write(state);
423
+ }
424
+
405
425
  function start({ onMessage, onAction, onReady }) {
406
- const handleMessage = async (data) => {
426
+ const take = (data) => {
407
427
  const msg = data.message;
408
428
  if (seen.has(msg.message_id)) return;
409
429
  seen.add(msg.message_id);
410
- if (seen.size > 1000) seen.delete(seen.values().next().value);
430
+ if (seen.size > 2000) seen.delete(seen.values().next().value);
431
+ queued(msg.chat_id, async () => {
432
+ await handleMessage(data);
433
+ markRead(msg.chat_id, Number(msg.create_time) || Date.now());
434
+ });
435
+ };
436
+ const handleMessage = async (data) => {
437
+ const msg = data.message;
411
438
  const openId = data.sender?.sender_id?.open_id;
412
439
  if (!cfg.ownerOpenId) {
413
440
  log(`owner not set; message from open_id ${openId}`);
@@ -437,7 +464,30 @@ export function create({ cfg, store, log, logWarn, logError, stateDir, lane, sco
437
464
  if (out.card?.kind === 'dialog') res.card = { type: 'raw', data: menuCard({ title: out.card.dialog.title, markdown: out.card.dialog.markdown, menu: out.card.dialog.menu, value: out.card.value }) };
438
465
  return res;
439
466
  };
440
- startEvents(cfg, handleMessage, handleAction, onReady);
467
+ // What came while the bot was away: the owner's messages after the last one handled,
468
+ // in every chat it reads (a chat with none handled yet starts from now).
469
+ const catchUp = async () => {
470
+ if (!cfg.ownerOpenId) return;
471
+ const state = read();
472
+ const chats = new Set([...Object.values(state.chats || {}).filter((c) => !c.ended).map((c) => c.chat), ...Object.keys(state.feishuRead || {})]);
473
+ for (const chatId of chats) {
474
+ const since = state.feishuRead?.[chatId];
475
+ if (!since) {
476
+ markRead(chatId, Date.now());
477
+ continue;
478
+ }
479
+ try {
480
+ const res = await client.im.v1.message.list({ params: { container_id_type: 'chat', container_id: chatId, start_time: String(Math.floor(since / 1000)), sort_type: 'ByCreateTimeAsc', page_size: 50 } });
481
+ const missed = (res?.data?.items || []).filter((m) => Number(m.create_time) > since && m.sender?.sender_type === 'user' && m.sender?.id === cfg.ownerOpenId && !seen.has(m.message_id));
482
+ if (missed.length) log(`feishu: ${missed.length} message(s) in ${chatId} came while the bot was away`);
483
+ const p2p = !Object.values(state.chats || {}).some((c) => c.chat === chatId);
484
+ for (const m of missed) take({ message: { message_id: m.message_id, chat_id: chatId, chat_type: p2p ? 'p2p' : 'group', message_type: m.msg_type, content: m.body?.content || '{}', create_time: m.create_time }, sender: { sender_id: { open_id: m.sender.id } } });
485
+ } catch (err) {
486
+ logWarn(`feishu: could not look for missed messages in ${chatId}: ${err.message}`);
487
+ }
488
+ }
489
+ };
490
+ startEvents(cfg, take, handleAction, onReady, () => catchUp().catch((err) => logError(`feishu catch-up: ${err.message}`)));
441
491
  setInterval(() => asYou().catch((err) => logError(`as you: ${err.message}`)), YOU_CHECK_MS);
442
492
  asYou().catch((err) => logError(`as you: ${err.message}`));
443
493
  }
@@ -13,9 +13,10 @@
13
13
  // topic's name gets a 🔚 in front, taken off when it is live again
14
14
  // images a message cannot hold one, so each goes as its own photo after the text
15
15
  // buttons callback_data holds 64 bytes, so a button carries a short id and the
16
- // dialog it answers is kept here
16
+ // dialog it answers is kept in state.json
17
17
  // a free answer no text box on a message: you type the answer in the topic
18
18
  // pin a topic cannot be pinned; nothing is done
19
+ import crypto from 'node:crypto';
19
20
  import fs from 'node:fs';
20
21
  import { createClient } from './client.mjs';
21
22
  import { escapeHtml, split, toHtml } from './format.mjs';
@@ -133,14 +134,17 @@ export function create({ cfg, store, log, logWarn, logError }) {
133
134
 
134
135
  // ---------- dialogs ----------
135
136
 
136
- // A button carries "<id>:<pick>"; the id names the dialog's value, kept here (a click
137
- // after a restart finds nothing and is told the dialog is gone).
138
- const buttons = new Map();
139
- let buttonN = 0;
137
+ // A button carries "<id>:<pick>"; the id names the dialog's value (the newest
138
+ // BUTTONS_KEPT; an older card is told its dialog is gone).
139
+ // They are kept in state.json (buttons), so a card still answers after a restart.
140
140
  function keyboard(dialog, value) {
141
- const id = (buttonN += 1).toString(36);
142
- buttons.set(id, { value, labels: Object.fromEntries(dialog.menu.options.map((o) => [o.n, o.label])) });
143
- if (buttons.size > BUTTONS_KEPT) buttons.delete(buttons.keys().next().value);
141
+ const id = crypto.randomBytes(6).toString('base64url');
142
+ const state = read();
143
+ const kept = { ...(state.buttons || {}), [id]: { value, labels: Object.fromEntries(dialog.menu.options.map((o) => [o.n, o.label])) } };
144
+ const ids = Object.keys(kept);
145
+ for (const old of ids.slice(0, Math.max(0, ids.length - BUTTONS_KEPT))) delete kept[old];
146
+ state.buttons = kept;
147
+ write(state);
144
148
  const rows = [];
145
149
  for (const o of dialog.menu.options) {
146
150
  if (o === dialog.menu.free) continue;
@@ -326,10 +330,10 @@ export function create({ cfg, store, log, logWarn, logError }) {
326
330
  const answer = (text) => tg.call('answerCallbackQuery', { callback_query_id: q.id, text: (text || '').slice(0, 200) }).catch(() => {});
327
331
  if (String(q.from?.id) !== owner) return answer('Only the owner can answer.');
328
332
  const [id, pickRaw] = String(q.data || '').split(':');
329
- const kept = buttons.get(id);
333
+ const kept = read().buttons?.[id];
330
334
  const message = String(q.message?.message_id || '');
331
335
  if (!kept) {
332
- await edit(message, `${q.message?.text ? escapeHtml(q.message.text) : ''}\n\n<b>This dialog is gone (the bot restarted); see the newest card.</b>`, { reply_markup: { inline_keyboard: [] } }).catch(() => {});
336
+ await edit(message, `${q.message?.text ? escapeHtml(q.message.text) : ''}\n\n<b>This card is too old to answer; see the newest card.</b>`, { reply_markup: { inline_keyboard: [] } }).catch(() => {});
333
337
  return answer('This dialog is gone.');
334
338
  }
335
339
  const pick = /^\d+$/.test(pickRaw) ? Number(pickRaw) : pickRaw;
@@ -340,9 +344,12 @@ export function create({ cfg, store, log, logWarn, logError }) {
340
344
  }
341
345
 
342
346
  // Long polling: getUpdates holds up to POLL_SECONDS for news; a failure waits RETRY_MS
343
- // and tries again (409: another copy of the bot polls the same token).
347
+ // and tries again (409: another copy of the bot polls the same token). Telegram keeps
348
+ // what the bot has not confirmed for 24 hours, so nothing sent while it was away is
349
+ // lost; which update it handled last is kept in state.json (telegramOffset), so after a
350
+ // restart nothing is handled twice.
344
351
  function start({ onMessage, onAction, onReady = () => {} }) {
345
- let offset = 0;
352
+ let offset = read().telegramOffset || 0;
346
353
  let connected = false;
347
354
  (async () => {
348
355
  for (;;) {
@@ -361,13 +368,17 @@ export function create({ cfg, store, log, logWarn, logError }) {
361
368
  }
362
369
  const updates = await tg.call('getUpdates', { offset, timeout: POLL_SECONDS, allowed_updates: ['message', 'callback_query'] }, { timeoutMs: (POLL_SECONDS + 15) * 1000 });
363
370
  for (const u of updates) {
364
- offset = u.update_id + 1;
365
371
  try {
366
372
  if (u.message) await handleMessage(u.message, onMessage);
367
373
  if (u.callback_query) await handleClick(u.callback_query, onAction);
368
374
  } catch (err) {
369
375
  logError(`telegram update ${u.update_id}:`, err);
370
376
  }
377
+ // saved once handled: a crash halfway handles it again, never skips it
378
+ offset = u.update_id + 1;
379
+ const state = read();
380
+ state.telegramOffset = offset;
381
+ write(state);
371
382
  }
372
383
  } catch (err) {
373
384
  logError(`telegram poll: ${err.message}`);
@@ -248,7 +248,7 @@ export function createEngine({ cfg, chat, hc, readers, store, log, logWarn, logE
248
248
  function transcriptOf(items) {
249
249
  const parts = [];
250
250
  for (const it of items) {
251
- if (it.role === 'you') parts.push(`**You** · ${clock(it.at)}\n${it.text.split('\n').map((l) => `> ${l}`).join('\n')}`);
251
+ if (it.role === 'you') parts.push(youOrOthers(it.text, ` · ${clock(it.at)}`));
252
252
  else if (it.role === 'ai') parts.push(it.text);
253
253
  else if (it.role === 'tool') parts.push(it.text);
254
254
  else if (it.role === 'image') parts.push(`_(an image ${it.from === 'you' ? 'you pasted' : 'it looked at'})_`);
@@ -357,6 +357,23 @@ export function createEngine({ cfg, chat, hc, readers, store, log, logWarn, logE
357
357
  // What you sent from the chat; its room shows it already, so it is not posted again.
358
358
  const fromChat = new Map();
359
359
 
360
+ // A message another terminal sent with `hc send` reaches the agent wrapped by hc as
361
+ // <hc from="t7 review" reach="...">...</hc> (any agent: hc adds it), so it is shown as
362
+ // from that terminal, not as yours; what is around such notes is yours.
363
+ const HC_NOTE = /<hc from="([^"]*)"(?:\s+reach="([^"]*)")?>([\s\S]*?)<\/hc>/g;
364
+ const quoted = (text) => text.split('\n').map((l) => `> ${l}`).join('\n');
365
+ function youOrOthers(text, at = '') {
366
+ const parts = [];
367
+ let rest = text;
368
+ for (const m of text.matchAll(HC_NOTE)) {
369
+ parts.push(`**From ${m[1]}**${at}${m[2] ? ` _(answer: ${m[2]})_` : ''}\n${quoted(m[3].trim())}`);
370
+ rest = rest.replace(m[0], '');
371
+ }
372
+ rest = rest.trim();
373
+ if (rest) parts.unshift(`**You:**${at ? ` ${at.replace(/^ · /, '')}` : ''}\n${quoted(rest)}`);
374
+ return parts.join('\n\n');
375
+ }
376
+
360
377
  function formatItems(k, items) {
361
378
  const sent = fromChat.get(k) || [];
362
379
  const parts = [];
@@ -380,7 +397,7 @@ export function createEngine({ cfg, chat, hc, readers, store, log, logWarn, logE
380
397
  sent.splice(i, 1);
381
398
  continue;
382
399
  }
383
- parts.push(`**You:**\n${it.text.split('\n').map((l) => `> ${l}`).join('\n')}`);
400
+ parts.push(youOrOthers(it.text));
384
401
  } else {
385
402
  parts.push(it.text);
386
403
  }
@@ -624,9 +641,17 @@ export function createEngine({ cfg, chat, hc, readers, store, log, logWarn, logE
624
641
  log(`<- ${session}/${address}: blocked${dialog.menu ? ` (${dialog.menu.options.length} options)` : ''}`);
625
642
  }
626
643
 
644
+ // The keys that pick an option: its digit, or, in a question with previews (which takes
645
+ // no digit), the arrows from the cursor to it and Enter.
646
+ function pickKeys(menu, option) {
647
+ if (!menu.beside) return [String(option.n)];
648
+ const moves = option.n - (menu.cursor || 1);
649
+ return [...Array(Math.abs(moves)).fill(moves > 0 ? 'down' : 'up'), 'enter'];
650
+ }
651
+
627
652
  // "Type something": pick that option, then type the words.
628
653
  async function answerFree(session, address, menu, text) {
629
- await hc.keys(session, address, [String(menu.free.n)]);
654
+ await hc.keys(session, address, pickKeys(menu, menu.free));
630
655
  await sleep(300);
631
656
  await hc.send(session, address, text, { force: true });
632
657
  }
@@ -689,7 +714,7 @@ export function createEngine({ cfg, chat, hc, readers, store, log, logWarn, logE
689
714
  await answerFree(v.s, v.a, menu, text);
690
715
  picked = text;
691
716
  } else {
692
- await hc.keys(v.s, v.a, [String(option.n)]);
717
+ await hc.keys(v.s, v.a, pickKeys(menu, option));
693
718
  picked = `${option.n}. ${option.label}`;
694
719
  }
695
720
  }
@@ -772,6 +797,7 @@ export function createEngine({ cfg, chat, hc, readers, store, log, logWarn, logE
772
797
  if (now !== was) since.set(k, Date.now());
773
798
  if (now !== 'blocked') {
774
799
  postedDialog.delete(k);
800
+ if (now) sendWaiting(k);
775
801
  if (chat.pinned().includes(k)) later(session, address, 'unpin', () => chat.unpin(k));
776
802
  if (openCard.has(k)) later(session, address, 'dialog', () => closeCard(k, '✔ Answered.'));
777
803
  return;
@@ -863,6 +889,23 @@ export function createEngine({ cfg, chat, hc, readers, store, log, logWarn, logE
863
889
  }
864
890
  }
865
891
 
892
+ // Messages sent while it waited on a dialog with no box to type in, per terminal, kept
893
+ // in state.json (waiting) so a restart does not lose them; they go in, in order, once it
894
+ // no longer waits (onStatus, and at start).
895
+ function hold(k, m) {
896
+ const state = read();
897
+ state.waiting = { ...(state.waiting || {}), [k]: [...(state.waiting?.[k] || []), m] };
898
+ write(state);
899
+ }
900
+ function sendWaiting(k) {
901
+ const state = read();
902
+ const held = state.waiting?.[k];
903
+ if (!held?.length) return;
904
+ delete state.waiting[k];
905
+ write(state);
906
+ for (const m of held) laneOf(k, 'held message', () => typeInto(m.where, m.message, m.session, m.address, m.text));
907
+ }
908
+
866
909
  async function typeInto(where, message, session, address, text) {
867
910
  const record = views.get(session)?.terminals.get(address);
868
911
  if (!record || record.state !== 'live') {
@@ -883,16 +926,21 @@ export function createEngine({ cfg, chat, hc, readers, store, log, logWarn, logE
883
926
  return;
884
927
  }
885
928
  const answer = text.match(/^\/answer\s+([\s\S]+)$/);
886
- if (!answer && agent && record.status?.state === 'blocked') {
929
+ // whether it waits on a dialog now: asked of hc, since the status followed from its
930
+ // events can be a moment behind (a dialog just answered still looks blocked)
931
+ const waits = !answer && agent && (await hc.getTerminal(session, address).catch(() => record))?.status?.state === 'blocked';
932
+ if (waits) {
887
933
  // It waits on a dialog: words go into its "Type something" box when it has one;
888
- // otherwise the dialog card comes again, to pick there.
934
+ // otherwise they wait for the dialog to be answered, then go in (never dropped).
889
935
  const menu = menuOf(await hc.screenTail(session, address, SCREEN_LINES));
890
936
  if (menu?.free) {
891
937
  await answerFree(session, address, menu, text);
892
938
  log(`-> ${session}/${address}: answered ${text.slice(0, 80)}`);
893
939
  await markWorking(key(session, address), message);
894
940
  } else {
895
- await postBlocked(session, address, record, { again: true });
941
+ hold(key(session, address), { where, message, session, address, text });
942
+ log(`-> ${session}/${address}: held until its dialog is answered: ${text.slice(0, 80)}`);
943
+ await chat.reply(where, 'It asks something first (the card above). Your message goes in once that is answered.');
896
944
  }
897
945
  return;
898
946
  }
@@ -1032,6 +1080,7 @@ export function createEngine({ cfg, chat, hc, readers, store, log, logWarn, logE
1032
1080
  const target = terminalOfRoom(room);
1033
1081
  if (!target) {
1034
1082
  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.');
1035
1084
  return;
1036
1085
  }
1037
1086
  if (views.get(target.session)?.terminals.get(target.address)?.state !== 'live') {
@@ -1058,6 +1107,11 @@ export function createEngine({ cfg, chat, hc, readers, store, log, logWarn, logE
1058
1107
  for (const session of cfg.sessions) watch(session);
1059
1108
  ready.then(() => setTimeout(async () => {
1060
1109
  await endGone().catch((err) => logError(`end gone rooms: ${err.message}`));
1110
+ // messages held before a restart, for terminals that no longer wait
1111
+ for (const k of Object.keys(read().waiting || {})) {
1112
+ const address = addressOfKey(k);
1113
+ if (!cfg.sessions.some((s) => views.get(s)?.terminals.get(address)?.status?.state === 'blocked')) sendWaiting(k);
1114
+ }
1061
1115
  // pins left from before a restart, for terminals that no longer wait
1062
1116
  for (const k of chat.pinned()) {
1063
1117
  const address = addressOfKey(k);
package/lib/core/menu.mjs CHANGED
@@ -45,7 +45,8 @@ function keyOf(lines, top, last) {
45
45
  }
46
46
 
47
47
  export function menuOf(screen) {
48
- const lines = screen.split('\n').map(ownPart);
48
+ const raw = screen.split('\n');
49
+ const lines = raw.map(ownPart);
49
50
  let run = null;
50
51
  let menu = null;
51
52
  lines.forEach((line, i) => {
@@ -63,7 +64,13 @@ export function menuOf(screen) {
63
64
  });
64
65
  if (!menu) return null;
65
66
  const top = topOf(lines, menu.first);
67
+ // A question with previews takes no digit (its keys say "↑/↓ to navigate"): an option
68
+ // is reached from the cursor (❯) with the arrows, then Enter.
69
+ const beside = raw.slice(menu.first, menu.last + 1).some((l) => BESIDE.test(l));
70
+ const cursor = menu.options.find((o, i) => raw.slice(menu.first, menu.last + 1).some((l) => /❯/.test(l) && new RegExp(`^\\s*❯\\s*${o.n}\\.`).test(l)))?.n || 1;
66
71
  return {
72
+ beside,
73
+ cursor,
67
74
  key: keyOf(lines, top, menu.last),
68
75
  framed: BORDER.test(lines[top]), // a dialog's top border; a numbered list in text has none
69
76
  text: lines.slice(top, menu.first).filter((l) => !RULE.test(l)).map((l) => l.trim()).join('\n'),
@@ -0,0 +1,165 @@
1
+ // herdrchat as a service of the system, so it starts at login and comes back by itself
2
+ // after a crash, whatever happens to the hc cores: a launchd LaunchAgent on macOS, a
3
+ // systemd user unit on Linux. `herdrchat install` makes it; start, stop and status then
4
+ // go through it. Without it the bot runs in an hc terminal (`herdrchat start`), which
5
+ // ends when that hc core stops.
6
+ //
7
+ // The service runs node and bin/herdrchat.mjs by their full paths, with only the
8
+ // variables listed in `env` (no agent's variables come along): a node upgrade that moves
9
+ // node needs `herdrchat install` again, which status says.
10
+ import { execFileSync } from 'node:child_process';
11
+ import fs from 'node:fs';
12
+ import os from 'node:os';
13
+ import path from 'node:path';
14
+
15
+ const THROTTLE_S = 10; // launchd: at most one start in this many seconds
16
+ const RESTART_S = 5; // systemd: wait before starting again
17
+
18
+ const xml = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
19
+
20
+ export function launchdPlist({ label, node, script, root, env, out }) {
21
+ const vars = Object.entries(env).map(([k, v]) => ` <key>${xml(k)}</key>\n <string>${xml(v)}</string>`).join('\n');
22
+ return `<?xml version="1.0" encoding="UTF-8"?>
23
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
24
+ <plist version="1.0">
25
+ <dict>
26
+ <key>Label</key>
27
+ <string>${xml(label)}</string>
28
+ <key>ProgramArguments</key>
29
+ <array>
30
+ <string>${xml(node)}</string>
31
+ <string>${xml(script)}</string>
32
+ </array>
33
+ <key>WorkingDirectory</key>
34
+ <string>${xml(root)}</string>
35
+ <key>EnvironmentVariables</key>
36
+ <dict>
37
+ ${vars}
38
+ </dict>
39
+ <key>RunAtLoad</key>
40
+ <true/>
41
+ <key>KeepAlive</key>
42
+ <true/>
43
+ <key>ThrottleInterval</key>
44
+ <integer>${THROTTLE_S}</integer>
45
+ <key>StandardOutPath</key>
46
+ <string>${xml(out)}</string>
47
+ <key>StandardErrorPath</key>
48
+ <string>${xml(out)}</string>
49
+ </dict>
50
+ </plist>
51
+ `;
52
+ }
53
+
54
+ const unitQuote = (s) => `"${String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
55
+
56
+ export function systemdUnit({ profile, node, script, root, env, out }) {
57
+ return `[Unit]
58
+ Description=herdrchat (profile ${profile}): herdr-app tabs in a chat app
59
+ After=network-online.target
60
+
61
+ [Service]
62
+ ExecStart=${unitQuote(node)} ${unitQuote(script)}
63
+ WorkingDirectory=${root}
64
+ ${Object.entries(env).map(([k, v]) => `Environment=${unitQuote(`${k}=${v}`)}`).join('\n')}
65
+ Restart=always
66
+ RestartSec=${RESTART_S}
67
+ StandardOutput=append:${out}
68
+ StandardError=append:${out}
69
+
70
+ [Install]
71
+ WantedBy=default.target
72
+ `;
73
+ }
74
+
75
+ const run = (cmd, args) => execFileSync(cmd, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
76
+
77
+ // {kind, name, file, content, write, remove, installed, load, unload, restart, state,
78
+ // nodeGone}, or null on a system with neither launchd nor systemd.
79
+ export function serviceOf({ profile, root, node, env, stateDir, platform = process.platform, home = os.homedir() }) {
80
+ const script = path.join(root, 'bin', 'herdrchat.mjs');
81
+ const out = path.join(stateDir, 'logs', 'service.out.log');
82
+ const common = { profile, node, script, root, env, out };
83
+ if (platform === 'darwin') {
84
+ const label = `app.herdrchat.${profile}`;
85
+ const file = path.join(home, 'Library', 'LaunchAgents', `${label}.plist`);
86
+ const target = `gui/${process.getuid()}/${label}`;
87
+ return {
88
+ kind: 'launchd',
89
+ name: label,
90
+ file,
91
+ content: launchdPlist({ ...common, label }),
92
+ loaded() {
93
+ try {
94
+ run('launchctl', ['print', target]);
95
+ return true;
96
+ } catch {
97
+ return false;
98
+ }
99
+ },
100
+ load() { run('launchctl', ['bootstrap', `gui/${process.getuid()}`, file]); },
101
+ unload() { try { run('launchctl', ['bootout', target]); } catch { /* not loaded */ } },
102
+ restart() { run('launchctl', ['kickstart', '-k', target]); },
103
+ // {running, pid}
104
+ state() {
105
+ try {
106
+ const text = run('launchctl', ['print', target]);
107
+ return { running: /\bstate = running\b/.test(text), pid: Number(text.match(/\bpid = (\d+)/)?.[1]) || null };
108
+ } catch {
109
+ return { running: false, pid: null };
110
+ }
111
+ },
112
+ ...files(file, out),
113
+ };
114
+ }
115
+ if (platform === 'linux') {
116
+ const unit = `herdrchat-${profile}.service`;
117
+ const file = path.join(process.env.XDG_CONFIG_HOME || path.join(home, '.config'), 'systemd', 'user', unit);
118
+ const ctl = (...a) => run('systemctl', ['--user', ...a]);
119
+ return {
120
+ kind: 'systemd',
121
+ name: unit,
122
+ file,
123
+ content: systemdUnit(common),
124
+ loaded() {
125
+ try {
126
+ return ctl('is-enabled', unit).trim() === 'enabled';
127
+ } catch {
128
+ return false;
129
+ }
130
+ },
131
+ load() { ctl('daemon-reload'); ctl('enable', '--now', unit); },
132
+ unload() { try { ctl('disable', '--now', unit); } catch { /* not loaded */ } },
133
+ restart() { ctl('restart', unit); },
134
+ state() {
135
+ try {
136
+ const pid = Number(ctl('show', '-p', 'MainPID', '--value', unit).trim()) || null;
137
+ return { running: ctl('is-active', unit).trim() === 'active', pid };
138
+ } catch {
139
+ return { running: false, pid: null };
140
+ }
141
+ },
142
+ ...files(file, out),
143
+ };
144
+ }
145
+ return null;
146
+ }
147
+
148
+ function files(file, out) {
149
+ return {
150
+ installed: () => fs.existsSync(file),
151
+ write(content) {
152
+ fs.mkdirSync(path.dirname(file), { recursive: true });
153
+ fs.mkdirSync(path.dirname(out), { recursive: true, mode: 0o700 });
154
+ fs.writeFileSync(file, content, { mode: 0o600 });
155
+ },
156
+ remove() { fs.rmSync(file, { force: true }); },
157
+ // the node the installed service runs, when it is gone (a node upgrade moved it)
158
+ nodeGone() {
159
+ if (!fs.existsSync(file)) return null;
160
+ const text = fs.readFileSync(file, 'utf8');
161
+ const node = text.match(/<string>([^<]*node[^<]*)<\/string>/)?.[1] || text.match(/^ExecStart="([^"]+)"/m)?.[1];
162
+ return node && !fs.existsSync(node) ? node : null;
163
+ },
164
+ };
165
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "herdrchat",
3
- "version": "1.1.2",
3
+ "version": "1.2.1",
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",
@@ -22,7 +22,8 @@ herdrchat`; when it is missing, ask before `npm install -g herdrchat`. A profile
22
22
  |---|---|
23
23
  | `/herdrchat setup` | First ask which chat app (Feishu, Telegram, or both: one profile each). Telegram: `/herdrchat telegram setup`. Feishu, in order: `herdrchat init`; make the Feishu app (`references/feishu.md`: create, bot, scopes, events and callbacks); fill `config.env` with the user (app id, secret, `HERDR_SESSIONS`: the hc sessions herdr-app shows on this machine); `herdrchat start`; publish a version; get the user's open_id (they message the bot, it answers with it) into `FEISHU_OWNER_OPEN_ID`; start again. Optional: `/herdrchat auth`. |
24
24
  | `/herdrchat status` | `herdrchat status`; explain each line; on errors, `/herdrchat fix`. |
25
- | `/herdrchat start`, `/herdrchat stop` | Read `config.env` (its `HERDR_SESSIONS`) in one step, then `herdrchat start` / `stop` in the next; never both at once. |
25
+ | `/herdrchat start`, `/herdrchat stop` | Read `config.env` (its `HERDR_SESSIONS`) in one step, then `herdrchat start` / `stop` in the next; never both at once. With a service installed they go through it. |
26
+ | `/herdrchat install`, `/herdrchat uninstall` | Run it as a service of the system (launchd on macOS, systemd on Linux): it starts at login and comes back after a crash, whatever happens to the hc cores. Recommended once it works. Read the config first; after a node upgrade, install again (`herdrchat status` says so). |
26
27
  | `/herdrchat logs` | `herdrchat logs --errors` (or `-n 100`); explain them with `references/ops.md` "Logs". |
27
28
  | `/herdrchat auth` | The user sends `/auth` to the bot and allows it on Feishu's page; the app needs the user scopes in `references/feishu.md`. Then groups are muted, labels and pins follow. |
28
29
  | `/herdrchat config` | Change a setting in `config.env` with the user (sessions, `HERDR_MUTED_SPACES`, `HERDR_SILENT_SPACES`, `HERDR_BOT_TAGS`, `HERDR_USER_TAGS`, `HERDR_HISTORY`, `HERDRCHAT_CHAT`; each is explained in `config.env.example`), read the file back, then restart. |
@@ -17,7 +17,9 @@ Below, `S` is the first session in `HERDR_SESSIONS`.
17
17
  HC_SESSION=S hc get herdrchat --json # state "live"
18
18
  HC_SESSION=S hc read herdrchat --lines 40 # its terminal: start lines, "[feishu] connected"
19
19
  herdrchat status # running, folders, /auth, errors today
20
- herdrchat start # restart: stops it in every session, waits until it reaches Feishu
20
+ herdrchat start # restart: stops it in every session, waits until it reaches its chat app
21
+ herdrchat install # run it as a service (launchd/systemd): starts at login, back after a crash
22
+ launchctl print gui/$(id -u)/app.herdrchat.<profile> # the service on macOS: state, pid, last exit
21
23
  ```
22
24
 
23
25
  A restart is needed after any code or `config.env` change. It is safe: what was posted
@@ -38,7 +40,7 @@ While the bot is down the user hears nothing, so:
38
40
 
39
41
  ## Logs
40
42
 
41
- `D/logs/<YYYY-MM-DD>.log`, one file a day, 14 kept, lines `<time> <LEVEL> <text>`:
43
+ `D/logs/<YYYY-MM-DD>.log`, one file a day, 14 kept, lines `<time> <LEVEL> <text>`. Run as a service, what the process printed before its log was open (a crash at start) is in `D/logs/service.out.log`:
42
44
 
43
45
  ```sh
44
46
  grep -h ERROR D/logs/*.log | tail -20 # what went wrong
@@ -39,6 +39,9 @@ Do these in order; the user does the Telegram steps, you check each one.
39
39
  takes about one message a second in a chat, so twenty tabs take twenty seconds. A new
40
40
  topic gets the conversation before it (`HERDR_HISTORY`); `/history` in a topic posts it
41
41
  again.
42
+ 7. **Make it a service.** Once it works: `herdrchat install --profile telegram`. It then
43
+ starts at login and comes back by itself after a crash, whatever happens to the hc
44
+ cores; `herdrchat status --profile telegram` says it runs as the service.
42
45
 
43
46
  ## What rings
44
47
 
@@ -74,4 +77,4 @@ without muting each topic by hand.
74
77
  | `herdrchat start` says it did not reach its chat app | wrong token, or no network | check with getMe (step 5) |
75
78
  | log: `telegram poll: ... 409` | another copy polls the same token | `herdrchat stop --profile telegram`, find the other one (another machine?), start once |
76
79
  | log: `too fast, waiting N s` | Telegram's flood limit; the bot waits as told | nothing; it catches up |
77
- | a click answers "This dialog is gone" | the bot restarted after the card was posted (button ids live in memory) | answer the newest card, or in the terminal |
80
+ | a click answers "This dialog is gone" | the card is older than the last 300 dialog cards | answer the newest card, or in the terminal |