@assuredloop/orca-bot-kit 0.21.0 → 0.23.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
@@ -10,7 +10,7 @@ an `obk` command and a set of skills. You are meant to manage the fleet by
10
10
  talking to Bot Father, the first bot, whose skills call the CLI for you — not by
11
11
  editing files.
12
12
 
13
- The design is in [`docs/prd.md`](docs/prd.md); the decisions with lasting
13
+ The design is in [`docs/prd/prd.md`](docs/prd/prd.md); the decisions with lasting
14
14
  consequences are in [`docs/adr/`](docs/adr/); what we know about Orca and the
15
15
  two harnesses is in [`docs/tech-notes.md`](docs/tech-notes.md).
16
16
 
@@ -350,12 +350,36 @@ address that is none of your bots folder's sessions, the hook tells it so, and
350
350
  names the address `obk message to` would have given. The message goes either
351
351
  way ([ADR 0032](docs/adr/0032-a-native-message-outside-the-fleet-is-warned-about.md)).
352
352
 
353
+ A Codex bot gets one more hook too. Inside Codex's sandbox the kit cannot always
354
+ tell whether the receiver of a message has its harness in front, as for a Claude
355
+ session busy running a command, so `obk message send` leaves that nudge, and the
356
+ hook, which Codex runs outside its sandbox right after the command, decides it and
357
+ types it, or says why not ([ADR 0034](docs/adr/0034-orca-is-the-host.md)). Codex
358
+ asks you to trust a hook it has not seen before, so a Codex bot made before this
359
+ hook asks again once.
360
+
353
361
  So: kill a tab, or reboot, and `obk up` brings the session back with the
354
362
  conversation it was having, rather than starting a new one. Clear a session —
355
363
  `/clear` on Claude Code, `/new` on Codex — and the kit writes down the new id,
356
364
  keeps the old one, and gives the session its start prompt again, because that
357
365
  prompt is what tells one session's duty from another's.
358
366
 
367
+ Ask the kit to clear or compact a session, rather than typing into its tab:
368
+
369
+ ```sh
370
+ obk session clear --bots /path/to/my-bots --bot api-bot --session daily
371
+ obk session compact --bots /path/to/my-bots --bot api-bot --session daily
372
+ ```
373
+
374
+ Each waits up to 30 s for the session to be idle, and types nothing into one
375
+ that is busy or has a question on its screen. It types the harness's own
376
+ command, checks the screen before it presses return, and then confirms the
377
+ result: for a clear, the new conversation in the book; for a compact, the
378
+ compaction in the harness's own record, within 5 minutes, or it says it is not
379
+ confirmed yet. A harness that does not offer `/compact` is told so, and nothing
380
+ is entered. A cleared session reads its `AGENTS.md` again, so a clear also
381
+ brings it onto changed rules.
382
+
359
383
  A session paused before its first turn can have an id in the book and no
360
384
  conversation behind it: the harness has nothing on record for that id. There is
361
385
  nothing to resume, so it starts again with its duty, and the book moves the id
@@ -641,7 +665,7 @@ editor's runner, skips and says how to run it.
641
665
  JavaScript this branch changed against `main` — committed, still in the working
642
666
  tree, or not tracked yet — and reports the mutants the tests do not kill. It is
643
667
  an audit, not a step of every pull request
