@assuredloop/orca-bot-kit 0.26.1 → 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/skills.js CHANGED
@@ -24,11 +24,11 @@ import { fileURLToPath } from 'node:url';
24
24
  import { parse, parseDocument, stringify } from 'yaml';
25
25
 
26
26
  import { bookFile, readBook, takeLineTurn, TYPING_HELD, TYPING_WAIT_MS } from './book.js';
27
- import { botDir, botNames, changesExactly, leadsOutside, readBot, YAML_OUT } from './bot.js';
27
+ import { botDir, botNames, changesExactly, leadsOutside, readBot, refuseOutside, withBotTurn, writeWhole, YAML_OUT } from './bot.js';
28
28
  import { harnessOf, ownCli, shellWord } from './launch.js';
29
- import { orcaCli, orcaTrouble, tabToTypeInto, typeIntoTab } from './orca.js';
29
+ import { orcaTrouble, tabToTypeInto, typeIntoTab } from './orca.js';
30
30
  import { listIn } from './rules.js';
31
- import { cloneDir, isCloned, readSources, skillsIn, wrongClone } from './sources.js';
31
+ import { cloneDir, isCloned, leavesClone, readSources, skillsIn, wrongClone } from './sources.js';
32
32
 
33
33
  /** The kit's own skills, inside the installed package. */
34
34
  const KIT_SKILLS = fileURLToPath(new URL('../skills', import.meta.url));
@@ -84,25 +84,28 @@ export function addSkill(bots, bot, ref) {
84
84
  throw new Error(`there is no bot called ${bot} in ${bots}: ${file} is not there. Create it with obk bot create.`);
85
85
  }
86
86
 
87
- if (listIn(file, 'skills').includes(ref)) return { bot, home, skill: ref, state: 'there' };
88
-
89
- const was = readFileSync(file, 'utf8');
90
- const doc = parseDocument(was);
91
- const listed = doc.get('skills', true);
92
- if (listed?.items === undefined) {
93
- // No list to add to: an empty `skills:`, or no skills key at all.
94
- doc.set('skills', [ref]);
95
- } else {
96
- listed.flow = false;
97
- doc.addIn(['skills'], ref);
98
- }
87
+ return withBotTurn(home, () => {
88
+ if (listIn(file, 'skills').includes(ref)) return { bot, home, skill: ref, state: 'there' };
89
+ refuseOutside(home, file);
99
90
 
100
- const text = doc.toString(YAML_OUT);
101
- if (!changesExactly(was, text, (had) => ({ ...had, skills: [...(had.skills ?? []), ref] }))) {
102
- throw new Error(`${file} cannot have a skill added to it without changing something else in it, so nothing was written. Add ${ref} to its skills list by hand.`);
103
- }
104
- writeFileSync(file, text);
105
- return { bot, home, skill: ref, state: 'added' };
91
+ const was = readFileSync(file, 'utf8');
92
+ const doc = parseDocument(was);
93
+ const listed = doc.get('skills', true);
94
+ if (listed?.items === undefined) {
95
+ // No list to add to: an empty `skills:`, or no skills key at all.
96
+ doc.set('skills', [ref]);
97
+ } else {
98
+ listed.flow = false;
99
+ doc.addIn(['skills'], ref);
100
+ }
101
+
102
+ const text = doc.toString(YAML_OUT);
103
+ if (!changesExactly(was, text, (had) => ({ ...had, skills: [...(had.skills ?? []), ref] }))) {
104
+ throw new Error(`${file} cannot have a skill added to it without changing something else in it, so nothing was written. Add ${ref} to its skills list by hand.`);
105
+ }
106
+ writeWhole(file, text);
107
+ return { bot, home, skill: ref, state: 'added' };
108
+ });
106
109
  }
107
110
 
