@assuredloop/orca-bot-kit 0.5.0 → 0.6.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/README.md CHANGED
@@ -350,6 +350,11 @@ conversation it was having, rather than starting a new one. Clear a session —
350
350
  keeps the old one, and gives the session its start prompt again, because that
351
351
  prompt is what tells one session's duty from another's.
352
352
 
353
+ A session paused before its first turn can have an id in the book and no
354
+ conversation behind it: the harness has nothing on record for that id. There is
355
+ nothing to resume, so it starts again with its duty, and the book moves the id
356
+ into its history as `no conversation`.
357
+
353
358
  One tab holds one session: the harness the kit started in it. Anything that
354
359
  session runs inside the tab — a `codex exec`, a helper, a subagent's own process
355
360
  — is not the session, and never becomes the conversation the kit brings back.
@@ -386,7 +391,10 @@ What it will not do, in order of how much it would cost you. It closes only a
386
391
  tab your book names, so Bot Father's ops tab and anything you opened yourself
387
392
  are left alone. It will not close a tab whose conversation the book cannot name:
388
393
  that close would be the end of that conversation, so it refuses, tells you which
389
- session and which file to settle it in, and touches nothing. It will not close
394
+ session and which file to settle it in, and touches nothing. The one exception
395
+ is a tab with only its shell in front: its harness has already quit, so there
396
+ is nothing in it to lose, and restart closes it and starts the session fresh
397
+ with its duty. It will not close
390
398
  anything at all until everything that would stop the session starting again has
391
399
  been settled — the same refusals `obk up` gives, made before the tab goes rather
392
400
  than after. If Orca refuses to close a tab it stops there, rather than start a
@@ -410,9 +418,11 @@ obk retire --bots /path/to/my-bots --bot api-bot [--session daily]
410
418
  A change to a charter or a setting is written at once, and a running session
411
419
  takes it when it next starts. A session keeps its harness: to move one, retire
412
420
  it and add another. A pause closes the tabs the way a restart does, with the
413
- same refusals, and `obk up` leaves what is paused closed until `obk unpause`
414
- brings it back with its conversations. Retiring a session takes it off the bot
415
- and keeps its conversations in the book under `retired`. Retiring a bot closes
421
+ same refusals, and one more: it also refuses a tab with only its shell in front
422
+ whose conversation the book does not name. `obk up` leaves what is paused
423
+ closed until `obk unpause` brings it back with its conversations. Retiring a
424
+ session takes it off the bot and keeps its conversations in the book under
425
+ `retired`. Retiring a bot closes
416
426
  its tabs, removes its Orca project and moves its folder to `retired/`; it will
417
427
  not touch a bot whose Orca project holds a tab your book does not name. The
418
428
  folder moves only once Orca no longer lists the project; if Orca still lists it,
@@ -433,11 +443,12 @@ link, not a tab — so everything it finds is yours to decide about.
433
443
 
434
444
  What it looks for:
435
445
 
436
- - **configuration that will not work**: a `bot.yaml` nothing can read, a session
437
- the kit would refuse to start, a bot with no `AGENTS.md`, a block somebody
438
- edited by hand, a file bigger than the 32 KiB Codex reads, a `CLAUDE.md` that
439
- is not this bot's rules, and a hooks file without the kit's own hook in it,
440
- which is how a book goes stale without anything saying so;
446
+ - **configuration that will not work**: a `bot.yaml` nothing can read, a key
447
+ in it the kit does not know (a typo such as `efort: high`, which nothing
448
+ reads), a session the kit would refuse to start, a bot with no `AGENTS.md`,
449
+ a block somebody edited by hand, a file bigger than the 32 KiB Codex reads, a
450
+ `CLAUDE.md` that is not this bot's rules, and a hooks file without the kit's
451
+ own hook in it, which is how a book goes stale without anything saying so;
441
452
  - **a skill that is not where its list says**: a listed skill missing from one
442
453
  of the two harnesses, a skill of your own standing where a listed one would
443
454
  go, and a link with nothing at the end of it;
@@ -484,16 +495,28 @@ obk message check --bots /path/to/my-bots --bot api-bot --session daily
484
495
  Two roads, and a bot never picks. Claude Code to Claude Code in the same
485
496
  approval class (`auto` and `ask` are one class, `dangerously-skip` the other)
486
497
  is the harness's own messaging: `message to` answers with the
487
- session's name — `<bot>.<session>`, which `obk up` puts on its launch line —
488
- and the sending session writes to that name itself, because no command can send
489
- that message for it. Everything else goes through Orca's mailbox, which the kit
490
- does carry.
491
-
492
- A session's mailbox is made the first time `obk up` brings it up, and written in
498
+ session's name, `<bot>.<session>.<token>`, which `obk up` puts on its launch
499
+ line, and the sending session writes to that name itself, because no command
500
+ can send that message for it. The token is new for each new conversation and
501
+ kept when one is resumed, because Claude Code refuses a name that other fleets'
502
+ sessions also answer to. Ask `message to` each time rather than keeping the
503
+ name. Everything else goes through Orca's mailbox, which the kit does carry.
504
+
505
+ That includes a Claude session still running under a name from before the
506
+ token, `<bot>.<session>`: `message to` answers the mailbox road for it, never
507
+ that name, until the session next starts a new conversation.
508
+
509
+ A session's mailbox is made the first time the kit starts it, and written in
493
510
  the book beside its tab. It is an Orca Run rather than the session's tab,
494
511
  because a tab's address dies with the tab and a Run does not — so a message sent
495
512
  while a session is down is still there when it comes back.
496
513
 
514
+ Orca lets a tab bind a mailbox to itself and to no other, so the mailbox is made
515
+ and bound from inside the session's own tab: the launch line the kit types there
516
+ runs `obk session mailbox` before the harness. For the same reason a session's
517
+ mail is read in its own tab. `message check` from any other tab reads nothing,
518
+ binds nothing, and says so; the mail waits.
519
+
497
520
  Nothing in a mailbox wakes anybody, so `message send` also types one line into
498
521
  the receiver's tab telling it to look. Both harnesses take a typed line as the
499
522
  next thing to do rather than cutting into what they are doing, which is the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assuredloop/orca-bot-kit",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
package/src/book.js CHANGED
@@ -269,3 +269,13 @@ export function rememberSession(entry = {}, id, ended) {
269
269
  const was = { session: entry.session, ended: ended ?? 'replaced', at: new Date().toISOString() };
270
270
  return { ...entry, session: id, history: [...(entry.history ?? []), was] };
271
271
  }
272
+
273
+ /**
274
+ * The session entry once the id it held has turned out to have no conversation
275
+ * behind it: the id goes into the history with that reason, and the entry holds
276
+ * none until the harness names the new one.
277
+ */
278
+ export function forgetSession(entry, ended) {
279
+ const { session, ...rest } = entry;
280
+ return { ...rest, history: [...(entry.history ?? []), { session, ended, at: new Date().toISOString() }] };
281
+ }
package/src/bot.js CHANGED
@@ -413,6 +413,31 @@ export const SESSION_FIELDS = [
413
413
  'name', 'harness', 'model', 'effort', 'context', 'approval', 'prompt', 'prompt_file', 'work_dir', 'extra_args',
414
414
  ];
415
415
 
416
+ /** Everything the top of a bot's own file can hold. */
417
+ const BOT_FIELDS = ['name', 'harness', 'charter', 'rules', 'skills', 'sessions', 'paused'];
418
+
419
+ /**
420
+ * The keys in a bot's file that the kit does not know, and so that nothing
421
+ * reads: a typo such as `efort` leaves its session on the harness's default
422
+ * while the file says otherwise (#273). Said, and never refused or taken out,
423
+ * because the file is the user's.
424
+ */
425
+ export function unknownKeys(home, name = path.basename(home)) {
426
+ const file = path.join(home, BOT_YAML);
427
+ const unknown = (entry, known) => Object.keys(entry).filter((key) => !known.includes(key));
428
+ const sessionKeys = [...SESSION_FIELDS, 'paused'];
429
+ return [
430
+ ...unknown(asMapping(parseYaml(file), file), BOT_FIELDS).map((key) => ({
431
+ where: file,
432
+ says: `${file} has a key the kit does not know, ${key}, so nothing reads it and ${name} runs as if it were not there. The keys a bot's file can have are ${BOT_FIELDS.join(', ')}.`,
433
+ })),
434
+ ...readBot(home, name).sessions.flatMap((session) => unknown(session, sessionKeys).map((key) => ({
435
+ where: file,
436
+ says: `${file}: ${name}'s session ${session.name} has a setting the kit does not know, ${key}, so nothing reads it and the session runs as if it were not there. The settings a session can have are ${sessionKeys.join(', ')}.`,
437
+ }))),
438
+ ];
439
+ }
440
+
416
441
  /** A session's settings, in that order, without the ones left out. */
