herdrchat 1.4.0 → 1.5.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/README.md +3 -2
- package/README.zh.md +1 -1
- package/docs/architecture.md +1 -1
- package/docs/guide.md +5 -1
- package/lib/chats/feishu/client.mjs +51 -10
- package/lib/chats/feishu/index.mjs +42 -271
- package/lib/chats/feishu/overview.mjs +16 -14
- package/lib/chats/feishu/rooms/decor.mjs +174 -0
- package/lib/chats/feishu/rooms/groups.mjs +110 -0
- package/lib/chats/feishu/userauth.mjs +10 -1
- package/lib/chats/telegram/index.mjs +3 -0
- package/lib/core/cursor.mjs +4 -1
- package/lib/core/engine.mjs +3 -1
- package/lib/core/home.mjs +10 -0
- package/lib/core/menu.mjs +14 -7
- package/lib/core/readers/codex.mjs +134 -0
- package/lib/core/readers/index.mjs +4 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -28,8 +28,9 @@ questions, send and receive images, and see all terminals in one overview. A new
|
|
|
28
28
|
topic starts with the conversation so far, and your phone rings only when an agent needs
|
|
29
29
|
you or is done (on Feishu once you have sent `/auth` to the bot; on Telegram right away).
|
|
30
30
|
|
|
31
|
-
Claude Code
|
|
32
|
-
until herdrchat learns to read their
|
|
31
|
+
Claude Code and Codex are mirrored fully, their dialogs answered with buttons. Other
|
|
32
|
+
agents show their screen when you type to them, until herdrchat learns to read their
|
|
33
|
+
conversations too.
|
|
33
34
|
|
|
34
35
|
## You need
|
|
35
36
|
|
package/README.zh.md
CHANGED
|
@@ -24,7 +24,7 @@ agent 干活时说的话实时可见,它问你的问题点按钮就能回答
|
|
|
24
24
|
新开的群或话题会先补上之前的对话;只有 agent 需要你、或者做完的时候手机才会响
|
|
25
25
|
(飞书要先给 bot 发一次 `/auth`;Telegram 直接就是这样)。
|
|
26
26
|
|
|
27
|
-
目前 Claude Code
|
|
27
|
+
目前 Claude Code 和 Codex 是完整同步的,它们的对话框都能点按钮回答。其他 agent 在你给它发消息时会回一次屏幕内容,
|
|
28
28
|
等 herdrchat 学会读它们的对话记录以后,也会完整同步。
|
|
29
29
|
|
|
30
30
|
## 需要什么
|
package/docs/architecture.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
herdrchat has two kinds of plug-ins that grow over time: **chat apps** (Feishu and
|
|
4
4
|
Telegram now, Slack and Discord later) and **agents**, each read by its reader (Claude
|
|
5
|
-
Code now, then
|
|
5
|
+
Code and Codex now, then pi). The core between them knows neither. This page says where everything is and
|
|
6
6
|
how to add one of each.
|
|
7
7
|
|
|
8
8
|
## The parts
|
package/docs/guide.md
CHANGED
|
@@ -345,8 +345,12 @@ before it is started again. Tokens and secrets are never logged. The skill's
|
|
|
345
345
|
## Limits
|
|
346
346
|
|
|
347
347
|
- Narration blocks are a condensed version of the text Claude showed, not the exact words.
|
|
348
|
-
-
|
|
348
|
+
- Claude Code and Codex have readers so far. pi and the rest post nothing by themselves
|
|
349
349
|
until each has a reader; what you type to them gets their screen in reply.
|
|
350
|
+
- Codex: its log is found as the one written in its folder since it started (Codex names
|
|
351
|
+
no pid outside its database), so two Codex sessions started in the same folder at the
|
|
352
|
+
same moment could be told apart wrongly. Its questions with choices come as text: answer
|
|
353
|
+
by typing. A group is named after Codex's thread name once it has one.
|
|
350
354
|
- Only text, images and rich text are passed on; files, stickers and the rest are not.
|
|
351
355
|
- An image that fails to upload three times is not sent; the group says so and why.
|
|
352
356
|
- Feishu only, not Lark (the international version) yet: the bot talks to open.feishu.cn.
|
|
@@ -48,10 +48,26 @@ function card(markdown) {
|
|
|
48
48
|
return JSON.stringify({ schema: '2.0', body: { elements: [{ tag: 'markdown', content: markdown }] } });
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
// A card's header: its title with Feishu's own icon for what the card is, in place of the
|
|
52
|
+
// emoji the core leads a title with (the icon says it once), and the color of its tone.
|
|
53
|
+
export const TONES = {
|
|
54
|
+
working: { template: 'blue', icon: 'loading_outlined' },
|
|
55
|
+
closed: { template: 'grey', icon: 'done_outlined' },
|
|
56
|
+
needs: { template: 'orange', icon: 'warning_outlined' },
|
|
57
|
+
done: { template: 'grey', icon: 'done_outlined' },
|
|
58
|
+
good: { template: 'green', icon: 'done_outlined' },
|
|
59
|
+
bad: { template: 'red', icon: 'warning_outlined' },
|
|
60
|
+
};
|
|
61
|
+
const LEADING_EMOJI = /^(?:[\p{Extended_Pictographic}\u2600-\u27BF][\uFE0F\u200D]*\s*)+/u;
|
|
62
|
+
export function header(title, tone) {
|
|
63
|
+
const t = TONES[tone] || TONES.working;
|
|
64
|
+
return { title: { tag: 'plain_text', content: String(title).replace(LEADING_EMOJI, '') || String(title) }, template: t.template, icon: { tag: 'standard_icon', token: t.icon, color: t.template } };
|
|
65
|
+
}
|
|
66
|
+
|
|
51
67
|
// A markdown card that can be edited after it is sent (updateCard needs update_multi
|
|
52
68
|
// on the card both before and after).
|
|
53
|
-
export function textCard(markdown, title,
|
|
54
|
-
return { schema: '2.0', config: { update_multi: true }, header:
|
|
69
|
+
export function textCard(markdown, title, tone = 'working') {
|
|
70
|
+
return { schema: '2.0', config: { update_multi: true }, header: header(title, tone), body: { elements: [{ tag: 'markdown', content: markdown }] } };
|
|
55
71
|
}
|
|
56
72
|
|
|
57
73
|
export async function updateCard(client, messageId, cardJson) {
|
|
@@ -99,9 +115,17 @@ const BUTTON_TEXT = 80;
|
|
|
99
115
|
|
|
100
116
|
function button(text, value, type = 'default') {
|
|
101
117
|
const short = text.length > BUTTON_TEXT ? `${text.slice(0, BUTTON_TEXT - 1)}…` : text;
|
|
102
|
-
return { tag: 'button', type, width: '
|
|
118
|
+
return { tag: 'button', type, width: 'default', size: 'medium', text: { tag: 'plain_text', content: short }, hover_tips: { tag: 'plain_text', content: text }, behaviors: [{ type: 'callback', value }] };
|
|
103
119
|
}
|
|
104
120
|
|
|
121
|
+
// Buttons side by side, wrapping onto the next line when the screen is narrow.
|
|
122
|
+
const row = (buttons) => ({ tag: 'column_set', flex_mode: 'flow', horizontal_spacing: 'small', columns: buttons.map((b) => ({ tag: 'column', width: 'auto', elements: [b] })) });
|
|
123
|
+
// Short options (Yes / No) sit side by side; a long one would be cut there, so then each
|
|
124
|
+
// option is a line of its own, as wide as its words, at the left like the text above.
|
|
125
|
+
const SIDE_BY_SIDE = { most: 4, chars: 14 };
|
|
126
|
+
const fitsSideBySide = (labels) => labels.length <= SIDE_BY_SIDE.most && labels.every((l) => [...l].length <= SIDE_BY_SIDE.chars);
|
|
127
|
+
const options = (buttons, labels) => (fitsSideBySide(labels) ? [row(buttons)] : buttons);
|
|
128
|
+
|
|
105
129
|
// A multi-select's boxes are checkers in a form: with no behaviors a checker is ticked on
|
|
106
130
|
// the phone only, so ticking makes no call and shows no loading; Next submits the form,
|
|
107
131
|
// and each box comes back in form_value under its name, box_<n> (ticksOf reads them).
|
|
@@ -109,16 +133,18 @@ export const BOX = 'box_';
|
|
|
109
133
|
export function menuCard({ title, markdown, menu, value }) {
|
|
110
134
|
const elements = [{ tag: 'markdown', content: markdown }];
|
|
111
135
|
const boxes = [];
|
|
136
|
+
const choices = [];
|
|
112
137
|
for (const o of menu.options) {
|
|
113
138
|
if (o === menu.free) continue;
|
|
114
139
|
if (menu.multi && o.checked !== null) {
|
|
115
140
|
boxes.push({ tag: 'checker', name: `${BOX}${o.n}`, checked: o.checked, text: { tag: 'plain_text', content: `${o.n}. ${o.label}` } });
|
|
116
141
|
continue;
|
|
117
142
|
}
|
|
118
|
-
|
|
143
|
+
choices.push(button(`${o.n}. ${o.label}`, { ...value, pick: o.n, label: o.label }, o.n === 1 && !menu.multi ? 'primary_filled' : 'default'));
|
|
119
144
|
}
|
|
145
|
+
if (choices.length) elements.push(...options(choices, choices.map((b) => b.text.content)));
|
|
120
146
|
if (menu.multi) {
|
|
121
|
-
elements.push({ tag: 'form', name: 'boxes', elements: [...boxes, { ...button('Next ⇥', { ...value, pick: 'tab' }, '
|
|
147
|
+
elements.push({ tag: 'form', name: 'boxes', elements: [...boxes, { ...button('Next ⇥', { ...value, pick: 'tab' }, 'primary_filled'), name: 'next', form_action_type: 'submit' }] });
|
|
122
148
|
}
|
|
123
149
|
if (menu.free) {
|
|
124
150
|
elements.push({
|
|
@@ -126,17 +152,24 @@ export function menuCard({ title, markdown, menu, value }) {
|
|
|
126
152
|
name: 'free',
|
|
127
153
|
elements: [
|
|
128
154
|
{ tag: 'input', name: 'answer', required: true, placeholder: { tag: 'plain_text', content: menu.free.label } },
|
|
129
|
-
{ ...button('Send', { ...value, pick: menu.free.n, label: menu.free.label }, '
|
|
155
|
+
{ ...button('Send', { ...value, pick: menu.free.n, label: menu.free.label }, 'primary_filled'), name: 'send', form_action_type: 'submit' },
|
|
130
156
|
],
|
|
131
157
|
});
|
|
132
158
|
}
|
|
133
|
-
elements.push(button('Esc (cancel)', { ...value, pick: 'esc' }, '
|
|
134
|
-
return { schema: '2.0', config: { update_multi: true }, header:
|
|
159
|
+
elements.push(row([button('Esc (cancel)', { ...value, pick: 'esc' }, 'danger_text')]));
|
|
160
|
+
return { schema: '2.0', config: { update_multi: true }, header: header(title, 'needs'), body: { elements } };
|
|
135
161
|
}
|
|
136
162
|
|
|
137
163
|
// The card once it is answered: the same text, no buttons, and what was done.
|
|
164
|
+
// A dialog once answered (or gone): grey, its text, and what was done in small type.
|
|
138
165
|
export function doneCard(title, markdown, done) {
|
|
139
|
-
return {
|
|
166
|
+
return {
|
|
167
|
+
schema: '2.0',
|
|
168
|
+
config: { update_multi: true },
|
|
169
|
+
header: header(title, 'done'),
|
|
170
|
+
// the icon is the check: one the core leads `done` with goes
|
|
171
|
+
body: { elements: [{ tag: 'markdown', content: markdown }, { tag: 'markdown', content: String(done).replace(/^[✔✓]\uFE0F?\s*/u, ''), text_size: 'notation', icon: { tag: 'standard_icon', token: 'done_outlined', color: 'grey' } }] },
|
|
172
|
+
};
|
|
140
173
|
}
|
|
141
174
|
|
|
142
175
|
// Takes back a message the bot sent.
|
|
@@ -184,8 +217,16 @@ export async function downloadImage(client, messageId, imageKey, file) {
|
|
|
184
217
|
|
|
185
218
|
// What a received message says: its text and the keys of its images, in order, for
|
|
186
219
|
// text, image and rich text (post) messages; null for any other kind.
|
|
220
|
+
// null for what is not passed on: another type (a file, forwarded chat records, ...), or a
|
|
221
|
+
// content that is not JSON (a merged forward lists as "Merged and Forwarded Message").
|
|
187
222
|
export function messageParts(messageType, content) {
|
|
188
|
-
|
|
223
|
+
let c;
|
|
224
|
+
try {
|
|
225
|
+
c = JSON.parse(content);
|
|
226
|
+
} catch {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
if (!c || typeof c !== 'object') return null;
|
|
189
230
|
if (messageType === 'text') return { text: c.text || '', images: [] };
|
|
190
231
|
if (messageType === 'image') return { text: '', images: [c.image_key] };
|
|
191
232
|
if (messageType !== 'post') return null;
|
|
@@ -1,28 +1,23 @@
|
|
|
1
|
-
// herdrchat's Feishu side:
|
|
2
|
-
// the bot for the overview and commands, and
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
import { ticksOf, createClient,
|
|
7
|
-
import { COLORS, avatarPng, letterOf } from './avatar.mjs';
|
|
1
|
+
// herdrchat's Feishu side: the rooms (rooms/: how tabs are laid out in groups, and what a
|
|
2
|
+
// group wears), the direct chat with the bot for the overview and commands, and, after
|
|
3
|
+
// /auth, acting as you (muted groups, your labels, pins, @ mentions). The core
|
|
4
|
+
// (lib/core/engine.mjs) calls only what is returned here; see "The chat side" there for
|
|
5
|
+
// what each call means.
|
|
6
|
+
import { ticksOf, createClient, chunk, doneCard, downloadImage, foldedCard, menuCard, messageParts, recallMessage, sendCard, sendFile, sendMarkdown, startEvents, textCard, updateCard, uploadImage } from './client.mjs';
|
|
8
7
|
import { overviewCard, overviewText } from './overview.mjs';
|
|
9
8
|
import { createUserAuth } from './userauth.mjs';
|
|
9
|
+
import { createDecor } from './rooms/decor.mjs';
|
|
10
|
+
import { createGroups } from './rooms/groups.mjs';
|
|
10
11
|
|
|
11
|
-
const ENDED_TAG = '#ended';
|
|
12
|
-
const ENDED_LABEL = 'ended';
|
|
13
12
|
const YOU_CHECK_MS = 60 * 1000;
|
|
14
13
|
const OVERVIEW_TRIES = 3;
|
|
15
14
|
const WORKING_EMOJI = 'THINKING';
|
|
16
15
|
const DONE_EMOJI = 'OK';
|
|
17
|
-
const TONES = { working: 'blue', closed: 'grey', needs: 'red', done: 'grey', good: 'green', bad: 'red' };
|
|
18
16
|
|
|
19
17
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
20
18
|
// What to do after an edit to config.env, said the way this profile is run.
|
|
21
19
|
const startAgain = (cfg) => `\`herdrchat start${cfg.profile && cfg.profile !== 'default' ? ` --profile ${cfg.profile}` : ''}\``;
|
|
22
20
|
|
|
23
|
-
const inSpaces = (set, space) => set.all || set.spaces.includes(space);
|
|
24
|
-
const spaceOfName = (name) => (name || '').split(' / ')[0];
|
|
25
|
-
|
|
26
21
|
// cfg: loadConfig(); store: {read, write} of state.json; lane(k, label, fn): runs fn in
|
|
27
22
|
// the room's lane, after what is queued for it; scopeOf(k): the session or machine a
|
|
28
23
|
// room's terminal belongs to (its tag and label).
|
|
@@ -33,110 +28,6 @@ export function create({ cfg, store, log, logWarn, logError, stateDir, lane, sco
|
|
|
33
28
|
const userAuth = createUserAuth(cfg, stateDir);
|
|
34
29
|
const { read, write } = store;
|
|
35
30
|
|
|
36
|
-
function setRoom(k, fields) {
|
|
37
|
-
const state = read();
|
|
38
|
-
if (state.chats[k]) state.chats[k] = { ...state.chats[k], ...fields };
|
|
39
|
-
write(state);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
// Lanes run side by side: a thing that is made once (a tag, a space's avatar) is made
|
|
43
|
-
// by the first lane that needs it; the others wait for that.
|
|
44
|
-
const making = new Map();
|
|
45
|
-
function once(id, make) {
|
|
46
|
-
if (!making.has(id)) making.set(id, make().finally(() => making.delete(id)));
|
|
47
|
-
return making.get(id);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
// ---------- tags and avatars ----------
|
|
51
|
-
|
|
52
|
-
// One tenant tag per session or machine, made once, and one "ended" tag; their ids are
|
|
53
|
-
// kept in state.json (tags).
|
|
54
|
-
async function tagOf(id, name = id) {
|
|
55
|
-
return read().tags?.[id] || once(`tag ${id}`, async () => {
|
|
56
|
-
const tag = await createTag(client, name);
|
|
57
|
-
const state = read();
|
|
58
|
-
state.tags = { ...(state.tags || {}), [id]: tag };
|
|
59
|
-
write(state);
|
|
60
|
-
log(`created tag ${name} (${tag})`);
|
|
61
|
-
return tag;
|
|
62
|
-
});
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// Each space gets an avatar: its first letter on a color of its own (the first color no
|
|
66
|
-
// other space has), kept in state.json (spaces), so all of its groups look alike, as in
|
|
67
|
-
// herdr-app. The color is picked and saved before the first await, so spaces made side
|
|
68
|
-
// by side never pick the same one. A failure is logged and tried again at the next check.
|
|
69
|
-
function colorOf(space) {
|
|
70
|
-
const state = read();
|
|
71
|
-
const spaces = state.spaces || {};
|
|
72
|
-
if (spaces[space]?.color) return spaces[space].color;
|
|
73
|
-
const used = Object.values(spaces).map((x) => x.color);
|
|
74
|
-
const color = COLORS.find((c) => !used.includes(c)) || COLORS[used.length % COLORS.length];
|
|
75
|
-
state.spaces = { ...spaces, [space]: { color } };
|
|
76
|
-
write(state);
|
|
77
|
-
return color;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
async function avatarOf(space) {
|
|
81
|
-
return read().spaces?.[space]?.image || once(`avatar ${space}`, async () => {
|
|
82
|
-
const color = colorOf(space);
|
|
83
|
-
const image = await uploadImage(client, await avatarPng(letterOf(space), color), 'avatar');
|
|
84
|
-
const state = read();
|
|
85
|
-
state.spaces = { ...(state.spaces || {}), [space]: { color, image } };
|
|
86
|
-
write(state);
|
|
87
|
-
log(`made avatar for space ${space} (${color})`);
|
|
88
|
-
return image;
|
|
89
|
-
});
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
// the space's avatar; a no-op when the group has it, so it follows a space that changes
|
|
93
|
-
async function avatarRoom(k, space) {
|
|
94
|
-
const c = read().chats[k];
|
|
95
|
-
if (!c) return;
|
|
96
|
-
try {
|
|
97
|
-
const image = await avatarOf(space);
|
|
98
|
-
if (c.avatar === image) return;
|
|
99
|
-
await setChatAvatar(client, c.chat, image);
|
|
100
|
-
setRoom(k, { avatar: image });
|
|
101
|
-
} catch (err) {
|
|
102
|
-
logError(`avatar ${space} on ${c.chat}: ${err.message}`);
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
// Bot tags (HERDR_BOT_TAGS): the session or machine tag next to a group's name and, once
|
|
107
|
-
// its terminal is gone, "ended". They are tenant tags, so they can be turned off; off
|
|
108
|
-
// takes off the ones already on. A failure is logged and tried again at the next check.
|
|
109
|
-
async function syncBotTags(k, scope) {
|
|
110
|
-
const c = read().chats[k];
|
|
111
|
-
if (!c) return;
|
|
112
|
-
try {
|
|
113
|
-
if (cfg.botTags) {
|
|
114
|
-
const tag = await tagOf(scope);
|
|
115
|
-
if (c.tag !== tag) {
|
|
116
|
-
await bindTag(client, tag, c.chat);
|
|
117
|
-
setRoom(k, { tag });
|
|
118
|
-
}
|
|
119
|
-
if (Boolean(c.ended) !== Boolean(c.endedTag)) {
|
|
120
|
-
const ended = await tagOf(ENDED_TAG, 'ended');
|
|
121
|
-
await (c.ended ? bindTag : unbindTag)(client, ended, c.chat);
|
|
122
|
-
setRoom(k, { endedTag: Boolean(c.ended) });
|
|
123
|
-
}
|
|
124
|
-
} else {
|
|
125
|
-
const tags = read().tags || {};
|
|
126
|
-
if (c.tag) {
|
|
127
|
-
await unbindTag(client, c.tag, c.chat).catch(() => {});
|
|
128
|
-
setRoom(k, { tag: null });
|
|
129
|
-
}
|
|
130
|
-
if (c.endedTag && tags[ENDED_TAG]) {
|
|
131
|
-
await unbindTag(client, tags[ENDED_TAG], c.chat).catch(() => {});
|
|
132
|
-
setRoom(k, { endedTag: false });
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
} catch (err) {
|
|
136
|
-
logError(`bot tags on ${c.name}: ${err.message}`);
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
31
|
// ---------- acting as you (optional, after /auth) ----------
|
|
141
32
|
|
|
142
33
|
// With your token (userauth.mjs) the bot does what only you can: mutes its groups,
|
|
@@ -153,10 +44,17 @@ export function create({ cfg, store, log, logWarn, logError, stateDir, lane, sco
|
|
|
153
44
|
if (Date.now() - youCheckedAt < YOU_CHECK_MS) return you;
|
|
154
45
|
youCheckedAt = Date.now();
|
|
155
46
|
const was = you;
|
|
156
|
-
|
|
47
|
+
try {
|
|
48
|
+
you = await userAuth.allowed();
|
|
49
|
+
} catch (err) {
|
|
50
|
+
// not known (Feishu not reached): it stays as it was, and is asked again soon
|
|
51
|
+
youCheckedAt = Date.now() - YOU_CHECK_MS + 10 * 1000;
|
|
52
|
+
logWarn(`acting as you: not checked (${err.message}); it stays ${you ? 'on' : 'off'}`);
|
|
53
|
+
return you;
|
|
54
|
+
}
|
|
157
55
|
if (you && !was) {
|
|
158
56
|
log('acting as you: on');
|
|
159
|
-
setTimeout(alignAll, 0);
|
|
57
|
+
setTimeout(() => rooms.alignAll(), 0);
|
|
160
58
|
} else if (!you && was) {
|
|
161
59
|
logWarn('acting as you: off');
|
|
162
60
|
await tellOwner({ title: '⚠️ Needs you', tone: 'needs', markdown: 'The bot can no longer act as you (mute, labels, pin). Send `/auth` to allow it again.' }).catch(() => {});
|
|
@@ -164,112 +62,15 @@ export function create({ cfg, store, log, logWarn, logError, stateDir, lane, sco
|
|
|
164
62
|
return you;
|
|
165
63
|
}
|
|
166
64
|
|
|
167
|
-
async function labelOf(name) {
|
|
168
|
-
return read().labels?.[name] || once(`label ${name}`, async () => {
|
|
169
|
-
const found = (await userAuth.feedGroups())?.groups?.find((g) => g.name === name);
|
|
170
|
-
const id = found?.group_id || (await userAuth.createFeedGroup(name))?.group_id;
|
|
171
|
-
if (!id) throw new Error(`no label ${name}`);
|
|
172
|
-
const state = read();
|
|
173
|
-
state.labels = { ...(state.labels || {}), [name]: id };
|
|
174
|
-
write(state);
|
|
175
|
-
log(`label ${name} (${id})`);
|
|
176
|
-
return id;
|
|
177
|
-
});
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
// A label you deleted is made again at the next try.
|
|
181
|
-
async function intoLabel(name, chat) {
|
|
182
|
-
try {
|
|
183
|
-
await userAuth.addToFeedGroup(await labelOf(name), [chat]);
|
|
184
|
-
} catch (err) {
|
|
185
|
-
const state = read();
|
|
186
|
-
if (state.labels) delete state.labels[name];
|
|
187
|
-
write(state);
|
|
188
|
-
throw err;
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
// Your label (HERDR_USER_TAGS, acting as you): the session or machine, or "ended" once
|
|
193
|
-
// the terminal is gone; off takes the group out of it (the label itself is yours).
|
|
194
|
-
async function syncUserLabel(k, scope) {
|
|
195
|
-
if (!(await asYou())) return;
|
|
196
|
-
const c = read().chats[k];
|
|
197
|
-
if (!c) return;
|
|
198
|
-
const want = cfg.userTags ? (c.ended ? ENDED_LABEL : tagName(scope)) : null;
|
|
199
|
-
if ((c.label || null) === want) return;
|
|
200
|
-
try {
|
|
201
|
-
if (c.label) await userAuth.removeFromFeedGroup(await labelOf(c.label), [c.chat]).catch(() => {});
|
|
202
|
-
if (want) await intoLabel(want, c.chat);
|
|
203
|
-
setRoom(k, { label: want });
|
|
204
|
-
} catch (err) {
|
|
205
|
-
logError(`as you, label of ${c.name}: ${err.message}`);
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
// HERDR_MUTED_SPACES says which groups are muted (all, none, or some spaces); a change
|
|
210
|
-
// in config.env is applied both ways at the next start.
|
|
211
|
-
async function muteRoom(k, space) {
|
|
212
|
-
const c = read().chats[k];
|
|
213
|
-
const want = inSpaces(cfg.muted, space);
|
|
214
|
-
if (!c || Boolean(c.muted) === want) return;
|
|
215
|
-
try {
|
|
216
|
-
await userAuth.setMuted([c.chat], want);
|
|
217
|
-
setRoom(k, { muted: want });
|
|
218
|
-
} catch (err) {
|
|
219
|
-
logError(`as you, mute ${c.chat}: ${err.message}`);
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
// Everything at once, when acting as you turns on: live groups to their labels, ended
|
|
224
|
-
// ones to "ended".
|
|
225
|
-
function alignAll() {
|
|
226
|
-
for (const [k, c] of Object.entries(read().chats)) {
|
|
227
|
-
if (!cfg.sessions.includes(c.session)) continue;
|
|
228
|
-
lane(k, 'as you', () => (c.ended ? endRoom(k) : placeRoom(k, { space: spaceOfName(c.name) })));
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
|
|
232
65
|
// ---------- rooms ----------
|
|
233
66
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
return createChat(client, cfg.ownerOpenId, name, intro);
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
async function renameRoom(room, name) {
|
|
240
|
-
await renameChat(client, room, name);
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
// Puts a live group where it belongs: its avatar, its bot tag and, acting as you, muted
|
|
244
|
-
// and in your label. Cheap when nothing is left to do.
|
|
245
|
-
async function placeRoom(k, { space }) {
|
|
246
|
-
await avatarRoom(k, space);
|
|
247
|
-
await syncBotTags(k, scopeOf(k));
|
|
248
|
-
if (!(await asYou())) return;
|
|
249
|
-
await muteRoom(k, space);
|
|
250
|
-
await syncUserLabel(k, scopeOf(k));
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
// A group whose terminal is gone keeps its history, tagged "ended".
|
|
254
|
-
async function endRoom(k) {
|
|
255
|
-
const c = read().chats[k];
|
|
256
|
-
if (!c) return;
|
|
257
|
-
await syncBotTags(k, scopeOf(k));
|
|
258
|
-
if (!c.avatar) await avatarRoom(k, spaceOfName(c.name));
|
|
259
|
-
if (!(await asYou())) return;
|
|
260
|
-
await muteRoom(k, spaceOfName(c.name));
|
|
261
|
-
await syncUserLabel(k, scopeOf(k));
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
async function reviveRoom(k) {
|
|
265
|
-
await syncBotTags(k, scopeOf(k));
|
|
266
|
-
await syncUserLabel(k, scopeOf(k));
|
|
267
|
-
}
|
|
67
|
+
const decor = createDecor({ client, cfg, store, log, logError, userAuth, asYou });
|
|
68
|
+
const rooms = createGroups({ client, cfg, store, logError, lane, scopeOf, userAuth, asYou, decor });
|
|
268
69
|
|
|
269
70
|
// ---------- posting ----------
|
|
270
71
|
|
|
271
72
|
const post = (room, markdown) => sendMarkdown(client, room, markdown);
|
|
272
|
-
const card = ({ title, tone, markdown }) => textCard(markdown, title,
|
|
73
|
+
const card = ({ title, tone, markdown }) => textCard(markdown, title, tone);
|
|
273
74
|
|
|
274
75
|
async function postCard(room, look) {
|
|
275
76
|
const res = await sendCard(client, room, card(look));
|
|
@@ -289,43 +90,6 @@ export function create({ cfg, store, log, logWarn, logError, stateDir, lane, sco
|
|
|
289
90
|
await updateCard(client, message, doneCard(dialog.doneTitle, dialog.markdown, done));
|
|
290
91
|
}
|
|
291
92
|
|
|
292
|
-
// The group that needs you is pinned while it waits (acting as you), and unpinned when
|
|
293
|
-
// it no longer does; which ones are pinned is kept in state.json (pinned).
|
|
294
|
-
async function pin(k, room) {
|
|
295
|
-
if (!(await asYou())) return;
|
|
296
|
-
try {
|
|
297
|
-
await userAuth.pin(room);
|
|
298
|
-
const state = read();
|
|
299
|
-
state.pinned = { ...(state.pinned || {}), [k]: room };
|
|
300
|
-
write(state);
|
|
301
|
-
} catch (err) {
|
|
302
|
-
logError(`pin ${room}: ${err.message}`);
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
async function unpin(k) {
|
|
307
|
-
const room = read().pinned?.[k];
|
|
308
|
-
if (!room) return;
|
|
309
|
-
try {
|
|
310
|
-
await userAuth.unpin(room);
|
|
311
|
-
} catch (err) {
|
|
312
|
-
logError(`unpin ${room}: ${err.message}`);
|
|
313
|
-
}
|
|
314
|
-
const state = read();
|
|
315
|
-
delete state.pinned[k];
|
|
316
|
-
write(state);
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
const pinned = () => Object.keys(read().pinned || {});
|
|
320
|
-
|
|
321
|
-
// In a muted group only a mention rings; a group that is not muted rings anyway. A
|
|
322
|
-
// space in HERDR_SILENT_SPACES gets no mention at all.
|
|
323
|
-
async function ring(k, message, text) {
|
|
324
|
-
const c = read().chats[k];
|
|
325
|
-
if (!message || !c?.muted || inSpaces(cfg.silent, spaceOfName(c.name)) || !(await asYou())) return;
|
|
326
|
-
await replyMention(client, message, cfg.ownerOpenId, text).catch((err) => logError(`mention: ${err.message}`));
|
|
327
|
-
}
|
|
328
|
-
|
|
329
93
|
async function addReaction(message, emoji) {
|
|
330
94
|
const res = await client.im.v1.messageReaction.create({ path: { message_id: message }, data: { reaction_type: { emoji_type: emoji } } });
|
|
331
95
|
return res?.data?.reaction_id;
|
|
@@ -432,7 +196,14 @@ export function create({ cfg, store, log, logWarn, logError, stateDir, lane, sco
|
|
|
432
196
|
seen.add(msg.message_id);
|
|
433
197
|
if (seen.size > 2000) seen.delete(seen.values().next().value);
|
|
434
198
|
queued(msg.chat_id, async () => {
|
|
435
|
-
|
|
199
|
+
// whatever goes wrong, you are told it did not get through, and it is not tried
|
|
200
|
+
// again at every connection
|
|
201
|
+
try {
|
|
202
|
+
await handleMessage(data);
|
|
203
|
+
} catch (err) {
|
|
204
|
+
logError(`feishu message ${msg.message_id} in ${msg.chat_id}:`, err);
|
|
205
|
+
if (data.sender?.sender_id?.open_id === cfg.ownerOpenId) await reply(msg.chat_id, `⚠️ Not delivered: it could not be handled (${err.message}). Send it again; \`herdrchat logs\` has the details.`).catch(() => {});
|
|
206
|
+
}
|
|
436
207
|
markRead(msg.chat_id, Number(msg.create_time) || Date.now());
|
|
437
208
|
});
|
|
438
209
|
};
|
|
@@ -455,12 +226,12 @@ export function create({ cfg, store, log, logWarn, logError, stateDir, lane, sco
|
|
|
455
226
|
}
|
|
456
227
|
const parts = messageParts(msg.message_type, msg.content);
|
|
457
228
|
if (!parts) {
|
|
458
|
-
await reply(msg.chat_id,
|
|
229
|
+
await reply(msg.chat_id, `Not delivered: only text and images are passed on, not this (${msg.message_type}). Copy its text into a message.`);
|
|
459
230
|
return;
|
|
460
231
|
}
|
|
461
232
|
const text = parts.text.replace(/@_user_\d+/g, '').trim();
|
|
462
233
|
if (!text && !parts.images.length) return;
|
|
463
|
-
await onMessage({ room:
|
|
234
|
+
await onMessage({ room: rooms.roomOf(msg), where: msg.chat_id, message: msg.message_id, text, images: parts.images });
|
|
464
235
|
};
|
|
465
236
|
// A click must be answered within 3 seconds; the core's answer becomes Feishu's
|
|
466
237
|
// callback response: a toast and, when the card changes, its new form.
|
|
@@ -487,7 +258,7 @@ export function create({ cfg, store, log, logWarn, logError, stateDir, lane, sco
|
|
|
487
258
|
const catchUp = async () => {
|
|
488
259
|
if (!cfg.ownerOpenId) return;
|
|
489
260
|
const state = read();
|
|
490
|
-
const chats = new Set([...
|
|
261
|
+
const chats = new Set([...rooms.roomChats(), ...Object.keys(state.feishuRead || {})]);
|
|
491
262
|
for (const chatId of chats) {
|
|
492
263
|
const since = state.feishuRead?.[chatId];
|
|
493
264
|
if (!since) {
|
|
@@ -498,7 +269,7 @@ export function create({ cfg, store, log, logWarn, logError, stateDir, lane, sco
|
|
|
498
269
|
const res = await client.im.v1.message.list({ params: { container_id_type: 'chat', container_id: chatId, start_time: String(Math.floor(since / 1000)), sort_type: 'ByCreateTimeAsc', page_size: 50 } });
|
|
499
270
|
const missed = (res?.data?.items || []).filter((m) => Number(m.create_time) > since && m.sender?.sender_type === 'user' && m.sender?.id === cfg.ownerOpenId && !seen.has(m.message_id));
|
|
500
271
|
if (missed.length) log(`feishu: ${missed.length} message(s) in ${chatId} came while the bot was away`);
|
|
501
|
-
const p2p = !
|
|
272
|
+
const p2p = !rooms.isRoomChat(chatId);
|
|
502
273
|
for (const m of missed) take({ message: { message_id: m.message_id, chat_id: chatId, chat_type: p2p ? 'p2p' : 'group', message_type: m.msg_type, content: m.body?.content || '{}', create_time: m.create_time }, sender: { sender_id: { open_id: m.sender.id } } });
|
|
503
274
|
} catch (err) {
|
|
504
275
|
logWarn(`feishu: could not look for missed messages in ${chatId}: ${err.message}`);
|
|
@@ -544,25 +315,25 @@ export function create({ cfg, store, log, logWarn, logError, stateDir, lane, sco
|
|
|
544
315
|
name: 'feishu',
|
|
545
316
|
ready: () => Boolean(cfg.ownerOpenId),
|
|
546
317
|
help: ['`/auth` let the bot act as you for chat-list settings (mute, labels, pin)'],
|
|
547
|
-
roomWord:
|
|
318
|
+
roomWord: rooms.roomWord,
|
|
548
319
|
liveBytes: 20000, // a card may hold 30 KB; room for JSON escaping
|
|
549
320
|
split: (text) => chunk(text),
|
|
550
321
|
start,
|
|
551
322
|
command,
|
|
552
|
-
openRoom,
|
|
553
|
-
renameRoom,
|
|
554
|
-
placeRoom,
|
|
555
|
-
endRoom,
|
|
556
|
-
reviveRoom,
|
|
323
|
+
openRoom: rooms.openRoom,
|
|
324
|
+
renameRoom: rooms.renameRoom,
|
|
325
|
+
placeRoom: rooms.placeRoom,
|
|
326
|
+
endRoom: rooms.endRoom,
|
|
327
|
+
reviveRoom: rooms.reviveRoom,
|
|
557
328
|
post,
|
|
558
329
|
postCard,
|
|
559
330
|
editCard,
|
|
560
331
|
postDialog,
|
|
561
332
|
closeDialog,
|
|
562
|
-
pin,
|
|
563
|
-
unpin,
|
|
564
|
-
pinned,
|
|
565
|
-
ring,
|
|
333
|
+
pin: rooms.pin,
|
|
334
|
+
unpin: rooms.unpin,
|
|
335
|
+
pinned: rooms.pinned,
|
|
336
|
+
ring: rooms.ring,
|
|
566
337
|
markWorking,
|
|
567
338
|
markDone,
|
|
568
339
|
image,
|
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
// The overview: one card in the direct chat with the bot, as herdr-app's sidebar shows it.
|
|
2
|
-
// Every terminal is a line: its state, its group (a link that opens it),
|
|
3
|
-
// and since when. What waits for you
|
|
4
|
-
// never rings.
|
|
2
|
+
// Every terminal is a line: its state as a colored tag, its group (a link that opens it),
|
|
3
|
+
// what it waits on and since when. What waits for you
|
|
4
|
+
// comes first. The card is edited in place, so it never rings.
|
|
5
|
+
import { header } from './client.mjs';
|
|
6
|
+
|
|
5
7
|
const STATES = {
|
|
6
|
-
blocked: {
|
|
7
|
-
error: {
|
|
8
|
-
working: {
|
|
9
|
-
done: {
|
|
10
|
-
idle: {
|
|
8
|
+
blocked: { color: 'orange', word: 'needs you', rank: 0 },
|
|
9
|
+
error: { color: 'red', word: 'error', rank: 1 },
|
|
10
|
+
working: { color: 'blue', word: 'working', rank: 2 },
|
|
11
|
+
done: { color: 'green', word: 'done', rank: 3 },
|
|
12
|
+
idle: { color: 'neutral', word: 'idle', rank: 4 },
|
|
11
13
|
};
|
|
12
|
-
const OTHER = {
|
|
14
|
+
const OTHER = { color: 'neutral', word: '', rank: 5 };
|
|
13
15
|
|
|
14
16
|
const chatLink = (chat) => `https://applink.feishu.cn/client/chat/open?openChatId=${chat}`;
|
|
15
17
|
const clock = (ms) => new Date(ms).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
|
|
@@ -24,19 +26,19 @@ export function overviewText(rows) {
|
|
|
24
26
|
const s = STATES[r.state] || OTHER;
|
|
25
27
|
const name = r.room ? `[${escape(r.name)}](${chatLink(r.room)})` : escape(r.name);
|
|
26
28
|
const word = s === OTHER ? r.program || 'no status' : `${s.word}${r.state === 'blocked' && r.kind ? ` (${r.kind})` : ''}`;
|
|
27
|
-
return
|
|
29
|
+
return `<text_tag color='${s.color}'>${escape(word)}</text_tag> **${name}**${r.since ? ` · since ${clock(r.since)}` : ''} · \`${r.address}\``;
|
|
28
30
|
}).join('\n');
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
export function overviewCard(rows, now) {
|
|
32
34
|
const waiting = rows.filter((r) => r.state === 'blocked').length;
|
|
33
35
|
const working = rows.filter((r) => r.state === 'working').length;
|
|
34
|
-
const
|
|
35
|
-
const title = `${waiting ?
|
|
36
|
+
const tone = waiting ? 'needs' : working ? 'working' : 'good';
|
|
37
|
+
const title = `${waiting ? `${waiting} needs you · ` : ''}herdrchat · ${rows.length} terminals`;
|
|
36
38
|
return {
|
|
37
39
|
schema: '2.0',
|
|
38
40
|
config: { update_multi: true },
|
|
39
|
-
header:
|
|
40
|
-
body: { elements: [{ tag: 'markdown', content:
|
|
41
|
+
header: header(title, tone),
|
|
42
|
+
body: { elements: [{ tag: 'markdown', content: overviewText(rows) }, { tag: 'markdown', content: `Updated ${clock(now)}`, text_size: 'notation' }] },
|
|
41
43
|
};
|
|
42
44
|
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// What a Feishu group wears (groups.mjs makes one per tab): the space's avatar, the bot
|
|
2
|
+
// tags, and, acting as you, your label and its mute. Each call takes an `entry`, {get() -> record, set(fields)}: the record
|
|
3
|
+
// of the group in state.json, {chat, name, avatar, tag, ended, endedTag, label, muted}.
|
|
4
|
+
import { bindTag, createTag, setChatAvatar, tagName, unbindTag, uploadImage } from '../client.mjs';
|
|
5
|
+
import { COLORS, avatarPng, letterOf } from '../avatar.mjs';
|
|
6
|
+
|
|
7
|
+
const ENDED_TAG = '#ended';
|
|
8
|
+
const ENDED_LABEL = 'ended';
|
|
9
|
+
|
|
10
|
+
export const inSpaces = (set, space) => set.all || set.spaces.includes(space);
|
|
11
|
+
export const spaceOfName = (name) => (name || '').split(' / ')[0];
|
|
12
|
+
|
|
13
|
+
// asYou() -> whether the bot may act as you now (index.mjs keeps that)
|
|
14
|
+
export function createDecor({ client, cfg, store, log, logError, userAuth, asYou }) {
|
|
15
|
+
const { read, write } = store;
|
|
16
|
+
|
|
17
|
+
// Lanes run side by side: a thing that is made once (a tag, a space's avatar) is made
|
|
18
|
+
// by the first lane that needs it; the others wait for that.
|
|
19
|
+
const making = new Map();
|
|
20
|
+
function once(id, make) {
|
|
21
|
+
if (!making.has(id)) making.set(id, make().finally(() => making.delete(id)));
|
|
22
|
+
return making.get(id);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ---------- tags and avatars ----------
|
|
26
|
+
|
|
27
|
+
// One tenant tag per session or machine, made once, and one "ended" tag; their ids are
|
|
28
|
+
// kept in state.json (tags).
|
|
29
|
+
async function tagOf(id, name = id) {
|
|
30
|
+
return read().tags?.[id] || once(`tag ${id}`, async () => {
|
|
31
|
+
const tag = await createTag(client, name);
|
|
32
|
+
const state = read();
|
|
33
|
+
state.tags = { ...(state.tags || {}), [id]: tag };
|
|
34
|
+
write(state);
|
|
35
|
+
log(`created tag ${name} (${tag})`);
|
|
36
|
+
return tag;
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Each space gets an avatar: its first letter on a color of its own (the first color no
|
|
41
|
+
// other space has), kept in state.json (spaces), so all of its groups look alike, as in
|
|
42
|
+
// herdr-app. The color is picked and saved before the first await, so spaces made side
|
|
43
|
+
// by side never pick the same one. A failure is logged and tried again at the next check.
|
|
44
|
+
function colorOf(space) {
|
|
45
|
+
const state = read();
|
|
46
|
+
const spaces = state.spaces || {};
|
|
47
|
+
if (spaces[space]?.color) return spaces[space].color;
|
|
48
|
+
const used = Object.values(spaces).map((x) => x.color);
|
|
49
|
+
const color = COLORS.find((c) => !used.includes(c)) || COLORS[used.length % COLORS.length];
|
|
50
|
+
state.spaces = { ...spaces, [space]: { color } };
|
|
51
|
+
write(state);
|
|
52
|
+
return color;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function avatarOf(space) {
|
|
56
|
+
return read().spaces?.[space]?.image || once(`avatar ${space}`, async () => {
|
|
57
|
+
const color = colorOf(space);
|
|
58
|
+
const image = await uploadImage(client, await avatarPng(letterOf(space), color), 'avatar');
|
|
59
|
+
const state = read();
|
|
60
|
+
state.spaces = { ...(state.spaces || {}), [space]: { color, image } };
|
|
61
|
+
write(state);
|
|
62
|
+
log(`made avatar for space ${space} (${color})`);
|
|
63
|
+
return image;
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// the space's avatar; a no-op when the group has it, so it follows a space that changes
|
|
68
|
+
async function avatar(entry, space) {
|
|
69
|
+
const c = entry.get();
|
|
70
|
+
if (!c) return;
|
|
71
|
+
try {
|
|
72
|
+
const image = await avatarOf(space);
|
|
73
|
+
if (c.avatar === image) return;
|
|
74
|
+
await setChatAvatar(client, c.chat, image);
|
|
75
|
+
entry.set({ avatar: image });
|
|
76
|
+
} catch (err) {
|
|
77
|
+
logError(`avatar ${space} on ${c.chat}: ${err.message}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Bot tags (HERDR_BOT_TAGS): the session or machine tag next to a group's name and, once
|
|
82
|
+
// its terminal is gone, "ended". They are tenant tags, so they can be turned off; off
|
|
83
|
+
// takes off the ones already on. A failure is logged and tried again at the next check.
|
|
84
|
+
async function botTags(entry, scope) {
|
|
85
|
+
const c = entry.get();
|
|
86
|
+
if (!c) return;
|
|
87
|
+
try {
|
|
88
|
+
if (cfg.botTags) {
|
|
89
|
+
const tag = await tagOf(scope);
|
|
90
|
+
if (c.tag !== tag) {
|
|
91
|
+
await bindTag(client, tag, c.chat);
|
|
92
|
+
entry.set({ tag });
|
|
93
|
+
}
|
|
94
|
+
if (Boolean(c.ended) !== Boolean(c.endedTag)) {
|
|
95
|
+
const ended = await tagOf(ENDED_TAG, 'ended');
|
|
96
|
+
await (c.ended ? bindTag : unbindTag)(client, ended, c.chat);
|
|
97
|
+
entry.set({ endedTag: Boolean(c.ended) });
|
|
98
|
+
}
|
|
99
|
+
} else {
|
|
100
|
+
const tags = read().tags || {};
|
|
101
|
+
if (c.tag) {
|
|
102
|
+
await unbindTag(client, c.tag, c.chat).catch(() => {});
|
|
103
|
+
entry.set({ tag: null });
|
|
104
|
+
}
|
|
105
|
+
if (c.endedTag && tags[ENDED_TAG]) {
|
|
106
|
+
await unbindTag(client, tags[ENDED_TAG], c.chat).catch(() => {});
|
|
107
|
+
entry.set({ endedTag: false });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
} catch (err) {
|
|
111
|
+
logError(`bot tags on ${c.name}: ${err.message}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ---------- acting as you ----------
|
|
116
|
+
|
|
117
|
+
async function labelOf(name) {
|
|
118
|
+
return read().labels?.[name] || once(`label ${name}`, async () => {
|
|
119
|
+
const found = (await userAuth.feedGroups())?.groups?.find((g) => g.name === name);
|
|
120
|
+
const id = found?.group_id || (await userAuth.createFeedGroup(name))?.group_id;
|
|
121
|
+
if (!id) throw new Error(`no label ${name}`);
|
|
122
|
+
const state = read();
|
|
123
|
+
state.labels = { ...(state.labels || {}), [name]: id };
|
|
124
|
+
write(state);
|
|
125
|
+
log(`label ${name} (${id})`);
|
|
126
|
+
return id;
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// A label you deleted is made again at the next try.
|
|
131
|
+
async function intoLabel(name, chat) {
|
|
132
|
+
try {
|
|
133
|
+
await userAuth.addToFeedGroup(await labelOf(name), [chat]);
|
|
134
|
+
} catch (err) {
|
|
135
|
+
const state = read();
|
|
136
|
+
if (state.labels) delete state.labels[name];
|
|
137
|
+
write(state);
|
|
138
|
+
throw err;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Your label (HERDR_USER_TAGS, acting as you): the session or machine, or "ended" once
|
|
143
|
+
// the terminal is gone; off takes the group out of it (the label itself is yours).
|
|
144
|
+
async function userLabel(entry, scope) {
|
|
145
|
+
if (!(await asYou())) return;
|
|
146
|
+
const c = entry.get();
|
|
147
|
+
if (!c) return;
|
|
148
|
+
const want = cfg.userTags ? (c.ended ? ENDED_LABEL : tagName(scope)) : null;
|
|
149
|
+
if ((c.label || null) === want) return;
|
|
150
|
+
try {
|
|
151
|
+
if (c.label) await userAuth.removeFromFeedGroup(await labelOf(c.label), [c.chat]).catch(() => {});
|
|
152
|
+
if (want) await intoLabel(want, c.chat);
|
|
153
|
+
entry.set({ label: want });
|
|
154
|
+
} catch (err) {
|
|
155
|
+
logError(`as you, label of ${c.name}: ${err.message}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// HERDR_MUTED_SPACES says which groups are muted (all, none, or some spaces); a change
|
|
160
|
+
// in config.env is applied both ways at the next start.
|
|
161
|
+
async function mute(entry, space) {
|
|
162
|
+
const c = entry.get();
|
|
163
|
+
const want = inSpaces(cfg.muted, space);
|
|
164
|
+
if (!c || Boolean(c.muted) === want) return;
|
|
165
|
+
try {
|
|
166
|
+
await userAuth.setMuted([c.chat], want);
|
|
167
|
+
entry.set({ muted: want });
|
|
168
|
+
} catch (err) {
|
|
169
|
+
logError(`as you, mute ${c.chat}: ${err.message}`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return { avatar, botTags, userLabel, mute };
|
|
174
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// One Feishu group per herdr-app tab, with you in it; what you type there goes to its
|
|
2
|
+
// terminal. A room is the group's chat id. The group wears its space's avatar, its tags
|
|
3
|
+
// and, acting as you, its mute and your label (decor.mjs); the group that needs you is
|
|
4
|
+
// pinned. index.mjs calls only what is returned here.
|
|
5
|
+
import { createChat, renameChat, replyMention } from '../client.mjs';
|
|
6
|
+
import { inSpaces, spaceOfName } from './decor.mjs';
|
|
7
|
+
|
|
8
|
+
export function createGroups({ client, cfg, store, logError, lane, scopeOf, userAuth, asYou, decor }) {
|
|
9
|
+
const { read, write } = store;
|
|
10
|
+
const entryOf = (k) => ({
|
|
11
|
+
get: () => read().chats[k],
|
|
12
|
+
set: (fields) => {
|
|
13
|
+
const state = read();
|
|
14
|
+
if (state.chats[k]) state.chats[k] = { ...state.chats[k], ...fields };
|
|
15
|
+
write(state);
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
async function openRoom({ name, intro }) {
|
|
20
|
+
return createChat(client, cfg.ownerOpenId, name, intro);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function renameRoom(room, name) {
|
|
24
|
+
await renameChat(client, room, name);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Puts a live group where it belongs: its avatar, its bot tag and, acting as you, muted
|
|
28
|
+
// and in your label. Cheap when nothing is left to do.
|
|
29
|
+
async function placeRoom(k, { space }) {
|
|
30
|
+
const entry = entryOf(k);
|
|
31
|
+
await decor.avatar(entry, space);
|
|
32
|
+
await decor.botTags(entry, scopeOf(k));
|
|
33
|
+
if (!(await asYou())) return;
|
|
34
|
+
await decor.mute(entry, space);
|
|
35
|
+
await decor.userLabel(entry, scopeOf(k));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// A group whose terminal is gone keeps its history, tagged "ended".
|
|
39
|
+
async function endRoom(k) {
|
|
40
|
+
const entry = entryOf(k);
|
|
41
|
+
const c = entry.get();
|
|
42
|
+
if (!c) return;
|
|
43
|
+
await decor.botTags(entry, scopeOf(k));
|
|
44
|
+
if (!c.avatar) await decor.avatar(entry, spaceOfName(c.name));
|
|
45
|
+
if (!(await asYou())) return;
|
|
46
|
+
await decor.mute(entry, spaceOfName(c.name));
|
|
47
|
+
await decor.userLabel(entry, scopeOf(k));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function reviveRoom(k) {
|
|
51
|
+
const entry = entryOf(k);
|
|
52
|
+
await decor.botTags(entry, scopeOf(k));
|
|
53
|
+
await decor.userLabel(entry, scopeOf(k));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Everything at once, when acting as you turns on: live groups to their labels, ended
|
|
57
|
+
// ones to "ended".
|
|
58
|
+
function alignAll() {
|
|
59
|
+
for (const [k, c] of Object.entries(read().chats)) {
|
|
60
|
+
if (!cfg.sessions.includes(c.session)) continue;
|
|
61
|
+
lane(k, 'as you', () => (c.ended ? endRoom(k) : placeRoom(k, { space: spaceOfName(c.name) })));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// The group that needs you is pinned while it waits (acting as you), and unpinned when
|
|
66
|
+
// it no longer does; which ones are pinned is kept in state.json (pinned).
|
|
67
|
+
async function pin(k, room) {
|
|
68
|
+
if (!(await asYou())) return;
|
|
69
|
+
try {
|
|
70
|
+
await userAuth.pin(room);
|
|
71
|
+
const state = read();
|
|
72
|
+
state.pinned = { ...(state.pinned || {}), [k]: room };
|
|
73
|
+
write(state);
|
|
74
|
+
} catch (err) {
|
|
75
|
+
logError(`pin ${room}: ${err.message}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function unpin(k) {
|
|
80
|
+
const room = read().pinned?.[k];
|
|
81
|
+
if (!room) return;
|
|
82
|
+
try {
|
|
83
|
+
await userAuth.unpin(room);
|
|
84
|
+
} catch (err) {
|
|
85
|
+
logError(`unpin ${room}: ${err.message}`);
|
|
86
|
+
}
|
|
87
|
+
const state = read();
|
|
88
|
+
delete state.pinned[k];
|
|
89
|
+
write(state);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const pinned = () => Object.keys(read().pinned || {});
|
|
93
|
+
|
|
94
|
+
// In a muted group only a mention rings; a group that is not muted rings anyway. A
|
|
95
|
+
// space in HERDR_SILENT_SPACES gets no mention at all.
|
|
96
|
+
async function ring(k, message, text) {
|
|
97
|
+
const c = read().chats[k];
|
|
98
|
+
if (!message || !c?.muted || inSpaces(cfg.silent, spaceOfName(c.name)) || !(await asYou())) return;
|
|
99
|
+
await replyMention(client, message, cfg.ownerOpenId, text).catch((err) => logError(`mention: ${err.message}`));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// A message, to the core's words: the room it came from (null: the direct chat).
|
|
103
|
+
const roomOf = (msg) => (msg.chat_type === 'p2p' ? null : msg.chat_id);
|
|
104
|
+
// The chats whose messages the bot reads (to look for what it missed), and whether a
|
|
105
|
+
// chat is one of them rather than the direct chat.
|
|
106
|
+
const roomChats = () => Object.values(read().chats || {}).filter((c) => !c.ended).map((c) => c.chat);
|
|
107
|
+
const isRoomChat = (chatId) => Object.values(read().chats || {}).some((c) => c.chat === chatId);
|
|
108
|
+
|
|
109
|
+
return { roomWord: 'group', openRoom, renameRoom, placeRoom, endRoom, reviveRoom, alignAll, pin, unpin, pinned, ring, roomOf, roomChats, isRoomChat };
|
|
110
|
+
}
|
|
@@ -107,7 +107,16 @@ export function createUserAuth(cfg, dir) {
|
|
|
107
107
|
return {
|
|
108
108
|
startDeviceFlow,
|
|
109
109
|
waitForToken,
|
|
110
|
-
|
|
110
|
+
// false only when there is no token or Feishu refused to refresh it; a call that did not
|
|
111
|
+
// get through (the network) throws, so a moment offline is not taken for a revoked /auth
|
|
112
|
+
allowed: async () => {
|
|
113
|
+
try {
|
|
114
|
+
return Boolean(await token());
|
|
115
|
+
} catch (err) {
|
|
116
|
+
if (/^token refresh:/.test(err.message)) return false;
|
|
117
|
+
throw err;
|
|
118
|
+
}
|
|
119
|
+
},
|
|
111
120
|
setMuted: (chatIds, muted) => call('POST', '/open-apis/im/v1/chat_user_setting/batch_update', { chat_settings: chatIds.map((chat_id) => ({ chat_id, is_muted: muted })) }),
|
|
112
121
|
feedGroups: () => call('GET', '/open-apis/im/v1/groups?page_size=50&page_token='),
|
|
113
122
|
createFeedGroup: (name) => call('POST', '/open-apis/im/v1/groups', { feed_group_creator: { name, type: 'normal' } }),
|
|
@@ -399,6 +399,9 @@ export function create({ cfg, store, log, logWarn, logError, claimOwner = () =>
|
|
|
399
399
|
if (u.callback_query) await handleClick(u.callback_query, onAction);
|
|
400
400
|
} catch (err) {
|
|
401
401
|
logError(`telegram update ${u.update_id}:`, err);
|
|
402
|
+
// a message that could not be handled: you are told, never left guessing
|
|
403
|
+
const m = u.message;
|
|
404
|
+
if (m && String(m.from?.id || '') === owner) await reply(`${m.chat.id}:${m.is_topic_message ? m.message_thread_id : ''}`, `⚠️ Not delivered: it could not be handled (${err.message}). Send it again; \`herdrchat logs\` has the details.`).catch(() => {});
|
|
402
405
|
}
|
|
403
406
|
// saved once handled: a crash halfway handles it again, never skips it
|
|
404
407
|
offset = u.update_id + 1;
|
package/lib/core/cursor.mjs
CHANGED
|
@@ -19,7 +19,10 @@ function after(cursor, at, id) {
|
|
|
19
19
|
|
|
20
20
|
function idOf(entry) {
|
|
21
21
|
if (entry.uuid) return entry.uuid;
|
|
22
|
-
|
|
22
|
+
// Codex keeps what an entry is in `payload` (an item has its own id): two entries in the
|
|
23
|
+
// same millisecond must not share an id, or the second would count as posted
|
|
24
|
+
const payload = entry.payload ? `|${entry.payload.item?.id || entry.payload.id || entry.payload.call_id || JSON.stringify(entry.payload).slice(0, 4000)}` : '';
|
|
25
|
+
return crypto.createHash('sha1').update(`${entry.type}|${entry.timestamp}|${entry.operation || ''}|${entry.content || ''}${payload}`).digest('hex');
|
|
23
26
|
}
|
|
24
27
|
|
|
25
28
|
// The items after `cursor` in log `lines`, in order, using `entryItems(entry)` (the
|
package/lib/core/engine.mjs
CHANGED
|
@@ -160,7 +160,9 @@ export function createEngine({ cfg, chat, hc, readers, store, log, logWarn, logE
|
|
|
160
160
|
// dropped, so a spinner in a title never renames a room.
|
|
161
161
|
function tabName(where, record) {
|
|
162
162
|
if (!/^\d+$/.test(where.tab)) return where.tab;
|
|
163
|
-
|
|
163
|
+
// an agent whose title holds more than a name (Codex: "<thread> | <folder>") says which part
|
|
164
|
+
const titled = agentOf(record)?.titleOf?.(record.title) ?? record.title;
|
|
165
|
+
const title = String(titled || '').replace(/^[^\p{L}\p{N}]+/u, '').replace(/\s+/g, ' ').trim();
|
|
164
166
|
const name = title || record.name || '';
|
|
165
167
|
if (!name) return where.tab;
|
|
166
168
|
return name.length > TITLE_MAX ? `${name.slice(0, TITLE_MAX - 1)}…` : name;
|
package/lib/core/home.mjs
CHANGED
|
@@ -17,6 +17,16 @@ export async function homeFile(session, machine, rel, { tail } = {}) {
|
|
|
17
17
|
return hc(session, ['exec', '--on', machine, ...argv], { timeoutMs: 30000 });
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
// A short shell command in the home folder of the machine a terminal runs on: here, or
|
|
21
|
+
// through `hc exec --on <machine>`. Its output; empty when it fails.
|
|
22
|
+
export async function homeShell(session, machine, script) {
|
|
23
|
+
if (!machine) {
|
|
24
|
+
const { execFile } = await import('node:child_process');
|
|
25
|
+
return new Promise((resolve) => execFile('sh', ['-c', script], { cwd: os.homedir(), timeout: 30000 }, (err, stdout) => resolve(err && !stdout ? '' : String(stdout))));
|
|
26
|
+
}
|
|
27
|
+
return hc(session, ['exec', '--on', machine, 'sh', '-c', script], { timeoutMs: 30000 }).catch(() => '');
|
|
28
|
+
}
|
|
29
|
+
|
|
20
30
|
// The items after `cursor` in a .jsonl log: its tail first, the whole file when the tail
|
|
21
31
|
// does not reach back to the cursor.
|
|
22
32
|
export async function logAfter(session, machine, rel, cursor, entryItems) {
|
package/lib/core/menu.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// The menu a blocked program shows on its screen, as Claude Code draws its permission
|
|
2
|
-
// and question dialogs
|
|
2
|
+
// and question dialogs (Codex draws its approvals alike, with › for the cursor, no border,
|
|
3
|
+
// and the dialog under the "• Running …" line of what it asks about):
|
|
3
4
|
// ❯ 1. Yes a digit key picks an option
|
|
4
5
|
// 2. [✔] Red in a multi-select, a digit toggles it; tab goes on
|
|
5
6
|
// 3. Type something. picking it lets you type your own answer
|
|
@@ -20,7 +21,9 @@
|
|
|
20
21
|
// nor of the key; a question's own left border (│) goes too.
|
|
21
22
|
import crypto from 'node:crypto';
|
|
22
23
|
|
|
23
|
-
const OPTION = /^\s*(
|
|
24
|
+
const OPTION = /^\s*(?:[❯›]\s*)?(\d{1,2})\.\s+(?:\[(.)\]\s+)?(.*\S)\s*$/;
|
|
25
|
+
const CURSOR = /[❯›]/;
|
|
26
|
+
const ITEM = /^•\s/; // a Codex item, at the left edge: what its dialog asks about
|
|
24
27
|
const RULE = /^[\s─╌━-]*$/;
|
|
25
28
|
const BORDER = /^\s*─{10,}/;
|
|
26
29
|
const FREE = /^Type something/i;
|
|
@@ -31,10 +34,14 @@ const ownPart = (line) => line.replace(BESIDE, '').replace(LEFT_BORDER, '$1');
|
|
|
31
34
|
|
|
32
35
|
const TEXT_LINES = 15; // a dialog with no border found: this many lines above its options
|
|
33
36
|
|
|
37
|
+
// {top, framed}: the dialog's top border; with none above it, the nearest Codex item line
|
|
38
|
+
// (also a known edge); else TEXT_LINES above its options, not known as a dialog
|
|
34
39
|
function topOf(lines, first) {
|
|
35
40
|
let top = first;
|
|
36
41
|
while (top > 0 && !BORDER.test(lines[top])) top -= 1;
|
|
37
|
-
|
|
42
|
+
if (BORDER.test(lines[top])) return { top, framed: true };
|
|
43
|
+
for (let i = first - 1; i >= 0; i -= 1) if (ITEM.test(lines[i])) return { top: i, framed: true };
|
|
44
|
+
return { top: Math.max(0, first - TEXT_LINES), framed: false };
|
|
38
45
|
}
|
|
39
46
|
|
|
40
47
|
const MARKS = /[☐☑☒✔✓✗✘■□●○◉◯]/gu;
|
|
@@ -42,7 +49,7 @@ const MARKS = /[☐☑☒✔✓✗✘■□●○◉◯]/gu;
|
|
|
42
49
|
// Joined with spaces, not line breaks: the same dialog wraps at other places when the
|
|
43
50
|
// terminal is resized (herdr-app opened on a phone), and it is still the same dialog.
|
|
44
51
|
function keyOf(lines, top, last) {
|
|
45
|
-
const body = lines.slice(top, last + 1).map((l) => l.replace(
|
|
52
|
+
const body = lines.slice(top, last + 1).map((l) => l.replace(/[❯›]/g, '').replace(/^(\s*\d{1,2}\.\s+)\[.\]/, '$1').replace(MARKS, '')).join(' ').replace(/\s+/g, ' ').trim();
|
|
46
53
|
return crypto.createHash('sha1').update(body).digest('hex').slice(0, 12);
|
|
47
54
|
}
|
|
48
55
|
|
|
@@ -65,16 +72,16 @@ export function menuOf(screen) {
|
|
|
65
72
|
menu = run;
|
|
66
73
|
});
|
|
67
74
|
if (!menu) return null;
|
|
68
|
-
const top = topOf(lines, menu.first);
|
|
75
|
+
const { top, framed } = topOf(lines, menu.first);
|
|
69
76
|
// A question with previews takes no digit (its keys say "↑/↓ to navigate"): an option
|
|
70
77
|
// is reached from the cursor (❯) with the arrows, then Enter.
|
|
71
78
|
const beside = raw.slice(menu.first, menu.last + 1).some((l) => BESIDE.test(l));
|
|
72
|
-
const cursor = menu.options.find((o
|
|
79
|
+
const cursor = menu.options.find((o) => raw.slice(menu.first, menu.last + 1).some((l) => CURSOR.test(l) && new RegExp(`^\\s*[❯›]\\s*${o.n}\\.`).test(l)))?.n || 1;
|
|
73
80
|
return {
|
|
74
81
|
beside,
|
|
75
82
|
cursor,
|
|
76
83
|
key: keyOf(lines, top, menu.last),
|
|
77
|
-
framed
|
|
84
|
+
framed, // a dialog's known top; a numbered list in text has none
|
|
78
85
|
text: lines.slice(top, menu.first).filter((l) => !RULE.test(l)).map((l) => l.trim()).join('\n'),
|
|
79
86
|
options: menu.options,
|
|
80
87
|
multi: menu.options.some((o) => o.checked !== null),
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// Reader for Codex (hc status app "codex"): its own rollout log.
|
|
2
|
+
// Where: Codex writes each session to ~/.codex/sessions/YYYY/MM/DD/rollout-<time>-<id>.jsonl,
|
|
3
|
+
// whose first line (session_meta) names its cwd. It does not keep the file open or
|
|
4
|
+
// name its pid anywhere outside its database, so the log is found as the one written
|
|
5
|
+
// since the terminal's codex started, in the cwd it runs in; with two such, the one
|
|
6
|
+
// whose session began nearest after that start (a resumed session began before it,
|
|
7
|
+
// and then the newest file wins). Found once per codex process.
|
|
8
|
+
// What becomes an item: the "item_completed" events, one per finished item, as Codex
|
|
9
|
+
// itself shows them:
|
|
10
|
+
// you UserMessage: what you typed
|
|
11
|
+
// ai AgentMessage: what it said; `phase` "commentary" while it works, "final_answer"
|
|
12
|
+
// at the end, which marks `end` (a question it asks the async way, with its
|
|
13
|
+
// choices in the text, is a final answer too, but the turn goes on after it)
|
|
14
|
+
// tool CommandExecution (a command it ran), FileChange (files it added, changed or
|
|
15
|
+
// removed), and any other item but reasoning, as one line
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { getTerminal, machineOf } from '../hc.mjs';
|
|
18
|
+
import { homeShell, logAfter } from '../home.mjs';
|
|
19
|
+
import { itemsAfter } from '../cursor.mjs';
|
|
20
|
+
|
|
21
|
+
export const app = 'codex';
|
|
22
|
+
export const processes = ['codex'];
|
|
23
|
+
|
|
24
|
+
const textOf = (content) => (Array.isArray(content) ? content.filter((c) => /^text$/i.test(c.type) && c.text).map((c) => c.text).join('\n') : '');
|
|
25
|
+
const short = (s, max = 120) => {
|
|
26
|
+
const one = String(s || '').replace(/\s+/g, ' ').trim();
|
|
27
|
+
return one.length > max ? `${one.slice(0, max - 3)}...` : one;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function commandOf(item) {
|
|
31
|
+
const argv = Array.isArray(item.command) ? item.command : [item.command];
|
|
32
|
+
// a shell's `-lc <command>` shows as the command itself
|
|
33
|
+
const at = argv.findIndex((a) => a === '-lc' || a === '-c');
|
|
34
|
+
return at >= 0 && argv[at + 1] ? argv[at + 1] : argv.join(' ');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function entryItems(entry) {
|
|
38
|
+
if (entry.type !== 'event_msg' || entry.payload?.type !== 'item_completed') return [];
|
|
39
|
+
const item = entry.payload.item || {};
|
|
40
|
+
switch (item.type) {
|
|
41
|
+
case 'UserMessage': {
|
|
42
|
+
const t = textOf(item.content).trim();
|
|
43
|
+
return t ? [{ role: 'you', text: t }] : [];
|
|
44
|
+
}
|
|
45
|
+
case 'AgentMessage': {
|
|
46
|
+
const t = textOf(item.content).trim();
|
|
47
|
+
return t ? [{ role: 'ai', text: t, end: item.phase === 'final_answer' && item.delivery !== 'async' }] : [];
|
|
48
|
+
}
|
|
49
|
+
case 'CommandExecution':
|
|
50
|
+
return [{ role: 'tool', text: `▸ exec: ${short(commandOf(item))}` }];
|
|
51
|
+
case 'FileChange': {
|
|
52
|
+
const changes = Object.entries(item.changes || {}).map(([file, c]) => `${c.type || 'change'} ${path.basename(file)}`);
|
|
53
|
+
return [{ role: 'tool', text: `▸ edit: ${short(changes.join(', ') || 'files')}` }];
|
|
54
|
+
}
|
|
55
|
+
default:
|
|
56
|
+
return item.type && !/reasoning/i.test(item.type) ? [{ role: 'tool', text: `▸ ${item.type}` }] : [];
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function conversationAfter(lines, cursor) {
|
|
61
|
+
return itemsAfter(lines, cursor, entryItems);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// "[[dd-]hh:]mm:ss", as ps prints how long a process has run
|
|
65
|
+
export function seconds(etime) {
|
|
66
|
+
const t = String(etime).trim();
|
|
67
|
+
const [days, rest] = t.includes('-') ? t.split('-') : ['0', t];
|
|
68
|
+
const parts = rest.split(':').map(Number);
|
|
69
|
+
while (parts.length < 3) parts.unshift(0);
|
|
70
|
+
return Number(days) * 86400 + parts[0] * 3600 + parts[1] * 60 + parts[2];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// lines "file\t"cwd":"…"\t"timestamp":"…"" -> the log of the codex that started at `started`
|
|
74
|
+
export function pickLog(list, cwd, started) {
|
|
75
|
+
const mine = list.split('\n').map((l) => l.split('\t')).filter(([f, c]) => f && c === `"cwd":"${cwd}"`)
|
|
76
|
+
.map(([file, , ts]) => ({ file, began: Date.parse(String(ts || '').replace(/^"timestamp":"|"$/g, '')) || 0 }));
|
|
77
|
+
if (!mine.length) return null;
|
|
78
|
+
const fresh = mine.filter((m) => m.began >= started - 5000).sort((a, b) => a.began - b.began);
|
|
79
|
+
return (fresh[0] || [...mine].sort((a, b) => b.file.localeCompare(a.file))[0]).file;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const LIST = (minutes) => [
|
|
83
|
+
`find .codex/sessions -name 'rollout-*.jsonl' -mmin -${minutes} 2>/dev/null | while read -r f; do`,
|
|
84
|
+
`printf '%s\\t%s\\t%s\\n' "$f" "$(head -n1 "$f" | grep -o '"cwd":"[^"]*"' | head -1)" "$(head -n1 "$f" | grep -o '"timestamp":"[^"]*"' | head -1)";`,
|
|
85
|
+
'done',
|
|
86
|
+
].join(' ');
|
|
87
|
+
|
|
88
|
+
const found = new Map(); // `${session}|${address}|${pid}` -> the log (relative to home)
|
|
89
|
+
|
|
90
|
+
async function logOf(session, address, fg) {
|
|
91
|
+
const key = `${session}|${address}|${fg.pid}`;
|
|
92
|
+
if (found.has(key)) return found.get(key);
|
|
93
|
+
const machine = machineOf(address);
|
|
94
|
+
const age = (await homeShell(session, machine, `ps -o etime= -p ${Number(fg.pid)}`)).trim();
|
|
95
|
+
if (!age) return null;
|
|
96
|
+
const started = Date.now() - seconds(age) * 1000;
|
|
97
|
+
const minutes = Math.ceil((Date.now() - started) / 60000) + 1;
|
|
98
|
+
const file = pickLog(await homeShell(session, machine, LIST(minutes)), fg.cwd, started);
|
|
99
|
+
if (file) found.set(key, file);
|
|
100
|
+
return file;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// What is new since `cursor`: {items, file}, or null when the terminal runs no codex whose
|
|
104
|
+
// log is found. A new codex in the same terminal (a new process) reads its own log; what
|
|
105
|
+
// is left in the cursor's old one comes first.
|
|
106
|
+
export async function after(session, address, cursor) {
|
|
107
|
+
const fg = (await getTerminal(session, address)).foreground;
|
|
108
|
+
if (!fg?.pid || !fg.cwd) return null;
|
|
109
|
+
const file = await logOf(session, address, fg);
|
|
110
|
+
if (!file) return null;
|
|
111
|
+
const machine = machineOf(address);
|
|
112
|
+
const items = [];
|
|
113
|
+
if (cursor.file && cursor.file !== file) {
|
|
114
|
+
try {
|
|
115
|
+
items.push(...(await logAfter(session, machine, cursor.file, cursor, entryItems)));
|
|
116
|
+
} catch {
|
|
117
|
+
// the old log is gone; nothing left to read there
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
items.push(...(await logAfter(session, machine, file, cursor, entryItems)));
|
|
121
|
+
return { items, file };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function rest(session, address, cursor) {
|
|
125
|
+
return logAfter(session, machineOf(address), cursor.file, cursor, entryItems);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// The room's name from the terminal's title: Codex titles it "<thread> | <folder>", led by
|
|
129
|
+
// a spinner while it works and by "[ . ] Action Required | " while it waits on you. The
|
|
130
|
+
// thread is "codex" until Codex names it; the name stays put while its state changes.
|
|
131
|
+
export function titleOf(title) {
|
|
132
|
+
const plain = String(title || '').replace(/^[^\p{L}\p{N}[]+/u, '').replace(/^\[[^\]]*\]\s*Action Required\s*\|\s*/i, '');
|
|
133
|
+
return plain.split(' | ')[0].trim();
|
|
134
|
+
}
|
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
// the log it read, kept in the cursor
|
|
12
12
|
// rest(session, address, cursor) -> items: what `cursor.file` still holds after the
|
|
13
13
|
// program is gone
|
|
14
|
+
// titleOf(title) (optional) the room's name from the terminal's title,
|
|
15
|
+
// when the agent's title holds more than a name
|
|
14
16
|
// Items are {role: 'you' | 'ai' | 'tool', text, at, id, end?} (`end` marks the turn's last
|
|
15
17
|
// answer) or {role: 'image', from: 'you' | 'ai', media, data (base64), at, id}.
|
|
16
18
|
// Cursors are in ../cursor.mjs.
|
|
@@ -19,6 +21,7 @@
|
|
|
19
21
|
// (docs/architecture.md, "Adding an agent").
|
|
20
22
|
import path from 'node:path';
|
|
21
23
|
import * as claude from './claude.mjs';
|
|
24
|
+
import * as codex from './codex.mjs';
|
|
22
25
|
|
|
23
26
|
// What every reader exports (see above); checked for each one listed here when this
|
|
24
27
|
// loads, so a reader that misses one fails at start, not when its agent first runs.
|
|
@@ -29,7 +32,7 @@ export function checkReader(reader) {
|
|
|
29
32
|
return reader;
|
|
30
33
|
}
|
|
31
34
|
|
|
32
|
-
export const LISTED = [claude].map(checkReader);
|
|
35
|
+
export const LISTED = [claude, codex].map(checkReader);
|
|
33
36
|
const READERS = new Map(LISTED.map((r) => [r.app, r]));
|
|
34
37
|
|
|
35
38
|
export function readerFor(app) {
|