108
111
  /**
@@ -118,19 +121,22 @@ export function removeSkill(bots, bot, ref) {
118
121
  throw new Error(`there is no bot called ${bot} in ${bots}: ${file} is not there.`);
119
122
  }
120
123
 
121
- const index = listIn(file, 'skills').indexOf(ref);
122
- if (index === -1) return { bot, home, skill: ref, state: 'absent' };
124
+ return withBotTurn(home, () => {
125
+ const index = listIn(file, 'skills').indexOf(ref);
126
+ if (index === -1) return { bot, home, skill: ref, state: 'absent' };
127
+ refuseOutside(home, file);
123
128
 
124
- const was = readFileSync(file, 'utf8');
125
- const doc = parseDocument(was);
126
- doc.deleteIn(['skills', index]);
129
+ const was = readFileSync(file, 'utf8');
130
+ const doc = parseDocument(was);
131
+ doc.deleteIn(['skills', index]);
127
132
 
128
- const text = doc.toString(YAML_OUT);
129
- if (!changesExactly(was, text, (had) => ({ ...had, skills: had.skills.filter((one, at) => at !== index) }))) {
130
- throw new Error(`${file} cannot have a skill taken off it without changing something else in it, so nothing was written. Take ${ref} off its skills list by hand.`);
131
- }
132
- writeFileSync(file, text);
133
- return { bot, home, skill: ref, state: 'removed' };
133
+ const text = doc.toString(YAML_OUT);
134
+ if (!changesExactly(was, text, (had) => ({ ...had, skills: had.skills.filter((one, at) => at !== index) }))) {
135
+ throw new Error(`${file} cannot have a skill taken off it without changing something else in it, so nothing was written. Take ${ref} off its skills list by hand.`);
136
+ }
137
+ writeWhole(file, text);
138
+ return { bot, home, skill: ref, state: 'removed' };
139
+ });
134
140
  }
135
141
 
136
142
  /**
@@ -438,7 +444,7 @@ const LOOK_MS = 2000;
438
444
  * `blocked`) or `unknown` (with `trouble`).
439
445
  *
440
446
  * Claude Code gets `/reload-skills` typed into its tab, through the same gate
441
- * as the mail nudge. A busy session queues it and runs it as a command when
447
+ * as the mail interrupt. A busy session queues it and runs it as a command when
442
448
  * its turn ends, so it never reaches the model as text. Codex has no reload
443
449
  * command and takes the change at the start of its next turn by itself, so
444
450
  * nothing is typed, and the caller is given the SKILL.md of each skill linked
@@ -472,7 +478,10 @@ function tellSessions(home, bot, linked) {
472
478
  const told = { session: session.name, harness };
473
479
  const tab = typeof book[session.name]?.tab === 'string' ? book[session.name].tab : undefined;
474
480
  if (tab === undefined) return { ...told, state: 'not-up' };
475
- if (down !== undefined) return { ...told, state: 'unknown', trouble: `Orca is not answering at ${orcaCli()}, so the kit could not look at its tab, and nothing was typed` };
481
+ // Skills reach a Pi session in a later slice (#547), and nothing is typed
482
+ // into its tab before then.
483
+ if (harness === 'pi') return { ...told, state: 'unknown', trouble: 'it runs on Pi, and the kit does not give a Pi session skills yet, so nothing was typed' };
484
+ if (down !== undefined) return { ...told, state: 'unknown', trouble: `the kit could not look at its tab, and nothing was typed: ${down}` };
476
485
  let turn;
477
486
  try {
478
487
  // The session's turn for a line (#482), for the look and its line. Codex
@@ -564,7 +573,14 @@ function follow(bots, ref, sources) {
564
573
  }
565
574
  const wrong = wrongClone(bots, source);
566
575
  if (wrong !== undefined) throw new Error(`${ref}: ${wrong}`);
567
- return path.join(skillsIn(bots, source), after);
576
+ // The pinned clone is the only place a source's skill comes from. A path,
577
+ // a skill name or a link the repo committed can lead out of it (#571).
578
+ const inClone = path.join(skillsIn(bots, source), after);
579
+ const real = leavesClone(bots, named, inClone);
580
+ if (real !== undefined) {
581
+ throw new Error(`${ref} leads outside the clone of the source ${named}, to ${real}, and a skill from a source comes only from the version skills.yaml pins for it. Name a skill inside ${cloneDir(bots, named)} with no link out of it.`);
582
+ }
583
+ return inClone;
568
584
  },
569
585
  common: () => path.join(bots, 'skills', ref),
570
586
  // The user's own spelling: absolute, at their home, or read from the bots
package/src/sources.js CHANGED
@@ -14,11 +14,11 @@
14
14
  // file, so the difference is theirs to see.
15
15
 
16
16
  import { spawnSync } from 'node:child_process';
17
- import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
17
+ import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
18
18
  import path from 'node:path';
19
19
  import { parseDocument } from 'yaml';
20
20
 
21
- import { changesExactly, requireBotsFolder, YAML_OUT } from './bot.js';
21
+ import { changesExactly, leadsOutside, requireBotsFolder, YAML_OUT } from './bot.js';
22
22
  import { ownCli, shellWord } from './launch.js';
23
23
 
24
24
  /** The user's file that lists where skills come from. */
