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.
package/lib/feishu.mjs ADDED
@@ -0,0 +1,273 @@
1
+ // Feishu side: one client, markdown cards, dialog cards with buttons, one group per
2
+ // hc terminal.
3
+ import * as Lark from '@larksuiteoapi/node-sdk';
4
+
5
+ const CHUNK = 6000;
6
+
7
+ // The SDK would also print every failed request; the bot logs its own failures, with
8
+ // what it was doing, so the SDK's logger says nothing. (Its "fatal" level is 0, which the
9
+ // SDK reads as unset and turns into "info", hence a silent logger instead.)
10
+ const SILENT = { error() {}, warn() {}, info() {}, debug() {}, trace() {} };
11
+
12
+ export function createClient(cfg) {
13
+ return new Lark.Client({ appId: cfg.appId, appSecret: cfg.appSecret, logger: SILENT, loggerLevel: Lark.LoggerLevel.error });
14
+ }
15
+
16
+ // Splits on line boundaries; a code fence cut in two is closed and reopened.
17
+ export function chunk(text, size = CHUNK) {
18
+ const parts = [];
19
+ let current = [];
20
+ let length = 0;
21
+ let fence = null;
22
+ const flush = () => {
23
+ if (!current.length) return;
24
+ let body = current.join('\n');
25
+ if (fence !== null) body += '\n```';
26
+ parts.push(body);
27
+ current = fence !== null ? [fence] : [];
28
+ length = current.length ? fence.length : 0;
29
+ };
30
+ for (const raw of text.split('\n')) {
31
+ for (let line = raw; ; ) {
32
+ const piece = line.length > size ? line.slice(0, size) : line;
33
+ if (length + piece.length + 1 > size && current.length) flush();
34
+ current.push(piece);
35
+ length += piece.length + 1;
36
+ if (/^\s*```/.test(piece)) fence = fence === null ? piece.trim() : null;
37
+ if (line.length <= size) break;
38
+ line = line.slice(size);
39
+ }
40
+ }
41
+ if (current.length) {
42
+ parts.push(current.join('\n'));
43
+ }
44
+ return parts.length ? parts : [''];
45
+ }
46
+
47
+ function card(markdown) {
48
+ return JSON.stringify({ schema: '2.0', body: { elements: [{ tag: 'markdown', content: markdown }] } });
49
+ }
50
+
51
+ // A markdown card that can be edited after it is sent (updateCard needs update_multi
52
+ // on the card both before and after).
53
+ export function textCard(markdown, title, template) {
54
+ return { schema: '2.0', config: { update_multi: true }, header: { title: { tag: 'plain_text', content: title }, template }, body: { elements: [{ tag: 'markdown', content: markdown }] } };
55
+ }
56
+
57
+ export async function updateCard(client, messageId, cardJson) {
58
+ const res = await client.im.v1.message.patch({ path: { message_id: messageId }, data: { content: JSON.stringify(cardJson) } });
59
+ if (res?.code) throw new Error(`feishu message.patch ${res.code}: ${res.msg}`);
60
+ }
61
+
62
+ async function createMessage(client, receiveIdType, receiveId, msgType, content) {
63
+ const res = await client.im.v1.message.create({
64
+ params: { receive_id_type: receiveIdType },
65
+ data: { receive_id: receiveId, msg_type: msgType, content },
66
+ });
67
+ if (res?.code) throw new Error(`feishu message.create ${res.code}: ${res.msg}`);
68
+ return res;
69
+ }
70
+
71
+ // A markdown card per chunk; plain text when the card is refused. Returns the id of the
72
+ // last message sent.
73
+ export async function sendMarkdown(client, chatId, text) {
74
+ let last = null;
75
+ for (const part of chunk(text || '(empty)')) {
76
+ try {
77
+ last = await createMessage(client, 'chat_id', chatId, 'interactive', card(part));
78
+ } catch (err) {
79
+ last = await createMessage(client, 'chat_id', chatId, 'text', JSON.stringify({ text: part }));
80
+ console.warn(`[feishu] card refused, sent as text: ${err.message}`);
81
+ }
82
+ }
83
+ return last?.data?.message_id || null;
84
+ }
85
+
86
+ // A one-line reply that @-mentions someone. Feishu notifies a mention in a text message
87
+ // even in a muted group; a mention inside a card is not counted as one.
88
+ export async function replyMention(client, messageId, openId, text) {
89
+ const res = await client.im.v1.message.reply({ path: { message_id: messageId }, data: { msg_type: 'text', content: JSON.stringify({ text: `<at user_id="${openId}"></at> ${text}` }) } });
90
+ if (res?.code) throw new Error(`feishu message.reply ${res.code}: ${res.msg}`);
91
+ }
92
+
93
+ // A card that asks you to pick: the screen, one button per option and, when the program
94
+ // takes your own words, a box to type them. Every button and the box send a callback
95
+ // (card.action.trigger) whose value is `value` plus {pick: <option number>, label} (or
96
+ // {pick: 'tab'} / {pick: 'esc'}); the box's text comes back as form_value.answer.
97
+ const BUTTON_TEXT = 80;
98
+
99
+ function button(text, value, type = 'default') {
100
+ const short = text.length > BUTTON_TEXT ? `${text.slice(0, BUTTON_TEXT - 1)}…` : text;
101
+ return { tag: 'button', type, width: 'fill', text: { tag: 'plain_text', content: short }, behaviors: [{ type: 'callback', value }] };
102
+ }
103
+
104
+ export function menuCard({ title, markdown, menu, value }) {
105
+ const elements = [{ tag: 'markdown', content: markdown }];
106
+ for (const o of menu.options) {
107
+ if (o === menu.free) continue;
108
+ const box = o.checked === null ? '' : o.checked ? '☑ ' : '☐ ';
109
+ elements.push(button(`${o.n}. ${box}${o.label}`, { ...value, pick: o.n, label: o.label }, o.n === 1 && !menu.multi ? 'primary' : 'default'));
110
+ }
111
+ if (menu.multi) elements.push(button('Next ⇥', { ...value, pick: 'tab' }, 'primary'));
112
+ if (menu.free) {
113
+ elements.push({
114
+ tag: 'form',
115
+ name: 'free',
116
+ elements: [
117
+ { tag: 'input', name: 'answer', required: true, placeholder: { tag: 'plain_text', content: menu.free.label } },
118
+ { ...button('Send', { ...value, pick: menu.free.n, label: menu.free.label }, 'primary'), name: 'send', form_action_type: 'submit' },
119
+ ],
120
+ });
121
+ }
122
+ elements.push(button('Esc (cancel)', { ...value, pick: 'esc' }, 'danger'));
123
+ return { schema: '2.0', config: { update_multi: true }, header: { title: { tag: 'plain_text', content: title }, template: 'red' }, body: { elements } };
124
+ }
125
+
126
+ // The card once it is answered: the same text, no buttons, and what was done.
127
+ export function doneCard(title, markdown, done) {
128
+ return { schema: '2.0', config: { update_multi: true }, header: { title: { tag: 'plain_text', content: title }, template: 'grey' }, body: { elements: [{ tag: 'markdown', content: `${markdown}\n\n**${done}**` }] } };
129
+ }
130
+
131
+ // Takes back a message the bot sent.
132
+ export async function recallMessage(client, messageId) {
133
+ const res = await client.im.v1.message.delete({ path: { message_id: messageId } });
134
+ if (res?.code) throw new Error(`feishu message.delete ${res.code}: ${res.msg}`);
135
+ }
136
+
137
+ export async function sendCard(client, chatId, cardJson, idType = 'chat_id') {
138
+ return createMessage(client, idType, chatId, 'interactive', JSON.stringify(cardJson));
139
+ }
140
+
141
+ // An image for a message (shown in a card's markdown as ![image](<image_key>)) or a group
142
+ // avatar (type 'avatar'). Needs the im:resource scope.
143
+ export async function uploadImage(client, buffer, type = 'message') {
144
+ const res = await client.im.v1.image.create({ data: { image_type: type, image: buffer } });
145
+ const key = res?.image_key || res?.data?.image_key;
146
+ if (!key) throw new Error(`feishu image.create: no image_key (${JSON.stringify(res).slice(0, 200)})`);
147
+ return key;
148
+ }
149
+
150
+ // An image someone sent in a chat the bot is in, saved to `file`. Needs im:message:readonly.
151
+ export async function downloadImage(client, messageId, imageKey, file) {
152
+ const res = await client.im.v1.messageResource.get({ path: { message_id: messageId, file_key: imageKey }, params: { type: 'image' } });
153
+ await res.writeFile(file);
154
+ }
155
+
156
+ // What a received message says: its text and the keys of its images, in order, for
157
+ // text, image and rich text (post) messages; null for any other kind.
158
+ export function messageParts(messageType, content) {
159
+ const c = JSON.parse(content);
160
+ if (messageType === 'text') return { text: c.text || '', images: [] };
161
+ if (messageType === 'image') return { text: '', images: [c.image_key] };
162
+ if (messageType !== 'post') return null;
163
+ const lines = [];
164
+ const images = [];
165
+ if (c.title) lines.push(c.title);
166
+ for (const paragraph of c.content || []) {
167
+ let line = '';
168
+ for (const el of paragraph) {
169
+ if (el.tag === 'img' && el.image_key) images.push(el.image_key);
170
+ else if (el.tag === 'a' && el.href) line += `${el.text || ''} (${el.href})`;
171
+ else if (typeof el.text === 'string') line += el.text; // text, code_block, md, ...
172
+ }
173
+ lines.push(line);
174
+ }
175
+ return { text: lines.join('\n').trim(), images };
176
+ }
177
+
178
+ // One group per terminal, with you in it. You can type in it: that text goes to the terminal.
179
+ export async function createChat(client, ownerOpenId, name, description) {
180
+ const res = await client.im.v1.chat.create({
181
+ params: { user_id_type: 'open_id' },
182
+ data: { name: name.slice(0, 60), description, user_id_list: [ownerOpenId], group_message_type: 'chat' },
183
+ });
184
+ if (res?.code) throw new Error(`feishu chat.create ${res.code}: ${res.msg}`);
185
+ return res.data.chat_id;
186
+ }
187
+
188
+ // Group tags (enterprise custom tags): one tenant tag, bound to a group. Needs
189
+ // im:tag:write and im:biz_entity_tag_relation:write.
190
+ // Feishu refuses a tag name over 16 characters (code 407, found by trying); a longer
191
+ // one is cut at the last "-", "_" or "." that fits: build-agent-server -> build-agent.
192
+ const TAG_MAX = 16;
193
+
194
+ export function tagName(name) {
195
+ if (name.length <= TAG_MAX) return name;
196
+ const cut = Math.max(...['-', '_', '.'].map((c) => name.lastIndexOf(c, TAG_MAX)));
197
+ return cut > 0 ? name.slice(0, cut) : name.slice(0, TAG_MAX);
198
+ }
199
+
200
+ export async function createTag(client, name) {
201
+ const res = await client.im.v2.tag.create({ data: { create_tag: { tag_type: 'tenant', name: tagName(name) } } });
202
+ if (res?.code || !res?.data?.id) throw new Error(`feishu tag.create ${res?.code}: ${res?.msg}`);
203
+ return res.data.id;
204
+ }
205
+
206
+ // Tag calls are limited to 5 a second: they go out one at a time, TAG_GAP_MS apart, so a
207
+ // burst (every group at once) is not refused.
208
+ const TAG_GAP_MS = 250;
209
+ let tagQueue = Promise.resolve();
210
+ function queued(fn) {
211
+ const run = tagQueue.then(fn);
212
+ tagQueue = run.catch(() => {}).then(() => new Promise((ok) => setTimeout(ok, TAG_GAP_MS)));
213
+ return run;
214
+ }
215
+
216
+ export function bindTag(client, tagId, chatId) {
217
+ return queued(async () => {
218
+ const res = await client.im.v2.bizEntityTagRelation.create({ data: { tag_biz_type: 'chat', biz_entity_id: chatId, tag_ids: [tagId] } });
219
+ if (res?.code) throw new Error(`feishu tag bind ${res.code}: ${res.msg}`);
220
+ });
221
+ }
222
+
223
+ export function unbindTag(client, tagId, chatId) {
224
+ return queued(async () => {
225
+ const res = await client.im.v2.bizEntityTagRelation.update({ data: { tag_biz_type: 'chat', biz_entity_id: chatId, tag_ids: [tagId] } });
226
+ if (res?.code) throw new Error(`feishu tag unbind ${res.code}: ${res.msg}`);
227
+ });
228
+ }
229
+
230
+ export async function setChatAvatar(client, chatId, imageKey) {
231
+ const res = await client.im.v1.chat.update({ path: { chat_id: chatId }, data: { avatar: imageKey } });
232
+ if (res?.code) throw new Error(`feishu chat.update avatar ${res.code}: ${res.msg}`);
233
+ }
234
+
235
+ export async function renameChat(client, chatId, name) {
236
+ const res = await client.im.v1.chat.update({ path: { chat_id: chatId }, data: { name: name.slice(0, 60) } });
237
+ if (res?.code) throw new Error(`feishu chat.update ${res.code}: ${res.msg}`);
238
+ }
239
+
240
+ // Messages in (im.message.receive_v1) and card clicks (card.action.trigger), both over
241
+ // the long connection. A click must be answered within 3 seconds; what `onCardAction`
242
+ // returns ({toast, card}) is that answer.
243
+ export function startEvents(cfg, onMessage, onCardAction) {
244
+ const ws = new Lark.WSClient({
245
+ appId: cfg.appId,
246
+ appSecret: cfg.appSecret,
247
+ loggerLevel: Lark.LoggerLevel.warn,
248
+ onReady: () => console.log('[feishu] connected'),
249
+ onReconnecting: () => console.warn('[feishu] reconnecting'),
250
+ onReconnected: () => console.log('[feishu] reconnected'),
251
+ onError: (err) => console.error(`[feishu] connection failed: ${err.message}`),
252
+ });
253
+ ws.start({
254
+ eventDispatcher: new Lark.EventDispatcher({}).register({
255
+ 'im.message.receive_v1': async (data) => {
256
+ try {
257
+ await onMessage(data);
258
+ } catch (err) {
259
+ console.error(`[feishu] message handler: ${err.stack || err.message}`);
260
+ }
261
+ },
262
+ 'card.action.trigger': async (data) => {
263
+ try {
264
+ return await onCardAction(data);
265
+ } catch (err) {
266
+ console.error(`[feishu] card handler: ${err.stack || err.message}`);
267
+ return { toast: { type: 'error', content: err.message.slice(0, 200) } };
268
+ }
269
+ },
270
+ }),
271
+ });
272
+ return ws;
273
+ }
package/lib/hc.mjs ADDED
@@ -0,0 +1,84 @@
1
+ // Thin wrapper over the hc CLI. Every call names its session (HC_SESSION),
2
+ // never the default core.
3
+ import { execFile } from 'node:child_process';
4
+
5
+ // The bot runs in an hc terminal itself. Called with that terminal's identity, hc would
6
+ // wrap what the bot types in <hc from="tN"> tags, as a note from another agent; without
7
+ // it the text arrives as if you typed it.
8
+ const { HC_TERMINAL_ID, HC_SCOPE, HC_TERMINAL_SOCKET, ...ENV } = process.env;
9
+
10
+ export function hc(session, args, { timeoutMs = 15000 } = {}) {
11
+ return new Promise((resolve, reject) => {
12
+ execFile(
13
+ 'hc',
14
+ args,
15
+ { env: { ...ENV, HC_SESSION: session }, timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024 },
16
+ (err, stdout, stderr) => {
17
+ if (err) {
18
+ err.message = `hc ${args.join(' ')}: ${(stderr || stdout || err.message).trim()}`;
19
+ reject(err);
20
+ return;
21
+ }
22
+ resolve(stdout);
23
+ },
24
+ );
25
+ });
26
+ }
27
+
28
+ async function json(session, args) {
29
+ return JSON.parse(await hc(session, [...args, '--json']));
30
+ }
31
+
32
+ // The machines a session is connected to: [{name, state}], state "connected" when up.
33
+ export async function machines(session) {
34
+ return (await json(session, ['ls'])).machines || [];
35
+ }
36
+
37
+ export async function getTerminal(session, address) {
38
+ return json(session, ['get', address]);
39
+ }
40
+
41
+ // `where` is "<space> / <tab>", set by herdr-app on every terminal it shows.
42
+ export function splitWhere(where) {
43
+ const i = (where || '').indexOf(' / ');
44
+ if (i < 0) return null;
45
+ return { space: where.slice(0, i), tab: where.slice(i + 3) };
46
+ }
47
+
48
+ export function run(session, { name, cwd, labels = {}, argv }) {
49
+ const args = ['run', '--bg', '--no-parent', '--json', ...(name ? ['--name', name] : []), '--cwd', cwd];
50
+ for (const [k, v] of Object.entries(labels)) args.push('--label', `${k}=${v}`);
51
+ args.push('--', ...argv);
52
+ return hc(session, args, { timeoutMs: 30000 });
53
+ }
54
+
55
+ // force: type even when the program is blocked on a dialog (it answers the dialog).
56
+ export function send(session, address, text, { force = false } = {}) {
57
+ const args = ['send', ...(force ? ['--force'] : []), address, text];
58
+ return hc(session, args, { timeoutMs: 60000 });
59
+ }
60
+
61
+ // Sends a local file beside a terminal, on its machine; returns where it landed there.
62
+ export async function put(session, address, file) {
63
+ return JSON.parse(await hc(session, ['put', address, file, '--json'], { timeoutMs: 60000 })).path;
64
+ }
65
+
66
+ export function keys(session, address, names) {
67
+ return hc(session, ['keys', address, ...names]);
68
+ }
69
+
70
+ function read(session, address, lines = 0) {
71
+ return hc(session, ['read', address, '--lines', String(lines)]);
72
+ }
73
+
74
+ // The last `lines` lines of a terminal's screen: the default output for a program with no reader.
75
+ export async function screenTail(session, address, lines) {
76
+ const out = await read(session, address, lines);
77
+ return out.split('\n').slice(-lines).join('\n').trimEnd();
78
+ }
79
+
80
+ // "machine/tN" for a terminal on a connected machine, "tN" here.
81
+ export function machineOf(address) {
82
+ const i = address.lastIndexOf('/');
83
+ return i < 0 ? null : address.slice(0, i);
84
+ }
package/lib/home.mjs ADDED
@@ -0,0 +1,26 @@
1
+ // Files under the home folder of the machine a terminal runs on: read here, or through
2
+ // `hc exec --on <machine>` for a terminal on a connected machine (cwd there is home).
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { hc } from './hc.mjs';
7
+ import { itemsAfter } from './cursor.mjs';
8
+
9
+ const TAIL_LINES = 1500;
10
+
11
+ export async function homeFile(session, machine, rel, { tail } = {}) {
12
+ if (!machine) {
13
+ const text = fs.readFileSync(path.join(os.homedir(), rel), 'utf8');
14
+ return tail ? text.split('\n').slice(-tail).join('\n') : text;
15
+ }
16
+ const argv = tail ? ['tail', '-n', String(tail), rel] : ['cat', rel];
17
+ return hc(session, ['exec', '--on', machine, ...argv], { timeoutMs: 30000 });
18
+ }
19
+
20
+ // The items after `cursor` in a .jsonl log: its tail first, the whole file when the tail
21
+ // does not reach back to the cursor.
22
+ export async function logAfter(session, machine, rel, cursor, entryItems) {
23
+ const tail = itemsAfter((await homeFile(session, machine, rel, { tail: TAIL_LINES })).split('\n'), cursor, entryItems);
24
+ if (tail.complete) return tail.items;
25
+ return itemsAfter((await homeFile(session, machine, rel)).split('\n'), cursor, entryItems).items;
26
+ }
package/lib/log.mjs ADDED
@@ -0,0 +1,59 @@
1
+ // The bot's log, kept on disk so a problem can be looked into after a restart: every line
2
+ // goes to stdout and to logs/<YYYY-MM-DD>.log in the state folder (KEEP_DAYS kept), as
3
+ // "<time> <LEVEL> <text>". `capture()` sends console output (the SDK, the event stream)
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.
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import util from 'node:util';
9
+
10
+ const KEEP_DAYS = 14;
11
+
12
+ export function createLog(dir) {
13
+ fs.mkdirSync(dir, { recursive: true });
14
+ const out = { log: console.log.bind(console), error: console.error.bind(console) };
15
+ let pruned = '';
16
+
17
+ const prune = (today) => {
18
+ if (pruned === today) return;
19
+ pruned = today;
20
+ const oldest = new Date(Date.now() - KEEP_DAYS * 86400 * 1000).toISOString().slice(0, 10);
21
+ for (const f of fs.readdirSync(dir)) {
22
+ if (/^\d{4}-\d{2}-\d{2}\.log$/.test(f) && f.slice(0, 10) < oldest) fs.rmSync(path.join(dir, f), { force: true });
23
+ }
24
+ };
25
+
26
+ const write = (level, args) => {
27
+ const now = new Date().toISOString();
28
+ // one entry per line: a newline in a message (your text) shows as ⏎; a stack keeps its lines
29
+ const text = args.map((a) => (typeof a === 'string' ? a.replace(/\r?\n/g, ' ⏎ ') : a instanceof Error ? a.stack || a.message : util.inspect(a, { depth: 4, breakLength: Infinity }))).join(' ');
30
+ const line = `${now} ${level} ${text}`;
31
+ (level === 'ERROR' ? out.error : out.log)(line);
32
+ try {
33
+ fs.appendFileSync(path.join(dir, `${now.slice(0, 10)}.log`), `${line}\n`, { mode: 0o600 }); // it quotes your messages
34
+ prune(now.slice(0, 10));
35
+ } catch {
36
+ // the disk is the last resort; stdout has the line
37
+ }
38
+ };
39
+
40
+ const log = {
41
+ info: (...args) => write('INFO', args),
42
+ warn: (...args) => write('WARN', args),
43
+ error: (...args) => write('ERROR', args),
44
+ };
45
+
46
+ log.capture = () => {
47
+ console.log = (...args) => write('INFO', args);
48
+ console.info = console.log;
49
+ console.warn = (...args) => write('WARN', args);
50
+ console.error = (...args) => write('ERROR', args);
51
+ process.on('uncaughtException', (err) => {
52
+ write('ERROR', ['uncaught:', err]);
53
+ process.exit(1);
54
+ });
55
+ process.on('unhandledRejection', (err) => write('ERROR', ['unhandled rejection:', err]));
56
+ };
57
+
58
+ return log;
59
+ }
package/lib/menu.mjs ADDED
@@ -0,0 +1,61 @@
1
+ // The menu a blocked program shows on its screen, as Claude Code draws its permission
2
+ // and question dialogs:
3
+ // ❯ 1. Yes a digit key picks an option
4
+ // 2. [✔] Red in a multi-select, a digit toggles it; tab goes on
5
+ // 3. Type something. picking it lets you type your own answer
6
+ // The last run of options numbered 1, 2, 3, ... on the screen is the menu; lines between
7
+ // them (an option's description) are skipped. null when there is none.
8
+ //
9
+ // `text` is what the dialog says above its options: from its top border (a line of ─),
10
+ // without rules and blank lines; what was on the screen above the dialog is left out.
11
+ // `key` names the dialog: a hash of it from its top border to its last option, without
12
+ // the cursor and the check marks. A card click is pressed only when the
13
+ // screen still shows the dialog with that key, so an old card can never answer a newer
14
+ // dialog that looks alike (two permission prompts both offer "1. Yes").
15
+ import crypto from 'node:crypto';
16
+
17
+ const OPTION = /^\s*(?:❯\s*)?(\d{1,2})\.\s+(?:\[(.)\]\s+)?(.*\S)\s*$/;
18
+ const RULE = /^[\s─╌━-]*$/;
19
+ const BORDER = /^\s*─{10,}/;
20
+ const FREE = /^Type something/i;
21
+
22
+ const TEXT_LINES = 15; // a dialog with no border found: this many lines above its options
23
+
24
+ function topOf(lines, first) {
25
+ let top = first;
26
+ while (top > 0 && !BORDER.test(lines[top])) top -= 1;
27
+ return BORDER.test(lines[top]) ? top : Math.max(0, first - TEXT_LINES);
28
+ }
29
+
30
+ function keyOf(lines, top, last) {
31
+ const body = lines.slice(top, last + 1).map((l) => l.replace(/❯/g, '').replace(/^(\s*\d{1,2}\.\s+)\[.\]/, '$1').trim()).join('\n');
32
+ return crypto.createHash('sha1').update(body).digest('hex').slice(0, 12);
33
+ }
34
+
35
+ export function menuOf(screen) {
36
+ const lines = screen.split('\n');
37
+ let run = null;
38
+ let menu = null;
39
+ lines.forEach((line, i) => {
40
+ const m = line.match(OPTION);
41
+ if (!m) return;
42
+ const n = Number(m[1]);
43
+ if (n === 1) run = { first: i, options: [] };
44
+ else if (!run || n !== run.options.length + 1) {
45
+ run = null;
46
+ return;
47
+ }
48
+ run.options.push({ n, label: m[3], checked: m[2] === undefined ? null : m[2] !== ' ' });
49
+ run.last = i;
50
+ menu = run;
51
+ });
52
+ if (!menu) return null;
53
+ const top = topOf(lines, menu.first);
54
+ return {
55
+ key: keyOf(lines, top, menu.last),
56
+ text: lines.slice(top, menu.first).filter((l) => !RULE.test(l)).map((l) => l.trim()).join('\n'),
57
+ options: menu.options,
58
+ multi: menu.options.some((o) => o.checked !== null),
59
+ free: menu.options.find((o) => FREE.test(o.label)) || null,
60
+ };
61
+ }
@@ -0,0 +1,42 @@
1
+ // The overview: one card in the direct chat with the bot, as herdr-app's sidebar shows it.
2
+ // Every terminal is a line: its state, its group (a link that opens it), what it waits on
3
+ // and since when. What waits for you comes first. The card is edited in place, so it
4
+ // never rings.
5
+ const STATES = {
6
+ blocked: { icon: '⚠️', word: 'needs you', rank: 0 },
7
+ error: { icon: '🔴', word: 'error', rank: 1 },
8
+ working: { icon: '⌛️', word: 'working', rank: 2 },
9
+ done: { icon: '✅', word: 'done', rank: 3 },
10
+ idle: { icon: '❓', word: 'idle', rank: 4 },
11
+ };
12
+ const OTHER = { icon: '❓', word: '', rank: 5 };
13
+
14
+ const chatLink = (chat) => `https://applink.feishu.cn/client/chat/open?openChatId=${chat}`;
15
+ const clock = (ms) => new Date(ms).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
16
+ const escape = (text) => text.replace(/[\\`*_[\]#>~|]/g, '\\$&');
17
+
18
+ // rows: [{name, address, state, kind, program, since, chat}]; state is the hc status
19
+ // word, or none. The address is what `/kill` takes.
20
+ export function overviewText(rows) {
21
+ const sorted = [...rows].sort((a, b) => (STATES[a.state] || OTHER).rank - (STATES[b.state] || OTHER).rank || a.name.localeCompare(b.name));
22
+ if (!sorted.length) return 'No terminal is shown in herdr-app.';
23
+ return sorted.map((r) => {
24
+ const s = STATES[r.state] || OTHER;
25
+ const name = r.chat ? `[${escape(r.name)}](${chatLink(r.chat)})` : escape(r.name);
26
+ const word = s === OTHER ? r.program || 'no status' : `${s.word}${r.state === 'blocked' && r.kind ? ` (${r.kind})` : ''}`;
27
+ return `${s.icon} **${name}** · ${escape(word)}${r.since ? ` · since ${clock(r.since)}` : ''} · \`${r.address}\``;
28
+ }).join('\n');
29
+ }
30
+
31
+ export function overviewCard(rows, now) {
32
+ const waiting = rows.filter((r) => r.state === 'blocked').length;
33
+ const working = rows.filter((r) => r.state === 'working').length;
34
+ const template = waiting ? 'orange' : working ? 'blue' : 'green';
35
+ const title = `${waiting ? `⚠️ ${waiting} needs you · ` : ''}herdrchat · ${rows.length} terminals`;
36
+ return {
37
+ schema: '2.0',
38
+ config: { update_multi: true },
39
+ header: { title: { tag: 'plain_text', content: title }, template },
40
+ body: { elements: [{ tag: 'markdown', content: `${overviewText(rows)}\n\n<font color='grey'>Updated ${clock(now)}</font>` }] },
41
+ };
42
+ }
@@ -0,0 +1,119 @@
1
+ // Reader for Claude Code (hc status app "claude-code"): its own transcript.
2
+ // Where: the terminal's foreground pid names the session in ~/.claude/sessions/<pid>.json
3
+ // ({sessionId, cwd}); the transcript is
4
+ // ~/.claude/projects/<cwd with every non-alphanumeric as "-">/<sessionId>.jsonl.
5
+ // What becomes an item:
6
+ // you a prompt you typed (user entry from a human), a message you sent mid-turn
7
+ // (attachment "queued_command" from a human; the queue also carries Claude
8
+ // Code's own task notifications, which are left out), or your answers to its
9
+ // questions (the AskUserQuestion result's `answers`)
10
+ // ai the assistant's text; `end` marks the turn's last answer (stop_reason
11
+ // "end_turn"). Some of what it says while it works is stored only as a
12
+ // "narration" block: a thinking block whose signature names narration, holding a
13
+ // condensed version of that text. Those count as text; plain thinking does not.
14
+ // tool a tool it called, as one line
15
+ // image an image you pasted (`from: 'you'`) or one a tool showed it (`from: 'ai'`:
16
+ // a screenshot, an image file it read), as {media, data} (base64)
17
+ import { getTerminal, machineOf } from '../hc.mjs';
18
+ import { homeFile, logAfter } from '../home.mjs';
19
+ import { itemsAfter } from '../cursor.mjs';
20
+
21
+ function text(content) {
22
+ if (typeof content === 'string') return content;
23
+ if (!Array.isArray(content) || content.some((b) => b.type === 'tool_result')) return '';
24
+ return content.filter((b) => b.type === 'text').map((b) => b.text).join('\n');
25
+ }
26
+
27
+ function isNarration(block) {
28
+ if (block.type !== 'thinking' || !block.thinking?.trim() || !block.signature) return false;
29
+ try {
30
+ return Buffer.from(block.signature, 'base64').includes('narration');
31
+ } catch {
32
+ return false;
33
+ }
34
+ }
35
+
36
+ function toolLine(block) {
37
+ const input = block.input || {};
38
+ const what = input.description || input.command || input.file_path || input.pattern || input.url || input.prompt || '';
39
+ const short = String(what).replace(/\s+/g, ' ').trim();
40
+ return `▸ ${block.name || 'tool'}${short ? `: ${short.length > 120 ? `${short.slice(0, 117)}...` : short}` : ''}`;
41
+ }
42
+
43
+ // The images in a user entry's content: ones you pasted (top-level blocks) and ones a
44
+ // tool gave the assistant to look at (inside tool results: screenshots, image files).
45
+ function images(content, human) {
46
+ if (!Array.isArray(content)) return [];
47
+ const out = [];
48
+ const add = (b, from) => {
49
+ if (b?.type === 'image' && b.source?.type === 'base64' && b.source.data) out.push({ role: 'image', from, media: b.source.media_type, data: b.source.data });
50
+ };
51
+ for (const b of content) {
52
+ if (human) add(b, 'you');
53
+ if (b.type === 'tool_result' && Array.isArray(b.content)) b.content.forEach((x) => add(x, 'ai'));
54
+ }
55
+ return out;
56
+ }
57
+
58
+ // The items one entry holds, in block order (an entry may hold text and tool calls).
59
+ function entryItems(entry) {
60
+ if (entry.isSidechain) return [];
61
+ if (entry.type === 'user' && !entry.isMeta && entry.origin?.kind === 'human') {
62
+ const t = text(entry.message?.content).trim();
63
+ return [...(t ? [{ role: 'you', text: t }] : []), ...images(entry.message?.content, true)];
64
+ }
65
+ const shown = entry.type === 'user' && !entry.isMeta ? images(entry.message?.content, false) : [];
66
+ if (shown.length) return shown;
67
+ const answers = entry.type === 'user' && entry.toolUseResult?.answers;
68
+ if (answers && typeof answers === 'object') {
69
+ const t = Object.entries(answers).map(([q, a]) => `${q} → ${a}`).join('\n').trim();
70
+ return t ? [{ role: 'you', text: t }] : [];
71
+ }
72
+ const queued = entry.type === 'attachment' && entry.attachment?.type === 'queued_command' && entry.attachment;
73
+ if (queued) {
74
+ const t = queued.origin?.kind === 'human' ? text(queued.prompt).trim() : '';
75
+ return t ? [{ role: 'you', text: t }] : [];
76
+ }
77
+ if (entry.type !== 'assistant' || !Array.isArray(entry.message?.content)) return [];
78
+ const end = entry.message.stop_reason === 'end_turn';
79
+ const out = [];
80
+ for (const b of entry.message.content) {
81
+ const said = b.type === 'text' ? b.text : isNarration(b) ? b.thinking : '';
82
+ if (said && said.trim()) out.push({ role: 'ai', text: said.trim(), end });
83
+ else if (b.type === 'tool_use') out.push({ role: 'tool', text: toolLine(b) });
84
+ }
85
+ return out;
86
+ }
87
+
88
+ export function conversationAfter(lines, cursor) {
89
+ return itemsAfter(lines, cursor, entryItems);
90
+ }
91
+
92
+ export const app = 'claude-code';
93
+
94
+ // What is new since `cursor`: {items, file}, or null when the terminal runs no readable
95
+ // Claude. When the session moved to a new transcript (/clear, resume), what is left in
96
+ // the cursor's old file comes first.
97
+ export async function after(session, address, cursor) {
98
+ const machine = machineOf(address);
99
+ const pid = (await getTerminal(session, address)).foreground?.pid;
100
+ if (!pid) return null;
101
+ const meta = JSON.parse(await homeFile(session, machine, `.claude/sessions/${pid}.json`));
102
+ if (!meta.sessionId || !meta.cwd) return null;
103
+ const file = `.claude/projects/${meta.cwd.replace(/[^A-Za-z0-9]/g, '-')}/${meta.sessionId}.jsonl`;
104
+ const items = [];
105
+ if (cursor.file && cursor.file !== file) {
106
+ try {
107
+ items.push(...(await logAfter(session, machine, cursor.file, cursor, entryItems)));
108
+ } catch {
109
+ // the old transcript is gone; nothing left to read there
110
+ }
111
+ }
112
+ items.push(...(await logAfter(session, machine, file, cursor, entryItems)));
113
+ return { items, file };
114
+ }
115
+
116
+ // What the cursor's file still holds once the program is gone.
117
+ export function rest(session, address, cursor) {
118
+ return logAfter(session, machineOf(address), cursor.file, cursor, entryItems);
119
+ }