@assuredloop/orca-bot-kit 0.22.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 +18 -2
- package/package.json +1 -1
- package/skills/obk-bot-building/SKILL.md +3 -2
- package/src/clear.js +455 -0
- package/src/cli.js +37 -0
- package/src/health.js +4 -7
- package/src/lines.js +7 -4
- package/src/orca.js +6 -4
- package/src/record.js +5 -5
- package/src/up.js +7 -6
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
|
|
|
@@ -364,6 +364,22 @@ conversation it was having, rather than starting a new one. Clear a session —
|
|
|
364
364
|
keeps the old one, and gives the session its start prompt again, because that
|
|
365
365
|
prompt is what tells one session's duty from another's.
|
|
366
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
|
+
|
|
367
383
|
A session paused before its first turn can have an id in the book and no
|
|
368
384
|
conversation behind it: the harness has nothing on record for that id. There is
|
|
369
385
|
nothing to resume, so it starts again with its duty, and the book moves the id
|
|
@@ -649,7 +665,7 @@ editor's runner, skips and says how to run it.
|
|
|
649
665
|
JavaScript this branch changed against `main` — committed, still in the working
|
|
650
666
|
tree, or not tracked yet — and reports the mutants the tests do not kill. It is
|
|
651
667
|
an audit, not a step of every pull request
|
|
652
|
-
([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
|
|
653
669
|
check is the implementer's own hand check, and the tool runs over the whole
|
|
654
670
|
suite at a milestone or when the owner asks, or once, narrowed to the changed
|
|
655
671
|
logic, for a change at the core of the kit. A surviving mutant is a change to
|
package/package.json
CHANGED
|
@@ -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:
|
|
372
|
-
|
|
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/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');
|
package/src/cli.js
CHANGED
|
@@ -23,6 +23,7 @@ import { allowCommand, allowIn, beyondDefaults, refuseBroad, refuseNoCodexForm,
|
|
|
23
23
|
import { pauseSessions, unpauseSessions } from './pause.js';
|
|
24
24
|
import { recordSession, SHELL_ENV, TAB_ENV } from './record.js';
|
|
25
25
|
import { restartSessions } from './restart.js';
|
|
26
|
+
import { clearSession, compactSession } from './clear.js';
|
|
26
27
|
import { retireBot, retireSession } from './retire.js';
|
|
27
28
|
import { sentWarning } from './sent.js';
|
|
28
29
|
import { readRoster } from './roster.js';
|
|
@@ -204,6 +205,18 @@ Usage:
|
|
|
204
205
|
one command that closes a tab, it closes only the
|
|
205
206
|
tabs your book names, and it will not close one
|
|
206
207
|
whose conversation the book cannot name.
|
|
208
|
+
obk session clear --bots <path> --bot <bot> --session <name>
|
|
209
|
+
Start the session on a new conversation: /clear on
|
|
210
|
+
Claude Code, /new on Codex. It waits up to 30 s for
|
|
211
|
+
the session to be idle, types nothing into a busy
|
|
212
|
+
one or one with a question on its screen, and
|
|
213
|
+
answers once the book holds the new conversation.
|
|
214
|
+
The kit's hook gives it its start prompt again.
|
|
215
|
+
obk session compact --bots <path> --bot <bot> --session <name>
|
|
216
|
+
Compact the session's conversation with /compact,
|
|
217
|
+
as safely as session clear, and answer once the
|
|
218
|
+
harness's record shows it, or after 5 minutes that
|
|
219
|
+
it is not confirmed yet.
|
|
207
220
|
obk health --bots <path> [--bot <bot>]
|
|
208
221
|
Say what is wrong with your setup: configuration
|
|
209
222
|
that will not work, a skill that is not where its
|
|
@@ -296,6 +309,8 @@ const COMMANDS = {
|
|
|
296
309
|
'source add': ['bots', 'name', 'repo', 'ref'],
|
|
297
310
|
'session add': ['bots', 'bot', 'name'],
|
|
298
311
|
'session change': ['bots', 'bot', 'session'],
|
|
312
|
+
'session clear': ['bots', 'bot', 'session'],
|
|
313
|
+
'session compact': ['bots', 'bot', 'session'],
|
|
299
314
|
'message to': ['bots', 'to'],
|
|
300
315
|
'message send': ['bots', 'to', 'subject'],
|
|
301
316
|
'message check': ['bots'],
|
|
@@ -1136,6 +1151,28 @@ const commands = {
|
|
|
1136
1151
|
],
|
|
1137
1152
|
};
|
|
1138
1153
|
},
|
|
1154
|
+
|
|
1155
|
+
async 'session clear'(bots, values) {
|
|
1156
|
+
refuseWhenOrcaIsDown();
|
|
1157
|
+
const cleared = await clearSession(bots, { bot: values.bot, session: values.session });
|
|
1158
|
+
const after = cleared.was === null ? '' : `, in place of ${cleared.was}`;
|
|
1159
|
+
return {
|
|
1160
|
+
answer: { bots, cleared },
|
|
1161
|
+
lines: [`cleared ${cleared.bot} ${cleared.session}: the book holds its new conversation ${cleared.now}${after}.`],
|
|
1162
|
+
};
|
|
1163
|
+
},
|
|
1164
|
+
|
|
1165
|
+
async 'session compact'(bots, values) {
|
|
1166
|
+
refuseWhenOrcaIsDown();
|
|
1167
|
+
const compacted = await compactSession(bots, { bot: values.bot, session: values.session });
|
|
1168
|
+
const what = `${compacted.bot} ${compacted.session}`;
|
|
1169
|
+
return {
|
|
1170
|
+
answer: { bots, compacted },
|
|
1171
|
+
lines: [compacted.confirmed
|
|
1172
|
+
? `compacted ${what}: the harness's record of its conversation ${compacted.conversation} shows the compaction.`
|
|
1173
|
+
: `asked ${what} to compact: /compact went in, and after 5 minutes the harness's record shows no compaction, so it is not confirmed yet. Look at its tab.`],
|
|
1174
|
+
};
|
|
1175
|
+
},
|
|
1139
1176
|
};
|
|
1140
1177
|
|
|
1141
1178
|
/**
|
package/src/health.js
CHANGED
|
@@ -322,13 +322,10 @@ function runningOn(bots, home, bot, book, handles, sessions) {
|
|
|
322
322
|
|
|
323
323
|
if (rules.state === 'older') {
|
|
324
324
|
const agents = path.join(home, 'AGENTS.md');
|
|
325
|
-
// A clear
|
|
326
|
-
// (tech notes,
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
? `A Claude Code session reads it again at a /clear in its tab, or when it starts: ${restart}`
|
|
330
|
-
: `A Codex session reads it when it starts: ${restart}`;
|
|
331
|
-
found.push(finding('session', agents, `${bot.name}'s session ${session.name} is running on older rules: ${agents} has changed since the kit noted which version this session read. ${how}`, bot.name));
|
|
325
|
+
// A clear reads the instructions again, as a start does, on both
|
|
326
|
+
// harnesses (tech notes, sections 2 and 3; #391).
|
|
327
|
+
const clear = `${shellWord(ownCli())} session clear --bots ${shellWord(bots)} --bot ${bot.name} --session ${session.name}`;
|
|
328
|
+
found.push(finding('session', agents, `${bot.name}'s session ${session.name} is running on older rules: ${agents} has changed since the kit noted which version this session read. A session reads it again when it is cleared: ${clear}, or when it starts: ${restart}`, bot.name));
|
|
332
329
|
}
|
|
333
330
|
}
|
|
334
331
|
return found;
|
package/src/lines.js
CHANGED
|
@@ -25,18 +25,21 @@ const LONGEST = 64 * 1024 * 1024;
|
|
|
25
25
|
/**
|
|
26
26
|
* Each line of `file`, in order, to `onLine`: its text, or `undefined` for a
|
|
27
27
|
* line too long to hold. A line `onLine` answers `true` to is the last one
|
|
28
|
-
* read.
|
|
29
|
-
*
|
|
28
|
+
* read. With `from`, reading starts at that byte, for a caller that wants only
|
|
29
|
+
* what was written after it (#391). Throws what the file system throws when
|
|
30
|
+
* the file cannot be opened or read.
|
|
30
31
|
*/
|
|
31
|
-
export function eachLine(file, onLine) {
|
|
32
|
+
export function eachLine(file, onLine, { from = 0 } = {}) {
|
|
32
33
|
const fd = openSync(file, 'r');
|
|
33
34
|
try {
|
|
34
35
|
const decoder = new StringDecoder('utf8');
|
|
35
36
|
const buffer = Buffer.allocUnsafe(CHUNK);
|
|
36
37
|
let pending = '';
|
|
37
38
|
let tooLong = false;
|
|
39
|
+
let position = from;
|
|
38
40
|
for (;;) {
|
|
39
|
-
const read = readSync(fd, buffer, 0, CHUNK,
|
|
41
|
+
const read = readSync(fd, buffer, 0, CHUNK, position);
|
|
42
|
+
position += read;
|
|
40
43
|
const text = read === 0 ? decoder.end() : decoder.write(buffer.subarray(0, read));
|
|
41
44
|
let start = 0;
|
|
42
45
|
for (let at = text.indexOf('\n'); at !== -1; at = text.indexOf('\n', start)) {
|
package/src/orca.js
CHANGED
|
@@ -377,14 +377,14 @@ export const QUESTION_ON_SCREEN = 'question-on-screen';
|
|
|
377
377
|
|
|
378
378
|
/**
|
|
379
379
|
* What the tab `handle` renders: `{ question }`, true when it shows a question
|
|
380
|
-
* of its harness's own, or `{ screenUnreadable: <why> }`. Orca refusing is an
|
|
380
|
+
* of its harness's own, with the `rows` it read, or `{ screenUnreadable: <why> }`. Orca refusing is an
|
|
381
381
|
* answer that cannot be read, not an error, and so is anything that is not the
|
|
382
382
|
* rendered screen: accumulated output comes back in fragments (tech notes,
|
|
383
383
|
* section 1).
|
|
384
384
|
*/
|
|
385
385
|
function screenOf(handle, readMs) {
|
|
386
386
|
const read = screenRows(handle, readMs);
|
|
387
|
-
return read.rows === undefined ? { screenUnreadable: read.unreadable } : { question: questionIn(read.rows) };
|
|
387
|
+
return read.rows === undefined ? { screenUnreadable: read.unreadable } : { question: questionIn(read.rows), rows: read.rows };
|
|
388
388
|
}
|
|
389
389
|
|
|
390
390
|
/**
|
|
@@ -445,7 +445,9 @@ export function questionIn(rows) {
|
|
|
445
445
|
* Whether the kit may type a line into `tabId`, the tab the book holds for a
|
|
446
446
|
* session in the Orca project at `home`, and the handle to type into when it
|
|
447
447
|
* may: `{ handle, agent }`, with the agent Orca names there. Otherwise nothing
|
|
448
|
-
* is typed, and the answer says why
|
|
448
|
+
* is typed, and the answer says why. `idle` says whether Orca's `tui-idle` wait
|
|
449
|
+
* answered ok, and `rows` is the screen the gate read, for a caller that must
|
|
450
|
+
* not type into a busy harness (#391):
|
|
449
451
|
* `{}` for a tab with no harness in it (none in the book, none Orca lists, or
|
|
450
452
|
* the shell in front), `{ blocked }` for one with something on screen waiting
|
|
451
453
|
* to be answered, and `{ unsure }`, a sentence, for one the kit cannot tell
|
|
@@ -508,7 +510,7 @@ export function tabToTypeInto(home, tabId, timeoutMs) {
|
|
|
508
510
|
if (seen.question === undefined) {
|
|
509
511
|
return { unsure: `the kit could not tell whether a question is waiting on its screen (${seen.screenUnreadable}), so nothing was typed` };
|
|
510
512
|
}
|
|
511
|
-
return { handle: live.handle, agent: seen.agent };
|
|
513
|
+
return { handle: live.handle, agent: seen.agent, idle: seen.answered, rows: seen.rows };
|
|
512
514
|
}
|
|
513
515
|
|
|
514
516
|
/**
|
package/src/record.js
CHANGED
|
@@ -80,11 +80,10 @@ export async function recordSession(bots, name, said, tabId, shellPid) {
|
|
|
80
80
|
// this bot's folder, which every session of the bot shares — so it can say
|
|
81
81
|
// that a conversation nobody claims exists, and never whose it is.
|
|
82
82
|
const unclaimed = unclaimedFor(readBook(home), home, bot, tabId, id);
|
|
83
|
-
// A clear
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
|
|
87
|
-
const rules = said.source === CLEARED ? rulesStamp(home) : undefined;
|
|
83
|
+
// A clear reads the bot's instructions again, as a start does, so the book
|
|
84
|
+
// notes which ones it read: Claude Code's `/clear` (tech notes, section 2),
|
|
85
|
+
// and Codex's `/new`, seen here as a new id (#391).
|
|
86
|
+
const stamp = rulesStamp(home);
|
|
88
87
|
|
|
89
88
|
let cleared = false;
|
|
90
89
|
let told;
|
|
@@ -106,6 +105,7 @@ export async function recordSession(bots, name, said, tabId, shellPid) {
|
|
|
106
105
|
const uncertain = was.session === undefined && unclaimed.length > 0;
|
|
107
106
|
told = session;
|
|
108
107
|
|
|
108
|
+
const rules = cleared ? stamp : undefined;
|
|
109
109
|
const now = rememberSession(was, id, said.source);
|
|
110
110
|
book.sessions[session] = withUnclaimed(rules === undefined ? now : { ...now, rules }, unclaimed);
|
|
111
111
|
forgetClaimed(book);
|
package/src/up.js
CHANGED
|
@@ -408,12 +408,13 @@ const LIST_LOOK_MS = 2000;
|
|
|
408
408
|
const LIST_ASK_MS = 500;
|
|
409
409
|
|
|
410
410
|
/**
|
|
411
|
-
* Type LIST_LINE into the tab `tabId` once the
|
|
412
|
-
* through lets it: `{ typed: true }`, or
|
|
413
|
-
* gate's last reason once LIST_WAIT_MS is up.
|
|
414
|
-
* form or a menu (#329, #416), nor into a tab
|
|
411
|
+
* Type `line`, LIST_LINE unless told otherwise, into the tab `tabId` once the
|
|
412
|
+
* gate every typed line goes through lets it: `{ typed: true }`, or
|
|
413
|
+
* `{ typed: false, why }` with the gate's last reason once LIST_WAIT_MS is up.
|
|
414
|
+
* Nothing goes into a question, a form or a menu (#329, #416), nor into a tab
|
|
415
|
+
* the kit cannot tell about.
|
|
415
416
|
*/
|
|
416
|
-
async function typeListLine(home, tabId) {
|
|
417
|
+
export async function typeListLine(home, tabId, line = LIST_LINE) {
|
|
417
418
|
const until = Date.now() + LIST_WAIT_MS;
|
|
418
419
|
for (;;) {
|
|
419
420
|
let found;
|
|
@@ -430,7 +431,7 @@ async function typeListLine(home, tabId) {
|
|
|
430
431
|
}
|
|
431
432
|
if (found.handle !== undefined) {
|
|
432
433
|
try {
|
|
433
|
-
typeIntoTab(found.handle,
|
|
434
|
+
typeIntoTab(found.handle, line);
|
|
434
435
|
return { typed: true };
|
|
435
436
|
} catch (error) {
|
|
436
437
|
return { typed: false, why: `Orca refused the line: ${error.message}` };
|