@assuredloop/orca-bot-kit 0.5.0 → 0.5.1

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.
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.5.1",
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/cli.js CHANGED
@@ -892,9 +892,14 @@ function refuseWhenOrcaIsDown() {
892
892
 
893
893
  /** The same facts as `--json`, as lines, for a person reading along. */
894
894
  /** 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.');
895
+ const howLines = (tab) => {
896
+ if (tab.resumed === true) return [' it was told to resume the session the book holds, with its conversation.'];
897
+ if (tab.noConversation === undefined) return [' it was told to start a new session: the book holds none for this one yet.'];
898
+ return [
899
+ ` it was told to start a new session: the book's conversation ${tab.noConversation} had nothing on record`,
900
+ " behind it, so it went into the session's history.",
901
+ ];
902
+ };
898
903
 
899
904
  /**
900
905
  * What the harness still has in this bot's folder that no session claims. The
@@ -1205,7 +1210,7 @@ function harnessLines(tab, bots) {
1205
1210
  // What the line that was typed in asked for, and where the kit got it: the
1206
1211
  // session the book holds, one the harness itself still had on record, or a new
1207
1212
  // one — and if a new one, whether anything was known about an older one.
1208
- const how = [howLine(tab)];
1213
+ const how = howLines(tab);
1209
1214
 
1210
1215
  if (!tab.harnessStarted) {
1211
1216
  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/message.js CHANGED
@@ -176,8 +176,8 @@ export function sendMessage(bots, { to: target, from: sender, tab, subject, text
176
176
  * Orca fences a Run to one reader, its coordinator. A session whose tab is live
177
177
  * is bound to that tab and read as it; one whose tab is down is read as its
178
178
  * 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.
179
+ * is not a peek acknowledges every batch waiting, one after another, so one
180
+ * check hands over all of it (issue #299).
181
181
  */
182
182
  export function checkMail(bots, { bot: botName, session: sessionName, tab, peek = false }) {
183
183
  const who = sessionName === undefined && botName === undefined
@@ -212,9 +212,16 @@ export function checkMail(bots, { bot: botName, session: sessionName, tab, peek
212
212
  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
213
  };
214
214
  }
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);
215
+ // Orca hands mail over a batch at a time and replays the oldest until it is
216
+ // acknowledged; the acknowledgement answers with the next batch. So a read
217
+ // takes every batch in turn, or newer mail waits behind an older one (#299).
218
+ const messages = [];
219
+ let found = readMailbox(who.mailbox, { peek, handle });
220
+ for (;;) {
221
+ messages.push(...(found.messages ?? []).map((message) => asMessage(bots, message)));
222
+ if (peek || !found.deliveryId || (found.messages ?? []).length === 0) break;
223
+ found = ackMailbox(who.mailbox, found.deliveryId, handle);
224
+ }
218
225
 
219
226
  return { bots, bot: who.bot, session: who.session, mailbox: who.mailbox, read: !peek, messages };
220
227
  }
