herdrchat 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.mjs +64 -15
- package/bin/herdrchat.mjs +63 -1246
- package/config.env.example +14 -0
- package/docs/architecture.md +76 -0
- package/docs/guide.md +18 -6
- package/lib/chats/contract.mjs +58 -0
- package/lib/{feishu.mjs → chats/feishu/client.mjs} +23 -2
- package/lib/chats/feishu/index.mjs +499 -0
- package/lib/{overview.mjs → chats/feishu/overview.mjs} +2 -2
- package/lib/chats/index.mjs +20 -0
- package/lib/chats/telegram/client.mjs +83 -0
- package/lib/chats/telegram/format.mjs +83 -0
- package/lib/chats/telegram/index.mjs +411 -0
- package/lib/{config.mjs → core/config.mjs} +39 -3
- package/lib/core/engine.mjs +1041 -0
- package/lib/{hc.mjs → core/hc.mjs} +50 -2
- package/lib/{log.mjs → core/log.mjs} +6 -3
- package/lib/{readers → core/readers}/claude.mjs +1 -0
- package/lib/core/readers/index.mjs +49 -0
- package/lib/core/views.mjs +58 -0
- package/package.json +1 -1
- package/skills/herdrchat/SKILL.md +1 -1
- package/skills/herdrchat/references/ops.md +15 -2
- package/skills/herdrchat/references/telegram.md +23 -0
- package/lib/readers/index.mjs +0 -24
- /package/lib/{avatar.mjs → chats/feishu/avatar.mjs} +0 -0
- /package/lib/{userauth.mjs → chats/feishu/userauth.mjs} +0 -0
- /package/lib/{cursor.mjs → core/cursor.mjs} +0 -0
- /package/lib/{events.mjs → core/events.mjs} +0 -0
- /package/lib/{home.mjs → core/home.mjs} +0 -0
- /package/lib/{menu.mjs → core/menu.mjs} +0 -0
|
@@ -0,0 +1,1041 @@
|
|
|
1
|
+
// herdrchat's core, the same for every chat app: one room per herdr-app tab (a group in
|
|
2
|
+
// Feishu, a topic in Telegram) for every hc session in HERDR_SESSIONS; what you type in a
|
|
3
|
+
// room goes to its terminal; the direct chat with the bot is herdr itself (/ls, /new,
|
|
4
|
+
// /kill). hc is the backbone: its events say which terminals exist, their status and
|
|
5
|
+
// when a turn ends. What an agent said comes from its reader (readers/: the agent's own
|
|
6
|
+
// records, posted live while it works); anything but a running agent posts nothing by
|
|
7
|
+
// itself. Only what happens after the bot starts is posted, never older history.
|
|
8
|
+
//
|
|
9
|
+
// The chat side (lib/chats/<app>/index.mjs) gives:
|
|
10
|
+
// ready() the owner is known (else no room is made)
|
|
11
|
+
// start({onMessage, onAction, onReady}) messages {room|null, where, message, text, images}
|
|
12
|
+
// (room null: the direct chat) and button clicks
|
|
13
|
+
// {value, message, answer} -> {toast: {type, text}, card?}
|
|
14
|
+
// help, command(cmd, args, where) its own direct-chat commands
|
|
15
|
+
// openRoom({name, intro}) -> room; renameRoom(room, name)
|
|
16
|
+
// placeRoom(k, {space}); endRoom(k); reviveRoom(k) what a live or ended room looks like
|
|
17
|
+
// post(room, markdown) -> message; reply(where, markdown)
|
|
18
|
+
// postCard(room, look) -> message; editCard(message, look) look: {title, tone, markdown}
|
|
19
|
+
// postDialog(room, dialog, value) -> message; closeDialog(message, dialog, done)
|
|
20
|
+
// pin(k, room); unpin(k); pinned() -> keys; ring(k, message, text)
|
|
21
|
+
// markWorking(message) -> token; markDone(message, token)
|
|
22
|
+
// image(buffer) -> markdown; download(message, ref, file)
|
|
23
|
+
// postFile(room, {name, data, caption}); postFolded(room, {title, markdown}); foldedBytes
|
|
24
|
+
// tellOwner(look); showOverview(rows, {fresh}); split(text) -> parts; liveBytes
|
|
25
|
+
import crypto from 'node:crypto';
|
|
26
|
+
import fs from 'node:fs';
|
|
27
|
+
import os from 'node:os';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
import { advance, startCursor } from './cursor.mjs';
|
|
30
|
+
import { isUnavailable, machineOf, plainError, splitWhere, tidyScreen } from './hc.mjs';
|
|
31
|
+
import { menuOf } from './menu.mjs';
|
|
32
|
+
import { coreViews } from './views.mjs';
|
|
33
|
+
|
|
34
|
+
const SHELL_ECHO_MS = 1500;
|
|
35
|
+
const SCREEN_LINES = 40;
|
|
36
|
+
const TURN_SCREEN_LINES = 150;
|
|
37
|
+
const POST_TRIES = 5;
|
|
38
|
+
const SWEEP_MS = 60 * 1000;
|
|
39
|
+
const SWEEP_DONE_MS = 30 * 1000;
|
|
40
|
+
const LIVE_MS = 5000;
|
|
41
|
+
const END_TRIES = 20;
|
|
42
|
+
const END_WAIT_MS = 750;
|
|
43
|
+
const KEY_SETTLE_MS = 700; // a dialog redraws this soon after a key
|
|
44
|
+
const CLICK_BUDGET_MS = 2000; // a click must be answered within 3 s
|
|
45
|
+
const OVERVIEW_WAIT_MS = 2000;
|
|
46
|
+
const UPLOAD_TRIES = 3;
|
|
47
|
+
const DRAW_TRIES = 3;
|
|
48
|
+
const DRAW_WAIT_MS = 400;
|
|
49
|
+
const TITLE_MAX = 40;
|
|
50
|
+
const DIALOG_LINES = 15;
|
|
51
|
+
const NEEDS_TITLE = '⚠️ Needs you';
|
|
52
|
+
const DONE_TITLE = '✅ Done';
|
|
53
|
+
const LIVE_TITLE = '⌛️ Working…';
|
|
54
|
+
const FULL_TITLE = '⌛️ Working… (continued below)'; // a full card while the turn goes on
|
|
55
|
+
const CLOSED_TITLE = '✅ Worked'; // the turn is over
|
|
56
|
+
const ENDED_REPLY = 'Not delivered: this terminal has ended. Nothing you send here reaches it.';
|
|
57
|
+
const WAIT = Symbol('wait');
|
|
58
|
+
const FILE_BYTES = 20 * 1000 * 1000;
|
|
59
|
+
|
|
60
|
+
// hc: {followEvents, cores, machines, getTerminal, screenTail, send, keys, put, run, kill};
|
|
61
|
+
// readers: {agentOf, readerFor}; store: {read, write} of state.json; inbox: a folder for
|
|
62
|
+
// images on their way to a terminal; quick: shorter waits (tests).
|
|
63
|
+
export function createEngine({ cfg, chat, hc, readers, store, log, logWarn, logError, startedAt = new Date().toISOString(), inbox = path.join(os.tmpdir(), 'herdrchat-inbox'), quick = false }) {
|
|
64
|
+
const { read, write } = store;
|
|
65
|
+
const { agentOf, readerFor } = readers;
|
|
66
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, quick ? Math.min(ms, 5) : ms));
|
|
67
|
+
|
|
68
|
+
const views = new Map(); // session -> followEvents handle (its live terminal records)
|
|
69
|
+
|
|
70
|
+
// ---------- which view follows a terminal ----------
|
|
71
|
+
|
|
72
|
+
// One terminal can be seen through several sessions (views.mjs): hc's core ids say
|
|
73
|
+
// which views are the same terminal, and only one of them is followed. They are read at
|
|
74
|
+
// start and again every SWEEP_MS, as machines come and go.
|
|
75
|
+
let topology = coreViews(cfg.sessions, new Map());
|
|
76
|
+
|
|
77
|
+
// A machine that cannot be reached (its link down, or hc says "unavailable") is said
|
|
78
|
+
// once when it goes and once when it is back; meanwhile its terminals are not checked.
|
|
79
|
+
const unreachable = new Set(); // "<session>|<machine>"
|
|
80
|
+
function machineDown(session, machine, why) {
|
|
81
|
+
const id = `${session}|${machine}`;
|
|
82
|
+
if (unreachable.has(id)) return;
|
|
83
|
+
unreachable.add(id);
|
|
84
|
+
logWarn(`machine ${machine} (seen from ${session}) is unreachable; its terminals wait until it is back${why ? `: ${why}` : ''}`);
|
|
85
|
+
}
|
|
86
|
+
function reachable(session, address) {
|
|
87
|
+
const machine = machineOf(address)?.split('/')[0];
|
|
88
|
+
return !machine || (!unreachable.has(`${session}|${machine}`) && topology.reachable(session, address));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function readCores() {
|
|
92
|
+
const found = new Map();
|
|
93
|
+
for (const session of cfg.sessions) {
|
|
94
|
+
try {
|
|
95
|
+
found.set(session, await hc.cores(session));
|
|
96
|
+
} catch (err) {
|
|
97
|
+
logWarn(`cores of ${session}: ${err.message}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
topology = coreViews(cfg.sessions, found);
|
|
101
|
+
for (const [session, info] of found) {
|
|
102
|
+
for (const link of info.links) {
|
|
103
|
+
const id = `${session}|${link.name}`;
|
|
104
|
+
if (link.state !== 'connected') machineDown(session, link.name, link.state);
|
|
105
|
+
else if (unreachable.delete(id)) log(`machine ${link.name} (seen from ${session}) is back`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// A terminal's key in state.json is "<session>|tN", or "@<machine>|<machine>/tN" for one
|
|
111
|
+
// on another machine, as named by the view that follows it.
|
|
112
|
+
const scopeOf = (session, address) => machineOf(address) || session;
|
|
113
|
+
const key = (session, address) => `${machineOf(address) ? '@' : ''}${scopeOf(session, address)}|${address}`;
|
|
114
|
+
const scopeOfKey = (k) => k.slice(k.startsWith('@') ? 1 : 0, k.indexOf('|'));
|
|
115
|
+
const addressOfKey = (k) => k.slice(k.indexOf('|') + 1);
|
|
116
|
+
|
|
117
|
+
function handles(session, address) {
|
|
118
|
+
const owns = topology.owns(session, address);
|
|
119
|
+
if (owns !== null) return owns;
|
|
120
|
+
// cores unknown: a terminal here is ours, one on a machine is the first session's that sees it
|
|
121
|
+
if (!machineOf(address)) return true;
|
|
122
|
+
return cfg.sessions.find((s) => views.get(s)?.terminals.get(address)?.state === 'live') === session;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ---------- lanes ----------
|
|
126
|
+
|
|
127
|
+
// Work for one terminal runs one at a time, in order (its lane): no races on its room
|
|
128
|
+
// or its cursor. Terminals do not wait for each other. Nothing runs before every
|
|
129
|
+
// session's first snapshot and the cores are in, so a terminal on another machine is
|
|
130
|
+
// claimed by the same session every time.
|
|
131
|
+
const firstSnapshot = new Map(cfg.sessions.map((s) => {
|
|
132
|
+
let done;
|
|
133
|
+
const promise = new Promise((r) => { done = r; });
|
|
134
|
+
return [s, { promise, done }];
|
|
135
|
+
}));
|
|
136
|
+
let coresRead = Promise.resolve();
|
|
137
|
+
let ready = Promise.resolve();
|
|
138
|
+
const lanes = new Map(); // terminal key -> its last queued work
|
|
139
|
+
function laneOf(k, label, fn) {
|
|
140
|
+
const next = (lanes.get(k) || ready).then(fn).catch((err) => {
|
|
141
|
+
const address = addressOfKey(k);
|
|
142
|
+
if (isUnavailable(err) && machineOf(address)) machineDown(scopeOfKey(k), machineOf(address).split('/')[0], plainError(err));
|
|
143
|
+
else logError(`${label} ${address}:`, err);
|
|
144
|
+
});
|
|
145
|
+
lanes.set(k, next);
|
|
146
|
+
next.then(() => { if (lanes.get(k) === next) lanes.delete(k); });
|
|
147
|
+
return next;
|
|
148
|
+
}
|
|
149
|
+
const later = (session, address, label, fn) => laneOf(key(session, address), label, fn);
|
|
150
|
+
|
|
151
|
+
// ---------- rooms ----------
|
|
152
|
+
|
|
153
|
+
const shownWhere = (record) => (record && record.state === 'live' ? splitWhere(record.labels?.where) : null);
|
|
154
|
+
|
|
155
|
+
// A room is named after where herdr-app shows the terminal. A tab herdr-app only
|
|
156
|
+
// numbered ("2") says nothing, so the terminal's title stands in for it (what Claude
|
|
157
|
+
// Code sets as its session name or topic), then its hc name; leading status glyphs are
|
|
158
|
+
// dropped, so a spinner in a title never renames a room.
|
|
159
|
+
function tabName(where, record) {
|
|
160
|
+
if (!/^\d+$/.test(where.tab)) return where.tab;
|
|
161
|
+
const title = String(record.title || '').replace(/^[^\p{L}\p{N}]+/u, '').replace(/\s+/g, ' ').trim();
|
|
162
|
+
const name = title || record.name || '';
|
|
163
|
+
if (!name) return where.tab;
|
|
164
|
+
return name.length > TITLE_MAX ? `${name.slice(0, TITLE_MAX - 1)}…` : name;
|
|
165
|
+
}
|
|
166
|
+
const roomName = (where, record) => `${where.space} / ${tabName(where, record)}`;
|
|
167
|
+
|
|
168
|
+
function setRoom(k, fields) {
|
|
169
|
+
const state = read();
|
|
170
|
+
if (state.chats[k]) state.chats[k] = { ...state.chats[k], ...fields };
|
|
171
|
+
write(state);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const roomOf = (session, address) => read().chats[key(session, address)];
|
|
175
|
+
|
|
176
|
+
function terminalOfRoom(room) {
|
|
177
|
+
for (const [k, v] of Object.entries(read().chats)) {
|
|
178
|
+
if (v.chat === room) return { session: v.session, address: addressOfKey(k) };
|
|
179
|
+
}
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// A room belongs to a herdr-app tab, by the tab's id (`herdr.tab`), not to the terminal
|
|
184
|
+
// that happens to run in it: when the terminal is replaced (Claude quit and the tab got
|
|
185
|
+
// a new shell, a program was started again), the new one takes over the tab's room once
|
|
186
|
+
// the old one is gone. A tab that was deleted and made again has a new id, so a new
|
|
187
|
+
// room. Returns the room, null (none: make one), or WAIT (the old terminal still runs:
|
|
188
|
+
// its end hands the room over, see postEnd). The old terminal's last words go first.
|
|
189
|
+
async function adoptTabRoom(k, tab) {
|
|
190
|
+
const before = Object.entries(read().chats).find(([ok, c]) => ok !== k && c.tab === tab && scopeOfKey(ok) === scopeOfKey(k));
|
|
191
|
+
if (!before) return null;
|
|
192
|
+
const [ok, was] = before;
|
|
193
|
+
if (views.get(was.session)?.terminals.get(addressOfKey(ok))?.state === 'live') return WAIT;
|
|
194
|
+
await lanes.get(ok)?.catch(() => {}); // its end (postEnd) runs to the last item
|
|
195
|
+
const state = read();
|
|
196
|
+
const old = state.chats[ok];
|
|
197
|
+
if (!old) return null;
|
|
198
|
+
delete state.chats[ok];
|
|
199
|
+
state.chats[k] = old;
|
|
200
|
+
for (const map of [state.pinned, state.reacts]) {
|
|
201
|
+
if (map?.[ok]) {
|
|
202
|
+
map[k] = map[ok];
|
|
203
|
+
delete map[ok];
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (state.cursors) delete state.cursors[ok];
|
|
207
|
+
write(state);
|
|
208
|
+
log(`room ${old.name} (${old.chat}) now follows ${addressOfKey(k)}, the new terminal of its tab`);
|
|
209
|
+
await chat.post(old.chat, `↪ This tab runs a new terminal now (${addressOfKey(k)}); it carries on here.`).catch((err) => logError(`adopt notice: ${err.message}`));
|
|
210
|
+
return old;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function ensureRoom(session, record) {
|
|
214
|
+
const where = shownWhere(record);
|
|
215
|
+
if (!where || !chat.ready() || !handles(session, record.address)) return null;
|
|
216
|
+
const k = key(session, record.address);
|
|
217
|
+
const name = roomName(where, record);
|
|
218
|
+
const tab = record.labels?.['herdr.tab'] || null;
|
|
219
|
+
// state.json is read again after each await: other lanes write it meanwhile
|
|
220
|
+
const known = read().chats[k] || (tab ? await adoptTabRoom(k, tab) : null);
|
|
221
|
+
if (known === WAIT) return null;
|
|
222
|
+
if (known) {
|
|
223
|
+
if (known.ended) await reviveRoom(k, known);
|
|
224
|
+
if (known.name !== name || known.session !== session || (tab && known.tab !== tab)) {
|
|
225
|
+
if (known.name !== name) await chat.renameRoom(known.chat, name);
|
|
226
|
+
setRoom(k, { name, session, ...(tab ? { tab } : {}) });
|
|
227
|
+
if (known.name !== name) log(`renamed room ${known.chat} -> ${name}`);
|
|
228
|
+
}
|
|
229
|
+
await chat.placeRoom(k, { space: where.space });
|
|
230
|
+
return known.chat;
|
|
231
|
+
}
|
|
232
|
+
const room = await chat.openRoom({ name, intro: `hc terminal ${record.address} (hc session "${session}"). Type here to talk to it.` });
|
|
233
|
+
const state = read();
|
|
234
|
+
state.chats[k] = { chat: room, name, session, ...(tab ? { tab } : {}) };
|
|
235
|
+
write(state);
|
|
236
|
+
log(`created room ${name} (${room})`);
|
|
237
|
+
await chat.placeRoom(k, { space: where.space });
|
|
238
|
+
await backfill(session, record, k, room, name, cfg.history || { file: true, folded: true }).catch((err) => logError(`earlier conversation of ${record.address}:`, err));
|
|
239
|
+
return room;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// A new room shows what came before it, so it does not open empty: the agent's whole
|
|
243
|
+
// conversation so far as one file, and its newest part in one folded post, as much as
|
|
244
|
+
// that holds. Only what lies before the room's cursor (the rest is posted the usual
|
|
245
|
+
// way, so nothing comes twice); silent; only when a room is made, never on a restart.
|
|
246
|
+
const clock = (at) => new Date(at).toISOString().replace('T', ' ').slice(0, 16);
|
|
247
|
+
function transcriptOf(items) {
|
|
248
|
+
const parts = [];
|
|
249
|
+
for (const it of items) {
|
|
250
|
+
if (it.role === 'you') parts.push(`**You** · ${clock(it.at)}\n${it.text.split('\n').map((l) => `> ${l}`).join('\n')}`);
|
|
251
|
+
else if (it.role === 'ai') parts.push(it.text);
|
|
252
|
+
else if (it.role === 'tool') parts.push(it.text);
|
|
253
|
+
else if (it.role === 'image') parts.push(`_(an image ${it.from === 'you' ? 'you pasted' : 'it looked at'})_`);
|
|
254
|
+
}
|
|
255
|
+
return parts.join('\n\n');
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// `parts`: which of {file, folded} to post (HERDR_HISTORY; /history posts both)
|
|
259
|
+
async function backfill(session, record, k, room, name, parts = { file: true, folded: true }) {
|
|
260
|
+
const reader = agentOf(record);
|
|
261
|
+
if (!reader || (!parts.file && !parts.folded)) return;
|
|
262
|
+
const until = cursorOf(k).at;
|
|
263
|
+
const all = await reader.after(session, record.address, startCursor('0000-00-00T00:00:00.000Z'));
|
|
264
|
+
const items = (all?.items || []).filter((it) => it.at <= until);
|
|
265
|
+
if (!items.length) return;
|
|
266
|
+
// a file both apps take (Feishu: 30 MB): the newest part when it is longer
|
|
267
|
+
let whole = transcriptOf(items);
|
|
268
|
+
if (Buffer.byteLength(whole) > FILE_BYTES) whole = `_(the oldest part is left out: the whole is over ${FILE_BYTES / 1e6} MB)_\n\n…${Buffer.from(whole).subarray(-FILE_BYTES).toString()}`;
|
|
269
|
+
const day = clock(items[0].at).slice(0, 10);
|
|
270
|
+
const file = `# ${name}\n\nhc terminal ${record.address} (hc session "${session}"), ${clock(items[0].at)} to ${clock(items[items.length - 1].at)} UTC, ${items.length} items.\n\n${whole}\n`;
|
|
271
|
+
if (parts.file) await chat.postFile(room, { name: `${name.replace(/[^\p{L}\p{N}._-]+/gu, '-')}-${day}.md`, data: Buffer.from(file), caption: `The conversation before this room: ${items.length} items` });
|
|
272
|
+
// the newest items that fit, whole items only, oldest of them first
|
|
273
|
+
const recent = [];
|
|
274
|
+
let bytes = 0;
|
|
275
|
+
for (let i = items.length - 1; i >= 0; i -= 1) {
|
|
276
|
+
const size = Buffer.byteLength(transcriptOf([items[i]])) + 2;
|
|
277
|
+
if (bytes + size > chat.foldedBytes) break;
|
|
278
|
+
recent.unshift(items[i]);
|
|
279
|
+
bytes += size;
|
|
280
|
+
}
|
|
281
|
+
if (parts.folded && recent.length) await chat.postFolded(room, { title: `📜 Before this room${recent.length < items.length ? ` (the last ${recent.length} of ${items.length}${parts.file ? '; all of it is in the file' : ''})` : ''}`, markdown: transcriptOf(recent) });
|
|
282
|
+
log(`<- ${session}/${record.address}: earlier conversation, ${items.length} items (${recent.length} shown)`);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// A terminal that is gone keeps its room, with its history, marked ended, so a new
|
|
286
|
+
// terminal in the same place gets a room that is not mistaken for the old one.
|
|
287
|
+
async function endRoom(k, { again = false } = {}) {
|
|
288
|
+
const known = read().chats[k];
|
|
289
|
+
if (!known || (known.ended && !again)) return;
|
|
290
|
+
setRoom(k, { ended: true });
|
|
291
|
+
if (!again) log(`ended room ${known.name} (${known.chat})`);
|
|
292
|
+
await chat.endRoom(k);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function reviveRoom(k, known) {
|
|
296
|
+
setRoom(k, { ended: false });
|
|
297
|
+
await chat.reviveRoom(k);
|
|
298
|
+
log(`room ${known.name} (${known.chat}) is live again`);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Rooms of terminals that ended while the bot was down get no end event: once every
|
|
302
|
+
// session's first snapshot is in, a room whose terminal is live in no session is ended.
|
|
303
|
+
// A terminal on another machine counts as gone only while that machine is connected,
|
|
304
|
+
// so a dropped connection never ends a room.
|
|
305
|
+
async function endGone() {
|
|
306
|
+
const connected = new Set();
|
|
307
|
+
for (const session of cfg.sessions) {
|
|
308
|
+
for (const m of await hc.machines(session).catch(() => [])) if (m.state === 'connected') connected.add(m.name);
|
|
309
|
+
}
|
|
310
|
+
const alive = new Set(); // every live terminal, by what it is, by address when that is unknown
|
|
311
|
+
for (const session of cfg.sessions) {
|
|
312
|
+
for (const record of views.get(session)?.terminals.values() || []) {
|
|
313
|
+
if (record.state === 'live') alive.add(topology.identity(session, record.address) || record.address);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
for (const [k, c] of Object.entries(read().chats)) {
|
|
317
|
+
const address = addressOfKey(k);
|
|
318
|
+
const machine = machineOf(address);
|
|
319
|
+
if (c.ended || !cfg.sessions.includes(c.session) || (machine && !connected.has(machine))) continue;
|
|
320
|
+
if (alive.has(topology.identity(c.session, address) || address)) continue;
|
|
321
|
+
laneOf(k, 'end', () => endRoom(k));
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ---------- posting ----------
|
|
326
|
+
|
|
327
|
+
// A post is retried; when it still fails, the caller keeps its cursor, so the same
|
|
328
|
+
// content goes out with the next post: a repeat is acceptable, a loss is not.
|
|
329
|
+
async function deliver(session, record, send) {
|
|
330
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
331
|
+
try {
|
|
332
|
+
const room = await ensureRoom(session, record);
|
|
333
|
+
return room ? await send(room) : null;
|
|
334
|
+
} catch (err) {
|
|
335
|
+
if (attempt >= POST_TRIES) throw err;
|
|
336
|
+
logWarn(`post to ${session}/${record.address} failed (${err.message}); retry ${attempt}/${POST_TRIES - 1}`);
|
|
337
|
+
await sleep(1000 * 2 ** attempt);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
const postTo = (session, record, text) => deliver(session, record, (room) => chat.post(room, text));
|
|
342
|
+
|
|
343
|
+
async function screen(session, address, lines = SCREEN_LINES) {
|
|
344
|
+
return `\`\`\`\n${tidyScreen(await hc.screenTail(session, address, lines)) || '(blank)'}\n\`\`\``;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Per terminal, in state.json: the cursor of what its reader posted, which reader and
|
|
348
|
+
// which log that was (kept after the program exits, to post what it left).
|
|
349
|
+
const cursorOf = (k) => read().cursors?.[k] || { ...startCursor(startedAt), reader: null, file: null };
|
|
350
|
+
function saveCursor(k, cursor) {
|
|
351
|
+
const state = read();
|
|
352
|
+
state.cursors = { ...(state.cursors || {}), [k]: cursor };
|
|
353
|
+
write(state);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// What you sent from the chat; its room shows it already, so it is not posted again.
|
|
357
|
+
const fromChat = new Map();
|
|
358
|
+
|
|
359
|
+
function formatItems(k, items) {
|
|
360
|
+
const sent = fromChat.get(k) || [];
|
|
361
|
+
const parts = [];
|
|
362
|
+
for (const it of items) {
|
|
363
|
+
if (it.role === 'tool') {
|
|
364
|
+
const last = parts[parts.length - 1];
|
|
365
|
+
if (last?.startsWith('▸ ')) parts[parts.length - 1] = `${last}\n${it.text}`;
|
|
366
|
+
else parts.push(it.text);
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
if (it.role === 'image') {
|
|
370
|
+
// uploaded by uploadImages. One you send from the room reaches the terminal as a
|
|
371
|
+
// file path, so it comes back as your text, not as an image.
|
|
372
|
+
const shown = it.key || `_(an image ${it.from === 'you' ? 'you pasted' : 'it looked at'} was not sent: ${it.error})_`;
|
|
373
|
+
parts.push(`${it.from === 'you' ? '**You:**\n' : ''}${shown}`);
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
if (it.role === 'you') {
|
|
377
|
+
const i = sent.indexOf(it.text);
|
|
378
|
+
if (i >= 0) {
|
|
379
|
+
sent.splice(i, 1);
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
parts.push(`**You:**\n${it.text.split('\n').map((l) => `> ${l}`).join('\n')}`);
|
|
383
|
+
} else {
|
|
384
|
+
parts.push(it.text);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return parts.join('\n\n');
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// While it works, a turn has one live card: a single message, edited in place as the
|
|
391
|
+
// agent goes on, so the room shows it is alive without a new message (and a ringing
|
|
392
|
+
// phone) every few seconds. The turn's last answer then comes as a new message. A full
|
|
393
|
+
// card is closed and the next one goes on; a card that cannot be edited is sent again
|
|
394
|
+
// as a new one (a repeat, never a loss). It is kept in the cursor (`live: {message,
|
|
395
|
+
// text}`), so a restart goes on editing it.
|
|
396
|
+
async function newLive(session, record, text) {
|
|
397
|
+
const parts = chat.split(text);
|
|
398
|
+
let message = null;
|
|
399
|
+
for (const [i, part] of parts.entries()) {
|
|
400
|
+
const last = i === parts.length - 1;
|
|
401
|
+
message = await deliver(session, record, (room) => chat.postCard(room, { title: last ? LIVE_TITLE : FULL_TITLE, tone: last ? 'working' : 'closed', markdown: part }));
|
|
402
|
+
}
|
|
403
|
+
return message ? { message, text: parts[parts.length - 1] } : null;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async function addLive(session, record, live, add) {
|
|
407
|
+
if (!live) return newLive(session, record, add);
|
|
408
|
+
const text = `${live.text}\n\n${add}`;
|
|
409
|
+
if (Buffer.byteLength(text) > chat.liveBytes) {
|
|
410
|
+
await closeLive(live, FULL_TITLE);
|
|
411
|
+
return newLive(session, record, add);
|
|
412
|
+
}
|
|
413
|
+
try {
|
|
414
|
+
await chat.editCard(live.message, { title: LIVE_TITLE, tone: 'working', markdown: text });
|
|
415
|
+
return { message: live.message, text };
|
|
416
|
+
} catch (err) {
|
|
417
|
+
logError(`live card ${live.message} not editable (${err.message}); sending it again`);
|
|
418
|
+
return newLive(session, record, text);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
async function closeLive(live, title = CLOSED_TITLE) {
|
|
423
|
+
try {
|
|
424
|
+
await chat.editCard(live.message, { title, tone: 'closed', markdown: live.text });
|
|
425
|
+
} catch (err) {
|
|
426
|
+
logError(`live card ${live.message} not closed (${err.message})`); // its text is all there
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Images go to the chat first: each once (by item id), so a post retried after a
|
|
431
|
+
// failure does not upload it again. After UPLOAD_TRIES posts that failed on the same
|
|
432
|
+
// image, the room gets a line saying it was not sent and why, so one image cannot hold
|
|
433
|
+
// back everything after it.
|
|
434
|
+
const uploaded = new Map();
|
|
435
|
+
const uploadFailures = new Map();
|
|
436
|
+
async function uploadImages(items) {
|
|
437
|
+
for (const it of items) {
|
|
438
|
+
if (it.role !== 'image' || it.key || it.error) continue;
|
|
439
|
+
if (!uploaded.has(it.id)) {
|
|
440
|
+
try {
|
|
441
|
+
uploaded.set(it.id, await chat.image(Buffer.from(it.data, 'base64')));
|
|
442
|
+
} catch (err) {
|
|
443
|
+
const failures = (uploadFailures.get(it.id) || 0) + 1;
|
|
444
|
+
uploadFailures.set(it.id, failures);
|
|
445
|
+
if (failures < UPLOAD_TRIES) throw err;
|
|
446
|
+
it.error = err.message;
|
|
447
|
+
logError(`image ${it.id} not sent after ${failures} tries: ${err.message}`);
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
if (uploaded.size > 500) uploaded.delete(uploaded.keys().next().value);
|
|
451
|
+
}
|
|
452
|
+
it.key = uploaded.get(it.id);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// `live`: a post while it works. Everything goes into the live card, except the turn's
|
|
457
|
+
// last answer, which is left for the turn's end (not consumed, so nothing is lost).
|
|
458
|
+
// Otherwise: what came before the last answer goes into the live card, which is closed,
|
|
459
|
+
// and the answer onward is a new message; with no live card, everything is one message.
|
|
460
|
+
async function postItems(session, record, k, cursor, items, reader, file, { live = false } = {}) {
|
|
461
|
+
const answerAt = items.findIndex((it) => it.end);
|
|
462
|
+
const take = live && answerAt >= 0 ? items.slice(0, answerAt) : items;
|
|
463
|
+
if (!take.length && !(cursor.live && !live)) return;
|
|
464
|
+
await uploadImages(take);
|
|
465
|
+
let open = cursor.live || null;
|
|
466
|
+
const head = live ? take : answerAt >= 0 ? take.slice(0, answerAt) : open ? take : [];
|
|
467
|
+
const tail = live ? [] : take.slice(head.length);
|
|
468
|
+
const headText = formatItems(k, head);
|
|
469
|
+
if (headText) open = await addLive(session, record, open, headText);
|
|
470
|
+
if (!live && open) {
|
|
471
|
+
await closeLive(open);
|
|
472
|
+
open = null;
|
|
473
|
+
}
|
|
474
|
+
const tailText = formatItems(k, tail);
|
|
475
|
+
if (tailText) await chat.ring(k, await postTo(session, record, tailText), '✅ done');
|
|
476
|
+
const last = take[take.length - 1];
|
|
477
|
+
saveCursor(k, { ...advance(cursor, take), reader: reader.app, file, ended: last ? last.role === 'ai' && Boolean(last.end) : cursor.ended, live: open });
|
|
478
|
+
if (take.length) log(`<- ${session}/${record.address}: ${take.length} items (${reader.app}${live ? ', live card' : ''})`);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Posts what a terminal said since the last post, through its reader. With
|
|
482
|
+
// `waitForEnd`, it first waits for the turn's last answer (hc reports the end before
|
|
483
|
+
// the agent writes it). Returns false when the terminal is not readable.
|
|
484
|
+
async function postRead(session, address, record, reader, { waitForEnd, live = false }) {
|
|
485
|
+
const k = key(session, address);
|
|
486
|
+
const cursor = cursorOf(k);
|
|
487
|
+
let found = null;
|
|
488
|
+
for (let attempt = 0; attempt < (waitForEnd ? END_TRIES : 1); attempt += 1) {
|
|
489
|
+
found = await reader.after(session, address, cursor);
|
|
490
|
+
if (!found || found.items.some((it) => it.end)) break;
|
|
491
|
+
if (!found.items.length && cursor.ended) break; // the live posts already reached the turn's end
|
|
492
|
+
await sleep(END_WAIT_MS);
|
|
493
|
+
}
|
|
494
|
+
if (!found) return false;
|
|
495
|
+
await postItems(session, record, k, cursor, found.items, reader, found.file, { live });
|
|
496
|
+
return true;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
async function postTurn(session, address, record) {
|
|
500
|
+
const reader = readerFor(record.status?.app);
|
|
501
|
+
if (reader) {
|
|
502
|
+
try {
|
|
503
|
+
if (await postRead(session, address, record, reader, { waitForEnd: true })) return;
|
|
504
|
+
} catch (err) {
|
|
505
|
+
logError(`${session}/${address}: ${reader.app} not readable (${err.message}); posting the screen`);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
await chat.ring(key(session, address), await postTo(session, record, await screen(session, address, TURN_SCREEN_LINES)), '✅ done');
|
|
509
|
+
log(`<- ${session}/${address}: screen`);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// The program is gone: post what its log still holds, then say it ended.
|
|
513
|
+
async function postEnd(session, address, record, reason) {
|
|
514
|
+
const k = key(session, address);
|
|
515
|
+
const cursor = cursorOf(k);
|
|
516
|
+
const reader = readerFor(cursor.reader);
|
|
517
|
+
if (reader && cursor.file) {
|
|
518
|
+
try {
|
|
519
|
+
await postItems(session, record, k, cursor, await reader.rest(session, address, cursor), reader, cursor.file);
|
|
520
|
+
} catch (err) {
|
|
521
|
+
logError(`${session}/${address}: last ${reader.app} entries not readable (${err.message})`);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
await postTo(session, record, `🔚 **Terminal ended**${reason ? ` (${reason})` : ''}`);
|
|
525
|
+
await markDone(k);
|
|
526
|
+
await endRoom(k);
|
|
527
|
+
// a new terminal that came up in the same tab while this one still ran takes the room now
|
|
528
|
+
const tab = read().chats[k]?.tab;
|
|
529
|
+
const next = tab && [...(views.get(session)?.terminals.values() || [])].find((t) => t.state === 'live' && t.address !== address && t.labels?.['herdr.tab'] === tab);
|
|
530
|
+
if (next) later(session, next.address, 'room', () => ensureRoom(session, next));
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// ---------- dialogs: it waits for you ----------
|
|
534
|
+
|
|
535
|
+
// What each dialog card showed, by message, so an answered card keeps it.
|
|
536
|
+
const dialogCards = new Map();
|
|
537
|
+
function remember(message, shown) {
|
|
538
|
+
dialogCards.set(message, shown);
|
|
539
|
+
if (dialogCards.size > 200) dialogCards.delete(dialogCards.keys().next().value);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// With a menu, the card shows only the dialog, as plain text that wraps on a phone;
|
|
543
|
+
// without one, the last lines of the screen, where a dialog sits.
|
|
544
|
+
const escapeMarkdown = (text) => text.replace(/[\\`*_[\]#>~|]/g, '\\$&');
|
|
545
|
+
function dialogOf(status, screenText) {
|
|
546
|
+
const menu = menuOf(screenText);
|
|
547
|
+
const title = `${NEEDS_TITLE}${status?.kind ? ` (${status.kind})` : ''}`;
|
|
548
|
+
const markdown = menu ? escapeMarkdown(menu.text) : `\`\`\`\n${tidyScreen(screenText, DIALOG_LINES)}\n\`\`\``;
|
|
549
|
+
return { menu, title, markdown, doneTitle: DONE_TITLE };
|
|
550
|
+
}
|
|
551
|
+
const dialogValue = (session, address, dialog) => ({ s: session, a: address, k: dialog.menu.key });
|
|
552
|
+
|
|
553
|
+
// One dialog gets one card. A dialog is named by its menu key (or, with no menu, by its
|
|
554
|
+
// status words); the name is claimed before anything is sent, so the turn event, a
|
|
555
|
+
// status event and a click that all see the same dialog post it once. Leaving `blocked`
|
|
556
|
+
// forgets it, so the same dialog asked again later gets a card again.
|
|
557
|
+
const postedDialog = new Map(); // terminal key -> name of the dialog last posted
|
|
558
|
+
const dialogName = (dialog, status) => (dialog.menu ? dialog.menu.key : `${status?.kind || ''}|${status?.msg || ''}`);
|
|
559
|
+
function claimDialog(k, dialog, status) {
|
|
560
|
+
const name = dialogName(dialog, status);
|
|
561
|
+
if (postedDialog.get(k) === name) return false;
|
|
562
|
+
postedDialog.set(k, name);
|
|
563
|
+
return true;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// The dialog card still waiting, per terminal. When its dialog goes away, the card drops
|
|
567
|
+
// its buttons and says so, so the room never shows a question nobody waits on.
|
|
568
|
+
const openCard = new Map(); // terminal key -> {message, dialog}
|
|
569
|
+
async function closeCard(k, done) {
|
|
570
|
+
const open = openCard.get(k);
|
|
571
|
+
if (!open) return;
|
|
572
|
+
openCard.delete(k);
|
|
573
|
+
try {
|
|
574
|
+
await chat.closeDialog(open.message, open.dialog, done);
|
|
575
|
+
} catch (err) {
|
|
576
|
+
logError(`dialog card ${open.message} not closed (${err.message})`);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// `screen`: the screen read when the event came (a promise), so a dialog answered in
|
|
581
|
+
// the terminal while earlier work was still being posted still gets its card. `again`:
|
|
582
|
+
// post it even if it was posted (you typed in the room while it waits). A screen read
|
|
583
|
+
// the moment `blocked` arrives may come before the dialog is drawn: with no menu on it,
|
|
584
|
+
// the screen is read again a few times.
|
|
585
|
+
async function postBlocked(session, address, record, { screen: first = null, again = false } = {}) {
|
|
586
|
+
const firstScreen = await first;
|
|
587
|
+
let dialog = firstScreen ? dialogOf(record.status, firstScreen) : null;
|
|
588
|
+
for (let i = 0; i < DRAW_TRIES && !dialog?.menu; i += 1) {
|
|
589
|
+
if (i) await sleep(DRAW_WAIT_MS);
|
|
590
|
+
dialog = dialogOf(record.status, await hc.screenTail(session, address, SCREEN_LINES));
|
|
591
|
+
}
|
|
592
|
+
const k = key(session, address);
|
|
593
|
+
if (!claimDialog(k, dialog, record.status) && !again) return;
|
|
594
|
+
await closeCard(k, 'It moved on; see the next card.');
|
|
595
|
+
if (!dialog.menu) {
|
|
596
|
+
const sent = await postTo(session, record, `**${dialog.title}**\n\n${dialog.markdown}\n\nReply \`/answer <text>\` to answer it, or \`/esc\` to cancel it.`);
|
|
597
|
+
const room = read().chats[k]?.chat;
|
|
598
|
+
if (room) await chat.pin(k, room);
|
|
599
|
+
await chat.ring(k, sent, '⚠️ needs you');
|
|
600
|
+
} else {
|
|
601
|
+
const message = await deliver(session, record, (room) => chat.postDialog(room, dialog, dialogValue(session, address, dialog)));
|
|
602
|
+
if (message) {
|
|
603
|
+
remember(message, dialog);
|
|
604
|
+
openCard.set(k, { message, dialog });
|
|
605
|
+
const room = read().chats[k]?.chat;
|
|
606
|
+
if (room) await chat.pin(k, room);
|
|
607
|
+
await chat.ring(k, message, '⚠️ needs you');
|
|
608
|
+
// answered while the card was on its way: nothing will close it later, so now
|
|
609
|
+
const live = views.get(session)?.terminals.get(address);
|
|
610
|
+
if (live?.status?.state !== 'blocked' || postedDialog.get(k) !== dialogName(dialog, record.status)) await closeCard(k, '✔ Answered.');
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
log(`<- ${session}/${address}: blocked${dialog.menu ? ` (${dialog.menu.options.length} options)` : ''}`);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// "Type something": pick that option, then type the words.
|
|
617
|
+
async function answerFree(session, address, menu, text) {
|
|
618
|
+
await hc.keys(session, address, [String(menu.free.n)]);
|
|
619
|
+
await sleep(300);
|
|
620
|
+
await hc.send(session, address, text, { force: true });
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
const toast = (type, text) => ({ toast: { type, text } });
|
|
624
|
+
const answered = (shown, done) => ({ ...toast('info', done), card: { kind: 'done', dialog: shown, done } });
|
|
625
|
+
|
|
626
|
+
// A click on a dialog card. The screen is read again first, and nothing is pressed
|
|
627
|
+
// unless it still shows the dialog the card was made from (same key) with the same
|
|
628
|
+
// option. After the key, the card turns into the dialog's next step (the next question,
|
|
629
|
+
// the review, a toggled box) or says what was picked.
|
|
630
|
+
async function onAction({ value: v = {}, message, answer = '' }) {
|
|
631
|
+
const started = Date.now();
|
|
632
|
+
const shown = dialogCards.get(message) || { title: NEEDS_TITLE, markdown: '', doneTitle: DONE_TITLE };
|
|
633
|
+
const record = views.get(v.s)?.terminals.get(v.a);
|
|
634
|
+
if (!record || record.state !== 'live') return answered(shown, 'The terminal has ended.');
|
|
635
|
+
const status = (await hc.getTerminal(v.s, v.a)).status;
|
|
636
|
+
const menu = status?.state === 'blocked' ? menuOf(await hc.screenTail(v.s, v.a, SCREEN_LINES)) : null;
|
|
637
|
+
if (!menu || menu.key !== v.k) {
|
|
638
|
+
if (menu) later(v.s, v.a, 'dialog', () => postBlocked(v.s, v.a, { ...record, status }));
|
|
639
|
+
return answered(shown, menu ? 'It now asks something else; see the new card.' : 'It no longer waits on this.');
|
|
640
|
+
}
|
|
641
|
+
const k = key(v.s, v.a);
|
|
642
|
+
if (openCard.get(k)?.message === message) openCard.delete(k);
|
|
643
|
+
let picked;
|
|
644
|
+
if (v.pick === 'esc' || v.pick === 'tab') {
|
|
645
|
+
await hc.keys(v.s, v.a, [v.pick]);
|
|
646
|
+
picked = v.pick === 'esc' ? 'Esc pressed' : 'Next';
|
|
647
|
+
} else {
|
|
648
|
+
const option = menu.options.find((o) => o.n === v.pick);
|
|
649
|
+
if (!option || option.label !== v.label) return answered(shown, 'The options changed; nothing was pressed.');
|
|
650
|
+
if (option === menu.free) {
|
|
651
|
+
const text = answer.trim();
|
|
652
|
+
if (!text) return toast('warning', 'Type your answer first.');
|
|
653
|
+
await answerFree(v.s, v.a, menu, text);
|
|
654
|
+
picked = text;
|
|
655
|
+
} else {
|
|
656
|
+
await hc.keys(v.s, v.a, [String(option.n)]);
|
|
657
|
+
picked = `${option.n}. ${option.label}`;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
log(`-> ${v.s}/${v.a}: picked ${picked}`);
|
|
661
|
+
|
|
662
|
+
// A slow machine: answer the click now, show the next step as a new card after.
|
|
663
|
+
if (Date.now() - started > CLICK_BUDGET_MS) {
|
|
664
|
+
later(v.s, v.a, 'dialog', async () => {
|
|
665
|
+
await sleep(KEY_SETTLE_MS);
|
|
666
|
+
const next = (await hc.getTerminal(v.s, v.a)).status;
|
|
667
|
+
if (next?.state === 'blocked') await postBlocked(v.s, v.a, { ...record, status: next });
|
|
668
|
+
});
|
|
669
|
+
return answered(shown, `✔ ${picked}`);
|
|
670
|
+
}
|
|
671
|
+
await sleep(KEY_SETTLE_MS);
|
|
672
|
+
const nextStatus = (await hc.getTerminal(v.s, v.a)).status;
|
|
673
|
+
if (nextStatus?.state === 'blocked') {
|
|
674
|
+
const next = dialogOf(nextStatus, await hc.screenTail(v.s, v.a, SCREEN_LINES));
|
|
675
|
+
// the same dialog (a toggled box) is redrawn here; a new one only if no card has it yet
|
|
676
|
+
if (next.menu && (next.menu.key === v.k || claimDialog(k, next, nextStatus))) {
|
|
677
|
+
remember(message, next);
|
|
678
|
+
openCard.set(k, { message, dialog: next });
|
|
679
|
+
return { ...toast('info', `✔ ${picked}`), card: { kind: 'dialog', dialog: next, value: dialogValue(v.s, v.a, next) } };
|
|
680
|
+
}
|
|
681
|
+
if (next.menu) return answered(shown, `✔ ${picked}`); // its card is out already
|
|
682
|
+
// still waiting, on something with no menu: hc sends no new turn for it, so post it
|
|
683
|
+
later(v.s, v.a, 'dialog', () => postBlocked(v.s, v.a, { ...record, status: nextStatus }));
|
|
684
|
+
}
|
|
685
|
+
return answered(shown, `✔ ${picked}`);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// ---------- following hc ----------
|
|
689
|
+
|
|
690
|
+
// Shown terminals this session runs whose program is a running agent, with its reader.
|
|
691
|
+
function readable(session) {
|
|
692
|
+
return [...(views.get(session)?.terminals.values() || [])]
|
|
693
|
+
.filter((record) => shownWhere(record) && handles(session, record.address) && reachable(session, record.address))
|
|
694
|
+
.map((record) => ({ record, reader: agentOf(record) }))
|
|
695
|
+
.filter((x) => x.reader);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// A missed turn event (bot down, event stream reconnected) or a turn posted before its
|
|
699
|
+
// last answer was written would leave content behind; idle readable terminals are
|
|
700
|
+
// checked again at start, after every snapshot and every SWEEP_MS.
|
|
701
|
+
function sweep(session) {
|
|
702
|
+
for (const { record, reader } of readable(session)) {
|
|
703
|
+
if (['working', 'blocked'].includes(record.status?.state)) continue;
|
|
704
|
+
later(session, record.address, 'sweep', async () => {
|
|
705
|
+
await postRead(session, record.address, record, reader, { waitForEnd: false });
|
|
706
|
+
await markDone(key(session, record.address), { olderThan: SWEEP_DONE_MS }); // a turn end that was missed
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// While a readable terminal works, what it says and does is posted every LIVE_MS; the
|
|
712
|
+
// turn's end then posts the rest. One check per terminal at a time.
|
|
713
|
+
const liveBusy = new Set();
|
|
714
|
+
function live(session) {
|
|
715
|
+
for (const { record, reader } of readable(session)) {
|
|
716
|
+
if (record.status?.state !== 'working') continue;
|
|
717
|
+
const k = key(session, record.address);
|
|
718
|
+
if (liveBusy.has(k)) continue;
|
|
719
|
+
liveBusy.add(k);
|
|
720
|
+
later(session, record.address, 'live', () => postRead(session, record.address, record, reader, { waitForEnd: false, live: true })).finally(() => liveBusy.delete(k));
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// hc sends a turn event only when a program leaves `working`. A program that goes from
|
|
725
|
+
// one dialog straight to the next never works in between, so the status events catch
|
|
726
|
+
// that: blocked, and not coming from working (that case is the turn event's, which
|
|
727
|
+
// posts what it said first).
|
|
728
|
+
const lastState = new Map(); // terminal key -> status state at the last status event
|
|
729
|
+
const since = new Map(); // terminal key -> when its status state last changed
|
|
730
|
+
|
|
731
|
+
function onStatus(session, address, record) {
|
|
732
|
+
const k = key(session, address);
|
|
733
|
+
const was = lastState.get(k);
|
|
734
|
+
const now = record.status?.state;
|
|
735
|
+
lastState.set(k, now);
|
|
736
|
+
if (now !== was) since.set(k, Date.now());
|
|
737
|
+
if (now !== 'blocked') {
|
|
738
|
+
postedDialog.delete(k);
|
|
739
|
+
if (chat.pinned().includes(k)) later(session, address, 'unpin', () => chat.unpin(k));
|
|
740
|
+
if (openCard.has(k)) later(session, address, 'dialog', () => closeCard(k, '✔ Answered.'));
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
if (was === 'working' || !shownWhere(record) || !handles(session, address) || !agentOf(record)) return;
|
|
744
|
+
// the screen and the status as they are now: by the time the lane gets to it, the
|
|
745
|
+
// dialog may be answered and the status moved on
|
|
746
|
+
const screenNow = hc.screenTail(session, address, SCREEN_LINES).catch(() => null);
|
|
747
|
+
const status = { ...record.status };
|
|
748
|
+
later(session, address, 'dialog', () => postBlocked(session, address, { ...record, status }, { screen: screenNow }));
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function watch(session) {
|
|
752
|
+
const view = hc.followEvents(session, {
|
|
753
|
+
log,
|
|
754
|
+
onSnapshot: (list) => {
|
|
755
|
+
firstSnapshot.get(session).done();
|
|
756
|
+
scheduleOverview();
|
|
757
|
+
for (const record of list) if (shownWhere(record)) later(session, record.address, 'room', () => ensureRoom(session, record));
|
|
758
|
+
setTimeout(() => sweep(session), 0); // after views has this session's records
|
|
759
|
+
},
|
|
760
|
+
onChange: ({ type, address, record, data }) => {
|
|
761
|
+
scheduleOverview();
|
|
762
|
+
if (type === 'status' && record) onStatus(session, address, record);
|
|
763
|
+
if (['created', 'restarted', 'labels', 'status', 'title'].includes(type) && shownWhere(record)) {
|
|
764
|
+
later(session, address, 'room', () => ensureRoom(session, record));
|
|
765
|
+
} else if ((type === 'finished' || type === 'removed') && roomOf(session, address)?.session === session) {
|
|
766
|
+
later(session, address, 'end', () => postEnd(session, address, record || { address }, data?.reason));
|
|
767
|
+
}
|
|
768
|
+
},
|
|
769
|
+
onTurn: ({ address, state, record }) => {
|
|
770
|
+
scheduleOverview();
|
|
771
|
+
log(`turn ${session}/${address}: ${state}${shownWhere(record) ? '' : ' (not shown in herdr-app)'}`);
|
|
772
|
+
// a room mirrors agents: a shell or another program posts nothing by itself
|
|
773
|
+
if (!shownWhere(record) || !handles(session, address) || !agentOf(record)) return;
|
|
774
|
+
const screenNow = state === 'blocked' ? hc.screenTail(session, address, SCREEN_LINES).catch(() => null) : null;
|
|
775
|
+
const status = { ...record.status };
|
|
776
|
+
later(session, address, 'turn', async () => {
|
|
777
|
+
if (state === 'blocked') {
|
|
778
|
+
// what it said before stopping to ask comes first
|
|
779
|
+
const reader = agentOf(record);
|
|
780
|
+
if (reader) await postRead(session, address, record, reader, { waitForEnd: false }).catch(() => {});
|
|
781
|
+
await postBlocked(session, address, { ...record, status }, { screen: screenNow });
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
await postTurn(session, address, record);
|
|
785
|
+
await markDone(key(session, address));
|
|
786
|
+
});
|
|
787
|
+
},
|
|
788
|
+
});
|
|
789
|
+
views.set(session, view);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// ---------- from the chat ----------
|
|
793
|
+
|
|
794
|
+
// What you sent from a room is marked: working once it is typed into the terminal,
|
|
795
|
+
// done when the turn that took it is over (the turn ends, a terminal with no agent
|
|
796
|
+
// shows what the command did, or the terminal ends). Which messages wait for their
|
|
797
|
+
// done mark is kept in state.json (reacts), so a restart still marks them. A failure
|
|
798
|
+
// is only logged.
|
|
799
|
+
async function markWorking(k, message) {
|
|
800
|
+
try {
|
|
801
|
+
const token = await chat.markWorking(message);
|
|
802
|
+
const state = read();
|
|
803
|
+
state.reacts = { ...(state.reacts || {}), [k]: [...(state.reacts?.[k] || []), { message, reaction: token, at: Date.now() }] };
|
|
804
|
+
write(state);
|
|
805
|
+
} catch (err) {
|
|
806
|
+
logError(`reaction on ${message}: ${err.message}`);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// `olderThan`: only messages typed in at least this long ago (the sweep: a message just
|
|
811
|
+
// typed in may not have started a turn yet).
|
|
812
|
+
async function markDone(k, { olderThan = 0 } = {}) {
|
|
813
|
+
const all = read().reacts?.[k] || [];
|
|
814
|
+
const waiting = all.filter((x) => Date.now() - (x.at || 0) >= olderThan);
|
|
815
|
+
if (!waiting.length) return;
|
|
816
|
+
const state = read();
|
|
817
|
+
const left = (state.reacts?.[k] || []).filter((x) => !waiting.some((w) => w.message === x.message));
|
|
818
|
+
if (left.length) state.reacts[k] = left;
|
|
819
|
+
else delete state.reacts[k];
|
|
820
|
+
write(state);
|
|
821
|
+
for (const { message, reaction } of waiting) {
|
|
822
|
+
try {
|
|
823
|
+
await chat.markDone(message, reaction);
|
|
824
|
+
} catch (err) {
|
|
825
|
+
logError(`reaction on ${message}: ${err.message}`);
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
async function typeInto(where, message, session, address, text) {
|
|
831
|
+
const record = views.get(session)?.terminals.get(address);
|
|
832
|
+
if (!record || record.state !== 'live') {
|
|
833
|
+
await chat.reply(where, ENDED_REPLY);
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
const agent = agentOf(record);
|
|
837
|
+
if (text === '/history') {
|
|
838
|
+
const k = key(session, address);
|
|
839
|
+
const c = read().chats[k];
|
|
840
|
+
if (!agent || !c) await chat.reply(where, 'No agent runs here, so there is no conversation to show.');
|
|
841
|
+
else await backfill(session, record, k, c.chat, c.name);
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
if (text === '/screen' || text === '/esc') {
|
|
845
|
+
if (text === '/esc') await hc.keys(session, address, ['esc']);
|
|
846
|
+
await chat.reply(where, `${text === '/esc' ? 'Esc pressed.\n\n' : ''}${await screen(session, address)}`);
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
const answer = text.match(/^\/answer\s+([\s\S]+)$/);
|
|
850
|
+
if (!answer && agent && record.status?.state === 'blocked') {
|
|
851
|
+
// It waits on a dialog: words go into its "Type something" box when it has one;
|
|
852
|
+
// otherwise the dialog card comes again, to pick there.
|
|
853
|
+
const menu = menuOf(await hc.screenTail(session, address, SCREEN_LINES));
|
|
854
|
+
if (menu?.free) {
|
|
855
|
+
await answerFree(session, address, menu, text);
|
|
856
|
+
log(`-> ${session}/${address}: answered ${text.slice(0, 80)}`);
|
|
857
|
+
await markWorking(key(session, address), message);
|
|
858
|
+
} else {
|
|
859
|
+
await postBlocked(session, address, record, { again: true });
|
|
860
|
+
}
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
try {
|
|
864
|
+
const typed = answer ? answer[1] : text;
|
|
865
|
+
await hc.send(session, address, typed, { force: Boolean(answer) });
|
|
866
|
+
const k = key(session, address);
|
|
867
|
+
fromChat.set(k, [...(fromChat.get(k) || []).slice(-20), typed.trim()]);
|
|
868
|
+
log(`-> ${session}/${address}: ${text.slice(0, 80)}`);
|
|
869
|
+
await markWorking(k, message);
|
|
870
|
+
} catch (err) {
|
|
871
|
+
const hint = /blocked/i.test(err.message) ? '\n\nIt waits on a dialog. Reply `/answer <text>` to answer it, or `/esc` to cancel it.' : '';
|
|
872
|
+
await chat.reply(where, `Not delivered: ${plainError(err)}\n\n${await screen(session, address)}${hint}`);
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
875
|
+
// Anything but an agent posts nothing by itself: what you typed gets one look at its
|
|
876
|
+
// screen in reply.
|
|
877
|
+
if (!agent) {
|
|
878
|
+
await sleep(SHELL_ECHO_MS);
|
|
879
|
+
await chat.reply(where, await screen(session, address));
|
|
880
|
+
await markDone(key(session, address));
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// Images you send to a room: saved here, sent beside the terminal (on its machine) with
|
|
885
|
+
// `hc put`, and typed in as their paths, which an agent like Claude Code opens.
|
|
886
|
+
function extOf(head) {
|
|
887
|
+
if (head[0] === 0x89 && head.toString('latin1', 1, 4) === 'PNG') return 'png';
|
|
888
|
+
if (head[0] === 0xff && head[1] === 0xd8) return 'jpg';
|
|
889
|
+
if (head.toString('latin1', 0, 4) === 'GIF8') return 'gif';
|
|
890
|
+
if (head.toString('latin1', 0, 4) === 'RIFF' && head.toString('latin1', 8, 12) === 'WEBP') return 'webp';
|
|
891
|
+
return 'png';
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
async function imagesInto(message, session, address, refs) {
|
|
895
|
+
fs.mkdirSync(inbox, { recursive: true });
|
|
896
|
+
const landed = [];
|
|
897
|
+
for (const [i, ref] of refs.entries()) {
|
|
898
|
+
const raw = path.join(inbox, `${String(message).replace(/[^\w-]/g, '_')}-${i}`);
|
|
899
|
+
await chat.download(message, ref, raw);
|
|
900
|
+
const file = `${raw}.${extOf(fs.readFileSync(raw).subarray(0, 12))}`;
|
|
901
|
+
fs.renameSync(raw, file);
|
|
902
|
+
landed.push(await hc.put(session, address, file));
|
|
903
|
+
fs.unlinkSync(file); // the terminal has its own copy now
|
|
904
|
+
}
|
|
905
|
+
return landed;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
// ---------- the overview and the direct chat ----------
|
|
909
|
+
|
|
910
|
+
// The overview is shown again whenever a terminal changes; changes within
|
|
911
|
+
// OVERVIEW_WAIT_MS make one update.
|
|
912
|
+
let overviewTimer = null;
|
|
913
|
+
function overviewRows() {
|
|
914
|
+
const rows = [];
|
|
915
|
+
for (const session of cfg.sessions) {
|
|
916
|
+
for (const record of views.get(session)?.terminals.values() || []) {
|
|
917
|
+
const where = shownWhere(record);
|
|
918
|
+
if (!where || !handles(session, record.address)) continue;
|
|
919
|
+
const k = key(session, record.address);
|
|
920
|
+
rows.push({
|
|
921
|
+
name: roomName(where, record),
|
|
922
|
+
address: session === cfg.sessions[0] || machineOf(record.address) ? record.address : `${session} ${record.address}`,
|
|
923
|
+
state: record.status?.state,
|
|
924
|
+
kind: record.status?.kind,
|
|
925
|
+
program: record.foreground?.name || record.agent,
|
|
926
|
+
since: since.get(k),
|
|
927
|
+
room: read().chats[k]?.chat,
|
|
928
|
+
});
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
return rows;
|
|
932
|
+
}
|
|
933
|
+
const refreshOverview = (opts) => chat.showOverview(overviewRows(), opts);
|
|
934
|
+
function scheduleOverview() {
|
|
935
|
+
if (overviewTimer) return;
|
|
936
|
+
overviewTimer = setTimeout(() => {
|
|
937
|
+
overviewTimer = null;
|
|
938
|
+
refreshOverview().catch((err) => logError(`overview: ${err.message}`));
|
|
939
|
+
}, quick ? 5 : OVERVIEW_WAIT_MS);
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
const help = () => [
|
|
943
|
+
'`/ls` a new overview of every terminal (the overview also keeps itself up to date)',
|
|
944
|
+
'`/new <session> <space>/<tab> [command]` start a terminal (default `claude`); `--cwd <dir>` first to pick its folder',
|
|
945
|
+
'`/kill <session> <address>` end a terminal',
|
|
946
|
+
...(chat.help || []),
|
|
947
|
+
'In a terminal\'s room: type to talk to it; `/screen`, `/answer <text>`, `/esc`, `/history` (the conversation so far, as a file).',
|
|
948
|
+
].join('\n');
|
|
949
|
+
|
|
950
|
+
async function newTerminal(args) {
|
|
951
|
+
let cwd = null;
|
|
952
|
+
if (args[0] === '--cwd') {
|
|
953
|
+
cwd = args[1];
|
|
954
|
+
args = args.slice(2);
|
|
955
|
+
}
|
|
956
|
+
const [session, place, ...command] = args;
|
|
957
|
+
if (!cfg.sessions.includes(session)) throw new Error(`unknown session "${session}"; known: ${cfg.sessions.join(', ')}`);
|
|
958
|
+
const slash = (place || '').indexOf('/');
|
|
959
|
+
if (slash < 1 || slash === place.length - 1) throw new Error('give the place as <space>/<tab>');
|
|
960
|
+
const space = place.slice(0, slash);
|
|
961
|
+
const tab = place.slice(slash + 1);
|
|
962
|
+
const sibling = [...views.get(session).terminals.values()].find((t) => shownWhere(t)?.space === space && t.labels?.['herdr.space']);
|
|
963
|
+
const spaceId = sibling?.labels['herdr.space'] || `sp${crypto.randomBytes(16).toString('hex')}`;
|
|
964
|
+
const folder = cwd || sibling?.foreground?.cwd || sibling?.cwd || os.homedir();
|
|
965
|
+
const line = command.length ? command.join(' ') : 'claude';
|
|
966
|
+
const out = await hc.run(session, {
|
|
967
|
+
cwd: folder,
|
|
968
|
+
labels: { 'herdr.space': spaceId, 'herdr.tab': `tb${crypto.randomBytes(16).toString('hex')}`, where: `${space} / ${tab}` },
|
|
969
|
+
argv: ['/bin/zsh', '-lc', `exec ${line}`], // login shell for PATH; exec so no bare shell is left behind
|
|
970
|
+
});
|
|
971
|
+
const address = JSON.parse(out).address;
|
|
972
|
+
return `Started \`${line}\` as \`${address}\` in ${session} / ${space} / ${tab} (\`${folder}\`). Its room appears in a moment.`;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
async function direct(where, text) {
|
|
976
|
+
const [cmd, ...args] = text.split(/\s+/);
|
|
977
|
+
if (cmd === '/ls') return refreshOverview({ fresh: true });
|
|
978
|
+
if (cmd === '/new') return chat.reply(where, await newTerminal(args));
|
|
979
|
+
if (cmd === '/kill') {
|
|
980
|
+
const [session, address] = args;
|
|
981
|
+
if (!cfg.sessions.includes(session) || !address) throw new Error('give `/kill <session> <address>`');
|
|
982
|
+
await hc.kill(session, address);
|
|
983
|
+
return chat.reply(where, `Ended \`${address}\` in ${session}.`);
|
|
984
|
+
}
|
|
985
|
+
if (await chat.command?.(cmd, args, where)) return null;
|
|
986
|
+
return chat.reply(where, help());
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
async function onMessage({ room, where, message, text, images = [] }) {
|
|
990
|
+
try {
|
|
991
|
+
if (!room) {
|
|
992
|
+
if (images.length) await chat.reply(where, 'Send images in a terminal\'s room.');
|
|
993
|
+
else await direct(where, text);
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
996
|
+
const target = terminalOfRoom(room);
|
|
997
|
+
if (!target) {
|
|
998
|
+
log(`message in a room this bot did not make: ${room}`);
|
|
999
|
+
return;
|
|
1000
|
+
}
|
|
1001
|
+
if (views.get(target.session)?.terminals.get(target.address)?.state !== 'live') {
|
|
1002
|
+
await chat.reply(where, ENDED_REPLY);
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
const files = await imagesInto(message, target.session, target.address, images);
|
|
1006
|
+
await typeInto(where, message, target.session, target.address, [text, ...files].filter(Boolean).join(' '));
|
|
1007
|
+
} catch (err) {
|
|
1008
|
+
logError(`message to ${where}:`, err);
|
|
1009
|
+
await chat.reply(where, `Not delivered: ${plainError(err)}`).catch(() => {});
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
// ---------- start ----------
|
|
1014
|
+
|
|
1015
|
+
const timers = [];
|
|
1016
|
+
function start({ onReady = () => {} } = {}) {
|
|
1017
|
+
log(`herdrchat (${chat.name}): sessions ${cfg.sessions.join(', ')}`);
|
|
1018
|
+
if (!chat.ready()) log('owner not set: no rooms are made until it is. Send the bot any direct message.');
|
|
1019
|
+
coresRead = readCores();
|
|
1020
|
+
ready = Promise.all([coresRead, ...[...firstSnapshot.values()].map((x) => x.promise)]);
|
|
1021
|
+
chat.start({ onMessage, onAction, onReady });
|
|
1022
|
+
for (const session of cfg.sessions) watch(session);
|
|
1023
|
+
ready.then(() => setTimeout(async () => {
|
|
1024
|
+
await endGone().catch((err) => logError(`end gone rooms: ${err.message}`));
|
|
1025
|
+
// pins left from before a restart, for terminals that no longer wait
|
|
1026
|
+
for (const k of chat.pinned()) {
|
|
1027
|
+
const address = addressOfKey(k);
|
|
1028
|
+
if (!cfg.sessions.some((s) => views.get(s)?.terminals.get(address)?.status?.state === 'blocked')) await chat.unpin(k);
|
|
1029
|
+
}
|
|
1030
|
+
}, 0));
|
|
1031
|
+
timers.push(setInterval(() => readCores().then(() => cfg.sessions.forEach(sweep)), SWEEP_MS));
|
|
1032
|
+
timers.push(setInterval(() => cfg.sessions.forEach(live), LIVE_MS));
|
|
1033
|
+
return ready;
|
|
1034
|
+
}
|
|
1035
|
+
const stop = () => timers.splice(0).forEach(clearInterval);
|
|
1036
|
+
|
|
1037
|
+
// for tests: what the timers would do, now
|
|
1038
|
+
const tick = { sweep: () => cfg.sessions.forEach(sweep), live: () => cfg.sessions.forEach(live), endGone, idle: () => Promise.all([...lanes.values()]) };
|
|
1039
|
+
|
|
1040
|
+
return { start, stop, onMessage, onAction, key, scopeOfKey, laneOf, tick };
|
|
1041
|
+
}
|