@@ -65,6 +65,9 @@ export function addSource(bots, { name, repo, ref, path: inside }) {
65
65
  if (TAKEN.includes(name)) {
66
66
  throw new Error(`${name} is the kit's own shelf, which a bot names kit:<name>, so a source cannot be called that. Call it something else.`);
67
67
  }
68
+ if (inside !== undefined && !staysInRepo(inside)) {
69
+ throw new Error(`${inside} cannot be the path of the source ${name}: it leads out of the repo, and a path is a subfolder inside it, such as skills.`);
70
+ }
68
71
 
69
72
  const file = path.join(bots, SKILLS_YAML);
70
73
  // Read through the same reader every other command uses, so a file this one
@@ -151,7 +154,6 @@ function bring(bots, source, moving) {
151
154
  const wrong = wrongClone(bots, source);
152
155
  if (wrong !== undefined) {
153
156
  if (!moving) throw new Error(wrong);
154
- rmSync(dir, { recursive: true, force: true });
155
157
  return clone(bots, source, source.ref, false);
156
158
  }
157
159
 
@@ -174,11 +176,16 @@ function bring(bots, source, moving) {
174
176
  * Clone a source and check it out out of the way, and put it in place only once
175
177
  * both have worked. `<name>.fetching` is where it happens; a run that cannot
176
178
  * finish leaves that behind and not a source.
179
+ *
180
+ * A clone already there, from a repository the file no longer names, stays
181
+ * where it is until the new one is ready (#568). It may hold the user's own
182
+ * files, and a replacement that fails must not cost them.
177
183
  */
178
184
  function clone(bots, source, rev, recorded) {
179
185
  mkdirSync(sourcesDir(bots), { recursive: true });
180
186
  const dir = cloneDir(bots, source.name);
181
187
  const fetching = `${dir}.fetching`;
188
+ const replaced = `${dir}.replaced`;
182
189
 
183
190
  try {
184
191
  rmSync(fetching, { recursive: true, force: true });
@@ -186,7 +193,25 @@ function clone(bots, source, rev, recorded) {
186
193
  // repository and never one of git's own options, whatever it begins with.
187
194
  git(['clone', '--quiet', '--', source.repo, fetching], undefined, `${source.name}: ${source.repo} could not be cloned`);
188
195
  checkout(fetching, source, rev, recorded);
189
- renameSync(fetching, dir);
196
+ if (existsSync(dir)) {
197
+ rmSync(replaced, { recursive: true, force: true });
198
+ renameSync(dir, replaced);
199
+ try {
200
+ renameSync(fetching, dir);
201
+ } catch (error) {
202
+ renameSync(replaced, dir);
203
+ throw error;
204
+ }
205
+ // The new clone is in place, so the update has worked. An old copy that
206
+ // will not go stays beside it, where health names it as a leftover.
207
+ try {
208
+ rmSync(replaced, { recursive: true, force: true });
209
+ } catch {
210
+ // Nothing to undo.
211
+ }
212
+ } else {
213
+ renameSync(fetching, dir);
214
+ }
190
215
  } finally {
191
216
  rmSync(fetching, { recursive: true, force: true });
192
217
  }
@@ -250,6 +275,47 @@ function git(args, cwd, trouble) {
250
275
  return result.stdout.trim();
251
276
  }
252
277
 
278
+ /**
279
+ * Whether a source's path, as written, names a folder inside its repo: not an
280
+ * absolute path, and no `..` that climbs above the top (#571). A link inside the
281
+ * clone can still lead out; `skills build` follows it before linking.
282
+ */
283
+ const staysInRepo = (inside) => !path.isAbsolute(inside) && path.normalize(inside).split(path.sep)[0] !== '..';
284
+
285
+ /**
286
+ * Where a source's skill at `dir` really leads when a link takes it, or anything
287
+ * in it, out of the source's clone; undefined when all of it stays inside (#571).
288
+ * A harness reads any file in a skill, so the whole folder is walked, through
289
+ * the links that stay inside as well.
290
+ */
291
+ export function leavesClone(bots, name, dir) {
292
+ const clone = cloneDir(bots, name);
293
+ const seen = new Set();
294
+ const walk = (at) => {
295
+ const real = leadsOutside(clone, at);
296
+ if (real !== undefined) return real;
297
+ let entries;
298
+ try {
299
+ const where = realpathSync.native(at);
300
+ // A folder reached twice, through a link back into the skill, is walked once.
301
+ if (seen.has(where) || !statSync(where).isDirectory()) return undefined;
302
+ seen.add(where);
303
+ entries = readdirSync(where);
304
+ } catch (error) {
305
+ // Nothing there: whatever links it says so.
306
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return undefined;
307
+ // A folder the kit may not read can hold a link out, and nothing shows it.
308
+ throw new Error(`${name}: the kit could not read ${at} (${error.code ?? error.message}), so it cannot tell whether anything in the skill leads out of the source's clone, and it links nothing from there.`);
309
+ }
310
+ for (const entry of entries) {
311
+ const found = walk(path.join(at, entry));
312
+ if (found !== undefined) return found;
313
+ }
314
+ return undefined;
315
+ };
316
+ return walk(dir);
317
+ }
318
+
253
319
  /** Where a source's skills sit inside its clone: its root, or the subfolder it names. */
254
320
  export const skillsIn = (bots, source) => path.join(cloneDir(bots, source.name), source.path ?? '');
255
321
 
@@ -331,6 +397,9 @@ export function readSources(file) {
331
397
  if (source.path !== undefined && typeof source.path !== 'string') {
332
398
  throw new Error(`${file}: the source ${name} has a path that is not a subfolder inside the repo.`);
333
399
  }
400
+ if (source.path !== undefined && !staysInRepo(source.path)) {
401
+ throw new Error(`${file}: the source ${name} has the path ${source.path}, which leads out of the repo, and a path is a subfolder inside it, such as skills.`);
402
+ }
334
403
  return source;
335
404
  });
336
405
  }
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, 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,12 +293,18 @@ 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
280
303
  // it (PRD 6.4). Nothing here is a git worktree.
281
304
  if (workDir !== undefined) mkdirSync(workDir, { recursive: true });
305
+ // The kit's own folders for the bot, which a Codex launch line names for its
306
+ // sandbox to write, are there before the line is (#534).
307
+ if (harness === 'codex') for (const folder of Object.values(kitFolders(bots, bot.name))) mkdirSync(folder, { recursive: true });
282
308
  writePrompt(launch);
283
309
 
284
310
  const made = openTab(home, tabTitle);
@@ -314,10 +340,16 @@ async function bringUpSession(bots, home, live, session, bot, title) {
314
340
  // gets, and trust-hooks asks what this one got (#506).
315
341
  let entry = { ...current.sessions[session.name], tab: made.tabId, launched, launched_with: session.extra_args ?? [], rules };
316
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;
317
346
  held = entry.session;
318
347
  // Before the line is typed, so the hook finds no id here and takes the one
319
348
  // it reports for a start rather than a clear, which would tell the duty twice.
320
- 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);
321
353
  current.sessions[session.name] = withUnclaimed(entry, which.unclaimed ?? []);
322
354
  forgetClaimed(current);
323
355
  });
@@ -328,13 +360,15 @@ async function bringUpSession(bots, home, live, session, bot, title) {
328
360
  // The old tab's hook can name another conversation while the tab is being
329
361
  // opened. A fresh start chosen before that is chosen again from the id the
330
362
  // book holds now: resumed if the harness has it, and otherwise set aside the
331
- // same way (review of PR #310).
332
- if (which.noConversation !== undefined && typeof held === 'string' && held !== which.noConversation) {
333
- 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);
334
367
  if (which.noConversation !== undefined) {
368
+ const chosen = which;
335
369
  await updateBook(home, (current) => {
336
370
  const entry = current.sessions[session.name];
337
- if (entry?.session === held) current.sessions[session.name] = forgetSession(entry, 'no conversation');
371
+ if (entry?.session === held) current.sessions[session.name] = setAside(entry, chosen);
338
372
  });
339
373
  }
340
374
  launch = launchOf(which);
@@ -399,7 +433,9 @@ async function bringUpSession(bots, home, live, session, bot, title) {
399
433
  promptReceived,
400
434
  promptFile: launch.promptFile,
401
435
  resumed: launch.resume !== undefined,
402
- noConversation: which.noConversation,
436
+ noConversation: which.ranOn === undefined ? which.noConversation : undefined,
437
+ harnessChanged: which.ranOn === undefined ? undefined : which.noConversation,
438
+ ranOn: which.ranOn,
403
439
  unclaimed: which.unclaimed,
404
440
  listLine: list?.typed,
405
441
  listLineTrouble: list?.why,
@@ -529,7 +565,7 @@ function lookAndType(home, tabId, line, until) {
529
565
  * The line that starts this session: resuming `resume`, or, with none, a fresh
530
566
  * start carrying its duty. Returns `{ resume, prompt, promptFile, command, address }`.
531
567
  */
532
- function launchFor(bots, bot, session, harness, home, workDir, resume, held) {
568
+ function launchFor(bots, bot, session, harness, home, workDir, resume, held, fresh) {
533
569
  const prompt = resume === undefined ? startPrompt(session, { home, workDir }) : undefined;
534
570
  // Anything longer than a line goes to the harness out of a file, rather than
535
571
  // through the tab's shell a character at a time.
@@ -550,6 +586,10 @@ function launchFor(bots, bot, session, harness, home, workDir, resume, held) {
550
586
  promptFile,
551
587
  resume,
552
588
  address,
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}` } : {}),
553
593
  });
554
594
  // The session's mailbox first, from inside its own tab (#317), and the
555
595
  // harness whatever became of it.
@@ -827,7 +867,7 @@ function whichConversation(book, home, bot, session, was, harness) {
827
867
  // it: a session paused before its first turn is one. There is nothing to
828
868
  // resume, so it starts again with its duty, and the book keeps the id in the
829
869
  // history with the reason (#295).
830
- return { noConversation: was.session };
870
+ return notHere(harness, home, was.session);
831
871
  }
832
872
  // No tab: nothing has ever run for this session, so there is nothing to find.
833
873
  if (typeof was?.tab !== 'string') return {};
@@ -847,6 +887,22 @@ function whichConversation(book, home, bot, session, was, harness) {
847
887
  return { unclaimed: known.map((one) => one.id) };
848
888
  }
849
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
+
850
906
  /**
851
907
  * A bot's Orca project, made if it is not there yet, with `change` saying
852
908
  * whether this run `made` it, `renamed` it or left it as it was (undefined).
@@ -870,12 +926,19 @@ const ids = (setup, change) => ({ project: setup.projectId, setup: setup.id, cha
870
926
  *
871
927
  * Beside *this* bots folder, and not in a shared temp directory: two bots
872
928
  * folders may each hold an api-bot with a daily session, and one file for both
873
- * of them is one bot's duty handed to another's session.
929
+ * of them is one bot's duty handed to another's session. In the bot's own
930
+ * folder there, which a Codex session of the bot may write and no other bot's
931
+ * may: a temporary session's maker writes its prompt from inside its sandbox
932
+ * (#534).
874
933
  */
875
934
  export const promptPath = (bots, bot, session) =>
935
+ path.join(kitFolders(bots, bot).prompts, `${encodeURIComponent(session)}.txt`);
936
+
937
+ /** Where a session's start prompt was before #534, flat beside the others: removed with the session. */
938
+ export const oldPromptPath = (bots, bot, session) =>
876
939
  path.join(`${bots}.prompts`, `${encodeURIComponent(bot)}.${encodeURIComponent(session)}.txt`);
877
940
 
878
- 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 }) {
879
942
  const made = { bot, name, title: tab.title, tabId: tab.tabId, terminal: tab.handle, created, harnessStarted: running };
880
943
  // A tab whose shell was not ready for the launch line, and why: nothing was
881
944
  // typed into it (#498).
@@ -889,6 +952,9 @@ function entry(tab, { bot, name, created, running = false, blockedReason, prompt
889
952
  if (listLineTrouble !== undefined) made.listLineTrouble = listLineTrouble;
890
953
  // The id the book held that had no conversation behind it, now in the history.
891
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 });
892
958
  // Orca's own words for what is on screen waiting to be answered, when it
893
959
  // gave any: the caller acts on it, the kit only passes it on.
894
960
  if (blockedReason !== undefined) made.blockedReason = blockedReason;