@@ -335,7 +342,7 @@ function nudge(to, from, subject) {
335
342
  if (to.tab === undefined) return { nudged: false };
336
343
 
337
344
  try {
338
- const found = tabToTypeInto(to.home, to.tab, LOOK_MS);
345
+ const found = lookAt(to);
339
346
  if (found.blocked !== undefined) return { nudged: false, blocked: found.blocked };
340
347
  // A line that lands in a shell is run there, with the sender's subject in it.
341
348
  if (found.unsure !== undefined) return { nudged: false, nudgeTrouble: found.unsure };
@@ -356,6 +363,21 @@ function nudge(to, from, subject) {
356
363
  }
357
364
  }
358
365
 
366
+ /**
367
+ * The look before the nudge. Orca now and then refuses a handle it has just
368
+ * listed as stale, and a fresh listing hands out one that works (tech notes,
369
+ * section 1), so that refusal earns one more listing and one more look. Only
370
+ * one: a second refusal is reported as it is (#294).
371
+ */
372
+ function lookAt(to) {
373
+ try {
374
+ return tabToTypeInto(to.home, to.tab, LOOK_MS);
375
+ } catch (error) {
376
+ if (error.code !== 'terminal_handle_stale') throw error;
377
+ return tabToTypeInto(to.home, to.tab, LOOK_MS);
378
+ }
379
+ }
380
+
359
381
  /** `<bot>/<session>`, or a bot on its own when it has only the one session. */
360
382
  function splitTarget(target) {
361
383
  const parts = target.split('/');
package/src/orca.js CHANGED
@@ -30,11 +30,17 @@ export function orca(args) {
30
30
  if (answer.ok !== true) {
31
31
  // Orca's own words: it knows what went wrong, and the caller is an LLM
32
32
  // that can act on them.
33
- throw new Error(`Orca refused ${args.join(' ')}: ${answer.error?.message ?? 'no reason given'}`);
33
+ throw refusal(args, answer);
34
34
  }
35
35
  return answer.result;
36
36
  }
37
37
 
38
+ /** Orca's refusal as an error, with its code on it, so a caller can tell a stale handle from the rest (#294). */
39
+ const refusal = (args, answer) => Object.assign(
40
+ new Error(`Orca refused ${args.join(' ')}: ${answer.error?.message ?? 'no reason given'}`),
41
+ { code: answer.error?.code },
42
+ );
43
+
38
44
  /** One Orca command, and the whole envelope back, refusals included. */
39
45
  function ask(args) {
40
46
  const asked = spawnSync(orcaCli(), [...args, '--json'], { encoding: 'utf8' });
@@ -257,7 +263,7 @@ export function harnessInTab(handle, timeoutMs) {
257
263
  // Out of time means nothing went idle, busy or absent alike. That is an
258
264
  // answer, not a breakdown.
259
265
  if (answer.ok !== true && answer.error?.code !== 'timeout') {
260
- throw new Error(`Orca refused ${args.join(' ')}: ${answer.error?.message ?? 'no reason given'}`);
266
+ throw refusal(args, answer);
261
267
  }
262
268
 
263
269
  const shown = orca(['terminal', 'show', '--terminal', handle]).terminal;
@@ -461,7 +467,7 @@ export function postMessage({ to, from, subject, body, type = 'status', thread }
461
467
  export const readMailbox = (id, { peek = false, handle } = {}) =>
462
468
  orca(['orchestration', 'check', '--run', id, ...as(handle), ...(peek ? ['--peek'] : [])]);
463
469
 
464
- /** Say a batch has been read, so the next check brings the one after it. */
470
+ /** Say a batch has been read. Orca answers with the batch after it. */
465
471
  export const ackMailbox = (id, delivery, handle) =>
466
472
  orca(['orchestration', 'check', '--run', id, ...as(handle), '--ack', delivery]);
467
473
 
package/src/up.js CHANGED
@@ -8,9 +8,9 @@ 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
15
  import { addressOf, harnessOf, isShortPrompt, launchCommand, reachesMail, sessionTrouble, startPrompt, workDirOf } from './launch.js';
16
16
  import { asFolderProject, findProject, harnessInTab, makeMailbox, makeProject, openTab, retitleTab, tabs, tellWindow, typeIntoTab, useMailbox } from './orca.js';
@@ -263,33 +263,14 @@ async function bringUpSession(bots, home, live, session, bot, title) {
263
263
  // given again (PRD 6.4) — after a clear it is, and that is the hook's work.
264
264
  // Asked before the book is held, because it reads a folder of the harness's
265
265
  // 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
- });
266
+ let which = whichConversation(book, home, bot, session, was, harness);
267
+ const launchOf = (chosen) => launchFor(bots, bot, session, harness, home, workDir, chosen.resume);
268
+ let launch = launchOf(which);
281
269
 
282
270
  // A work dir is a plain folder, made for the session before it is told about
283
271
  // it (PRD 6.4). Nothing here is a git worktree.
284
272
  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
- }
273
+ writePrompt(launch);
293
274
 
294
275
  const made = openTab(home, tabTitle);
295
276
 
@@ -305,20 +286,41 @@ async function bringUpSession(bots, home, live, session, bot, title) {
305
286
  // built, noted so that health can say when the file moves on and the session
306
287
  // does not (#272).
307
288
  const rules = rulesStamp(home);
289
+ let held;
308
290
  await updateBook(home, (current) => {
309
291
  // What the harness has in this folder that nobody claims goes on the record,
310
292
  // for a person or Bot Father to settle — added to whatever was already noted,
311
293
  // because this run's scan cannot see what an earlier one found. The kit never
312
294
  // settles it itself.
313
- const entry = { ...current.sessions[session.name], tab: made.tabId, launched, rules };
295
+ let entry = { ...current.sessions[session.name], tab: made.tabId, launched, rules };
314
296
  if (rules === undefined) delete entry.rules;
297
+ held = entry.session;
298
+ // Before the line is typed, so the hook finds no id here and takes the one
299
+ // it reports for a start rather than a clear, which would tell the duty twice.
300
+ if (which.noConversation !== undefined && held === which.noConversation) entry = forgetSession(entry, 'no conversation');
315
301
  current.sessions[session.name] = withUnclaimed(entry, which.unclaimed ?? []);
316
302
  forgetClaimed(current);
317
303
  });
318
304
 
305
+ // The old tab's hook can name another conversation while the tab is being
306
+ // opened. A fresh start chosen before that is chosen again from the id the
307
+ // book holds now: resumed if the harness has it, and otherwise set aside the
308
+ // same way (review of PR #310).
309
+ if (which.noConversation !== undefined && typeof held === 'string' && held !== which.noConversation) {
310
+ which = hasConversation(harness, home, held) ? { resume: held } : { noConversation: held };
311
+ if (which.noConversation !== undefined) {
312
+ await updateBook(home, (current) => {
313
+ const entry = current.sessions[session.name];
314
+ if (entry?.session === held) current.sessions[session.name] = forgetSession(entry, 'no conversation');
315
+ });
316
+ }
317
+ launch = launchOf(which);
318
+ writePrompt(launch);
319
+ }
320
+
319
321
  // Typing it in is the way: for a project the kit has just made, giving Orca
320
322
  // the harness as the tab's own command times out and leaves a dead tab.
321
- typeIntoTab(made.handle, command);
323
+ typeIntoTab(made.handle, launch.command);
322
324
 
323
325
  // Now the session has an address, and not before: the mailbox it can be
324
326
  // written to, and — on Claude Code — the name that line just gave it, which
@@ -344,9 +346,9 @@ async function bringUpSession(bots, home, live, session, bot, title) {
344
346
  // duty: one held on a first-run screen, or one that refused the argument, has
345
347
  // been told nothing. So it is received only when the session's own record
346
348
  // holds it as a user turn, and otherwise not confirmed (#274).
347
- const promptReceived = prompt === undefined
349
+ const promptReceived = launch.prompt === undefined
348
350
  ? undefined
349
- : await heldInRecord(home, session.name, harness, prompt, { launched, running: tui.running === true });
351
+ : await heldInRecord(home, session.name, harness, launch.prompt, { launched, running: tui.running === true });
350
352
 
351
353
  return entry(made, {
352
354
  bot: bot.name,
@@ -354,12 +356,45 @@ async function bringUpSession(bots, home, live, session, bot, title) {
354
356
  created: true,
355
357
  ...tui,
356
358
  promptReceived,
357
- promptFile,
358
- resumed: resume !== undefined,
359
+ promptFile: launch.promptFile,
360
+ resumed: launch.resume !== undefined,
361
+ noConversation: which.noConversation,
359
362
  unclaimed: which.unclaimed,
360
363
  });
361
364
  }
362
365
 
366
+ /**
367
+ * The line that starts this session: resuming `resume`, or, with none, a fresh
368
+ * start carrying its duty. Returns `{ resume, prompt, promptFile, command }`.
369
+ */
370
+ function launchFor(bots, bot, session, harness, home, workDir, resume) {
371
+ const prompt = resume === undefined ? startPrompt(session, { home, workDir }) : undefined;
372
+ // Anything longer than a line goes to the harness out of a file, rather than
373
+ // through the tab's shell a character at a time.
374
+ const promptFile = prompt === undefined || isShortPrompt(prompt) ? undefined : promptPath(bots, bot.name, session.name);
375
+ const command = launchCommand(session, {
376
+ harness,
377
+ home,
378
+ workDir,
379
+ prompt,
380
+ promptFile,
381
+ resume,
382
+ address: harness === 'claude' ? addressOf(bot.name, session.name) : undefined,
383
+ });
384
+ return { resume, prompt, promptFile, command };
385
+ }
386
+
387
+ /**
388
+ * The prompt is written where the launch line can read it from, before that
389
+ * line is typed. It is the kit's own file, not the user's: theirs stays where
390
+ * they put it, in the bot home.
391
+ */
392
+ function writePrompt({ prompt, promptFile }) {
393
+ if (promptFile === undefined) return;
394
+ mkdirSync(path.dirname(promptFile), { recursive: true });
395
+ writeFileSync(promptFile, prompt);
396
+ }
397
+
363
398
  /**
364
399
  * How long a running harness's own record is given to show the start prompt.
365
400
  * Seen live (Claude Code 2.1.282, a trusted folder): the hook's id and the
@@ -479,7 +514,14 @@ function mailboxFor(book, bot, session, harness, handle) {
479
514
  * has already started a harness in, the harness's own record is asked.
480
515
  */
481
516
  function whichConversation(book, home, bot, session, was, harness) {
482
- if (typeof was?.session === 'string') return { resume: was.session };
517
+ if (typeof was?.session === 'string') {
518
+ if (hasConversation(harness, home, was.session)) return { resume: was.session };
519
+ // The hook reported an id and the harness never wrote a conversation behind
520
+ // it: a session paused before its first turn is one. There is nothing to
521
+ // resume, so it starts again with its duty, and the book keeps the id in the
522
+ // history with the reason (#295).
523
+ return { noConversation: was.session };
524
+ }
483
525
  // No tab: nothing has ever run for this session, so there is nothing to find.
484
526
  if (typeof was?.tab !== 'string') return {};
485
527
 
@@ -526,11 +568,13 @@ const ids = (setup, change) => ({ project: setup.projectId, setup: setup.id, cha
526
568
  export const promptPath = (bots, bot, session) =>
527
569
  path.join(`${bots}.prompts`, `${encodeURIComponent(bot)}.${encodeURIComponent(session)}.txt`);
528
570
 
529
- function entry(tab, { bot, name, created, running = false, blockedReason, promptReceived, promptFile, resumed, unclaimed }) {
571
+ function entry(tab, { bot, name, created, running = false, blockedReason, promptReceived, promptFile, resumed, noConversation, unclaimed }) {
530
572
  const made = { bot, name, title: tab.title, tabId: tab.tabId, terminal: tab.handle, created, harnessStarted: running };
531
573
  // Whether this run picked the session up where it was or started a new one.
532
574
  // Only for a tab this run opened: a tab that was already there was left alone.
533
575
  if (resumed !== undefined) made.resumed = resumed;
576
+ // The id the book held that had no conversation behind it, now in the history.
577
+ if (noConversation !== undefined) made.noConversation = noConversation;
534
578
  // Orca's own words for what is on screen waiting to be answered, when it
535
579
  // gave any: the caller acts on it, the kit only passes it on.
536
580
  if (blockedReason !== undefined) made.blockedReason = blockedReason;