@assuredloop/orca-bot-kit 0.23.0 → 0.25.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/src/hooks.js CHANGED
@@ -69,6 +69,21 @@ const NUDGE_TIMEOUT = 30;
69
69
  export const nudgeCommand = (bots, bot, cli = ownCli()) =>
70
70
  `${shellWord(cli)} session nudge --bots ${shellWord(bots)} --bot ${shellWord(bot)} 2>/dev/null || true`;
71
71
 
72
+ /**
73
+ * Codex's third kit hook: at each turn end, the session's thread is named after
74
+ * its bot and session, so its tab says which it is (#480). Async, so the turn
75
+ * ends without waiting on it: it waits for the session to be idle, up to 30 s,
76
+ * then types and confirms. A naming took 73 s live, each character waiting on
77
+ * a look through Orca's gate, and a hook stopped partway would leave half the
78
+ * command in the input line, so the timeout leaves room to spare.
79
+ */
80
+ const NAME_EVENT = 'Stop';
81
+ const NAME_TIMEOUT = 300;
82
+
83
+ /** What the naming hook runs, by the kit's own path, as `hookCommand` does. */
84
+ export const nameCommand = (bots, bot, cli = ownCli()) =>
85
+ `${shellWord(cli)} session name --bots ${shellWord(bots)} --bot ${shellWord(bot)} 2>/dev/null || true`;
86
+
72
87
  /**
73
88
  * Make sure the bot at `home` has the kit's SessionStart hook for `harness`.
74
89
  * Returns the file it wrote, relative to the bot home, or undefined when the
@@ -91,7 +106,7 @@ export function installHook(home, harness, { bots, bot }) {
91
106
  // longer asks about is taken out, and a hook of the user's beside the kit's
92
107
  // stays where they put it.
93
108
  const started = withKitHook(settings.hooks, file, mine);
94
- const wanted = { ...settings, hooks: withToolHook(started, toolHookOf(harness, bots, bot)) };
109
+ const wanted = { ...settings, hooks: toolHooksOf(harness, bots, bot).reduce(withToolHook, started) };
95
110
 
96
111
  // Compared as documents, not as text: how the user laid their file out is
97
112
  // theirs, and a run that changes nothing writes nothing.
@@ -138,26 +153,29 @@ export function hookTrouble(home, harness, { bots, bot }) {
138
153
  }
139
154
  const program = running ?? ownCli();
140
155
  wanted = { ...settings, hooks: withKitHook(settings.hooks, file, { type: 'command', command: hookCommand(bots, bot, program), timeout: TIMEOUT }) };
141
- // The harness's second hook is judged on its own: the session hook may be
142
- // right while the warning after a native message (ADR 0032), or the nudge
143
- // a Codex sender left (ADR 0034), is missing.
156
+ // The harness's other hooks are each judged on their own: the session hook
157
+ // may be right while the warning after a native message (ADR 0032), the
158
+ // nudge a Codex sender left (ADR 0034), or the naming of a Codex thread
159
+ // (#480) is missing.
144
160
  if (isDeepStrictEqual(settings, wanted)) {
145
- const tool = toolHookOf(harness, bots, bot);
146
- const toolRunning = programIn(settings, tool.pattern);
147
- // One that runs a kit no longer there is as quiet as a missing one:
148
- // `|| true` hides it (#451 review).
149
- if (toolRunning !== undefined && toolRunning !== BARE && !existsSync(toolRunning)) {
150
- return {
151
- where: file,
152
- says: `${file} holds the kit's ${tool.event} hook for ${tool.matcher}, but it runs ${toolRunning}, which is not there any more, so ${tool.missing}. obk up puts back one that runs the kit you have.`,
153
- };
154
- }
155
- const withTool = { ...settings, hooks: withToolHook(settings.hooks, toolHookOf(harness, bots, bot, toolRunning ?? program)) };
156
- if (!isDeepStrictEqual(settings, withTool)) {
157
- return {
158
- where: file,
159
- says: `${file} does not hold the kit's ${tool.event} hook for ${tool.matcher}, without which ${tool.missing}. obk up puts it back.`,
160
- };
161
+ for (const tool of toolHooksOf(harness, bots, bot)) {
162
+ const toolRunning = programIn(settings, tool.pattern);
163
+ const what = tool.matcher === undefined ? `${tool.event} hook` : `${tool.event} hook for ${tool.matcher}`;
164
+ // One that runs a kit no longer there is as quiet as a missing one:
165
+ // `|| true` hides it (#451 review).
166
+ if (toolRunning !== undefined && toolRunning !== BARE && !existsSync(toolRunning)) {
167
+ return {
168
+ where: file,
169
+ says: `${file} holds the kit's ${what}, but it runs ${toolRunning}, which is not there any more, so ${tool.missing}. obk up puts back one that runs the kit you have.`,
170
+ };
171
+ }
172
+ const withTool = { ...settings, hooks: withToolHook(settings.hooks, toolHookOf(tool.kind, bots, bot, toolRunning ?? program)) };
173
+ if (!isDeepStrictEqual(settings, withTool)) {
174
+ return {
175
+ where: file,
176
+ says: `${file} does not hold the kit's ${what}, without which ${tool.missing}. obk up puts it back.`,
177
+ };
178
+ }
161
179
  }
162
180
  }
163
181
  } catch (error) {
@@ -230,15 +248,29 @@ function withKitHook(hooks, file, mine) {
230
248
  return { ...events, [EVENT]: [...(events[EVENT] ?? []), { hooks: [mine] }] };
231
249
  }
232
250
 
251
+ /** Each harness's kit hooks beside its session hook, as `toolHookOf` gives each. */
252
+ const toolHooksOf = (harness, bots, bot, cli = ownCli()) =>
253
+ (harness === 'claude' ? ['sent'] : ['nudge', 'name']).map((kind) => toolHookOf(kind, bots, bot, cli));
254
+
233
255
  /**
234
- * Each harness's second kit hook, after one of its tools: Claude Code's after a
235
- * native `SendMessage` (ADR 0032), Codex's after its shell tool (ADR 0034).
236
- * The entry, where it goes, how the kit knows its own, and what is lost
237
- * without it.
256
+ * One of the kit's hooks beside its session hook: Claude Code's after a native
257
+ * `SendMessage` (`sent`, ADR 0032), Codex's after its shell tool (`nudge`,
258
+ * ADR 0034), and Codex's at a turn end (`name`, #480). The entry, where it
259
+ * goes, how the kit knows its own, and what is lost without it.
238
260
  */
