@assuredloop/orca-bot-kit 0.25.0 → 0.25.2
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 +10 -5
- package/package.json +1 -1
- package/src/clear.js +12 -4
- package/src/cli.js +17 -2
- package/src/orca.js +155 -21
- 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.
|
package/package.json
CHANGED
package/src/clear.js
CHANGED
|
@@ -19,7 +19,7 @@ import { botDir, readBot } from './bot.js';
|
|
|
19
19
|
import { claudeTranscript, codexRollout } from './conversations.js';
|
|
20
20
|
import { harnessOf, ownCli, shellWord } from './launch.js';
|
|
21
21
|
import { eachLine, firstLine } from './lines.js';
|
|
22
|
-
import { findProject, orca, QUESTION_ON_SCREEN, questionIn, screenRows, tabs, tabToTypeInto, TIMED_OUT } from './orca.js';
|
|
22
|
+
import { FEEDBACK_PANEL, feedbackPanelIn, findProject, orca, QUESTION_ON_SCREEN, questionIn, screenRows, tabs, tabToTypeInto, TIMED_OUT } from './orca.js';
|
|
23
23
|
import { botsNamed, sessionsOf, typeListLine } from './up.js';
|
|
24
24
|
|
|
25
25
|
/** The harness's own command for each, as typed into its input line. Codex's clear is `/new` (tech notes, section 3). */
|
|
@@ -201,7 +201,7 @@ async function enter(it, verb, before = () => {}) {
|
|
|
201
201
|
if (typing === undefined) throw new Error(`${it.name}: ${TYPING_HELD}. Run this again in a moment.`);
|
|
202
202
|
before();
|
|
203
203
|
},
|
|
204
|
-
cannot: (wrong) => (verb === 'compact' && wrong.menu ? ` ${harnessName(it.harness)} here cannot compact: its menu does not offer ${COMMANDS[verb][it.harness]}.` : ''),
|
|
204
|
+
cannot: (wrong) => (verb === 'compact' && wrong.menu && it.harness === 'codex' ? ` ${harnessName(it.harness)} here cannot compact: its menu does not offer ${COMMANDS[verb][it.harness]}.` : ''),
|
|
205
205
|
});
|
|
206
206
|
return { ...typed, typing };
|
|
207
207
|
} catch (error) {
|
|
@@ -350,8 +350,8 @@ function lookAt(it, { idle = true } = {}) {
|
|
|
350
350
|
const found = tabToTypeInto(it.home, it.tabId, idle ? LOOK_MS : CHAR_LOOK_MS);
|
|
351
351
|
if (found.blocked !== undefined) {
|
|
352
352
|
return {
|
|
353
|
-
why: found.blocked ===
|
|
354
|
-
? 'a question of its harness\'s own is on its screen'
|
|
353
|
+
why: found.blocked === FEEDBACK_PANEL ? PANEL_WHY
|
|
354
|
+
: found.blocked === QUESTION_ON_SCREEN ? 'a question of its harness\'s own is on its screen'
|
|
355
355
|
: `a question is waiting on its screen: Orca says ${found.blocked}`,
|
|
356
356
|
};
|
|
357
357
|
}
|
|
@@ -390,10 +390,18 @@ async function typedWrong(handle, harness, command, version, check) {
|
|
|
390
390
|
}
|
|
391
391
|
}
|
|
392
392
|
|
|
393
|
+
/**
|
|
394
|
+
* Why nothing goes in while Claude Code's panel of feedback drafts is open
|
|
395
|
+
* (#502): it reads single keys from the input line, and one sends a draft to
|
|
396
|
+
* Anthropic, so only the user answers it.
|
|
397
|
+
*/
|
|
398
|
+
const PANEL_WHY = 'its panel of feedback drafts is open above its input box, and the kit does not answer it, since a draft sent from it goes to Anthropic: it clears once the user reviews, sends or dismisses the drafts in that tab';
|
|
399
|
+
|
|
393
400
|
/** Why `rows` say not to type now, as the look says it, or undefined: a turn at work, or a question. */
|
|
394
401
|
function signalIn(rows) {
|
|
395
402
|
const working = rows.find((row) => AT_WORK.some((marker) => marker.test(row)));
|
|
396
403
|
if (working !== undefined) return `it is busy with a turn: its screen shows "${working.trim()}"`;
|
|
404
|
+
if (feedbackPanelIn(rows)) return PANEL_WHY;
|
|
397
405
|
if (questionIn(rows)) return 'a question of its harness\'s own is on its screen';
|
|
398
406
|
return undefined;
|
|
399
407
|
}
|
package/src/cli.js
CHANGED
|
@@ -472,7 +472,9 @@ async function run(argv) {
|
|
|
472
472
|
const { answer, lines, code = 0 } = await commands[command](bots, values);
|
|
473
473
|
|
|
474
474
|
process.stdout.write(values.json ? `${JSON.stringify(answer, null, 2)}\n` : `${lines.join('\n')}\n`);
|
|
475
|
-
|
|
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;
|
|
476
478
|
}
|
|
477
479
|
|
|
478
480
|
/**
|
|
@@ -756,7 +758,9 @@ const commands = {
|
|
|
756
758
|
lines: [
|
|
757
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)}`]),
|
|
758
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}`}`}`),
|
|
759
|
-
...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}`),
|
|
760
764
|
],
|
|
761
765
|
};
|
|
762
766
|
},
|
|
@@ -1771,6 +1775,17 @@ function tabLines({ bots, created, completed, rules, skills, permissions = [], t
|
|
|
1771
1775
|
function harnessLines(tab, bots) {
|
|
1772
1776
|
if (!tab.created || tab.name === null) return [];
|
|
1773
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
|
+
|
|
1774
1789
|
// What the line that was typed in asked for, and where the kit got it: the
|
|
1775
1790
|
// session the book holds, one the harness itself still had on record, or a new
|
|
1776
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;
|
|
@@ -427,6 +427,7 @@ const ON_A_CHOICE = /^( *[›❯] +)\d+\. /;
|
|
|
427
427
|
* line, lined up with it, and by its layout that is one.
|
|
428
428
|
*/
|
|
429
429
|
export function questionIn(rows) {
|
|
430
|
+
if (feedbackPanelIn(rows)) return true;
|
|
430
431
|
const at = rows.findLastIndex((row) => POINTER_ROW.test(row));
|
|
431
432
|
// A form of the harness's own has no numbers but a foot that says how to
|
|
432
433
|
// answer it: Claude Code 2.1.283's "Teach auto mode" ends `Enter to continue
|
|
@@ -455,6 +456,39 @@ export function questionIn(rows) {
|
|
|
455
456
|
return false;
|
|
456
457
|
}
|
|
457
458
|
|
|
459
|
+
/** The gate's answer for Claude Code's panel of feedback drafts (#502). */
|
|
460
|
+
export const FEEDBACK_PANEL = 'feedback-drafts-panel';
|
|
461
|
+
|
|
462
|
+
/** The first row in the panel's box, as Claude Code 2.1.291 draws it: `✻ Bug report drafted: <title>`. */
|
|
463
|
+
const PANEL_TITLE = /^ *│ +(?:\S+ +)?(?:Bug report|Product feedback|Feature request|Feedback) drafted: /;
|
|
464
|
+
|
|
465
|
+
/** What Claude Code 2.1.291 asks in the panel's place after a `0`, its keys' foot last: `… 0 to turn off · Esc to keep`. */
|
|
466
|
+
const PANEL_TURN_OFF = /^Turn off Claude-drafted feedback\? 0 to turn off · Esc to keep$/i;
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Whether the rows of a rendered screen show Claude Code's panel of feedback
|
|
470
|
+
* drafts (#502). It has no pointer and no numbers, but it reads single keys
|
|
471
|
+
* from the input line, and `2` twice sends a draft to Anthropic. Claude Code
|
|
472
|
+
* 2.1.291 draws it as a box, its title row first, as the last thing above its
|
|
473
|
+
* input box's top rule; after a `0`, the question whether to turn the drafts
|
|
474
|
+
* off stands in its place, wrapped as the pane needs, and a typed line answers
|
|
475
|
+
* that too. The same words
|
|
476
|
+
* with anything after them are history.
|
|
477
|
+
*/
|
|
478
|
+
export function feedbackPanelIn(rows) {
|
|
479
|
+
const at = rows.findLastIndex((row) => POINTER_ROW.test(row));
|
|
480
|
+
if (at < 1 || !RULE_ROW.test(rows[at - 1])) return false;
|
|
481
|
+
const end = rows.slice(0, at - 1).findLastIndex((row) => row.trim() !== '');
|
|
482
|
+
if (end < 0) return false;
|
|
483
|
+
// The question is plain text, which a narrow pane wraps onto rows of its
|
|
484
|
+
// own, with a blank row above it: it is the whole block that ends there.
|
|
485
|
+
const start = rows.slice(0, end).findLastIndex((row) => row.trim() === '') + 1;
|
|
486
|
+
if (PANEL_TURN_OFF.test(rows.slice(start, end + 1).map((row) => row.trim()).join(' '))) return true;
|
|
487
|
+
if (!/^ *╰─/.test(rows[end])) return false;
|
|
488
|
+
const top = rows.slice(0, end).findLastIndex((row) => /^ *╭─/.test(row));
|
|
489
|
+
return top >= 0 && PANEL_TITLE.test(rows[top + 1]);
|
|
490
|
+
}
|
|
491
|
+
|
|
458
492
|
/** A rule Claude Code draws right across, as the top of its input box: `─` or `▔`. */
|
|
459
493
|
const RULE_ROW = /^ *[─▔]{8,}/;
|
|
460
494
|
|
|
@@ -538,7 +572,7 @@ export function tabToTypeInto(home, tabId, timeoutMs) {
|
|
|
538
572
|
// Orca's reason does not cover every question: it called Codex's update
|
|
539
573
|
// offer idle, and a return typed into it took "Update now" (#329). So the
|
|
540
574
|
// screen is read too, and one that cannot be read is not a reason to type.
|
|
541
|
-
if (seen.question === true) return { blocked: QUESTION_ON_SCREEN };
|
|
575
|
+
if (seen.question === true) return { blocked: feedbackPanelIn(seen.rows) ? FEEDBACK_PANEL : QUESTION_ON_SCREEN };
|
|
542
576
|
if (seen.question === undefined) {
|
|
543
577
|
return { unsure: `the kit could not tell whether a question is waiting on its screen (${seen.screenUnreadable}), so nothing was typed` };
|
|
544
578
|
}
|
|
@@ -586,17 +620,21 @@ const psCli = () => process.env.OBK_PS || '/bin/ps';
|
|
|
586
620
|
* Who holds the terminal of the tab `handle`, whose pane is `ptyId`:
|
|
587
621
|
* `{ front: 'shell' }`, `{ front: 'program', command, pid }` with the name and,
|
|
588
622
|
* where `ps` gave it, the pid of the process leading the group in front, or
|
|
589
|
-
* `{ unreadable: <why> }
|
|
623
|
+
* `{ unreadable: <why> }`; each with `pane`, the pane's pid, where Orca gave it.
|
|
590
624
|
*
|
|
591
625
|
* `ps` is asked first. Where it cannot read the tab, as inside Codex's
|
|
592
626
|
* sandbox, where it does not start at all, Orca's runtime is asked instead
|
|
593
|
-
* (#298, ADR 0034).
|
|
627
|
+
* (#298, ADR 0034). `readMs` bounds each call: a number, or a function that
|
|
628
|
+
* says what is left of a wait, asked before each one (#498). A `ps` that runs
|
|
629
|
+
* out of it ends the look, `late`, with no runtime asked after it.
|
|
594
630
|
*/
|
|
595
631
|
function frontOf(handle, ptyId, readMs) {
|
|
596
632
|
const read = frontByPs(ptyId, readMs);
|
|
597
|
-
if (read.unreadable === undefined) return read;
|
|
598
|
-
const asked = frontByOrca(handle);
|
|
599
|
-
|
|
633
|
+
if (read.unreadable === undefined || read.late === true) return read;
|
|
634
|
+
const asked = frontByOrca(handle, msOf(readMs));
|
|
635
|
+
// The pane's pid, where `diagnostics memory` gave it, goes on with the answer.
|
|
636
|
+
const pane = read.pane === undefined ? {} : { pane: read.pane };
|
|
637
|
+
return { ...(asked.unreadable === undefined ? asked : { unreadable: `${read.unreadable}, and ${asked.unreadable}` }), psUnread: true, ...pane };
|
|
600
638
|
}
|
|
601
639
|
|
|
602
640
|
/**
|
|
@@ -608,8 +646,8 @@ function frontOf(handle, ptyId, readMs) {
|
|
|
608
646
|
* else is "cannot tell": for a moment after a harness quits, Orca can name
|
|
609
647
|
* nothing and still see a child in front (read in the Orca 1.4.212 bundle).
|
|
610
648
|
*/
|
|
611
|
-
function frontByOrca(handle) {
|
|
612
|
-
const seen = askRuntime('terminal.inspectProcess', { terminal: handle })?.result?.process;
|
|
649
|
+
function frontByOrca(handle, readMs) {
|
|
650
|
+
const seen = askRuntime('terminal.inspectProcess', { terminal: handle }, readMs)?.result?.process;
|
|
613
651
|
const evidence = seen?.foregroundProcessEvidence;
|
|
614
652
|
if (evidence?.verdict !== 'live') {
|
|
615
653
|
const why = typeof evidence?.reason === 'string' ? `: ${evidence.reason}` : '';
|
|
@@ -633,6 +671,29 @@ function frontByOrca(handle) {
|
|
|
633
671
|
* here ends in "cannot tell" rather than in an error or a guess.
|
|
634
672
|
*/
|
|
635
673
|
function frontByPs(ptyId, readMs) {
|
|
674
|
+
const { pane, unreadable } = panePid(ptyId, msOf(readMs));
|
|
675
|
+
if (unreadable !== undefined) return { unreadable };
|
|
676
|
+
|
|
677
|
+
const own = psLine(pane, msOf(readMs));
|
|
678
|
+
if (own?.late === true) return { unreadable: `ps did not answer in time for its pane, pid ${pane}`, late: true };
|
|
679
|
+
if (own === undefined) return { unreadable: `ps could not read its pane, pid ${pane}`, pane };
|
|
680
|
+
if (own.tpgid === pane) return { front: 'shell', pane };
|
|
681
|
+
|
|
682
|
+
const front = psLine(own.tpgid, msOf(readMs));
|
|
683
|
+
if (front?.late === true) return { unreadable: `ps did not answer in time for the process in front, pid ${own.tpgid}`, late: true };
|
|
684
|
+
if (front === undefined) return { unreadable: `ps could not read the process in front, pid ${own.tpgid}`, pane };
|
|
685
|
+
const shell = front.ppid === pane && path.basename(own.comm) === 'login';
|
|
686
|
+
return shell ? { front: 'shell', pane } : { front: 'program', command: path.basename(front.comm), pid: own.tpgid, pane };
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/** A bound `frontOf` was given, as a number of milliseconds: asked now when it is a function. */
|
|
690
|
+
const msOf = (readMs) => (typeof readMs === 'function' ? readMs() : readMs);
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* The pid of the pane `ptyId`, `{ pane }`, from `diagnostics memory`, or
|
|
694
|
+
* `{ unreadable: <why> }`.
|
|
695
|
+
*/
|
|
696
|
+
function panePid(ptyId, readMs) {
|
|
636
697
|
let pane;
|
|
637
698
|
try {
|
|
638
699
|
pane = orca(['diagnostics', 'memory'], { timeoutMs: readMs }).worktrees
|
|
@@ -646,15 +707,87 @@ function frontByPs(ptyId, readMs) {
|
|
|
646
707
|
return { unreadable: `Orca would not give the pane's pid: ${error.message}` };
|
|
647
708
|
}
|
|
648
709
|
if (!Number.isInteger(pane) || pane <= 0) return { unreadable: 'Orca gave no pid for its pane' };
|
|
710
|
+
return { pane };
|
|
711
|
+
}
|
|
649
712
|
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
713
|
+
/** The `lsof` and `stty` this run asks. OBK_LSOF and OBK_STTY override them, as OBK_PS does `ps`. */
|
|
714
|
+
const lsofCli = () => process.env.OBK_LSOF || '/usr/sbin/lsof';
|
|
715
|
+
const sttyCli = () => process.env.OBK_STTY || '/bin/stty';
|
|
653
716
|
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
717
|
+
/**
|
|
718
|
+
* One look at the shell of a tab the kit has just opened, before anything is
|
|
719
|
+
* typed into it (#498): `{ ready: true }` when the shell is at a prompt that
|
|
720
|
+
* takes a line, `{ asking: true }` when the shell is in front and reading the
|
|
721
|
+
* tty some other way, `{ program }` when something else is in front, and
|
|
722
|
+
* `{ unsure: <why> }` when the kit cannot tell, with `late` when a call ran out
|
|
723
|
+
* of the time left until `until`.
|
|
724
|
+
*
|
|
725
|
+
* A shell's line editor (zsh's, bash's readline) puts the tty in non-canonical
|
|
726
|
+
* mode with no echo and no literal-next key while it waits at a prompt. A
|
|
727
|
+
* question asked in a start-up file (`read -k 1`, `read -p`, `read -s -k 1`)
|
|
728
|
+
* leaves echo or the literal-next key on, and so does a shell still busy with
|
|
729
|
+
* its start-up files. Orca's daemon tells a ready shell
|
|
730
|
+
* the same way when its own mark does not come (tech notes, section 1). The
|
|
731
|
+
* shell's prompt itself is never read: it is the user's.
|
|
732
|
+
*/
|
|
733
|
+
export function shellInTab(handle, until) {
|
|
734
|
+
// Each call is given what is left until `until`, so a reader that hangs
|
|
735
|
+
// cannot hold the launch past its wait.
|
|
736
|
+
const left = () => Math.max(1, until - Date.now());
|
|
737
|
+
try {
|
|
738
|
+
let shown;
|
|
739
|
+
try {
|
|
740
|
+
shown = orca(['terminal', 'show', '--terminal', handle], { timeoutMs: left() }).terminal;
|
|
741
|
+
} catch (error) {
|
|
742
|
+
if (error.code === TIMED_OUT) throw error;
|
|
743
|
+
return { unsure: `Orca would not show the tab: ${error.message}` };
|
|
744
|
+
}
|
|
745
|
+
const front = frontOf(handle, shown?.ptyId, left);
|
|
746
|
+
if (front.unreadable !== undefined) return { unsure: front.unreadable, ...(front.late ? { late: true } : {}) };
|
|
747
|
+
if (front.front === 'program') return { program: front.command };
|
|
748
|
+
const { pane, unreadable } = front.pane === undefined ? panePid(shown?.ptyId, left()) : front;
|
|
749
|
+
if (unreadable !== undefined) return { unsure: unreadable };
|
|
750
|
+
const mode = ttyMode(pane, left);
|
|
751
|
+
if (mode.unreadable !== undefined) return { unsure: mode.unreadable, ...(mode.late ? { late: true } : {}) };
|
|
752
|
+
return mode.lineEditor ? { ready: true } : { asking: true };
|
|
753
|
+
} catch (error) {
|
|
754
|
+
if (error.code !== TIMED_OUT) throw error;
|
|
755
|
+
return { unsure: `Orca did not answer in time: ${error.message}`, late: true };
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* Whether the tty of the pane `pid` is in a line editor's mode, `{ lineEditor }`,
|
|
761
|
+
* or `{ unreadable: <why> }`. `lsof` names the tty on the standard input of the
|
|
762
|
+
* shell, and `stty` reads its flags; both run inside Codex's sandbox, where
|
|
763
|
+
* `ps` does not. The pane is `login`, which runs as root and whose files `lsof`
|
|
764
|
+
* cannot read, so the shell is found as the user's process whose parent it is,
|
|
765
|
+
* or as the pane itself where the pane is the shell.
|
|
766
|
+
*/
|
|
767
|
+
function ttyMode(pid, left) {
|
|
768
|
+
const listed = spawnSync(lsofCli(), ['-a', '-R', '-d', '0', '-u', String(process.getuid()), '-FpRn'], { encoding: 'utf8', timeout: left() });
|
|
769
|
+
if (listed.error?.code === 'ETIMEDOUT') return { unreadable: 'lsof did not answer in time', late: true };
|
|
770
|
+
if (listed.error || listed.status !== 0) return { unreadable: `lsof could not list the processes' ttys` };
|
|
771
|
+
// One record per process: `p<pid>`, `R<parent pid>`, `f0`, `n<name>`.
|
|
772
|
+
let record = {};
|
|
773
|
+
let tty;
|
|
774
|
+
for (const line of listed.stdout.split('\n')) {
|
|
775
|
+
if (line.startsWith('p')) record = { pid: Number(line.slice(1)) };
|
|
776
|
+
else if (line.startsWith('R')) record.ppid = Number(line.slice(1));
|
|
777
|
+
else if (line.startsWith('n/dev/') && (record.pid === pid || record.ppid === pid)) tty ??= line.slice(1);
|
|
778
|
+
}
|
|
779
|
+
if (tty === undefined) return { unreadable: `lsof named no tty for the shell of its pane, pid ${pid}` };
|
|
780
|
+
|
|
781
|
+
const read = spawnSync(sttyCli(), ['-a', '-f', tty], { encoding: 'utf8', timeout: left() });
|
|
782
|
+
if (read.error?.code === 'ETIMEDOUT') return { unreadable: `stty did not answer in time for ${tty}`, late: true };
|
|
783
|
+
if (read.error || read.status !== 0) return { unreadable: `stty could not read ${tty}` };
|
|
784
|
+
const words = read.stdout.split(/\s+/);
|
|
785
|
+
const flag = (name) => (words.includes(name) ? true : words.includes(`-${name}`) ? false : undefined);
|
|
786
|
+
const [icanon, echo] = [flag('icanon'), flag('echo')];
|
|
787
|
+
if (icanon === undefined || echo === undefined) return { unreadable: `stty gave no icanon and echo flags for ${tty}` };
|
|
788
|
+
// A line editor also turns off the literal-next key, which a question read
|
|
789
|
+
// with echo off (`read -s -k 1`) leaves on, as Orca's own test has it.
|
|
790
|
+
return { lineEditor: !icanon && !echo && /(?:^|[;\s])lnext\s*=\s*<undef>(?:;|\s|$)/.test(read.stdout) };
|
|
658
791
|
}
|
|
659
792
|
|
|
660
793
|
/**
|
|
@@ -670,9 +803,10 @@ export function wordsOfProcess(pid) {
|
|
|
670
803
|
return asked.stdout.trim().split(/\s+/);
|
|
671
804
|
}
|
|
672
805
|
|
|
673
|
-
/** One process as `ps` gives it, read only: `{ ppid, tpgid, comm }`, or undefined. */
|
|
674
|
-
function psLine(pid) {
|
|
675
|
-
const asked = spawnSync(psCli(), ['-o', 'pid=,ppid=,tpgid=,comm=', '-p', String(pid)], { encoding: 'utf8' });
|
|
806
|
+
/** One process as `ps` gives it, read only: `{ ppid, tpgid, comm }`, `{ late: true }` past `readMs`, or undefined. */
|
|
807
|
+
function psLine(pid, readMs) {
|
|
808
|
+
const asked = spawnSync(psCli(), ['-o', 'pid=,ppid=,tpgid=,comm=', '-p', String(pid)], { encoding: 'utf8', ...(readMs === undefined ? {} : { timeout: readMs }) });
|
|
809
|
+
if (asked.error?.code === 'ETIMEDOUT') return { late: true };
|
|
676
810
|
if (asked.error || asked.status !== 0) return undefined;
|
|
677
811
|
const line = /^\s*(\d+)\s+(\d+)\s+(-?\d+)\s+(.+?)\s*$/.exec(asked.stdout);
|
|
678
812
|
if (line === null) return undefined;
|
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;
|