@assuredloop/orca-bot-kit 0.27.0 → 0.29.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/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
- import { installHook } from './hooks.js';
15
+ import { dropRemovedHooks, 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
@@ -145,8 +148,10 @@ export function prepareBots(bots, names, onlySession) {
145
148
  // so one written later would miss the session it was written for (ADR 0022),
146
149
  // and a bot folder the kit cannot write it into stops the run with nothing
147
150
  // opened anywhere. Only the harnesses a bot actually runs on; a bot with no
148
- // sessions gets none.
151
+ // sessions gets none. The hooks of commands the kit no longer has go from
152
+ // every hooks file the bot has, of a harness it has left too (#613).
149
153
  for (const { bot, home } of running) {
154
+ dropRemovedHooks(home);
150
155
  for (const harness of new Set(awake(sessionsOf(bot, onlySession)).map((session) => harnessOf(session, bot.harness)))) {
151
156
  installHook(home, harness, { bots, bot: bot.name });
152
157
  }
@@ -170,7 +175,7 @@ export function prepareBots(bots, names, onlySession) {
170
175
  export function botsNamed(bots, onlyBot) {
171
176
  const names = botNames(bots);
172
177
  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.`);
178
+ 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
179
  }
175
180
  if (onlyBot !== undefined && !names.includes(onlyBot)) {
176
181
  throw new Error(`there is no bot called ${onlyBot} in ${bots}. The bots there are: ${names.join(', ')}.`);
@@ -185,6 +190,23 @@ function refuseWhatCannotStart(bot, home, onlySession) {
185
190
  }
186
191
  }
187
192
 
193
+ /**
194
+ * Refuse the run before Orca is asked for anything when it would start a Pi
195
+ * session and Pi cannot start one: Pi does not run, can use no model, or
196
+ * cannot use the one a session names (#578). The user signs in to Pi on their
197
+ * own. A run with no Pi session does not ask Pi anything.
198
+ */
199
+ function refuseWhenPiIsNotReady(chosen, onlySession) {
200
+ const onPi = chosen.flatMap(({ bot }) => awake(sessionsOf(bot, onlySession))
201
+ .filter((session) => harnessOf(session, bot.harness) === 'pi')
202
+ .map((session) => ({ bot, session })));
203
+ if (onPi.length === 0) return;
204
+ const trouble = piTrouble(onPi.map(({ session }) => session.model).filter((model) => typeof model === 'string' && model.trim() !== ''));
205
+ if (trouble !== undefined) {
206
+ throw new Error(`${trouble} It would start ${onPi.map(({ bot, session }) => `${bot.name} ${session.name}`).join(', ')}.`);
207
+ }
208
+ }
209
+
188
210
  /**
189
211
  * The sessions that are not paused. A paused session is left out of a run
190
212
  * altogether, so what it is set to asks nothing of the ones that will start;
@@ -273,7 +295,10 @@ async function bringUpSession(bots, home, live, session, bot, title) {
273
295
  // Asked before the book is held, because it reads a folder of the harness's
274
296
  // own files: nothing slow happens under the lock.
275
297
  let which = whichConversation(book, home, bot, session, was, harness);
276
- const launchOf = (chosen) => launchFor(bots, bot, session, harness, home, workDir, chosen.resume, was?.address);
298
+ // A Pi session is told which conversation to be, and a new one is named here:
299
+ // the book is where that name is kept (ADR 0012, tech notes, section 6).
300
+ const fresh = harness === 'pi' ? randomUUID() : undefined;
301
+ const launchOf = (chosen) => launchFor(bots, bot, session, harness, home, workDir, chosen.resume, was?.address, fresh);
277
302
  let launch = launchOf(which);
278
303
 
279
304
  // A work dir is a plain folder, made for the session before it is told about
@@ -317,10 +342,16 @@ async function bringUpSession(bots, home, live, session, bot, title) {
317
342
  // gets, and trust-hooks asks what this one got (#506).
318
343
  let entry = { ...current.sessions[session.name], tab: made.tabId, launched, launched_with: session.extra_args ?? [], rules };
319
344
  if (rules === undefined) delete entry.rules;
345
+ // Only Claude Code answers to an address. One left from a Claude run
346
+ // before a move to another harness would draw mail to nobody (#565).
347
+ if (harness !== 'claude') delete entry.address;
320
348
  held = entry.session;
321
349
  // Before the line is typed, so the hook finds no id here and takes the one
322
350
  // 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');
351
+ if (which.noConversation !== undefined && held === which.noConversation) entry = setAside(entry, which);
352
+ // The id a new Pi conversation is started under, written before the line
353
+ // that starts it, as a hook writes Claude Code's and Codex's.
354
+ if (fresh !== undefined && launch.resume === undefined) entry = rememberSession(entry, fresh);
324
355
  current.sessions[session.name] = withUnclaimed(entry, which.unclaimed ?? []);
325
356
  forgetClaimed(current);
326
357
  });
@@ -331,13 +362,15 @@ async function bringUpSession(bots, home, live, session, bot, title) {
331
362
  // The old tab's hook can name another conversation while the tab is being
332
363
  // opened. A fresh start chosen before that is chosen again from the id the
333
364
  // 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 };
365
+ // same way (review of PR #310). A Pi session has no hook, and the id this run
366
+ // named for it is the one the book now holds.
367
+ if (fresh === undefined && which.noConversation !== undefined && typeof held === 'string' && held !== which.noConversation) {
368
+ which = hasConversation(harness, home, held) ? { resume: held } : notHere(harness, home, held);
337
369
  if (which.noConversation !== undefined) {
370
+ const chosen = which;
338
371
  await updateBook(home, (current) => {
339
372
  const entry = current.sessions[session.name];
340
- if (entry?.session === held) current.sessions[session.name] = forgetSession(entry, 'no conversation');
373
+ if (entry?.session === held) current.sessions[session.name] = setAside(entry, chosen);
341
374
  });
342
375
  }
343
376
  launch = launchOf(which);
@@ -402,7 +435,9 @@ async function bringUpSession(bots, home, live, session, bot, title) {
402
435
  promptReceived,
403
436
  promptFile: launch.promptFile,
404
437
  resumed: launch.resume !== undefined,
405
- noConversation: which.noConversation,
438
+ noConversation: which.ranOn === undefined ? which.noConversation : undefined,
439
+ harnessChanged: which.ranOn === undefined ? undefined : which.noConversation,
440
+ ranOn: which.ranOn,
406
441
  unclaimed: which.unclaimed,
407
442
  listLine: list?.typed,
408
443
  listLineTrouble: list?.why,
@@ -532,7 +567,7 @@ function lookAndType(home, tabId, line, until) {
532
567
  * The line that starts this session: resuming `resume`, or, with none, a fresh
533
568
  * start carrying its duty. Returns `{ resume, prompt, promptFile, command, address }`.
534
569
  */
535
- function launchFor(bots, bot, session, harness, home, workDir, resume, held) {
570
+ function launchFor(bots, bot, session, harness, home, workDir, resume, held, fresh) {
536
571
  const prompt = resume === undefined ? startPrompt(session, { home, workDir }) : undefined;
537
572
  // Anything longer than a line goes to the harness out of a file, rather than
538
573
  // through the tab's shell a character at a time.
@@ -554,6 +589,9 @@ function launchFor(bots, bot, session, harness, home, workDir, resume, held) {
554
589
  resume,
555
590
  address,
556
591
  kit: kitFolders(bots, bot.name),
592
+ // A new Pi conversation's id, and the name its tab and session list show,
593
+ // `<bot>.<session>` as a Codex thread is named (#480).
594
+ ...(harness === 'pi' ? { sessionId: resume === undefined ? fresh : undefined, name: `${bot.name}.${session.name}` } : {}),
557
595
  });
558
596
  // The session's mailbox first, from inside its own tab (#317), and the
559
597
  // harness whatever became of it.
@@ -831,7 +869,7 @@ function whichConversation(book, home, bot, session, was, harness) {
831
869
  // it: a session paused before its first turn is one. There is nothing to
832
870
  // resume, so it starts again with its duty, and the book keeps the id in the
833
871
  // history with the reason (#295).
834
- return { noConversation: was.session };
872
+ return notHere(harness, home, was.session);
835
873
  }
836
874
  // No tab: nothing has ever run for this session, so there is nothing to find.
837
875
  if (typeof was?.tab !== 'string') return {};
@@ -851,6 +889,22 @@ function whichConversation(book, home, bot, session, was, harness) {
851
889
  return { unclaimed: known.map((one) => one.id) };
852
890
  }
853
891
 
892
+ /**
893
+ * An id the book holds that this harness has no conversation behind, so the
894
+ * session starts fresh: `{ noConversation: id }`, and `ranOn`, the harness that
895
+ * does have it, when the session has moved to another harness since it ran
896
+ * (#565). That conversation is not lost, only not this harness's to resume.
897
+ */
898
+ function notHere(harness, home, id) {
899
+ const ranOn = HARNESSES.find((other) => other !== harness && hasConversation(other, home, id));
900
+ return ranOn === undefined ? { noConversation: id } : { noConversation: id, ranOn };
901
+ }
902
+
903
+ /** The entry with the id `which` set aside moved into its history, with why. */
904
+ const setAside = (entry, which) => (which.ranOn === undefined
905
+ ? forgetSession(entry, 'no conversation')
906
+ : forgetSession(entry, 'harness changed', { harness: which.ranOn }));
907
+
854
908
  /**
855
909
  * A bot's Orca project, made if it is not there yet, with `change` saying
856
910
  * whether this run `made` it, `renamed` it or left it as it was (undefined).
@@ -886,7 +940,7 @@ export const promptPath = (bots, bot, session) =>
886
940
  export const oldPromptPath = (bots, bot, session) =>
887
941
  path.join(`${bots}.prompts`, `${encodeURIComponent(bot)}.${encodeURIComponent(session)}.txt`);
888
942
 
889
- function entry(tab, { bot, name, created, running = false, blockedReason, promptReceived, promptFile, resumed, noConversation, unclaimed, noMailbox = false, listLine, listLineTrouble, notLaunched }) {
943
+ function entry(tab, { bot, name, created, running = false, blockedReason, promptReceived, promptFile, resumed, noConversation, harnessChanged, ranOn, unclaimed, noMailbox = false, listLine, listLineTrouble, notLaunched }) {
890
944
  const made = { bot, name, title: tab.title, tabId: tab.tabId, terminal: tab.handle, created, harnessStarted: running };
891
945
  // A tab whose shell was not ready for the launch line, and why: nothing was
892
946
  // typed into it (#498).
@@ -900,6 +954,9 @@ function entry(tab, { bot, name, created, running = false, blockedReason, prompt
900
954
  if (listLineTrouble !== undefined) made.listLineTrouble = listLineTrouble;
901
955
  // The id the book held that had no conversation behind it, now in the history.
902
956
  if (noConversation !== undefined) made.noConversation = noConversation;
957
+ // The id the book held that ran on another harness, the one this session
958
+ // has since moved from, now in the history (#565).
959
+ if (harnessChanged !== undefined) Object.assign(made, { harnessChanged, ranOn });
903
960
  // Orca's own words for what is on screen waiting to be answered, when it
904
961
  // gave any: the caller acts on it, the kit only passes it on.
905
962
  if (blockedReason !== undefined) made.blockedReason = blockedReason;