@assuredloop/orca-bot-kit 0.2.0 → 0.4.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.
Files changed (50) hide show
  1. package/README.md +31 -15
  2. package/SETUP.md +10 -4
  3. package/package.json +1 -1
  4. package/rules/finishing.md +3 -0
  5. package/rules/mail.md +3 -3
  6. package/rules/scope.md +4 -2
  7. package/rules/tests-first.md +6 -4
  8. package/skills/obk-arch/LICENSES.md +7 -0
  9. package/skills/obk-arch/NOTICE.md +113 -5
  10. package/skills/obk-arch/SKILL.md +221 -10
  11. package/skills/obk-bot-building/NOTICE.md +2 -2
  12. package/skills/obk-bot-building/SKILL.md +108 -19
  13. package/skills/obk-debugging/SKILL.md +30 -15
  14. package/skills/obk-decision-memo/NOTICE.md +1 -1
  15. package/skills/obk-finops/NOTICE.md +1 -1
  16. package/skills/obk-finops/SKILL.md +6 -4
  17. package/skills/obk-fleet-review/SKILL.md +42 -5
  18. package/skills/obk-grilling/NOTICE.md +11 -3
  19. package/skills/obk-grilling/SKILL.md +11 -7
  20. package/skills/obk-grooming/SKILL.md +5 -2
  21. package/skills/obk-handoff/NOTICE.md +1 -1
  22. package/skills/obk-recall/NOTICE.md +1 -1
  23. package/skills/obk-researching/NOTICE.md +1 -1
  24. package/skills/obk-researching/SKILL.md +19 -9
  25. package/skills/obk-reviewing/SKILL.md +4 -1
  26. package/skills/obk-tdd/SKILL.md +54 -23
  27. package/skills/obk-teach/NOTICE.md +2 -2
  28. package/skills/obk-why/NOTICE.md +1 -1
  29. package/skills/obk-why/SKILL.md +21 -10
  30. package/src/book.js +2 -2
  31. package/src/bot.js +1 -1
  32. package/src/cli.js +117 -33
  33. package/src/groom.js +5 -3
  34. package/src/health.js +2 -2
  35. package/src/hooks.js +73 -18
  36. package/src/init.js +3 -3
  37. package/src/launch.js +28 -5
  38. package/src/message.js +38 -19
  39. package/src/orca-window.cjs +21 -0
  40. package/src/orca.js +191 -28
  41. package/src/pause.js +11 -6
  42. package/src/record.js +1 -1
  43. package/src/restart.js +6 -5
  44. package/src/retire.js +41 -6
  45. package/src/roster.js +1 -1
  46. package/src/rules.js +2 -2
  47. package/src/skills.js +113 -21
  48. package/src/sources.js +4 -3
  49. package/src/up.js +57 -23
  50. package/src/usage.js +2 -2
package/src/skills.js CHANGED
@@ -1,26 +1,32 @@
1
1
  // The skills a bot has: links in the bot's own folder, into both harnesses
2
- // (PRD 6.7, ADR 0004, ADR 0009).
2
+ // (PRD 6.7, ADR 0014, ADR 0019).
3
3
  //
4
4
  // Linked, never copied, in both directions. Inwards: what lands in the bot home
5
5
  // is a symlink to the skill's own directory, so a kit skill is read where the
6
- // package installed it and an edit to a skill is what a running session reads —
7
- // both harnesses follow a symlinked skill folder and pick a change up without a
8
- // restart (tech notes, sections 2 and 3). Outwards: the kit takes away only the
9
- // links it put there, and never the skill one was pointing at.
6
+ // package installed it, and both harnesses follow the link. A running session
7
+ // reads an edited skill the next time it loads it. A skill linked in or taken
8
+ // away reaches it without a restart. Claude Code takes it through its own
9
+ // `/reload-skills`, which `obk skills build` types into the session's tab.
10
+ // Codex has no such command and takes it at the start of its next turn (tech
11
+ // notes, sections 2 and 3). Outwards: the kit takes away only the links it put
12
+ // there, and never the skill one was pointing at.
10
13
  //
11
14
  // An entry names one of four shelves: the kit's own (`kit:<name>`), an online
12
15
  // source `skills.yaml` lists (`<source>:<name>`, read from its clone beside the
13
16
  // bots folder), the user's common folder (a bare name), or a path to anywhere
14
17
  // on disk.
15
18
 
