@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 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assuredloop/orca-bot-kit",
3
- "version": "0.25.0",
3
+ "version": "0.25.2",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
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 === QUESTION_ON_SCREEN
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
- 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;
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, `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}`),
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
- return { ...(asked.unreadable === undefined ? asked : { unreadable: `${read.unreadable}, and ${asked.unreadable}` }), psUnread: true };
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
- const own = psLine(pane);
651
- if (own === undefined) return { unreadable: `ps could not read its pane, pid ${pane}` };
652
- if (own.tpgid === pane) return { front: 'shell' };
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
- const front = psLine(own.tpgid);
655
- if (front === undefined) return { unreadable: `ps could not read the process in front, pid ${own.tpgid}` };
656
- const shell = front.ppid === pane && path.basename(own.comm) === 'login';
657
- return shell ? { front: 'shell' } : { front: 'program', command: path.basename(front.comm), pid: own.tpgid };
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;