herdrchat 1.3.3 → 1.4.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
- A chat front-end for [herdr](https://herdr.dev): every terminal it runs, in Feishu or Telegram.
5
+ The chat client for hc ([herdr](https://herdr.dev)): see and answer every hc terminal from Feishu or Telegram.
6
6
 
7
7
  [English](README.md) · [简体中文](README.zh.md)
8
8
 
@@ -12,13 +12,15 @@ A chat front-end for [herdr](https://herdr.dev): every terminal it runs, in Feis
12
12
 
13
13
  </div>
14
14
 
15
+ **herdr-app is hc's desktop and phone client; herdrchat is hc's chat client.**
16
+
15
17
  [herdr](https://herdr.dev) runs your coding agents (Claude Code, codex, pi, …) in
16
- terminals that stay alive: **hc** (herdr-core) keeps them running, **herdr-app** shows
17
- them in spaces and tabs. herdr-app has phone and desktop apps, but your phone has to
18
+ terminals that stay alive: **hc** (herdr-core) keeps them running, and herdr-app shows
19
+ them in spaces and tabs. But to use herdr-app away from your desk, your phone has to
18
20
  reach the machine that runs hc, so you first need a VPN or a private network, and working
19
21
  with an agent on a small phone screen is clumsy.
20
22
 
21
- herdrchat puts them in a chat app instead. It connects out to Feishu or Telegram by
23
+ herdrchat shows the same terminals in a chat app you already have on your phone. It connects out to Feishu or Telegram by
22
24
  itself, so there is nothing to set up on your network: no public IP, no VPN. Each
23
25
  herdr-app tab gets its own Feishu group, or a topic in your private chat with the
24
26
  Telegram bot. You read what the agent says while it works, tap buttons to answer its
@@ -32,8 +34,9 @@ until herdrchat learns to read their conversations too.
32
34
  ## You need
33
35
 
34
36
  - **herdr**: herdrchat is built on it and does nothing without it. Install it from
35
- [herdr.dev](https://herdr.dev) first; its **hc** must run on this machine, with at least
36
- one session.
37
+ [herdr.dev](https://herdr.dev) first (or let your coding agent do it with you:
38
+ `/herdrchat setup` offers to); its **hc** must run on this machine, with at least one
39
+ session.
37
40
  - **Node.js 20** or newer, on **macOS or Linux**.
38
41
  - **Feishu**: a Feishu team (not Lark) where you may create a custom app and publish it.
39
42
  **Telegram**: a Telegram account. Use one app, or both at once.
@@ -58,17 +61,21 @@ reach the agent.
58
61
  **With your agent:** ask it `/herdrchat setup`. It asks which chat app, then walks you
59
62
  through everything, clicking in the Feishu console for you when it can.
60
63
 
61
- **By hand:**
64
+ **In the terminal** (the same steps, asked one by one; the agent can also click through
65
+ the Feishu console for you, with the [Ego Lite](https://lite.ego.app/) browser, which the
66
+ terminal cannot; without Ego it tells you what to click):
62
67
 
63
68
  ```bash
64
- herdrchat init --chat telegram # or --chat feishu; it prints where the settings file is
65
- # fill in that file (each line says where its value comes from), then:
66
- herdrchat start # starts it and waits until it is connected
67
- herdrchat install # once it works: start at login, come back after a crash
69
+ herdrchat setup
68
70
  ```
69
71
 
70
- The steps for each app (making the Feishu app, or the Telegram bot) are in the
71
- [guide](docs/guide.md#set-up-telegram).
72
+ It asks which chat app, takes the bot's token (or the Feishu app's keys) and checks them
73
+ at once, picks your hc sessions (`default` unless you say otherwise), starts the bot, and
74
+ shows a 6-digit code: send it to the bot and it knows you are its owner. Last, it offers
75
+ to keep it running as a service. No file to edit, no id to copy.
76
+
77
+ Making the Telegram bot or the Feishu app, and doing it all by editing the settings file
78
+ instead: the [guide](docs/guide.md#set-up-telegram).
72
79
 
73
80
  ## Use
74
81
 
@@ -86,7 +93,7 @@ start a terminal, `/kill` to end one.
86
93
  | `/herdrchat feishu <task>` | Does a task in the Feishu developer console (with the [Ego Lite](https://lite.ego.app/) browser, or step by step). |
87
94
  | `/herdrchat telegram <task>` | The Telegram bot, from @BotFather to the first topic. |
88
95
 
89
- On the command line: `herdrchat init`, `start`, `stop`, `status`, `logs`, `install`,
96
+ On the command line: `herdrchat setup`, `pair`, `init`, `start`, `stop`, `status`, `logs`, `install`,
90
97
  `uninstall`, `version`; `herdrchat` alone lists them. Add `--profile <name>` to run a
91
98
  second chat app beside the first.
92
99
 
package/README.zh.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  # herdrchat
4
4
 
5
- [herdr](https://herdr.dev) 的聊天前端:它跑的每个终端,都能在飞书或 Telegram 里看到和回复。
5
+ hc([herdr](https://herdr.dev))的聊天客户端:在飞书或 Telegram 里查看和回复 hc 的每一个终端。
6
6
 
7
7
  [English](README.md) · [简体中文](README.zh.md)
8
8
 
@@ -12,11 +12,13 @@
12
12
 
13
13
  </div>
14
14
 
15
+ **herdr-app 是 hc 的电脑和手机客户端;herdrchat 是 hc 的聊天客户端。**
16
+
15
17
  [herdr](https://herdr.dev) 让你的编程 agent(Claude Code、codex、pi……)跑在不会断的终端里:
16
- **hc**(herdr-core)让它们一直跑着,**herdr-app** 按空间和标签页把它们展示出来。herdr-app 有手机端和电脑端,
17
- 但手机得能连到跑 hc 的机器,所以要先折腾 VPN 或内网;而且在手机的小屏幕上和 agent 交互,体验并不好。
18
+ **hc**(herdr-core)让它们一直跑着,herdr-app 按空间和标签页把它们展示出来。
19
+ 但想在外面用 herdr-app,手机得能连到跑 hc 的机器,所以要先折腾 VPN 或内网;而且在手机的小屏幕上和 agent 交互,体验并不好。
18
20
 
19
- herdrchat 把它们搬进聊天软件。它自己主动连到飞书或 Telegram,网络上什么都不用配:不要公网 IP,也不要 VPN。
21
+ herdrchat 把同样这些终端,放进你手机上本来就有的聊天软件里。它自己主动连到飞书或 Telegram,网络上什么都不用配:不要公网 IP,也不要 VPN。
20
22
  herdr-app 的每个标签页,在飞书里是一个群,在 Telegram 里是和 bot 私聊中的一个话题:
21
23
  agent 干活时说的话实时可见,它问你的问题点按钮就能回答,图片双向都能传,所有终端汇总在一张总览里。
22
24
  新开的群或话题会先补上之前的对话;只有 agent 需要你、或者做完的时候手机才会响
@@ -27,8 +29,8 @@ agent 干活时说的话实时可见,它问你的问题点按钮就能回答
27
29
 
28
30
  ## 需要什么
29
31
 
30
- - **herdr**:herdrchat 是基于它做的,没有它什么都做不了。请先从 [herdr.dev](https://herdr.dev) 安装;
31
- 它的 **hc** 要在这台机器上跑着,至少有一个 session。
32
+ - **herdr**:herdrchat 是基于它做的,没有它什么都做不了。请先从 [herdr.dev](https://herdr.dev) 安装
33
+ (也可以让编程 agent 帮你装:`/herdrchat setup` 会主动问你);它的 **hc** 要在这台机器上跑着,至少有一个 session。
32
34
  - **Node.js 20** 或更新版本,系统是 **macOS 或 Linux**。
33
35
  - **飞书**:一个能创建并发布企业自建应用的飞书团队(暂不支持 Lark 国际版)。
34
36
  **Telegram**:一个 Telegram 账号。可以只用一个,也可以两个同时用。
@@ -49,18 +51,19 @@ skill 会教你的编程 agent(Claude Code、Codex……)替你配置和照
49
51
  ## 配置
50
52
 
51
53
  **让 agent 帮你:** 对它说 `/herdrchat setup`。它会先问你用哪个聊天软件,然后一步步带你配好,
52
- 能自动点的飞书后台操作它会替你点。
54
+ 飞书后台的操作,装了 [Ego Lite](https://lite.ego.app/) 浏览器它会替你点;没装的话,它会问你要不要装,不装就一步步告诉你点哪里。
53
55
 
54
- **自己动手:**
56
+ **在终端里**(步骤和 agent 一样,一个个问你;区别是 agent 还能替你在飞书后台点,终端做不到):
55
57
 
56
58
  ```bash
57
- herdrchat init --chat telegram # 或者 --chat feishu;它会告诉你配置文件在哪
58
- # 填好那个文件(每一行上面都写了值从哪来),然后:
59
- herdrchat start # 启动,并等到真的连上
60
- herdrchat install # 跑通以后:装成系统服务,开机自动启动,崩溃自动重启
59
+ herdrchat setup
61
60
  ```
62
61
 
63
- 每个聊天软件的具体步骤(建飞书应用,或者建 Telegram bot)在[详细说明](docs/guide.md#set-up-telegram)里(英文)。
62
+ 它会问你用哪个聊天软件,让你粘贴 bot 的 token(或飞书应用的 App ID 和 Secret)并当场检查,
63
+ 帮你选好要同步的 hc session(不改就是 `default`),然后启动 bot,并给你一个 6 位数字:
64
+ 把它发给 bot,bot 就认出你是主人。最后问你要不要装成系统服务。不用改文件,也不用复制任何 id。
65
+
66
+ 怎么建 Telegram bot 或飞书应用,以及想自己改配置文件的做法,见[详细说明](docs/guide.md#set-up-telegram)(英文)。
64
67
 
65
68
  ## 使用
66
69
 
@@ -77,7 +80,7 @@ herdrchat install # 跑通以后:装成系统服务,开机自
77
80
  | `/herdrchat feishu <任务>` | 替你在飞书开发者后台完成操作(用 [Ego Lite](https://lite.ego.app/) 浏览器,或者一步步带你点)。 |
78
81
  | `/herdrchat telegram <任务>` | Telegram bot:从 @BotFather 一直到第一个话题。 |
79
82
 
80
- 命令行:`herdrchat init`、`start`、`stop`、`status`、`logs`、`install`、`uninstall`、`version`;
83
+ 命令行:`herdrchat setup`、`pair`、`init`、`start`、`stop`、`status`、`logs`、`install`、`uninstall`、`version`;
81
84
  只输入 `herdrchat` 会列出全部。加上 `--profile <名字>`,可以再开一个聊天软件。
82
85
 
83
86
  ## 更新
package/bin/cli.mjs CHANGED
@@ -2,6 +2,7 @@
2
2
  // herdrchat's command line: set up, start, stop and look at one profile.
3
3
  //
4
4
  // herdrchat <command> [--profile <name>]
5
+ // setup a few questions, and it is set up, started, paired with you and kept running
5
6
  // init make the profile's config.env from the example when it is missing
6
7
  // install run it as a service of the system (launchd, systemd): it starts at
7
8
  // login and comes back after a crash; start, stop, status go through it
@@ -21,11 +22,13 @@ import { fileURLToPath } from 'node:url';
21
22
 
22
23
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
23
24
  const GUIDE = 'https://github.com/zhangxiangliang/herdrchat/blob/main/docs/guide.md';
24
- const HERDR = 'herdrchat is built on herdr: it mirrors the terminals that hc (herdr-core) runs.\nIt needs hc on this machine: install herdr first, from https://herdr.dev';
25
- const USAGE = `herdrchat: herdr's terminals in Feishu or Telegram. Needs herdr (https://herdr.dev).
25
+ const HERDR = 'herdrchat is built on herdr: it mirrors the terminals that hc (herdr-core) runs.\nIt needs hc on this machine: install herdr first, from https://herdr.dev\n(or ask your coding agent `/herdrchat setup`: it can install herdr with you).';
26
+ const USAGE = `herdrchat: the chat client for hc (herdr), in Feishu or Telegram. Needs hc (https://herdr.dev).
26
27
 
27
28
  herdrchat <command> [--profile <name>]
28
29
 
30
+ setup set it up by answering a few questions (the easy way in)
31
+ pair make you the bot's owner: it shows a code to send to the bot
29
32
  init make the profile's config.env (--chat feishu|telegram picks the app)
30
33
  start start (or restart) it: the service, or else its own hc terminal
31
34
  stop stop it
@@ -36,7 +39,7 @@ herdrchat <command> [--profile <name>]
36
39
  run run in the foreground (what start keeps running)
37
40
  version which herdrchat this is
38
41
 
39
- First time: herdrchat init, fill in the file it names, then herdrchat start.
42
+ First time: herdrchat setup. (Or by hand: herdrchat init, fill in the file, herdrchat start.)
40
43
  A second chat app: add --profile <name> to every command.
41
44
  Environment: HERDRCHAT_PROFILE (same as --profile), HERDRCHAT_HOME (one folder for a
42
45
  profile's settings and state).
@@ -85,7 +88,8 @@ if (helpAsked || !command) {
85
88
  if (process.env.HERDRCHAT_PROFILE && !/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(process.env.HERDRCHAT_PROFILE)) fail(`HERDRCHAT_PROFILE "${process.env.HERDRCHAT_PROFILE}": use letters, digits, - and _ only.`);
86
89
 
87
90
  // after the profile is set: lib/core/config.mjs reads it when it loads
88
- const { CONFIG_FILE, STATE_DIR, chatOf, loadConfig, makeDirs, placesOf } = await import('../lib/core/config.mjs');
91
+ const { CONFIG_FILE, STATE_DIR, chatOf, loadConfig, makeDirs, placesOf, readState, setConfigValue, writeState } = await import('../lib/core/config.mjs');
92
+ const { startPairing, PAIR_MINUTES } = await import('../lib/core/pairing.mjs');
89
93
  const { chatModule, chatNames } = await import('../lib/chats/index.mjs');
90
94
  const { coreUp, hc, sessionsHere } = await import('../lib/core/hc.mjs');
91
95
  const { serviceOf } = await import('../lib/core/service.mjs');
@@ -107,7 +111,10 @@ function readyConfig() {
107
111
  // a session not on this machine would be made by hc as the bot asks for it: a typo
108
112
  const here = sessionsHere();
109
113
  const unknown = cfg.sessions.filter((s) => !here.includes(s));
110
- if (unknown.length) fail(`HERDR_SESSIONS in ${CONFIG_FILE} names ${unknown.join(', ')}, which ${unknown.length > 1 ? 'are' : 'is'} not an hc session on this machine.\nThe sessions here: ${here.join(', ') || 'none (start hc first)'}.`, 1);
114
+ if (unknown.length) {
115
+ const what = cfg.sessionsSet ? `HERDR_SESSIONS in ${CONFIG_FILE} names ${unknown.join(', ')}, which ${unknown.length > 1 ? 'are' : 'is'} not an hc session on this machine` : `HERDR_SESSIONS is empty, so herdrchat follows hc's default session, which is not on this machine`;
116
+ fail(`${what}.\nThe sessions here: ${here.join(', ') || 'none: install and start herdr first (https://herdr.dev)'}.${here.length ? ` Put the ones to mirror in HERDR_SESSIONS in ${CONFIG_FILE}.` : ''}`, 1);
117
+ }
111
118
  return cfg;
112
119
  }
113
120
  // a day's log file: the local date, as the log names its files
@@ -181,7 +188,235 @@ async function terminalState(session) {
181
188
  }
182
189
  }
183
190
 
191
+ // ---------- setup: questions instead of a file to fill ----------
192
+
193
+ async function prompter() {
194
+ const readline = await import('node:readline/promises');
195
+ let rl = null;
196
+ const open = () => { rl ||= readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true }); return rl; };
197
+ const shut = () => { rl?.close(); rl = null; };
198
+ const ask = async (q, fallback = '') => (await open().question(fallback ? `${q} [${fallback}] ` : `${q} `)).trim() || fallback;
199
+ // A token or a secret is typed with nothing shown, not even dots: the terminal is put in
200
+ // raw mode and each key is read here, so neither the terminal nor readline echoes it.
201
+ const askSecret = (q) => new Promise((resolve) => {
202
+ shut();
203
+ process.stdout.write(`${q} `);
204
+ const stdin = process.stdin;
205
+ stdin.setRawMode(true);
206
+ stdin.resume();
207
+ let value = '';
208
+ const onData = (buf) => {
209
+ for (const ch of buf.toString('utf8')) {
210
+ if (ch === '\u0003') { // Ctrl+C
211
+ stdin.setRawMode(false);
212
+ process.stdout.write('\n');
213
+ process.exit(130);
214
+ }
215
+ if (ch === '\r' || ch === '\n' || ch === '\u0004') {
216
+ stdin.setRawMode(false);
217
+ stdin.pause();
218
+ stdin.off('data', onData);
219
+ process.stdout.write('\n');
220
+ resolve(value.trim());
221
+ return;
222
+ }
223
+ if (ch === '\u007f' || ch === '\b') value = value.slice(0, -1);
224
+ else if (ch >= ' ') value += ch;
225
+ }
226
+ };
227
+ stdin.on('data', onData);
228
+ });
229
+ return { ask, askSecret, close: shut };
230
+ }
231
+
232
+ const ok = (text) => console.log(`✔ ${text}`);
233
+ const bad = (text) => console.log(`✘ ${text}`);
234
+ const tell = (text) => console.log(` ${text}`);
235
+
236
+ async function telegramMe(token) {
237
+ try {
238
+ const res = await fetch(`https://api.telegram.org/bot${token}/getMe`, { signal: AbortSignal.timeout(15000) });
239
+ const body = await res.json();
240
+ return body.ok ? body.result : { error: body.description || `HTTP ${res.status}` };
241
+ } catch (err) {
242
+ return { error: err.message };
243
+ }
244
+ }
245
+
246
+ async function feishuCheck(appId, appSecret) {
247
+ try {
248
+ const res = await fetch('https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ app_id: appId, app_secret: appSecret }), signal: AbortSignal.timeout(15000) });
249
+ const body = await res.json();
250
+ return body.code === 0 ? { ok: true } : { error: body.msg || `code ${body.code}` };
251
+ } catch (err) {
252
+ return { error: err.message };
253
+ }
254
+ }
255
+
184
256
  const commands = {
257
+ async setup() {
258
+ if (!process.stdin.isTTY) fail(`herdrchat setup asks questions, so run it in a terminal. Without one: herdrchat init${again}, fill in the file, herdrchat start${again}.`);
259
+ console.log(`herdrchat setup${again}: a few questions, then it runs.\n`);
260
+ // 1. herdr
261
+ const hcPath = findHc();
262
+ if (!hcPath) {
263
+ bad(`hc is not on PATH. ${HERDR}`);
264
+ process.exitCode = 1;
265
+ return;
266
+ }
267
+ const { execFileSync } = await import('node:child_process');
268
+ ok(`herdr is here (${execFileSync(hcPath, ['--version'], { encoding: 'utf8' }).trim()})`);
269
+ const p = await prompter();
270
+ try {
271
+ // 2. the chat app
272
+ makeDirs();
273
+ const had = fs.existsSync(CONFIG_FILE) ? loadConfig() : null;
274
+ let chatName = chatFlag || had?.chat;
275
+ if (!chatName || (!had && !chatFlag)) {
276
+ const pick = await p.ask('Which chat app? 1) Feishu 2) Telegram', had?.chat === 'telegram' ? '2' : '1');
277
+ chatName = pick === '2' || /^t/i.test(pick) ? 'telegram' : 'feishu';
278
+ }
279
+ if (!had) {
280
+ const example = fs.readFileSync(path.join(ROOT, 'config.env.example'), 'utf8').replace(/^HERDRCHAT_CHAT=.*$/m, `HERDRCHAT_CHAT=${chatName}`);
281
+ fs.writeFileSync(CONFIG_FILE, example, { mode: 0o600 });
282
+ } else {
283
+ setConfigValue(CONFIG_FILE, 'HERDRCHAT_CHAT', chatName);
284
+ }
285
+ ok(`${chatName}, settings in ${CONFIG_FILE}`);
286
+ const env = loadConfig().env;
287
+ // 3. its keys, checked now
288
+ if (chatName === 'telegram') {
289
+ let token = env.TELEGRAM_BOT_TOKEN || '';
290
+ if (!token) tell('Make a bot first: in Telegram, message @BotFather, send /newbot, pick a name; it gives you a token.');
291
+ for (;;) {
292
+ if (!token) token = await p.askSecret('Paste the token (it is not shown):');
293
+ const me = await telegramMe(token);
294
+ if (me.error) {
295
+ bad(`Telegram says: ${me.error}. Check the token and paste it again.`);
296
+ token = '';
297
+ continue;
298
+ }
299
+ setConfigValue(CONFIG_FILE, 'TELEGRAM_BOT_TOKEN', token);
300
+ ok(`the token works: @${me.username}`);
301
+ if (!me.has_topics_enabled) {
302
+ bad('topic mode is off, and herdrchat makes one topic per terminal.');
303
+ tell('In the @BotFather chat tap Open (its mini app), pick your bot, turn on Threaded Mode.');
304
+ await p.ask('Press Enter when it is on.');
305
+ continue;
306
+ }
307
+ ok('topic mode is on');
308
+ break;
309
+ }
310
+ } else {
311
+ let appId = env.FEISHU_APP_ID || '';
312
+ let appSecret = env.FEISHU_APP_SECRET || '';
313
+ if (!appId || !appSecret) {
314
+ tell('Make the Feishu app first, in https://open.feishu.cn/app: a custom app (企业自建应用) with the bot ability');
315
+ tell('and the scopes in the guide, "Set up: Feishu": ' + GUIDE + '#set-up-feishu');
316
+ tell('(your coding agent can click through it for you: /herdrchat feishu create the app)');
317
+ }
318
+ for (;;) {
319
+ if (!appId) appId = await p.ask('App ID (cli_…, on the app\'s 凭证与基础信息 page):');
320
+ if (!appSecret) appSecret = await p.askSecret('App Secret (it is not shown):');
321
+ const check = await feishuCheck(appId, appSecret);
322
+ if (check.error) {
323
+ bad(`Feishu says: ${check.error}. Check the App ID and Secret and give them again.`);
324
+ appId = '';
325
+ appSecret = '';
326
+ continue;
327
+ }
328
+ setConfigValue(CONFIG_FILE, 'FEISHU_APP_ID', appId);
329
+ setConfigValue(CONFIG_FILE, 'FEISHU_APP_SECRET', appSecret);
330
+ ok('the App ID and Secret work');
331
+ break;
332
+ }
333
+ }
334
+ // 4. which sessions
335
+ const here = sessionsHere();
336
+ if (!here.length) {
337
+ bad('herdr is installed but no hc session runs on this machine, so there is nothing to mirror yet.');
338
+ tell('Open herdr-app, or start hc as herdr\'s docs say (https://herdr.dev), then run herdrchat setup' + again + ' again: what is done so far is kept.');
339
+ process.exitCode = 1;
340
+ return;
341
+ }
342
+ const named = loadConfig().sessionsSet ? loadConfig().sessions.join(',') : '';
343
+ const fallback = named || (here.includes('default') ? 'default' : here[0] || 'default');
344
+ tell(`hc sessions on this machine: ${here.join(', ') || 'none yet'}. "default" with herdr-app reaches all it shows.`);
345
+ for (;;) {
346
+ const pick = (await p.ask('Which sessions to mirror (comma-separated)?', fallback)).split(',').map((x) => x.trim()).filter(Boolean);
347
+ const unknown = pick.filter((x) => !here.includes(x));
348
+ if (unknown.length) {
349
+ bad(`not on this machine: ${unknown.join(', ')}`);
350
+ continue;
351
+ }
352
+ setConfigValue(CONFIG_FILE, 'HERDR_SESSIONS', pick.join(',') === 'default' ? '' : pick.join(','));
353
+ ok(`following ${pick.join(', ')}`);
354
+ break;
355
+ }
356
+ // 5. start
357
+ console.log('');
358
+ await commands.start();
359
+ if (process.exitCode) return;
360
+ if (chatName === 'feishu') {
361
+ tell('Now, with the bot running, in the Feishu developer console: 事件与回调 -> long connection (长连接),');
362
+ tell('add the event im.message.receive_v1 and the callback card.action.trigger, then publish a version.');
363
+ await p.ask('Press Enter when the version is published.');
364
+ }
365
+ // 6. pair: the owner is whoever sends the code (the same step the agent runs)
366
+ await commands.pair();
367
+ if (process.exitCode) return;
368
+ // 7. keep it running
369
+ const svc = service();
370
+ if (svc && !svc.installed()) {
371
+ const yes = await p.ask('Keep it running as a service (starts at login, comes back after a crash)? [Y/n]', 'Y');
372
+ if (/^y/i.test(yes)) await commands.install();
373
+ }
374
+ console.log(`\nDone. herdrchat status${again} shows how it is; herdrchat logs${again} what it did.`);
375
+ if (chatName === 'feishu') console.log('Send /auth to the bot once to let it mute its groups, so your phone rings only when it should.');
376
+ } finally {
377
+ p.close();
378
+ }
379
+ },
380
+
381
+ // The owner, without copying an id: a 6-digit code sent to the bot. It needs no terminal,
382
+ // so `herdrchat setup` and the agent's `/herdrchat setup` both use it. Sending the code
383
+ // also proves the whole path works: the bot runs, the chat app delivers, events are on.
384
+ async pair() {
385
+ const cfg = loadConfig();
386
+ const ownerKey = cfg.chat === 'telegram' ? 'TELEGRAM_OWNER_ID' : 'FEISHU_OWNER_OPEN_ID';
387
+ if (cfg.env[ownerKey]) {
388
+ ok(`the owner is set already (${ownerKey} in ${CONFIG_FILE}); to change it, empty that line and pair again`);
389
+ return;
390
+ }
391
+ const svc = service();
392
+ const running = svc?.installed() ? svc.state().running : (await terminalState(cfg.sessions[0])) === 'live';
393
+ if (!running) fail(`The bot is not running, and it is the bot that reads the code. Run herdrchat start${again} first.`);
394
+ let where = 'your chat with the bot';
395
+ if (cfg.chat === 'telegram') {
396
+ const me = await telegramMe(cfg.env.TELEGRAM_BOT_TOKEN || '');
397
+ if (me.username) where = `your chat with @${me.username} (https://t.me/${me.username}), tap Start if Telegram shows it,`;
398
+ } else {
399
+ where = 'your chat with the bot (in Feishu, search the app\'s name, open it, and message it)';
400
+ }
401
+ const code = startPairing({ read: readState, write: writeState });
402
+ console.log(`\nOpen ${where} and send it this code:\n\n ${code}\n`);
403
+ tell(`It works for ${PAIR_MINUTES} minutes. Waiting…`);
404
+ const since = new Date().toISOString();
405
+ const until = Date.now() + PAIR_MINUTES * 60 * 1000;
406
+ while (Date.now() < until && !loadConfig().env[ownerKey]) await new Promise((r) => setTimeout(r, 2000));
407
+ if (!loadConfig().env[ownerKey]) {
408
+ bad(`no code came in ${PAIR_MINUTES} minutes. Run herdrchat pair${again} for a new one.`);
409
+ if (cfg.chat === 'feishu') tell('When the bot never answered at all: in the developer console, check the event im.message.receive_v1 is added on the long connection, and that a version with it is published.');
410
+ else tell('When the bot never answered at all: herdrchat logs' + again + ' says why.');
411
+ process.exitCode = 1;
412
+ return;
413
+ }
414
+ ok('paired: you are the owner. It starts again as yours…');
415
+ const back = await waitConnected(since);
416
+ if (!back.ok) return failedToConnect('It paired', back);
417
+ ok(`connected; your terminals' ${cfg.chat === 'telegram' ? 'topics' : 'groups'} appear in a moment`);
418
+ },
419
+
185
420
  init() {
186
421
  const chatName = chatFlag || 'feishu';
187
422
  if (!chatNames().includes(chatName)) fail(`--chat ${chatName}: the chat apps are ${chatNames().join(', ')}`);
@@ -195,7 +430,7 @@ const commands = {
195
430
  const example = fs.readFileSync(path.join(ROOT, 'config.env.example'), 'utf8').replace(/^HERDRCHAT_CHAT=.*$/m, `HERDRCHAT_CHAT=${chatName}`);
196
431
  fs.writeFileSync(CONFIG_FILE, example, { mode: 0o600 });
197
432
  const keys = (chatModule(chatName).requires || []).join(', ');
198
- console.log(`Made ${CONFIG_FILE} (only you can read it) for ${chatName}.\nOpen it and fill in ${keys} and HERDR_SESSIONS (the hc sessions to mirror), then run \`herdrchat start${again}\`.\nThe guide walks through it: ${GUIDE}`);
433
+ console.log(`Made ${CONFIG_FILE} (only you can read it) for ${chatName}.\nOpen it and fill in ${keys} (HERDR_SESSIONS is hc's default session unless you name others), then run \`herdrchat start${again}\`.\nThe guide walks through it: ${GUIDE}`);
199
434
  },
200
435
 
201
436
  // The service file is written again each time (a moved node or hc is picked up), and
@@ -270,7 +505,7 @@ const commands = {
270
505
  if (!here.includes(session)) sessionWords.push(`${session} (no such session here; this machine has: ${here.join(', ') || 'none'})`);
271
506
  else sessionWords.push(`${session} (${(await coreUp(session)) ? 'core up' : 'core not running'})`);
272
507
  }
273
- console.log(`sessions ${sessionWords.join(', ') || 'none yet: set HERDR_SESSIONS (`ls ~/.local/state/herdr-core` lists them)'}`);
508
+ console.log(`sessions ${sessionWords.join(', ')}${cfg.sessionsSet ? '' : ' (hc\'s default session: HERDR_SESSIONS is empty)'}`);
274
509
  // how it runs: the service, an hc terminal, or not at all
275
510
  const svc = hcThere ? service() : null;
276
511
  if (!hcThere) {
package/bin/herdrchat.mjs CHANGED
@@ -5,7 +5,8 @@
5
5
  //
6
6
  // Usage: node bin/herdrchat.mjs (`herdrchat start` keeps it running)
7
7
  import path from 'node:path';
8
- import { STATE_DIR, chatOf, loadConfig, makeDirs, readState, writeState } from '../lib/core/config.mjs';
8
+ import { STATE_DIR, chatOf, loadConfig, makeDirs, readState, setConfigValue, writeState } from '../lib/core/config.mjs';
9
+ import { tryPairing } from '../lib/core/pairing.mjs';
9
10
  import { createEngine } from '../lib/core/engine.mjs';
10
11
  import { followEvents } from '../lib/core/events.mjs';
11
12
  import { cores, getTerminal, hc, keys, machines, nameTab, put, run, screenTail, send } from '../lib/core/hc.mjs';
@@ -34,6 +35,17 @@ const chat = createChat(cfg.chat, {
34
35
  stateDir: STATE_DIR,
35
36
  lane: (k, label, fn) => engine.laneOf(k, label, fn),
36
37
  scopeOf: (k) => engine.scopeOfKey(k),
38
+ // the pairing code from `herdrchat setup`: its sender becomes the owner, written to
39
+ // config.env; the bot then starts again (its service or terminal brings it back) as theirs
40
+ claimOwner: (text, key, id) => {
41
+ const result = tryPairing(store, text);
42
+ if (result === 'ok') {
43
+ setConfigValue(cfg.configFile, key, id);
44
+ log(`paired: ${key} set; starting again as the owner's`);
45
+ setTimeout(() => process.exit(0), 2000);
46
+ }
47
+ return result;
48
+ },
37
49
  });
38
50
 
39
51
  // A bot that is down is a phone that hears nothing, so down time is said in the direct
@@ -7,7 +7,8 @@
7
7
  # (`herdrchat init --profile telegram --chat telegram`).
8
8
  HERDRCHAT_CHAT=feishu
9
9
 
10
- # The hc sessions to mirror, comma-separated (machines they reach come along).
10
+ # The hc sessions to mirror, comma-separated (machines they reach come along). Empty is
11
+ # hc's own session, `default`: with herdr-app it reaches every session herdr-app shows.
11
12
  # `ls ~/.local/state/herdr-core` lists the sessions on this machine.
12
13
  HERDR_SESSIONS=
13
14
 
package/docs/guide.md CHANGED
@@ -2,8 +2,8 @@
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 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,
5
+ herdrchat is hc's chat client, as herdr-app is its desktop and phone client: every
6
+ herdr-app tab, followed and answered in a chat app, Feishu or Telegram. Both sit on the same core (hc events, a reader per agent,
7
7
  dialogs, the overview), each behind one contract (`docs/architecture.md`); Slack and
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.
@@ -85,7 +85,7 @@ In the profile's `config.env` (`herdrchat status` prints where it is):
85
85
  | Key | For | Default | What it does |
86
86
  |---|---|---|---|
87
87
  | `HERDRCHAT_CHAT` | both | `feishu` | the chat app this profile mirrors into: `feishu` or `telegram` |
88
- | `HERDR_SESSIONS` | both | (required) | the hc sessions to mirror, comma-separated; machines they reach come along |
88
+ | `HERDR_SESSIONS` | both | `default` | the hc sessions to mirror, comma-separated; machines they reach come along. `default` is hc's own session, which with herdr-app reaches every session it shows |
89
89
  | `HERDR_MUTED_SPACES` | both | `all` | spaces whose rooms are quiet: only "needs you" and "done" ring; empty for none |
90
90
  | `HERDR_SILENT_SPACES` | both | (none) | spaces where nothing rings, not even "needs you" |
91
91
  | `HERDR_HISTORY` | both | `file,folded` | what a new room gets of the conversation before it: `file`, `folded`, both, or empty |
@@ -228,6 +228,11 @@ delivered (the terminal has ended) says so plainly.
228
228
  dialog it waits on, as is), `/esc`, `/history` (the conversation so far, as a file and
229
229
  a folded post). Dialog cards have buttons.
230
230
 
231
+ `herdrchat setup` does the steps below by asking: it checks the token or the Feishu keys
232
+ at once, picks the sessions, starts the bot and pairs you as its owner with a 6-digit
233
+ code you send to the bot, then offers the service. The steps say what happens, and how
234
+ to do it by hand. When the owner is missing later, `herdrchat pair` shows a new code.
235
+
231
236
  ## Set up: Telegram
232
237
 
233
238
  1. In Telegram, message **@BotFather**: `/newbot`, give it a name and a username ending
@@ -235,10 +240,10 @@ delivered (the terminal has ended) says so plainly.
235
240
  2. Turn on topics for the bot: in the BotFather chat tap **Open** (its mini app), pick the
236
241
  bot, turn on **Threaded Mode**. Without it the bot cannot make topics.
237
242
  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
243
+ prints where its `config.env` is. Fill in `TELEGRAM_BOT_TOKEN`. `HERDR_SESSIONS`
244
+ can stay empty for hc's default session (`ls ~/.local/state/herdr-core` lists yours).
245
+ 4. `herdrchat start`, then `herdrchat pair`: it shows a 6-digit code. Open the bot (tap
246
+ Start) and send it the code; the bot makes you its owner and starts again. One topic per
242
247
  tab appears in your private chat with the bot (Telegram takes about one message a
243
248
  second, so many tabs take a moment).
244
249
  5. Once it works: `herdrchat install` (see "Running as a service").
@@ -249,8 +254,9 @@ If Telegram is your only app, use the default profile with `--chat telegram`. Us
249
254
  ## Set up: Feishu
250
255
 
251
256
  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.
257
+ are in the Feishu developer console (`https://open.feishu.cn/app`). The herdrchat skill
258
+ (`/herdrchat setup`) clicks them for you with the [Ego Lite](https://lite.ego.app/)
259
+ browser, or tells you what to click without it; or follow the steps here yourself.
254
260
 
255
261
  1. Create a custom app (企业自建应用) and add the bot ability (机器人).
256
262
  2. Permissions: import the scopes listed in `skills/herdrchat/references/feishu.md`,
@@ -258,8 +264,8 @@ are in the Feishu developer console (`https://open.feishu.cn/app`); the herdrcha
258
264
  reading the messages sent to it, making and renaming groups, reactions, images both
259
265
  ways, and group tags.
260
266
  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).
267
+ Secret (the app's 凭证与基础信息 page). `HERDR_SESSIONS` can stay empty for hc's
268
+ default session (`ls ~/.local/state/herdr-core` lists yours).
263
269
  4. `herdrchat start`. The bot must be running for the next step: the console checks for
264
270
  a live connection.
265
271
  5. Events and callbacks (事件与回调): for events, choose the long connection mode (长连接)
@@ -267,9 +273,9 @@ are in the Feishu developer console (`https://open.feishu.cn/app`); the herdrcha
267
273
  mode too and add `card.action.trigger` (the dialog buttons).
268
274
  6. Publish a version. If the page does not say 本次发布免审核, your team's admin must
269
275
  approve it first.
270
- 7. Open `https://applink.feishu.cn/client/bot/open?appId=<app id>` and send anything.
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.
276
+ 7. `herdrchat pair`: it shows a 6-digit code. In Feishu, search the app's name, open the
277
+ bot and send it the code; the bot makes you its owner and starts again. One group per
278
+ tab appears. When the code gets no answer, step 5 or 6 is not done.
273
279
  8. Optional: send `/auth` to the bot so it can mute its groups for you ("Optional: let
274
280
  the bot act as you"), and add the bot menu (`skills/herdrchat/references/feishu.md`,
275
281
  "The bot menu").
@@ -12,7 +12,10 @@
12
12
  // working | closed | needs | done | good | bad.
13
13
  //
14
14
  // ctx: {cfg, store: {read, write}, log, logWarn, logError, stateDir,
15
- // lane(k, label, fn): run fn in the room's lane, scopeOf(k): its session or machine}
15
+ // lane(k, label, fn): run fn in the room's lane, scopeOf(k): its session or machine,
16
+ // claimOwner(text, key, id) -> 'ok' | 'wrong' | 'none': with no owner yet, a message
17
+ // that may be the pairing code `herdrchat setup` shows; on 'ok' the id is written
18
+ // to config.env as `key` and the bot starts again as that owner's}
16
19
 
17
20
  export const CHAT_MEMBERS = {
18
21
  name: 'string: the app, as in HERDRCHAT_CHAT',
@@ -21,7 +24,7 @@ export const CHAT_MEMBERS = {
21
24
  roomWord: 'string: what the user calls a room in this app (group, topic), for what the bot says',
22
25
  liveBytes: 'number: how much text one editable card holds',
23
26
  split: '(markdown) -> string[]: one message\'s worth each',
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',
27
+ 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, ticks?}) -> {toast: {type, text}, card?: {kind: done|dialog, ...}} for button clicks; ticks: {<option n>: true|false}, a multi-select\'s boxes as ticked on the card itself (a form, sent with Next), when the app has them',
25
28
  command: '(cmd, args, where) -> boolean: one of its own direct-chat commands, handled',
26
29
  openRoom: '({name, intro}) -> room',
27
30
  renameRoom: '(room, name)',