@assuredloop/orca-bot-kit 0.27.0 → 0.28.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
@@ -75,6 +75,9 @@ export async function makeTemp(bots, { tab, ...given }) {
75
75
 
76
76
  const maker = bot.sessions.find((session) => session.name === caller.session);
77
77
  const { settings, chosen } = settingsFor(bot, maker, role?.option ?? {}, given);
78
+ if (chosen.harness.value === 'pi') {
79
+ throw new Error(`${name} would run on Pi, and a temporary session on Pi comes in a later slice of #547. Make it on claude or codex. Nothing was made.`);
80
+ }
78
81
  refuseWiderApproval(bots, caller, bot, maker, given.approval);
79
82
  refuseApprovalArgs(chosen.harness.value, given.extra_args);
80
83
  settings.name = name;
@@ -83,27 +86,42 @@ export async function makeTemp(bots, { tab, ...given }) {
83
86
  else settings.prompt_file = given.prompt_file ?? role.prompt_file;
84
87
  settings.work_dir = `work/${name}`;
85
88
 
86
- const written = bot.sessions.some((session) => session.name === name) ? undefined : writePromptFirst(bots, caller, settings);
87
89
  // bot.yaml and the book are written under the book's lock, so that a second
88
90
  // make from the same maker waits, and then sees this one (#464). addSession
89
91
  // refuses a name the bot already has, and a setting that will not work,
90
92
  // before it writes anything.
91
93
  const made = new Date().toISOString();
92
94
  let locked = false;
95
+ let written;
93
96
  let added;
94
97
  try {
95
98
  await updateBook(caller.home, (book) => {
96
99
  locked = true;
97
100
  refuseNested(bots, caller, book);
98
101
  if (role !== undefined) refuseCap(caller, role, book);
99
- added = addSession(bots, caller.bot, settings);
102
+ // The prompt file is written under the lock too, and only for a name the
103
+ // bot does not have yet: a make of the same name that waited on this one
104
+ // writes nothing, so it has nothing of this one's to remove (#572).
105
+ written = readBot(caller.home, caller.bot).sessions.some((session) => session.name === name) ? undefined : writePromptFirst(bots, caller, settings);
106
+ try {
107
+ added = addSession(bots, caller.bot, settings);
108
+ } catch (error) {
109
+ // Taken back while the lock is held: once it is let go, another make
110
+ // of this name may write the same file.
111
+ const left = removeWritten(written);
112
+ if (left !== undefined) throw promptLeft(error.message.replace(/ ?Nothing was made\.$/, ''), written, left);
113
+ throw error;
114
+ }
100
115
  const kind = role === undefined ? {} : { role: role.name, option: role.option.name };
101
116
  book.sessions[name] = { ...book.sessions[name], temporary: { maker: caller.session, made, ...kind } };
102
117
  });
103
118
  } catch (error) {
104
- // A refusal under the lock has written nothing but the prompt file.
105
- const refused = locked && added === undefined;
119
+ // A refusal under the lock has written nothing, or taken its prompt file back.
120
+ if (locked && added === undefined) throw error;
106
121
  const failed = `${caller.bot}/${name} could not be recorded in the book as temporary (${error.message})`;
122
+ // The prompt file goes while the session is still in bot.yaml, where it
123
+ // keeps any other make of its name from writing that file.
124
+ const left = removeWritten(written);
107
125
  // A session in bot.yaml that the book does not call temporary would be a
108
126
  // long-lived one to everything that reads it, its maker's retire included,
109
127
  // so it is taken back off rather than left that way.
@@ -114,15 +132,8 @@ export async function makeTemp(bots, { tab, ...given }) {
114
132
  throw new Error(`${failed}, and taking it back off bot.yaml failed too (${undo.message}). It is in bot.yaml as a session the book does not call temporary. Take it off with ${shellWord(ownCli())} retire --bots ${shellWord(bots)} --bot ${caller.bot} --session ${name}`);
115
133
  }
116
134
  }
117
- // Off bot.yaml, nothing is made; a prompt file that stays is only a file,
118
- // and nothing reads it (#393).
119
- const why = refused ? error.message.replace(/ ?Nothing was made\.$/, '') : `${failed}${added === undefined ? '.' : ', so it was taken back off bot.yaml.'}`;
120
- try {
121
- if (written !== undefined) rmSync(written, { force: true });
122
- } catch (left) {
123
- throw new Error(`${why} Nothing was made. Its start prompt ${written} could not be removed (${left.message}), and nothing reads it. Remove it with rm ${shellWord(written)}`);
124
- }
125
- if (refused) throw error;
135
+ const why = `${failed}${added === undefined ? '.' : ', so it was taken back off bot.yaml.'}`;
136
+ if (left !== undefined) throw promptLeft(why, written, left);
126
137
  throw new Error(`${why} Nothing was made. Run this again once the book can be written.`);
127
138
  }
128
139
 
@@ -178,7 +189,7 @@ function settingsFor(bot, maker, option, given) {
178
189
  /**
179
190
  * Refuse an approval wider than the widest of the maker's own and the bot's
180
191
  * `temp_approval`, which only the user's yes through `permission approval
181
- * --temps` widens (ADR 0041).
192
+ * --temps` widens (ADR 0051).
182
193
  */
183
194
  function refuseWiderApproval(bots, caller, bot, maker, asked) {
184
195
  if (asked === undefined) return;
@@ -316,6 +327,24 @@ function writePromptFirst(bots, caller, settings) {
316
327
  return file;
317
328
  }
318
329
 
330
+ /** Remove the start prompt's file `written`, when there is one, and answer why it could not be. */
331
+ function removeWritten(written) {
332
+ try {
333
+ if (written !== undefined) rmSync(written, { force: true });
334
+ } catch (error) {
335
+ return error;
336
+ }
337
+ return undefined;
338
+ }
339
+
340
+ /**
341
+ * The refusal `why` for a make whose start prompt's file `written` could not
342
+ * be removed (`left`). Off bot.yaml, nothing is made; a prompt file that stays
343
+ * is only a file, and nothing reads it (#393).
344
+ */
345
+ const promptLeft = (why, written, left) =>
346
+ new Error(`${why} Nothing was made. Its start prompt ${written} could not be removed (${left.message}), and nothing reads it. Remove it with rm ${shellWord(written)}`);
347
+
319
348
  /**
320
349
  * Retire a temporary session the caller made. Returns what `retireSession`
321
350
  * does, with the maker.
@@ -506,10 +535,11 @@ const TEACH_FOOT = /Enter to .*· Esc to cancel/;
506
535
  * captured live, with what answers it. Rows are compared trimmed, the pointer
507
536
  * `❯` taken out, so the pointer may sit on any one of them.
508
537
  *
509
- * Claude Code 2.1.283's form (#261's live test, #416) is answered with Esc,
510
- * which cancels it (Not now), never a return, which is Continue. Claude Code
511
- * 2.1.289's numbered list (#489's live run 1) is answered with "2. Not now",
512
- * never "1. Yes" or "3. Don't show again" (the architect's ruling on #489).
538
+ * Claude Code 2.1.283's form (#261's live test, #416) and 2.1.302's (#604) are
539
+ * answered with Esc, which cancels them (Not now), never a return, which is
540
+ * Continue. Claude Code 2.1.289's numbered list (#489's live run 1) is
541
+ * answered with "2. Not now", never "1. Yes" or "3. Don't show again" (the
542
+ * architect's ruling on #489).
513
543
  */
514
544
  const TEACH_SHAPES = [
515
545
  {
@@ -524,6 +554,21 @@ const TEACH_SHAPES = [
524
554
  'Continue',
525
555
  '←/→ to change usage · Enter to continue · Esc to cancel',
526
556
  ],
557
+ escape: true,
558
+ },
559
+ {
560
+ name: 'the 2.1.302 form',
561
+ rows: [
562
+ TEACH_TITLE,
563
+ 'Claude Code reads this project, your recent Claude sessions, and optionally your shell history and other',
564
+ 'repositories. Claude analyzes this data and customizes auto mode to make better decisions.',
565
+ 'How you use Claude here Mixed',
566
+ 'Also scan shell history false',
567
+ 'Also scan your other repos false',
568
+ 'Continue',
569
+ '←/→ to change · Enter to continue · Esc to cancel',
570
+ ],
571
+ escape: true,
527
572
  },
528
573
  {
529
574
  name: 'the 2.1.289 list',
@@ -597,7 +642,7 @@ const wholeScreen = (rows) => rows.filter((row) => row.trim() !== '').map((row)
597
642
  /**
598
643
  * Answer the first-run screen of a temporary session the caller made (#489):
599
644
  * Claude Code's Teach auto mode screen, in one of the shapes the kit knows,
600
- * matched exactly. The 2.1.283 form gets Esc. The 2.1.289 list gets arrows to
645
+ * matched exactly. The 2.1.283 and 2.1.302 forms get Esc. The 2.1.289 list gets arrows to
601
646
  * "2. Not now" where needed, a second look that the pointer is there and the
602
647
  * list is otherwise as it was, and only then a return. Anything else is refused with
603
648
  * nothing typed. Like `trustHooks`, it is one command a permission rule of its
@@ -628,7 +673,7 @@ function answerTeach(target, kind) {
628
673
  const hooks = rows.some((row) => row.includes(HOOKS_REVIEW)) ? ` A Codex session's "${HOOKS_REVIEW}" is answered with ${kind} trust-hooks.` : '';
629
674
  throw new Error(`${run}'s screen is not one ${kind} answer answers: ${seen.misfit}.${hooks} ${nothing} It shows:\n${wholeScreen(rows)}`);
630
675
  }
631
- if (seen.shape === TEACH_SHAPES[0]) {
676
+ if (seen.shape.escape) {
632
677
  send('\x1b');
633
678
  return 'Esc (Not now)';
634
679
  }
package/src/up.js CHANGED
@@ -4,16 +4,18 @@
4
4
  // conversation and Orca's resume record with it. Run it twice and the second
5
5
  // run does nothing.
6
6
 
7
+ import { randomUUID } from 'node:crypto';
7
8
  import { existsSync, mkdirSync, realpathSync, writeFileSync } from 'node:fs';
8
9
  import path from 'node:path';
9
10
  import { setTimeout as pause } from 'node:timers/promises';
10
11
 
11
- import { forgetClaimed, forgetSession, MAILBOX_WAIT_MS, readBook, sessionIdsIn, tabIdsIn, takeLineTurn, takeMailboxTurn, TYPING_HELD, TYPING_WAIT_MS, updateBook, withUnclaimed } from './book.js';
12
+ import { forgetClaimed, forgetSession, MAILBOX_WAIT_MS, readBook, rememberSession, sessionIdsIn, tabIdsIn, takeLineTurn, takeMailboxTurn, TYPING_HELD, TYPING_WAIT_MS, updateBook, withUnclaimed } from './book.js';
12
13
  import { botDir, botNames, displayName, projectName, readBot } from './bot.js';
13
14
  import { conversationsIn, hasConversation, heldAsUserTurn, transcriptsIn } from './conversations.js';
14
15
  import { installHook } from './hooks.js';
15
16
  import { writePermissions } from './permissions.js';
16
- import { addressOf, harnessOf, isAddressOf, isShortPrompt, kitFolders, launchCommand, mailboxStep, reachesMail, sessionTrouble, startPrompt, workDirOf } from './launch.js';
17
+ import { piTrouble } from './pi.js';
18
+ import { addressOf, HARNESSES, harnessOf, isAddressOf, isShortPrompt, kitFolders, launchCommand, mailboxStep, reachesMail, sessionTrouble, startPrompt, workDirOf } from './launch.js';
17
19
  import { asFolderProject, coordinatorOf, findProject, harnessInTab, makeMailbox, makeProject, openTab, QUESTION_ON_SCREEN, retitleTab, runMissing, screenRows, shellInTab, tabs, tabToTypeInto, TERMINAL_ENV, TIMED_OUT, tellWindow, typeIntoTab, useMailbox } from './orca.js';
18
20
  import { TAB_ENV } from './record.js';
19
21
  import { buildAgents, rulesStamp } from './rules.js';
@@ -110,6 +112,7 @@ export function prepareBots(bots, names, onlySession) {
110
112
  return { home, bot: readBot(home, name) };
111
113
  });
112
114
  for (const { bot, home } of chosen) refuseWhatCannotStart(bot, home, onlySession);
115
+ refuseWhenPiIsNotReady(chosen, onlySession);
113
116
 
114
117
  // Every bot's AGENTS.md is built before Orca is asked for anything, because a
115
118
  // session reads that file as it starts: a build landing after the tab was
@@ -170,7 +173,7 @@ export function prepareBots(bots, names, onlySession) {
170
173
  export function botsNamed(bots, onlyBot) {
171
174
  const names = botNames(bots);
172
175
  if (names.length === 0) {
173
- throw new Error(`there are no bots in ${bots} yet: make one with obk bot create --bots <path> --name <name> --harness claude|codex.`);
176
+ throw new Error(`there are no bots in ${bots} yet: make one with obk bot create --bots <path> --name <name> --harness claude|codex|pi.`);
174
177
  }
175
178
  if (onlyBot !== undefined && !names.includes(onlyBot)) {
176
179
  throw new Error(`there is no bot called ${onlyBot} in ${bots}. The bots there are: ${names.join(', ')}.`);
@@ -185,6 +188,23 @@ function refuseWhatCannotStart(bot, home, onlySession) {
185
188
  }
186
189
  }
187
190
 
191
+ /**
192
+ * Refuse the run before Orca is asked for anything when it would start a Pi
193
+ * session and Pi cannot start one: Pi does not run, can use no model, or
194
+ * cannot use the one a session names (#578). The user signs in to Pi on their
195
+ * own. A run with no Pi session does not ask Pi anything.
196
+ */
197
+ function refuseWhenPiIsNotReady(chosen, onlySession) {
198
+ const onPi = chosen.flatMap(({ bot }) => awake(sessionsOf(bot, onlySession))
199
+ .filter((session) => harnessOf(session, bot.harness) === 'pi')
200
+ .map((session) => ({ bot, session })));
201
+ if (onPi.length === 0) return;
202
+ const trouble = piTrouble(onPi.map(({ session }) => session.model).filter((model) => typeof model === 'string' && model.trim() !== ''));
203
+ if (trouble !== undefined) {
204
+ throw new Error(`${trouble} It would start ${onPi.map(({ bot, session }) => `${bot.name} ${session.name}`).join(', ')}.`);
205
+ }
206
+ }
207
+
188
208
  /**
189
209
  * The sessions that are not paused. A paused session is left out of a run
190
210
  * altogether, so what it is set to asks nothing of the ones that will start;
@@ -273,7 +293,10 @@ async function bringUpSession(bots, home, live, session, bot, title) {
273
293
  // Asked before the book is held, because it reads a folder of the harness's
274
294
  // own files: nothing slow happens under the lock.
275
295
  let which = whichConversation(book, home, bot, session, was, harness);
276
- const launchOf = (chosen) => launchFor(bots, bot, session, harness, home, workDir, chosen.resume, was?.address);
296
+ // A Pi session is told which conversation to be, and a new one is named here:
297
+ // the book is where that name is kept (ADR 0012, tech notes, section 6).
298
+ const fresh = harness === 'pi' ? randomUUID() : undefined;
299
+ const launchOf = (chosen) => launchFor(bots, bot, session, harness, home, workDir, chosen.resume, was?.address, fresh);
277
300
  let launch = launchOf(which);
278
301
 
279
302
  // A work dir is a plain folder, made for the session before it is told about
@@ -317,10 +340,16 @@ async function bringUpSession(bots, home, live, session, bot, title) {
317
340
  // gets, and trust-hooks asks what this one got (#506).
318
341
  let entry = { ...current.sessions[session.name], tab: made.tabId, launched, launched_with: session.extra_args ?? [], rules };
319
342
  if (rules === undefined) delete entry.rules;
343
+ // Only Claude Code answers to an address. One left from a Claude run
344
+ // before a move to another harness would draw mail to nobody (#565).
345
+ if (harness !== 'claude') delete entry.address;
320
346
  held = entry.session;
321
347
  // Before the line is typed, so the hook finds no id here and takes the one
322
348
  // it reports for a start rather than a clear, which would tell the duty twice.
323
- if (which.noConversation !== undefined && held === which.noConversation) entry = forgetSession(entry, 'no conversation');
349
+ if (which.noConversation !== undefined && held === which.noConversation) entry = setAside(entry, which);
350
+ // The id a new Pi conversation is started under, written before the line
351
+ // that starts it, as a hook writes Claude Code's and Codex's.
352
+ if (fresh !== undefined && launch.resume === undefined) entry = rememberSession(entry, fresh);
324
353
  current.sessions[session.name] = withUnclaimed(entry, which.unclaimed ?? []);
325
354
  forgetClaimed(current);
326
355
  });
@@ -331,13 +360,15 @@ async function bringUpSession(bots, home, live, session, bot, title) {
331
360
  // The old tab's hook can name another conversation while the tab is being
332
361
  // opened. A fresh start chosen before that is chosen again from the id the
333
362
  // book holds now: resumed if the harness has it, and otherwise set aside the
334
- // same way (review of PR #310).
335
- if (which.noConversation !== undefined && typeof held === 'string' && held !== which.noConversation) {
336
- which = hasConversation(harness, home, held) ? { resume: held } : { noConversation: held };
363
+ // same way (review of PR #310). A Pi session has no hook, and the id this run
364
+ // named for it is the one the book now holds.
365
+ if (fresh === undefined && which.noConversation !== undefined && typeof held === 'string' && held !== which.noConversation) {
366
+ which = hasConversation(harness, home, held) ? { resume: held } : notHere(harness, home, held);
337
367
  if (which.noConversation !== undefined) {
368
+ const chosen = which;
338
369
  await updateBook(home, (current) => {
339
370
  const entry = current.sessions[session.name];
340
- if (entry?.session === held) current.sessions[session.name] = forgetSession(entry, 'no conversation');
371
+ if (entry?.session === held) current.sessions[session.name] = setAside(entry, chosen);
341
372
  });
342
373
  }
343
374
  launch = launchOf(which);
@@ -402,7 +433,9 @@ async function bringUpSession(bots, home, live, session, bot, title) {
402
433
  promptReceived,
403
434
  promptFile: launch.promptFile,
404
435
  resumed: launch.resume !== undefined,
405
- noConversation: which.noConversation,
436
+ noConversation: which.ranOn === undefined ? which.noConversation : undefined,
437
+ harnessChanged: which.ranOn === undefined ? undefined : which.noConversation,
438
+ ranOn: which.ranOn,
406
439
  unclaimed: which.unclaimed,
407
440
  listLine: list?.typed,
408
441
  listLineTrouble: list?.why,
@@ -532,7 +565,7 @@ function lookAndType(home, tabId, line, until) {
532
565
  * The line that starts this session: resuming `resume`, or, with none, a fresh
533
566
  * start carrying its duty. Returns `{ resume, prompt, promptFile, command, address }`.
534
567
  */
535
- function launchFor(bots, bot, session, harness, home, workDir, resume, held) {
568
+ function launchFor(bots, bot, session, harness, home, workDir, resume, held, fresh) {
536
569
  const prompt = resume === undefined ? startPrompt(session, { home, workDir }) : undefined;
537
570
  // Anything longer than a line goes to the harness out of a file, rather than
538
571
  // through the tab's shell a character at a time.
@@ -554,6 +587,9 @@ function launchFor(bots, bot, session, harness, home, workDir, resume, held) {
554
587
  resume,
555
588
  address,
556
589
  kit: kitFolders(bots, bot.name),
590
+ // A new Pi conversation's id, and the name its tab and session list show,
591
+ // `<bot>.<session>` as a Codex thread is named (#480).
592
+ ...(harness === 'pi' ? { sessionId: resume === undefined ? fresh : undefined, name: `${bot.name}.${session.name}` } : {}),
557
593
  });
558
594
  // The session's mailbox first, from inside its own tab (#317), and the
559
595
  // harness whatever became of it.
@@ -831,7 +867,7 @@ function whichConversation(book, home, bot, session, was, harness) {
831
867
  // it: a session paused before its first turn is one. There is nothing to
832
868
  // resume, so it starts again with its duty, and the book keeps the id in the
833
869
  // history with the reason (#295).
834
- return { noConversation: was.session };
870
+ return notHere(harness, home, was.session);
835
871
  }
836
872
  // No tab: nothing has ever run for this session, so there is nothing to find.
837
873
  if (typeof was?.tab !== 'string') return {};
@@ -851,6 +887,22 @@ function whichConversation(book, home, bot, session, was, harness) {
851
887
  return { unclaimed: known.map((one) => one.id) };
852
888
  }
853
889
 
890
+ /**
891
+ * An id the book holds that this harness has no conversation behind, so the
892
+ * session starts fresh: `{ noConversation: id }`, and `ranOn`, the harness that
893
+ * does have it, when the session has moved to another harness since it ran
894
+ * (#565). That conversation is not lost, only not this harness's to resume.
895
+ */
896
+ function notHere(harness, home, id) {
897
+ const ranOn = HARNESSES.find((other) => other !== harness && hasConversation(other, home, id));
898
+ return ranOn === undefined ? { noConversation: id } : { noConversation: id, ranOn };
899
+ }
900
+
901
+ /** The entry with the id `which` set aside moved into its history, with why. */
902
+ const setAside = (entry, which) => (which.ranOn === undefined
903
+ ? forgetSession(entry, 'no conversation')
904
+ : forgetSession(entry, 'harness changed', { harness: which.ranOn }));
905
+
854
906
  /**
855
907
  * A bot's Orca project, made if it is not there yet, with `change` saying
856
908
  * whether this run `made` it, `renamed` it or left it as it was (undefined).
@@ -886,7 +938,7 @@ export const promptPath = (bots, bot, session) =>
886
938
  export const oldPromptPath = (bots, bot, session) =>
887
939
  path.join(`${bots}.prompts`, `${encodeURIComponent(bot)}.${encodeURIComponent(session)}.txt`);
888
940
 
889
- function entry(tab, { bot, name, created, running = false, blockedReason, promptReceived, promptFile, resumed, noConversation, unclaimed, noMailbox = false, listLine, listLineTrouble, notLaunched }) {
941
+ function entry(tab, { bot, name, created, running = false, blockedReason, promptReceived, promptFile, resumed, noConversation, harnessChanged, ranOn, unclaimed, noMailbox = false, listLine, listLineTrouble, notLaunched }) {
890
942
  const made = { bot, name, title: tab.title, tabId: tab.tabId, terminal: tab.handle, created, harnessStarted: running };
891
943
  // A tab whose shell was not ready for the launch line, and why: nothing was
892
944
  // typed into it (#498).
@@ -900,6 +952,9 @@ function entry(tab, { bot, name, created, running = false, blockedReason, prompt
900
952
  if (listLineTrouble !== undefined) made.listLineTrouble = listLineTrouble;
901
953
  // The id the book held that had no conversation behind it, now in the history.
902
954
  if (noConversation !== undefined) made.noConversation = noConversation;
955
+ // The id the book held that ran on another harness, the one this session
956
+ // has since moved from, now in the history (#565).
957
+ if (harnessChanged !== undefined) Object.assign(made, { harnessChanged, ranOn });
903
958
  // Orca's own words for what is on screen waiting to be answered, when it
904
959
  // gave any: the caller acts on it, the kit only passes it on.
905
960
  if (blockedReason !== undefined) made.blockedReason = blockedReason;