@assuredloop/orca-bot-kit 0.23.0 → 0.25.0

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/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
@@ -28,7 +29,7 @@ import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
28
29
  import path from 'node:path';
29
30
  import { setTimeout as pause } from 'node:timers/promises';
30
31
 
31
- import { readBook, updateBook } from './book.js';
32
+ import { readBook, takeLineTurn, TYPING_HELD, TYPING_WAIT_MS, updateBook } from './book.js';
32
33
  import { addSession, dropSession, NAME, readBot, tempRoles } from './bot.js';
33
34
  import { DEFAULT_APPROVAL, harnessOf, isShortPrompt, ownCli, shellWord, startPrompt, workDirOf } from './launch.js';
34
35
  import { sessionInTab } from './message.js';
@@ -348,30 +349,18 @@ 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
  }
351
- const tabId = readBook(caller.home).sessions[name]?.tab;
352
- const handle = tabId === undefined ? undefined : tabs(caller.home).find((one) => one.tabId === tabId)?.handle;
353
- if (handle === undefined) {
354
- throw new Error(`${run} has no tab open in Orca, so there is no review of its to answer. ${nothing}`);
355
- }
356
- const seen = screenRows(handle);
357
- if (seen.rows === undefined) {
358
- throw new Error(`${run}'s screen could not be read (${seen.unreadable}), so the kit cannot tell whether its hooks review is there. ${nothing}`);
359
- }
360
- const keys = keysToTrustAll(seen.rows);
361
- if (keys === undefined) {
362
- 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}`);
363
- }
364
- // The return is inside the text: `--enter` would be a second key.
365
- orca(['terminal', 'send', '--terminal', handle, '--text', keys]);
366
- for (let waited = 0; ; waited += 500) {
367
- const after = screenRows(handle);
368
- if (after.rows !== undefined && !after.rows.some((row) => row.includes(HOOKS_REVIEW))) break;
369
- if (waited >= REVIEW_GONE_MS) {
370
- const still = after.rows === undefined ? `its screen could not be read again (${after.unreadable})` : `its screen still shows "${HOOKS_REVIEW}"`;
371
- throw new Error(`${run}: "${TRUST_ALL}" was chosen on its hooks review, but ${REVIEW_GONE_MS / 1000} seconds later ${still}. Look at its tab.`);
372
- }
373
- await pause(500);
374
- }
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
+ });
375
364
  return { bot: caller.bot, session: name, maker: caller.session };
376
365
  }
377
366
 
@@ -396,6 +385,198 @@ function shown(rows) {
396
385
  return text.length > 500 ? `${text.slice(0, 500)}…` : text;
397
386
  }
398
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.';
400
+ const tabId = readBook(caller.home).sessions[name]?.tab;
401
+ const handle = tabId === undefined ? undefined : tabs(caller.home).find((one) => one.tabId === tabId)?.handle;
402
+ if (handle === undefined) {
403
+ throw new Error(`${run} has no tab open in Orca, so there is no ${what} of its to answer. ${nothing}`);
404
+ }
405
+ const turn = takeLineTurn(caller.home, name, TYPING_WAIT_MS);
406
+ if (turn === undefined) throw new Error(`${run}: ${TYPING_HELD}.`);
407
+ let answered;
408
+ try {
409
+ const seen = screenRows(handle);
410
+ if (seen.rows === undefined) {
411
+ throw new Error(`${run}'s screen could not be read (${seen.unreadable}), so the kit cannot tell whether its ${what} is there. ${nothing}`);
412
+ }
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
+ });
418
+ } finally {
419
+ turn.release();
420
+ }
421
+ for (let waited = 0; ; waited += 500) {
422
+ const after = screenRows(handle);
423
+ if (after.rows !== undefined && !after.rows.some((row) => row.includes(mark))) return answered;
424
+ if (waited >= REVIEW_GONE_MS) {
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.`);
427
+ }
428
+ await pause(500);
429
+ }
430
+ }
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
+
438
+ /**
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).
447
+ */
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 ')}` };
526
+ }
527
+
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 };
578
+ }
579
+
399
580
  /**
400
581
  * The session this runs in, as the book knows it, with its home and whether it
401
582
  * is itself temporary.
package/src/up.js CHANGED
@@ -8,7 +8,7 @@ import { existsSync, mkdirSync, realpathSync, writeFileSync } from 'node:fs';
8
8
  import path from 'node:path';
9
9
  import { setTimeout as pause } from 'node:timers/promises';
10
10
 
11
- import { forgetClaimed, forgetSession, MAILBOX_WAIT_MS, readBook, sessionIdsIn, tabIdsIn, takeMailboxTurn, updateBook, withUnclaimed } from './book.js';
11
+ import { forgetClaimed, forgetSession, MAILBOX_WAIT_MS, readBook, sessionIdsIn, tabIdsIn, takeLineTurn, takeMailboxTurn, TYPING_HELD, TYPING_WAIT_MS, updateBook, withUnclaimed } from './book.js';
12
12
  import { botDir, botNames, displayName, projectName, readBot } from './bot.js';
13
13
  import { conversationsIn, hasConversation, heldAsUserTurn, transcriptsIn } from './conversations.js';
14
14
  import { installHook } from './hooks.js';
@@ -374,7 +374,7 @@ async function bringUpSession(bots, home, live, session, bot, title) {
374
374
  // 0.157.1 (#226). So it is given one line, through the same gate as a mail
375
375
  // notice, which the owner allowed. A fresh start's first turn is its prompt.
376
376
  const list = harness === 'codex' && launch.resume !== undefined && tui.running === true
377
- ? await typeListLine(home, made.tabId)
377
+ ? await typeListLine(home, session.name, made.tabId)
378
378
  : undefined;
379
379
 
380
380
  return entry(made, {
@@ -408,43 +408,65 @@ const LIST_LOOK_MS = 2000;
408
408
  const LIST_ASK_MS = 500;
409
409
 
410
410
  /**
411
- * Type `line`, LIST_LINE unless told otherwise, into the tab `tabId` once the
412
- * gate every typed line goes through lets it: `{ typed: true }`, or
413
- * `{ typed: false, why }` with the gate's last reason once LIST_WAIT_MS is up.
414
- * Nothing goes into a question, a form or a menu (#329, #416), nor into a tab
415
- * the kit cannot tell about.
411
+ * Type `line`, LIST_LINE unless told otherwise, into the tab `tabId` of
412
+ * `session` once the gate every typed line goes through lets it:
413
+ * `{ typed: true }`, or `{ typed: false, why }` with the gate's last reason
414
+ * once LIST_WAIT_MS is up. Nothing goes into a question, a form or a menu
415
+ * (#329, #416), nor into a tab the kit cannot tell about. Each look and its
416
+ * line are taken in the session's turn for a line (#482), unless the caller
417
+ * says it holds the session's typing turn already (`held`).
416
418
  */
