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