herdrchat 0.0.0-stage → 0.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.
@@ -0,0 +1,17 @@
1
+ # herdrchat settings, the example of config.env. `herdrchat init` copies it to where the
2
+ # profile reads it (~/.config/herdrchat/default/config.env, ~/.config/herdrchat/<profile>/
3
+ # with --profile, or HERDRCHAT_HOME), private; then fill it in.
4
+ FEISHU_APP_ID=cli_xxxxxxxxxxxxxxxx
5
+ FEISHU_APP_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
6
+ FEISHU_OWNER_OPEN_ID=
7
+
8
+ HERDR_SESSIONS=session-a,session-b
9
+ # once /auth is done, spaces whose groups are muted (only an @ rings): `all` (default),
10
+ # empty for none, or spaces, comma-separated
11
+ HERDR_MUTED_SPACES=all
12
+ # spaces that do not even @-mention you (fully quiet): empty (default), `all`, or spaces
13
+ HERDR_SILENT_SPACES=
14
+ # the bot's tags next to group names (tenant tags): on (default) or off
15
+ HERDR_BOT_TAGS=on
16
+ # your sidebar labels, once /auth is done: on (default) or off
17
+ HERDR_USER_TAGS=on
package/docs/guide.md ADDED
@@ -0,0 +1,198 @@
1
+ # herdrchat guide
2
+
3
+ How herdrchat works and every setting, for when the README is not enough.
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.
8
+
9
+ ## Feishu
10
+
11
+ herdr-app, moved into one Feishu bot:
12
+
13
+ | herdr-app | Feishu |
14
+ |---|---|
15
+ | a terminal it shows (it has a `where` label) | a group named `<space> / <tab>`, tagged with the session; type there to talk to it. A tab only numbered (`2`) is named by the terminal's title instead (whatever the program sets, for any agent; Claude Code sets its session name or topic), else its hc name |
16
+ | a space | one avatar for all of its groups: the space's first letter on a color of its own |
17
+ | a terminal on another machine | a group named `<space> / <tab>`, tagged with the machine, made once |
18
+ | a terminal that is gone | its group stays, with its history, tagged `ended` (untagged if it comes back) |
19
+ | herdr itself | the direct chat with the bot: an overview card (every terminal's state, what waits for you first, each a link to its group), kept up to date in place; `/ls`, `/new`, `/kill` |
20
+
21
+ Everything comes from hc, so it works the same for Claude Code, codex, pi or any program
22
+ hc shows, on this machine or on a machine hc has added. Nothing is installed in the agents.
23
+
24
+ ## How it works: hc, plus a reader per agent
25
+
26
+ **hc is the backbone.** The bot follows `hc events --json` for each session (format:
27
+ herdr-core `schema/README.md`, "Events"). From hc it learns which terminals exist, where
28
+ herdr-app shows them, their status, and when a turn ends (`turn` event: the status left
29
+ `working`). Typing from a group goes through `hc send`.
30
+
31
+ **A reader gives the conversation.** hc can show only the screen, and the screen is a
32
+ picture: long output is folded, and you cannot tell your words from the agent's. So for
33
+ each agent a reader reads the agent's own records. The status hc reports names the agent
34
+ (`status.app`), and that name picks the reader (`lib/readers/`):
35
+
36
+ | `status.app` | reader | reads |
37
+ |---|---|---|
38
+ | `claude-code` | `lib/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` |
40
+
41
+ On another machine, a reader reads the same files through `hc exec --on`. To add an
42
+ agent, write `lib/readers/<agent>.mjs` (the contract is at the top of
43
+ `lib/readers/index.mjs`) and list it there.
44
+
45
+ ## What a group shows
46
+
47
+ - A program with a reader:
48
+ - while it works: one "⌛️ Working…" card per turn (then "✅ Worked"), edited every 5
49
+ seconds with what is new: what it says, your prompts (typed in the terminal or sent
50
+ mid-turn) as **You:**, and each tool it calls as one line (`▸ Bash: Run the tests`).
51
+ It shows the program is alive, as herdr-app does, and since an edit is not a new
52
+ 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").
56
+ - Images too: ones it looked at (a screenshot, an image file it read) and ones you
57
+ pasted in the terminal, shown in the card.
58
+ - What you sent from the group is not repeated.
59
+ - A program without one: the last 150 lines of its screen when a turn ends.
60
+ - A turn that ends on `blocked` (permission, question, auth): what it said first, then
61
+ a red "⚠️ Needs you" card. When the screen shows a numbered menu (as Claude Code's
62
+ permission and question dialogs do), the card shows only the dialog, as text, with a
63
+ button per option; a multi-select also has "Next ⇥", a "Type something" option a box to
64
+ type in, and every card "Esc". Without a menu the card shows the screen and how to
65
+ answer by typing. Once the dialog is gone the card turns to "✅ Done".
66
+
67
+ ## Answering a dialog from Feishu
68
+
69
+ A button presses that option's number key in the terminal (Next presses tab, Esc esc).
70
+ Before it presses anything, the bot reads the screen again and checks that it still
71
+ shows the same dialog (a hash of it, from its top border to its last option) and the
72
+ same option, so an old card can never answer a newer dialog that looks alike. After the
73
+ key, the card turns into the dialog's next step (a toggled box, the next question, the
74
+ review) or says what was picked. Text you type in the group while it waits goes into its
75
+ "Type something" box when it has one.
76
+
77
+ A dialog answered in the terminal (or left for the next one) closes its card: the
78
+ buttons go and the card says so. The screen is read the moment `blocked` arrives, so a
79
+ dialog answered in the terminal at once still gets its card; and since hc sends a turn
80
+ event only when a program leaves `working`, status events catch a program that goes
81
+ from one dialog straight to the next. One dialog gets one card.
82
+
83
+ ## Nothing is lost
84
+
85
+ - What was posted is remembered per terminal in `state.json` (entry ids, not only a
86
+ time), so a restart picks up where it stopped, entries with the same timestamp or
87
+ written late are still posted, and nothing is posted twice.
88
+ - A post that fails is retried (2, 4, 8, 16 s); if it still fails the cursor stays, and the
89
+ content goes out with the next post. A repeat is acceptable, a loss is not.
90
+ - The "⌛️ Working…" card holds everything, not a status line. A full card (20 KB) is closed
91
+ and the next one goes on; a card that cannot be edited is sent again as a new one. Which
92
+ card is open is kept in `state.json`, so a restart goes on editing it. While it
93
+ works, the turn's last answer is left for the turn's end, so it always arrives as its
94
+ own message.
95
+ - After a turn ends the bot waits for the agent's last answer (for Claude,
96
+ `stop_reason: end_turn`); hc reports the end before the agent writes it.
97
+ - Idle terminals with a reader are checked again at start, after every event snapshot and
98
+ every minute, so a missed event or a late write is caught up.
99
+ - When a Claude session moves to a new transcript (`/clear`, resume), what is left in the
100
+ old one is posted first; when a terminal ends, what its log still holds is posted first.
101
+ - Some of what Claude says while it works is stored only as a "narration" block (a thinking
102
+ block whose signature names narration); it is posted too. It holds a condensed version of
103
+ the text shown on screen, the only version on disk.
104
+
105
+ On the first start the bot posts only what happens from then on, never older history.
106
+ A shell reports no turns, so after you type a command into one, the bot posts its screen.
107
+
108
+ ## Commands
109
+
110
+ - Direct chat: `/ls` (the overview again at the bottom of the chat; the old one is
111
+ recalled), `/new <session> <space>/<tab> [command]` (start one; default `claude`;
112
+ `--cwd <dir>` first to pick its folder), `/kill <session> <address>`, `/auth` (see
113
+ below).
114
+ - In a terminal's group: any text goes to it (marked THINKING once typed in, then OK when
115
+ the turn that took it is over); an image (alone or in a rich text message) is sent
116
+ beside the terminal with `hc put`, on its machine, and its path is typed in with your
117
+ text, so an agent like Claude Code opens it; `/screen`, `/answer <text>` (type into the
118
+ dialog it waits on, as is), `/esc`. Dialog cards have buttons.
119
+
120
+ ## Set up
121
+
122
+ Steps 1, 2, 5 and 6 are in the Feishu developer console; the herdrchat skill
123
+ (`/herdrchat setup`, see "Skills") does them with an agent, or guides you through them.
124
+
125
+ 1. Feishu developer console: create a custom app (企业自建应用), add the bot ability.
126
+ 2. Permissions (batch import): `im:message:send_as_bot`, `im:message.p2p_msg:readonly`,
127
+ `im:message.group_msg`, `im:chat:create`, `im:chat:update`,
128
+ `im:message.reactions:write_only`, `im:resource` (images out),
129
+ `im:message:readonly` (images in), `im:tag:write`,
130
+ `im:biz_entity_tag_relation:write` (group tags).
131
+ 3. `herdrchat init` makes `~/.config/herdrchat/default/config.env`; fill the app ID, the
132
+ secret and `HERDR_SESSIONS`: the hc sessions on the machine the bot runs on that herdr-app shows
133
+ (the machines they connect to come along, as in herdr-app). See "Where things are"
134
+ for other places and more than one profile.
135
+ 4. `herdrchat start` (the console checks for a live connection in the next step).
136
+ 5. Events and callbacks (事件与回调): for events, choose the long connection mode (长连接)
137
+ and add `im.message.receive_v1`; for callbacks (回调配置), choose the long connection
138
+ mode too and add `card.action.trigger` (the dialog buttons).
139
+ 6. Publish a version.
140
+ 7. Open `https://applink.feishu.cn/client/bot/open?appId=<app id>` and send anything.
141
+ The bot answers with your `open_id`; put it in `FEISHU_OWNER_OPEN_ID` and run
142
+ `herdrchat start` again.
143
+
144
+ ## Optional: let the bot act as you
145
+
146
+ Some chat-list settings only you can change. Send `/auth` in the direct chat and allow it
147
+ on Feishu's page (OAuth device flow: a link and a code, no public address needed); the
148
+ token is refreshed on its own and kept in `user-token.json` in the state folder (mode 600).
149
+ Then:
150
+
151
+ - groups are muted (`HERDR_MUTED_SPACES` in `config.env`: `all`, the default; empty for none; or
152
+ some spaces, comma-separated; a change applies both ways at the next start), and in a
153
+ muted group a one-line reply that @-mentions you goes under each card that needs you and
154
+ under each turn's last answer, unless its space is in `HERDR_SILENT_SPACES` (empty by
155
+ default; same values) to stay fully quiet: in a muted group only a mention rings (a
156
+ mention inside a card does not count as one), so the phone rings only then;
157
+ - each group is also in one of your own labels (the session or machine; `ended` once
158
+ the terminal is gone), to filter the chat list by; the bot tags stay, since only they
159
+ show next to a group's name. Either can be turned off: `HERDR_USER_TAGS=off` takes the
160
+ groups out of your labels (the labels stay), `HERDR_BOT_TAGS=off` takes the bot's tags
161
+ off (they are tenant tags, visible to the whole tenant);
162
+ - the group that needs you is pinned until it no longer does.
163
+
164
+ The app needs these user scopes: `im:chat.user_setting:write`, `im:feed_group_v1:read`,
165
+ `im:feed_group_v1:write`, `im:feed.shortcut:write`, `offline_access`. Without `/auth`
166
+ none of this happens and everything else works the same. If the token can no longer be
167
+ refreshed, the bot says so once and falls back.
168
+
169
+ ## Skills
170
+
171
+ `skills/herdrchat/` is one skill for an agent (Claude Code or another that reads
172
+ `SKILL.md`): `/herdrchat setup`, `status`, `start`, `stop`, `logs`, `auth`, `config`, `fix`
173
+ and `feishu <task>` (the developer console, with Ego Lite from https://lite.ego.app/ or as
174
+ a guided walk-through). Details per part are in `skills/herdrchat/references/`. To use it
175
+ with Claude Code, link it where skills are read, for example
176
+ `ln -s "$PWD/skills/herdrchat" ~/.claude/skills/herdrchat`.
177
+
178
+ ## Logs
179
+
180
+ Everything the bot logs goes to `logs/<YYYY-MM-DD>.log` in the state folder (one file a
181
+ day, the last 14 kept) as `<time> <LEVEL> <text>`, besides its terminal: `grep ERROR
182
+ ~/.local/state/herdrchat/default/logs/*.log` finds
183
+ what went wrong, a retry that recovered is a `WARN`, and a crash leaves its stack there
184
+ before it is started again. Tokens and secrets are never logged. The
185
+ skill's `references/ops.md` explains the lines.
186
+
187
+ ## Limits
188
+
189
+ - Narration blocks are a condensed version of the text Claude showed, not the exact words.
190
+ - Only Claude Code has a reader so far. codex, pi and the rest get their screen, at turn
191
+ end only, until each has a reader.
192
+ - Only text, images and rich text are passed on; files, stickers and the rest are not.
193
+ - An image that fails to upload three times is not sent; the group says so and why.
194
+ - Feishu takes tag names of at most 16 characters; a longer session or machine name is cut
195
+ at its last `-`, `_` or `.` that fits (`build-agent-server` becomes `build-agent`).
196
+ - Avatars are drawn with `sips`, so only on macOS; elsewhere groups keep Feishu's default.
197
+ - Dialog buttons come from the screen's numbered menu; a dialog drawn some other way gets
198
+ the screen and `/answer`, `/esc`.
package/lib/avatar.mjs ADDED
@@ -0,0 +1,34 @@
1
+ // Group avatars, drawn as herdr-app draws its spaces: a colored square with the space's
2
+ // first letter. The caller gives each space one color, so all of its groups look alike.
3
+ // The SVG is turned into a PNG by sips (macOS); where that fails, the caller leaves the
4
+ // group with Feishu's default avatar.
5
+ import { execFile } from 'node:child_process';
6
+ import fs from 'node:fs';
7
+ import os from 'node:os';
8
+ import path from 'node:path';
9
+
10
+ export const COLORS = ['#3f9e8f', '#d9824b', '#5b7fe0', '#8a6fd6', '#4fa35a', '#d0605e', '#c99a2e', '#3f8fc0'];
11
+ const SIZE = 240;
12
+ const FONT = 130;
13
+
14
+ export function letterOf(space) {
15
+ return (Array.from(space.trim())[0] || '?').toUpperCase();
16
+ }
17
+
18
+ const escapeXml = (s) => s.replace(/[<>&"']/g, (c) => `&#${c.charCodeAt(0)};`);
19
+
20
+ export async function avatarPng(letter, color) {
21
+ // sips ignores dominant-baseline: the baseline sits half a cap height below the middle
22
+ const baseline = Math.round(SIZE / 2 + FONT * 0.36);
23
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${SIZE}" height="${SIZE}"><rect width="${SIZE}" height="${SIZE}" fill="${color}"/><text x="${SIZE / 2}" y="${baseline}" font-family="Helvetica Neue, Helvetica, Arial" font-weight="600" font-size="${FONT}" fill="#ffffff" text-anchor="middle">${escapeXml(letter)}</text></svg>`;
24
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'herdrchat-avatar-'));
25
+ try {
26
+ fs.writeFileSync(path.join(dir, 'a.svg'), svg);
27
+ await new Promise((resolve, reject) => {
28
+ execFile('sips', ['-s', 'format', 'png', path.join(dir, 'a.svg'), '--out', path.join(dir, 'a.png')], (err) => (err ? reject(err) : resolve()));
29
+ });
30
+ return fs.readFileSync(path.join(dir, 'a.png'));
31
+ } finally {
32
+ fs.rmSync(dir, { recursive: true, force: true });
33
+ }
34
+ }
package/lib/config.mjs ADDED
@@ -0,0 +1,93 @@
1
+ // Where herdrchat keeps things. Several can run side by side, one per profile
2
+ // (HERDRCHAT_PROFILE, `default` when unset), each with its own folders and hc terminal:
3
+ // ~/.config/herdrchat/<profile>/config.env your settings, below (XDG_CONFIG_HOME moves it)
4
+ // ~/.local/state/herdrchat/<profile>/ what the bot writes (XDG_STATE_HOME moves it):
5
+ // state.json (per terminal, its group and what was posted from it; tag and label ids),
6
+ // user-token.json (your token after /auth), logs/
7
+ // HERDRCHAT_HOME puts one profile in one folder of your choice instead: config.env and
8
+ // state/ inside it. The folders are made private (mode 700). The settings in config.env:
9
+ // FEISHU_APP_ID, FEISHU_APP_SECRET herdrchat's Feishu app
10
+ // FEISHU_OWNER_OPEN_ID your open_id for that app; the only person it listens to
11
+ // HERDR_SESSIONS hc sessions to mirror, comma-separated
12
+ // HERDR_MUTED_SPACES spaces whose groups are muted once /auth is done (only
13
+ // an @ rings there): `all` (default), empty for none, or
14
+ // spaces, comma-separated
15
+ // HERDR_SILENT_SPACES spaces that do not even @-mention you (fully quiet):
16
+ // empty (default) for none, `all`, or spaces
17
+ // HERDR_BOT_TAGS `on` (default) or `off`: the bot's tags next to group
18
+ // names (tenant tags: session or machine, and "ended")
19
+ // HERDR_USER_TAGS `on` (default) or `off`: your sidebar labels, once
20
+ // /auth is done
21
+ import fs from 'node:fs';
22
+ import os from 'node:os';
23
+ import path from 'node:path';
24
+
25
+ // Where one profile lives, from the environment: {profile, configDir, stateDir, terminal}.
26
+ export function placesOf(env = process.env, home = os.homedir()) {
27
+ const profile = env.HERDRCHAT_PROFILE || 'default';
28
+ if (!/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(profile)) throw new Error(`HERDRCHAT_PROFILE "${profile}": letters, digits, - and _ only`);
29
+ const terminal = profile === 'default' ? 'herdrchat' : `herdrchat-${profile}`;
30
+ if (env.HERDRCHAT_HOME) return { profile, configDir: env.HERDRCHAT_HOME, stateDir: path.join(env.HERDRCHAT_HOME, 'state'), terminal };
31
+ const configRoot = env.XDG_CONFIG_HOME || path.join(home, '.config');
32
+ const stateRoot = env.XDG_STATE_HOME || path.join(home, '.local', 'state');
33
+ return { profile, configDir: path.join(configRoot, 'herdrchat', profile), stateDir: path.join(stateRoot, 'herdrchat', profile), terminal };
34
+ }
35
+
36
+ const places = placesOf();
37
+ export const CONFIG_DIR = places.configDir;
38
+ export const STATE_DIR = places.stateDir;
39
+ export const CONFIG_FILE = path.join(CONFIG_DIR, 'config.env');
40
+ const STATE_FILE = path.join(STATE_DIR, 'state.json');
41
+ // Made when the bot starts, not when this is imported (a test must not touch your home).
42
+ export function makeDirs() {
43
+ for (const dir of [CONFIG_DIR, STATE_DIR]) fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
44
+ }
45
+
46
+ function parseEnv(file) {
47
+ const env = {};
48
+ if (!fs.existsSync(file)) return env;
49
+ for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
50
+ const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
51
+ if (m) env[m[1]] = m[2].replace(/^(['"])(.*)\1$/, '$2');
52
+ }
53
+ return env;
54
+ }
55
+
56
+ // A list of spaces: `all`, empty or `none` for none, or the spaces listed. `fallback` is
57
+ // what an unset variable means.
58
+ export function spacesOf(value, fallback) {
59
+ const v = (value ?? fallback).trim();
60
+ if (v === 'all') return { all: true, spaces: [] };
61
+ if (v === '' || v === 'none') return { all: false, spaces: [] };
62
+ return { all: false, spaces: v.split(',').map((s) => s.trim()).filter(Boolean) };
63
+ }
64
+
65
+ // on / off (also true, yes, 1 / false, no, 0); `fallback` is what an unset variable means.
66
+ export function onOff(value, fallback) {
67
+ const v = String(value ?? '').trim().toLowerCase();
68
+ if (['on', 'true', 'yes', '1'].includes(v)) return true;
69
+ if (['off', 'false', 'no', '0'].includes(v)) return false;
70
+ return fallback;
71
+ }
72
+
73
+ export function loadConfig() {
74
+ const env = { ...parseEnv(CONFIG_FILE), ...process.env };
75
+ const sessions = (env.HERDR_SESSIONS || '').split(',').map((s) => s.trim()).filter(Boolean);
76
+ if (!env.FEISHU_APP_ID || !env.FEISHU_APP_SECRET) throw new Error(`${CONFIG_FILE}: FEISHU_APP_ID and FEISHU_APP_SECRET are required`);
77
+ if (!sessions.length) throw new Error(`${CONFIG_FILE}: HERDR_SESSIONS must list at least one hc session`);
78
+ return { appId: env.FEISHU_APP_ID, appSecret: env.FEISHU_APP_SECRET, ownerOpenId: env.FEISHU_OWNER_OPEN_ID || '', sessions, muted: spacesOf(env.HERDR_MUTED_SPACES, 'all'), silent: spacesOf(env.HERDR_SILENT_SPACES, ''), botTags: onOff(env.HERDR_BOT_TAGS, true), userTags: onOff(env.HERDR_USER_TAGS, true) };
79
+ }
80
+
81
+ export function readState() {
82
+ try {
83
+ return { chats: {}, ...JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')) };
84
+ } catch {
85
+ return { chats: {} };
86
+ }
87
+ }
88
+
89
+ export function writeState(state) {
90
+ const tmp = `${STATE_FILE}.tmp`;
91
+ fs.writeFileSync(tmp, JSON.stringify(state, null, 2));
92
+ fs.renameSync(tmp, STATE_FILE);
93
+ }
package/lib/cursor.mjs ADDED
@@ -0,0 +1,57 @@
1
+ // What was already posted from an agent's conversation log (a .jsonl file), so a reader
2
+ // returns only what is new. Shared by every reader that reads such a log.
3
+ //
4
+ // A cursor is {at, seen}: `seen` lists [id, at] of every item posted in the last LAG_MS,
5
+ // and `at` trails the newest posted item by LAG_MS. An item counts as new when it is
6
+ // after `at` and not seen, so an entry written late with an older timestamp (within
7
+ // LAG_MS) is still picked up, and nothing is posted twice.
8
+ import crypto from 'node:crypto';
9
+
10
+ export const LAG_MS = 10 * 60 * 1000;
11
+
12
+ export function startCursor(at) {
13
+ return { at, seen: [] };
14
+ }
15
+
16
+ function after(cursor, at, id) {
17
+ return at > cursor.at && !cursor.seen.some(([seenId]) => seenId === id);
18
+ }
19
+
20
+ function idOf(entry) {
21
+ if (entry.uuid) return entry.uuid;
22
+ return crypto.createHash('sha1').update(`${entry.type}|${entry.timestamp}|${entry.operation || ''}|${entry.content || ''}`).digest('hex');
23
+ }
24
+
25
+ // The items after `cursor` in log `lines`, in order, using `entryItems(entry)` (the
26
+ // reader's own parser) for the items one entry holds. Each item gets the entry's
27
+ // timestamp (`at`) and a stable `id`. `complete` says whether `lines` reach back far
28
+ // enough: when the oldest readable entry is already after the cursor, older unposted
29
+ // entries may lie outside these lines, so the caller must read the whole file.
30
+ export function itemsAfter(lines, cursor, entryItems) {
31
+ const found = [];
32
+ let oldest = null;
33
+ for (const line of lines) {
34
+ if (!line.trim()) continue;
35
+ let entry;
36
+ try {
37
+ entry = JSON.parse(line);
38
+ } catch {
39
+ continue; // a cut first line or a half-written last line
40
+ }
41
+ if (!entry.timestamp) continue;
42
+ if (oldest === null || entry.timestamp < oldest) oldest = entry.timestamp;
43
+ const id = idOf(entry);
44
+ if (!after(cursor, entry.timestamp, id)) continue;
45
+ entryItems(entry).forEach((it, i) => found.push({ ...it, at: entry.timestamp, id: i ? `${id}#${i}` : id }));
46
+ }
47
+ return { items: found, complete: oldest !== null && oldest <= cursor.at };
48
+ }
49
+
50
+ // The cursor once `items` are posted.
51
+ export function advance(cursor, items) {
52
+ const seen = [...cursor.seen, ...items.map((it) => [it.id, it.at])];
53
+ const newest = seen.reduce((max, [, at]) => (at > max ? at : max), cursor.at);
54
+ const lagged = new Date(new Date(newest).getTime() - LAG_MS).toISOString();
55
+ const at = lagged > cursor.at ? lagged : cursor.at;
56
+ return { at, seen: seen.filter(([, t]) => t > at) };
57
+ }
package/lib/events.mjs ADDED
@@ -0,0 +1,95 @@
1
+ // Follows `hc events --json` for one hc session (local and connected machines).
2
+ // Format (herdr-core schema/README.md, "Events"): the first line is a snapshot
3
+ // {seq, terminals}; then JSON-RPC notifications: `event` {seq, type, terminal, data},
4
+ // `heartbeat` {seq}, `missed` {seq, terminals} (a fresh snapshot).
5
+ // A `turn` event fires each time a program's status leaves `working`; its `state`
6
+ // is the word it ended on: done/idle/error (the turn is over) or blocked (it needs a person).
7
+ import { spawn } from 'node:child_process';
8
+ import readline from 'node:readline';
9
+
10
+ const RESTART_MS = 3000;
11
+
12
+ export function followEvents(session, { onTurn, onChange = () => {}, onSnapshot = () => {}, log = console.log }) {
13
+ const terminals = new Map(); // address -> Terminal record, kept current from events
14
+ let seq = 0;
15
+
16
+ const snapshot = (list, at) => {
17
+ terminals.clear();
18
+ for (const t of list || []) terminals.set(t.address, t);
19
+ seq = at;
20
+ onSnapshot([...terminals.values()]);
21
+ };
22
+
23
+ const apply = ({ seq: at, type, terminal, data }) => {
24
+ if (at <= seq) return; // already covered by a newer snapshot
25
+ seq = at;
26
+ const record = terminals.get(terminal);
27
+ switch (type) {
28
+ case 'created':
29
+ case 'restarted':
30
+ terminals.set(terminal, data);
31
+ break;
32
+ case 'removed':
33
+ terminals.delete(terminal);
34
+ break;
35
+ case 'finished':
36
+ if (record) Object.assign(record, data, { state: 'finished', foreground: null });
37
+ break;
38
+ case 'lost':
39
+ if (record) Object.assign(record, data, { pid: null, foreground: null });
40
+ break;
41
+ case 'status':
42
+ if (record) Object.assign(record, data);
43
+ break;
44
+ case 'labels':
45
+ if (data.record) terminals.set(terminal, data.record);
46
+ else if (record) record.labels = data.labels;
47
+ break;
48
+ case 'foreground':
49
+ if (record) record.foreground = data;
50
+ break;
51
+ case 'turn':
52
+ if (record) record.turn = data.turn;
53
+ onTurn({ address: terminal, state: data.state, record: terminals.get(terminal) });
54
+ break;
55
+ default:
56
+ if (record && data && typeof data === 'object') Object.assign(record, data); // title, size, keep
57
+ }
58
+ if (type !== 'turn') onChange({ type, address: terminal, record: terminals.get(terminal) || record, data });
59
+ };
60
+
61
+ const start = () => {
62
+ const child = spawn('hc', ['events', '--json'], { env: { ...process.env, HC_SESSION: session } });
63
+ let first = true;
64
+ readline.createInterface({ input: child.stdout }).on('line', (line) => {
65
+ if (!line.trim()) return;
66
+ let msg;
67
+ try {
68
+ msg = JSON.parse(line);
69
+ } catch {
70
+ log(`[events] unreadable line: ${line.slice(0, 120)}`);
71
+ return;
72
+ }
73
+ if (first) {
74
+ first = false;
75
+ snapshot(msg.terminals, msg.seq);
76
+ log(`[events] following hc session "${session}": ${terminals.size} terminals`);
77
+ return;
78
+ }
79
+ try {
80
+ if (msg.method === 'event') apply(msg.params);
81
+ else if (msg.method === 'missed') snapshot(msg.params.terminals, msg.params.seq);
82
+ } catch (err) {
83
+ log(`[events] ${err.stack || err.message}`);
84
+ }
85
+ });
86
+ child.stderr.on('data', (d) => log(`[events] ${String(d).trim()}`));
87
+ child.on('exit', (code) => {
88
+ log(`[events] hc events ended (${code}); restarting in ${RESTART_MS / 1000} s`);
89
+ setTimeout(start, RESTART_MS);
90
+ });
91
+ };
92
+
93
+ start();
94
+ return { terminals };
95
+ }