@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 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 and nothing to wait for, so
151
- the kit types the launch line straight into the tab's shell and only then asks
152
- whether a harness came up. If that shell was busy with a question of its own, it
153
- swallowed the line. So two different things can be on the screen, and they need
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assuredloop/orca-bot-kit",
3
- "version": "0.24.1",
3
+ "version": "0.25.1",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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`. Codex's hooks
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
- return code;
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, `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}`),
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 only the lowest pointer row is
424
- * asked about: a question counts while it stands in the input line's place.
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
- return { ...(asked.unreadable === undefined ? asked : { unreadable: `${read.unreadable}, and ${asked.unreadable}` }), psUnread: true };
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
- const own = psLine(pane);
619
- if (own === undefined) return { unreadable: `ps could not read its pane, pid ${pane}` };
620
- if (own.tpgid === pane) return { front: 'shell' };
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
- const front = psLine(own.tpgid);
623
- if (front === undefined) return { unreadable: `ps could not read the process in front, pid ${own.tpgid}` };
624
- const shell = front.ppid === pane && path.basename(own.comm) === 'login';
625
- return shell ? { front: 'shell' } : { front: 'program', command: path.basename(front.comm), pid: own.tpgid };
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`, the one answer a maker gives its
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 review of its to answer. ${nothing}`);
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 hooks review is there. ${nothing}`);
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
- const keys = keysToTrustAll(seen.rows);
365
- if (keys === undefined) {
366
- throw new Error(`${run}'s screen shows no "${HOOKS_REVIEW}" with its choices, so there is nothing for this to answer. It shows: ${shown(seen.rows)}. ${nothing}`);
367
- }
368
- // The return is inside the text: `--enter` would be a second key.
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(HOOKS_REVIEW))) break;
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 "${HOOKS_REVIEW}"`;
378
- throw new Error(`${run}: "${TRUST_ALL}" was chosen on its hooks review, but ${REVIEW_GONE_MS / 1000} seconds later ${still}. Look at its tab.`);
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 keys that take Codex's hooks review to "2. Trust all and continue" and
387
- * answer it, from wherever its pointer is: arrows, then return, never a digit
388
- * (a digit and return once took whatever was highlighted). Undefined when the
389
- * rows show no such review with the pointer on one of its choices.
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
- function keysToTrustAll(rows) {
392
- if (!rows.some((row) => row.includes(HOOKS_REVIEW))) return undefined;
393
- if (!rows.some((row) => new RegExp(`^ *(?:› +)?2\\. ${TRUST_ALL}`).test(row))) return undefined;
394
- const pointer = rows.map((row) => /^ *› +(\d+)\. /.exec(row)).findLast((found) => found !== null);
395
- if (pointer === undefined) return undefined;
396
- const moves = 2 - Number(pointer[1]);
397
- return (moves > 0 ? '\x1b[B'.repeat(moves) : '\x1b[A'.repeat(-moves)) + '\r';
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
- /** What a screen shows, on one line: its rows with words in them, cut short. */
401
- function shown(rows) {
402
- const text = rows.map((row) => row.trim()).filter((row) => row !== '').join(' ⏎ ');
403
- return text.length > 500 ? `${text.slice(0, 500)}…` : text;
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;