417
442
  function ordered(session) {
418
443
  const entry = {};
package/src/cli.js CHANGED
@@ -17,7 +17,7 @@ import { grooming } from './groom.js';
17
17
  import { checkHealth, orcaSettingFindings } from './health.js';
18
18
  import { initBots } from './init.js';
19
19
  import { APPROVALS, HARNESSES, ownCli, shellWord, workDirOf } from './launch.js';
20
- import { checkMail, lookUp, sendMessage } from './message.js';
20
+ import { checkMail, lookUp, noMailboxYet, sendMessage } from './message.js';
21
21
  import { orcaCli, orcaTrouble, RELOAD_LINE } from './orca.js';
22
22
  import { pauseSessions, unpauseSessions } from './pause.js';
23
23
  import { recordSession, SHELL_ENV, TAB_ENV } from './record.js';
@@ -28,7 +28,7 @@ import { buildAgents, buildRules, CODEX_CAP } from './rules.js';
28
28
  import { addSkill, buildSkills, linkSkills, removeSkill } from './skills.js';
29
29
  import { addSource, fetchSources } from './sources.js';
30
30
  import { readUsage } from './usage.js';
31
- import { BOT_FATHER, bringUp } from './up.js';
31
+ import { BOT_FATHER, bringUp, ownMailbox } from './up.js';
32
32
 
33
33
  const USAGE = `obk — Orca Bot Kit.
34
34
 
@@ -186,6 +186,10 @@ Usage:
186
186
  For the kit's own hook, not for typing: it reads
187
187
  what the harness says about a session starting on
188
188
  standard input and writes it into the book.
189
+ obk session mailbox --bots <path> --bot <bot> --session <name>
190
+ For the kit's own launch line, not for typing: run
191
+ in the session's own tab, it gives the session its
192
+ mailbox, or binds the one it has, to that tab.
189
193
  obk --version Print the kit's version.
190
194
  obk --help Print this text.
191
195
 
@@ -221,6 +225,7 @@ const COMMANDS = {
221
225
  'message send': ['bots', 'to', 'subject'],
222
226
  'message check': ['bots'],
223
227
  'session record': ['bots', 'bot'],
228
+ 'session mailbox': ['bots', 'bot', 'session'],
224
229
  };
225
230
 
226
231
  /** What each flag is for, in the sentence a caller reads when it is missing. */
@@ -796,6 +801,17 @@ const commands = {
796
801
  };
797
802
  },
798
803
 
804
+ async 'session mailbox'(bots, values) {
805
+ const answer = await ownMailbox(bots, values.bot, values.session);
806
+ const who = `${answer.bot}/${answer.session}`;
807
+ const said = {
808
+ made: `${who} has its mailbox ${answer.mailbox}, made in this tab.`,
809
+ bound: `${who}'s mailbox ${answer.mailbox} is bound to this tab.`,
810
+ none: `${who} gets no mailbox: it is a Codex session with its sandbox switch off, which could not read one.`,
811
+ };
812
+ return { answer, lines: [said[answer.change]] };
813
+ },
814
+
799
815
  'session change'(bots, values) {
800
816
  if (values.harness !== undefined) {
801
817
  throw new Error(`session change does not change a session's harness: its conversations belong to the harness they ran on. To move it, retire it with obk retire and add one on ${values.harness} with obk session add.`);
@@ -892,9 +908,14 @@ function refuseWhenOrcaIsDown() {
892
908
 
893
909
  /** The same facts as `--json`, as lines, for a person reading along. */
894
910
  /** The one line that says which conversation this tab was given, and from where. */
895
- const howLine = (tab) => (tab.resumed === true
896
- ? ' it was told to resume the session the book holds, with its conversation.'
897
- : ' it was told to start a new session: the book holds none for this one yet.');
911
+ const howLines = (tab) => {
912
+ if (tab.resumed === true) return [' it was told to resume the session the book holds, with its conversation.'];
913
+ if (tab.noConversation === undefined) return [' it was told to start a new session: the book holds none for this one yet.'];
914
+ return [
915
+ ` it was told to start a new session: the book's conversation ${tab.noConversation} had nothing on record`,
916
+ " behind it, so it went into the session's history.",
917
+ ];
918
+ };
898
919
 
899
920
  /**
900
921
  * What the harness still has in this bot's folder that no session claims. The
@@ -1175,6 +1196,9 @@ function tabLines({ bots, created, completed, rules, skills, tabs, paused = [],
1175
1196
  for (const tab of tabs) {
1176
1197
  lines.push(`${tab.created ? 'opened' : 'found '} ${tab.title} tab ${tab.tabId} terminal ${tab.terminal}`);
1177
1198
  lines.push(...harnessLines(tab, bots));
1199
+ if (tab.noMailbox) {
1200
+ lines.push(` it has no mailbox: ${noMailboxYet({ bots, bot: tab.bot, session: tab.name })}`);
1201
+ }
1178
1202
  }
1179
1203
  for (const one of paused) {
1180
1204
  const what = one.session === undefined ? one.bot : `${one.bot} ${one.session}`;
@@ -1205,7 +1229,7 @@ function harnessLines(tab, bots) {
1205
1229
  // What the line that was typed in asked for, and where the kit got it: the
1206
1230
  // session the book holds, one the harness itself still had on record, or a new
1207
1231
  // one — and if a new one, whether anything was known about an older one.
1208
- const how = [howLine(tab)];
1232
+ const how = howLines(tab);
1209
1233
 
1210
1234
  if (!tab.harnessStarted) {
1211
1235
  return [
@@ -10,7 +10,7 @@
10
10
  // directory, and every session starts at its bot home. Read only, never
11
11
  // written: these are the harness's files (tech notes, sections 2 and 3).
12
12
 
13
- import { readdirSync, readFileSync, statSync } from 'node:fs';
13
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
14
14
  import { homedir } from 'node:os';
15
15
  import path from 'node:path';
16
16
 
@@ -39,6 +39,20 @@ export function conversationsIn(harness, home, since) {
39
39
  .map((one) => ({ id: one.id, at: one.at }));
40
40
  }
41
41
 
42
+ /**
43
+ * Whether the harness has a record of the conversation `id` for this bot home,
44
+ * which is what its own resume looks for. A session paused before its first
45
+ * turn has an id the hook reported and nothing written behind it, and Claude
46
+ * Code answers a resume of it with "No conversation found" (#295).
47
+ *
48
+ * Asked of the file names alone, which both harnesses make from the id: nothing
49
+ * is opened, however many conversations the machine has.
50
+ */
51
+ export function hasConversation(harness, home, id) {
52
+ if (harness === 'claude') return existsSync(path.join(claudeDir(home), `${id}.jsonl`));
53
+ return rollouts(codexDir()).some((file) => path.basename(file).endsWith(`-${id}.jsonl`));
54
+ }
55
+
42
56
  /**
43
57
  * The same conversations, each with the file the harness keeps it in, for a
44
58
  * caller that has to read what is inside one rather than only know it is there,
package/src/health.js CHANGED
@@ -22,7 +22,7 @@ import { existsSync, readdirSync, realpathSync } from 'node:fs';
22
22
  import path from 'node:path';
23
23
 
24
24
  import { bookFile, readBook, sessionIdsIn, tabIdsIn } from './book.js';
25
- import { botDir, botNames, botsDir, readBot } from './bot.js';
25
+ import { botDir, botNames, botsDir, readBot, unknownKeys } from './bot.js';
26
26
  import { transcriptsIn } from './conversations.js';
27
27
  import { hookTrouble } from './hooks.js';
28
28
  import { bypassFlags, harnessOf, HARNESSES, ownCli, sessionTrouble, shellWord } from './launch.js';
@@ -186,6 +186,7 @@ function aboutBot(bots, name, setups, sessions) {
186
186
 
187
187
  const said = (kind) => ({ where, says }) => finding(kind, where, says, name);
188
188
  return [
189
+ ...unknownKeys(home, name).map(said('config')),
189
190
  ...sessionSettings(home, bot).map(said('config')),
190
191
  ...agentsTrouble(bots, home, bot).map(said('config')),
191
192
  ...hooksOf(bots, home, bot).map(said('config')),
@@ -256,15 +257,7 @@ function runningOn(bots, home, bot, book, handles, sessions) {
256
257
  const restart = `${shellWord(ownCli())} restart --bots ${shellWord(bots)} --bot ${bot.name} --session ${session.name}`;
257
258
  const front = frontOfTab(handle);
258
259
  if (front.front === 'shell') {
259
- // Restart refuses a live tab whose conversation the book cannot name, and
260
- // there may be none to name: the harness can quit before it has one. Its
261
- // harness is gone, so closing the tab ends nothing, and `up` then starts
262
- // the session again as it does any closed tab with no conversation.
263
- const up = `${shellWord(ownCli())} up --bots ${shellWord(bots)} --bot ${bot.name} --session ${session.name}`;
264
- const back = typeof entry.session === 'string'
265
- ? `${restart} does`
266
- : `nor does obk restart, which refuses to close a tab whose conversation the book does not name, and the book names none for it. Its harness has already quit, so close the tab in Orca yourself; then ${up} starts it again, with a new conversation and its duty. Any conversation it left in this bot's folder is noted in the book as unclaimed, for health to list`;
267
- found.push(finding('session', entry.tab, `${bot.name}'s session ${session.name} is not running: its tab ${entry.tab} is open with only the tab's shell in front, so its harness quit or crashed. obk up finds the tab open and types nothing into it, so it does not bring the session back; ${back}.`, bot.name));
260
+ found.push(finding('session', entry.tab, `${bot.name}'s session ${session.name} is not running: its tab ${entry.tab} is open with only the tab's shell in front, so its harness quit or crashed. obk up finds the tab open and types nothing into it, so it does not bring the session back; ${restart} does.`, bot.name));
268
261
  continue;
269
262
  }
270
263
  const harness = harnessOf(session, bot.harness);
package/src/launch.js CHANGED
@@ -10,6 +10,7 @@
10
10
  // The text goes into the tab's ordinary shell, so every word is quoted the way
11
11
  // a shell needs it. `claude --model sonnet[1m]` would be a glob to zsh.
12
12
 
13
+ import { randomInt } from 'node:crypto';
13
14
  import { readFileSync } from 'node:fs';
14
15
  import path from 'node:path';
15
16
  import { fileURLToPath } from 'node:url';
@@ -53,8 +54,22 @@ export const harnessOf = (session, botHarness) => (set(session.harness) ? sessio
53
54
  * session writes to (ADR 0018). It goes on the launch line as `-n`, and it is
54
55
  * re-applied every time the session is started: a resume keeps the name by
55
56
  * itself (tech notes, section 2), and the kit does not depend on that.
57
+ *
58
+ * `<bot>.<session>` alone is not an address: every fleet with that bot and
59
+ * session answers to it, old runs and throwaway test fleets included, and
60
+ * Claude Code refuses a send to a name more than one session holds (#286). So
61
+ * the name ends in a token of its own, made for each new conversation: eight
62
+ * letters and digits, 36^8 names, so that no two conversations draw the same
63
+ * one by chance.
56
64
  */
57
- export const addressOf = (bot, session) => `${bot}.${session}`;
65
+ export const addressOf = (bot, session) =>
66
+ `${bot}.${session}.${Array.from({ length: 8 }, () => randomInt(36).toString(36)).join('')}`;
67
+
68
+ /** Whether `address` is one `addressOf` made for this bot and session. */
69
+ export function isAddressOf(bot, session, address) {
70
+ const own = `${bot}.${session}.`;
71
+ return typeof address === 'string' && address.startsWith(own) && /^[a-z0-9]{8}$/.test(address.slice(own.length));
72
+ }
58
73
 
59
74
  /**
60
75
  * The Codex setting without which a bot cannot reach Orca at all.
@@ -277,6 +292,17 @@ const quoted = (word) => (/^[A-Za-z0-9,._+:@%/=-]+$/.test(word) ? word : `'${wor
277
292
  /** The same, for anything else of the kit's that has to build a shell line. */
278
293
  export const shellWord = quoted;
279
294
 
295
+ /**
296
+ * What a launch line starts with for a session that can have a mailbox: the
297
+ * kit itself, run by the new tab's shell before the harness, giving the session
298
+ * its mailbox bound to that tab. Orca 1.4.210 lets a process in a tab bind a
299
+ * Run to that tab and to no other, so the `obk` that opened the tab cannot do
300
+ * it (#317). The line joins it with `;`, so the harness starts whatever became
301
+ * of it.
302
+ */
303
+ export const mailboxStep = (bots, bot, session) =>
304
+ [ownCli(), 'session', 'mailbox', '--bots', bots, '--bot', bot, '--session', session].map(quoted).join(' ');
305
+
280
306
  /**
281
307
  * The CLI that is running, by the path it was started by: the kit calls itself
282
308
  * back with this, never with the bare name `obk`, because the bare name is
package/src/message.js CHANGED
@@ -20,8 +20,8 @@ import path from 'node:path';
20
20
 
21
21
  import { readBook } from './book.js';
22
22
  import { botDir, botNames, readBot } from './bot.js';
23
- import { harnessOf, ownCli, reachesMail, shellWord } from './launch.js';
24
- import { ackMailbox, coordinatorOf, postMessage, readMailbox, tabs, tabToTypeInto, typeIntoTab, useMailbox } from './orca.js';
23
+ import { harnessOf, isAddressOf, ownCli, reachesMail, shellWord } from './launch.js';
24
+ import { ackMailbox, coordinatorOf, postMessage, readMailbox, tabs, tabToTypeInto, TERMINAL_ENV, typeIntoTab, useMailbox } from './orca.js';
25
25
 
26
26
  /**
27
27
  * How much of a message travels as itself. Above this it is written to a file
@@ -67,7 +67,10 @@ export function findSession(bots, target) {
67
67
  session: session.name,
68
68
  harness,
69
69
  approval: classOf(session),
70
- address: typeof held.address === 'string' ? held.address : undefined,
70
+ // Only a name the kit gave this session is an address. A bare
71
+ // `<bot>.<session>`, from before #286, is shared with other fleets'
72
+ // sessions, and Claude Code refuses a send to it.
73
+ address: isAddressOf(bot.name, session.name, held.address) ? held.address : undefined,
71
74
  mailbox: typeof held.mailbox === 'string' ? held.mailbox : undefined,
72
75
  tab: typeof held.tab === 'string' ? held.tab : undefined,
73
76
  trouble: whyNotReachable(bot.name, session, harness, held),
@@ -91,7 +94,8 @@ export function roadBetween(from, to) {
91
94
  address: to.mailbox === undefined ? undefined : `run:${to.mailbox}`,
92
95
  // A Claude pair that the native road cannot carry, because the receiver is
93
96
  // running under no name the kit gave it: it was started before the kit
94
- // named sessions, and nothing renames a live harness. The mailbox is the
97
+ // named sessions, or before it gave them names of their own (#286), and
98
+ // nothing renames a live harness. The mailbox is the
95
99
  // road that exists, and the caller is told why it is the one being used
96
100
  // rather than left to wonder (review of PR #132, finding 1).
97
101
  unnamed: pair ? true : undefined,
@@ -147,7 +151,9 @@ export function sendMessage(bots, { to: target, from: sender, tab, subject, text
147
151
  return {
148
152
  ...answer,
149
153
  sent: false,
150
- trouble: `${from.bot}/${from.session} has no mailbox of its own yet, so a reply would have nowhere to go. Bring it up first: ${shellWord(ownCli())} up --bots ${shellWord(bots)} --bot ${shellWord(from.bot)}`,
154
+ trouble: from.tab === undefined
155
+ ? `${from.bot}/${from.session} has no mailbox of its own yet, so a reply would have nowhere to go. Bring it up first: ${upCommand(from)}`
156
+ : `${from.bot}/${from.session} has no mailbox of its own, so a reply would have nowhere to go: ${noMailboxYet(from)}`,
151
157
  };
152
158
  }
153
159
 
@@ -176,8 +182,8 @@ export function sendMessage(bots, { to: target, from: sender, tab, subject, text
176
182
  * Orca fences a Run to one reader, its coordinator. A session whose tab is live
177
183
  * is bound to that tab and read as it; one whose tab is down is read as its
178
184
  * Run's coordinator, and nothing is bound (issue #249). A read that
179
- * is not a peek acknowledges the batch it read: that is what makes the next
180
- * check bring the next one rather than the same again.
185
+ * is not a peek acknowledges every batch waiting, one after another, so one
186
+ * check hands over all of it (issue #299).
181
187
  */
182
188
  export function checkMail(bots, { bot: botName, session: sessionName, tab, peek = false }) {
183
189
  const who = sessionName === undefined && botName === undefined
@@ -200,6 +206,20 @@ export function checkMail(bots, { bot: botName, session: sessionName, tab, peek
200
206
  // whatever its mailbox is bound to, a closed tab included, and nothing is
201
207
  // bound: the session's tab is bound again when it is back up (issue #249).
202
208
  const live = who.tab === undefined ? undefined : tabs(who.home).find((tab) => tab.tabId === who.tab)?.handle;
209
+ // Orca 1.4.210 lets a process in a tab bind and read as that tab and no
210
+ // other, and refuses the rest with nothing done (#317). So from inside any
211
+ // other tab, the kit asks nothing and says where the mail can be read.
212
+ const caller = process.env[TERMINAL_ENV];
213
+ if (caller !== undefined && caller !== live) {
214
+ return {
215
+ bots,
216
+ bot: who.bot,
217
+ session: who.session,
218
+ mailbox: who.mailbox,
219
+ messages: [],
220
+ trouble: `${who.bot}/${who.session}'s mail can be read only in its own tab: Orca lets a tab bind and read its own mailbox and no other. Nothing was read, and its mail is still waiting.`,
221
+ };
222
+ }
203
223
  if (live !== undefined) useMailbox(who.mailbox, live);
204
224
  const handle = live ?? coordinatorOf(who.mailbox);
205
225
  if (handle === undefined) {
@@ -212,9 +232,16 @@ export function checkMail(bots, { bot: botName, session: sessionName, tab, peek
212
232
  trouble: `${who.bot}/${who.session} is not up, and its mailbox is bound to no tab, so there is nothing to read it as. Its mail waits until its tab is back: \`up\` brings it back, or \`unpause\` when it is paused.`,
213
233
  };
214
234
  }
215
- const found = readMailbox(who.mailbox, { peek, handle });
216
- const messages = (found.messages ?? []).map((message) => asMessage(bots, message));
217
- if (!peek && found.deliveryId !== undefined && messages.length > 0) ackMailbox(who.mailbox, found.deliveryId, handle);
235
+ // Orca hands mail over a batch at a time and replays the oldest until it is
236
+ // acknowledged; the acknowledgement answers with the next batch. So a read
237
+ // takes every batch in turn, or newer mail waits behind an older one (#299).
238
+ const messages = [];
239
+ let found = readMailbox(who.mailbox, { peek, handle });
240
+ for (;;) {
241
+ messages.push(...(found.messages ?? []).map((message) => asMessage(bots, message)));
242
+ if (peek || !found.deliveryId || (found.messages ?? []).length === 0) break;
243
+ found = ackMailbox(who.mailbox, found.deliveryId, handle);
244
+ }
218
245
 
219
246
  return { bots, bot: who.bot, session: who.session, mailbox: who.mailbox, read: !peek, messages };
220
247
  }
@@ -335,7 +362,7 @@ function nudge(to, from, subject) {
335
362
  if (to.tab === undefined) return { nudged: false };
336
363
 
337
364
  try {
338
- const found = tabToTypeInto(to.home, to.tab, LOOK_MS);
365
+ const found = lookAt(to);
339
366
  if (found.blocked !== undefined) return { nudged: false, blocked: found.blocked };
340
367
  // A line that lands in a shell is run there, with the sender's subject in it.
341
368
  if (found.unsure !== undefined) return { nudged: false, nudgeTrouble: found.unsure };
@@ -356,6 +383,21 @@ function nudge(to, from, subject) {
356
383
  }
357
384
  }
358
385
 
386
+ /**
387
+ * The look before the nudge. Orca now and then refuses a handle it has just
388
+ * listed as stale, and a fresh listing hands out one that works (tech notes,
389
+ * section 1), so that refusal earns one more listing and one more look. Only
390
+ * one: a second refusal is reported as it is (#294).
391
+ */
392
+ function lookAt(to) {
393
+ try {
394
+ return tabToTypeInto(to.home, to.tab, LOOK_MS);
395
+ } catch (error) {
396
+ if (error.code !== 'terminal_handle_stale') throw error;
397
+ return tabToTypeInto(to.home, to.tab, LOOK_MS);
398
+ }
399
+ }
400
+
359
401
  /** `<bot>/<session>`, or a bot on its own when it has only the one session. */
360
402
  function splitTarget(target) {
361
403
  const parts = target.split('/');
@@ -395,5 +437,22 @@ function whyNotReachable(bot, session, harness, held) {
395
437
  return `${bot}/${session.name} is a Codex session whose extra arguments turn sandbox_workspace_write.network_access off. Orca is out of reach from inside that sandbox, so it can neither be written to nor read its own mail. Take that argument out, or write to it another way.`;
396
438
  }
397
439
 
398
- const notUpYet = (who) =>
399
- `${who.bot}/${who.session} has no mailbox yet: it has never been brought up. Start it and it gets one: ${shellWord(ownCli())} up --bots ${shellWord(who.bots)} --bot ${shellWord(who.bot)} --session ${shellWord(who.session)}`;
440
+ const notUpYet = (who) => (who.tab === undefined
441
+ ? `${who.bot}/${who.session} has no mailbox yet: it has never been brought up. Start it and it gets one: ${upCommand(who)} --session ${shellWord(who.session)}`
442
+ : `${who.bot}/${who.session} has no mailbox: ${noMailboxYet(who)}`);
443
+
444
+ /**
445
+ * Why a session the kit has started has no mailbox, and what gets it one. Its
446
+ * mailbox is made by the step at the head of its launch line, in its own tab
447
+ * (#317), so `up` of a session that is running gives it none: starting it
448
+ * again does, and `up` does when its tab is closed.
449
+ */
450
+ export const noMailboxYet = (who) =>
451
+ `its tab did not make one when it started, and the tab shows why. Start it again and its tab makes one: ${restartCommand(who)} (or, if its tab is closed: ${upCommand(who)} --session ${shellWord(who.session)})`;
452
+
453
+ /** The command that starts one session again, in a new tab. */
454
+ const restartCommand = (who) =>
455
+ `${shellWord(ownCli())} restart --bots ${shellWord(who.bots)} --bot ${shellWord(who.bot)} --session ${shellWord(who.session)}`;
456
+
457
+ /** The command that brings a bot's sessions up, for the caller to finish with a session or not. */
458
+ const upCommand = (who) => `${shellWord(ownCli())} up --bots ${shellWord(who.bots)} --bot ${shellWord(who.bot)}`;
package/src/orca.js CHANGED
@@ -24,20 +24,34 @@ export const orcaCli = () => process.env.OBK_ORCA || BUILT_IN;
24
24
  /**
25
25
  * Run one Orca command and give back its `result`.
26
26
  * Every call asks for `--json`; anything else would be text to guess at.
27
+ * With `timeoutMs`, Orca is given that long to answer, and the call is ended
28
+ * and refused if it has not.
27
29
  */
28
- export function orca(args) {
29
- const answer = ask(args);
30
+ export function orca(args, { timeoutMs } = {}) {
31
+ const answer = ask(args, timeoutMs);
30
32
  if (answer.ok !== true) {
31
33
  // Orca's own words: it knows what went wrong, and the caller is an LLM
32
34
  // that can act on them.
33
- throw new Error(`Orca refused ${args.join(' ')}: ${answer.error?.message ?? 'no reason given'}`);
35
+ throw refusal(args, answer);
34
36
  }
35
37
  return answer.result;
36
38
  }
37
39
 
40
+ /** What an error carries when Orca was given a time and did not answer in it. */
41
+ export const TIMED_OUT = 'orca_timed_out';
42
+
43
+ /** Orca's refusal as an error, with its code on it, so a caller can tell a stale handle from the rest (#294). */
44
+ const refusal = (args, answer) => Object.assign(
45
+ new Error(`Orca refused ${args.join(' ')}: ${answer.error?.message ?? 'no reason given'}`),
46
+ { code: answer.error?.code },
47
+ );
48
+
38
49
  /** One Orca command, and the whole envelope back, refusals included. */
39
- function ask(args) {
40
- const asked = spawnSync(orcaCli(), [...args, '--json'], { encoding: 'utf8' });
50
+ function ask(args, timeoutMs) {
51
+ const asked = spawnSync(orcaCli(), [...args, '--json'], { encoding: 'utf8', ...(timeoutMs === undefined ? {} : { timeout: timeoutMs }) });
52
+ if (asked.error?.code === 'ETIMEDOUT') {
53
+ throw Object.assign(new Error(`Orca did not answer ${args.join(' ')} within ${timeoutMs / 1000} seconds.`), { code: TIMED_OUT });
54
+ }
41
55
  if (asked.error) {
42
56
  throw new Error(`could not run Orca at ${orcaCli()}: ${asked.error.message}`);
43
57
  }
@@ -257,7 +271,7 @@ export function harnessInTab(handle, timeoutMs) {
257
271
  // Out of time means nothing went idle, busy or absent alike. That is an
258
272
  // answer, not a breakdown.
259
273
  if (answer.ok !== true && answer.error?.code !== 'timeout') {
260
- throw new Error(`Orca refused ${args.join(' ')}: ${answer.error?.message ?? 'no reason given'}`);
274
+ throw refusal(args, answer);
261
275
  }
262
276
 
263
277
  const shown = orca(['terminal', 'show', '--terminal', handle]).terminal;
@@ -412,37 +426,41 @@ export function typeIntoTab(handle, text) {
412
426
  /** How long a re-issued line is given to be submitted before Orca gives up on it. */
413
427
  const SUBMIT_WAIT_S = 5;
414
428
 
429
+ /** Where Orca names the terminal a process runs in, in every pane it opens. */
430
+ export const TERMINAL_ENV = 'ORCA_TERMINAL_HANDLE';
431
+
415
432
  /**
416
433
  * A mailbox of one session's own: an Orca Run, which is a name and an inbox and
417
434
  * nothing else — it schedules nothing and runs nobody. The objective is what a
418
435
  * person sees in `orca orchestration run-list`, so it says whose it is.
419
436
  *
420
- * Bound to `handle`, the session's own tab, from the moment it is made. Orca
421
- * tells a Run's coordinator terminal about its mail and nobody else, and binds
422
- * the caller when no `--from` is given: the tab that ran `obk up` would be told
423
- * about every session it brought up, and would lose its own Run's binding, since
424
- * one terminal holds one Run (tech notes, section 1).
437
+ * Bound to the terminal this process runs in, which has to be the session's own
438
+ * tab: Orca tells a Run's coordinator terminal about its mail and nobody else,
439
+ * and one terminal holds one Run. Orca 1.4.210 lets a process in a tab bind a
440
+ * Run to that tab and to no other, so this is called from inside the session's
441
+ * tab and names no `--from` (tech notes, section 1; #317).
425
442
  */
426
- export const makeMailbox = (objective, handle) =>
427
- orca(['orchestration', 'run-create', '--objective', `obk ${objective}`, '--from', handle]).run.id;
443
+ export const makeMailbox = (objective, options) =>
444
+ orca(['orchestration', 'run-create', '--objective', `obk ${objective}`], options).run.id;
428
445
 
429
446
  /**
430
447
  * Bind a mailbox to the terminal `handle`, which makes it the Run's coordinator
431
448
  * and its one reader: Orca refuses a `check` from any other terminal with
432
449
  * `consumer_fenced`. A read itself binds nothing, so this is for a session's own
433
450
  * live tab, never for whoever is reading (tech notes, section 1). Without a
434
- * handle it binds this process's own terminal.
451
+ * handle it binds this process's own terminal; on Orca 1.4.210 a process in a
452
+ * tab may name no other (#317).
435
453
  */
436
- export const useMailbox = (id, handle) =>
437
- orca(['orchestration', 'run-use', '--id', id, ...(handle === undefined ? [] : ['--from', handle])]).run;
454
+ export const useMailbox = (id, handle, options) =>
455
+ orca(['orchestration', 'run-use', '--id', id, ...(handle === undefined ? [] : ['--from', handle])], options).run;
438
456
 
439
457
  /**
440
458
  * The terminal a mailbox is bound to, or undefined when it has none. A closed
441
459
  * tab keeps its binding, and a read as its handle still works: a read binds
442
460
  * nothing, only `run-use` does (tech notes, section 1).
443
461
  */
444
- export const coordinatorOf = (id) =>
445
- orca(['orchestration', 'run-show', '--id', id]).run.coordinator_handle ?? undefined;
462
+ export const coordinatorOf = (id, options) =>
463
+ orca(['orchestration', 'run-show', '--id', id], options).run.coordinator_handle ?? undefined;
446
464
 
447
465
  /** Queue one message. `to` and `from` are mailboxes, written `run:<id>`. */
448
466
  export function postMessage({ to, from, subject, body, type = 'status', thread }) {
@@ -461,7 +479,7 @@ export function postMessage({ to, from, subject, body, type = 'status', thread }
461
479
  export const readMailbox = (id, { peek = false, handle } = {}) =>
462
480
  orca(['orchestration', 'check', '--run', id, ...as(handle), ...(peek ? ['--peek'] : [])]);
463
481
 
464
- /** Say a batch has been read, so the next check brings the one after it. */
482
+ /** Say a batch has been read. Orca answers with the batch after it. */
465
483
  export const ackMailbox = (id, delivery, handle) =>
466
484
  orca(['orchestration', 'check', '--run', id, ...as(handle), '--ack', delivery]);
467
485
 
package/src/restart.js CHANGED
@@ -15,7 +15,9 @@
15
15
  // else open in a bot's project, belongs to the user (PRD 6.2).
16
16
  // 2. Never a tab whose conversation the kit could not name again. A live tab
17
17
  // with no session id in the book is refused before anything is touched:
18
- // the close would be the end of that conversation.
18
+ // the close would be the end of that conversation. Unless only its shell
19
+ // is in front: its harness has quit, so the close ends nothing, and `up`
20
+ // starts the session fresh as it does any closed tab with no id (#303).
19
21
  // 3. One tab at a time, by its own handle. Orca's `--worktree <sel> --all`
20
22
  // ends every tab of a project and is never called from anywhere in the kit.
21
23
  //
@@ -32,7 +34,7 @@ import { setTimeout as pause } from 'node:timers/promises';
32
34
  import { bookFile, readBook } from './book.js';
33
35
  import { botDir, readBot } from './bot.js';
34
36
  import { ownCli, shellWord } from './launch.js';
35
- import { closeTab, findProject, tabs } from './orca.js';
37
+ import { closeTab, findProject, frontOfTab, tabs } from './orca.js';
36
38
  import { botsNamed, bringUp, prepareBots, sessionsOf } from './up.js';
37
39
 
38
40
  /**
@@ -65,7 +67,7 @@ export async function restartSessions(bots, { bot: name, session: onlySession }
65
67
  throw new Error(`${name} would not come up again as it is, so nothing was closed. ${prepared.rules[0].trouble}`);
66
68
  }
67
69
 
68
- const going = tabsToClose(bots, name, home, sessions.filter((session) => session.paused !== true));
70
+ const going = tabsToClose(bots, name, home, sessions.filter((session) => session.paused !== true), { quit: true });
69
71
  const closed = await closeTabs(home, going, bots, name);
70
72
  return { closed, ...(await bringUp(bots, { bot: name, session: onlySession })) };
71
73
  }
@@ -75,9 +77,11 @@ export async function restartSessions(bots, { bot: name, session: onlySession }
75
77
  * has been judged safe to close: a live tab whose conversation the book cannot
76
78
  * name refuses the lot, before anything is touched. With `keepless`, a tab is
77
79
  * closed whatever the book knows, for a caller that is ending the session on
78
- * purpose.
80
+ * purpose. With `quit`, a tab whose harness has quit to its shell is closed
81
+ * too, for a caller that starts the session again: there is nothing in it to
82
+ * lose. A program in front, or a front that cannot be read, still refuses.
79
83
  */
80
- export function tabsToClose(bots, name, home, sessions, { keepless = false } = {}) {
84
+ export function tabsToClose(bots, name, home, sessions, { keepless = false, quit = false } = {}) {
81
85
  const book = readBook(home);
82
86
 
83
87
  // Orca is asked what the project holds only when it has one: a folder Orca
@@ -96,7 +100,7 @@ export function tabsToClose(bots, name, home, sessions, { keepless = false } = {
96
100
  // Nothing of this session's is open: there is nothing to close, and `up`
97
101
  // brings it back the way it brings back a tab the user closed themselves.
98
102
  if (tab === undefined) continue;
99
- if (keepless || typeof was.session === 'string') going.push({ name: session.name, tab });
103
+ if (keepless || typeof was.session === 'string' || (quit && frontOfTab(tab.handle).front === 'shell')) going.push({ name: session.name, tab });
100
104
  else refusals.push(cannotComeBack(bots, name, session.name, was));
101
105
  }
102
106
 
package/src/up.js CHANGED
@@ -8,12 +8,13 @@ import { existsSync, mkdirSync, realpathSync, writeFileSync } from 'node:fs';
8
8
  import path from 'node:path';
9
9
  import { setTimeout as pause } from 'node:timers/promises';
10
10
 
11
- import { forgetClaimed, readBook, sessionIdsIn, tabIdsIn, updateBook, withUnclaimed } from './book.js';
11
+ import { forgetClaimed, forgetSession, readBook, sessionIdsIn, tabIdsIn, updateBook, withUnclaimed } from './book.js';
12
12
  import { botDir, botNames, displayName, readBot } from './bot.js';
13
- import { conversationsIn, heldAsUserTurn, transcriptsIn } from './conversations.js';
13
+ import { conversationsIn, hasConversation, heldAsUserTurn, transcriptsIn } from './conversations.js';
14
14
  import { installHook } from './hooks.js';
15
- import { addressOf, harnessOf, isShortPrompt, launchCommand, reachesMail, sessionTrouble, startPrompt, workDirOf } from './launch.js';
16
- import { asFolderProject, findProject, harnessInTab, makeMailbox, makeProject, openTab, retitleTab, tabs, tellWindow, typeIntoTab, useMailbox } from './orca.js';
15
+ import { addressOf, harnessOf, isAddressOf, isShortPrompt, launchCommand, mailboxStep, reachesMail, sessionTrouble, startPrompt, workDirOf } from './launch.js';
16
+ import { asFolderProject, coordinatorOf, findProject, harnessInTab, makeMailbox, makeProject, openTab, retitleTab, tabs, TERMINAL_ENV, TIMED_OUT, tellWindow, typeIntoTab, useMailbox } from './orca.js';
17
+ import { TAB_ENV } from './record.js';
17
18
  import { buildAgents, rulesStamp } from './rules.js';
18
19
  import { linkSkills } from './skills.js';
19
20
 
@@ -237,20 +238,21 @@ async function bringUpSession(bots, home, live, session, bot, title) {
237
238
  const tabTitle = `${title} ${session.name}`;
238
239
 
239
240
  if (known) {
240
- // A mailbox it can be written to, and no name. The name is the one thing
241
- // the kit cannot give a session that is already running: `-n` goes on the
241
+ // No name, and no mailbox from here. The name is the one thing the kit
242
+ // cannot give a session that is already running: `-n` goes on the
242
243
  // launch line, this session was launched without one, and nothing renames a
243
244
  // live harness. Writing the name down here would advertise an address that
244
245
  // answers to nobody (review of PR #132, finding 1). It gets one the next
245
- // time it starts, which is the next time the kit types its launch line.
246
- await ensureMailbox(home, bot, session, harness, known.handle);
246
+ // time it starts, which is the next time the kit types its launch line. So
247
+ // does a mailbox, if its book has none: that is made from inside the tab
248
+ // the session starts in, and nothing is typed into this one (#317).
247
249
 
248
250
  // Whatever runs in the tab may have rewritten its title. The kit writes its
249
251
  // own back, and reports that one rather than the name Orca last saw: the id
250
252
  // is the session, and a title is only ever set. Nothing is typed into a tab
251
253
  // that is already there — the session is in the middle of its work.
252
254
  retitleTab(known.handle, tabTitle);
253
- return entry({ ...known, title: tabTitle }, { bot: bot.name, name: session.name, created: false });
255
+ return entry({ ...known, title: tabTitle }, { bot: bot.name, name: session.name, created: false, noMailbox: lacksMailbox(home, session, harness) });
254
256
  }
255
257
 
256
258
  // Everything that can be refused is settled before Orca is asked for
@@ -263,33 +265,14 @@ async function bringUpSession(bots, home, live, session, bot, title) {
263
265
  // given again (PRD 6.4) — after a clear it is, and that is the hook's work.
264
266
  // Asked before the book is held, because it reads a folder of the harness's
265
267
  // own files: nothing slow happens under the lock.
266
- const which = whichConversation(book, home, bot, session, was, harness);
267
- const resume = which.resume;
268
- const prompt = resume === undefined ? startPrompt(session, { home, workDir }) : undefined;
269
- // Anything longer than a line goes to the harness out of a file, rather than
270
- // through the tab's shell a character at a time.
271
- const promptFile = prompt === undefined || isShortPrompt(prompt) ? undefined : promptPath(bots, bot.name, session.name);
272
- const command = launchCommand(session, {
273
- harness,
274
- home,
275
- workDir,
276
- prompt,
277
- promptFile,
278
- resume,
279
- address: harness === 'claude' ? addressOf(bot.name, session.name) : undefined,
280
- });
268
+ let which = whichConversation(book, home, bot, session, was, harness);
269
+ const launchOf = (chosen) => launchFor(bots, bot, session, harness, home, workDir, chosen.resume, was?.address);
270
+ let launch = launchOf(which);
281
271
 
282
272
  // A work dir is a plain folder, made for the session before it is told about
283
273
  // it (PRD 6.4). Nothing here is a git worktree.
284
274
  if (workDir !== undefined) mkdirSync(workDir, { recursive: true });
285
-
286
- // The prompt is written where the launch line can read it from, before that
287
- // line is typed. It is the kit's own file, not the user's: theirs stays where
288
- // they put it, in the bot home.
289
- if (promptFile !== undefined) {
290
- mkdirSync(path.dirname(promptFile), { recursive: true });
291
- writeFileSync(promptFile, prompt);
292
- }
275
+ writePrompt(launch);
293
276
 
294
277
  const made = openTab(home, tabTitle);
295
278
 
@@ -305,28 +288,48 @@ async function bringUpSession(bots, home, live, session, bot, title) {
305
288
  // built, noted so that health can say when the file moves on and the session
306
289
  // does not (#272).
307
290
  const rules = rulesStamp(home);
291
+ let held;
308
292
  await updateBook(home, (current) => {
309
293
  // What the harness has in this folder that nobody claims goes on the record,
310
294
  // for a person or Bot Father to settle — added to whatever was already noted,
311
295
  // because this run's scan cannot see what an earlier one found. The kit never
312
296
  // settles it itself.
313
- const entry = { ...current.sessions[session.name], tab: made.tabId, launched, rules };
297
+ let entry = { ...current.sessions[session.name], tab: made.tabId, launched, rules };
314
298
  if (rules === undefined) delete entry.rules;
299
+ held = entry.session;
300
+ // Before the line is typed, so the hook finds no id here and takes the one
301
+ // it reports for a start rather than a clear, which would tell the duty twice.
302
+ if (which.noConversation !== undefined && held === which.noConversation) entry = forgetSession(entry, 'no conversation');
315
303
  current.sessions[session.name] = withUnclaimed(entry, which.unclaimed ?? []);
316
304
  forgetClaimed(current);
317
305
  });
318
306
 
307
+ // The old tab's hook can name another conversation while the tab is being
308
+ // opened. A fresh start chosen before that is chosen again from the id the
309
+ // book holds now: resumed if the harness has it, and otherwise set aside the
310
+ // same way (review of PR #310).
311
+ if (which.noConversation !== undefined && typeof held === 'string' && held !== which.noConversation) {
312
+ which = hasConversation(harness, home, held) ? { resume: held } : { noConversation: held };
313
+ if (which.noConversation !== undefined) {
314
+ await updateBook(home, (current) => {
315
+ const entry = current.sessions[session.name];
316
+ if (entry?.session === held) current.sessions[session.name] = forgetSession(entry, 'no conversation');
317
+ });
318
+ }
319
+ launch = launchOf(which);
320
+ writePrompt(launch);
321
+ }
322
+
319
323
  // Typing it in is the way: for a project the kit has just made, giving Orca
320
324
  // the harness as the tab's own command times out and leaves a dead tab.
321
- typeIntoTab(made.handle, command);
325
+ typeIntoTab(made.handle, launch.command);
322
326
 
323
- // Now the session has an address, and not before: the mailbox it can be
324
- // written to, and — on Claude Code — the name that line just gave it, which
325
- // is the name another Claude session writes to. Both are written after the
326
- // tab is on the books, so a mailbox Orca will not make leaves a tab the next
327
- // run finds and finishes rather than a tab nobody owns (review of PR #132,
328
- // finding 3).
329
- await ensureMailbox(home, bot, session, harness, made.handle, { named: harness === 'claude', opened: true });
327
+ // Now the session has an address, and not before: on Claude Code, the name
328
+ // that line just gave it, which is the name another Claude session writes
329
+ // to. Its mailbox comes from the line's own first step, in the tab, and a
330
+ // mailbox Orca will not make there leaves a tab on the books all the same,
331
+ // which the next launch finishes (#317; review of PR #132, finding 3).
332
+ if (launch.address !== undefined) await writeAddress(home, session, launch.address);
330
333
 
331
334
  // And then asking whether a TUI came up, rather than assuming one did. The
332
335
  // text goes into the tab's own shell, which may have been busy with a
@@ -344,22 +347,69 @@ async function bringUpSession(bots, home, live, session, bot, title) {
344
347
  // duty: one held on a first-run screen, or one that refused the argument, has
345
348
  // been told nothing. So it is received only when the session's own record
346
349
  // holds it as a user turn, and otherwise not confirmed (#274).
347
- const promptReceived = prompt === undefined
350
+ const promptReceived = launch.prompt === undefined
348
351
  ? undefined
349
- : await heldInRecord(home, session.name, harness, prompt, { launched, running: tui.running === true });
352
+ : await heldInRecord(home, session.name, harness, launch.prompt, { launched, running: tui.running === true });
350
353
 
351
354
  return entry(made, {
352
355
  bot: bot.name,
353
356
  name: session.name,
354
357
  created: true,
358
+ // Asked once the tab is up: the step at the head of the line has run by
359
+ // then, and whatever it said is on the tab's screen, not in this report.
360
+ noMailbox: lacksMailbox(home, session, harness),
355
361
  ...tui,
356
362
  promptReceived,
357
- promptFile,
358
- resumed: resume !== undefined,
363
+ promptFile: launch.promptFile,
364
+ resumed: launch.resume !== undefined,
365
+ noConversation: which.noConversation,
359
366
  unclaimed: which.unclaimed,
360
367
  });
361
368
  }
362
369
 
370
+ /**
371
+ * The line that starts this session: resuming `resume`, or, with none, a fresh
372
+ * start carrying its duty. Returns `{ resume, prompt, promptFile, command, address }`.
373
+ */
374
+ function launchFor(bots, bot, session, harness, home, workDir, resume, held) {
375
+ const prompt = resume === undefined ? startPrompt(session, { home, workDir }) : undefined;
376
+ // Anything longer than a line goes to the harness out of a file, rather than
377
+ // through the tab's shell a character at a time.
378
+ const promptFile = prompt === undefined || isShortPrompt(prompt) ? undefined : promptPath(bots, bot.name, session.name);
379
+ // A new conversation is given a new name. A resume goes on under the one the
380
+ // kit gave it, and under no `-n` at all when the book `held` none of the
381
+ // kit's own: a resume keeps whatever name the conversation has, which is
382
+ // proven, and whether `-n` renames it is not (#286). Such a session is
383
+ // written to through its mailbox until it next starts fresh.
384
+ const address = harness !== 'claude' ? undefined
385
+ : resume === undefined ? addressOf(bot.name, session.name)
386
+ : isAddressOf(bot.name, session.name, held) ? held : undefined;
387
+ const line = launchCommand(session, {
388
+ harness,
389
+ home,
390
+ workDir,
391
+ prompt,
392
+ promptFile,
393
+ resume,
394
+ address,
395
+ });
396
+ // The session's mailbox first, from inside its own tab (#317), and the
397
+ // harness whatever became of it.
398
+ const command = reachesMail(session, harness) ? `${mailboxStep(bots, bot.name, session.name)}; ${line}` : line;
399
+ return { resume, prompt, promptFile, command, address };
400
+ }
401
+
402
+ /**
403
+ * The prompt is written where the launch line can read it from, before that
404
+ * line is typed. It is the kit's own file, not the user's: theirs stays where
405
+ * they put it, in the bot home.
406
+ */
407
+ function writePrompt({ prompt, promptFile }) {
408
+ if (promptFile === undefined) return;
409
+ mkdirSync(path.dirname(promptFile), { recursive: true });
410
+ writeFileSync(promptFile, prompt);
411
+ }
412
+
363
413
  /**
364
414
  * How long a running harness's own record is given to show the start prompt.
365
415
  * Seen live (Claude Code 2.1.282, a trusted folder): the hook's id and the
@@ -406,8 +456,8 @@ function lookFor(handle, timeoutMs) {
406
456
  }
407
457
 
408
458
  /**
409
- * Make sure the book holds this session's mailbox, and — only where this run
410
- * has just launched the session under it — the name it answers to.
459
+ * Write down the name a session answers to, where this run has just launched it
460
+ * under that name.
411
461
  *
412
462
  * The name is written by whoever typed the launch line that gave it, and by
413
463
  * nobody else. A session that was already running when the kit reached it was
@@ -415,56 +465,143 @@ function lookFor(handle, timeoutMs) {
415
465
  * that no harness answers to is worse than no name: the kit would send another
416
466
  * session to an address with nobody at it.
417
467
  *
468
+ * The session's mailbox is not made here. The launch line this run typed
469
+ * starts with `obk session mailbox`, which the new tab's shell runs before the
470
+ * harness: Orca 1.4.210 lets a process in a tab bind a Run to that tab and to
471
+ * no other, so the `obk` that opened the tab cannot (#317, `ownMailbox`).
472
+ */
473
+ async function writeAddress(home, session, address) {
474
+ if (readBook(home).sessions[session.name]?.address === address) return;
475
+
476
+ await updateBook(home, (current) => {
477
+ current.sessions[session.name] = { ...current.sessions[session.name], address };
478
+ });
479
+ }
480
+
481
+ /**
482
+ * `obk session mailbox`: give one session its mailbox, bound to the tab this
483
+ * runs in, which is the session's own. The kit's launch line runs it in a new
484
+ * tab before the harness, and nothing else is meant to.
485
+ *
418
486
  * The mailbox is an Orca Run, made once and kept for ever. It has to be a Run
419
487
  * rather than the session's tab: Orca calls a terminal a live terminal-only
420
488
  * mailbox, says plainly that delivery does not outlive the tab, and refuses a
421
489
  * send once the pane is gone, while a Run survives a closed tab, a relaunch and
422
490
  * a restart (tech notes, section 1). Orca offers no way to delete one, so this
423
- * makes exactly one per session and never a second.
491
+ * makes one for a session whose book names none, and otherwise binds the one
492
+ * the book names to this tab: after a restart, or a closed tab brought back,
493
+ * it would stay with the tab that is gone (review of PR #248, finding 1).
424
494
  *
425
- * It is bound to `handle`, the session's own tab, and never to the tab this run
426
- * of `obk up` was typed in: Orca tells a Run's coordinator about its mail, and
427
- * only the session the mail is for should be told (issue #228). A mailbox the
428
- * session already had is bound again when this run `opened` a new tab for it —
429
- * after a restart, or a closed tab brought back — or it would stay with the tab
430
- * that is gone (review of PR #248, finding 1).
495
+ * Only inside an Orca tab. Asked from anywhere else, Orca picks a terminal
496
+ * itself, and seen live that was not the asker's (tech notes, section 1), so
497
+ * this refuses rather than bind somebody else's tab.
431
498
  *
432
499
  * A Codex session whose user turned the sandbox switch off gets none: it could
433
500
  * not read a mailbox if it had one, and an address nobody can read is worse
434
501
  * than none at all. `obk message` says so in those words.
435
502
  */
436
- async function ensureMailbox(home, bot, session, harness, handle, { named = false, opened = false } = {}) {
437
- const held = readBook(home).sessions[session.name] ?? {};
438
- if (opened && typeof held.mailbox === 'string') useMailbox(held.mailbox, handle);
503
+ export async function ownMailbox(bots, botName, sessionName) {
504
+ const home = botDir(bots, botName);
505
+ const bot = readBot(home, botName);
506
+ const session = (bot.sessions ?? []).find((entry) => entry.name === sessionName);
507
+ if (session === undefined) throw new Error(`${botName} has no session called ${sessionName} in ${path.join(home, 'bot.yaml')}.`);
508
+ const who = `${botName}/${sessionName}`;
509
+
510
+ if (!reachesMail(session, harnessOf(session, bot.harness))) {
511
+ return { bot: botName, session: sessionName, mailbox: null, change: 'none' };
512
+ }
513
+ if (process.env[TERMINAL_ENV] === undefined) {
514
+ throw new Error(`session mailbox binds ${who}'s mailbox to the Orca tab it runs in, and this is not one: ${TERMINAL_ENV} is not set. The kit runs it in the session's own tab when it starts the session.`);
515
+ }
439
516
 
440
- const address = named ? addressOf(bot.name, session.name) : undefined;
441
- const mailbox = mailboxFor(readBook(home), bot, session, harness, handle);
517
+ // Only in the tab the book names for the session. Two runs of `up` at once
518
+ // can leave a session two tabs, and the book one of them: a mailbox made or
519
+ // bound in the other would be bound to a tab no message is meant for.
520
+ const entry = readBook(home).sessions[sessionName] ?? {};
521
+ const here = process.env[TAB_ENV];
522
+ if (entry.tab === undefined || entry.tab !== here) {
523
+ throw new Error(`${who}'s mailbox is given in the tab the book names for it, ${entry.tab ?? 'none yet'}, and this is ${here ?? 'a tab that does not say which it is'}. Nothing was made or bound.`);
524
+ }
442
525
 
443
- if (mailbox === undefined && (address === undefined || held.address === address)) return;
526
+ const held = entry.mailbox;
527
+ if (typeof held === 'string') {
528
+ try {
529
+ useMailbox(held, undefined, STEP_WAIT);
530
+ } catch (error) {
531
+ // An Orca that did not answer may still have done it, and is not asked
532
+ // a second time.
533
+ if (error.code === TIMED_OUT) {
534
+ throw new Error(`${error.message}\n${who}'s mailbox ${held} may now be bound to this tab, or may still be bound where it was: Orca did not say which.`);
535
+ }
536
+ throw new Error(`${error.message}\n${who}'s mailbox ${held} is unchanged: it is still bound to ${boundTo(held)}.`);
537
+ }
538
+ return { bot: botName, session: sessionName, mailbox: held, change: 'bound' };
539
+ }
444
540
 
445
- await updateBook(home, (current) => {
446
- const entry = { ...current.sessions[session.name] };
447
- // Under the lock, and only if the book still has none: two runs at once
448
- // would each have made one, and a session with two mailboxes is a session
449
- // half its mail never reaches. The loser's Run is left unused.
450
- if (mailbox !== undefined && typeof entry.mailbox !== 'string') entry.mailbox = mailbox;
451
- if (address !== undefined) entry.address = address;
452
- current.sessions[session.name] = entry;
453
- });
541
+ // Made outside the book's lock, which is held for one read and one write
542
+ // (book.js).
543
+ let made;
544
+ try {
545
+ made = makeMailbox(who, STEP_WAIT);
546
+ } catch (error) {
547
+ const left = error.code === TIMED_OUT
548
+ ? `None is written down for ${who}; a Run Orca made after the kit stopped waiting is left unused.`
549
+ : `No mailbox was made for ${who}, and none is written down.`;
550
+ throw new Error(`${error.message}\n${left} It gets one the next time the kit starts it.`);
551
+ }
552
+
553
+ let kept;
554
+ let movedTo;
555
+ try {
556
+ await updateBook(home, (current) => {
557
+ const now = { ...current.sessions[sessionName] };
558
+ // The book may have moved to another tab while Orca was asked: a second
559
+ // `up` of the same session. Then nothing is written here and nothing
560
+ // bound, so the tab the book names keeps whatever it has.
561
+ if (now.tab !== here) {
562
+ movedTo = now.tab ?? 'none';
563
+ return undefined;
564
+ }
565
+ // Under the lock, and only if the book still has none: two runs at once
566
+ // would each have made one, and a session with two mailboxes is a session
567
+ // half its mail never reaches. The loser's Run is left unused.
568
+ if (typeof now.mailbox !== 'string') now.mailbox = made;
569
+ kept = now.mailbox;
570
+ current.sessions[sessionName] = now;
571
+ return undefined;
572
+ });
573
+ } catch (error) {
574
+ throw new Error(`${error.message}\nOrca made the mailbox ${made} for ${who}, bound to this tab, but it could not be written into the book, so it is left unused. The next time the kit starts ${who}, it makes another.`);
575
+ }
576
+ if (movedTo !== undefined) {
577
+ throw new Error(`the book now names another tab for ${who}, ${movedTo}, so the mailbox ${made} made in this tab is left unused. It is bound to this tab, and nothing was written into the book: ${who}'s mailbox there is left where it is.`);
578
+ }
579
+ // One terminal holds one Run, so making the loser took this tab off the one
580
+ // the book kept. It goes back.
581
+ if (kept !== made) useMailbox(kept, undefined, STEP_WAIT);
582
+ return { bot: botName, session: sessionName, mailbox: kept, change: kept === made ? 'made' : 'bound' };
454
583
  }
455
584
 
456
585
  /**
457
- * A new mailbox for this session, or undefined when it should not have one: it
458
- * has one already, or it is a Codex session whose user turned the sandbox
459
- * switch off, which could not read a mailbox if it had one.
460
- *
461
- * The Orca call is made outside the book's lock, which is held for one read and
462
- * one write (book.js).
586
+ * How long the step gives each Orca call. The step runs in front of the
587
+ * harness on the launch line, so it has to end: an Orca that never answers
588
+ * would otherwise leave the tab blank and the session never started. Generous,
589
+ * because a busy machine is slow.
463
590
  */
464
- function mailboxFor(book, bot, session, harness, handle) {
465
- const held = book.sessions[session.name] ?? {};
466
- if (typeof held.mailbox === 'string' || !reachesMail(session, harness)) return undefined;
467
- return makeMailbox(`${bot.name}/${session.name}`, handle);
591
+ const STEP_WAIT = { timeoutMs: 20_000 };
592
+
593
+ /** Whether a session that can have a mailbox has none in its book. */
594
+ const lacksMailbox = (home, session, harness) => reachesMail(session, harness)
595
+ && typeof readBook(home).sessions[session.name]?.mailbox !== 'string';
596
+
597
+ /** Where a Run is bound, as a sentence can say it, or the plain truth when Orca will not say. */
598
+ function boundTo(id) {
599
+ try {
600
+ const handle = coordinatorOf(id, STEP_WAIT);
601
+ return handle === undefined ? 'no tab' : `the terminal ${handle}`;
602
+ } catch {
603
+ return 'whatever it was bound to before';
604
+ }
468
605
  }
469
606
 
470
607
  /**
@@ -479,7 +616,14 @@ function mailboxFor(book, bot, session, harness, handle) {
479
616
  * has already started a harness in, the harness's own record is asked.
480
617
  */
481
618
  function whichConversation(book, home, bot, session, was, harness) {
482
- if (typeof was?.session === 'string') return { resume: was.session };
619
+ if (typeof was?.session === 'string') {
620
+ if (hasConversation(harness, home, was.session)) return { resume: was.session };
621
+ // The hook reported an id and the harness never wrote a conversation behind
622
+ // it: a session paused before its first turn is one. There is nothing to
623
+ // resume, so it starts again with its duty, and the book keeps the id in the
624
+ // history with the reason (#295).
625
+ return { noConversation: was.session };
626
+ }
483
627
  // No tab: nothing has ever run for this session, so there is nothing to find.
484
628
  if (typeof was?.tab !== 'string') return {};
485
629
 
@@ -526,14 +670,19 @@ const ids = (setup, change) => ({ project: setup.projectId, setup: setup.id, cha
526
670
  export const promptPath = (bots, bot, session) =>
527
671
  path.join(`${bots}.prompts`, `${encodeURIComponent(bot)}.${encodeURIComponent(session)}.txt`);
528
672
 
529
- function entry(tab, { bot, name, created, running = false, blockedReason, promptReceived, promptFile, resumed, unclaimed }) {
673
+ function entry(tab, { bot, name, created, running = false, blockedReason, promptReceived, promptFile, resumed, noConversation, unclaimed, noMailbox = false }) {
530
674
  const made = { bot, name, title: tab.title, tabId: tab.tabId, terminal: tab.handle, created, harnessStarted: running };
531
675
  // Whether this run picked the session up where it was or started a new one.
532
676
  // Only for a tab this run opened: a tab that was already there was left alone.
533
677
  if (resumed !== undefined) made.resumed = resumed;
678
+ // The id the book held that had no conversation behind it, now in the history.
679
+ if (noConversation !== undefined) made.noConversation = noConversation;
534
680
  // Orca's own words for what is on screen waiting to be answered, when it
535
681
  // gave any: the caller acts on it, the kit only passes it on.
536
682
  if (blockedReason !== undefined) made.blockedReason = blockedReason;
683
+ // A session that should have a mailbox and has none: the step in its tab
684
+ // did not make one, and nothing in this run would (#317).
685
+ if (noMailbox) made.noMailbox = true;
537
686
  // Conversations the harness still has in this bot's folder that no session
538
687
  // claims: the caller is told, because the kit will not pick one.
539
688
  if (unclaimed !== undefined && unclaimed.length > 0) made.unclaimed = unclaimed;