herdrchat 0.1.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.
package/bin/herdrchat.mjs CHANGED
@@ -1,1260 +1,77 @@
1
1
  #!/usr/bin/env node
2
- // herdr-app, moved to Feishu. One bot for every hc session in HERDR_SESSIONS (config.env):
3
- // every terminal herdr-app shows (it has a `where` label) -> one group,
4
- // named "<space> / <tab>", tagged with its session; what you type there goes to that terminal
5
- // the direct chat with the bot -> herdr itself: /ls, /new, /kill
6
- // hc is the backbone: its events say which terminals exist, their status and when a
7
- // turn ends (a program left `working`). What the program said comes from its reader
8
- // (lib/readers/: the agent's own records, so your prompts and its answers, posted live
9
- // while it works); a program with no reader gets its screen at each turn's end.
10
- // Only what happens after the bot starts is posted, never older history.
2
+ // herdrchat: every herdr-app tab, mirrored into a chat app. This file only wires the
3
+ // parts together: the config, the log, the chat app (lib/chats/, picked by
4
+ // HERDRCHAT_CHAT) and the core (lib/core/engine.mjs), which does the work.
11
5
  //
12
- // Usage: node bin/herdrchat.mjs
13
- import crypto from 'node:crypto';
14
- import fs from 'node:fs';
15
- import os from 'node:os';
6
+ // Usage: node bin/herdrchat.mjs (`herdrchat start` keeps it running)
16
7
  import path from 'node:path';
17
- import { COLORS, avatarPng, letterOf } from '../lib/avatar.mjs';
18
- import { overviewCard, overviewText } from '../lib/overview.mjs';
19
- import { CONFIG_FILE, STATE_DIR, loadConfig, makeDirs, readState, writeState } from '../lib/config.mjs';
20
- import { advance, startCursor } from '../lib/cursor.mjs';
21
- import { followEvents } from '../lib/events.mjs';
22
- import { bindTag, chunk, createChat, createClient, createTag, recallMessage, replyMention, setChatAvatar, tagName, unbindTag, doneCard, downloadImage, menuCard, messageParts, renameChat, sendCard, sendMarkdown, startEvents, textCard, updateCard, uploadImage } from '../lib/feishu.mjs';
23
- import { getTerminal, hc, keys, machineOf, machines, put, run, screenTail, send, splitWhere } from '../lib/hc.mjs';
24
- import { menuOf } from '../lib/menu.mjs';
25
- import { createLog } from '../lib/log.mjs';
26
- import { readerFor } from '../lib/readers/index.mjs';
27
- import { createUserAuth } from '../lib/userauth.mjs';
28
-
29
- const SHELLS = new Set(['zsh', 'bash', 'sh', 'fish', 'nu', 'dash']);
30
- const SHELL_ECHO_MS = 1500;
31
- const SCREEN_LINES = 40;
32
- const TURN_SCREEN_LINES = 150;
33
- const POST_TRIES = 5;
34
- const SWEEP_MS = 60 * 1000;
35
- const SWEEP_DONE_MS = 30 * 1000;
36
- const LIVE_MS = 5000;
37
- const END_TRIES = 20;
38
- const END_WAIT_MS = 750;
39
- const KEY_SETTLE_MS = 700; // a dialog redraws this soon after a key
40
- const CLICK_BUDGET_MS = 2000; // a card click must be answered within 3 s
41
- const STARTED = new Date().toISOString();
8
+ import { STATE_DIR, chatOf, loadConfig, makeDirs, readState, writeState } from '../lib/core/config.mjs';
9
+ import { createEngine } from '../lib/core/engine.mjs';
10
+ import { followEvents } from '../lib/core/events.mjs';
11
+ import { cores, getTerminal, hc, keys, machines, put, run, screenTail, send } from '../lib/core/hc.mjs';
12
+ import { createLog } from '../lib/core/log.mjs';
13
+ import * as readers from '../lib/core/readers/index.mjs';
14
+ import { chatModule, createChat } from '../lib/chats/index.mjs';
42
15
 
43
16
  makeDirs();
44
- const cfg = loadConfig();
45
- const client = createClient(cfg);
46
- const userAuth = createUserAuth(cfg, STATE_DIR);
47
17
  const logger = createLog(path.join(STATE_DIR, 'logs'));
48
- logger.capture();
49
18
  const log = logger.info;
50
19
  const logWarn = logger.warn;
51
20
  const logError = logger.error;
