@assuredloop/orca-bot-kit 0.22.0 → 0.24.1
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 +18 -2
- package/package.json +1 -1
- package/skills/obk-bot-building/SKILL.md +3 -2
- package/src/book.js +94 -3
- package/src/clear.js +554 -0
- package/src/cli.js +61 -0
- package/src/groom.js +17 -10
- package/src/health.js +4 -7
- package/src/hooks.js +67 -31
- package/src/lines.js +7 -4
- package/src/message.js +15 -1
- package/src/name.js +138 -0
- package/src/orca.js +6 -4
- package/src/record.js +5 -5
- package/src/skills.js +10 -1
- package/src/temp.js +17 -10
- package/src/up.js +49 -26
package/src/clear.js
ADDED
|
@@ -0,0 +1,554 @@
|
|
|
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, takeTypingTurn, TYPING_HELD, TYPING_WAIT_MS } 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, TIMED_OUT } 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
|
+
/**
|
|
62
|
+
* How long each look between two characters waits on Orca's tui-idle, whose
|
|
63
|
+
* answer it does not use: an open slash menu keeps it from answering ok (live
|
|
64
|
+
* run 3 of #391). Short, so a naming holds the session's typing turn for
|
|
65
|
+
* seconds (#480).
|
|
66
|
+
*/
|
|
67
|
+
const CHAR_LOOK_MS = 250;
|
|
68
|
+
/** How long the screen is given to show the command typed, or Codex's question after `/new`. */
|
|
69
|
+
const SCREEN_MS = 3000;
|
|
70
|
+
/** How long the book is given to hold the new conversation, and a harness to record a compaction (the architect: up to 5 minutes). */
|
|
71
|
+
const CLEAR_MS = 30_000;
|
|
72
|
+
const COMPACT_MS = 300_000;
|
|
73
|
+
/** How long each screen read is given before it counts as a screen that cannot be read. */
|
|
74
|
+
const READ_MS = 5000;
|
|
75
|
+
/** The pause between looks, and between looks at a record that may be large. */
|
|
76
|
+
const ASK_MS = 500;
|
|
77
|
+
const RECORD_ASK_MS = 2000;
|
|
78
|
+
/**
|
|
79
|
+
* The gap between two characters of a command. Codex takes characters that come
|
|
80
|
+
* less than 8 ms apart as a paste, and flushes a paste after 60 ms without one
|
|
81
|
+
* (PASTE_BURST_CHAR_INTERVAL and PASTE_BURST_ACTIVE_IDLE_TIMEOUT in
|
|
82
|
+
* codex-rs/tui/src/bottom_pane/paste_burst.rs, rust-v0.160.0). A command it
|
|
83
|
+
* takes for a paste was seen to leave its input line bare (#391, live run 2),
|
|
84
|
+
* so the kit types well past both.
|
|
85
|
+
*/
|
|
86
|
+
const CHAR_GAP_MS = 100;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* A row that says the harness is at work on a turn, as seen live (#391): Codex
|
|
90
|
+
* 0.160.0's "• Working (8s • esc to interrupt)", and Claude Code 2.1.288's
|
|
91
|
+
* spinner row, "✳ Nucleating… (1m 8s · ↓ 131 tokens)": a glyph, a word that
|
|
92
|
+
* ends in "…", then the time. A finished turn's row, "✻ Churned for 13s · done
|
|
93
|
+
* 12:27 PM", has no "…".
|
|
94
|
+
*/
|
|
95
|
+
const AT_WORK = [/esc to interrupt/i, /^\s*\S\s+\S+…\s+\(\d/];
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Clear the session: `/clear` on Claude Code, `/new` on Codex. Returns
|
|
99
|
+
* `{ bot, session, harness, was, now }`, the conversation the book held before
|
|
100
|
+
* (or null) and the one it holds now.
|
|
101
|
+
*/
|
|
102
|
+
export async function clearSession(bots, { bot, session }) {
|
|
103
|
+
const it = sessionToType(bots, bot, session);
|
|
104
|
+
const was = readBook(it.home).sessions[session]?.session ?? null;
|
|
105
|
+
const { command, handle, typing } = await enter(it, 'clear');
|
|
106
|
+
|
|
107
|
+
// Codex's question and the line after it are part of the clear, typed in
|
|
108
|
+
// the typing turn it took for the command.
|
|
109
|
+
try {
|
|
110
|
+
if (it.harness === 'codex') {
|
|
111
|
+
await answerWhereToRun(it, handle, command);
|
|
112
|
+
const line = await typeListLine(it.home, it.session, it.tabId, { line: RECORD_LINE, held: true });
|
|
113
|
+
if (!line.typed) {
|
|
114
|
+
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.`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
} finally {
|
|
118
|
+
typing.release();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const until = Date.now() + CLEAR_MS;
|
|
122
|
+
for (;;) {
|
|
123
|
+
const now = readBook(it.home).sessions[session]?.session;
|
|
124
|
+
if (typeof now === 'string' && now !== was) return { bot, session, harness: it.harness, was, now };
|
|
125
|
+
if (Date.now() >= until) {
|
|
126
|
+
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.`);
|
|
127
|
+
}
|
|
128
|
+
await pause(ASK_MS);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Compact the session's conversation with the harness's `/compact`. Returns
|
|
134
|
+
* `{ bot, session, harness, conversation, confirmed }`: confirmed once the
|
|
135
|
+
* harness's own record of the conversation shows a compaction made after the
|
|
136
|
+
* command began, and not yet when COMPACT_MS passed without one.
|
|
137
|
+
*/
|
|
138
|
+
export async function compactSession(bots, { bot, session }) {
|
|
139
|
+
const it = sessionToType(bots, bot, session);
|
|
140
|
+
// What the record holds when /compact is typed is not this compact's: one
|
|
141
|
+
// that ends while the kit waits for the session to be idle is the one
|
|
142
|
+
// before it (review of PR #466). So only what is written after counts.
|
|
143
|
+
let record;
|
|
144
|
+
const { typedAt, typing } = await enter(it, 'compact', () => {
|
|
145
|
+
record = recordAt(it.harness, it.home, readBook(it.home).sessions[session]?.session ?? null);
|
|
146
|
+
});
|
|
147
|
+
typing.release();
|
|
148
|
+
|
|
149
|
+
const until = typedAt + COMPACT_MS;
|
|
150
|
+
for (;;) {
|
|
151
|
+
const conversation = readBook(it.home).sessions[session]?.session ?? null;
|
|
152
|
+
// A conversation the book did not hold when it was typed is read whole.
|
|
153
|
+
if (conversation !== record.id) record = { id: conversation, from: 0 };
|
|
154
|
+
record.file ??= recordFile(it.harness, it.home, conversation);
|
|
155
|
+
if (record.file !== undefined && compactedSince(it.harness, record, typedAt)) {
|
|
156
|
+
return { bot, session, harness: it.harness, conversation, confirmed: true };
|
|
157
|
+
}
|
|
158
|
+
if (Date.now() >= until) return { bot, session, harness: it.harness, conversation, confirmed: false };
|
|
159
|
+
await pause(RECORD_ASK_MS);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The session as something to type into, once everything that refuses before
|
|
165
|
+
* Orca is asked to type has been asked: `{ name, home, session, harness, tabId }`.
|
|
166
|
+
*/
|
|
167
|
+
export function sessionToType(bots, bot, session) {
|
|
168
|
+
botsNamed(bots, bot);
|
|
169
|
+
const home = realpathSync(botDir(bots, bot));
|
|
170
|
+
const known = readBot(home, bot);
|
|
171
|
+
const [settings] = sessionsOf(known, session);
|
|
172
|
+
const name = `${bot} ${session}`;
|
|
173
|
+
const unpause = `${shellWord(ownCli())} unpause --bots ${shellWord(bots)} --bot ${bot}`;
|
|
174
|
+
if (known.paused === true) throw new Error(`${bot} is paused, so ${name} is not running and nothing was typed. Bring it back with ${unpause}.`);
|
|
175
|
+
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}.`);
|
|
176
|
+
|
|
177
|
+
const tabId = readBook(home).sessions[session]?.tab;
|
|
178
|
+
// Orca refuses to list the tabs of a folder it has no project for (tech
|
|
179
|
+
// notes, section 1), so a bot never brought up is asked nothing.
|
|
180
|
+
const open = typeof tabId === 'string' && findProject(home) !== undefined && tabs(home).some((tab) => tab.tabId === tabId);
|
|
181
|
+
if (!open) {
|
|
182
|
+
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}.`);
|
|
183
|
+
}
|
|
184
|
+
return { name, home, session, harness: harnessOf(settings, known.harness), tabId, restart: `${shellWord(ownCli())} restart --bots ${shellWord(bots)} --bot ${bot} --session ${session}` };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Type the harness's command for `verb` into the session's tab and press
|
|
189
|
+
* return, once it is idle and the screen shows the command typed and nothing
|
|
190
|
+
* else. `before` runs just before the command is typed. Returns the command,
|
|
191
|
+
* the handle it went into, when it was typed, and the session's typing turn
|
|
192
|
+
* (#482), taken once it is idle, as the naming takes it, and held for the
|
|
193
|
+
* caller to release.
|
|
194
|
+
*/
|
|
195
|
+
async function enter(it, verb, before = () => {}) {
|
|
196
|
+
let typing;
|
|
197
|
+
try {
|
|
198
|
+
const typed = await typeCommand(it, COMMANDS[verb][it.harness], {
|
|
199
|
+
before: () => {
|
|
200
|
+
typing = takeTypingTurn(it.home, it.session, TYPING_WAIT_MS);
|
|
201
|
+
if (typing === undefined) throw new Error(`${it.name}: ${TYPING_HELD}. Run this again in a moment.`);
|
|
202
|
+
before();
|
|
203
|
+
},
|
|
204
|
+
cannot: (wrong) => (verb === 'compact' && wrong.menu ? ` ${harnessName(it.harness)} here cannot compact: its menu does not offer ${COMMANDS[verb][it.harness]}.` : ''),
|
|
205
|
+
});
|
|
206
|
+
return { ...typed, typing };
|
|
207
|
+
} catch (error) {
|
|
208
|
+
typing?.release();
|
|
209
|
+
throw error;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Type `command` into the session's tab, `it` as `sessionToType` gives it, and
|
|
215
|
+
* press return, once it is idle and `check` finds nothing wrong with the
|
|
216
|
+
* screen: by default the slash menu's check, which a command with words after
|
|
217
|
+
* it does not pass, since Codex closes its menu at the space (#480). `before`
|
|
218
|
+
* runs just before the command is typed; `cannot` adds to a refusal what a
|
|
219
|
+
* wrong screen means; past `deadline`, a time, what was typed is taken back
|
|
220
|
+
* and nothing is entered. Returns the command, the handle it went into, and
|
|
221
|
+
* when it was typed.
|
|
222
|
+
*/
|
|
223
|
+
export async function typeCommand(it, command, { before = () => {}, check = menuWrong, cannot = () => '', deadline = Infinity } = {}) {
|
|
224
|
+
// On Codex, what is typed cannot be read back once its menu is open, so
|
|
225
|
+
// nothing may be in its input line before: no draft for the command to join.
|
|
226
|
+
const emptyLine = (rows) => {
|
|
227
|
+
if (it.harness !== 'codex') return;
|
|
228
|
+
const line = rows.findLast((row) => /^ *›/.test(row))?.trim();
|
|
229
|
+
if (!CODEX_EMPTY.includes(line)) {
|
|
230
|
+
throw new Error(`${it.name}: nothing was typed, because its input line is not empty: it reads "${line ?? 'nothing'}".${shownEnd(rows)}`);
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
emptyLine((await idleTab(it)).rows);
|
|
234
|
+
const version = it.harness === 'codex' ? codexVersion(readBook(it.home).sessions[it.session]?.session) : undefined;
|
|
235
|
+
|
|
236
|
+
before();
|
|
237
|
+
// What `before` waited for can have changed the session: one more look,
|
|
238
|
+
// with no wait, before the first key (#480 review).
|
|
239
|
+
const ready = lookAt(it);
|
|
240
|
+
if (ready.why !== undefined) throw new Error(`${it.name}: nothing was typed, because ${ready.why}.`);
|
|
241
|
+
emptyLine(ready.rows);
|
|
242
|
+
const { handle } = ready;
|
|
243
|
+
const typedAt = Date.now();
|
|
244
|
+
// One character a send, each after a look through the gate, so nothing goes
|
|
245
|
+
// in as one burst and nothing goes in once something asks a question
|
|
246
|
+
// (the architect's ruling on #391, after live run 2).
|
|
247
|
+
// Between characters, and once more before the return, the look is the gate
|
|
248
|
+
// and the harness's own at-work row, not Orca's tui-idle: an open slash menu
|
|
249
|
+
// can stop it answering ok (live run 3).
|
|
250
|
+
// The characters Orca took, and so what is in the input line of ours.
|
|
251
|
+
let typed = 0;
|
|
252
|
+
// Taken back, one backspace a character, in one send, so the input line is
|
|
253
|
+
// as it was. Whatever stops the typing takes them back, a send Orca refused
|
|
254
|
+
// or a look that threw included (#480 review); a take-back Orca refuses
|
|
255
|
+
// too leaves nothing more the kit can do.
|
|
256
|
+
const takeBack = () => {
|
|
257
|
+
const keys = '\x7f'.repeat(typed);
|
|
258
|
+
typed = 0;
|
|
259
|
+
if (keys === '') return;
|
|
260
|
+
try {
|
|
261
|
+
send(handle, keys);
|
|
262
|
+
} catch {
|
|
263
|
+
// Said by the error that stopped the typing.
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
const lookAgain = async () => {
|
|
267
|
+
await pause(CHAR_GAP_MS);
|
|
268
|
+
const look = lookAt(it, { idle: false });
|
|
269
|
+
// Past the caller's deadline nothing more goes in: a hook stopped partway
|
|
270
|
+
// would leave the text in the input line (#480 review).
|
|
271
|
+
if (look.why === undefined && Date.now() > deadline) look.why = 'its time ran out';
|
|
272
|
+
if (look.why === undefined) return;
|
|
273
|
+
const sofar = command.slice(0, typed);
|
|
274
|
+
takeBack();
|
|
275
|
+
throw new Error(`${it.name}: ${command} was being typed, and after ${sofar} ${look.why}, so what was typed was taken back and nothing was entered.`);
|
|
276
|
+
};
|
|
277
|
+
try {
|
|
278
|
+
for (const char of command) {
|
|
279
|
+
if (typed > 0) await lookAgain();
|
|
280
|
+
try {
|
|
281
|
+
send(handle, char);
|
|
282
|
+
} catch (error) {
|
|
283
|
+
// A send Orca did not answer in time may have gone in all the same.
|
|
284
|
+
// Counted only where the line was empty before the first key, so the
|
|
285
|
+
// one backspace too many can take nothing of the user's (#480 review).
|
|
286
|
+
if (error.code === TIMED_OUT && it.harness === 'codex') typed += 1;
|
|
287
|
+
throw error;
|
|
288
|
+
}
|
|
289
|
+
typed += 1;
|
|
290
|
+
}
|
|
291
|
+
await lookAgain();
|
|
292
|
+
let wrong = await typedWrong(handle, it.harness, command, version, check);
|
|
293
|
+
if (wrong === undefined && Date.now() > deadline) wrong = { why: 'its time ran out' };
|
|
294
|
+
if (wrong !== undefined) {
|
|
295
|
+
takeBack();
|
|
296
|
+
throw new Error(`${it.name}: ${command} was typed but not entered, and was taken back, because ${wrong.why}.${cannot(wrong)}${shownEnd(wrong.rows)}`);
|
|
297
|
+
}
|
|
298
|
+
// A return of its own, not `--enter`: Orca's gate can refuse a line sent
|
|
299
|
+
// with `--enter` while it names a reason, and on Codex a return inside the
|
|
300
|
+
// text lands in the draft (helpers in the system tests, #329).
|
|
301
|
+
send(handle, '\r');
|
|
302
|
+
typed = 0;
|
|
303
|
+
} catch (error) {
|
|
304
|
+
takeBack();
|
|
305
|
+
throw error;
|
|
306
|
+
}
|
|
307
|
+
return { command, handle, typedAt };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* The handle of the session's tab once the session is idle, with nothing on
|
|
312
|
+
* its screen to answer, within IDLE_WAIT_MS. Busy is Orca's `tui-idle` not
|
|
313
|
+
* answering ok, or a screen that says it is at work: Orca can call a busy
|
|
314
|
+
* Codex idle. Throws when the tab holds no harness, and when the wait ran out.
|
|
315
|
+
*/
|
|
316
|
+
async function idleTab(it) {
|
|
317
|
+
const until = Date.now() + IDLE_WAIT_MS;
|
|
318
|
+
for (;;) {
|
|
319
|
+
const look = lookAt(it);
|
|
320
|
+
if (look.moved) throw new Error(`${it.name}: nothing was typed, because ${look.why}.`);
|
|
321
|
+
let why = look.why;
|
|
322
|
+
// The wait is a cutoff, not a count of looks: a yes that comes after it
|
|
323
|
+
// is too late (as for LIST_LINE, review of PR #421).
|
|
324
|
+
if (why === undefined && Date.now() >= until) why = 'the look that found it idle came only after the wait was up';
|
|
325
|
+
if (why === undefined) return look;
|
|
326
|
+
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.`);
|
|
327
|
+
await pause(ASK_MS);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* One look through the typing gate at the session's tab: `{ handle }` when
|
|
333
|
+
* nothing on its screen asks a question and nothing says it is at work, and,
|
|
334
|
+
* with `idle`, Orca's tui-idle answered ok; or `{ why }`, naming the signal,
|
|
335
|
+
* with `moved` when the book holds another tab for the session now, or, where
|
|
336
|
+
* `it.conversation` is given, another conversation.
|
|
337
|
+
* Throws when the tab holds no harness.
|
|
338
|
+
*/
|
|
339
|
+
function lookAt(it, { idle = true } = {}) {
|
|
340
|
+
// A restart can give the session a new tab while this waits or types, and
|
|
341
|
+
// a hook can outlive its Codex (#480): only the tab the book holds is the
|
|
342
|
+
// session's.
|
|
343
|
+
const entry = readBook(it.home).sessions[it.session];
|
|
344
|
+
if (entry?.tab !== it.tabId) return { moved: true, why: `the book no longer holds its tab ${it.tabId} for it, but ${entry?.tab ?? 'none'}` };
|
|
345
|
+
// And, for a caller typing for one conversation, a `/new` in the same tab
|
|
346
|
+
// gives the session another (#480 review).
|
|
347
|
+
if (it.conversation !== undefined && entry.session !== it.conversation) {
|
|
348
|
+
return { moved: true, why: `the book no longer holds the conversation ${it.conversation} for it, but ${entry.session ?? 'none'}` };
|
|
349
|
+
}
|
|
350
|
+
const found = tabToTypeInto(it.home, it.tabId, idle ? LOOK_MS : CHAR_LOOK_MS);
|
|
351
|
+
if (found.blocked !== undefined) {
|
|
352
|
+
return {
|
|
353
|
+
why: found.blocked === QUESTION_ON_SCREEN
|
|
354
|
+
? 'a question of its harness\'s own is on its screen'
|
|
355
|
+
: `a question is waiting on its screen: Orca says ${found.blocked}`,
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
if (found.unsure !== undefined) return { why: found.unsure };
|
|
359
|
+
if (found.handle === undefined) {
|
|
360
|
+
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.`);
|
|
361
|
+
}
|
|
362
|
+
if (found.agent !== it.harness) return { why: `Orca names ${found.agent} in it, and the session runs on ${it.harness}` };
|
|
363
|
+
// The gate has read the screen already: it saw no question on it.
|
|
364
|
+
const signal = signalIn(found.rows);
|
|
365
|
+
if (signal !== undefined) return { why: signal };
|
|
366
|
+
if (idle && !found.idle) return { why: 'it is busy with a turn: Orca\'s tui-idle did not answer ok' };
|
|
367
|
+
return { handle: found.handle, rows: found.rows };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Why the screen does not show `command` typed and ready, or undefined when it
|
|
372
|
+
* does, as `check` reads it: for a slash command alone, the input line, the
|
|
373
|
+
* lowest row the harness's pointer starts, reads the pointer and the command
|
|
374
|
+
* and nothing else, and the slash menu's selected row names the command.
|
|
375
|
+
* `menu` is set when the input line was right and the menu was not. Read
|
|
376
|
+
* again for SCREEN_MS while it is not so: a screen
|
|
377
|
+
* takes a moment to draw what was typed.
|
|
378
|
+
*/
|
|
379
|
+
async function typedWrong(handle, harness, command, version, check) {
|
|
380
|
+
const until = Date.now() + SCREEN_MS;
|
|
381
|
+
for (;;) {
|
|
382
|
+
const seen = screenRows(handle, READ_MS);
|
|
383
|
+
// The screen that lets the return in has to pass the look too: a turn or
|
|
384
|
+
// a question that shows on it stops it at once (review of c1e5ba4).
|
|
385
|
+
const signal = seen.rows === undefined ? undefined : signalIn(seen.rows);
|
|
386
|
+
if (signal !== undefined) return { why: signal, rows: seen.rows };
|
|
387
|
+
const wrong = seen.rows === undefined ? { why: `its screen could not be read (${seen.unreadable})` } : check(seen.rows, harness, command, version);
|
|
388
|
+
if (wrong === undefined || Date.now() >= until) return wrong && { ...wrong, rows: seen.rows };
|
|
389
|
+
await pause(ASK_MS);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Why `rows` say not to type now, as the look says it, or undefined: a turn at work, or a question. */
|
|
394
|
+
function signalIn(rows) {
|
|
395
|
+
const working = rows.find((row) => AT_WORK.some((marker) => marker.test(row)));
|
|
396
|
+
if (working !== undefined) return `it is busy with a turn: its screen shows "${working.trim()}"`;
|
|
397
|
+
if (questionIn(rows)) return 'a question of its harness\'s own is on its screen';
|
|
398
|
+
return undefined;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function menuWrong(rows, harness, command, version) {
|
|
402
|
+
const at = rows.findLastIndex((row) => /^ *[›❯]/.test(row));
|
|
403
|
+
if (at < 0) return { why: 'its screen shows no input line' };
|
|
404
|
+
// Claude Code 2.1.288 puts a non-breaking space after its pointer (live run 4).
|
|
405
|
+
const line = rows[at].replaceAll('\u00a0', ' ').trim();
|
|
406
|
+
if (harness === 'codex') return codexMenuWrong(rows, at, line, command, version);
|
|
407
|
+
if (line !== `${POINTER[harness]} ${command}`) return { why: `its input line reads "${line}"` };
|
|
408
|
+
return claudeMenuWrong(rows, at, command);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Claude Code 2.1.288 draws its slash menu above its input box's top rule, a
|
|
413
|
+
* row of ─: a row per command, starting with it, its description wrapped onto
|
|
414
|
+
* rows set far in (live run 4). No pointer marks a selection, so the first
|
|
415
|
+
* command row there has to be the command.
|
|
416
|
+
*/
|
|
417
|
+
function claudeMenuWrong(rows, at, command) {
|
|
418
|
+
const rule = rows.slice(0, at).findLastIndex((row) => /^\s*─{3}/.test(row));
|
|
419
|
+
const menu = [];
|
|
420
|
+
for (let up = rule - 1; up >= 0 && (/^ {1,4}\/\S/.test(rows[up]) || /^ {20,}\S/.test(rows[up])); up -= 1) menu.unshift(rows[up]);
|
|
421
|
+
const first = menu.find((row) => /^ {1,4}\//.test(row));
|
|
422
|
+
if (rule < 0 || first === undefined) return { why: 'no menu of commands came up above its input box', menu: true };
|
|
423
|
+
if (first.trim().split(/\s+/)[0] !== command) return { why: `the first row of its menu is "${first.trim()}"`, menu: true };
|
|
424
|
+
return undefined;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Codex draws its slash menu above the input line: a row per command, the
|
|
429
|
+
* selected one starting with its pointer, then a blank row, then the input
|
|
430
|
+
* line (its own snapshot tests, chat_composer slash_popup_*.snap,
|
|
431
|
+
* rust-v0.160.0; live run 4). The menu has to hold one command row, the
|
|
432
|
+
* selected one, naming the command. The input line reads the command, or, on
|
|
433
|
+
* the Codex whose line Orca shows bare, its pointer alone.
|
|
434
|
+
*/
|
|
435
|
+
function codexMenuWrong(rows, at, line, command, version) {
|
|
436
|
+
const bare = line === '›' && version === CODEX_BARE_LINE;
|
|
437
|
+
if (line !== `› ${command}` && !bare) {
|
|
438
|
+
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}"`;
|
|
439
|
+
return { why };
|
|
440
|
+
}
|
|
441
|
+
const menu = [];
|
|
442
|
+
for (let up = at - 1; up >= 0 && (rows[up].trim() === '' || /^ *(?:› +)?\/\S/.test(rows[up])); up -= 1) menu.unshift(rows[up]);
|
|
443
|
+
const commands = menu.filter((row) => row.trim() !== '');
|
|
444
|
+
if (commands.length === 0) return { why: 'no menu of commands came up above its input line', menu: true };
|
|
445
|
+
if (commands.length > 1) return { why: `its menu offers more than one command: ${commands.map((row) => `"${row.trim()}"`).join(', ')}`, menu: true };
|
|
446
|
+
if (!/^ *› +/.test(commands[0])) return { why: `its menu's one row "${commands[0].trim()}" is not selected`, menu: true };
|
|
447
|
+
const selected = commands[0].replace(/^ *› +/, '');
|
|
448
|
+
if (selected.split(/\s+/)[0] !== command) return { why: `the selected row of its menu is "${selected.trim()}"`, menu: true };
|
|
449
|
+
return undefined;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** The Codex version that wrote the rollout of `conversation`, from its session_meta line; undefined when that cannot be read. */
|
|
453
|
+
function codexVersion(conversation) {
|
|
454
|
+
const file = typeof conversation === 'string' ? codexRollout(conversation) : undefined;
|
|
455
|
+
if (file === undefined) return undefined;
|
|
456
|
+
try {
|
|
457
|
+
const version = JSON.parse(firstLine(file) ?? 'null')?.payload?.cli_version;
|
|
458
|
+
return typeof version === 'string' ? version : undefined;
|
|
459
|
+
} catch {
|
|
460
|
+
return undefined;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Answer Codex's "Where should the new conversation run?" with "1. Current
|
|
466
|
+
* checkout", the bot home, when it comes up after `/new`. With its selection
|
|
467
|
+
* anywhere else it is backed out of with Esc: the kit never makes a worktree
|
|
468
|
+
* (PRD 6.2).
|
|
469
|
+
*/
|
|
470
|
+
async function answerWhereToRun(it, handle, command) {
|
|
471
|
+
const until = Date.now() + SCREEN_MS;
|
|
472
|
+
for (;;) {
|
|
473
|
+
const rows = screenRows(handle, READ_MS).rows ?? [];
|
|
474
|
+
if (rows.some((row) => row.includes(WHERE_TO_RUN))) {
|
|
475
|
+
const pointer = rows.findLast((row) => /^ *›/.test(row));
|
|
476
|
+
if (pointer === undefined || !CURRENT_CHECKOUT.test(pointer)) {
|
|
477
|
+
send(handle, '\x1b');
|
|
478
|
+
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.`);
|
|
479
|
+
}
|
|
480
|
+
send(handle, '\r');
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
if (Date.now() >= until) return;
|
|
484
|
+
await pause(ASK_MS);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** Where the harness keeps the record of `conversation`, or undefined when it has none yet. */
|
|
489
|
+
function recordFile(harness, home, conversation) {
|
|
490
|
+
if (conversation === null) return undefined;
|
|
491
|
+
return harness === 'claude' ? claudeTranscript(home, conversation) : codexRollout(conversation);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** The record of `conversation` as it is now: `{ id, file, from }`, `from` the byte it ends at. */
|
|
495
|
+
function recordAt(harness, home, conversation) {
|
|
496
|
+
const file = recordFile(harness, home, conversation);
|
|
497
|
+
let from = 0;
|
|
498
|
+
try {
|
|
499
|
+
from = file === undefined ? 0 : statSync(file).size;
|
|
500
|
+
} catch {
|
|
501
|
+
// Not written yet: everything in it will be new.
|
|
502
|
+
}
|
|
503
|
+
return { id: conversation, file, from };
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Whether the record holds, from its byte `from` on, a compaction made at
|
|
508
|
+
* `since` or later. Read a line at a time: a record can be larger than one
|
|
509
|
+
* string holds (#396).
|
|
510
|
+
*/
|
|
511
|
+
function compactedSince(harness, record, since) {
|
|
512
|
+
let found = false;
|
|
513
|
+
try {
|
|
514
|
+
eachLine(record.file, (line) => {
|
|
515
|
+
// Only a line that names a compaction is worth parsing.
|
|
516
|
+
if (line === undefined || !line.includes('compact')) return false;
|
|
517
|
+
let entry;
|
|
518
|
+
try {
|
|
519
|
+
entry = JSON.parse(line);
|
|
520
|
+
} catch {
|
|
521
|
+
return false;
|
|
522
|
+
}
|
|
523
|
+
// Claude Code's marker, and Codex's (tech notes, sections 2 and 3).
|
|
524
|
+
const compaction = harness === 'claude'
|
|
525
|
+
? entry?.type === 'system' && entry.subtype === 'compact_boundary'
|
|
526
|
+
: entry?.type === 'compacted';
|
|
527
|
+
found = compaction && Date.parse(entry.timestamp) >= since;
|
|
528
|
+
return found;
|
|
529
|
+
}, { from: record.from });
|
|
530
|
+
} catch {
|
|
531
|
+
return false;
|
|
532
|
+
}
|
|
533
|
+
return found;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* The last rows of the screen with words in them, as a sentence for a refusal,
|
|
538
|
+
* so whoever reads it sees what the harness drew there.
|
|
539
|
+
*/
|
|
540
|
+
function shownEnd(rows) {
|
|
541
|
+
const shown = (rows ?? []).filter((row) => row.trim() !== '').slice(-SHOWN_ROWS);
|
|
542
|
+
return shown.length === 0 ? '' : ` Its screen ended: ${shown.map((row) => row.trimEnd()).join(' ⏎ ')}`;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** How many rows of the screen a refusal shows. */
|
|
546
|
+
const SHOWN_ROWS = 12;
|
|
547
|
+
|
|
548
|
+
/** How long Orca is given to take one send: a send it never answers must not hold the caller (#480 review). */
|
|
549
|
+
const SEND_MS = 5000;
|
|
550
|
+
|
|
551
|
+
/** Type `keys` into the tab as they are, with no return of Orca's. */
|
|
552
|
+
const send = (handle, keys) => orca(['terminal', 'send', '--terminal', handle, '--text', keys], { timeoutMs: SEND_MS });
|
|
553
|
+
|
|
554
|
+
const harnessName = (harness) => (harness === 'claude' ? 'Claude Code' : 'Codex');
|