644
- ([PRD 7.3](docs/prd.md#73-decided-rules-inside-the-skills)): the everyday
668
+ ([PRD 7.3](docs/prd/prd.md#prd-21-73-decided-rules-inside-the-skills)): the everyday
645
669
  check is the implementer's own hand check, and the tool runs over the whole
646
670
  suite at a milestone or when the owner asks, or once, narrowed to the changed
647
671
  logic, for a change at the core of the kit. A surviving mutant is a change to
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assuredloop/orca-bot-kit",
3
- "version": "0.21.0",
3
+ "version": "0.23.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -368,8 +368,9 @@ already up even when a skill change would, and a session's model, effort or
368
368
  approval only changes when it is started again (`obk restart`, on the user's
369
369
  word, and then read its tabs as above). Say which you changed, and what that
370
370
  means for the sessions running now. `obk health` names each running session
371
- still on the older rules or on other settings, and what brings it up to date: on
372
- Claude Code a `/clear` reads the rules again, and a start does on both harnesses.
371
+ still on the older rules or on other settings, and what brings it up to date: a
372
+ clear reads the rules again (`obk session clear`), and so does a start, on both
373
+ harnesses.
373
374
  Where everything needs restarting, remind
374
375
  them to do it from Bot Father's ops tab.
375
376
 
package/src/book.js CHANGED
@@ -32,7 +32,8 @@ const HEADER = `# What Orca calls this bot on this machine, and where each of it
32
32
  # the bot, with when, so the conversations it had are still accounted for.
33
33
  #
34
34
  # \`temporary\` marks a session another session of this bot made with
35
- # \`obk temp make\`: which session made it, and when. Its maker retires it.
35
+ # \`obk temp make\`: which session made it, and when, and the role and option
36
+ # of bot.yaml's temp_roles it was made in, if any. Its maker retires it.
36
37
  `;
37
38
 
38
39
  export const bookFile = (home) => path.join(home, 'sessions.yaml');
package/src/bot.js CHANGED
@@ -159,6 +159,8 @@ export function readBot(home, name = path.basename(home)) {
159
159
  // The permission rules the user said yes to, judged where they are written
160
160
  // (src/permissions.js), as `rules` is judged by the build.
161
161
  allow: bot.allow,
162
+ // The roles its temporary sessions can be made in, judged by tempRoles.
163
+ ...(bot.temp_roles === undefined ? {} : { temp_roles: bot.temp_roles }),
162
164
  // A paused bot is one `obk up` leaves closed; its sessions keep their book.
163
165
  ...(bot.paused === true ? { paused: true } : {}),
164
166
  sessions: sessions.map((session) => {
@@ -495,7 +497,7 @@ export const SESSION_FIELDS = [
495
497
  ];
496
498
 
497
499
  /** Everything the top of a bot's own file can hold. */
498
- const BOT_FIELDS = ['name', 'harness', 'charter', 'rules', 'skills', 'allow', 'sessions', 'paused'];
500
+ const BOT_FIELDS = ['name', 'harness', 'charter', 'rules', 'skills', 'allow', 'sessions', 'paused', 'temp_roles'];
499
501
 
500
502
  /**
501
503
  * The keys in a bot's file that the kit does not know, and so that nothing
@@ -519,6 +521,81 @@ export function unknownKeys(home, name = path.basename(home)) {
519
521
  ];
520
522
  }
521
523
 
524
+ /** What a role's option can set, and what a role written as a mapping can hold (#465). */
525
+ const OPTION_FIELDS = ['name', 'for', 'harness', 'model', 'effort', 'context'];
526
+ const ROLE_FIELDS = ['options', 'cap', 'prompt_file'];
527
+
528
+ /**
529
+ * The roles a bot's temporary sessions can be made in, from `temp_roles` in its
530
+ * file (#465), each read into one form: `{ name, options, cap, prompt_file,
531
+ * trouble }`. A role is written as a list of options, the first its default,
532
+ * or as a mapping that holds that list as `options` beside a `cap` and a
533
+ * `prompt_file`. `trouble` is each thing wrong with the role, as a sentence;
534
+ * a role with any is not one to make a session in. A `temp_roles` that is not
535
+ * a mapping at all is thrown. The file is the user's, so nothing here is put
536
+ * right: `obk health` says it, and `obk temp make` refuses the role.
537
+ */
538
+ export function tempRoles(home, bot) {
539
+ const file = path.join(home, BOT_YAML);
540
+ const given = bot.temp_roles;
541
+ if (given === undefined || given === null) return [];
542
+ if (typeof given !== 'object' || Array.isArray(given)) {
543
+ throw new Error(`${file} has a temp_roles that is not a mapping of role names to roles, so the bot has no roles to make a temporary session in.`);
544
+ }
545
+ return Object.entries(given).map(([name, written]) => readRole(home, file, name, written));
546
+ }
547
+
548
+ function readRole(home, file, name, written) {
549
+ const trouble = [];
550
+ const say = (what) => trouble.push(`${file}: the temporary-session role ${name} ${what}`);
551
+ const role = Array.isArray(written) ? { options: written } : written;
552
+ if (!NAME.test(name)) say('cannot be a role\'s name: a name is lower-case letters, digits and single hyphens, and it begins the name of each session made in it.');
553
+ if (role === null || typeof role !== 'object') {
554
+ say('is neither a list of options nor a mapping with options in it.');
555
+ return { name, options: [], trouble };
556
+ }
557
+ for (const key of Object.keys(role).filter((one) => !ROLE_FIELDS.includes(one))) {
558
+ say(`has a key the kit does not know, ${key}, so nothing reads it. A role written as a mapping can have ${ROLE_FIELDS.join(', ')}.`);
559
+ }
560
+ const options = Array.isArray(role.options) ? role.options : [];
561
+ if (options.length === 0) say('has no options. Give it a list of them, the first being its default.');
562
+ const seen = new Set();
563
+ options.forEach((option, at) => {
564
+ if (option === null || typeof option !== 'object' || Array.isArray(option) || option.name === undefined || option.name === null || option.name === '') {
565
+ say(`has an option with no name, number ${at + 1} of its options.`);
566
+ return;
567
+ }
568
+ const called = `${name}:${option.name}`;
569
+ if (typeof option.name !== 'string' || !NAME.test(option.name)) say(`has an option ${option.name} whose name is not a word of lower-case letters, digits and single hyphens, so ${called} cannot be asked for. Write the name in quotes if YAML reads it as something else.`);
570
+ if (seen.has(option.name)) say(`has two options called ${option.name}, and ${called} would name either.`);
571
+ seen.add(option.name);
572
+ for (const key of Object.keys(option).filter((one) => !OPTION_FIELDS.includes(one))) {
573
+ say(`has an option ${option.name} with a setting the kit does not know, ${key}, so nothing reads it and a session made in ${called} would not get it. An option can set ${OPTION_FIELDS.join(', ')}.`);
574
+ }
575
+ if (option.harness !== undefined && option.harness !== null && !HARNESSES.includes(option.harness)) {
576
+ say(`has an option ${option.name} that runs on ${option.harness}, and the harnesses are ${HARNESSES.join(' and ')}.`);
577
+ }
578
+ });
579
+ if (role.cap !== undefined && !(Number.isInteger(role.cap) && role.cap > 0)) {
580
+ say(`has a cap of ${JSON.stringify(role.cap)}, and a cap is a whole number of open sessions, 1 or more.`);
581
+ }
582
+ if (role.prompt_file !== undefined) {
583
+ const prompt = path.resolve(home, String(role.prompt_file));
584
+ try {
585
+ readFileSync(prompt, 'utf8');
586
+ } catch (error) {
587
+ say(`has its prompt in ${role.prompt_file}, and that file cannot be read (${error.code}): ${prompt}. Write it, or point the role at the file you meant.`);
588
+ }
589
+ }
590
+ return {
591
+ name,
592
+ options,
593
+ ...(role.cap === undefined ? {} : { cap: role.cap }),
594
+ ...(role.prompt_file === undefined ? {} : { prompt_file: role.prompt_file }),
595
+ trouble,
596
+ };
597
+ }
598
+
522
599
  /** A session's settings, in that order, without the ones left out. */
523
600
  function ordered(session) {
524
601
  const entry = {};
package/src/clear.js ADDED
@@ -0,0 +1,455 @@
1
+ // `obk session clear` and `obk session compact` (#391): the kit, not the
2
+ // harness, is asked to clear or compact a session, and does it safely.
3
+ //
4
+ // A clear is special because a cleared session has to be told its duty again.
5
+ // That is the kit's hook's work already (src/record.js): it hears the new
6
+ // conversation and answers with the start prompt. So this only types the
7
+ // harness's own command, and only when that cannot cut off a turn or answer a
8
+ // question: it waits for the session to be idle, types the command without a
9
+ // return, reads the screen, and presses return only when the screen shows the
10
+ // command and nothing else. Afterwards it confirms what happened from the
11
+ // records, not from the screen: the book's new conversation for a clear, the
12
+ // harness's own record of a compaction for a compact.
13
+
14
+ import { realpathSync, statSync } from 'node:fs';
15
+ import { setTimeout as pause } from 'node:timers/promises';
16
+
17
+ import { readBook } from './book.js';
18
+ import { botDir, readBot } from './bot.js';
19
+ import { claudeTranscript, codexRollout } from './conversations.js';
20
+ import { harnessOf, ownCli, shellWord } from './launch.js';
21
+ import { eachLine, firstLine } from './lines.js';
22
+ import { findProject, orca, QUESTION_ON_SCREEN, questionIn, screenRows, tabs, tabToTypeInto } from './orca.js';
23
+ import { botsNamed, sessionsOf, typeListLine } from './up.js';
24
+
25
+ /** The harness's own command for each, as typed into its input line. Codex's clear is `/new` (tech notes, section 3). */
26
+ const COMMANDS = {
27
+ clear: { claude: '/clear', codex: '/new' },
28
+ compact: { claude: '/compact', codex: '/compact' },
29
+ };
30
+
31
+ /** The pointer each harness starts its input line with (tech notes, section 1). */
32
+ const POINTER = { claude: '❯', codex: '›' };
33
+
34
+ /**
35
+ * The line typed into Codex after its `/new`, in the architect's words (#391).
36
+ * Codex reports a new conversation only at its first turn (tech notes, section
37
+ * 3), so without a turn the hook never hears of it and never re-briefs it.
38
+ */
39
+ const RECORD_LINE = 'obk: this conversation was started by obk session clear, and this line is only so the kit can record it. Reply "ok"; nothing else is asked.';
40
+
41
+ /** What Codex asks after `/new`, and the one answer the kit gives it: the bot home it runs in. */
42
+ const WHERE_TO_RUN = 'Where should the new conversation run?';
43
+ const CURRENT_CHECKOUT = /^ *› +1\. Current checkout\b/;
44
+
45
+ /** Codex's empty input line: its pointer alone, or with its placeholder. */
46
+ const CODEX_EMPTY = ['›', '› Ask Codex to do anything'];
47
+
48
+ /**
49
+ * The Codex whose input line Orca's screen shows bare while its slash menu is
50
+ * open, whatever was typed (#391, live runs 2 to 4). On it alone, the menu's
51
+ * selected row stands for what Return will run: with the menu open, Return
52
+ * runs the selected command, and the menu is narrowed to what was typed
53
+ * (codex-rs/tui/src/bottom_pane/chat_composer.rs, rust-v0.160.0). The
54
+ * architect's ruling on #391, after live run 4.
55
+ */
56
+ const CODEX_BARE_LINE = '0.160.0';
57
+
58
+ /** How long the session is given to be idle before nothing is typed, and how long each look waits on it. */
59
+ const IDLE_WAIT_MS = 30_000;
60
+ const LOOK_MS = 2000;
61
+ /** How long the screen is given to show the command typed, or Codex's question after `/new`. */
62
+ const SCREEN_MS = 3000;
63
+ /** How long the book is given to hold the new conversation, and a harness to record a compaction (the architect: up to 5 minutes). */
64
+ const CLEAR_MS = 30_000;
65
+ const COMPACT_MS = 300_000;
66
+ /** How long each screen read is given before it counts as a screen that cannot be read. */
67
+ const READ_MS = 5000;
68
+ /** The pause between looks, and between looks at a record that may be large. */
69
+ const ASK_MS = 500;
70
+ const RECORD_ASK_MS = 2000;
71
+ /**
72
+ * The gap between two characters of a command. Codex takes characters that come
73
+ * less than 8 ms apart as a paste, and flushes a paste after 60 ms without one
74
+ * (PASTE_BURST_CHAR_INTERVAL and PASTE_BURST_ACTIVE_IDLE_TIMEOUT in
75
+ * codex-rs/tui/src/bottom_pane/paste_burst.rs, rust-v0.160.0). A command it
76
+ * takes for a paste was seen to leave its input line bare (#391, live run 2),
77
+ * so the kit types well past both.
78
+ */
79
+ const CHAR_GAP_MS = 100;
80
+
81
+ /**
82
+ * A row that says the harness is at work on a turn, as seen live (#391): Codex
83
+ * 0.160.0's "• Working (8s • esc to interrupt)", and Claude Code 2.1.288's
84
+ * spinner row, "✳ Nucleating… (1m 8s · ↓ 131 tokens)": a glyph, a word that
85
+ * ends in "…", then the time. A finished turn's row, "✻ Churned for 13s · done
86
+ * 12:27 PM", has no "…".
87
+ */
88
+ const AT_WORK = [/esc to interrupt/i, /^\s*\S\s+\S+…\s+\(\d/];
89
+
90
+ /**
91
+ * Clear the session: `/clear` on Claude Code, `/new` on Codex. Returns
92
+ * `{ bot, session, harness, was, now }`, the conversation the book held before
93
+ * (or null) and the one it holds now.
94
+ */
95
+ export async function clearSession(bots, { bot, session }) {
96
+ const it = sessionToType(bots, bot, session);
97
+ const was = readBook(it.home).sessions[session]?.session ?? null;
98
+ const { command, handle } = await enter(it, 'clear');
99
+
100
+ if (it.harness === 'codex') {
101
+ await answerWhereToRun(it, handle, command);
102
+ const line = await typeListLine(it.home, it.tabId, RECORD_LINE);
103
+ if (!line.typed) {
104
+ throw new Error(`${it.name}: ${command} went in, but the line that starts the new conversation's first turn was not typed (${line.why}), and Codex reports a new conversation only at its first turn. The book still holds ${was ?? 'no conversation'}. Look at its tab.`);
105
+ }
106
+ }
107
+
108
+ const until = Date.now() + CLEAR_MS;
109
+ for (;;) {
110
+ const now = readBook(it.home).sessions[session]?.session;
111
+ if (typeof now === 'string' && now !== was) return { bot, session, harness: it.harness, was, now };
112
+ if (Date.now() >= until) {
113
+ throw new Error(`${it.name}: ${command} went in, but after ${CLEAR_MS / 1000} s the book has no new conversation for it: it still holds ${was ?? 'none'}. Look at its tab.`);
114
+ }
115
+ await pause(ASK_MS);
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Compact the session's conversation with the harness's `/compact`. Returns
121
+ * `{ bot, session, harness, conversation, confirmed }`: confirmed once the
122
+ * harness's own record of the conversation shows a compaction made after the
123
+ * command began, and not yet when COMPACT_MS passed without one.
124
+ */
125
+ export async function compactSession(bots, { bot, session }) {
126
+ const it = sessionToType(bots, bot, session);
127
+ // What the record holds when /compact is typed is not this compact's: one
128
+ // that ends while the kit waits for the session to be idle is the one
129
+ // before it (review of PR #466). So only what is written after counts.
130
+ let record;
131
+ const { typedAt } = await enter(it, 'compact', () => {
132
+ record = recordAt(it.harness, it.home, readBook(it.home).sessions[session]?.session ?? null);
133
+ });
134
+
135
+ const until = typedAt + COMPACT_MS;
136
+ for (;;) {
137
+ const conversation = readBook(it.home).sessions[session]?.session ?? null;
138
+ // A conversation the book did not hold when it was typed is read whole.
139
+ if (conversation !== record.id) record = { id: conversation, from: 0 };
140
+ record.file ??= recordFile(it.harness, it.home, conversation);
141
+ if (record.file !== undefined && compactedSince(it.harness, record, typedAt)) {
142
+ return { bot, session, harness: it.harness, conversation, confirmed: true };
143
+ }
144
+ if (Date.now() >= until) return { bot, session, harness: it.harness, conversation, confirmed: false };
145
+ await pause(RECORD_ASK_MS);
146
+ }
147
+ }
148
+
149
+ /**
150
+ * The session as something to type into, once everything that refuses before
151
+ * Orca is asked to type has been asked: `{ name, home, session, harness, tabId }`.
152
+ */
153
+ function sessionToType(bots, bot, session) {
154
+ botsNamed(bots, bot);
155
+ const home = realpathSync(botDir(bots, bot));
156
+ const known = readBot(home, bot);
157
+ const [settings] = sessionsOf(known, session);
158
+ const name = `${bot} ${session}`;
159
+ const unpause = `${shellWord(ownCli())} unpause --bots ${shellWord(bots)} --bot ${bot}`;
160
+ if (known.paused === true) throw new Error(`${bot} is paused, so ${name} is not running and nothing was typed. Bring it back with ${unpause}.`);
161
+ if (settings.paused === true) throw new Error(`${name} is paused, so it is not running and nothing was typed. Bring it back with ${unpause} --session ${session}.`);
162
+
163
+ const tabId = readBook(home).sessions[session]?.tab;
164
+ // Orca refuses to list the tabs of a folder it has no project for (tech
165
+ // notes, section 1), so a bot never brought up is asked nothing.
166
+ const open = typeof tabId === 'string' && findProject(home) !== undefined && tabs(home).some((tab) => tab.tabId === tabId);
167
+ if (!open) {
168
+ throw new Error(`${name} has no tab open in Orca, so it is not running and nothing was typed. Bring it up with ${shellWord(ownCli())} up --bots ${shellWord(bots)} --bot ${bot} --session ${session}.`);
169
+ }
170
+ return { name, home, session, harness: harnessOf(settings, known.harness), tabId, restart: `${shellWord(ownCli())} restart --bots ${shellWord(bots)} --bot ${bot} --session ${session}` };
171
+ }
172
+
173
+ /**
174
+ * Type the harness's command for `verb` into the session's tab and press
175
+ * return, once it is idle and the screen shows the command typed and nothing
176
+ * else. `before` runs just before the command is typed. Returns the command,
177
+ * the handle it went into, and when it was typed.
178
+ */
179
+ async function enter(it, verb, before = () => {}) {
180
+ const command = COMMANDS[verb][it.harness];
181
+ const { handle, rows } = await idleTab(it);
182
+ // On Codex, what is typed cannot be read back once its menu is open, so
183
+ // nothing may be in its input line before: no draft for the command to join.
184
+ if (it.harness === 'codex') {
185
+ const line = rows.findLast((row) => /^ *›/.test(row))?.trim();
186
+ if (!CODEX_EMPTY.includes(line)) {
187
+ throw new Error(`${it.name}: nothing was typed, because its input line is not empty: it reads "${line ?? 'nothing'}".${shownEnd(rows)}`);
188
+ }
189
+ }
190
+ const version = it.harness === 'codex' ? codexVersion(readBook(it.home).sessions[it.session]?.session) : undefined;
191
+
192
+ before();
193
+ const typedAt = Date.now();
194
+ // One character a send, each after a look through the gate, so nothing goes
195
+ // in as one burst and nothing goes in once something asks a question
196
+ // (the architect's ruling on #391, after live run 2).
197
+ // Between characters, and once more before the return, the look is the gate
198
+ // and the harness's own at-work row, not Orca's tui-idle: an open slash menu
199
+ // can stop it answering ok (live run 3).
200
+ let typed = 0;
201
+ const lookAgain = async () => {
202
+ await pause(CHAR_GAP_MS);
203
+ const look = lookAt(it, { idle: false });
204
+ if (look.why === undefined) return;
205
+ send(handle, '\x7f'.repeat(typed));
206
+ throw new Error(`${it.name}: ${command} was being typed, and after ${command.slice(0, typed)} ${look.why}, so what was typed was taken back and nothing was entered.`);
207
+ };
208
+ for (const char of command) {
209
+ if (typed > 0) await lookAgain();
210
+ send(handle, char);
211
+ typed += 1;
212
+ }
213
+ await lookAgain();
214
+ const wrong = await typedWrong(handle, it.harness, command, version);
215
+ if (wrong !== undefined) {
216
+ // Taken back, one backspace a character, so the input line is as it was.
217
+ send(handle, '\x7f'.repeat(command.length));
218
+ const cannot = verb === 'compact' && wrong.menu ? ` ${harnessName(it.harness)} here cannot compact: its menu does not offer ${command}.` : '';
219
+ throw new Error(`${it.name}: ${command} was typed but not entered, and was taken back, because ${wrong.why}.${cannot}${shownEnd(wrong.rows)}`);
220
+ }
221
+ // A return of its own, not `--enter`: Orca's gate can refuse a line sent
222
+ // with `--enter` while it names a reason, and on Codex a return inside the
223
+ // text lands in the draft (helpers in the system tests, #329).
224
+ send(handle, '\r');
225
+ return { command, handle, typedAt };
226
+ }
227
+
228
+ /**
229
+ * The handle of the session's tab once the session is idle, with nothing on
230
+ * its screen to answer, within IDLE_WAIT_MS. Busy is Orca's `tui-idle` not
231
+ * answering ok, or a screen that says it is at work: Orca can call a busy
232
+ * Codex idle. Throws when the tab holds no harness, and when the wait ran out.
233
+ */
234
+ async function idleTab(it) {
235
+ const until = Date.now() + IDLE_WAIT_MS;
236
+ for (;;) {
237
+ const look = lookAt(it);
238
+ let why = look.why;
239
+ // The wait is a cutoff, not a count of looks: a yes that comes after it
240
+ // is too late (as for LIST_LINE, review of PR #421).
241
+ if (why === undefined && Date.now() >= until) why = 'the look that found it idle came only after the wait was up';
242
+ if (why === undefined) return look;
243
+ if (Date.now() >= until) throw new Error(`${it.name}: nothing was typed, because after ${IDLE_WAIT_MS / 1000} s ${why}. Run this again once it is idle.`);
244
+ await pause(ASK_MS);
245
+ }
246
+ }
247
+
248
+ /**
249
+ * One look through the typing gate at the session's tab: `{ handle }` when
250
+ * nothing on its screen asks a question and nothing says it is at work, and,
251
+ * with `idle`, Orca's tui-idle answered ok; or `{ why }`, naming the signal.
252
+ * Throws when the tab holds no harness.
253
+ */
254
+ function lookAt(it, { idle = true } = {}) {
255
+ const found = tabToTypeInto(it.home, it.tabId, LOOK_MS);
256
+ if (found.blocked !== undefined) {
257
+ return {
258
+ why: found.blocked === QUESTION_ON_SCREEN
259
+ ? 'a question of its harness\'s own is on its screen'
260
+ : `a question is waiting on its screen: Orca says ${found.blocked}`,
261
+ };
262
+ }
263
+ if (found.unsure !== undefined) return { why: found.unsure };
264
+ if (found.handle === undefined) {
265
+ throw new Error(`${it.name} is not running: its tab is open with no harness in front of it, so its harness quit or crashed, and nothing was typed. ${it.restart} brings it back.`);
266
+ }
267
+ if (found.agent !== it.harness) return { why: `Orca names ${found.agent} in it, and the session runs on ${it.harness}` };
268
+ // The gate has read the screen already: it saw no question on it.
269
+ const signal = signalIn(found.rows);
270
+ if (signal !== undefined) return { why: signal };
271
+ if (idle && !found.idle) return { why: 'it is busy with a turn: Orca\'s tui-idle did not answer ok' };
272
+ return { handle: found.handle, rows: found.rows };
273
+ }
274
+
275
+ /**
276
+ * Why the screen does not show `command` typed and ready, or undefined when it
277
+ * does: the input line, the lowest row the harness's pointer starts, reads the
278
+ * pointer and the command and nothing else, and the slash menu's selected row
279
+ * names the command. `menu` is set when the input line was right and the menu
280
+ * was not. Read again for SCREEN_MS while it is not so: a screen
281
+ * takes a moment to draw what was typed.
282
+ */
283
+ async function typedWrong(handle, harness, command, version) {
284
+ const until = Date.now() + SCREEN_MS;
285
+ for (;;) {
286
+ const seen = screenRows(handle, READ_MS);
287
+ // The screen that lets the return in has to pass the look too: a turn or
288
+ // a question that shows on it stops it at once (review of c1e5ba4).
289
+ const signal = seen.rows === undefined ? undefined : signalIn(seen.rows);
290
+ if (signal !== undefined) return { why: signal, rows: seen.rows };
291
+ const wrong = seen.rows === undefined ? { why: `its screen could not be read (${seen.unreadable})` } : menuWrong(seen.rows, harness, command, version);
292
+ if (wrong === undefined || Date.now() >= until) return wrong && { ...wrong, rows: seen.rows };
293
+ await pause(ASK_MS);
294
+ }
295
+ }
296
+
297
+ /** Why `rows` say not to type now, as the look says it, or undefined: a turn at work, or a question. */
298
+ function signalIn(rows) {
299
+ const working = rows.find((row) => AT_WORK.some((marker) => marker.test(row)));
300
+ if (working !== undefined) return `it is busy with a turn: its screen shows "${working.trim()}"`;
301
+ if (questionIn(rows)) return 'a question of its harness\'s own is on its screen';
302
+ return undefined;
303
+ }
304
+
305
+ function menuWrong(rows, harness, command, version) {
306
+ const at = rows.findLastIndex((row) => /^ *[›❯]/.test(row));
307
+ if (at < 0) return { why: 'its screen shows no input line' };
308
+ // Claude Code 2.1.288 puts a non-breaking space after its pointer (live run 4).
309
+ const line = rows[at].replaceAll('\u00a0', ' ').trim();
310
+ if (harness === 'codex') return codexMenuWrong(rows, at, line, command, version);
311
+ if (line !== `${POINTER[harness]} ${command}`) return { why: `its input line reads "${line}"` };
312
+ return claudeMenuWrong(rows, at, command);
313
+ }
314
+
315
+ /**
316
+ * Claude Code 2.1.288 draws its slash menu above its input box's top rule, a
317
+ * row of ─: a row per command, starting with it, its description wrapped onto
318
+ * rows set far in (live run 4). No pointer marks a selection, so the first
319
+ * command row there has to be the command.
320
+ */
321
+ function claudeMenuWrong(rows, at, command) {
322
+ const rule = rows.slice(0, at).findLastIndex((row) => /^\s*─{3}/.test(row));
323
+ const menu = [];
324
+ for (let up = rule - 1; up >= 0 && (/^ {1,4}\/\S/.test(rows[up]) || /^ {20,}\S/.test(rows[up])); up -= 1) menu.unshift(rows[up]);
325
+ const first = menu.find((row) => /^ {1,4}\//.test(row));
326
+ if (rule < 0 || first === undefined) return { why: 'no menu of commands came up above its input box', menu: true };
327
+ if (first.trim().split(/\s+/)[0] !== command) return { why: `the first row of its menu is "${first.trim()}"`, menu: true };
328
+ return undefined;
329
+ }
330
+
331
+ /**
332
+ * Codex draws its slash menu above the input line: a row per command, the
333
+ * selected one starting with its pointer, then a blank row, then the input
334
+ * line (its own snapshot tests, chat_composer slash_popup_*.snap,
335
+ * rust-v0.160.0; live run 4). The menu has to hold one command row, the
336
+ * selected one, naming the command. The input line reads the command, or, on
337
+ * the Codex whose line Orca shows bare, its pointer alone.
338
+ */
339
+ function codexMenuWrong(rows, at, line, command, version) {
340
+ const bare = line === '›' && version === CODEX_BARE_LINE;
341
+ if (line !== `› ${command}` && !bare) {
342
+ const why = line === '›' ? `its input line reads "›" and this Codex is ${version ?? 'of a version its record does not give'}, not ${CODEX_BARE_LINE}` : `its input line reads "${line}"`;
343
+ return { why };
344
+ }
345
+ const menu = [];
346
+ for (let up = at - 1; up >= 0 && (rows[up].trim() === '' || /^ *(?:› +)?\/\S/.test(rows[up])); up -= 1) menu.unshift(rows[up]);
347
+ const commands = menu.filter((row) => row.trim() !== '');
348
+ if (commands.length === 0) return { why: 'no menu of commands came up above its input line', menu: true };
349
+ if (commands.length > 1) return { why: `its menu offers more than one command: ${commands.map((row) => `"${row.trim()}"`).join(', ')}`, menu: true };
350
+ if (!/^ *› +/.test(commands[0])) return { why: `its menu's one row "${commands[0].trim()}" is not selected`, menu: true };
351
+ const selected = commands[0].replace(/^ *› +/, '');
352
+ if (selected.split(/\s+/)[0] !== command) return { why: `the selected row of its menu is "${selected.trim()}"`, menu: true };
353
+ return undefined;
354
+ }
355
+
356
+ /** The Codex version that wrote the rollout of `conversation`, from its session_meta line; undefined when that cannot be read. */
357
+ function codexVersion(conversation) {
358
+ const file = typeof conversation === 'string' ? codexRollout(conversation) : undefined;
359
+ if (file === undefined) return undefined;
360
+ try {
361
+ const version = JSON.parse(firstLine(file) ?? 'null')?.payload?.cli_version;
362
+ return typeof version === 'string' ? version : undefined;
363
+ } catch {
364
+ return undefined;
365
+ }
366
+ }
367
+
368
+ /**
369
+ * Answer Codex's "Where should the new conversation run?" with "1. Current
370
+ * checkout", the bot home, when it comes up after `/new`. With its selection
371
+ * anywhere else it is backed out of with Esc: the kit never makes a worktree
372
+ * (PRD 6.2).
373
+ */
374
+ async function answerWhereToRun(it, handle, command) {
375
+ const until = Date.now() + SCREEN_MS;
376
+ for (;;) {
377
+ const rows = screenRows(handle, READ_MS).rows ?? [];
378
+ if (rows.some((row) => row.includes(WHERE_TO_RUN))) {
379
+ const pointer = rows.findLast((row) => /^ *›/.test(row));
380
+ if (pointer === undefined || !CURRENT_CHECKOUT.test(pointer)) {
381
+ send(handle, '\x1b');
382
+ throw new Error(`${it.name}: after ${command}, Codex asked "${WHERE_TO_RUN}" with its selection on "${pointer?.trim() ?? 'nothing'}" rather than "1. Current checkout", so it was backed out of with Esc and nothing else was typed. Look at its tab.`);
383
+ }
384
+ send(handle, '\r');
385
+ return;
386
+ }
387
+ if (Date.now() >= until) return;
388
+ await pause(ASK_MS);
389
+ }
390
+ }
391
+
392
+ /** Where the harness keeps the record of `conversation`, or undefined when it has none yet. */
393
+ function recordFile(harness, home, conversation) {
394
+ if (conversation === null) return undefined;
395
+ return harness === 'claude' ? claudeTranscript(home, conversation) : codexRollout(conversation);
396
+ }
397
+
398
+ /** The record of `conversation` as it is now: `{ id, file, from }`, `from` the byte it ends at. */
399
+ function recordAt(harness, home, conversation) {
400
+ const file = recordFile(harness, home, conversation);
401
+ let from = 0;
402
+ try {
403
+ from = file === undefined ? 0 : statSync(file).size;
404
+ } catch {
405
+ // Not written yet: everything in it will be new.
406
+ }
407
+ return { id: conversation, file, from };
408
+ }
409
+
410
+ /**
411
+ * Whether the record holds, from its byte `from` on, a compaction made at
412
+ * `since` or later. Read a line at a time: a record can be larger than one
413
+ * string holds (#396).
414
+ */
415
+ function compactedSince(harness, record, since) {
416
+ let found = false;
417
+ try {
418
+ eachLine(record.file, (line) => {
419
+ // Only a line that names a compaction is worth parsing.
420
+ if (line === undefined || !line.includes('compact')) return false;
421
+ let entry;
422
+ try {
423
+ entry = JSON.parse(line);
424
+ } catch {
425
+ return false;
426
+ }
427
+ // Claude Code's marker, and Codex's (tech notes, sections 2 and 3).
428
+ const compaction = harness === 'claude'
429
+ ? entry?.type === 'system' && entry.subtype === 'compact_boundary'
430
+ : entry?.type === 'compacted';
431
+ found = compaction && Date.parse(entry.timestamp) >= since;
432
+ return found;
433
+ }, { from: record.from });
434
+ } catch {
435
+ return false;
436
+ }
437
+ return found;
438
+ }
439
+
440
+ /**
441
+ * The last rows of the screen with words in them, as a sentence for a refusal,
442
+ * so whoever reads it sees what the harness drew there.
443
+ */
444
+ function shownEnd(rows) {
445
+ const shown = (rows ?? []).filter((row) => row.trim() !== '').slice(-SHOWN_ROWS);
446
+ return shown.length === 0 ? '' : ` Its screen ended: ${shown.map((row) => row.trimEnd()).join(' ⏎ ')}`;
447
+ }
448
+
449
+ /** How many rows of the screen a refusal shows. */
450
+ const SHOWN_ROWS = 12;
451
+
452
+ /** Type `keys` into the tab as they are, with no return of Orca's. */
453
+ const send = (handle, keys) => orca(['terminal', 'send', '--terminal', handle, '--text', keys]);
454
+
455
+ const harnessName = (harness) => (harness === 'claude' ? 'Claude Code' : 'Codex');