239
- function toolHookOf(harness, bots, bot, cli = ownCli()) {
240
- if (harness === 'claude') {
261
+ function toolHookOf(kind, bots, bot, cli = ownCli()) {
262
+ if (kind === 'name') {
263
+ return {
264
+ kind,
265
+ event: NAME_EVENT,
266
+ pattern: KIT_NAME,
267
+ mine: { type: 'command', command: nameCommand(bots, bot, cli), timeout: NAME_TIMEOUT, async: true },
268
+ missing: "a Codex session's thread is never named after its bot and session, and its tab shows a topic Codex made up instead (#480)",
269
+ };
270
+ }
271
+ if (kind === 'sent') {
241
272
  return {
273
+ kind,
242
274
  event: SENT_EVENT,
243
275
  matcher: SENT_MATCHER,
244
276
  pattern: KIT_SENT,
@@ -247,6 +279,7 @@ function toolHookOf(harness, bots, bot, cli = ownCli()) {
247
279
  };
248
280
  }
249
281
  return {
282
+ kind,
250
283
  event: NUDGE_EVENT,
251
284
  matcher: NUDGE_MATCHER,
252
285
  pattern: KIT_NUDGE,
@@ -257,10 +290,11 @@ function toolHookOf(harness, bots, bot, cli = ownCli()) {
257
290
 
258
291
  /**
259
292
  * The hooks with the kit's tool hook in a group of its event matched to its
260
- * tool, and everything else as the user left it, as `withKitHook` keeps the
261
- * session hook: one entry of the kit's, written in place where it already sits
262
- * in such a group, taken out of anywhere else, and added in a group of its own
263
- * when there is none. A group the kit empties goes.
293
+ * tool (or with no matcher, for one that has none), and everything else as the
294
+ * user left it, as `withKitHook` keeps the session hook: one entry of the
295
+ * kit's, written in place where it already sits in such a group, taken out of
296
+ * anywhere else, and added in a group of its own when there is none. A group
297
+ * the kit empties goes.
264
298
  */
265
299
  function withToolHook(hooks, { event: wantedEvent, matcher, pattern, mine }) {
266
300
  let placed = false;
@@ -279,7 +313,8 @@ function withToolHook(hooks, { event: wantedEvent, matcher, pattern, mine }) {
279
313
  return kept.length === 0 ? [] : [[event, kept]];
280
314
  }));
281
315
  if (placed) return events;
282
- return { ...events, [wantedEvent]: [...(events[wantedEvent] ?? []), { matcher, hooks: [mine] }] };
316
+ const group = matcher === undefined ? { hooks: [mine] } : { matcher, hooks: [mine] };
317
+ return { ...events, [wantedEvent]: [...(events[wantedEvent] ?? []), group] };
283
318
  }
284
319
 
285
320
  /** The program a command of the kit's of this pattern runs, unquoted, or undefined for any other command. */
@@ -320,6 +355,7 @@ const WORD = String.raw`(?:[A-Za-z0-9,._+:@%/=-]+|'(?:[^']|'\\'')*')`;
320
355
  const KIT_HOOK = new RegExp(String.raw`^(${WORD}) session record --bots ${WORD} --bot ${WORD} 2>/dev/null \|\| true$`);
321
356
  const KIT_SENT = new RegExp(String.raw`^(${WORD}) session sent --bots ${WORD} --bot ${WORD} 2>/dev/null \|\| true$`);
322
357
  const KIT_NUDGE = new RegExp(String.raw`^(${WORD}) session nudge --bots ${WORD} --bot ${WORD} 2>/dev/null \|\| true$`);
358
+ const KIT_NAME = new RegExp(String.raw`^(${WORD}) session name --bots ${WORD} --bot ${WORD} 2>/dev/null \|\| true$`);
323
359
 
324
360
  /** The program a bot made before the kit named itself by path runs. */
325
361
  const BARE = 'obk';
package/src/message.js CHANGED
@@ -19,7 +19,7 @@ import { mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync,
19
19
  import os from 'node:os';
20
20
  import path from 'node:path';
21
21
 
22
- import { MAILBOX_WAIT_MS, readBook, takeMailboxTurn } from './book.js';
22
+ import { MAILBOX_WAIT_MS, readBook, takeLineTurn, takeMailboxTurn, TYPING_HELD, TYPING_WAIT_MS } from './book.js';
23
23
  import { botDir, botNames, readBot } from './bot.js';
24
24
  import { harnessOf, isAddressOf, ownCli, reachesMail, SHELL_ENV, shellWord } from './launch.js';
25
25
  import { ackMailbox, coordinatorOf, postMessage, readMailbox, tabs, tabToTypeInto, TERMINAL_ENV, TIMED_OUT, typeIntoTab, useMailbox } from './orca.js';
@@ -456,6 +456,18 @@ const stamp = () => new Date().toISOString().replaceAll(':', '-').replace('.', '
456
456
  function nudge(to, from, subject, tab) {
457
457
  if (to.tab === undefined) return { nudged: false };
458
458
 
459
+ // A line typed while the kit types a command one key at a time would land
460
+ // in it and send it with its own return (#480): so the receiver's turn for
461
+ // a line first, for a bounded time, and the mail waits in its mailbox if not.
462
+ let turn;
463
+ try {
464
+ turn = takeLineTurn(to.home, to.session, TYPING_WAIT_MS);
465
+ } catch (error) {
466
+ return { nudged: false, nudgeTrouble: error.message };
467
+ }
468
+ if (turn === undefined) {
469
+ return { nudged: false, nudgeTrouble: TYPING_HELD };
470
+ }
459
471
  try {
460
472
  const found = lookAt(to);
461
473
  if (found.blocked !== undefined) return { nudged: false, blocked: found.blocked };
@@ -483,6 +495,8 @@ function nudge(to, from, subject, tab) {
483
495
  // silent `false` would read as "the session is not up", which is a
484
496
  // different thing from "Orca would not say".
485
497
  return { nudged: false, nudgeTrouble: error.message };
498
+ } finally {
499
+ turn.release();
486
500
  }
487
501
  }
488
502
 
package/src/name.js ADDED
@@ -0,0 +1,138 @@
1
+ // `obk session name`: the kit names a Codex session's thread `<bot>.<session>`
2
+ // (#480), so the tab, which Codex titles with the thread's name after every
3
+ // turn, says which bot and session it is. A Claude session has its name from
4
+ // the kit's `-n` already.
5
+ //
6
+ // Codex 0.160.0 has no launch-time way to name a thread, so this types Codex's
7
+ // own `/rename` (the architect's ruling on #480). It runs from the kit's async
8
+ // Stop hook (src/hooks.js) at each turn end, never inside a turn: a fresh
9
+ // start's first turn is its start prompt, which nothing may cut off. It types
10
+ // only through the path `obk session clear` takes (src/clear.js, #391), and
11
+ // what is not safe to type now waits for the next turn end.
12
+
13
+ import { readFileSync, realpathSync } from 'node:fs';
14
+ import { homedir } from 'node:os';
15
+ import path from 'node:path';
16
+ import { setTimeout as pause } from 'node:timers/promises';
17
+
18
+ import { readBook, takeNameTurn, takeTypingTurn } from './book.js';
19
+ import { botDir, readBot } from './bot.js';
20
+ import { sessionToType, typeCommand } from './clear.js';
21
+ import { harnessOf } from './launch.js';
22
+ import { sessionsOf } from './up.js';
23
+
24
+ /**
25
+ * Where Codex keeps its threads' names: a line `{ id, thread_name, updated_at }`
26
+ * a name, the file only growing and the newest line for an id winning
27
+ * (codex-rs/rollout/src/session_index.rs, rust-v0.160.0).
28
+ */
29
+ const sessionIndex = () => path.join(homedir(), '.codex', 'session_index.jsonl');
30
+
31
+ /**
32
+ * When a naming stops typing, counted from its start: well inside the hook's
33
+ * 300 s (src/hooks.js), at which Codex kills the hook's whole process group
34
+ * (codex-rs/hooks/src/engine/command_runner.rs), so that what was typed is
35
+ * taken back first.
36
+ */
37
+ const DEADLINE_MS = 240_000;
38
+
39
+ /** How long Codex is given to write the name down after the return. */
40
+ const CONFIRM_MS = 10_000;
41
+ const ASK_MS = 500;
42
+
43
+ /**
44
+ * Name the thread that just ended a turn in the tab `tabId`, as Codex's Stop
45
+ * hook `said` it, when that tab is a Codex session of `bot` and its thread does
46
+ * not have the name yet. Returns whether Codex's record names it afterwards,
47
+ * or undefined when there was nothing to do. Throws when it could not type.
48
+ */
49
+ export async function nameSession(bots, bot, said, tabId) {
50
+ const deadline = Date.now() + DEADLINE_MS;
51
+ if (said?.hook_event_name !== 'Stop') return undefined;
52
+ if (typeof tabId !== 'string' || tabId === '') return undefined;
53
+
54
+ const home = realpathSync(botDir(bots, bot));
55
+ const [session, entry] = Object.entries(readBook(home).sessions).find(([, one]) => one?.tab === tabId) ?? [];
56
+ if (session === undefined) return undefined;
57
+ const known = readBot(home, bot);
58
+ const [settings] = sessionsOf(known, session);
59
+ if (harnessOf(settings, known.harness) !== 'codex') return undefined;
60
+ // Only the thread the book says this tab is running. A Codex that Orca
61
+ // brought back by itself runs its hooks in Codex's shared background server,
62
+ // under another session's tab (#408), and that tab shows another thread.
63
+ const thread = said.session_id;
64
+ if (typeof thread !== 'string' || thread !== entry.session) return undefined;
65
+
66
+ const name = `${bot}.${session}`;
67
+ if (nameOf(thread) === name) return undefined;
68
+
69
+ const turn = takeNameTurn(home, session);
70
+ if (turn === undefined) return undefined;
71
+ let typing;
72
+ try {
73
+ // Another naming may have finished while this one waited for its turn.
74
+ if (nameOf(thread) === name) return undefined;
75
+ // Only into the tab the hook ran in: one whose Codex was killed hard can
76
+ // leave its hook running (Codex starts each hook in a session of its own,
77
+ // codex-rs/hooks/src/engine/command_runner.rs), and the tab the book holds
78
+ // now shows another process.
79
+ const it = { ...sessionToType(bots, bot, session), conversation: thread };
80
+ if (it.tabId !== tabId) return undefined;
81
+ // The session's typing turn, taken once it is idle and not before, so a
82
+ // mail nudge waits seconds for it and not the whole wait for idle. Held by
83
+ // anything else: nothing is typed, and the next turn end tries again.
84
+ const before = () => {
85
+ typing = takeTypingTurn(home, session, 0);
86
+ if (typing === undefined) throw new Error(`${it.name}: nothing was typed, because the kit is typing into its tab already.`);
87
+ };
88
+ try {
89
+ await typeCommand(it, `/rename ${name}`, { check: renameWrong, deadline, before });
90
+ } finally {
91
+ typing?.release();
92
+ }
93
+ const until = Date.now() + CONFIRM_MS;
94
+ while (nameOf(thread) !== name && Date.now() < until) await pause(ASK_MS);
95
+ return nameOf(thread) === name;
96
+ } finally {
97
+ turn.release();
98
+ }
99
+ }
100
+
101
+ /** The name Codex's record gives `thread` now, or undefined. */
102
+ function nameOf(thread) {
103
+ let text;
104
+ try {
105
+ text = readFileSync(sessionIndex(), 'utf8');
106
+ } catch {
107
+ return undefined;
108
+ }
109
+ let name;
110
+ for (const line of text.split('\n')) {
111
+ let entry;
112
+ try {
113
+ entry = JSON.parse(line);
114
+ } catch {
115
+ continue;
116
+ }
117
+ if (entry?.id === thread && typeof entry.thread_name === 'string') name = entry.thread_name;
118
+ }
119
+ return name;
120
+ }
121
+
122
+ /**
123
+ * Why the screen does not show `command` typed and ready, or undefined when it
124
+ * does: the input line, the lowest row Codex's pointer starts, reads the
125
+ * pointer and the command exactly, and no row of its slash menu stands above
126
+ * it. With words after the command Codex has closed its menu (chat_composer.rs
127
+ * `sync_command_popup`, rust-v0.160.0), so a menu still open means the line is
128
+ * not what it seems.
129
+ */
130
+ function renameWrong(rows, _harness, command) {
131
+ const at = rows.findLastIndex((row) => /^ *›/.test(row));
132
+ if (at < 0) return { why: 'its screen shows no input line' };
133
+ const line = rows[at].replaceAll(' ', ' ').trim();
134
+ if (line !== `› ${command}`) return { why: `its input line reads "${line}"` };
135
+ const above = rows.slice(0, at).findLast((row) => row.trim() !== '');
136
+ if (above !== undefined && /^ *(?:› +)?\/\S/.test(above)) return { why: `a menu of commands stands above its input line: "${above.trim()}"` };
137
+ return undefined;
138
+ }
package/src/orca.js CHANGED
@@ -420,8 +420,9 @@ const ON_A_CHOICE = /^( *[›❯] +)\d+\. /;
420
420
  * Every one seen is a numbered list of choices with the harness's pointer on
421
421
  * one (tech notes, section 1; ADR 0034). The same pointer starts the harness's
422
422
  * input line and its echo of the user's past turns, and the input line is the
423
- * lowest of them whenever it is on screen, so only the lowest pointer row is
424
- * asked about: a question counts while it stands in the input line's place.
423
+ * lowest of them whenever it is on screen, so the lowest pointer row is asked
424
+ * about: a question counts while it stands in the input line's place, or
425
+ * while it is the last thing drawn right above the input box (#491).
425
426
  * Unnumbered lists do not count. Codex puts a status row right under its input
426
427
  * line, lined up with it, and by its layout that is one.
427
428
  */
@@ -434,6 +435,37 @@ export function questionIn(rows) {
434
435
  // there is none: under old words of one in the history, the input line is
435
436
  // the lowest pointer, and nothing below it is a foot.
436
437
  if (rows.slice(Math.max(at, 0)).some((row) => FORM_FOOT.test(row))) return true;
438
+ if (pointedChoiceAt(rows, at)) return true;
439
+ // Claude Code 2.1.289 draws its Teach list above its input box, whose own
440
+ // `❯` stays the lowest pointer row (#491). A question there is the last
441
+ // thing drawn above the box's top rule: its foot, or its numbered choices
442
+ // with the pointer on one, under it a foot that a narrow pane wraps onto
443
+ // rows of its own. The same words with anything after them are history.
444
+ if (at < 1 || !RULE_ROW.test(rows[at - 1])) return false;
445
+ let end = rows.slice(0, at - 1).findLastIndex((row) => row.trim() !== '');
446
+ if (end < 0) return false;
447
+ if (FORM_FOOT.test(rows[end])) return true;
448
+ while (end > 0 && FOOT_PART.test(rows[end])) end -= 1;
449
+ if (!NUMBERED.test(rows[end])) return false;
450
+ let first = end;
451
+ while (first > 0 && NUMBERED.test(rows[first - 1])) first -= 1;
452
+ for (let row = first; row <= end; row += 1) {
453
+ if (pointedChoiceAt(rows, row)) return true;
454
+ }
455
+ return false;
456
+ }
457
+
458
+ /** A rule Claude Code draws right across, as the top of its input box: `─` or `▔`. */
459
+ const RULE_ROW = /^ *[─▔]{8,}/;
460
+
461
+ /** A numbered choice, with the pointer on it or not. */
462
+ const NUMBERED = /^ *(?:[›❯] +)?\d+\. /;
463
+
464
+ /** A part of a foot, as a narrow pane wraps it: `Enter to confirm ·`, `Esc to cancel`. */
465
+ const FOOT_PART = /\b(?:Enter to (?:continue|confirm|select)|Esc to cancel)\b/i;
466
+
467
+ /** Whether `rows[at]` has the pointer on a numbered choice, with another lined up right above or below it. */
468
+ function pointedChoiceAt(rows, at) {
437
469
  const pointer = at < 0 ? null : ON_A_CHOICE.exec(rows[at]);
438
470
  if (pointer === null) return false;
439
471
  const column = pointer[1].length;
package/src/skills.js CHANGED
@@ -23,7 +23,7 @@ import { fileURLToPath } from 'node:url';
23
23
 
24
24
  import { parse, parseDocument, stringify } from 'yaml';
25
25
 
26
- import { bookFile, readBook } from './book.js';
26
+ import { bookFile, readBook, takeLineTurn, TYPING_HELD, TYPING_WAIT_MS } from './book.js';
27
27
  import { botDir, botNames, changesExactly, leadsOutside, readBot, YAML_OUT } from './bot.js';
28
28
  import { harnessOf, ownCli, shellWord } from './launch.js';
29
29
  import { orcaCli, orcaTrouble, tabToTypeInto, typeIntoTab } from './orca.js';
@@ -473,7 +473,14 @@ function tellSessions(home, bot, linked) {
473
473
  const tab = typeof book[session.name]?.tab === 'string' ? book[session.name].tab : undefined;
474
474
  if (tab === undefined) return { ...told, state: 'not-up' };
475
475
  if (down !== undefined) return { ...told, state: 'unknown', trouble: `Orca is not answering at ${orcaCli()}, so the kit could not look at its tab, and nothing was typed` };
476
+ let turn;
476
477
  try {
478
+ // The session's turn for a line (#482), for the look and its line. Codex
479
+ // is typed nothing here, so it waits for none.
480
+ if (harness !== 'codex') {
481
+ turn = takeLineTurn(real, session.name, TYPING_WAIT_MS);
482
+ if (turn === undefined) return { ...told, state: 'unknown', trouble: TYPING_HELD };
483
+ }
477
484
  const found = tabToTypeInto(real, tab, LOOK_MS);
478
485
  if (found.blocked !== undefined) return { ...told, state: 'blocked', blocked: found.blocked };
479
486
  if (found.unsure !== undefined) return { ...told, state: 'unknown', trouble: found.unsure };
@@ -495,6 +502,8 @@ function tellSessions(home, bot, linked) {
495
502
  // The links are made whatever Orca says, so this is reported, not
496
503
  // thrown: the build did what it was asked, and the session was not told.
497
504
  return { ...told, state: 'unknown', trouble: error.message };
505
+ } finally {
506
+ turn?.release();
498
507
  }
499
508
  });
500
509
  }