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 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 is mirrored fully. Other agents show their screen when you type to them,
32
- until herdrchat learns to read their conversations too.
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 是完整同步的。其他 agent 在你给它发消息时会回一次屏幕内容,
27
+ 目前 Claude Code 和 Codex 是完整同步的,它们的对话框都能点按钮回答。其他 agent 在你给它发消息时会回一次屏幕内容,
28
28
  等 herdrchat 学会读它们的对话记录以后,也会完整同步。
29
29
 
30
30
  ## 需要什么
@@ -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 codex and pi). The core between them knows neither. This page says where everything is and
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
- - Only Claude Code has a reader so far. codex, pi and the rest post nothing by themselves
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, template) {
54
- return { schema: '2.0', config: { update_multi: true }, header: { title: { tag: 'plain_text', content: title }, template }, body: { elements: [{ tag: 'markdown', content: markdown }] } };
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: 'fill', text: { tag: 'plain_text', content: short }, behaviors: [{ type: 'callback', value }] };
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
- elements.push(button(`${o.n}. ${o.label}`, { ...value, pick: o.n, label: o.label }, o.n === 1 && !menu.multi ? 'primary' : 'default'));
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' }, 'primary'), name: 'next', form_action_type: 'submit' }] });
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 }, 'primary'), name: 'send', form_action_type: 'submit' },
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' }, 'danger'));
134
- return { schema: '2.0', config: { update_multi: true }, header: { title: { tag: 'plain_text', content: title }, template: 'red' }, body: { elements } };
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 { schema: '2.0', config: { update_multi: true }, header: { title: { tag: 'plain_text', content: title }, template: 'grey' }, body: { elements: [{ tag: 'markdown', content: `${markdown}\n\n**${done}**` }] } };
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
- you = await userAuth.allowed();
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, TONES[tone] || 'blue');
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', root_id: m.root_id, thread_id: m.thread_id, message_type: m.msg_type, content: m.body?.content || '{}', create_time: m.create_time }, sender: { sender_id: { open_id: m.sender.id } } });
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), what it waits on
3
- // and since when. What waits for you comes first. The card is edited in place, so it
4
- // never rings.
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: { icon: '⚠️', word: 'needs you', rank: 0 },
7
- error: { icon: '🔴', word: 'error', rank: 1 },
8
- working: { icon: '⌛️', word: 'working', rank: 2 },
9
- done: { icon: '✅', word: 'done', rank: 3 },
10
- idle: { icon: '❓', word: 'idle', rank: 4 },
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 = { icon: '❓', word: '', rank: 5 };
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 `${s.icon} **${name}** · ${escape(word)}${r.since ? ` · since ${clock(r.since)}` : ''} · \`${r.address}\``;
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 template = waiting ? 'orange' : working ? 'blue' : 'green';
35
- const title = `${waiting ? `⚠️ ${waiting} needs you · ` : ''}herdrchat · ${rows.length} terminals`;
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: { title: { tag: 'plain_text', content: title }, template },
40
- body: { elements: [{ tag: 'markdown', content: `${overviewText(rows)}\n\n<font color='grey'>Updated ${clock(now)}</font>` }] },
41
+ 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, whichever way rooms are laid out (groups.mjs: one group per
2
- // tab; a space's group, later): the space's avatar, the bot tags, and, acting as you, your
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
- // HERDR_ROOMS=tab: one Feishu group per herdr-app tab, with you in it; what you type there
2
- // goes to its terminal. A room is the group's chat id. The group wears its space's avatar,
3
- // its tags and, acting as you, its mute and your label (decor.mjs); the group that needs
4
- // you is pinned. index.mjs calls only what is returned here, and a layout of rooms in
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
- allowed: async () => Boolean(await token().catch(() => null)),
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' } }),
@@ -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
- return crypto.createHash('sha1').update(`${entry.type}|${entry.timestamp}|${entry.operation || ''}|${entry.content || ''}`).digest('hex');
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
@@ -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
- const title = String(record.title || '').replace(/^[^\p{L}\p{N}]+/u, '').replace(/\s+/g, ' ').trim();
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*(?:❯\s*)?(\d{1,2})\.\s+(?:\[(.)\]\s+)?(.*\S)\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
- return BORDER.test(lines[top]) ? top : Math.max(0, first - TEXT_LINES);
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(/❯/g, '').replace(/^(\s*\d{1,2}\.\s+)\[.\]/, '$1').replace(MARKS, '')).join(' ').replace(/\s+/g, ' ').trim();
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, i) => raw.slice(menu.first, menu.last + 1).some((l) => /❯/.test(l) && new RegExp(`^\\s*❯\\s*${o.n}\\.`).test(l)))?.n || 1;
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: BORDER.test(lines[top]), // a dialog's top border; a numbered list in text has none
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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "herdrchat",
3
- "version": "1.4.1",
3
+ "version": "1.5.0",
4
4
  "description": "The chat client for hc (herdr): see and answer every hc terminal from Feishu or Telegram",
5
5
  "repository": {
6
6
  "type": "git",