herdrchat 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,10 +7,14 @@ import { execFile } from 'node:child_process';
7
7
  // it the text arrives as if you typed it.
8
8
  const { HC_TERMINAL_ID, HC_SCOPE, HC_TERMINAL_SOCKET, ...ENV } = process.env;
9
9
 
10
+ // `herdrchat start` passes hc's full path: the core that runs the bot may have a PATH
11
+ // without it, and a bot that cannot run hc sees nothing.
12
+ const HC = process.env.HERDRCHAT_HC || 'hc';
13
+
10
14
  export function hc(session, args, { timeoutMs = 15000 } = {}) {
11
15
  return new Promise((resolve, reject) => {
12
16
  execFile(
13
- 'hc',
17
+ HC,
14
18
  args,
15
19
  { env: { ...ENV, HC_SESSION: session }, timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024 },
16
20
  (err, stdout, stderr) => {
@@ -34,6 +38,16 @@ export async function machines(session) {
34
38
  return (await json(session, ['ls'])).machines || [];
35
39
  }
36
40
 
41
+ // The id of a session's own core, and the cores it is connected to as machines:
42
+ // { self, links: [{ name, id, state }] }. A link's id is the id of the core it reaches.
43
+ export async function cores(session) {
44
+ const [status, list] = await Promise.all([json(session, ['server', 'status']), json(session, ['machine', 'ls'])]);
45
+ return {
46
+ self: status.core?.id || null,
47
+ links: (list.machines || []).map(({ name, id, state }) => ({ name, id, state })),
48
+ };
49
+ }
50
+
37
51
  export async function getTerminal(session, address) {
38
52
  return json(session, ['get', address]);
39
53
  }
@@ -71,12 +85,46 @@ function read(session, address, lines = 0) {
71
85
  return hc(session, ['read', address, '--lines', String(lines)]);
72
86
  }
73
87
 
74
- // The last `lines` lines of a terminal's screen: the default output for a program with no reader.
88
+ // The last `lines` lines of a terminal's screen (dialogs, and replies in a terminal with no agent).
75
89
  export async function screenTail(session, address, lines) {
76
90
  const out = await read(session, address, lines);
77
91
  return out.split('\n').slice(-lines).join('\n').trimEnd();
78
92
  }
79
93
 
94
+ // hc could not reach the machine a terminal is on (`"kind":"unavailable"`).
95
+ export function isUnavailable(err) {
96
+ return /"kind":"unavailable"/.test(err?.message || '');
97
+ }
98
+
99
+ // A failed hc call as a person reads it: hc's own message, without the command line or
100
+ // its JSON wrapping.
101
+ export function plainError(err) {
102
+ const raw = err?.message || String(err);
103
+ const json = raw.match(/\{"error":.*\}\s*$/);
104
+ if (json) {
105
+ try {
106
+ const { message, kind } = JSON.parse(json[0]).error;
107
+ if (kind === 'finished') return 'this terminal has ended.';
108
+ if (message) return message;
109
+ } catch {
110
+ // not hc's JSON
111
+ }
112
+ }
113
+ return raw.replace(/^hc [^:]*: /, '');
114
+ }
115
+
116
+ // A screen as people read it: no trailing spaces, no blank lines at the ends, never two
117
+ // blank lines in a row; with `last`, only its last `last` lines (a dialog sits at the bottom).
118
+ export function tidyScreen(text, last = Infinity) {
119
+ const lines = [];
120
+ for (const line of (text || '').split('\n').map((l) => l.trimEnd())) {
121
+ if (!line && (!lines.length || !lines[lines.length - 1])) continue;
122
+ lines.push(line);
123
+ }
124
+ while (lines.length && !lines[lines.length - 1]) lines.pop();
125
+ return lines.slice(-last).join('\n');
126
+ }
127
+
80
128
  // "machine/tN" for a terminal on a connected machine, "tN" here.
81
129
  export function machineOf(address) {
82
130
  const i = address.lastIndexOf('/');
@@ -2,12 +2,14 @@
2
2
  // goes to stdout and to logs/<YYYY-MM-DD>.log in the state folder (KEEP_DAYS kept), as
3
3
  // "<time> <LEVEL> <text>". `capture()` sends console output (the SDK, the event stream)
4
4
  // through it too, and writes an uncaught error with its stack before the process exits
5
- // (`herdrchat start` keeps it running). No token or secret is ever passed to it.
5
+ // (`herdrchat start` keeps it running); `onCrash` gets a few seconds first, to say so
6
+ // where you will see it. No token or secret is ever passed to it.
6
7
  import fs from 'node:fs';
7
8
  import path from 'node:path';
8
9
  import util from 'node:util';
9
10
 
10
11
  const KEEP_DAYS = 14;
12
+ const CRASH_WAIT_MS = 3000;
11
13
 
12
14
  export function createLog(dir) {
13
15
  fs.mkdirSync(dir, { recursive: true });
@@ -43,14 +45,15 @@ export function createLog(dir) {
43
45
  error: (...args) => write('ERROR', args),
44
46
  };
45
47
 
46
- log.capture = () => {
48
+ log.capture = ({ onCrash } = {}) => {
47
49
  console.log = (...args) => write('INFO', args);
48
50
  console.info = console.log;
49
51
  console.warn = (...args) => write('WARN', args);
50
52
  console.error = (...args) => write('ERROR', args);
51
53
  process.on('uncaughtException', (err) => {
52
54
  write('ERROR', ['uncaught:', err]);
53
- process.exit(1);
55
+ const said = onCrash ? Promise.resolve().then(() => onCrash(err)).catch(() => {}) : Promise.resolve();
56
+ Promise.race([said, new Promise((r) => setTimeout(r, CRASH_WAIT_MS))]).finally(() => process.exit(1));
54
57
  });
55
58
  process.on('unhandledRejection', (err) => write('ERROR', ['unhandled rejection:', err]));
56
59
  };
@@ -90,6 +90,7 @@ export function conversationAfter(lines, cursor) {
90
90
  }
91
91
 
92
92
  export const app = 'claude-code';
93
+ export const processes = ['claude'];
93
94
 
94
95
  // What is new since `cursor`: {items, file}, or null when the terminal runs no readable
95
96
  // Claude. When the session moved to a new transcript (/clear, resume), what is left in
@@ -0,0 +1,49 @@
1
+ // Readers: how the bot gets what an agent said from the agent's own records, so nothing
2
+ // is lost and the group shows it while the agent works. hc stays the backbone (which
3
+ // terminals exist, their status, when a turn ends, typing into them); a reader adds the
4
+ // conversation. A terminal with no running agent is not mirrored (see agentOf).
5
+ //
6
+ // A reader is a module that exports:
7
+ // app the name the agent gives in its hc status (`status.app`)
8
+ // processes the names its process runs under (hc's `foreground`)
9
+ // after(session, address, cursor) -> {items, file} | null: what is new since `cursor`
10
+ // (null: this terminal is not readable); `file` is
11
+ // the log it read, kept in the cursor
12
+ // rest(session, address, cursor) -> items: what `cursor.file` still holds after the
13
+ // program is gone
14
+ // Items are {role: 'you' | 'ai' | 'tool', text, at, id, end?} (`end` marks the turn's last
15
+ // answer) or {role: 'image', from: 'you' | 'ai', media, data (base64), at, id}.
16
+ // Cursors are in ../cursor.mjs.
17
+ //
18
+ // To add an agent: write lib/core/readers/<agent>.mjs and list it in LISTED below
19
+ // (docs/architecture.md, "Adding a harness").
20
+ import path from 'node:path';
21
+ import * as claude from './claude.mjs';
22
+
23
+ // What every reader exports (see above); checked for each one listed here when this
24
+ // loads, so a reader that misses one fails at start, not when its agent first runs.
25
+ export const READER_MEMBERS = { app: 'string', processes: 'object', after: 'function', rest: 'function' };
26
+ export function checkReader(reader) {
27
+ const wrong = Object.entries(READER_MEMBERS).filter(([m, type]) => typeof reader?.[m] !== type);
28
+ if (wrong.length) throw new Error(`reader ${reader?.app || '?'} does not give: ${wrong.map(([m]) => m).join(', ')} (lib/core/readers/index.mjs)`);
29
+ return reader;
30
+ }
31
+
32
+ export const LISTED = [claude].map(checkReader);
33
+ const READERS = new Map(LISTED.map((r) => [r.app, r]));
34
+
35
+ export function readerFor(app) {
36
+ return READERS.get(app) || null;
37
+ }
38
+
39
+ // The agent a terminal runs right now, as its reader, or null: a group mirrors agents
40
+ // only, never a shell or another program. hc's status can outlive the agent (it still
41
+ // said "blocked" after Claude quit, and a tool run after it was posted as a dialog), so
42
+ // the foreground process must be that agent too; with no foreground known, the status
43
+ // alone decides, so a terminal is never silenced for missing data.
44
+ export function agentOf(record) {
45
+ const reader = readerFor(record?.status?.app);
46
+ const fg = record?.foreground;
47
+ if (!reader || !fg) return reader;
48
+ return [fg.name, path.basename(fg.argv?.[0] || '')].some((n) => reader.processes.includes(n)) ? reader : null;
49
+ }
@@ -0,0 +1,58 @@
1
+ // One terminal can be seen several ways: in its own hc session, and through every
2
+ // followed session that is connected to that session's core as a machine (under any
3
+ // name). What a terminal is comes from hc, never from names: the id of the core it runs
4
+ // on (`hc server status` for a session's own core, `hc machine ls` for the cores it is
5
+ // connected to) plus its tN on that core.
6
+ //
7
+ // The bot follows each terminal through one view: its own session when that session is
8
+ // followed, else the first followed session (config order) with a live connection to
9
+ // its core.
10
+ import { machineOf } from './hc.mjs';
11
+
12
+ // cores: Map session -> { self: core id, links: [{ name, id, state }] }
13
+ export function coreViews(sessions, cores) {
14
+ // the core a view's terminal runs on, or null when it cannot be told (unknown session
15
+ // or machine, or a machine reached through another machine)
16
+ function coreOf(session, address) {
17
+ const info = cores.get(session);
18
+ const machine = machineOf(address);
19
+ if (!info || machine?.includes('/')) return null;
20
+ if (!machine) return info.self || null;
21
+ return info.links.find((l) => l.name === machine)?.id || null;
22
+ }
23
+
24
+ // "<core id>|tN": the same for every view of one terminal
25
+ function identity(session, address) {
26
+ const core = coreOf(session, address);
27
+ return core ? `${core}|${address.slice(address.lastIndexOf('/') + 1)}` : null;
28
+ }
29
+
30
+ // the view that follows a core's terminals: { session, machine } (machine null: directly)
31
+ function ownerOf(core) {
32
+ const direct = sessions.find((s) => cores.get(s)?.self === core);
33
+ if (direct) return { session: direct, machine: null };
34
+ for (const session of sessions) {
35
+ const link = cores.get(session)?.links.find((l) => l.id === core && l.state === 'connected');
36
+ if (link) return { session, machine: link.name };
37
+ }
38
+ return null;
39
+ }
40
+
41
+ // true or false, or null when the cores cannot tell (the caller falls back)
42
+ function owns(session, address) {
43
+ const core = coreOf(session, address);
44
+ if (!core) return null;
45
+ const owner = ownerOf(core);
46
+ return !!owner && owner.session === session && owner.machine === machineOf(address);
47
+ }
48
+
49
+ // false while the machine a view reaches its terminal through is not connected
50
+ function reachable(session, address) {
51
+ const machine = machineOf(address);
52
+ if (!machine) return true;
53
+ const link = cores.get(session)?.links.find((l) => l.name === machine.split('/')[0]);
54
+ return !link || link.state === 'connected';
55
+ }
56
+
57
+ return { identity, owns, reachable };
58
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "herdrchat",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
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,7 @@ in `~/.config/herdrchat/<profile>/config.env` and its state in
22
22
  | `/herdrchat start`, `/herdrchat stop` | Read `config.env` (its `HERDR_SESSIONS`) in one step, then `herdrchat start` / `stop` in the next; never both at once. |
23
23
  | `/herdrchat logs` | `herdrchat logs --errors` (or `-n 100`); explain them with `references/ops.md` "Logs". |
24
24
  | `/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. |
25
- | `/herdrchat config` | Change a setting in `config.env` with the user (sessions, `HERDR_MUTED_SPACES`, `HERDR_SILENT_SPACES`, `HERDR_BOT_TAGS`, `HERDR_USER_TAGS`; each is explained in `config.env.example`), read the file back, then restart. |
25
+ | `/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. |
26
26
  | `/herdrchat fix` | Find the cause from `herdrchat status`, the logs and the live result, then fix it: `references/ops.md`. |
27
27
  | `/herdrchat feishu <task>` | Anything in the Feishu developer console: scopes, callbacks, publishing, the icon, deleting an app: `references/feishu.md`. |
28
28
 
@@ -17,12 +17,25 @@ 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 the old one first)
20
+ herdrchat start # restart: stops it in every session, waits until it reaches Feishu
21
21
  ```
22
22
 
23
23
  A restart is needed after any code or `config.env` change. It is safe: what was posted
24
24
  is in `D/state.json`, so it picks up where it stopped.
25
25
 
26
+ While the bot is down the user hears nothing, so:
27
+
28
+ - Never end with the bot down: `herdrchat start` exits 1 with its errors when the bot
29
+ does not reach Feishu within 30 s; fix that before anything else.
30
+ - Never stop or restart the hc core (`hc server stop|restart`) of the session the bot
31
+ runs in without saying so first, and run `herdrchat start` right after.
32
+ - Never start an hc core from an agent's shell: the core passes that shell's variables
33
+ (`CLAUDECODE`, `CLAUDE_CODE_*`) to every terminal, and an agent started there treats
34
+ itself as a sub-agent (Claude Code then saves no transcript). Start it with those
35
+ variables removed (`env -u CLAUDECODE -u CLAUDE_CODE_CHILD_SESSION … hc server start`).
36
+ - A crash posts "🔴 herdrchat is down" in the direct chat (at most every 10 minutes) and
37
+ the next connection posts "✅ herdrchat is back" with how long it was gone.
38
+
26
39
  ## Logs
27
40
 
28
41
  `D/logs/<YYYY-MM-DD>.log`, one file a day, 14 kept, lines `<time> <LEVEL> <text>`:
@@ -61,7 +74,7 @@ your token after `/auth`: never print it.
61
74
  ## Did the change take effect
62
75
 
63
76
  Check the live result, not only the code: count what actually happened in Feishu with
64
- the bot's own client (`lib/feishu.mjs` `createClient` with `loadConfig()`), for example:
77
+ the bot's own client (`lib/chats/feishu/client.mjs` `createClient` with `loadConfig()`), for example:
65
78
 
66
79
  - mentions in a group since a time: `im.v1.message.list` on the chat, then each item's
67
80
  `mentions` (a mention inside a card is not counted by Feishu, so it does not ring);
@@ -0,0 +1,23 @@
1
+ # Telegram: setting up the bot
2
+
3
+ A Telegram bot cannot make groups, so herdrchat puts each herdr-app tab in a topic of
4
+ your private chat with the bot; the chat's main thread is the overview and the commands
5
+ (`/ls`, `/new`, `/kill`; in a topic `/screen`, `/answer`, `/esc`). Posts are silent;
6
+ "needs you" and "done" ring.
7
+
8
+ It runs as its own profile, beside a Feishu one:
9
+
10
+ 1. In Telegram, message **@BotFather**: `/newbot`, pick a name and a username, and keep
11
+ the token it gives. Never paste the token into a chat or a log.
12
+ 2. Turn on topics for the bot in @BotFather (its mini app, "Open": the bot's settings,
13
+ Threaded Mode). Check: `getMe` answers `has_topics_enabled: true`.
14
+ 3. Make the profile's config: `herdrchat init --profile telegram`, then in
15
+ `~/.config/herdrchat/telegram/config.env` set `HERDRCHAT_CHAT=telegram`, the token as
16
+ `TELEGRAM_BOT_TOKEN`, and `HERDR_SESSIONS`.
17
+ 4. `herdrchat start --profile telegram`, then message the bot once: it answers with your
18
+ `TELEGRAM_OWNER_ID=...`. Put that in the config and start it again. The topics appear,
19
+ one per tab (Telegram allows about one message a second, so many tabs take a moment).
20
+
21
+ Check the token without printing it: read `TELEGRAM_BOT_TOKEN` from the config in a short
22
+ `node -e`, call `https://api.telegram.org/bot<token>/getMe`, print only `username` and
23
+ `has_topics_enabled`.
@@ -1,24 +0,0 @@
1
- // Readers: how the bot gets what an agent said from the agent's own records, so nothing
2
- // is lost and the group shows it while the agent works. hc stays the backbone (which
3
- // terminals exist, their status, when a turn ends, typing into them); a reader adds the
4
- // conversation. A program with no reader gets the default: its screen through `hc read`.
5
- //
6
- // A reader is a module that exports:
7
- // app the name the agent gives in its hc status (`status.app`)
8
- // after(session, address, cursor) -> {items, file} | null: what is new since `cursor`
9
- // (null: this terminal is not readable); `file` is
10
- // the log it read, kept in the cursor
11
- // rest(session, address, cursor) -> items: what `cursor.file` still holds after the
12
- // program is gone
13
- // Items are {role: 'you' | 'ai' | 'tool', text, at, id, end?} (`end` marks the turn's last
14
- // answer) or {role: 'image', from: 'you' | 'ai', media, data (base64), at, id}.
15
- // Cursors are in ../cursor.mjs.
16
- //
17
- // To add an agent: write lib/readers/<agent>.mjs and list it here.
18
- import * as claude from './claude.mjs';
19
-
20
- const READERS = new Map([claude].map((r) => [r.app, r]));
21
-
22
- export function readerFor(app) {
23
- return READERS.get(app) || null;
24
- }
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes