@assuredloop/orca-bot-kit 0.24.1 → 0.25.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/SETUP.md +32 -5
- package/package.json +1 -1
- package/skills/obk-bot-building/SKILL.md +12 -2
- package/src/cli.js +38 -3
- package/src/orca.js +154 -22
- package/src/temp.js +204 -30
- package/src/up.js +60 -2
package/SETUP.md
CHANGED
|
@@ -147,14 +147,19 @@ their own edits included.
|
|
|
147
147
|
## 5. Answer what the tabs ask
|
|
148
148
|
|
|
149
149
|
**How the tab got into this state**, because it decides what you do about it. A
|
|
150
|
-
tab the kit has just made has no harness in it yet
|
|
151
|
-
the
|
|
152
|
-
whether a harness came up.
|
|
153
|
-
|
|
154
|
-
different answers.
|
|
150
|
+
tab the kit has just made has no harness in it yet. The kit waits up to 15 s for
|
|
151
|
+
the tab's shell to come to a ready prompt, types the launch line, and only then
|
|
152
|
+
asks whether a harness came up. A shell that asks a question of its own as it
|
|
153
|
+
starts gets nothing typed. So different things can be on the screen, and they
|
|
154
|
+
need different answers.
|
|
155
155
|
|
|
156
156
|
`obk init` tells you which, for every tab it opened:
|
|
157
157
|
|
|
158
|
+
- *not launched: the shell is asking: …* — the shell asked a question as it
|
|
159
|
+
started, and the kit typed nothing. Answer the shell (the oh-my-zsh question
|
|
160
|
+
in the table below is the usual one), then run the `obk restart` command the
|
|
161
|
+
output gives for that session. It closes that tab and launches the session in
|
|
162
|
+
a new one. The command exits 1 while a session is not launched.
|
|
158
163
|
- *the harness was typed in and is running. The kit cannot see whether a screen
|
|
159
164
|
in it is waiting for an answer* — look at it: Orca does not flag every first-run
|
|
160
165
|
screen, so it may still be asking something of its own. That is the table below.
|
|
@@ -200,6 +205,7 @@ waiting for:
|
|
|
200
205
|
| Codex's update offer, `1. Update now` | `\r` | return: its selection starts on **Update now**, which accepts it | Codex 0.156.1; seen on 0.157.1 (offering 0.158.0) with the selection on **Update now**, not pressed; 0.158.0 offered 0.160.0, taken on 2026-10-02 by Bot Father's restarts |
|
|
201
206
|
| `[oh-my-zsh] Would you like to update?` | `n` | they update their own shell | oh-my-zsh bf77e35 |
|
|
202
207
|
| Claude Code's `Teach auto mode about your environment?` | `\x1b` | Esc, which cancels it (**Not now**). On 2.1.283 it is a form, not a list: `How you use Claude here`, `Also scan shell history`, `Also scan your other repos`, `Continue`, and `Enter to continue · Esc to cancel`. **Never send a return**: Enter is **Continue**, which starts a scan of the project, recent Claude sessions and, by default, the machine's shell history | Not yet proven: Esc cancels in Claude Code 2.1.283's code, not yet seen live (#370); the form was seen live twice on 2.1.283, in #261's live test and #239's live run 1 (#416); 2.1.278 showed a numbered list, answered with 2. Not now |
|
|
208
|
+
| Claude Code 2.1.289's `Teach auto mode about your environment?`, a numbered list: `1. Yes`, `2. Not now`, `3. Don't show again`, and `Enter to confirm · Esc to cancel`, above the input box | `\x1b[B\r` | down, then return, to **2. Not now**: its selection starts on **1. Yes**. Read the screen after the down arrow, and send the return only with the selection on **2. Not now**. Never **1. Yes**, which starts the teach scan, and never **3. Don't show again** | Not yet proven: the list was seen live on 2.1.289 with its selection on 1. Yes, in #489's live run 1, and was not answered there |
|
|
203
209
|
|
|
204
210
|
Each key counts on where the selection starts, as it did on the version named.
|
|
205
211
|
Read the screen in front of you before you send: if the selection is somewhere
|
|
@@ -221,6 +227,27 @@ Codex's `/new` question (seen on 0.156.1, 0.157.1, 0.158.0 and 0.160.0) is alway
|
|
|
221
227
|
home. The kit never makes a git worktree, so
|
|
222
228
|
`2. New worktree` is never the answer.
|
|
223
229
|
|
|
230
|
+
A bot's own temporary sessions are answered by their maker, through the kit, so
|
|
231
|
+
that one narrow permission rule allows it rather than a rule to type into any
|
|
232
|
+
tab. On Codex's hooks review, the maker runs
|
|
233
|
+
`obk temp trust-hooks --bots <their path> --name <session>`. On Claude Code's
|
|
234
|
+
`Teach auto mode` screen, it runs `obk temp answer --bots <their path> --name <session>`.
|
|
235
|
+
That command reads the screen and answers only when it holds one of the two
|
|
236
|
+
shapes in the table exactly as the kit knows it: Esc for the 2.1.283 form, and
|
|
237
|
+
**2. Not now** for the 2.1.289 list. On the list it sends the arrows, reads the
|
|
238
|
+
screen again, and sends the return only with the selection on **2. Not now**.
|
|
239
|
+
It refuses any other screen, types nothing then, and prints what it saw.
|
|
240
|
+
Afterwards it checks that the screen has gone. In
|
|
241
|
+
auto mode the maker's bot needs this rule, with the kit's full CLI path for
|
|
242
|
+
`<kit>`, as the command's own output prints it:
|
|
243
|
+
|
|
244
|
+
```text
|
|
245
|
+
Bash(<kit> temp answer:*)
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
Ask the user, and only after their yes run
|
|
249
|
+
`obk bot change --bots <their path> --bot <bot> --allow 'Bash(<kit> temp answer:*)'`.
|
|
250
|
+
|
|
224
251
|
**Anything you do not recognise: type nothing.** Tell them what is on the
|
|
225
252
|
screen and which tab it is in, and wait. A keypress into a menu you have not read
|
|
226
253
|
is how a harness quits back to the shell.
|
package/package.json
CHANGED
|
@@ -294,6 +294,7 @@ otherwise `/Applications/Orca.app/Contents/Resources/bin/orca`. A bare
|
|
|
294
294
|
| Codex's update offer, `1. Update now` | `\r` | **Update now**, where it starts | Codex 0.156.1 |
|
|
295
295
|
| `[oh-my-zsh] Would you like to update?` | `n` | no: the user updates it | oh-my-zsh bf77e35 |
|
|
296
296
|
| Claude Code's `Teach auto mode…` | `\x1b` | Esc: Not now; no return | Not yet proven, below |
|
|
297
|
+
| The same, 2.1.289's list | `\x1b[B\r` | down, return: **2. Not now** | Not yet proven, below |
|
|
297
298
|
|
|
298
299
|
Each key counts on where the selection starts on the version named. Read the
|
|
299
300
|
screen before you send: if the selection is elsewhere or the options have
|
|
@@ -309,7 +310,10 @@ Claude Code's `Teach auto mode` on 2.1.283 is a form, not a list, ending
|
|
|
309
310
|
Enter is Continue, which scans the project, recent sessions and the machine's
|
|
310
311
|
shell history. Esc cancels it (Not now) in its code; that is not yet proven
|
|
311
312
|
live, though the form was seen live twice on 2.1.283. On 2.1.278 it was a
|
|
312
|
-
numbered list, answered with `2. Not now
|
|
313
|
+
numbered list, answered with `2. Not now`, and on 2.1.289 it is one again:
|
|
314
|
+
`1. Yes`, `2. Not now`, `3. Don't show again`, above the input box, its
|
|
315
|
+
selection on 1. Yes (seen live, not answered, in #489). Go down to 2. Not now,
|
|
316
|
+
read the screen, and only then return. Codex's hooks
|
|
313
317
|
question matters most. The kit's hook is how the book learns which
|
|
314
318
|
conversation the session is running, and until it is answered the
|
|
315
319
|
conversation has not started. Codex's trust applies to the repository root,
|
|
@@ -323,7 +327,13 @@ exception: answer it with `obk temp trust-hooks --bots <bots> --name
|
|
|
323
327
|
<session>`, not with the keys. It reads the screen, chooses **Trust all and
|
|
324
328
|
continue** only if the review is there, and checks that the review went. A
|
|
325
329
|
permission rule can allow that one command, where it cannot allow keys sent
|
|
326
|
-
into any tab.
|
|
330
|
+
into any tab. The same goes for Claude Code's `Teach auto mode` screen on a
|
|
331
|
+
temporary session you made: answer it with `obk temp answer --bots <bots>
|
|
332
|
+
--name <session>`. It answers only a shape in the table exactly as the kit
|
|
333
|
+
knows it (Esc on the 2.1.283 form, 2. Not now on the 2.1.289 list, the return
|
|
334
|
+
only after a second look), refuses any other screen, and checks that it went. Its
|
|
335
|
+
rule is `Bash(<kit> temp answer:*)`, `<kit>` the kit's CLI path as the
|
|
336
|
+
command prints it; ask the user before `obk bot change --allow` adds it.
|
|
327
337
|
|
|
328
338
|
Where the kit says no session came up, the shell swallowed the launch line,
|
|
329
339
|
usually while it was asking its own question. Answer the shell, then close
|
package/src/cli.js
CHANGED
|
@@ -31,7 +31,7 @@ import { readRoster } from './roster.js';
|
|
|
31
31
|
import { buildAgents, buildRules, CODEX_CAP } from './rules.js';
|
|
32
32
|
import { addSkill, buildSkills, linkSkills, removeSkill } from './skills.js';
|
|
33
33
|
import { addSource, fetchSources } from './sources.js';
|
|
34
|
-
import { listRoles, makeTemp, optionsLine, retireTemp, trustHooks } from './temp.js';
|
|
34
|
+
import { answerTemp, listRoles, makeTemp, optionsLine, retireTemp, trustHooks } from './temp.js';
|
|
35
35
|
import { readUsage } from './usage.js';
|
|
36
36
|
import { BOT_FATHER, bringUp, ownMailbox } from './up.js';
|
|
37
37
|
|
|
@@ -140,6 +140,12 @@ Usage:
|
|
|
140
140
|
all and continue", and check the review went. It
|
|
141
141
|
refuses anything else on that screen, and types
|
|
142
142
|
nothing then.
|
|
143
|
+
obk temp answer --bots <path> --name <session>
|
|
144
|
+
Run in the maker's own tab: answer Claude Code's
|
|
145
|
+
"Teach auto mode" screen on a temporary session it
|
|
146
|
+
made with Not now, in a shape the kit knows, and
|
|
147
|
+
check the screen went. It refuses any other
|
|
148
|
+
screen, and types nothing then.
|
|
143
149
|
obk rules build --bots <path> [--bot <bot>]
|
|
144
150
|
Build every bot's AGENTS.md from its charter and
|
|
145
151
|
the rule units it carries, or just the one you
|
|
@@ -329,6 +335,7 @@ const COMMANDS = {
|
|
|
329
335
|
'temp roles': ['bots'],
|
|
330
336
|
'temp retire': ['bots', 'name'],
|
|
331
337
|
'temp trust-hooks': ['bots', 'name'],
|
|
338
|
+
'temp answer': ['bots', 'name'],
|
|
332
339
|
};
|
|
333
340
|
|
|
334
341
|
/** What each flag is for, in the sentence a caller reads when it is missing. */
|
|
@@ -465,7 +472,9 @@ async function run(argv) {
|
|
|
465
472
|
const { answer, lines, code = 0 } = await commands[command](bots, values);
|
|
466
473
|
|
|
467
474
|
process.stdout.write(values.json ? `${JSON.stringify(answer, null, 2)}\n` : `${lines.join('\n')}\n`);
|
|
468
|
-
|
|
475
|
+
// A session whose tab was opened and not launched is a command that stopped
|
|
476
|
+
// short, whichever command opened it (#498).
|
|
477
|
+
return answer?.tabs?.some((tab) => tab.launched === false) ? 1 : code;
|
|
469
478
|
}
|
|
470
479
|
|
|
471
480
|
/**
|
|
@@ -749,7 +758,9 @@ const commands = {
|
|
|
749
758
|
lines: [
|
|
750
759
|
...(made.role === undefined ? [] : [`role ${made.role.name}:${made.role.option}${made.role.for === undefined ? '' : `, for ${made.role.for}`}. Its options: ${optionsLine(made.role.options)}`]),
|
|
751
760
|
...Object.entries(made.chosen).map(([setting, one]) => `${setting.padEnd(9)} ${one.value === undefined ? `the ${one.from}` : `${one.value}, from ${one.from === 'flag' ? `--${setting}` : `the ${one.from}`}`}`),
|
|
752
|
-
...tabLines(answer,
|
|
761
|
+
...tabLines(answer, tabs.some((one) => one.launched === false)
|
|
762
|
+
? `${made.bot}/${made.session}, a temporary session of ${made.maker}'s, is in bot.yaml and the book, and was not launched. Restart it once its tab is ready, or retire it: ${retire}`
|
|
763
|
+
: `Made ${made.bot}/${made.session}, a temporary session of ${made.maker}'s, working in work/${made.session}. Retire it when its work is done: ${retire}`),
|
|
753
764
|
],
|
|
754
765
|
};
|
|
755
766
|
},
|
|
@@ -792,6 +803,19 @@ const commands = {
|
|
|
792
803
|
};
|
|
793
804
|
},
|
|
794
805
|
|
|
806
|
+
async 'temp answer'(bots, values) {
|
|
807
|
+
const tab = callerTab(bots, true);
|
|
808
|
+
refuseWhenOrcaIsDown();
|
|
809
|
+
const answered = await answerTemp(bots, { tab, name: values.name });
|
|
810
|
+
return {
|
|
811
|
+
answer: { bots, ...answered },
|
|
812
|
+
lines: [
|
|
813
|
+
`answered ${answered.bot} ${answered.session}, a temporary session of ${answered.maker}'s: sent ${answered.sent} to its "Teach auto mode about your environment?" screen, and the screen has gone`,
|
|
814
|
+
`rule a maker's bot runs this under one permission rule, which the user approves: Bash(${shellWord(ownCli())} temp answer:*)`,
|
|
815
|
+
],
|
|
816
|
+
};
|
|
817
|
+
},
|
|
818
|
+
|
|
795
819
|
'bot create'(bots, values) {
|
|
796
820
|
const made = createBot(bots, { name: values.name, harness: values.harness, charter: values.charter });
|
|
797
821
|
// The bot's AGENTS.md is the rules build's, here as everywhere else, so
|
|
@@ -1751,6 +1775,17 @@ function tabLines({ bots, created, completed, rules, skills, permissions = [], t
|
|
|
1751
1775
|
function harnessLines(tab, bots) {
|
|
1752
1776
|
if (!tab.created || tab.name === null) return [];
|
|
1753
1777
|
|
|
1778
|
+
// Nothing was typed into it, so there is nothing more to say about a harness
|
|
1779
|
+
// (#498).
|
|
1780
|
+
if (tab.launched === false) {
|
|
1781
|
+
return [
|
|
1782
|
+
` not launched: ${tab.notLaunched}. Nothing was typed into it.`,
|
|
1783
|
+
` Look at it: ${lookAt(tab.terminal)}`,
|
|
1784
|
+
' Answer it in the tab, then start the session again in a new tab:',
|
|
1785
|
+
` ${shellWord(ownCli())} restart --bots ${shellWord(bots)} --bot ${tab.bot} --session ${tab.name}`,
|
|
1786
|
+
];
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1754
1789
|
// What the line that was typed in asked for, and where the kit got it: the
|
|
1755
1790
|
// session the book holds, one the harness itself still had on record, or a new
|
|
1756
1791
|
// one — and if a new one, whether anything was known about an older one.
|
package/src/orca.js
CHANGED
|
@@ -231,7 +231,7 @@ export const tellWindow = (projectId) => askRuntime('project.update', { projectI
|
|
|
231
231
|
* Orca's published interface, so anything that goes wrong is a quiet
|
|
232
232
|
* undefined: it never throws and is never tried twice.
|
|
233
233
|
*/
|
|
234
|
-
function askRuntime(method, params) {
|
|
234
|
+
function askRuntime(method, params, killMs = CLIENT_KILL_MS) {
|
|
235
235
|
try {
|
|
236
236
|
// The CLI sits at <Orca.app>/Contents/Resources/bin/orca, often reached
|
|
237
237
|
// through a link.
|
|
@@ -244,8 +244,8 @@ function askRuntime(method, params) {
|
|
|
244
244
|
|
|
245
245
|
const asked = spawnSync(
|
|
246
246
|
path.join(contents, 'MacOS', 'Orca'),
|
|
247
|
-
[RUNTIME_SCRIPT, client, method, JSON.stringify(params), String(CLIENT_WAIT_MS)],
|
|
248
|
-
{ env, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: CLIENT_KILL_MS },
|
|
247
|
+
[RUNTIME_SCRIPT, client, method, JSON.stringify(params), String(Math.min(CLIENT_WAIT_MS, killMs))],
|
|
248
|
+
{ env, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: Math.min(CLIENT_KILL_MS, killMs) },
|
|
249
249
|
);
|
|
250
250
|
if (asked.error !== undefined || asked.status !== 0) return undefined;
|
|
251
251
|
return parse(asked.stdout) ?? undefined;
|
|
@@ -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;
|
|
@@ -554,17 +586,21 @@ const psCli = () => process.env.OBK_PS || '/bin/ps';
|
|
|
554
586
|
* Who holds the terminal of the tab `handle`, whose pane is `ptyId`:
|
|
555
587
|
* `{ front: 'shell' }`, `{ front: 'program', command, pid }` with the name and,
|
|
556
588
|
* where `ps` gave it, the pid of the process leading the group in front, or
|
|
557
|
-
* `{ unreadable: <why> }
|
|
589
|
+
* `{ unreadable: <why> }`; each with `pane`, the pane's pid, where Orca gave it.
|
|
558
590
|
*
|
|
559
591
|
* `ps` is asked first. Where it cannot read the tab, as inside Codex's
|
|
560
592
|
* sandbox, where it does not start at all, Orca's runtime is asked instead
|
|
561
|
-
* (#298, ADR 0034).
|
|
593
|
+
* (#298, ADR 0034). `readMs` bounds each call: a number, or a function that
|
|
594
|
+
* says what is left of a wait, asked before each one (#498). A `ps` that runs
|
|
595
|
+
* out of it ends the look, `late`, with no runtime asked after it.
|
|
562
596
|
*/
|
|
563
597
|
function frontOf(handle, ptyId, readMs) {
|
|
564
598
|
const read = frontByPs(ptyId, readMs);
|
|
565
|
-
if (read.unreadable === undefined) return read;
|
|
566
|
-
const asked = frontByOrca(handle);
|
|
567
|
-
|
|
599
|
+
if (read.unreadable === undefined || read.late === true) return read;
|
|
600
|
+
const asked = frontByOrca(handle, msOf(readMs));
|
|
601
|
+
// The pane's pid, where `diagnostics memory` gave it, goes on with the answer.
|
|
602
|
+
const pane = read.pane === undefined ? {} : { pane: read.pane };
|
|
603
|
+
return { ...(asked.unreadable === undefined ? asked : { unreadable: `${read.unreadable}, and ${asked.unreadable}` }), psUnread: true, ...pane };
|
|
568
604
|
}
|
|
569
605
|
|
|
570
606
|
/**
|
|
@@ -576,8 +612,8 @@ function frontOf(handle, ptyId, readMs) {
|
|
|
576
612
|
* else is "cannot tell": for a moment after a harness quits, Orca can name
|
|
577
613
|
* nothing and still see a child in front (read in the Orca 1.4.212 bundle).
|
|
578
614
|
*/
|
|
579
|
-
function frontByOrca(handle) {
|
|
580
|
-
const seen = askRuntime('terminal.inspectProcess', { terminal: handle })?.result?.process;
|
|
615
|
+
function frontByOrca(handle, readMs) {
|
|
616
|
+
const seen = askRuntime('terminal.inspectProcess', { terminal: handle }, readMs)?.result?.process;
|
|
581
617
|
const evidence = seen?.foregroundProcessEvidence;
|
|
582
618
|
if (evidence?.verdict !== 'live') {
|
|
583
619
|
const why = typeof evidence?.reason === 'string' ? `: ${evidence.reason}` : '';
|
|
@@ -601,6 +637,29 @@ function frontByOrca(handle) {
|
|
|
601
637
|
* here ends in "cannot tell" rather than in an error or a guess.
|
|
602
638
|
*/
|
|
603
639
|
function frontByPs(ptyId, readMs) {
|
|
640
|
+
const { pane, unreadable } = panePid(ptyId, msOf(readMs));
|
|
641
|
+
if (unreadable !== undefined) return { unreadable };
|
|
642
|
+
|
|
643
|
+
const own = psLine(pane, msOf(readMs));
|
|
644
|
+
if (own?.late === true) return { unreadable: `ps did not answer in time for its pane, pid ${pane}`, late: true };
|
|
645
|
+
if (own === undefined) return { unreadable: `ps could not read its pane, pid ${pane}`, pane };
|
|
646
|
+
if (own.tpgid === pane) return { front: 'shell', pane };
|
|
647
|
+
|
|
648
|
+
const front = psLine(own.tpgid, msOf(readMs));
|
|
649
|
+
if (front?.late === true) return { unreadable: `ps did not answer in time for the process in front, pid ${own.tpgid}`, late: true };
|
|
650
|
+
if (front === undefined) return { unreadable: `ps could not read the process in front, pid ${own.tpgid}`, pane };
|
|
651
|
+
const shell = front.ppid === pane && path.basename(own.comm) === 'login';
|
|
652
|
+
return shell ? { front: 'shell', pane } : { front: 'program', command: path.basename(front.comm), pid: own.tpgid, pane };
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/** A bound `frontOf` was given, as a number of milliseconds: asked now when it is a function. */
|
|
656
|
+
const msOf = (readMs) => (typeof readMs === 'function' ? readMs() : readMs);
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* The pid of the pane `ptyId`, `{ pane }`, from `diagnostics memory`, or
|
|
660
|
+
* `{ unreadable: <why> }`.
|
|
661
|
+
*/
|
|
662
|
+
function panePid(ptyId, readMs) {
|
|
604
663
|
let pane;
|
|
605
664
|
try {
|
|
606
665
|
pane = orca(['diagnostics', 'memory'], { timeoutMs: readMs }).worktrees
|
|
@@ -614,15 +673,87 @@ function frontByPs(ptyId, readMs) {
|
|
|
614
673
|
return { unreadable: `Orca would not give the pane's pid: ${error.message}` };
|
|
615
674
|
}
|
|
616
675
|
if (!Number.isInteger(pane) || pane <= 0) return { unreadable: 'Orca gave no pid for its pane' };
|
|
676
|
+
return { pane };
|
|
677
|
+
}
|
|
617
678
|
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
679
|
+
/** The `lsof` and `stty` this run asks. OBK_LSOF and OBK_STTY override them, as OBK_PS does `ps`. */
|
|
680
|
+
const lsofCli = () => process.env.OBK_LSOF || '/usr/sbin/lsof';
|
|
681
|
+
const sttyCli = () => process.env.OBK_STTY || '/bin/stty';
|
|
621
682
|
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
683
|
+
/**
|
|
684
|
+
* One look at the shell of a tab the kit has just opened, before anything is
|
|
685
|
+
* typed into it (#498): `{ ready: true }` when the shell is at a prompt that
|
|
686
|
+
* takes a line, `{ asking: true }` when the shell is in front and reading the
|
|
687
|
+
* tty some other way, `{ program }` when something else is in front, and
|
|
688
|
+
* `{ unsure: <why> }` when the kit cannot tell, with `late` when a call ran out
|
|
689
|
+
* of the time left until `until`.
|
|
690
|
+
*
|
|
691
|
+
* A shell's line editor (zsh's, bash's readline) puts the tty in non-canonical
|
|
692
|
+
* mode with no echo and no literal-next key while it waits at a prompt. A
|
|
693
|
+
* question asked in a start-up file (`read -k 1`, `read -p`, `read -s -k 1`)
|
|
694
|
+
* leaves echo or the literal-next key on, and so does a shell still busy with
|
|
695
|
+
* its start-up files. Orca's daemon tells a ready shell
|
|
696
|
+
* the same way when its own mark does not come (tech notes, section 1). The
|
|
697
|
+
* shell's prompt itself is never read: it is the user's.
|
|
698
|
+
*/
|
|
699
|
+
export function shellInTab(handle, until) {
|
|
700
|
+
// Each call is given what is left until `until`, so a reader that hangs
|
|
701
|
+
// cannot hold the launch past its wait.
|
|
702
|
+
const left = () => Math.max(1, until - Date.now());
|
|
703
|
+
try {
|
|
704
|
+
let shown;
|
|
705
|
+
try {
|
|
706
|
+
shown = orca(['terminal', 'show', '--terminal', handle], { timeoutMs: left() }).terminal;
|
|
707
|
+
} catch (error) {
|
|
708
|
+
if (error.code === TIMED_OUT) throw error;
|
|
709
|
+
return { unsure: `Orca would not show the tab: ${error.message}` };
|
|
710
|
+
}
|
|
711
|
+
const front = frontOf(handle, shown?.ptyId, left);
|
|
712
|
+
if (front.unreadable !== undefined) return { unsure: front.unreadable, ...(front.late ? { late: true } : {}) };
|
|
713
|
+
if (front.front === 'program') return { program: front.command };
|
|
714
|
+
const { pane, unreadable } = front.pane === undefined ? panePid(shown?.ptyId, left()) : front;
|
|
715
|
+
if (unreadable !== undefined) return { unsure: unreadable };
|
|
716
|
+
const mode = ttyMode(pane, left);
|
|
717
|
+
if (mode.unreadable !== undefined) return { unsure: mode.unreadable, ...(mode.late ? { late: true } : {}) };
|
|
718
|
+
return mode.lineEditor ? { ready: true } : { asking: true };
|
|
719
|
+
} catch (error) {
|
|
720
|
+
if (error.code !== TIMED_OUT) throw error;
|
|
721
|
+
return { unsure: `Orca did not answer in time: ${error.message}`, late: true };
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* Whether the tty of the pane `pid` is in a line editor's mode, `{ lineEditor }`,
|
|
727
|
+
* or `{ unreadable: <why> }`. `lsof` names the tty on the standard input of the
|
|
728
|
+
* shell, and `stty` reads its flags; both run inside Codex's sandbox, where
|
|
729
|
+
* `ps` does not. The pane is `login`, which runs as root and whose files `lsof`
|
|
730
|
+
* cannot read, so the shell is found as the user's process whose parent it is,
|
|
731
|
+
* or as the pane itself where the pane is the shell.
|
|
732
|
+
*/
|
|
733
|
+
function ttyMode(pid, left) {
|
|
734
|
+
const listed = spawnSync(lsofCli(), ['-a', '-R', '-d', '0', '-u', String(process.getuid()), '-FpRn'], { encoding: 'utf8', timeout: left() });
|
|
735
|
+
if (listed.error?.code === 'ETIMEDOUT') return { unreadable: 'lsof did not answer in time', late: true };
|
|
736
|
+
if (listed.error || listed.status !== 0) return { unreadable: `lsof could not list the processes' ttys` };
|
|
737
|
+
// One record per process: `p<pid>`, `R<parent pid>`, `f0`, `n<name>`.
|
|
738
|
+
let record = {};
|
|
739
|
+
let tty;
|
|
740
|
+
for (const line of listed.stdout.split('\n')) {
|
|
741
|
+
if (line.startsWith('p')) record = { pid: Number(line.slice(1)) };
|
|
742
|
+
else if (line.startsWith('R')) record.ppid = Number(line.slice(1));
|
|
743
|
+
else if (line.startsWith('n/dev/') && (record.pid === pid || record.ppid === pid)) tty ??= line.slice(1);
|
|
744
|
+
}
|
|
745
|
+
if (tty === undefined) return { unreadable: `lsof named no tty for the shell of its pane, pid ${pid}` };
|
|
746
|
+
|
|
747
|
+
const read = spawnSync(sttyCli(), ['-a', '-f', tty], { encoding: 'utf8', timeout: left() });
|
|
748
|
+
if (read.error?.code === 'ETIMEDOUT') return { unreadable: `stty did not answer in time for ${tty}`, late: true };
|
|
749
|
+
if (read.error || read.status !== 0) return { unreadable: `stty could not read ${tty}` };
|
|
750
|
+
const words = read.stdout.split(/\s+/);
|
|
751
|
+
const flag = (name) => (words.includes(name) ? true : words.includes(`-${name}`) ? false : undefined);
|
|
752
|
+
const [icanon, echo] = [flag('icanon'), flag('echo')];
|
|
753
|
+
if (icanon === undefined || echo === undefined) return { unreadable: `stty gave no icanon and echo flags for ${tty}` };
|
|
754
|
+
// A line editor also turns off the literal-next key, which a question read
|
|
755
|
+
// with echo off (`read -s -k 1`) leaves on, as Orca's own test has it.
|
|
756
|
+
return { lineEditor: !icanon && !echo && /(?:^|[;\s])lnext\s*=\s*<undef>(?:;|\s|$)/.test(read.stdout) };
|
|
626
757
|
}
|
|
627
758
|
|
|
628
759
|
/**
|
|
@@ -638,9 +769,10 @@ export function wordsOfProcess(pid) {
|
|
|
638
769
|
return asked.stdout.trim().split(/\s+/);
|
|
639
770
|
}
|
|
640
771
|
|
|
641
|
-
/** One process as `ps` gives it, read only: `{ ppid, tpgid, comm }`, or undefined. */
|
|
642
|
-
function psLine(pid) {
|
|
643
|
-
const asked = spawnSync(psCli(), ['-o', 'pid=,ppid=,tpgid=,comm=', '-p', String(pid)], { encoding: 'utf8' });
|
|
772
|
+
/** One process as `ps` gives it, read only: `{ ppid, tpgid, comm }`, `{ late: true }` past `readMs`, or undefined. */
|
|
773
|
+
function psLine(pid, readMs) {
|
|
774
|
+
const asked = spawnSync(psCli(), ['-o', 'pid=,ppid=,tpgid=,comm=', '-p', String(pid)], { encoding: 'utf8', ...(readMs === undefined ? {} : { timeout: readMs }) });
|
|
775
|
+
if (asked.error?.code === 'ETIMEDOUT') return { late: true };
|
|
644
776
|
if (asked.error || asked.status !== 0) return undefined;
|
|
645
777
|
const line = /^\s*(\d+)\s+(\d+)\s+(-?\d+)\s+(.+?)\s*$/.exec(asked.stdout);
|
|
646
778
|
if (line === null) return undefined;
|
package/src/temp.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// `obk temp make` and `obk temp retire`: a session's own temporary sessions
|
|
2
|
-
// (PRD 6.4, #227). And `obk temp trust-hooks
|
|
3
|
-
// Codex run's hooks review (#238)
|
|
2
|
+
// (PRD 6.4, #227). And `obk temp trust-hooks` and `obk temp answer`, the
|
|
3
|
+
// answers a maker gives its Codex run's hooks review (#238) and its Claude
|
|
4
|
+
// session's Teach auto mode form (#489).
|
|
4
5
|
//
|
|
5
6
|
// Any long-lived session can make a temporary session of its own bot for a
|
|
6
7
|
// piece of work, and retire it when the work is done, without Bot Father, who
|
|
@@ -348,59 +349,232 @@ export async function trustHooks(bots, { tab, name }) {
|
|
|
348
349
|
if (harness !== 'codex') {
|
|
349
350
|
throw new Error(`${run} runs on ${harness}, and temp trust-hooks answers a Codex run's "${HOOKS_REVIEW}" alone. ${nothing}`);
|
|
350
351
|
}
|
|
352
|
+
await answerScreen(caller, name, {
|
|
353
|
+
what: 'hooks review',
|
|
354
|
+
mark: HOOKS_REVIEW,
|
|
355
|
+
done: `"${TRUST_ALL}" was chosen on its hooks review`,
|
|
356
|
+
answer(rows, { send }) {
|
|
357
|
+
const keys = keysToTrustAll(rows);
|
|
358
|
+
if (keys === undefined) {
|
|
359
|
+
throw new Error(`${run}'s screen shows no "${HOOKS_REVIEW}" with its choices, so there is nothing for this to answer. It shows: ${shown(rows)}. ${nothing}`);
|
|
360
|
+
}
|
|
361
|
+
send(keys);
|
|
362
|
+
},
|
|
363
|
+
});
|
|
364
|
+
return { bot: caller.bot, session: name, maker: caller.session };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* The keys that take Codex's hooks review to "2. Trust all and continue" and
|
|
369
|
+
* answer it, from wherever its pointer is: arrows, then return, never a digit
|
|
370
|
+
* (a digit and return once took whatever was highlighted). Undefined when the
|
|
371
|
+
* rows show no such review with the pointer on one of its choices.
|
|
372
|
+
*/
|
|
373
|
+
function keysToTrustAll(rows) {
|
|
374
|
+
if (!rows.some((row) => row.includes(HOOKS_REVIEW))) return undefined;
|
|
375
|
+
if (!rows.some((row) => new RegExp(`^ *(?:› +)?2\\. ${TRUST_ALL}`).test(row))) return undefined;
|
|
376
|
+
const pointer = rows.map((row) => /^ *› +(\d+)\. /.exec(row)).findLast((found) => found !== null);
|
|
377
|
+
if (pointer === undefined) return undefined;
|
|
378
|
+
const moves = 2 - Number(pointer[1]);
|
|
379
|
+
return (moves > 0 ? '\x1b[B'.repeat(moves) : '\x1b[A'.repeat(-moves)) + '\r';
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** What a screen shows, on one line: its rows with words in them, cut short. */
|
|
383
|
+
function shown(rows) {
|
|
384
|
+
const text = rows.map((row) => row.trim()).filter((row) => row !== '').join(' ⏎ ');
|
|
385
|
+
return text.length > 500 ? `${text.slice(0, 500)}…` : text;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Answer a screen of `name`, a temporary session the caller made: under the
|
|
390
|
+
* session's turn for a line (#482), read its screen and hand it to
|
|
391
|
+
* `answer(rows, { send, look })`, which sends keys with `send`, may read the
|
|
392
|
+
* screen again with `look`, and throws when the screen is not its to answer.
|
|
393
|
+
* Then wait for `mark` to leave the screen. `what` names the screen and `done`
|
|
394
|
+
* says what was sent, for the sentences that refuse or fail; `done` may be a
|
|
395
|
+
* function of what `answer` returned.
|
|
396
|
+
*/
|
|
397
|
+
async function answerScreen(caller, name, { what, mark, done, answer }) {
|
|
398
|
+
const run = `${caller.bot}/${name}`;
|
|
399
|
+
const nothing = 'Nothing was typed.';
|
|
351
400
|
const tabId = readBook(caller.home).sessions[name]?.tab;
|
|
352
401
|
const handle = tabId === undefined ? undefined : tabs(caller.home).find((one) => one.tabId === tabId)?.handle;
|
|
353
402
|
if (handle === undefined) {
|
|
354
|
-
throw new Error(`${run} has no tab open in Orca, so there is no
|
|
403
|
+
throw new Error(`${run} has no tab open in Orca, so there is no ${what} of its to answer. ${nothing}`);
|
|
355
404
|
}
|
|
356
|
-
// The session's turn for a line (#482), for the look and the keys.
|
|
357
405
|
const turn = takeLineTurn(caller.home, name, TYPING_WAIT_MS);
|
|
358
406
|
if (turn === undefined) throw new Error(`${run}: ${TYPING_HELD}.`);
|
|
407
|
+
let answered;
|
|
359
408
|
try {
|
|
360
409
|
const seen = screenRows(handle);
|
|
361
410
|
if (seen.rows === undefined) {
|
|
362
|
-
throw new Error(`${run}'s screen could not be read (${seen.unreadable}), so the kit cannot tell whether its
|
|
411
|
+
throw new Error(`${run}'s screen could not be read (${seen.unreadable}), so the kit cannot tell whether its ${what} is there. ${nothing}`);
|
|
363
412
|
}
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
orca(['terminal', 'send', '--terminal', handle, '--text', keys]);
|
|
413
|
+
answered = answer(seen.rows, {
|
|
414
|
+
// The return, where there is one, is inside the text: `--enter` would be a second key.
|
|
415
|
+
send: (keys) => orca(['terminal', 'send', '--terminal', handle, '--text', keys]),
|
|
416
|
+
look: () => screenRows(handle),
|
|
417
|
+
});
|
|
370
418
|
} finally {
|
|
371
419
|
turn.release();
|
|
372
420
|
}
|
|
373
421
|
for (let waited = 0; ; waited += 500) {
|
|
374
422
|
const after = screenRows(handle);
|
|
375
|
-
if (after.rows !== undefined && !after.rows.some((row) => row.includes(
|
|
423
|
+
if (after.rows !== undefined && !after.rows.some((row) => row.includes(mark))) return answered;
|
|
376
424
|
if (waited >= REVIEW_GONE_MS) {
|
|
377
|
-
const still = after.rows === undefined ? `its screen could not be read again (${after.unreadable})` : `its screen still shows "${
|
|
378
|
-
throw new Error(`${run}:
|
|
425
|
+
const still = after.rows === undefined ? `its screen could not be read again (${after.unreadable})` : `its screen still shows "${mark}"`;
|
|
426
|
+
throw new Error(`${run}: ${typeof done === 'function' ? done(answered) : done}, but ${REVIEW_GONE_MS / 1000} seconds later ${still}. Look at its tab.`);
|
|
379
427
|
}
|
|
380
428
|
await pause(500);
|
|
381
429
|
}
|
|
382
|
-
return { bot: caller.bot, session: name, maker: caller.session };
|
|
383
430
|
}
|
|
384
431
|
|
|
432
|
+
/** The title of Claude Code's "Teach auto mode" screen, in each known shape. */
|
|
433
|
+
const TEACH_TITLE = 'Teach auto mode about your environment?';
|
|
434
|
+
|
|
435
|
+
/** The foot that ends a Claude Code form, below which its input box sits. */
|
|
436
|
+
const TEACH_FOOT = /Enter to .*· Esc to cancel/;
|
|
437
|
+
|
|
385
438
|
/**
|
|
386
|
-
* The
|
|
387
|
-
*
|
|
388
|
-
*
|
|
389
|
-
*
|
|
439
|
+
* The known shapes of the Teach screen, from its title to its foot, each as
|
|
440
|
+
* captured live, with what answers it. Rows are compared trimmed, the pointer
|
|
441
|
+
* `❯` taken out, so the pointer may sit on any one of them.
|
|
442
|
+
*
|
|
443
|
+
* Claude Code 2.1.283's form (#261's live test, #416) is answered with Esc,
|
|
444
|
+
* which cancels it (Not now), never a return, which is Continue. Claude Code
|
|
445
|
+
* 2.1.289's numbered list (#489's live run 1) is answered with "2. Not now",
|
|
446
|
+
* never "1. Yes" or "3. Don't show again" (the architect's ruling on #489).
|
|
390
447
|
*/
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
448
|
+
const TEACH_SHAPES = [
|
|
449
|
+
{
|
|
450
|
+
name: 'the 2.1.283 form',
|
|
451
|
+
rows: [
|
|
452
|
+
TEACH_TITLE,
|
|
453
|
+
'Claude Code reads this project, your recent Claude sessions, and optionally your shell history and other',
|
|
454
|
+
'repositories. Claude analyzes this data and customizes auto mode to make better decisions.',
|
|
455
|
+
'How you use Claude here Mixed',
|
|
456
|
+
'Also scan shell history true',
|
|
457
|
+
'Also scan your other repos false',
|
|
458
|
+
'Continue',
|
|
459
|
+
'←/→ to change usage · Enter to continue · Esc to cancel',
|
|
460
|
+
],
|
|
461
|
+
},
|
|
462
|
+
{
|
|
463
|
+
name: 'the 2.1.289 list',
|
|
464
|
+
rows: [
|
|
465
|
+
TEACH_TITLE,
|
|
466
|
+
'Auto mode works better when it knows your environment. Takes about a minute.',
|
|
467
|
+
'1. Yes',
|
|
468
|
+
'2. Not now',
|
|
469
|
+
"3. Don't show again",
|
|
470
|
+
'Enter to confirm · Esc to cancel',
|
|
471
|
+
],
|
|
472
|
+
},
|
|
473
|
+
];
|
|
474
|
+
|
|
475
|
+
/** The 2.1.289 list's choice this kit takes, and the row it is on. */
|
|
476
|
+
const NOT_NOW_CHOICE = '2. Not now';
|
|
477
|
+
|
|
478
|
+
/** A row as a shape is compared: trimmed, with its pointer, wherever it sits, taken out. */
|
|
479
|
+
const unpointed = (row) => row.replace('❯', ' ').trim();
|
|
480
|
+
|
|
481
|
+
/** The full-width rule Claude Code draws right above the screen's title: `─` in 2.1.289, `▔` in 2.1.283. */
|
|
482
|
+
const ABOVE_TITLE = /^(?:─+|▔+)$/;
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* The Teach screen on `rows`, from its title to its foot, the rows below the
|
|
486
|
+
* foot (the input box) left out: `{ shape, block }` when its non-blank rows
|
|
487
|
+
* are one known shape's, in its order and nothing else, with the pointer on
|
|
488
|
+
* exactly one; otherwise `{ misfit }`, why not.
|
|
489
|
+
*
|
|
490
|
+
* It counts only in the frame Claude Code drew it in, in both live captures:
|
|
491
|
+
* a full-width rule as the non-blank row right above the title, and, right
|
|
492
|
+
* below the foot, nothing or the input box's top rule. A quote of the screen
|
|
493
|
+
* in a turn, with the turn's own rows around it, is refused (the review of
|
|
494
|
+
* #490). A quote of the frame as well would still look the same: the frame
|
|
495
|
+
* narrows what passes, and is no proof that the screen is live.
|
|
496
|
+
*/
|
|
497
|
+
function teachScreen(rows) {
|
|
498
|
+
const from = rows.findIndex((row) => row.trim() === TEACH_TITLE);
|
|
499
|
+
if (from < 0) return { misfit: `it shows no "${TEACH_TITLE}"` };
|
|
500
|
+
const foot = rows.findIndex((row, at) => at > from && TEACH_FOOT.test(row));
|
|
501
|
+
if (foot < 0) return { misfit: `it shows "${TEACH_TITLE}" with no foot row ending "Esc to cancel" under it` };
|
|
502
|
+
const above = rows.slice(0, from).findLast((row) => row.trim() !== '');
|
|
503
|
+
if (above === undefined || !ABOVE_TITLE.test(above.trim())) {
|
|
504
|
+
return { misfit: `the row right above its title is ${above === undefined ? 'nothing' : `"${above.trim()}"`}, not the full-width rule Claude Code draws there` };
|
|
505
|
+
}
|
|
506
|
+
const below = rows.slice(foot + 1).find((row) => row.trim() !== '');
|
|
507
|
+
if (below !== undefined && !below.trimStart().startsWith('─')) {
|
|
508
|
+
return { misfit: `the row right below its foot is "${below.trim()}", not the top rule of the input box` };
|
|
509
|
+
}
|
|
510
|
+
const block = rows.slice(from, foot + 1).filter((row) => row.trim() !== '');
|
|
511
|
+
const misfits = TEACH_SHAPES.map((shape) => {
|
|
512
|
+
const pointers = block.filter((row) => row.includes('❯')).length;
|
|
513
|
+
if (pointers !== 1) return `${pointers} rows with the pointer ❯, where ${shape.name} has one`;
|
|
514
|
+
const odd = block.find((row) => !shape.rows.includes(unpointed(row)));
|
|
515
|
+
if (odd !== undefined) return `a row ${shape.name} does not have: ${odd.trim()}`;
|
|
516
|
+
const missing = shape.rows.find((row) => !block.map(unpointed).includes(row));
|
|
517
|
+
if (missing !== undefined) return `no row ${missing}, which ${shape.name} has`;
|
|
518
|
+
if (block.length !== shape.rows.length) return `${block.length} rows, where ${shape.name} has ${shape.rows.length}`;
|
|
519
|
+
const moved = block.findIndex((row, at) => unpointed(row) !== shape.rows[at]);
|
|
520
|
+
if (moved >= 0) return `the row ${block[moved].trim()} where ${shape.name} has ${shape.rows[moved]}`;
|
|
521
|
+
return undefined;
|
|
522
|
+
});
|
|
523
|
+
const fits = misfits.findIndex((misfit) => misfit === undefined);
|
|
524
|
+
if (fits >= 0) return { shape: TEACH_SHAPES[fits], block };
|
|
525
|
+
return { misfit: `its Teach screen is no shape the kit knows: it has ${misfits.join('; and it has ')}` };
|
|
398
526
|
}
|
|
399
527
|
|
|
400
|
-
/**
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
528
|
+
/** The whole screen, a row to a line: what decides is often at its foot. */
|
|
529
|
+
const wholeScreen = (rows) => rows.filter((row) => row.trim() !== '').map((row) => ` ${row}`).join('\n');
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Answer the first-run screen of a temporary session the caller made (#489):
|
|
533
|
+
* Claude Code's Teach auto mode screen, in one of the shapes the kit knows,
|
|
534
|
+
* matched exactly. The 2.1.283 form gets Esc. The 2.1.289 list gets arrows to
|
|
535
|
+
* "2. Not now" where needed, a second look that the pointer is there and the
|
|
536
|
+
* list is otherwise as it was, and only then a return. Anything else is refused with
|
|
537
|
+
* nothing typed. Like `trustHooks`, it is one command a permission rule of its
|
|
538
|
+
* own can allow. Returns `{ bot, session, maker, sent }`, `sent` saying what
|
|
539
|
+
* answered it.
|
|
540
|
+
*/
|
|
541
|
+
export async function answerTemp(bots, { tab, name }) {
|
|
542
|
+
const caller = callerIn(bots, tab, 'answer', "answer a session's first-run screen");
|
|
543
|
+
const nothing = 'Nothing was typed.';
|
|
544
|
+
ownTemp(caller, name, 'answer for', nothing);
|
|
545
|
+
const run = `${caller.bot}/${name}`;
|
|
546
|
+
const sent = await answerScreen(caller, name, {
|
|
547
|
+
what: `"${TEACH_TITLE}"`,
|
|
548
|
+
mark: TEACH_TITLE,
|
|
549
|
+
done: (said) => `${said} was sent to its Teach auto mode screen`,
|
|
550
|
+
answer(rows, { send, look }) {
|
|
551
|
+
const seen = teachScreen(rows);
|
|
552
|
+
if (seen.misfit !== undefined) {
|
|
553
|
+
const hooks = rows.some((row) => row.includes(HOOKS_REVIEW)) ? ` A Codex run's "${HOOKS_REVIEW}" is answered with temp trust-hooks.` : '';
|
|
554
|
+
throw new Error(`${run}'s screen is not one temp answer answers: ${seen.misfit}.${hooks} ${nothing} It shows:\n${wholeScreen(rows)}`);
|
|
555
|
+
}
|
|
556
|
+
if (seen.shape === TEACH_SHAPES[0]) {
|
|
557
|
+
send('\x1b');
|
|
558
|
+
return 'Esc (Not now)';
|
|
559
|
+
}
|
|
560
|
+
const at = seen.block.findIndex((row) => row.includes('❯'));
|
|
561
|
+
const moves = seen.block.findIndex((row) => unpointed(row) === NOT_NOW_CHOICE) - at;
|
|
562
|
+
if (moves !== 0) send(moves > 0 ? '\x1b[B'.repeat(moves) : '\x1b[A'.repeat(-moves));
|
|
563
|
+
// The guard, before every return, arrows or none: the list again, its
|
|
564
|
+
// rows in the same order (an exact shape), and the pointer on "2. Not now".
|
|
565
|
+
const again = look();
|
|
566
|
+
const now = again.rows === undefined ? { misfit: `it could not be read again (${again.unreadable})` } : teachScreen(again.rows);
|
|
567
|
+
const on = now.block?.find((row) => row.includes('❯'));
|
|
568
|
+
if (now.shape !== seen.shape || unpointed(on) !== NOT_NOW_CHOICE) {
|
|
569
|
+
const why = now.misfit ?? `its pointer is on ${unpointed(on)}, not ${NOT_NOW_CHOICE}`;
|
|
570
|
+
const sent = moves === 0 ? 'Nothing was typed' : `The arrows to "${NOT_NOW_CHOICE}" went in`;
|
|
571
|
+
throw new Error(`${run}: ${sent}, but then ${why}, so no return was sent. Look at its tab.${again.rows === undefined ? '' : ` It shows:\n${wholeScreen(again.rows)}`}`);
|
|
572
|
+
}
|
|
573
|
+
send('\r');
|
|
574
|
+
return `"${NOT_NOW_CHOICE}"`;
|
|
575
|
+
},
|
|
576
|
+
});
|
|
577
|
+
return { bot: caller.bot, session: name, maker: caller.session, sent };
|
|
404
578
|
}
|
|
405
579
|
|
|
406
580
|
/**
|
package/src/up.js
CHANGED
|
@@ -14,7 +14,7 @@ import { conversationsIn, hasConversation, heldAsUserTurn, transcriptsIn } from
|
|
|
14
14
|
import { installHook } from './hooks.js';
|
|
15
15
|
import { writePermissions } from './permissions.js';
|
|
16
16
|
import { addressOf, harnessOf, isAddressOf, isShortPrompt, launchCommand, mailboxStep, reachesMail, sessionTrouble, startPrompt, workDirOf } from './launch.js';
|
|
17
|
-
import { asFolderProject, coordinatorOf, findProject, harnessInTab, makeMailbox, makeProject, openTab, QUESTION_ON_SCREEN, retitleTab, tabs, tabToTypeInto, TERMINAL_ENV, TIMED_OUT, tellWindow, typeIntoTab, useMailbox } from './orca.js';
|
|
17
|
+
import { asFolderProject, coordinatorOf, findProject, harnessInTab, makeMailbox, makeProject, openTab, QUESTION_ON_SCREEN, retitleTab, screenRows, shellInTab, tabs, tabToTypeInto, TERMINAL_ENV, TIMED_OUT, tellWindow, typeIntoTab, useMailbox } from './orca.js';
|
|
18
18
|
import { TAB_ENV } from './record.js';
|
|
19
19
|
import { buildAgents, rulesStamp } from './rules.js';
|
|
20
20
|
import { linkSkills } from './skills.js';
|
|
@@ -338,6 +338,14 @@ async function bringUpSession(bots, home, live, session, bot, title) {
|
|
|
338
338
|
writePrompt(launch);
|
|
339
339
|
}
|
|
340
340
|
|
|
341
|
+
// Only into a shell that is ready for it (#498). A question the shell asks as
|
|
342
|
+
// it starts takes the line as its answer: Orca holds a line for a new tab
|
|
343
|
+
// only until its shell is ready or 15 s pass, and then lets it through.
|
|
344
|
+
const notLaunched = await shellNotReady(made.handle);
|
|
345
|
+
if (notLaunched !== undefined) {
|
|
346
|
+
return entry(made, { bot: bot.name, name: session.name, created: true, notLaunched });
|
|
347
|
+
}
|
|
348
|
+
|
|
341
349
|
// Typing it in is the way: for a project the kit has just made, giving Orca
|
|
342
350
|
// the harness as the tab's own command times out and leaves a dead tab.
|
|
343
351
|
typeIntoTab(made.handle, launch.command);
|
|
@@ -395,6 +403,53 @@ async function bringUpSession(bots, home, live, session, bot, title) {
|
|
|
395
403
|
});
|
|
396
404
|
}
|
|
397
405
|
|
|
406
|
+
/**
|
|
407
|
+
* How long a new tab's shell is given to come to a ready prompt, and the pause
|
|
408
|
+
* between looks. A start-up with oh-my-zsh took about a second here; Orca
|
|
409
|
+
* itself waits 15 s for a new shell.
|
|
410
|
+
*/
|
|
411
|
+
const SHELL_READY_MS = 15000;
|
|
412
|
+
const SHELL_LOOK_MS = 250;
|
|
413
|
+
|
|
414
|
+
/** The least a look is given: a wait with less left than that ends. */
|
|
415
|
+
const LAST_LOOK_MS = 1000;
|
|
416
|
+
|
|
417
|
+
/** How long the screen read for a refusal's words is given. */
|
|
418
|
+
const SCREEN_READ_MS = 5000;
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Undefined once the shell in the new tab `handle` is at a ready prompt, or,
|
|
422
|
+
* when it is not within SHELL_READY_MS, a sentence that says why and names
|
|
423
|
+
* what its screen shows (#498).
|
|
424
|
+
*/
|
|
425
|
+
async function shellNotReady(handle) {
|
|
426
|
+
const until = Date.now() + SHELL_READY_MS;
|
|
427
|
+
let seen;
|
|
428
|
+
// The last look that was not cut short: what the refusal is about, when the
|
|
429
|
+
// last one ran out of time.
|
|
430
|
+
let answered;
|
|
431
|
+
for (;;) {
|
|
432
|
+
const look = shellInTab(handle, until);
|
|
433
|
+
seen = look.late === true ? answered ?? look : look;
|
|
434
|
+
if (look.late !== true) answered = look;
|
|
435
|
+
// The calls that decide a yes, lsof and stty last, end by `until`, so a
|
|
436
|
+
// yes is never late.
|
|
437
|
+
if (seen.ready === true) return undefined;
|
|
438
|
+
// The last look ends the wait: one more would have no time to answer in.
|
|
439
|
+
if (until - Date.now() < SHELL_LOOK_MS + LAST_LOOK_MS) break;
|
|
440
|
+
await pause(SHELL_LOOK_MS);
|
|
441
|
+
}
|
|
442
|
+
const wait = `it did not come to a ready prompt in ${SHELL_READY_MS / 1000} s`;
|
|
443
|
+
if (seen.program !== undefined) return `${seen.program} is running in front of the shell, and ${wait}`;
|
|
444
|
+
if (seen.unsure !== undefined) return `the kit could not tell whether the shell is ready (${seen.unsure})`;
|
|
445
|
+
const read = screenRows(handle, SCREEN_READ_MS);
|
|
446
|
+
if (read.rows === undefined) return `the shell is asking something, and ${wait}; its screen could not be read (${read.unreadable})`;
|
|
447
|
+
const last = read.rows.findLast((row) => row.trim() !== '');
|
|
448
|
+
return last === undefined
|
|
449
|
+
? `the shell is asking something, and ${wait}; its screen is blank`
|
|
450
|
+
: `the shell is asking: ${last.trim()} (${wait})`;
|
|
451
|
+
}
|
|
452
|
+
|
|
398
453
|
/** The line a resumed Codex session is typed so Orca lists it, in the architect's words (#226). */
|
|
399
454
|
const LIST_LINE = 'obk: this session was resumed in a new tab, and this line is only so Orca lists it. Reply "ok"; nothing else is asked.';
|
|
400
455
|
|
|
@@ -796,8 +851,11 @@ const ids = (setup, change) => ({ project: setup.projectId, setup: setup.id, cha
|
|
|
796
851
|
export const promptPath = (bots, bot, session) =>
|
|
797
852
|
path.join(`${bots}.prompts`, `${encodeURIComponent(bot)}.${encodeURIComponent(session)}.txt`);
|
|
798
853
|
|
|
799
|
-
function entry(tab, { bot, name, created, running = false, blockedReason, promptReceived, promptFile, resumed, noConversation, unclaimed, noMailbox = false, listLine, listLineTrouble }) {
|
|
854
|
+
function entry(tab, { bot, name, created, running = false, blockedReason, promptReceived, promptFile, resumed, noConversation, unclaimed, noMailbox = false, listLine, listLineTrouble, notLaunched }) {
|
|
800
855
|
const made = { bot, name, title: tab.title, tabId: tab.tabId, terminal: tab.handle, created, harnessStarted: running };
|
|
856
|
+
// A tab whose shell was not ready for the launch line, and why: nothing was
|
|
857
|
+
// typed into it (#498).
|
|
858
|
+
if (notLaunched !== undefined) Object.assign(made, { launched: false, notLaunched });
|
|
801
859
|
// Whether this run picked the session up where it was or started a new one.
|
|
802
860
|
// Only for a tab this run opened: a tab that was already there was left alone.
|
|
803
861
|
if (resumed !== undefined) made.resumed = resumed;
|