417
- export async function typeListLine(home, tabId, line = LIST_LINE) {
419
+ export async function typeListLine(home, session, tabId, { line = LIST_LINE, held = false } = {}) {
418
420
  const until = Date.now() + LIST_WAIT_MS;
419
421
  for (;;) {
420
- let found;
421
- try {
422
- // Each look waits no longer than what is left of the wait.
423
- found = tabToTypeInto(home, tabId, Math.max(1, Math.min(LIST_LOOK_MS, until - Date.now())));
424
- } catch (error) {
425
- return { typed: false, why: `Orca would not say whether it may be typed into: ${error.message}` };
426
- }
427
- // A yes that comes after the wait is too late: the wait is a cutoff, not a
428
- // count of looks (review of PR #421).
429
- if (found.handle !== undefined && Date.now() > until) {
430
- return { typed: false, why: `the ${LIST_WAIT_MS / 1000} s wait for it ran out before the kit could tell it may be typed into` };
431
- }
432
- if (found.handle !== undefined) {
422
+ let turn;
423
+ if (!held) {
433
424
  try {
434
- typeIntoTab(found.handle, line);
435
- return { typed: true };
425
+ turn = takeLineTurn(home, session, TYPING_WAIT_MS);
436
426
  } catch (error) {
437
- return { typed: false, why: `Orca refused the line: ${error.message}` };
427
+ return { typed: false, why: error.message };
438
428
  }
429
+ if (turn === undefined) return { typed: false, why: TYPING_HELD };
439
430
  }
440
- if (Date.now() >= until) {
441
- const last = found.blocked !== undefined ? `it is waiting on ${found.blocked}` : found.unsure ?? 'no harness is running in it';
442
- return { typed: false, why: `the ${LIST_WAIT_MS / 1000} s wait for it ran out: ${last}` };
431
+ try {
432
+ const done = lookAndType(home, tabId, line, until);
433
+ if (done !== undefined) return done;
434
+ } finally {
435
+ turn?.release();
443
436
  }
444
437
  await pause(LIST_ASK_MS);
445
438
  }
446
439
  }
447
440
 
441
+ /** One look of typeListLine's, and its line when the gate lets it: what it returns, or undefined to look again. */
442
+ function lookAndType(home, tabId, line, until) {
443
+ let found;
444
+ try {
445
+ // Each look waits no longer than what is left of the wait.
446
+ found = tabToTypeInto(home, tabId, Math.max(1, Math.min(LIST_LOOK_MS, until - Date.now())));
447
+ } catch (error) {
448
+ return { typed: false, why: `Orca would not say whether it may be typed into: ${error.message}` };
449
+ }
450
+ // A yes that comes after the wait is too late: the wait is a cutoff, not a
451
+ // count of looks (review of PR #421).
452
+ if (found.handle !== undefined && Date.now() > until) {
453
+ return { typed: false, why: `the ${LIST_WAIT_MS / 1000} s wait for it ran out before the kit could tell it may be typed into` };
454
+ }
455
+ if (found.handle !== undefined) {
456
+ try {
457
+ typeIntoTab(found.handle, line);
458
+ return { typed: true };
459
+ } catch (error) {
460
+ return { typed: false, why: `Orca refused the line: ${error.message}` };
461
+ }
462
+ }
463
+ if (Date.now() >= until) {
464
+ const last = found.blocked !== undefined ? `it is waiting on ${found.blocked}` : found.unsure ?? 'no harness is running in it';
465
+ return { typed: false, why: `the ${LIST_WAIT_MS / 1000} s wait for it ran out: ${last}` };
466
+ }
467
+ return undefined;
468
+ }
469
+
448
470
  /**
449
471
  * The line that starts this session: resuming `resume`, or, with none, a fresh
450
472
  * start carrying its duty. Returns `{ resume, prompt, promptFile, command, address }`.