52
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
53
-
54
- const views = new Map(); // session -> followEvents handle (its live terminal records)
55
-
56
- // A terminal on another machine shows up in every session connected to that machine.
57
- // It belongs to the machine: one group, tagged with the machine, run by the first
58
- // session (in config order) that sees it. A terminal's key in state.json is
59
- // "<session>|tN", or "@<machine>|<machine>/tN" for one on another machine.
60
- function scopeOf(session, address) {
61
- return machineOf(address) || session;
62
- }
63
-
64
- const key = (session, address) => `${machineOf(address) ? '@' : ''}${scopeOf(session, address)}|${address}`;
65
- const scopeOfKey = (k) => k.slice(k.startsWith('@') ? 1 : 0, k.indexOf('|'));
66
- const addressOfKey = (k) => k.slice(k.indexOf('|') + 1);
67
-
68
- function handles(session, address) {
69
- if (!machineOf(address)) return true;
70
- return cfg.sessions.find((s) => views.get(s)?.terminals.get(address)?.state === 'live') === session;
71
- }
72
-
73
- // Work for one terminal runs one at a time, in order (its lane): no races on its group
74
- // or its cursor. Terminals do not wait for each other, so a busy one cannot hold back
75
- // another's dialog card. Nothing runs before every session's first snapshot is in, so a
76
- // terminal on another machine is claimed by the same session every time.
77
- const firstSnapshot = new Map(cfg.sessions.map((s) => {
78
- let done;
79
- const promise = new Promise((r) => { done = r; });
80
- return [s, { promise, done }];
81
- }));
82
- const ready = Promise.all([...firstSnapshot.values()].map((x) => x.promise));
83
- const lanes = new Map(); // terminal key -> its last queued work
84
- function later(session, address, label, fn) {
85
- const k = key(session, address);
86
- const next = (lanes.get(k) || ready).then(fn).catch((err) => logError(`${label} ${address}:`, err));
87
- lanes.set(k, next);
88
- next.then(() => { if (lanes.get(k) === next) lanes.delete(k); });
89
- return next;
90
- }
91
-
92
- // ---------- terminals and their groups ----------
93
-
94
- function shownWhere(record) {
95
- return record && record.state === 'live' ? splitWhere(record.labels?.where) : null;
96
- }
97
-
98
- // A group is named after where herdr-app shows the terminal; the session (or, for a
99
- // terminal on another machine, the machine) is the group's tag. A tab herdr-app only
100
- // numbered ("2") says nothing, so the terminal's title stands in for it (what any
101
- // program sets with the standard title sequence; Claude Code sets its session name or
102
- // topic), then its hc name; leading status glyphs are dropped, so a spinner in a title
103
- // never renames a group.
104
- const TITLE_MAX = 40;
105
-
106
- function tabName(where, record) {
107
- if (!/^\d+$/.test(where.tab)) return where.tab;
108
- const title = String(record.title || '').replace(/^[^\p{L}\p{N}]+/u, '').replace(/\s+/g, ' ').trim();
109
- const name = title || record.name || '';
110
- if (!name) return where.tab;
111
- return name.length > TITLE_MAX ? `${name.slice(0, TITLE_MAX - 1)}…` : name;
112
- }
113
-
114
- function groupName(where, record) {
115
- return `${where.space} / ${tabName(where, record)}`;
116
- }
117
-
118
- // Lanes run side by side: a thing that is made once (a tag, a space's avatar) is made
119
- // by the first lane that needs it; the others wait for that.
120
- const making = new Map();
121
- function once(id, make) {
122
- if (!making.has(id)) making.set(id, make().finally(() => making.delete(id)));
123
- return making.get(id);
124
- }
125
-
126
- // One tenant tag per session or machine, made once, and one "ended" tag; their ids are
127
- // kept in state.json (tags).
128
- const ENDED_TAG = '#ended';
129
-
130
- async function tagOf(id, name = id) {
131
- return readState().tags?.[id] || once(`tag ${id}`, async () => {
132
- const tag = await createTag(client, name);
133
- const state = readState();
134
- state.tags = { ...(state.tags || {}), [id]: tag };
135
- writeState(state);
136
- log(`created tag ${name} (${tag})`);
137
- return tag;
138
- });
139
- }
140
-
141
- // Each space gets an avatar: its first letter on a color of its own (the first color no
142
- // other space has), kept in state.json (spaces), so all of its groups look alike, as in
143
- // herdr-app. An avatar is a nicety: a failure is logged and tried again at the next check.
144
- // The color is picked and saved before the first await, so spaces made side by side
145
- // never pick the same one.
146
- function colorOf(space) {
147
- const state = readState();
148
- const spaces = state.spaces || {};
149
- if (spaces[space]?.color) return spaces[space].color;
150
- const used = Object.values(spaces).map((x) => x.color);
151
- const color = COLORS.find((c) => !used.includes(c)) || COLORS[used.length % COLORS.length];
152
- state.spaces = { ...spaces, [space]: { color } };
153
- writeState(state);
154
- return color;
155
- }
156
-
157
- async function avatarOf(space) {
158
- return readState().spaces?.[space]?.image || once(`avatar ${space}`, async () => {
159
- const color = colorOf(space);
160
- const image = await uploadImage(client, await avatarPng(letterOf(space), color), 'avatar');
161
- const state = readState();
162
- state.spaces = { ...(state.spaces || {}), [space]: { color, image } };
163
- writeState(state);
164
- log(`made avatar for space ${space} (${color})`);
165
- return image;
166
- });
167
- }
168
-
169
- async function avatarChat(k, chat, space) {
170
- try {
171
- const image = await avatarOf(space);
172
- if (readState().chats[k]?.avatar === image) return;
173
- await setChatAvatar(client, chat, image);
174
- const state = readState();
175
- if (state.chats[k]) state.chats[k] = { ...state.chats[k], avatar: image };
176
- writeState(state);
177
- } catch (err) {
178
- logError(`avatar ${space} on ${chat}: ${err.message}`);
179
- }
180
- }
181
-
182
- // Bot tags (HERDR_BOT_TAGS): the session or machine tag next to a group's name and, once
183
- // its terminal is gone, "ended". They are tenant tags, so they can be turned off; off takes
184
- // off the ones already on. A tag is a nicety: a failure is logged and tried again at the
185
- // next check, and never holds back a post.
186
- async function syncBotTags(k) {
187
- const c = readState().chats[k];
188
- if (!c) return;
189
- try {
190
- if (cfg.botTags) {
191
- const tag = await tagOf(scopeOfKey(k));
192
- if (c.tag !== tag) {
193
- await bindTag(client, tag, c.chat);
194
- setChat(k, { tag });
195
- }
196
- if (c.ended !== Boolean(c.endedTag)) {
197
- const ended = await tagOf(ENDED_TAG, 'ended');
198
- await (c.ended ? bindTag : unbindTag)(client, ended, c.chat);
199
- setChat(k, { endedTag: Boolean(c.ended) });
200
- }
201
- } else {
202
- const tags = readState().tags || {};
203
- if (c.tag) {
204
- await unbindTag(client, c.tag, c.chat).catch(() => {});
205
- setChat(k, { tag: null });
206
- }
207
- if (c.endedTag && tags[ENDED_TAG]) {
208
- await unbindTag(client, tags[ENDED_TAG], c.chat).catch(() => {});
209
- setChat(k, { endedTag: false });
210
- }
211
- }
212
- } catch (err) {
213
- logError(`bot tags on ${c.name}: ${err.message}`);
214
- }
215
- }
216
-
217
- // ---------- acting as you (optional, after /auth) ----------
218
-
219
- // With your token (lib/userauth.mjs) the bot does what only you can: mutes its groups,
220
- // puts each in one of your own labels (the session or machine; "ended" once the terminal
221
- // is gone) besides its bot tag, pins the group that needs you, and @-mentions you in a
222
- // one-line reply under a card that needs you and under the turn's last answer, since in a
223
- // muted group only a mention rings. Bot tags stay either way: they show next to a
224
- // group's name, your labels only in the sidebar. Without the token none of this happens
225
- // and the bot works as before. Whether it may act as you is checked every YOU_CHECK_MS;
226
- // when that turns on, every group is brought in line; when it turns off, you are told once.
227
- const YOU_CHECK_MS = 60 * 1000;
228
- const ENDED_LABEL = 'ended';
229
- let you = false;
230
- let youCheckedAt = 0;
231
-
232
- function setChat(k, fields) {
233
- const state = readState();
234
- if (state.chats[k]) state.chats[k] = { ...state.chats[k], ...fields };
235
- writeState(state);
236
- }
237
-
238
- async function asYou() {
239
- if (Date.now() - youCheckedAt < YOU_CHECK_MS) return you;
240
- youCheckedAt = Date.now();
241
- const was = you;
242
- you = await userAuth.allowed();
243
- if (you && !was) {
244
- log('acting as you: on');
245
- setTimeout(() => alignAll(), 0);
246
- } else if (!you && was) {
247
- logWarn('acting as you: off');
248
- await sendCard(client, cfg.ownerOpenId, textCard('The bot can no longer act as you (mute, labels, pin). Send `/auth` to allow it again.', '⚠️ Needs you', 'red'), 'open_id').catch(() => {});
249
- }
250
- return you;
251
- }
252
-
253
- async function labelOf(name) {
254
- return readState().labels?.[name] || once(`label ${name}`, async () => {
255
- const found = (await userAuth.feedGroups())?.groups?.find((g) => g.name === name);
256
- const id = found?.group_id || (await userAuth.createFeedGroup(name))?.group_id;
257
- if (!id) throw new Error(`no label ${name}`);
258
- const state = readState();
259
- state.labels = { ...(state.labels || {}), [name]: id };
260
- writeState(state);
261
- log(`label ${name} (${id})`);
262
- return id;
263
- });
264
- }
265
-
266
- // A label you deleted is made again at the next try.
267
- async function intoLabel(name, chat) {
268
- try {
269
- await userAuth.addToFeedGroup(await labelOf(name), [chat]);
270
- } catch (err) {
271
- const state = readState();
272
- if (state.labels) delete state.labels[name];
273
- writeState(state);
274
- throw err;
275
- }
276
- }
277
-
278
- // Your label (HERDR_USER_TAGS, acting as you): the session or machine, or "ended" once the
279
- // terminal is gone; off takes the group out of it (the label itself stays: it is yours).
280
- async function syncUserLabel(k) {
281
- if (!(await asYou())) return;
282
- const c = readState().chats[k];
283
- if (!c) return;
284
- const want = cfg.userTags ? (c.ended ? ENDED_LABEL : tagName(scopeOfKey(k))) : null;
285
- if ((c.label || null) === want) return;
286
- try {
287
- if (c.label) await userAuth.removeFromFeedGroup(await labelOf(c.label), [c.chat]).catch(() => {});
288
- if (want) await intoLabel(want, c.chat);
289
- setChat(k, { label: want });
290
- } catch (err) {
291
- logError(`as you, label of ${c.name}: ${err.message}`);
292
- }
293
- }
294
-
295
- // HERDR_MUTED_SPACES says which groups are muted (all, none, or some spaces); a change in
296
- // config.env is applied both ways at the next start.
297
- const inSpaces = (set, space) => set.all || set.spaces.includes(space);
298
-
299
- async function muteChat(k, chat, space) {
300
- const want = inSpaces(cfg.muted, space);
301
- if (Boolean(readState().chats[k]?.muted) === want) return;
302
- await userAuth.setMuted([chat], want);
303
- setChat(k, { muted: want });
304
- }
305
-
306
- // Puts a live group where it belongs: its bot tag and, acting as you, muted and in your
307
- // label. Cheap when nothing is left to do.
308
- async function placeChat(k, chat, space) {
309
- await syncBotTags(k);
310
- if (!(await asYou())) return;
311
- await muteChat(k, chat, space).catch((err) => logError(`as you, mute ${chat}: ${err.message}`));
312
- await syncUserLabel(k);
313
- }
314
-
315
- // Everything at once, when acting as you turns on: live groups to their labels, ended
316
- // ones to "ended", bot tags off.
317
- function alignAll() {
318
- for (const [k, c] of Object.entries(readState().chats)) {
319
- if (!cfg.sessions.includes(c.session)) continue;
320
- later(c.session, addressOfKey(k), 'as you', () => (c.ended ? endChat(k, { again: true }) : placeChat(k, c.chat, c.name.split(' / ')[0])));
321
- }
322
- }
323
-
324
- // The group that needs you is pinned while it waits, and unpinned when it no longer does.
325
- async function pinChat(k, chat) {
326
- if (!(await asYou())) return;
327
- try {
328
- await userAuth.pin(chat);
329
- const state = readState();
330
- state.pinned = { ...(state.pinned || {}), [k]: chat };
331
- writeState(state);
332
- } catch (err) {
333
- logError(`pin ${chat}: ${err.message}`);
334
- }
335
- }
336
-
337
- async function unpinChat(k) {
338
- const chat = readState().pinned?.[k];
339
- if (!chat) return;
340
- try {
341
- await userAuth.unpin(chat);
342
- } catch (err) {
343
- logError(`unpin ${chat}: ${err.message}`);
344
- }
345
- const state = readState();
346
- delete state.pinned[k];
347
- writeState(state);
348
- }
349
-
350
- // In a muted group only a mention rings; a group that is not muted rings anyway. A space
351
- // in HERDR_SILENT_SPACES gets no mention at all.
352
- async function mentionYou(k, messageId, text) {
353
- const c = readState().chats[k];
354
- if (!messageId || !c?.muted || inSpaces(cfg.silent, c.name.split(' / ')[0]) || !(await asYou())) return;
355
- await replyMention(client, messageId, cfg.ownerOpenId, text).catch((err) => logError(`mention: ${err.message}`));
356
- }
357
-
358
- function chatOf(session, address) {
359
- return readState().chats[key(session, address)];
360
- }
361
-
362
- function terminalOfChat(chatId) {
363
- for (const [k, v] of Object.entries(readState().chats)) {
364
- if (v.chat === chatId) return { session: v.session, address: addressOfKey(k) };
365
- }
366
- return null;
367
- }
368
-
369
- async function ensureChat(session, record) {
370
- const where = shownWhere(record);
371
- if (!where || !cfg.ownerOpenId || !handles(session, record.address)) return null;
372
- const k = key(session, record.address);
373
- const name = groupName(where, record);
374
- // state.json is read again after each await: other terminals' lanes write it meanwhile
375
- const known = readState().chats[k];
376
- if (known) {
377
- if (known.ended) await reviveChat(k, known);
378
- if (known.name !== name || known.session !== session) {
379
- if (known.name !== name) await renameChat(client, known.chat, name);
380
- const state = readState();
381
- state.chats[k] = { ...state.chats[k], name, session };
382
- writeState(state);
383
- log(`renamed group ${known.chat} -> ${name}`);
384
- }
385
- await placeChat(k, known.chat, where.space);
386
- if (!known.avatar) await avatarChat(k, known.chat, where.space);
387
- return known.chat;
388
- }
389
- const chat = await createChat(client, cfg.ownerOpenId, name, `hc terminal ${record.address} (hc session "${session}"). Type here to talk to it.`);
390
- const state = readState();
391
- state.chats[k] = { chat, name, session };
392
- writeState(state);
393
- log(`created group ${name} (${chat})`);
394
- await placeChat(k, chat, where.space);
395
- await avatarChat(k, chat, where.space);
396
- return chat;
397
- }
398
-
399
- // A terminal that is gone keeps its group, with its history, tagged "ended", so a new
400
- // terminal in the same place gets a group that is not mistaken for the old one. The tag
401
- // goes when the terminal comes back.
402
- async function endChat(k, { again = false } = {}) {
403
- const known = readState().chats[k];
404
- if (!known || (known.ended && !again)) return;
405
- setChat(k, { ended: true });
406
- if (!again) log(`ended group ${known.name} (${known.chat})`);
407
- await syncBotTags(k);
408
- if (!known.avatar) await avatarChat(k, known.chat, known.name.split(' / ')[0]);
409
- if (!(await asYou())) return;
410
- await muteChat(k, known.chat, known.name.split(' / ')[0]).catch((err) => logError(`as you, mute ${known.chat}: ${err.message}`));
411
- await syncUserLabel(k);
412
- }
413
-
414
- async function reviveChat(k, known) {
415
- setChat(k, { ended: false });
416
- await syncBotTags(k);
417
- await syncUserLabel(k);
418
- log(`group ${known.name} (${known.chat}) is live again`);
419
- }
420
-
421
- // Groups of terminals that ended while the bot was down get no end event: once every
422
- // session's first snapshot is in, a group whose terminal is live in no session is ended.
423
- // A terminal on another machine counts as gone only while that machine is connected,
424
- // so a dropped connection never ends a group.
425
- async function endGone() {
426
- const connected = new Set();
427
- for (const session of cfg.sessions) {
428
- for (const m of await machines(session).catch(() => [])) if (m.state === 'connected') connected.add(m.name);
429
- }
430
- for (const [k, c] of Object.entries(readState().chats)) {
431
- const address = addressOfKey(k);
432
- const machine = machineOf(address);
433
- if (c.ended && !c.avatar) later(c.session, address, 'avatar', () => avatarChat(k, c.chat, c.name.split(' / ')[0]));
434
- if (c.ended || !cfg.sessions.includes(c.session) || (machine && !connected.has(machine))) continue;
435
- if (cfg.sessions.some((s) => views.get(s)?.terminals.get(address)?.state === 'live')) continue;
436
- later(c.session, address, 'end', () => endChat(k));
437
- }
438
- }
439
-
440
- // A post is retried; when it still fails, the caller keeps its cursor, so the same
441
- // content goes out with the next post: a repeat is acceptable, a loss is not.
442
- function postTo(session, record, text) {
443
- return deliver(session, record, (chat) => sendMarkdown(client, chat, text));
444
- }
445
-
446
- async function deliver(session, record, send) {
447
- for (let attempt = 1; ; attempt += 1) {
448
- try {
449
- const chat = await ensureChat(session, record);
450
- return chat ? await send(chat) : null;
451
- } catch (err) {
452
- if (attempt >= POST_TRIES) throw err;
453
- logWarn(`post to ${session}/${record.address} failed (${err.message}); retry ${attempt}/${POST_TRIES - 1}`);
454
- await sleep(1000 * 2 ** attempt);
455
- }
456
- }
457
- }
458
-
459
- async function screen(session, address, lines = SCREEN_LINES) {
460
- return `\`\`\`\n${await screenTail(session, address, lines)}\n\`\`\``;
461
- }
462
-
463
- // Per terminal, in state.json: the cursor of what its reader posted, which reader and
464
- // which log that was (kept after the program exits, to post what it left).
465
- function cursorOf(k) {
466
- return readState().cursors?.[k] || { ...startCursor(STARTED), reader: null, file: null };
467
- }
468
-
469
- function saveCursor(k, cursor) {
470
- const state = readState();
471
- state.cursors = { ...(state.cursors || {}), [k]: cursor };
472
- writeState(state);
473
- }
474
-
475
- // What you sent from Feishu; its group shows it already, so it is not posted again.
476
- const fromFeishu = new Map();
477
-
478
- function formatItems(k, items) {
479
- const sent = fromFeishu.get(k) || [];
480
- const parts = [];
481
- for (const it of items) {
482
- if (it.role === 'tool') {
483
- const last = parts[parts.length - 1];
484
- if (last?.startsWith('▸ ')) parts[parts.length - 1] = `${last}\n${it.text}`;
485
- else parts.push(it.text);
486
- continue;
487
- }
488
- if (it.role === 'image') {
489
- // uploaded by uploadImages. One you send from the group reaches the terminal as a
490
- // file path, so it comes back as your text, not as an image.
491
- const shown = it.key ? `![image](${it.key})` : `_(an image ${it.from === 'you' ? 'you pasted' : 'it looked at'} was not sent: ${it.error})_`;
492
- parts.push(`${it.from === 'you' ? '**You:**\n' : ''}${shown}`);
493
- continue;
494
- }
495
- if (it.role === 'you') {
496
- const i = sent.indexOf(it.text);
497
- if (i >= 0) {
498
- sent.splice(i, 1);
499
- continue;
500
- }
501
- parts.push(`**You:**\n${it.text.split('\n').map((l) => `> ${l}`).join('\n')}`);
502
- } else {
503
- parts.push(it.text);
504
- }
505
- }
506
- return parts.join('\n\n');
507
- }
508
-
509
- // While it works, a turn has one live card: a single message, edited in place as the
510
- // agent goes on, so the group shows it is alive without a new message (and a ringing
511
- // phone) every few seconds. The turn's last answer then comes as a new message: you are
512
- // notified when it starts and when it is done. The card holds everything, not a status
513
- // line: a full card is closed and the next one goes on, and a card that cannot be edited
514
- // is sent again as a new one (a repeat, never a loss). It is kept in the cursor
515
- // (`live: {message, text}`), so a restart goes on editing it.
516
- const LIVE_CARD_BYTES = 20000; // a card may hold 30 KB; leave room for JSON escaping
517
- const LIVE_TITLE = '⌛️ Working…';
518
- const FULL_TITLE = '⌛️ Working… (continued below)'; // a full card while the turn goes on
519
- const CLOSED_TITLE = '✅ Worked'; // the turn is over
520
-
521
- async function newLive(session, record, text) {
522
- const parts = chunk(text);
523
- let res = null;
524
- for (const [i, part] of parts.entries()) {
525
- const last = i === parts.length - 1;
526
- res = await deliver(session, record, (chat) => sendCard(client, chat, textCard(part, last ? LIVE_TITLE : FULL_TITLE, last ? 'blue' : 'grey')));
527
- }
528
- return res?.data?.message_id ? { message: res.data.message_id, text: parts[parts.length - 1] } : null;
529
- }
530
-
531
- async function addLive(session, record, live, add) {
532
- if (!live) return newLive(session, record, add);
533
- const text = `${live.text}\n\n${add}`;
534
- if (Buffer.byteLength(text) > LIVE_CARD_BYTES) {
535
- await closeLive(live, FULL_TITLE);
536
- return newLive(session, record, add);
537
- }
538
- try {
539
- await updateCard(client, live.message, textCard(text, LIVE_TITLE, 'blue'));
540
- return { message: live.message, text };
541
- } catch (err) {
542
- logError(`live card ${live.message} not editable (${err.message}); sending it again`);
543
- return newLive(session, record, text);
544
- }
545
- }
546
-
547
- async function closeLive(live, title = CLOSED_TITLE) {
548
- try {
549
- await updateCard(client, live.message, textCard(live.text, title, 'grey'));
550
- } catch (err) {
551
- logError(`live card ${live.message} not closed (${err.message})`); // its text is all there
552
- }
553
- }
554
-
555
- // Images go to Feishu first: each once (by item id), so a post retried after a failure
556
- // does not upload it again. A failed upload fails the post and the cursor stays; after
557
- // UPLOAD_TRIES posts that failed on the same image (too big, a format Feishu refuses),
558
- // the group gets a line saying it was not sent and why, so one image cannot hold back
559
- // everything after it.
560
- const UPLOAD_TRIES = 3;
561
- const uploaded = new Map();
562
- const uploadFailures = new Map();
563
- async function uploadImages(items) {
564
- for (const it of items) {
565
- if (it.role !== 'image' || it.key || it.error) continue;
566
- if (!uploaded.has(it.id)) {
567
- try {
568
- uploaded.set(it.id, await uploadImage(client, Buffer.from(it.data, 'base64')));
569
- } catch (err) {
570
- const failures = (uploadFailures.get(it.id) || 0) + 1;
571
- uploadFailures.set(it.id, failures);
572
- if (failures < UPLOAD_TRIES) throw err;
573
- it.error = err.message;
574
- logError(`image ${it.id} not sent after ${failures} tries: ${err.message}`);
575
- continue;
576
- }
577
- if (uploaded.size > 500) uploaded.delete(uploaded.keys().next().value);
578
- }
579
- it.key = uploaded.get(it.id);
580
- }
581
- }
582
-
583
- // `live`: a post while it works. Everything goes into the live card, except the turn's
584
- // last answer, which is left for the turn's end (it is not consumed, so nothing is lost).
585
- // Otherwise (turn end, sweep, a dialog, the end): what came before the last answer goes
586
- // into the live card, which is closed, and the answer onward is a new message; with no
587
- // live card, everything is one new message.
588
- async function postItems(session, record, k, cursor, items, reader, file, { live = false } = {}) {
589
- const answerAt = items.findIndex((it) => it.end);
590
- const take = live && answerAt >= 0 ? items.slice(0, answerAt) : items;
591
- if (!take.length && !(cursor.live && !live)) return;
592
- await uploadImages(take);
593
- let open = cursor.live || null;
594
- const head = live ? take : answerAt >= 0 ? take.slice(0, answerAt) : open ? take : [];
595
- const tail = live ? [] : take.slice(head.length);
596
- const headText = formatItems(k, head);
597
- if (headText) open = await addLive(session, record, open, headText);
598
- if (!live && open) {
599
- await closeLive(open);
600
- open = null;
601
- }
602
- const tailText = formatItems(k, tail);
603
- if (tailText) await mentionYou(k, await postTo(session, record, tailText), '✅ done');
604
- const last = take[take.length - 1];
605
- saveCursor(k, { ...advance(cursor, take), reader: reader.app, file, ended: last ? last.role === 'ai' && Boolean(last.end) : cursor.ended, live: open });
606
- if (take.length) log(`<- ${session}/${record.address}: ${take.length} items (${reader.app}${live ? ', live card' : ''})`);
607
- }
608
-
609
- // Posts what a terminal said since the last post, through its reader. With
610
- // `waitForEnd`, it first waits for the turn's last answer (hc reports the end before
611
- // the agent writes it). Returns false when the terminal is not readable.
612
- async function postRead(session, address, record, reader, { waitForEnd, live = false }) {
613
- const k = key(session, address);
614
- const cursor = cursorOf(k);
615
- let read = null;
616
- for (let attempt = 0; attempt < (waitForEnd ? END_TRIES : 1); attempt += 1) {
617
- read = await reader.after(session, address, cursor);
618
- if (!read || read.items.some((it) => it.end)) break;
619
- if (!read.items.length && cursor.ended) break; // the live posts already reached the turn's end
620
- await sleep(END_WAIT_MS);
621
- }
622
- if (!read) return false;
623
- await postItems(session, record, k, cursor, read.items, reader, read.file, { live });
624
- return true;
625
- }
626
-
627
- async function postTurn(session, address, record) {
628
- const reader = readerFor(record.status?.app);
629
- if (reader) {
630
- try {
631
- if (await postRead(session, address, record, reader, { waitForEnd: true })) return;
632
- } catch (err) {
633
- logError(`${session}/${address}: ${reader.app} not readable (${err.message}); posting the screen`);
634
- }
635
- }
636
- await mentionYou(key(session, address), await postTo(session, record, await screen(session, address, TURN_SCREEN_LINES)), '✅ done');
637
- log(`<- ${session}/${address}: screen`);
638
- }
639
-
640
- // The program is gone: post what its log still holds, then say it ended.
641
- async function postEnd(session, address, record, reason) {
642
- const k = key(session, address);
643
- const cursor = cursorOf(k);
644
- const reader = readerFor(cursor.reader);
645
- if (reader && cursor.file) {
646
- try {
647
- await postItems(session, record, k, cursor, await reader.rest(session, address, cursor), reader, cursor.file);
648
- } catch (err) {
649
- logError(`${session}/${address}: last ${reader.app} entries not readable (${err.message})`);
650
- }
651
- }
652
- await postTo(session, record, `🔚 **Terminal ended**${reason ? ` (${reason})` : ''}`);
653
- await markDone(k);
654
- await endChat(k);
655
- }
656
-
657
- // ---------- dialogs: it waits for you ----------
658
-
659
- // What each dialog card showed, by message id, so an answered card keeps it.
660
- const dialogCards = new Map();
661
-
662
- function remember(messageId, shown) {
663
- dialogCards.set(messageId, shown);
664
- if (dialogCards.size > 200) dialogCards.delete(dialogCards.keys().next().value);
665
- }
666
-
667
- // With a menu, the card shows only the dialog, as plain text that wraps on a phone (the
668
- // screen above it was posted already, and the options are the buttons); without one,
669
- // the screen is all there is to go by.
670
- const escapeMarkdown = (text) => text.replace(/[\\`*_[\]#>~|]/g, '\\$&');
671
-
672
- // A card that needs you says so first thing, so the chat list shows it at a glance; once
673
- // it no longer waits, it says that instead (the card's last line says why).
674
- const NEEDS_TITLE = '⚠️ Needs you';
675
- const DONE_TITLE = '✅ Done';
676
-
677
- function dialogOf(status, screenText) {
678
- const menu = menuOf(screenText);
679
- const title = `${NEEDS_TITLE}${status?.kind ? ` (${status.kind})` : ''}`;
680
- const markdown = menu ? escapeMarkdown(menu.text) : `\`\`\`\n${screenText}\n\`\`\``;
681
- return { menu, title, markdown };
682
- }
683
-
684
- function dialogCard(session, address, { menu, title, markdown }) {
685
- return menuCard({ title, markdown, menu, value: { s: session, a: address, k: menu.key } });
686
- }
687
-
688
- // It waits for you (permission, question, auth). When its screen holds a numbered menu,
689
- // the card has a button per option; otherwise it shows the screen and how to answer by
690
- // typing.
691
- // One dialog gets one card. A dialog is named by its menu key (or, with no menu, by its
692
- // status words); the name is claimed before anything is sent, so the turn event, a
693
- // status event and a card click that all see the same dialog post it once. Leaving
694
- // `blocked` forgets it, so the same dialog asked again later gets a card again.
695
- const postedDialog = new Map(); // terminal key -> name of the dialog last posted
696
-
697
- const dialogName = (dialog, status) => (dialog.menu ? dialog.menu.key : `${status?.kind || ''}|${status?.msg || ''}`);
698
-
699
- function claimDialog(k, dialog, status) {
700
- const name = dialogName(dialog, status);
701
- if (postedDialog.get(k) === name) return false;
702
- postedDialog.set(k, name);
703
- return true;
704
- }
705
-
706
- // The dialog card still waiting, per terminal. When its dialog goes away (answered in the
707
- // terminal, or the program moved on to the next one), the card drops its buttons and
708
- // says so, so the group never shows a question nobody waits on. A card being clicked
709
- // is not open: its click answers it.
710
- const openCard = new Map(); // terminal key -> {message, dialog}
711
-
712
- async function closeCard(k, done) {
713
- const open = openCard.get(k);
714
- if (!open) return;
715
- openCard.delete(k);
716
- try {
717
- await updateCard(client, open.message, doneCard(DONE_TITLE, open.dialog.markdown, done));
718
- } catch (err) {
719
- logError(`dialog card ${open.message} not closed (${err.message})`);
720
- }
721
- }
722
-
723
- // `screen`: the screen read when the event came (a promise), so a dialog answered in
724
- // the terminal while earlier work was still being posted still gets its card. `again`:
725
- // post it even if it was posted (you typed in the group while it waits).
726
- // A screen read the moment `blocked` arrives may come before the dialog is drawn: with no
727
- // menu on it, the screen is read again a few times.
728
- const DRAW_TRIES = 3;
729
- const DRAW_WAIT_MS = 400;
730
-
731
- async function postBlocked(session, address, record, { screen = null, again = false } = {}) {
732
- const first = await screen;
733
- let dialog = first ? dialogOf(record.status, first) : null;
734
- for (let i = 0; i < DRAW_TRIES && !dialog?.menu; i += 1) {
735
- if (i) await sleep(DRAW_WAIT_MS);
736
- dialog = dialogOf(record.status, await screenTail(session, address, SCREEN_LINES));
737
- }
738
- const k = key(session, address);
739
- if (!claimDialog(k, dialog, record.status) && !again) return;
740
- await closeCard(k, 'It moved on; see the next card.');
741
- if (!dialog.menu) {
742
- const sent = await postTo(session, record, `**${dialog.title}**\n\n${dialog.markdown}\n\nReply \`/answer <text>\` to answer it, or \`/esc\` to cancel it.`);
743
- const chat = readState().chats[k]?.chat;
744
- if (chat) await pinChat(k, chat);
745
- await mentionYou(k, sent, '⚠️ needs you');
746
- } else {
747
- const res = await deliver(session, record, (chat) => sendCard(client, chat, dialogCard(session, address, dialog)));
748
- if (res?.data?.message_id) {
749
- remember(res.data.message_id, dialog);
750
- openCard.set(k, { message: res.data.message_id, dialog });
751
- await pinChat(k, res.data.chat_id);
752
- await mentionYou(k, res.data.message_id, '⚠️ needs you');
753
- // answered while the card was on its way: nothing will close it later, so now
754
- const live = views.get(session)?.terminals.get(address);
755
- if (live?.status?.state !== 'blocked' || postedDialog.get(k) !== dialogName(dialog, record.status)) await closeCard(k, '✔ Answered.');
756
- }
757
- }
758
- log(`<- ${session}/${address}: blocked${dialog.menu ? ` (${dialog.menu.options.length} options)` : ''}`);
759
- }
760
-
761
- // "Type something": pick that option, then type the words.
762
- async function answerFree(session, address, menu, text) {
763
- await keys(session, address, [String(menu.free.n)]);
764
- await sleep(300);
765
- await send(session, address, text, { force: true });
766
- }
767
-
768
- const toast = (type, content) => ({ toast: { type, content } });
769
- const answered = (shown, done) => ({ ...toast('info', done), card: { type: 'raw', data: doneCard(DONE_TITLE, shown.markdown, done) } });
770
-
771
- // A click on a dialog card. The screen is read again first, and nothing is pressed
772
- // unless it still shows the dialog the card was made from (same key) with the same
773
- // option. After the key, the card turns into the dialog's next step (the next question,
774
- // the review, a toggled box) or says what was picked.
775
- async function onCardAction(data) {
776
- const started = Date.now();
777
- if (data.operator?.open_id !== cfg.ownerOpenId) return toast('error', 'Only the owner can answer.');
778
- const v = data.action?.value || {};
779
- const messageId = data.context?.open_message_id;
780
- const shown = dialogCards.get(messageId) || { title: NEEDS_TITLE, markdown: '' };
781
- const record = views.get(v.s)?.terminals.get(v.a);
782
- if (!record || record.state !== 'live') return answered(shown, 'The terminal has ended.');
783
- const status = (await getTerminal(v.s, v.a)).status;
784
- const menu = status?.state === 'blocked' ? menuOf(await screenTail(v.s, v.a, SCREEN_LINES)) : null;
785
- if (!menu || menu.key !== v.k) {
786
- if (menu) later(v.s, v.a, 'dialog', () => postBlocked(v.s, v.a, { ...record, status }));
787
- return answered(shown, menu ? 'It now asks something else; see the new card.' : 'It no longer waits on this.');
788
- }
789
-
790
- const k = key(v.s, v.a);
791
- if (openCard.get(k)?.message === messageId) openCard.delete(k);
792
- let picked;
793
- if (v.pick === 'esc' || v.pick === 'tab') {
794
- await keys(v.s, v.a, [v.pick]);
795
- picked = v.pick === 'esc' ? 'Esc pressed' : 'Next';
796
- } else {
797
- const option = menu.options.find((o) => o.n === v.pick);
798
- if (!option || option.label !== v.label) return answered(shown, 'The options changed; nothing was pressed.');
799
- if (option === menu.free) {
800
- const text = String(data.action?.form_value?.answer || '').trim();
801
- if (!text) return toast('warning', 'Type your answer first.');
802
- await answerFree(v.s, v.a, menu, text);
803
- picked = text;
804
- } else {
805
- await keys(v.s, v.a, [String(option.n)]);
806
- picked = `${option.n}. ${option.label}`;
807
- }
808
- }
809
- log(`-> ${v.s}/${v.a}: picked ${picked}`);
810
-
811
- // A slow machine: answer the click now, show the next step as a new card after.
812
- if (Date.now() - started > CLICK_BUDGET_MS) {
813
- later(v.s, v.a, 'dialog', async () => {
814
- await sleep(KEY_SETTLE_MS);
815
- const next = (await getTerminal(v.s, v.a)).status;
816
- if (next?.state === 'blocked') await postBlocked(v.s, v.a, { ...record, status: next });
817
- });
818
- return answered(shown, `✔ ${picked}`);
819
- }
820
- await sleep(KEY_SETTLE_MS);
821
- const nextStatus = (await getTerminal(v.s, v.a)).status;
822
- if (nextStatus?.state === 'blocked') {
823
- const next = dialogOf(nextStatus, await screenTail(v.s, v.a, SCREEN_LINES));
824
- // the same dialog (a toggled box) is redrawn here; a new one only if no card has it yet
825
- if (next.menu && (next.menu.key === v.k || claimDialog(k, next, nextStatus))) {
826
- remember(messageId, next);
827
- openCard.set(k, { message: messageId, dialog: next });
828
- return { ...toast('info', `✔ ${picked}`), card: { type: 'raw', data: dialogCard(v.s, v.a, next) } };
829
- }
830
- if (next.menu) return answered(shown, `✔ ${picked}`); // its card is out already
831
- // still waiting, on something with no menu: hc sends no new turn for it, so post it
832
- later(v.s, v.a, 'dialog', () => postBlocked(v.s, v.a, { ...record, status: nextStatus }));
833
- }
834
- return answered(shown, `✔ ${picked}`);
835
- }
836
-
837
- // Shown terminals this session runs whose program has a reader, with that reader.
838
- function readable(session) {
839
- return [...(views.get(session)?.terminals.values() || [])]
840
- .filter((record) => shownWhere(record) && handles(session, record.address))
841
- .map((record) => ({ record, reader: readerFor(record.status?.app) }))
842
- .filter((x) => x.reader);
843
- }
844
-
845
- // A missed turn event (bot down, event stream reconnected) or a turn posted before its
846
- // last answer was written would leave content behind; idle readable terminals are
847
- // checked again at start, after every snapshot and every SWEEP_MS.
848
- function sweep(session) {
849
- for (const { record, reader } of readable(session)) {
850
- if (['working', 'blocked'].includes(record.status?.state)) continue;
851
- later(session, record.address, 'sweep', async () => {
852
- await postRead(session, record.address, record, reader, { waitForEnd: false });
853
- await markDone(key(session, record.address), { olderThan: SWEEP_DONE_MS }); // a turn end that was missed
854
- });
855
- }
856
- }
857
-
858
- // While a readable terminal works, what it says and does is posted every LIVE_MS, so
859
- // the group shows it is alive; the turn's end then posts the rest. One check per
860
- // terminal at a time.
861
- const liveBusy = new Set();
862
- function live(session) {
863
- for (const { record, reader } of readable(session)) {
864
- if (record.status?.state !== 'working') continue;
865
- const k = key(session, record.address);
866
- if (liveBusy.has(k)) continue;
867
- liveBusy.add(k);
868
- later(session, record.address, 'live', () => postRead(session, record.address, record, reader, { waitForEnd: false, live: true })).finally(() => liveBusy.delete(k));
869
- }
870
- }
871
-
872
- // hc sends a turn event only when a program leaves `working`. A program that goes from
873
- // one dialog straight to the next (a permission, then a question) never works in
874
- // between, so the status events catch that: blocked, and not coming from working
875
- // (that case is the turn event's, which posts what it said first).
876
- const lastState = new Map(); // terminal key -> status state at the last status event
877
-
878
- function onStatus(session, address, record) {
879
- const k = key(session, address);
880
- const was = lastState.get(k);
881
- const now = record.status?.state;
882
- lastState.set(k, now);
883
- if (now !== was) since.set(k, Date.now());
884
- if (now !== 'blocked') {
885
- postedDialog.delete(k);
886
- if (readState().pinned?.[k]) later(session, address, 'unpin', () => unpinChat(k));
887
- if (openCard.has(k)) later(session, address, 'dialog', () => closeCard(k, '✔ Answered.'));
888
- return;
889
- }
890
- if (was === 'working' || !shownWhere(record) || !handles(session, address)) return;
891
- // the screen and the status as they are now: by the time the lane gets to it, the
892
- // dialog may be answered and the status moved on
893
- const screen = screenTail(session, address, SCREEN_LINES).catch(() => null);
894
- const status = { ...record.status };
895
- later(session, address, 'dialog', () => postBlocked(session, address, { ...record, status }, { screen }));
896
- }
897
-
898
- function watch(session) {
899
- const view = followEvents(session, {
900
- log,
901
- onSnapshot: (list) => {
902
- firstSnapshot.get(session).done();
903
- scheduleOverview();
904
- for (const record of list) if (shownWhere(record)) later(session, record.address, 'group', () => ensureChat(session, record));
905
- setTimeout(() => sweep(session), 0); // after views has this session's records
906
- },
907
- onChange: ({ type, address, record, data }) => {
908
- scheduleOverview();
909
- if (type === 'status' && record) onStatus(session, address, record);
910
- if (['created', 'restarted', 'labels', 'status', 'title'].includes(type) && shownWhere(record)) {
911
- later(session, address, 'group', () => ensureChat(session, record));
912
- } else if ((type === 'finished' || type === 'removed') && chatOf(session, address)?.session === session) {
913
- later(session, address, 'end', () => postEnd(session, address, record || { address }, data?.reason));
914
- }
915
- },
916
- onTurn: ({ address, state, record }) => {
917
- scheduleOverview();
918
- log(`turn ${session}/${address}: ${state}${shownWhere(record) ? '' : ' (not shown in herdr-app)'}`);
919
- if (!shownWhere(record) || !handles(session, address)) return;
920
- const screen = state === 'blocked' ? screenTail(session, address, SCREEN_LINES).catch(() => null) : null;
921
- const status = { ...record.status };
922
- later(session, address, 'turn', async () => {
923
- if (state === 'blocked') {
924
- // what it said before stopping to ask comes first
925
- const reader = readerFor(record.status?.app);
926
- if (reader) await postRead(session, address, record, reader, { waitForEnd: false }).catch(() => {});
927
- await postBlocked(session, address, { ...record, status }, { screen });
928
- return;
929
- }
930
- await postTurn(session, address, record);
931
- await markDone(key(session, address));
932
- });
933
- },
934
- });
935
- views.set(session, view);
936
- }
937
-
938
- // ---------- Feishu in ----------
939
-
940
- const seen = new Set();
941
-
942
- async function reply(chatId, text) {
943
- await sendMarkdown(client, chatId, text);
944
- }
945
-
946
- // What you sent from a group is marked with a reaction: THINKING once it is typed into
947
- // the terminal, swapped for OK when the turn that took it is over (the turn ends, a
948
- // shell shows what the command did, or the terminal ends). A dialog in between is not
949
- // the end. Which messages wait for their OK is kept in state.json (reacts), so a
950
- // restart still marks them. Reactions are a nicety: a failure is only logged.
951
- const WORKING_EMOJI = 'THINKING';
952
- const DONE_EMOJI = 'OK';
953
-
954
- async function addReaction(messageId, emoji) {
955
- const res = await client.im.v1.messageReaction.create({ path: { message_id: messageId }, data: { reaction_type: { emoji_type: emoji } } });
956
- return res?.data?.reaction_id;
957
- }
958
-
959
- async function markWorking(k, messageId) {
960
- try {
961
- const reaction = await addReaction(messageId, WORKING_EMOJI);
962
- const state = readState();
963
- state.reacts = { ...(state.reacts || {}), [k]: [...(state.reacts?.[k] || []), { message: messageId, reaction, at: Date.now() }] };
964
- writeState(state);
965
- } catch (err) {
966
- logError(`reaction on ${messageId}: ${err.message}`);
967
- }
968
- }
969
-
970
- // `olderThan`: only messages typed in at least this long ago (the sweep: a message just
971
- // typed in may not have started a turn yet).
972
- async function markDone(k, { olderThan = 0 } = {}) {
973
- const all = readState().reacts?.[k] || [];
974
- const waiting = all.filter((x) => Date.now() - (x.at || 0) >= olderThan);
975
- if (!waiting.length) return;
21
+ const store = { read: readState, write: writeState };
22
+
23
+ const cfg = loadConfig({ requires: chatModule(chatOf()).requires || [] });
24
+
25
+ // The core is made after the chat app, which needs the core's lanes and keys: these
26
+ // forward to it.
27
+ let engine = null;
28
+ const chat = createChat(cfg.chat, {
29
+ cfg,
30
+ store,
31
+ log,
32
+ logWarn,
33
+ logError,
34
+ stateDir: STATE_DIR,
35
+ lane: (k, label, fn) => engine.laneOf(k, label, fn),
36
+ scopeOf: (k) => engine.scopeOfKey(k),
37
+ });
38
+
39
+ // A bot that is down is a phone that hears nothing, so down time is said in the direct
40
+ // chat: a crash says why (at most once per CRASH_NOTICE_MS, as a restart loop repeats
41
+ // it), and the next connection says how long it was gone.
42
+ const CRASH_NOTICE_MS = 10 * 60 * 1000;
43
+ async function sayCrash(err) {
44
+ if (!chat.ready()) return;
45
+ const now = Date.now();
976
46
  const state = readState();
977
- const left = (state.reacts?.[k] || []).filter((x) => !waiting.some((w) => w.message === x.message));
978
- if (left.length) state.reacts[k] = left;
979
- else delete state.reacts[k];
47
+ const down = { since: now, said: 0, ...state.down, why: String(err?.message || err).slice(0, 300) };
48
+ const say = now - down.said >= CRASH_NOTICE_MS;
49
+ if (say) down.said = now;
50
+ state.down = down;
980
51
  writeState(state);
981
- for (const { message, reaction } of waiting) {
982
- try {
983
- if (reaction) await client.im.v1.messageReaction.delete({ path: { message_id: message, reaction_id: reaction } });
984
- await addReaction(message, DONE_EMOJI);
985
- } catch (err) {
986
- logError(`reaction on ${message}: ${err.message}`);
987
- }
988
- }
989
- }
990
-
991
- async function typeInto(chatId, messageId, session, address, text) {
992
- const record = views.get(session)?.terminals.get(address);
993
- if (!record || record.state !== 'live') {
994
- await reply(chatId, 'This terminal has ended.');
995
- return;
996
- }
997
- if (text === '/screen' || text === '/esc') {
998
- if (text === '/esc') await keys(session, address, ['esc']);
999
- await reply(chatId, `${text === '/esc' ? 'Esc pressed.\n\n' : ''}${await screen(session, address)}`);
1000
- return;
1001
- }
1002
- const answer = text.match(/^\/answer\s+([\s\S]+)$/);
1003
- if (!answer && record.status?.state === 'blocked') {
1004
- // It waits on a dialog: words go into its "Type something" box when it has one;
1005
- // otherwise the dialog card comes again, to pick there.
1006
- const menu = menuOf(await screenTail(session, address, SCREEN_LINES));
1007
- if (menu?.free) {
1008
- await answerFree(session, address, menu, text);
1009
- log(`-> ${session}/${address}: answered ${text.slice(0, 80)}`);
1010
- await markWorking(key(session, address), messageId);
1011
- } else {
1012
- await postBlocked(session, address, record, { again: true });
1013
- }
1014
- return;
1015
- }
1016
- try {
1017
- const typed = answer ? answer[1] : text;
1018
- await send(session, address, typed, { force: Boolean(answer) });
1019
- const k = key(session, address);
1020
- fromFeishu.set(k, [...(fromFeishu.get(k) || []).slice(-20), typed.trim()]);
1021
- log(`-> ${session}/${address}: ${text.slice(0, 80)}`);
1022
- await markWorking(key(session, address), messageId);
1023
- } catch (err) {
1024
- const hint = /blocked/i.test(err.message) ? '\n\nIt waits on a dialog. Reply `/answer <text>` to answer it, or `/esc` to cancel it.' : '';
1025
- await reply(chatId, `Not sent: ${err.message}\n\n${await screen(session, address)}${hint}`);
1026
- return;
1027
- }
1028
- // A shell reports no turns, so show what the command did.
1029
- const foreground = (await getTerminal(session, address)).foreground?.name;
1030
- if (!record.status && SHELLS.has(foreground)) {
1031
- await sleep(SHELL_ECHO_MS);
1032
- await reply(chatId, await screen(session, address));
1033
- await markDone(key(session, address));
1034
- }
1035
- }
1036
-
1037
- // Images you send to a group: saved here, sent beside the terminal (on its machine) with
1038
- // `hc put`, and typed in as their paths, which an agent like Claude Code opens.
1039
- const INBOX = path.join(os.tmpdir(), 'herdrchat-inbox');
1040
-
1041
- function extOf(head) {
1042
- if (head[0] === 0x89 && head.toString('latin1', 1, 4) === 'PNG') return 'png';
1043
- if (head[0] === 0xff && head[1] === 0xd8) return 'jpg';
1044
- if (head.toString('latin1', 0, 4) === 'GIF8') return 'gif';
1045
- if (head.toString('latin1', 0, 4) === 'RIFF' && head.toString('latin1', 8, 12) === 'WEBP') return 'webp';
1046
- return 'png';
1047
- }
1048
-
1049
- async function imagesInto(messageId, session, address, imageKeys) {
1050
- fs.mkdirSync(INBOX, { recursive: true });
1051
- const landed = [];
1052
- for (const [i, imageKey] of imageKeys.entries()) {
1053
- const raw = path.join(INBOX, `${messageId}-${i}`);
1054
- await downloadImage(client, messageId, imageKey, raw);
1055
- const file = `${raw}.${extOf(fs.readFileSync(raw).subarray(0, 12))}`;
1056
- fs.renameSync(raw, file);
1057
- landed.push(await put(session, address, file));
1058
- fs.unlinkSync(file); // the terminal has its own copy now
1059
- }
1060
- return landed;
1061
- }
1062
-
1063
- // ---------- the overview, in the direct chat ----------
1064
-
1065
- // The overview card (lib/overview.mjs) is edited whenever a terminal changes; changes
1066
- // within OVERVIEW_WAIT_MS make one edit, and an edit that would change nothing is
1067
- // skipped. Which message it is lives in state.json (overview). `/ls` sends a new one at
1068
- // the bottom of the chat and the old one points to it.
1069
- const OVERVIEW_WAIT_MS = 2000;
1070
- const since = new Map(); // terminal key -> when its status state last changed
1071
- let overviewTimer = null;
1072
- let overviewShown = null;
1073
-
1074
- function overviewRows() {
1075
- const rows = [];
1076
- for (const session of cfg.sessions) {
1077
- for (const record of views.get(session)?.terminals.values() || []) {
1078
- const where = shownWhere(record);
1079
- if (!where || !handles(session, record.address)) continue;
1080
- const k = key(session, record.address);
1081
- rows.push({
1082
- name: groupName(where, record),
1083
- address: session === cfg.sessions[0] || machineOf(record.address) ? record.address : `${session} ${record.address}`,
1084
- state: record.status?.state,
1085
- kind: record.status?.kind,
1086
- program: record.foreground?.name || record.agent,
1087
- since: since.get(k),
1088
- chat: readState().chats[k]?.chat,
1089
- });
1090
- }
1091
- }
1092
- return rows;
52
+ if (!say) return;
53
+ await chat.tellOwner({ title: '🔴 herdrchat is down', tone: 'bad', markdown: `herdrchat stopped: ${down.why}\n\nIt is being started again. Until it is back, nothing you send here reaches your terminals.` });
1093
54
  }
1094
-
1095
- async function sendOverview(card) {
1096
- const res = await sendCard(client, cfg.ownerOpenId, card, 'open_id');
55
+ async function sayBack() {
1097
56
  const state = readState();
1098
- state.overview = { message: res.data.message_id };
57
+ const { down } = state;
58
+ if (!down) return;
59
+ delete state.down;
1099
60
  writeState(state);
1100
- }
1101
-
1102
- // There is one overview. An edit that fails is tried again (Feishu sometimes answers
1103
- // 500 for a moment); only when it keeps failing, or on `/ls`, a new one is sent, and the
1104
- // old one is recalled, so the chat never shows two.
1105
- const OVERVIEW_TRIES = 3;
1106
-
1107
- async function refreshOverview({ fresh = false } = {}) {
1108
- if (!cfg.ownerOpenId) return;
1109
- const rows = overviewRows();
1110
- const text = overviewText(rows);
1111
- if (text === overviewShown && !fresh) return;
1112
- const card = overviewCard(rows, Date.now());
1113
- const old = readState().overview?.message;
1114
- if (old && !fresh) {
1115
- for (let attempt = 1; attempt <= OVERVIEW_TRIES; attempt += 1) {
1116
- try {
1117
- await updateCard(client, old, card);
1118
- overviewShown = text;
1119
- return;
1120
- } catch (err) {
1121
- logWarn(`overview ${old} not edited (${err.message}); ${attempt < OVERVIEW_TRIES ? 'trying again' : 'sending a new one'}`);
1122
- if (attempt < OVERVIEW_TRIES) await sleep(1000 * 2 ** attempt);
1123
- }
1124
- }
1125
- }
1126
- await sendOverview(card);
1127
- overviewShown = text;
1128
- if (old) await recallMessage(client, old).catch((err) => logError(`old overview ${old} not recalled (${err.message})`));
1129
- }
1130
-
1131
- function scheduleOverview() {
1132
- if (overviewTimer) return;
1133
- overviewTimer = setTimeout(() => {
1134
- overviewTimer = null;
1135
- refreshOverview().catch((err) => logError(`overview: ${err.message}`));
1136
- }, OVERVIEW_WAIT_MS);
1137
- }
1138
-
1139
- const HELP = [
1140
- '`/ls` a new overview of every terminal (the overview also keeps itself up to date)',
1141
- '`/new <session> <space>/<tab> [command]` start a terminal (default `claude`); `--cwd <dir>` first to pick its folder',
1142
- '`/kill <session> <address>` end a terminal',
1143
- '`/auth` let the bot act as you for chat-list settings (mute, labels, pin)',
1144
- 'In a terminal\'s group: type to talk to it; `/screen`, `/answer <text>`, `/esc`.',
1145
- ].join('\n');
1146
-
1147
- async function newTerminal(args) {
1148
- let cwd = null;
1149
- if (args[0] === '--cwd') {
1150
- cwd = args[1];
1151
- args = args.slice(2);
1152
- }
1153
- const [session, place, ...command] = args;
1154
- if (!cfg.sessions.includes(session)) throw new Error(`unknown session "${session}"; known: ${cfg.sessions.join(', ')}`);
1155
- const slash = (place || '').indexOf('/');
1156
- if (slash < 1 || slash === place.length - 1) throw new Error('give the place as <space>/<tab>');
1157
- const space = place.slice(0, slash);
1158
- const tab = place.slice(slash + 1);
1159
- const sibling = [...views.get(session).terminals.values()].find((t) => shownWhere(t)?.space === space && t.labels?.['herdr.space']);
1160
- const spaceId = sibling?.labels['herdr.space'] || `sp${crypto.randomBytes(16).toString('hex')}`;
1161
- const folder = cwd || sibling?.foreground?.cwd || sibling?.cwd || os.homedir();
1162
- const line = command.length ? command.join(' ') : 'claude';
1163
- const out = await run(session, {
1164
- cwd: folder,
1165
- labels: { 'herdr.space': spaceId, 'herdr.tab': `tb${crypto.randomBytes(16).toString('hex')}`, where: `${space} / ${tab}` },
1166
- argv: ['/bin/zsh', '-lc', `exec ${line}`], // login shell for PATH; exec so no bare shell is left behind
1167
- });
1168
- const address = JSON.parse(out).address;
1169
- return `Started \`${line}\` as \`${address}\` in ${session} / ${space} / ${tab} (\`${folder}\`). Its group appears in a moment.`;
1170
- }
1171
-
1172
- // `/auth`: the device flow, in the direct chat. The link opens Feishu's own consent page;
1173
- // the bot waits for your yes in the background.
1174
- async function authorize(chatId) {
1175
- const device = await userAuth.startDeviceFlow();
1176
- await reply(chatId, `Open this link and allow it (the code is ${device.user_code}):\n\n${device.verification_uri_complete || device.verification_uri}\n\nIt lets the bot mute its groups for you, put them in your labels and pin the one that needs you. Nothing else.`);
1177
- userAuth.waitForToken(device)
1178
- .then(async (t) => {
1179
- await reply(chatId, `✅ Allowed${t.refresh ? '' : ' (no refresh token: it lasts about two hours)'}. Muting the groups and putting them in your labels now.`);
1180
- youCheckedAt = 0;
1181
- await asYou();
1182
- })
1183
- .catch((err) => reply(chatId, `Not allowed: ${err.message}`));
1184
- }
1185
-
1186
- async function admin(chatId, text) {
1187
- const [cmd, ...args] = text.split(/\s+/);
1188
- if (cmd === '/ls') return refreshOverview({ fresh: true });
1189
- if (cmd === '/auth') return authorize(chatId);
1190
- if (cmd === '/new') return reply(chatId, await newTerminal(args));
1191
- if (cmd === '/kill') {
1192
- const [session, address] = args;
1193
- if (!cfg.sessions.includes(session) || !address) throw new Error('give `/kill <session> <address>`');
1194
- await hc(session, ['kill', address]);
1195
- return reply(chatId, `Ended \`${address}\` in ${session}.`);
1196
- }
1197
- return reply(chatId, HELP);
1198
- }
1199
-
1200
- async function onMessage(data) {
1201
- const msg = data.message;
1202
- if (seen.has(msg.message_id)) return;
1203
- seen.add(msg.message_id);
1204
- if (seen.size > 1000) seen.delete(seen.values().next().value);
1205
-
1206
- const openId = data.sender?.sender_id?.open_id;
1207
- if (!cfg.ownerOpenId) {
1208
- log(`owner not set; message from open_id ${openId}`);
1209
- if (msg.chat_type === 'p2p') await reply(msg.chat_id, `Owner is not set. Put this in ${CONFIG_FILE}:\n\n\`FEISHU_OWNER_OPEN_ID=${openId}\`\n\nthen restart the bot.`);
1210
- return;
1211
- }
1212
- if (openId !== cfg.ownerOpenId) {
1213
- logWarn(`ignored message from ${openId}`);
1214
- return;
1215
- }
1216
- const parts = messageParts(msg.message_type, msg.content);
1217
- if (!parts) {
1218
- await reply(msg.chat_id, 'Only text and images are passed on.');
1219
- return;
1220
- }
1221
- const text = parts.text.replace(/@_user_\d+/g, '').trim();
1222
- if (!text && !parts.images.length) return;
1223
-
1224
- try {
1225
- if (msg.chat_type === 'p2p') {
1226
- if (parts.images.length) await reply(msg.chat_id, 'Send images in a terminal\'s group.');
1227
- else await admin(msg.chat_id, text);
1228
- return;
1229
- }
1230
- const target = terminalOfChat(msg.chat_id);
1231
- if (!target) {
1232
- log(`message in a group this bot did not make: ${msg.chat_id}${msg.thread_id ? ` (thread ${msg.thread_id})` : ''}`);
1233
- return;
1234
- }
1235
- const files = await imagesInto(msg.message_id, target.session, target.address, parts.images);
1236
- await typeInto(msg.chat_id, msg.message_id, target.session, target.address, [text, ...files].filter(Boolean).join(' '));
1237
- } catch (err) {
1238
- await reply(msg.chat_id, err.message);
1239
- }
1240
- }
1241
-
1242
- // ---------- start ----------
1243
-
1244
- log(`herdrchat: sessions ${cfg.sessions.join(', ')}`);
1245
- if (!cfg.ownerOpenId) log('owner not set: no groups are made until it is. Send the bot any direct message.');
1246
- startEvents(cfg, onMessage, onCardAction);
1247
- for (const session of cfg.sessions) watch(session);
1248
- ready.then(() => setTimeout(async () => {
1249
- await endGone().catch((err) => logError(`end gone groups: ${err.message}`));
1250
- if (await asYou()) {
1251
- // pins left from before a restart, for terminals that no longer wait
1252
- for (const k of Object.keys(readState().pinned || {})) {
1253
- const address = addressOfKey(k);
1254
- if (!cfg.sessions.some((s) => views.get(s)?.terminals.get(address)?.status?.state === 'blocked')) await unpinChat(k);
1255
- }
1256
- }
1257
- }, 0));
1258
- setInterval(() => asYou().catch((err) => logError(`as you: ${err.message}`)), YOU_CHECK_MS);
1259
- setInterval(() => cfg.sessions.forEach(sweep), SWEEP_MS);
1260
- setInterval(() => cfg.sessions.forEach(live), LIVE_MS);
61
+ if (!chat.ready()) return;
62
+ const minutes = Math.max(1, Math.round((Date.now() - down.since) / 60000));
63
+ await chat.tellOwner({ title: '✅ herdrchat is back', tone: 'good', markdown: `herdrchat is back after about ${minutes} min. What stopped it: ${down.why}` });
64
+ }
65
+ logger.capture({ onCrash: sayCrash });
66
+
67
+ engine = createEngine({
68
+ cfg,
69
+ chat,
70
+ hc: { followEvents, cores, machines, getTerminal, screenTail, send, keys, put, run, kill: (session, address) => hc(session, ['kill', address]) },
71
+ readers,
72
+ store,
73
+ log,
74
+ logWarn,
75
+ logError,
76
+ });
77
+ engine.start({ onReady: () => sayBack().catch((err) => logError(`back notice: ${err.message}`)) });