@assuredloop/orca-bot-kit 0.25.0 → 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.
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.1",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
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;
@@ -586,17 +586,21 @@ const psCli = () => process.env.OBK_PS || '/bin/ps';
586
586
  * Who holds the terminal of the tab `handle`, whose pane is `ptyId`:
587
587
  * `{ front: 'shell' }`, `{ front: 'program', command, pid }` with the name and,
588
588
  * where `ps` gave it, the pid of the process leading the group in front, or
589
- * `{ unreadable: <why> }`.
589
+ * `{ unreadable: <why> }`; each with `pane`, the pane's pid, where Orca gave it.
590
590
  *
591
591
  * `ps` is asked first. Where it cannot read the tab, as inside Codex's
592
592
  * sandbox, where it does not start at all, Orca's runtime is asked instead
593
- * (#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.
594
596
  */
595
597
  function frontOf(handle, ptyId, readMs) {
596
598
  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 };
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 };
600
604
  }
601
605
 
602
606
  /**
@@ -608,8 +612,8 @@ function frontOf(handle, ptyId, readMs) {
608
612
  * else is "cannot tell": for a moment after a harness quits, Orca can name
609
613
  * nothing and still see a child in front (read in the Orca 1.4.212 bundle).
610
614
  */
611
- function frontByOrca(handle) {
612
- 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;
613
617
  const evidence = seen?.foregroundProcessEvidence;
614
618
  if (evidence?.verdict !== 'live') {
615
619
  const why = typeof evidence?.reason === 'string' ? `: ${evidence.reason}` : '';
@@ -633,6 +637,29 @@ function frontByOrca(handle) {
633
637
  * here ends in "cannot tell" rather than in an error or a guess.
634
638
  */
635
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) {
636
663
  let pane;
637
664
  try {
638
665
  pane = orca(['diagnostics', 'memory'], { timeoutMs: readMs }).worktrees
@@ -646,15 +673,87 @@ function frontByPs(ptyId, readMs) {
646
673
  return { unreadable: `Orca would not give the pane's pid: ${error.message}` };
647
674
  }
648
675
  if (!Number.isInteger(pane) || pane <= 0) return { unreadable: 'Orca gave no pid for its pane' };
676
+ return { pane };
677
+ }
649
678
 
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' };
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';
653
682
 
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 };
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) };
658
757
  }
659
758
 
660
759
  /**
@@ -670,9 +769,10 @@ export function wordsOfProcess(pid) {
670
769
  return asked.stdout.trim().split(/\s+/);
671
770
  }
672
771
 
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' });
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 };
676
776
  if (asked.error || asked.status !== 0) return undefined;
677
777
  const line = /^\s*(\d+)\s+(\d+)\s+(-?\d+)\s+(.+?)\s*$/.exec(asked.stdout);
678
778
  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;