16
- import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
19
+ import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
17
20
  import os from 'node:os';
18
21
  import path from 'node:path';
19
22
  import { fileURLToPath } from 'node:url';
20
23
 
21
24
  import { parse, parseDocument, stringify } from 'yaml';
22
25
 
26
+ import { bookFile, readBook } from './book.js';
23
27
  import { botDir, botNames, changesExactly, leadsOutside, readBot, YAML_OUT } from './bot.js';
28
+ import { harnessOf, ownCli, shellWord } from './launch.js';
29
+ import { orcaCli, orcaTrouble, tabToTypeInto, typeIntoTab } from './orca.js';
24
30
  import { listIn } from './rules.js';
25
31
  import { cloneDir, isCloned, readSources, skillsIn, wrongClone } from './sources.js';
26
32
 
@@ -39,7 +45,7 @@ export const SKILL_DIRS = {
39
45
  /**
40
46
  * What a skill may be called: the Agent Skills name, which is also the name of
41
47
  * the directory it lives in and of the link the kit makes (tech notes, section
42
- * 4, and ADR 0009).
48
+ * 4, and ADR 0019).
43
49
  */
44
50
  const NAME = /^[a-z0-9]+(-[a-z0-9]+)*$/;
45
51
 
@@ -129,7 +135,7 @@ export function removeSkill(bots, bot, ref) {
129
135
 
130
136
  /**
131
137
  * Give one bot the skills its lists name. Returns what there is to report:
132
- * `{ bot, skills, removed }` for a bot whose links are in place, and
138
+ * `{ bot, skills, linked, removed }` for a bot whose links are in place, and
133
139
  * `{ bot, skills, trouble }` for one whose list the kit could not follow.
134
140
  *
135
141
  * A list with one entry the kit cannot follow links nothing at all: half a
@@ -209,6 +215,7 @@ export function linkSkills(bots, home, bot) {
209
215
  // something to throw through `prepareBots` and stop the fleet. What was
210
216
  // linked before it went wrong is still written down, so the next run knows it
211
217
  // for the kit's own.
218
+ const linked = [];
212
219
  const removed = [];
213
220
  try {
214
221
  for (const harness of Object.keys(SKILL_DIRS)) {
@@ -217,8 +224,8 @@ export function linkSkills(bots, home, bot) {
217
224
  held.set(harness, heldIn(path.join(home, SKILL_DIRS[harness]), record[harness]));
218
225
  continue;
219
226
  }
220
- const done = link(path.join(home, SKILL_DIRS[harness]), wanted, record[harness]);
221
- removed.push(...done.removed);
227
+ // Filled as it goes, so what was changed before a failure is still known.
228
+ const done = link(path.join(home, SKILL_DIRS[harness]), wanted, record[harness], linked, removed);
222
229
  held.set(harness, done.held);
223
230
  }
224
231
  } catch (error) {
@@ -231,6 +238,9 @@ export function linkSkills(bots, home, bot) {
231
238
  return {
232
239
  bot: bot.name,
233
240
  skills: heldBy(home, [], held),
241
+ // What did change before it went wrong, so the sessions it reaches are told.
242
+ linked: [...new Set(linked)].sort(),
243
+ removed: [...new Set(removed)].sort(),
234
244
  trouble: `${bot.name}'s skills could not be linked: ${error.message}. Nothing of yours was changed; put right what is named there, then build again.`,
235
245
  };
236
246
  }
@@ -242,6 +252,7 @@ export function linkSkills(bots, home, bot) {
242
252
  return {
243
253
  bot: bot.name,
244
254
  skills,
255
+ linked: [...new Set(linked)].sort(),
245
256
  removed: [...new Set(removed)].sort(),
246
257
  trouble: [...away].map(([harness, real]) => `${path.join(home, SKILL_DIRS[harness])} leads outside the bot folder, to ${real}, through a link, and the kit links skills only inside the bot folder, so ${harness} does not get the skills the lists name. Replace the link with a directory of the bot's own, then build again.`).join(' '),
247
258
  };
@@ -250,6 +261,7 @@ export function linkSkills(bots, home, bot) {
250
261
  return {
251
262
  bot: bot.name,
252
263
  skills,
264
+ linked: [...new Set(linked)].sort(),
253
265
  removed: [...new Set(removed)].sort(),
254
266
  // A name the lists ask for that the bot has something else under. The user's
255
267
  // own is never written over, so what they have to know is that the list did
@@ -280,7 +292,7 @@ export function unlinkSkills(home) {
280
292
  * user made, or one of the kit's that they have since repointed, is theirs.
281
293
  *
282
294
  * It is the kit's own file and lives beside the hook files it already keeps in
283
- * the bot folder (ADR 0010). A record that cannot be read is treated as an
295
+ * the bot folder (ADR 0020). A record that cannot be read is treated as an
284
296
  * empty one: the worst that follows is that the kit leaves alone something it
285
297
  * would otherwise have tidied up, which is the safe way round.
286
298
  */
@@ -311,7 +323,7 @@ function writeRecord(home, record) {
311
323
  *
312
324
  * A link that leads nowhere is reported whoever made it. Whose it is changes
313
325
  * what may be done about it, not the fact that a session cannot read the skill
314
- * (ADR 0004).
326
+ * (ADR 0014).
315
327
  */
316
328
  export function skillsTrouble(bots, home, bot) {
317
329
  const record = readRecord(home);
@@ -390,7 +402,8 @@ function namesIn(dir) {
390
402
 
391
403
  /**
392
404
  * Give every bot in the folder its skills, or the one named, in name order.
393
- * This is the whole of `obk skills build`: it writes links and asks Orca nothing.
405
+ * This is the whole of `obk skills build`: it writes links, and a bot whose
406
+ * links changed has its running sessions told (`tellSessions`).
394
407
  */
395
408
  export function buildSkills(bots, { bot: onlyBot } = {}) {
396
409
  const names = botNames(bots);
@@ -400,11 +413,89 @@ export function buildSkills(bots, { bot: onlyBot } = {}) {
400
413
 
401
414
  return (onlyBot === undefined ? names : [onlyBot]).map((name) => {
402
415
  const home = botDir(bots, name);
416
+ let bot;
417
+ let entry;
403
418
  try {
404
- return linkSkills(bots, home, readBot(home, name));
419
+ bot = readBot(home, name);
420
+ entry = linkSkills(bots, home, bot);
405
421
  } catch (error) {
406
422
  return { bot: name, skills: [], trouble: error.message };
407
423
  }
424
+ const changed = (entry.linked?.length ?? 0) + (entry.removed?.length ?? 0) > 0;
425
+ return changed ? { ...entry, sessions: tellSessions(home, bot, entry.linked) } : entry;
426
+ });
427
+ }
428
+
429
+ /** How long Orca is given to say what is in a session's tab. */
430
+ const LOOK_MS = 2000;
431
+
432
+ /**
433
+ * Tell each session of a bot whose skill links just changed, through its
434
+ * harness's own means (PRD 6.5: changes reach running sessions without a
435
+ * restart, and the sessions a change affects are told). Returns one entry per
436
+ * session, in the bot's order: `{ session, harness, state }`, where the state
437
+ * is `reloaded`, `next-turn` (with `read`), `not-up`, `blocked` (with
438
+ * `blocked`) or `unknown` (with `trouble`).
439
+ *
440
+ * 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
442
+ * its turn ends, so it never reaches the model as text. Codex has no reload
443
+ * command and takes the change at the start of its next turn by itself, so
444
+ * nothing is typed, and the caller is given the SKILL.md of each skill linked
445
+ * in, for the session to read if the skill has not appeared (tech notes,
446
+ * sections 2 and 3). Nothing is ever restarted.
447
+ */
448
+ function tellSessions(home, bot, linked) {
449
+ let book;
450
+ try {
451
+ book = readBook(home).sessions;
452
+ } catch (error) {
453
+ // The links are made; which tab is whose is what cannot be known.
454
+ const trouble = `${bookFile(home)} cannot be read (${error.message}), so the kit could not tell which tab is this session's, and nothing was typed`;
455
+ return bot.sessions.map((session) => ({ session: session.name, harness: harnessOf(session, bot.harness), state: 'unknown', trouble }));
456
+ }
457
+ // Resolved, because Orca is asked about this folder by path and does not
458
+ // follow a link to it (tech notes, section 1).
459
+ let real;
460
+ try {
461
+ real = realpathSync(home);
462
+ } catch {
463
+ real = home;
464
+ }
465
+ // An Orca whose runtime is not there still answers some commands from what
466
+ // it last knew, so it is asked whether it is up before it is asked anything
467
+ // about a tab.
468
+ const down = bot.sessions.some((session) => typeof book[session.name]?.tab === 'string') ? orcaTrouble() : undefined;
469
+
470
+ return bot.sessions.map((session) => {
471
+ const harness = harnessOf(session, bot.harness);
472
+ const told = { session: session.name, harness };
473
+ const tab = typeof book[session.name]?.tab === 'string' ? book[session.name].tab : undefined;
474
+ 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` };
476
+ try {
477
+ const found = tabToTypeInto(real, tab, LOOK_MS);
478
+ if (found.blocked !== undefined) return { ...told, state: 'blocked', blocked: found.blocked };
479
+ if (found.unsure !== undefined) return { ...told, state: 'unknown', trouble: found.unsure };
480
+ if (found.handle === undefined) return { ...told, state: 'not-up' };
481
+ // The book's tab, now running the other harness: its reload is not this one's.
482
+ if (found.agent !== harness) {
483
+ return { ...told, state: 'unknown', trouble: `Orca names ${found.agent} in its tab, not ${harness}, so nothing was typed` };
484
+ }
485
+
486
+ if (harness === 'codex') {
487
+ // Only what Codex can actually read: a link can fail for one harness
488
+ // alone, and a skills directory that is the user's gets none.
489
+ const read = linked.map((name) => path.join(home, SKILL_DIRS.codex, name, MANIFEST)).filter((file) => existsSync(file));
490
+ return { ...told, state: 'next-turn', read };
491
+ }
492
+ typeIntoTab(found.handle, '/reload-skills');
493
+ return { ...told, state: 'reloaded' };
494
+ } catch (error) {
495
+ // The links are made whatever Orca says, so this is reported, not
496
+ // thrown: the build did what it was asked, and the session was not told.
497
+ return { ...told, state: 'unknown', trouble: error.message };
498
+ }
408
499
  });
409
500
  }
410
501
 
@@ -460,7 +551,7 @@ function follow(bots, ref, sources) {
460
551
  throw new Error(`${ref} names the source ${named}, and skills.yaml lists no source called that.`);
461
552
  }
462
553
  if (!isCloned(bots, named)) {
463
- throw new Error(`${ref}: ${named} has not been fetched yet, so there is nothing to link. Run obk skills fetch --bots ${bots} --source ${named}.`);
554
+ throw new Error(`${ref}: ${named} has not been fetched yet, so there is nothing to link. Run ${shellWord(ownCli())} skills fetch --bots ${shellWord(bots)} --source ${named}.`);
464
555
  }
465
556
  const wrong = wrongClone(bots, source);
466
557
  if (wrong !== undefined) throw new Error(`${ref}: ${wrong}`);
@@ -488,7 +579,9 @@ function follow(bots, ref, sources) {
488
579
 
489
580
  /**
490
581
  * Make one harness's skills directory hold what `wanted` says, as far as the
491
- * kit is allowed to. Returns `{ removed, held }` — the names it took away, and
582
+ * kit is allowed to. Returns `{ linked, removed, held }` — the names it linked
583
+ * in or repointed, the names it took away (added to the lists given, as it
584
+ * goes, so a caller whose run throws still has them), and
492
585
  * what is in the directory afterwards.
493
586
  *
494
587
  * The kit acts only where the entry on disk is still the link it wrote, which
@@ -496,12 +589,10 @@ function follow(bots, ref, sources) {
496
589
  * link the very same skill by hand — so the kit remembers what it made rather
497
590
  * than guessing from where a link points (PRD 6.7).
498
591
  */
499
- function link(dir, wanted, record) {
592
+ function link(dir, wanted, record, linked = [], removed = []) {
500
593
  // The directory is there: `linkSkills` makes both before it calls this.
501
594
 
502
595
  const keep = new Map(wanted.map((skill) => [skill.name, skill]));
503
- const removed = [];
504
-
505
596
  // What the lists no longer name. The kit takes back only its own, and only
506
597
  // while it is untouched; anything else it forgets about and leaves.
507
598
  for (const name of Object.keys(record)) {
@@ -537,12 +628,13 @@ function link(dir, wanted, record) {
537
628
  }
538
629
 
539
630
  // Absolute: a kit skill lives in the installed package, outside the user's
540
- // repo, and the link has to reach out of the repo to find it (ADR 0004).
631
+ // repo, and the link has to reach out of the repo to find it (ADR 0014).
541
632
  symlinkSync(skill.dir, at);
542
633
  record[skill.name] = skill.dir;
634
+ linked.push(skill.name);
543
635
  }
544
636
 
545
- return { removed, held: heldIn(dir, record) };
637
+ return { linked, removed, held: heldIn(dir, record) };
546
638
  }
547
639
 
548
640
  /**
package/src/sources.js CHANGED
@@ -1,4 +1,4 @@
1
- // Skills from an online repo, at the version the user pinned (PRD 6.7, ADR 0004).
1
+ // Skills from an online repo, at the version the user pinned (PRD 6.7, ADR 0014).
2
2
  //
3
3
  // A source is a git repository the user names in `<bots>/skills.yaml`. The kit
4
4
  // clones it into `<bots>.skill-sources/<name>`, a sibling of the bots folder and
@@ -19,6 +19,7 @@ import path from 'node:path';
19
19
  import { parseDocument } from 'yaml';
20
20
 
21
21
  import { changesExactly, requireBotsFolder, YAML_OUT } from './bot.js';
22
+ import { ownCli, shellWord } from './launch.js';
22
23
 
23
24
  /** The user's file that lists where skills come from. */
24
25
  const SKILLS_YAML = 'skills.yaml';
@@ -201,7 +202,7 @@ function clone(bots, source, rev, recorded) {
201
202
  export function wrongClone(bots, source) {
202
203
  const from = originOf(cloneDir(bots, source.name));
203
204
  if (from === source.repo) return undefined;
204
- return `${source.name}: the clone beside your bots folder came from ${from}, and skills.yaml now names ${source.repo}. Run obk skills update --source ${source.name} to take it from there instead.`;
205
+ return `${source.name}: the clone beside your bots folder came from ${from}, and skills.yaml now names ${source.repo}. Run ${shellWord(ownCli())} skills update --source ${source.name} to take it from there instead.`;
205
206
  }
206
207
 
207
208
  /** Where a clone came from, as git itself has it written down. */
@@ -226,7 +227,7 @@ function checkout(dir, source, rev, recorded) {
226
227
 
227
228
  if (at === undefined) {
228
229
  throw new Error(recorded
229
- ? `${source.name}: ${source.repo} no longer has the commit skills.yaml records for it (${rev.slice(0, 7)}), so the version you pinned cannot be put back. Run obk skills update --source ${source.name} to take what ${source.ref} names now, or point it at a version the repo still has.`
230
+ ? `${source.name}: ${source.repo} no longer has the commit skills.yaml records for it (${rev.slice(0, 7)}), so the version you pinned cannot be put back. Run ${shellWord(ownCli())} skills update --source ${source.name} to take what ${source.ref} names now, or point it at a version the repo still has.`
230
231
  : `${source.name}: ${source.repo} has nothing called ${rev}`);
231
232
  }
232
233
  git(['-c', 'advice.detachedHead=false', 'checkout', '--quiet', at], dir, `${source.name}: could not go to ${rev}`);
package/src/up.js CHANGED
@@ -12,7 +12,7 @@ import { botDir, botNames, displayName, readBot } from './bot.js';
12
12
  import { conversationsIn } from './conversations.js';
13
13
  import { installHook } from './hooks.js';
14
14
  import { addressOf, harnessOf, isShortPrompt, launchCommand, reachesMail, sessionTrouble, startPrompt, workDirOf } from './launch.js';
15
- import { asFolderProject, findProject, makeMailbox, makeProject, openTab, retitleTab, tabs, tuiInTab, typeIntoTab } from './orca.js';
15
+ import { asFolderProject, findProject, harnessInTab, makeMailbox, makeProject, openTab, retitleTab, tabs, tellWindow, typeIntoTab, useMailbox } from './orca.js';
16
16
  import { buildAgents } from './rules.js';
17
17
  import { linkSkills } from './skills.js';
18
18
 
@@ -26,8 +26,8 @@ const STARTUP_MS = 10000;
26
26
  * And how long the second look takes. A harness can draw its first screen and
27
27
  * then die on something it was handed — a context window it cannot read, an
28
28
  * extra argument it does not know — and the first look would have called it
29
- * started. A tab whose TUI is still there answers this at once; only one that
30
- * has fallen back to a shell waits it out.
29
+ * started. An idle harness answers this at once; a busy one and a shell both
30
+ * wait it out, and it is the tab's foreground that tells them apart (#232).
31
31
  */
32
32
  const SECOND_LOOK_MS = 2000;
33
33
 
@@ -35,6 +35,7 @@ const SECOND_LOOK_MS = 2000;
35
35
  * Bring bots up in Orca. Returns `{ tabs, rules }`: one tab entry per tab it
36
36
  * looked at — the sessions the book knows, and, for Bot Father, whatever else
37
37
  * is open in its project — and one rules entry and one skills entry per bot.
38
+ * `projects` has one entry per Orca project this run made or renamed.
38
39
  *
39
40
  * With no name it is every bot in the folder, in name order. `bot` brings up
40
41
  * one bot and `session` one of its sessions, for a caller that wants one thing
@@ -58,11 +59,14 @@ export async function bringUp(bots, { bot: onlyBot, session: onlySession } = {})
58
59
  const { running, rules, skills } = prepareBots(bots, names, onlySession);
59
60
 
60
61
  const report = [];
62
+ const projects = [];
61
63
  for (const { bot, home } of running) {
62
64
  for (const session of sessionsOf(bot, onlySession)) {
63
65
  if (session.paused === true) paused.push({ bot: bot.name, session: session.name });
64
66
  }
65
- report.push(...await bringUpBot(bots, home, bot, onlySession));
67
+ const up = await bringUpBot(bots, home, bot, onlySession);
68
+ report.push(...up.tabs);
69
+ if (up.project !== undefined) projects.push(up.project);
66
70
  }
67
71
 
68
72
  // A bot whose rules would not build is reported here rather than in the
@@ -78,6 +82,7 @@ export async function bringUp(bots, { bot: onlyBot, session: onlySession } = {})
78
82
  })),
79
83
  skills,
80
84
  paused,
85
+ projects,
81
86
  };
82
87
  }
83
88
 
@@ -87,7 +92,7 @@ export async function bringUp(bots, { bot: onlyBot, session: onlySession } = {})
87
92
  *
88
93
  * Separate from the tabs because the order matters twice over. A session reads
89
94
  * its rules, its skills and its hooks as it comes up, so all three have to be
90
- * there before the tab is (PRD 6.6, ADR 0010) — and a restart closes a tab in
95
+ * there before the tab is (PRD 6.6, ADR 0020) — and a restart closes a tab in
91
96
  * between, so everything that can refuse must have refused before that. It
92
97
  * refuses by throwing, exactly as `up` always has; a bot it leaves out of
93
98
  * `running` is one whose sessions must not be started.
@@ -133,7 +138,7 @@ export function prepareBots(bots, names, onlySession) {
133
138
 
134
139
  // And the kit's hook goes into every bot folder before Orca is asked for
135
140
  // anything, for the same reason: a harness reads its hooks when it comes up,
136
- // so one written later would miss the session it was written for (ADR 0010),
141
+ // so one written later would miss the session it was written for (ADR 0020),
137
142
  // and a bot folder the kit cannot write it into stops the run with nothing
138
143
  // opened anywhere. Only the harnesses a bot actually runs on; a bot with no
139
144
  // sessions gets none.
@@ -197,8 +202,11 @@ async function bringUpBot(bots, home, bot, onlySession) {
197
202
  // Orca is asked first and the book is written after: nothing that takes time
198
203
  // happens while the book is held, because a session's own hook may be writing
199
204
  // its id into that same file at any moment.
200
- const orca = orcaProject(home, title);
205
+ const { change, ...orca } = orcaProject(home, title);
201
206
  await updateBook(home, (book) => { book.orca = orca; });
207
+ // Orca's window does not see a project made or renamed on the command line
208
+ // until it is told (#224).
209
+ if (change !== undefined) tellWindow(orca.project);
202
210
 
203
211
  const live = new Map(tabs(home).map((tab) => [tab.tabId, tab]));
204
212
  const report = [];
@@ -217,7 +225,7 @@ async function bringUpBot(bots, home, bot, onlySession) {
217
225
  : spare.map((tab) => entry(tab, { bot: name, name: null, created: false }))));
218
226
  }
219
227
 
220
- return report;
228
+ return { tabs: report, project: change === undefined ? undefined : { bot: name, project: orca.project, change } };
221
229
  }
222
230
 
223
231
  async function bringUpSession(bots, home, live, session, bot, title) {
@@ -234,7 +242,7 @@ async function bringUpSession(bots, home, live, session, bot, title) {
234
242
  // live harness. Writing the name down here would advertise an address that
235
243
  // answers to nobody (review of PR #132, finding 1). It gets one the next
236
244
  // time it starts, which is the next time the kit types its launch line.
237
- await ensureMailbox(home, bot, session, harness);
245
+ await ensureMailbox(home, bot, session, harness, known.handle);
238
246
 
239
247
  // Whatever runs in the tab may have rewritten its title. The kit writes its
240
248
  // own back, and reports that one rather than the name Orca last saw: the id
@@ -312,7 +320,7 @@ async function bringUpSession(bots, home, live, session, bot, title) {
312
320
  // tab is on the books, so a mailbox Orca will not make leaves a tab the next
313
321
  // run finds and finishes rather than a tab nobody owns (review of PR #132,
314
322
  // finding 3).
315
- await ensureMailbox(home, bot, session, harness, { named: harness === 'claude' });
323
+ await ensureMailbox(home, bot, session, harness, made.handle, { named: harness === 'claude', opened: true });
316
324
 
317
325
  // And then asking whether a TUI came up, rather than assuming one did. The
318
326
  // text goes into the tab's own shell, which may have been busy with a
@@ -321,9 +329,9 @@ async function bringUpSession(bots, home, live, session, bot, title) {
321
329
  // Twice, because coming up and staying up are different things: a harness
322
330
  // that refuses what it was handed draws a screen, prints its complaint and
323
331
  // leaves a shell behind, and a run that looked once would report it as
324
- // started. A tab that still has a TUI answers the second look at once,
325
- // whatever the agent in it is busy with.
326
- const tui = tuiInTab(made.handle, STARTUP_MS).running ? tuiInTab(made.handle, SECOND_LOOK_MS) : { running: false };
332
+ // started.
333
+ const first = lookFor(made.handle, STARTUP_MS);
334
+ const tui = first.running ? lookFor(made.handle, SECOND_LOOK_MS) : first;
327
335
 
328
336
  // The start prompt went in with that line, as the harness's own prompt
329
337
  // argument, so it is the harness that holds it until it is ready — through
@@ -344,6 +352,23 @@ async function bringUpSession(bots, home, live, session, bot, title) {
344
352
  });
345
353
  }
346
354
 
355
+ /**
356
+ * One look at a tab the kit just typed a launch line into: whether a harness
357
+ * is running there, and what it is waiting on if Orca says.
358
+ *
359
+ * The harness is up when something other than the shell holds the tab's
360
+ * terminal, busy or not, or when Orca sees a question on its screen. Orca's
361
+ * `agentIdentity` is not asked for: it can come seconds after the launch
362
+ * (tech notes, section 1). When the tab's foreground cannot be read, Orca's own
363
+ * answers stand in, for this report only: nothing more is typed here.
364
+ */
365
+ function lookFor(handle, timeoutMs) {
366
+ const seen = harnessInTab(handle, timeoutMs);
367
+ const running = seen.blockedReason !== undefined
368
+ || (seen.front === undefined ? seen.answered || seen.agent !== undefined : seen.front === 'program');
369
+ return { running, blockedReason: seen.blockedReason };
370
+ }
371
+
347
372
  /**
348
373
  * Make sure the book holds this session's mailbox, and — only where this run
349
374
  * has just launched the session under it — the name it answers to.
@@ -361,15 +386,23 @@ async function bringUpSession(bots, home, live, session, bot, title) {
361
386
  * a restart (tech notes, section 1). Orca offers no way to delete one, so this
362
387
  * makes exactly one per session and never a second.
363
388
  *
389
+ * It is bound to `handle`, the session's own tab, and never to the tab this run
390
+ * of `obk up` was typed in: Orca tells a Run's coordinator about its mail, and
391
+ * only the session the mail is for should be told (issue #228). A mailbox the
392
+ * session already had is bound again when this run `opened` a new tab for it —
393
+ * after a restart, or a closed tab brought back — or it would stay with the tab
394
+ * that is gone (review of PR #248, finding 1).
395
+ *
364
396
  * A Codex session whose user turned the sandbox switch off gets none: it could
365
397
  * not read a mailbox if it had one, and an address nobody can read is worse
366
398
  * than none at all. `obk message` says so in those words.
367
399
  */
368
- async function ensureMailbox(home, bot, session, harness, { named = false } = {}) {
400
+ async function ensureMailbox(home, bot, session, harness, handle, { named = false, opened = false } = {}) {
369
401
  const held = readBook(home).sessions[session.name] ?? {};
402
+ if (opened && typeof held.mailbox === 'string') useMailbox(held.mailbox, handle);
370
403
 
371
404
  const address = named ? addressOf(bot.name, session.name) : undefined;
372
- const mailbox = mailboxFor(readBook(home), bot, session, harness);
405
+ const mailbox = mailboxFor(readBook(home), bot, session, harness, handle);
373
406
 
374
407
  if (mailbox === undefined && (address === undefined || held.address === address)) return;
375
408
 
@@ -392,10 +425,10 @@ async function ensureMailbox(home, bot, session, harness, { named = false } = {}
392
425
  * The Orca call is made outside the book's lock, which is held for one read and
393
426
  * one write (book.js).
394
427
  */
395
- function mailboxFor(book, bot, session, harness) {
428
+ function mailboxFor(book, bot, session, harness, handle) {
396
429
  const held = book.sessions[session.name] ?? {};
397
430
  if (typeof held.mailbox === 'string' || !reachesMail(session, harness)) return undefined;
398
- return makeMailbox(`${bot.name}/${session.name}`);
431
+ return makeMailbox(`${bot.name}/${session.name}`, handle);
399
432
  }
400
433
 
401
434
  /**
@@ -403,7 +436,7 @@ function mailboxFor(book, bot, session, harness) {
403
436
  * the one the book holds, one the harness itself still has on record, or none.
404
437
  *
405
438
  * One tab holds one session, and the book is the authority for which
406
- * conversation that is (ADR 0002). But the book can be incomplete — on Codex a
439
+ * conversation that is (ADR 0012). But the book can be incomplete — on Codex a
407
440
  * hooks file must be trusted before any hook runs, and trusting it does not
408
441
  * replay the event it missed — and "the book does not say" must never be read as
409
442
  * "there was no conversation". So where the book is silent about a tab the kit
@@ -430,7 +463,8 @@ function whichConversation(book, home, bot, session, was, harness) {
430
463
  }
431
464
 
432
465
  /**
433
- * A bot's Orca project, made if it is not there yet.
466
+ * A bot's Orca project, made if it is not there yet, with `change` saying
467
+ * whether this run `made` it, `renamed` it or left it as it was (undefined).
434
468
  *
435
469
  * It has to be a folder workspace. Registering a folder that sits inside the
436
470
  * bots repo gets it recorded as a git one, which Orca gives no workspace at
@@ -438,12 +472,12 @@ function whichConversation(book, home, bot, session, was, harness) {
438
472
  */
439
473
  function orcaProject(home, title) {
440
474
  const found = findProject(home);
441
- const setup = found === undefined
442
- ? makeProject(home, title)
443
- : (found.kind === 'folder' ? found : asFolderProject(found.id, title));
444
- return { project: setup.projectId, setup: setup.id };
475
+ if (found === undefined) return ids(makeProject(home, title), 'made');
476
+ return found.kind === 'folder' ? ids(found) : ids(asFolderProject(found.id, title), 'renamed');
445
477
  }
446
478
 
479
+ const ids = (setup, change) => ({ project: setup.projectId, setup: setup.id, change });
480
+
447
481
  /**
448
482
  * Where the kit leaves a prompt for the launch line to pick up: a folder of its
449
483
  * own beside the bots repo, the way skill-source clones sit beside it (PRD 6.3)
package/src/usage.js CHANGED
@@ -5,10 +5,10 @@
5
5
  // and efforts, between which two moments, and how many times it was compacted.
6
6
  // What any of that costs is a skill's to work out: a price comes from a live
7
7
  // lookup and a price that cannot be found has to be said to be unknown, which is
8
- // judgement and not arithmetic (ADR 0006).
8
+ // judgement and not arithmetic (ADR 0016).
9
9
  //
10
10
  // Which conversation belongs to which session is the book's to say and is never
11
- // guessed, as everywhere else in the kit (ADR 0002). A session's conversations
11
+ // guessed, as everywhere else in the kit (ADR 0012). A session's conversations
12
12
  // are the one the book names for it now and the ones in its history. One in the
13
13
  // bot's folder that no session claims is reported under the bot instead, with
14
14
  // its figures, so that what it spent is visible rather than quietly dropped.