herdrchat 1.4.1 → 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 +42 -9
- package/lib/chats/feishu/index.mjs +10 -4
- package/lib/chats/feishu/overview.mjs +16 -14
- package/lib/chats/feishu/rooms/decor.mjs +2 -3
- package/lib/chats/feishu/rooms/groups.mjs +4 -5
- package/lib/chats/feishu/userauth.mjs +10 -1
- 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.
|
|
@@ -13,7 +13,6 @@ const YOU_CHECK_MS = 60 * 1000;
|
|
|
13
13
|
const OVERVIEW_TRIES = 3;
|
|
14
14
|
const WORKING_EMOJI = 'THINKING';
|
|
15
15
|
const DONE_EMOJI = 'OK';
|
|
16
|
-
const TONES = { working: 'blue', closed: 'grey', needs: 'red', done: 'grey', good: 'green', bad: 'red' };
|
|
17
16
|
|
|
18
17
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
19
18
|
// What to do after an edit to config.env, said the way this profile is run.
|
|
@@ -45,7 +44,14 @@ export function create({ cfg, store, log, logWarn, logError, stateDir, lane, sco
|
|
|
45
44
|
if (Date.now() - youCheckedAt < YOU_CHECK_MS) return you;
|
|
46
45
|
youCheckedAt = Date.now();
|
|
47
46
|
const was = you;
|
|
48
|
-
|
|
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
|
+
}
|
|
49
55
|
if (you && !was) {
|
|
50
56
|
log('acting as you: on');
|
|
51
57
|
setTimeout(() => rooms.alignAll(), 0);
|
|
@@ -64,7 +70,7 @@ export function create({ cfg, store, log, logWarn, logError, stateDir, lane, sco
|
|
|
64
70
|
// ---------- posting ----------
|
|
65
71
|
|
|
66
72
|
const post = (room, markdown) => sendMarkdown(client, room, markdown);
|
|
67
|
-
const card = ({ title, tone, markdown }) => textCard(markdown, title,
|
|
73
|
+
const card = ({ title, tone, markdown }) => textCard(markdown, title, tone);
|
|
68
74
|
|
|
69
75
|
async function postCard(room, look) {
|
|
70
76
|
const res = await sendCard(client, room, card(look));
|
|
@@ -264,7 +270,7 @@ export function create({ cfg, store, log, logWarn, logError, stateDir, lane, sco
|
|
|
264
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));
|
|
265
271
|
if (missed.length) log(`feishu: ${missed.length} message(s) in ${chatId} came while the bot was away`);
|
|
266
272
|
const p2p = !rooms.isRoomChat(chatId);
|
|
267
|
-
for (const m of missed) take({ message: { message_id: m.message_id, chat_id: chatId, chat_type: p2p ? 'p2p' : 'group',
|
|
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 } } });
|
|
268
274
|
} catch (err) {
|
|
269
275
|
logWarn(`feishu: could not look for missed messages in ${chatId}: ${err.message}`);
|
|
270
276
|
}
|
|
@@ -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
|
}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
// What a Feishu group wears
|
|
2
|
-
//
|
|
3
|
-
// label and its mute. Each call takes an `entry`, {get() -> record, set(fields)}: the record
|
|
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
|
|
4
3
|
// of the group in state.json, {chat, name, avatar, tag, ended, endedTag, label, muted}.
|
|
5
4
|
import { bindTag, createTag, setChatAvatar, tagName, unbindTag, uploadImage } from '../client.mjs';
|
|
6
5
|
import { COLORS, avatarPng, letterOf } from '../avatar.mjs';
|
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// another file gives the same members.
|
|
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.
|
|
6
5
|
import { createChat, renameChat, replyMention } from '../client.mjs';
|
|
7
6
|
import { inSpaces, spaceOfName } from './decor.mjs';
|
|
8
7
|
|
|
@@ -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' } }),
|
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) {
|