@assuredloop/orca-bot-kit 0.26.1 → 0.27.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
@@ -365,19 +365,6 @@ address that is none of your bots folder's sessions, the hook tells it so, and
365
365
  names the address `obk message to` would have given. The message goes either
366
366
  way ([ADR 0032](docs/adr/0032-a-native-message-outside-the-fleet-is-warned-about.md)).
367
367
 
368
- And a third: at the end of each turn, it tells the session once about fleet
369
- mail the kit sent it that is still unread, without typing into its tab
370
- ([ADR 0035](docs/adr/0035-messaging-transport.md)). It asks Orca only when the kit
371
- knows of mail for that session.
372
-
373
- A Codex bot gets one more hook too. Inside Codex's sandbox the kit cannot always
374
- tell whether the receiver of a message has its harness in front, as for a Claude
375
- session busy running a command, so `obk message send` leaves that nudge, and the
376
- hook, which Codex runs outside its sandbox right after the command, decides it and
377
- types it, or says why not ([ADR 0034](docs/adr/0034-orca-is-the-host.md)). Codex
378
- asks you to trust a hook it has not seen before, so a Codex bot made before this
379
- hook asks again once.
380
-
381
368
  So: kill a tab, or reboot, and `obk up` brings the session back with the
382
369
  conversation it was having, rather than starting a new one. Clear a session —
383
370
  `/clear` on Claude Code, `/new` on Codex — and the kit writes down the new id,
@@ -477,7 +464,12 @@ same refusals, and one more: it also refuses a tab with only its shell in front
477
464
  whose conversation the book does not name. `obk up` leaves what is paused
478
465
  closed until `obk unpause` brings it back with its conversations. Retiring a
479
466
  session takes it off the bot and keeps its conversations in the book under
480
- `retired`. Retiring a bot closes
467
+ `retired`. Once its tab is closed, it stops what the session left running in
468
+ its work dir: each of your processes whose working folder is there, and what
469
+ they started, with SIGTERM and, after up to 3 seconds, SIGKILL. It names each
470
+ process it stopped, and each it left: one that only shares a process group with
471
+ them, or one it could not stop. Where it cannot read the processes, as inside
472
+ Codex's sandbox, it says so and stops nothing. Retiring a bot closes
481
473
  its tabs, removes its Orca project and moves its folder to `retired/`; it will
482
474
  not touch a bot whose Orca project holds a tab your book does not name. The
483
475
  folder moves only once Orca no longer lists the project; if Orca still lists it,
@@ -557,6 +549,8 @@ What it looks for:
557
549
  `<bot>.<session>` every fleet's sessions had before 0.10.0, which a session of
558
550
  another fleet can write to. Its next start through the kit gives it one of its
559
551
  own, so the finding names the `obk restart … --session` that does it;
552
+ - **a process still running in a retired session's work dir**: one from before
553
+ retire stopped them, or one it could not stop, with its pid and command;
560
554
  - **leftovers no book owns**: an Orca project inside your bots folder with no
561
555
  bot in it, a tab in a bot's project that the book does not name, a start
562
556
  prompt written for a session that has gone, a clone of a skills source you no
@@ -580,7 +574,7 @@ verdict: which of these matter, and in what order, is for you or for Bot Father.
580
574
  ## Sessions and bots that talk
581
575
 
582
576
  Ask the kit how to reach a session, and it answers with the road and the
583
- address ([ADR 0035](docs/adr/0035-messaging-transport.md)):
577
+ address ([ADR 0042](docs/adr/0042-messaging-transport.md)):
584
578
 
585
579
  ```sh
586
580
  obk message to --bots /path/to/my-bots --to api-bot/daily
@@ -623,17 +617,17 @@ mailbox where it was and says so, and the harness starts all the same. A check
623
617
  holds its turn for 40 seconds at most: with more mail than it reads in that
624
618
  time, it shows what it read and says the rest is still waiting.
625
619
 
626
- Nothing in a mailbox wakes anybody, so each message gets one signal that tells
627
- the receiver to look. Orca's own notice comes first: Orca types it into an idle
628
- tab. The kit watches an idle receiver for up to 8 seconds, and reads the
629
- receiver's own record of its turns. When Orca's notice is there, the kit types
630
- nothing. When no turn started, the kit types its own line. A Claude session busy
631
- with a turn gets nothing typed: its hook tells it at the end of the turn. A busy
632
- Codex session gets the kit's line, which Codex takes into the turn it is having.
633
- The send says which signal went. A session that is not up is not typed into at
634
- all; its message waits. When a session is retired, the kit says how many
635
- messages sent to it were not read with `obk message check`, and who sent them
636
- ([ADR 0035](docs/adr/0035-messaging-transport.md)).
620
+ Nothing in a mailbox wakes anybody, so each message gets one signal: Orca's
621
+ own notice, which Orca types into the receiver's tab once it is idle. A busy
622
+ receiver gets it a minute or a few after its turn ends. The kit types nothing
623
+ into the receiver's tab, and the send says which road it took. For urgent mail,
624
+ `obk message send --interrupt` presses Escape once in a busy receiver's tab to
625
+ end its turn, and then sends the message, so the notice comes at once. Codex
626
+ keeps a command it was running as a background terminal after an Escape. A
627
+ receiver reads its mail with `obk message check`, which marks it read: a bare
628
+ `orca orchestration check` leaves it unread, and Orca then gives no more
629
+ notices for that mailbox
630
+ ([ADR 0042](docs/adr/0042-messaging-transport.md)).
637
631
 
638
632
  A message longer than 4 KiB is written to a file beside your bots folder and
639
633
  named in the message, so a long one arrives whole without landing a document in
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assuredloop/orca-bot-kit",
3
- "version": "0.26.1",
3
+ "version": "0.27.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
package/rules/mail.md CHANGED
@@ -12,12 +12,12 @@ If your harness refuses a message, do not send it again by another road. Tell
12
12
  your maker or your user, with the harness's reason. A refusal can mean a missing
13
13
  permission rule, which they can add through the kit after the user's yes.
14
14
 
15
- Mail is queued, not an interruption. Read it when you finish what you are on.
15
+ Mail is queued, not an interruption. When Orca says you have mail, read it
16
+ with `"${OBK_CLI:-obk}" message check --bots <bots>`, never a bare `orca
17
+ orchestration check`: that leaves it unread, and Orca stops telling you of mail.
16
18
 
17
19
  Ask for a reply when you need one, and send one when you were asked for one.
18
20
 
19
- A message sent is queued, not read or acted on: the reply, or the work done,
20
- is what shows it arrived. Look for one before you send it again.
21
+ A sent message is only queued. Look for the reply or the work before you resend.
21
22
 
22
- Write to the session that needs it, not to several in the hope that one of
23
- them is right.
23
+ Write only to the session that needs it, not to several at once.
package/src/book.js CHANGED
@@ -6,12 +6,14 @@
6
6
  // theirs. Beside each session's tab it holds the harness session id that
7
7
  // session is running under, and every id it ran under before.
8
8
 
9
- import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from 'node:fs';
9
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, writeFileSync } from 'node:fs';
10
10
  import path from 'node:path';
11
11
  import { DatabaseSync } from 'node:sqlite';
12
12
  import { isDeepStrictEqual } from 'node:util';
13
13
  import { parse, stringify } from 'yaml';
14
14
 
15
+ import { kitFolders } from './launch.js';
16
+
15
17
  const HEADER = `# What Orca calls this bot on this machine, and where each of its sessions
16
18
  # lives. \`obk up\` writes this file; it is committed with the rest of the repo.
17
19
  #
@@ -151,7 +153,7 @@ function takeLock(home) {
151
153
  try {
152
154
  return lockOn(lockFile(home), WAIT_MS);
153
155
  } catch (error) {
154
- throw waitedTooLong(home, error);
156
+ throw error instanceof LockNotWritable ? error : waitedTooLong(home, error);
155
157
  }
156
158
  }
157
159
 
@@ -162,7 +164,21 @@ function takeLock(home) {
162
164
  * `EXCLUSIVE`, alone, once every `SHARED` holder has let go.
163
165
  */
164
166
  function lockOn(file, waitMs, how = 'IMMEDIATE') {
165
- mkdirSync(path.dirname(file), { recursive: true });
167
+ // SQLite opens a file it cannot write read-only, without a word, and a
168
+ // read-only connection's BEGIN passes while another process holds the lock:
169
+ // seen in Codex's sandbox (#534). So the file is opened for writing first,
170
+ // and a lock that cannot be held is an error, never a turn that is not one.
171
+ // Only while this process holds no turn on it: closing any descriptor of a
172
+ // file lets go of every lock the process holds on that file, and SQLite's
173
+ // own are among them. A file a turn is held on was written already.
174
+ if (!heldHere.has(file)) {
175
+ try {
176
+ mkdirSync(path.dirname(file), { recursive: true });
177
+ closeSync(openSync(file, 'a'));
178
+ } catch (error) {
179
+ throw new LockNotWritable(file, error);
180
+ }
181
+ }
166
182
 
167
183
  const db = new DatabaseSync(file);
168
184
  try {
@@ -181,17 +197,24 @@ function lockOn(file, waitMs, how = 'IMMEDIATE') {
181
197
  throw error;
182
198
  }
183
199
 
200
+ heldHere.set(file, (heldHere.get(file) ?? 0) + 1);
184
201
  return {
185
202
  release() {
186
203
  try {
187
204
  db.exec('COMMIT');
188
205
  } finally {
189
206
  db.close();
207
+ const left = heldHere.get(file) - 1;
208
+ if (left > 0) heldHere.set(file, left);
209
+ else heldHere.delete(file);
190
210
  }
191
211
  },
192
212
  };
193
213
  }
194
214
 
215
+ /** How many turns this process holds on each lock file, by its path. */
216
+ const heldHere = new Map();
217
+
195
218
  /**
196
219
  * Where a writer takes its turn: a folder of the kit's own beside the bots repo,
197
220
  * carrying that folder's own name, the way start-prompt files sit beside it
@@ -213,14 +236,31 @@ function lockOn(file, waitMs, how = 'IMMEDIATE') {
213
236
  * the bots folder through a link and another by its real path, and two paths
214
237
  * would be two locks for one book (#375).
215
238
  */
216
- const lockFile = (home) => {
239
+ const lockFile = (home) => path.join(botLocks(home), `${encodeURIComponent(path.basename(realpathSync(home)))}.lock`);
240
+
241
+ /**
242
+ * The bot's own folder of locks, `<bots>.locks/<bot>/`: the one a Codex
243
+ * session of the bot is given to write, beside its mail and its start prompts
244
+ * (#534). One folder per bot, so a bot's sessions can take each other's turns
245
+ * and no other bot's.
246
+ */
247
+ const botLocks = (home) => {
217
248
  const real = realpathSync(home);
218
- return path.join(
219
- `${path.dirname(path.dirname(real))}.locks`,
220
- `${encodeURIComponent(path.basename(real))}.lock`,
221
- );
249
+ return kitFolders(path.dirname(path.dirname(real)), path.basename(real)).locks;
222
250
  };
223
251
 
252
+ /**
253
+ * A lock the kit cannot write. Inside Codex's sandbox that is a session
254
+ * started before the kit gave its bot a folder of locks, so it says what puts
255
+ * it right.
256
+ */
257
+ class LockNotWritable extends Error {
258
+ constructor(file, why) {
259
+ super(`the kit cannot write its lock ${file} (${why.code ?? why.message}), so it did not take the turn, and nothing was done. A Codex session started before this version of the kit cannot write its bot's folder of locks: restart this session with the kit. Elsewhere, make ${path.dirname(file)} writable.`);
260
+ this.code = why.code;
261
+ }
262
+ }
263
+
224
264
  /**
225
265
  * One session's turn at its mailbox (#321): held by `session mailbox` for its
226
266
  * whole step, by `message check` in the session's own tab for its bind, read and
@@ -286,11 +326,11 @@ export function takeTypingTurn(home, session, waitMs) {
286
326
 
287
327
  /**
288
328
  * A session's turn for one whole line, sent with its return in one go, as the
289
- * mail nudge is: through the typing turn's gate, waited for `waitMs` at most,
329
+ * mail interrupt is: through the typing turn's gate, waited for `waitMs` at most,
290
330
  * and then held beside any other line, so whole lines never wait on each
291
331
  * other, only on typing one key at a time. Returns `{ release }`, or undefined
292
332
  * when the gate did not come within `waitMs`. Every whole line the kit types
293
- * takes it: the nudge, the list line, the skills reload and the grooming line (#482).
333
+ * takes it: the mail interrupt, the list line, the skills reload and the grooming line (#482).
294
334
  */
295
335
  export function takeLineTurn(home, session, waitMs) {
296
336
  const gate = turnOn(home, session, 'typing', waitMs);
@@ -312,8 +352,9 @@ export const TYPING_WAIT_MS = 5000;
312
352
  export const TYPING_HELD = `the kit is typing into it, and it was still at it after ${TYPING_WAIT_MS / 1000} s, so nothing was typed`;
313
353
 
314
354
  /**
315
- * How long a typing turn waits for the lines already on their way: a nudge
316
- * holds its line's turn while Orca waits up to 5 s to see it start a turn.
355
+ * How long a typing turn waits for the lines already on their way: a line
356
+ * re-issued to Orca holds its turn while Orca waits up to 5 s to see it start
357
+ * a turn.
317
358
  */
318
359
  const LINES_WAIT_MS = 10_000;
319
360
 
@@ -344,10 +385,8 @@ const SQLITE_BUSY = 5;
344
385
  * link by one command and by its real path by another, and two paths would be
345
386
  * two turns.
346
387
  */
347
- const mailboxLockFile = (home, session) => {
348
- const real = realpathSync(home);
349
- return path.join(`${path.dirname(path.dirname(real))}.locks`, `${path.basename(real)}.${session}.mailbox.lock`);
350
- };
388
+ const mailboxLockFile = (home, session) =>
389
+ path.join(botLocks(home), `${path.basename(realpathSync(home))}.${session}.mailbox.lock`);
351
390
 
352
391
  /**
353
392
  * Something else has been writing the book for longer than this run is prepared
package/src/cli.js CHANGED
@@ -19,14 +19,14 @@ import { checkHealth, orcaSettingFindings } from './health.js';
19
19
  import { initBots } from './init.js';
20
20
  import { nameSession } from './name.js';
21
21
  import { APPROVALS, harnessOf, HARNESSES, ownCli, refuseApprovalArgs, shellWord, workDirOf } from './launch.js';
22
- import { checkMail, decideLeftNudges, lookUp, noMailboxYet, sendMessage, sessionInTab, stillUnread, WATCH_MS } from './message.js';
22
+ import { checkMail, lookUp, noMailboxYet, sendMessage, sessionInTab } from './message.js';
23
23
  import { orcaCli, orcaTrouble, RELOAD_LINE, TERMINAL_ENV } from './orca.js';
24
24
  import { allowIn, beyondDefaults, refuseBroad, refuseNoCodexForm, runsOnClaude, runsOnCodex, takeBack, writePermissions } from './permissions.js';
25
25
  import { pauseSessions, unpauseSessions } from './pause.js';
26
26
  import { recordSession, SHELL_ENV, TAB_ENV } from './record.js';
27
27
  import { restartSessions } from './restart.js';
28
28
  import { clearSession, compactSession } from './clear.js';
29
- import { retireBot, retireSession, unreadWords } from './retire.js';
29
+ import { retireBot, retireSession } from './retire.js';
30
30
  import { sentWarning } from './sent.js';
31
31
  import { readRoster } from './roster.js';
32
32
  import { buildAgents, buildRules, CODEX_CAP } from './rules.js';
@@ -109,7 +109,9 @@ Usage:
109
109
  End a session: close its tab and take it off the
110
110
  bot, keeping its conversations in the book, and
111
111
  first do the same for the temporary sessions it
112
- made, and theirs. Or end a bot: close its tabs,
112
+ made, and theirs. It stops what the session left
113
+ running in its work dir, and names each process.
114
+ Or end a bot: close its tabs,
113
115
  remove its Orca project and move its folder to
114
116
  retired/. It will not retire a bot whose Orca
115
117
  project holds a tab your book does not name, and
@@ -207,9 +209,12 @@ Usage:
207
209
  the Orca mailbox. Nothing is sent.
208
210
  obk message send --bots <path> --to <bot>[/<session>] [--from <bot>/<session>]
209
211
  --subject <text> [--text <text> | --text-file <path>]
210
- [--thread <id>]
211
- Put a message in that session's Orca mailbox and
212
- tell its tab to look. A message too long to travel
212
+ [--thread <id>] [--interrupt]
213
+ Put a message in that session's Orca mailbox. Orca
214
+ tells it to look once it is idle. --interrupt is
215
+ for urgent mail: when the session is busy, the kit
216
+ presses Escape once in its tab first, to end its
217
+ turn, so it looks now. A message too long to travel
213
218
  as itself is written to a file beside your bots
214
219
  folder and named in the message. A pair the
215
220
  harness's own messaging reaches is not carried:
@@ -305,17 +310,6 @@ Usage:
305
310
  Claude session's native message, it warns the
306
311
  session when the address was none of this bots
307
312
  folder's own. It never stops a message.
308
- obk session nudge --bots <path> --bot <bot>
309
- For the kit's own hook, not for typing: after a
310
- Codex session's shell command, it decides the mail
311
- nudges a send there left because it could not
312
- tell from inside Codex's sandbox, and types each
313
- one or says why not.
314
- obk session mail --bots <path> --bot <bot>
315
- For the kit's own hook, not for typing: at the end
316
- of a Claude session's turn, it tells the session
317
- once about each fleet mail the kit sent it that is
318
- still unread. It types nothing into the tab.
319
313
  obk session name --bots <path> --bot <bot>
320
314
  For the kit's own hook, not for typing: after a
321
315
  Codex session's turn, it names the session's thread
@@ -409,6 +403,7 @@ async function run(argv) {
409
403
  'text-file': { type: 'string' },
410
404
  thread: { type: 'string' },
411
405
  peek: { type: 'boolean' },
406
+ interrupt: { type: 'boolean' },
412
407
  charter: { type: 'string' },
413
408
  allow: { type: 'string', multiple: true },
414
409
  disallow: { type: 'string', multiple: true },
@@ -476,8 +471,6 @@ async function run(argv) {
476
471
  // The send hook is quiet whatever it is given, a bots folder that is not
477
472
  // there included, so it goes before anything that can complain (ADR 0032).
478
473
  if (command === SENT) return sent(path.resolve(values.bots));
479
- if (command === NUDGE) return nudgeLeft();
480
- if (command === MAIL) return mailHook(path.resolve(values.bots), values.bot);
481
474
  if (command === NAME) return nameThread(path.resolve(values.bots), values.bot);
482
475
 
483
476
  // One fleet, one identity, whatever spelling of its path was given (#164).
@@ -548,8 +541,6 @@ const ANSWER_IT = [
548
541
  /** The commands a harness runs rather than a person: the kit's hooks. */
549
542
  const RECORD = 'session record';
550
543
  const SENT = 'session sent';
551
- const NUDGE = 'session nudge';
552
- const MAIL = 'session mail';
553
544
  const NAME = 'session name';
554
545
 
555
546
  /**
@@ -569,45 +560,6 @@ function sent(bots) {
569
560
  return 0;
570
561
  }
571
562
 
572
- /**
573
- * What the kit's Codex hook does after a shell command: decide the nudges a
574
- * send in this tab left for it, outside Codex's sandbox, and say to the session
575
- * what became of each, as Codex's `additionalContext` (#350, ADR 0034). Silent
576
- * when nothing was left, never a decision, and never anything but exit 0.
577
- */
578
- async function nudgeLeft() {
579
- try {
580
- const said = JSON.parse(readFileSync(0, 'utf8'));
581
- if (said?.hook_event_name !== 'PostToolUse') return 0;
582
- const decided = await decideLeftNudges(process.env[TAB_ENV]);
583
- if (decided.length > 0) {
584
- const words = decided.map((one) => `Your mail "${one.subject}" to ${one.to}: ${one.nudged ? "the kit's hook nudged its tab, from outside Codex's sandbox:" : "the kit's hook looked at its tab from outside Codex's sandbox, and"} ${nudgeLine(one, one.to).trim()}`);
585
- process.stdout.write(`${JSON.stringify({ hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: words.join('\n') } })}\n`);
586
- }
587
- } catch {
588
- // Nothing: a hook does not disturb the session it runs in.
589
- }
590
- return 0;
591
- }
592
-
593
- /**
594
- * What the kit's Claude Code Stop hook does at a turn end (#509, ADR 0035): tell
595
- * the session once about fleet mail still unread, as the Stop event's
596
- * additional context, which the session goes on with. Not a block: Claude Code
597
- * 2.1.296 draws a block's reason in red as "Stop hook error", and its additional
598
- * context as "Stop hook feedback". Nothing typed into the tab; never anything
599
- * but exit 0; silent whenever it cannot be sure.
600
- */
601
- function mailHook(bots, bot) {
602
- try {
603
- const reason = stillUnread(sameFleet(bots), bot, JSON.parse(readFileSync(0, 'utf8')), process.env[TAB_ENV], process.env[TERMINAL_ENV]);
604
- if (reason !== undefined) process.stdout.write(`${JSON.stringify({ hookSpecificOutput: { hookEventName: 'Stop', additionalContext: reason } })}\n`);
605
- } catch {
606
- // Nothing: a hook does not disturb the session it runs in.
607
- }
608
- return 0;
609
- }
610
-
611
563
  /**
612
564
  * What the kit's Codex Stop hook does at a turn end: name the session's thread
613
565
  * after its bot and session, once it is idle (#480). Silent, never a decision,
@@ -737,7 +689,7 @@ const commands = {
737
689
  ...withLines(retired.retiredWith, retired.session),
738
690
  ...closedLines(retired.closed),
739
691
  `retired ${retired.bot} ${retired.session}: off ${path.join('bots', retired.bot, 'bot.yaml')}, and its conversations kept in the book under retired`,
740
- ...unreadLines(retired.unread),
692
+ ...processLines(retired.processes),
741
693
  ...leftLines(retired.promptsLeft),
742
694
  ],
743
695
  };
@@ -749,6 +701,7 @@ const commands = {
749
701
  answer: { bots, ...retired },
750
702
  lines: [
751
703
  ...closedLines(retired.closed),
704
+ ...processLines(retired.processes),
752
705
  `${'trouble'.padEnd(9)} Orca project ${retired.project}`,
753
706
  ` ${retired.trouble}`,
754
707
  `Retire it again: ${shellWord(ownCli())} retire --bots ${shellWord(bots)} --bot ${retired.bot}`,
@@ -767,7 +720,7 @@ const commands = {
767
720
  retired.windowReloaded ? "reloaded Orca's window, so its sidebar no longer shows the project" : RELOAD_LINE,
768
721
  ]),
769
722
  `retired ${retired.bot}: moved to ${path.relative(bots, retired.moved)}, with its book, charter and memory`,
770
- ...(retired.unread ?? []).flatMap((one) => unreadLines(one, `${retired.bot}/${one.session}`)),
723
+ ...processLines(retired.processes),
771
724
  ...leftLines(retired.promptsLeft),
772
725
  ],
773
726
  };
@@ -824,7 +777,7 @@ const commands = {
824
777
  ...withLines(retired.retiredWith, retired.session),
825
778
  ...closedLines(retired.closed),
826
779
  `retired ${retired.bot} ${retired.session}, a temporary session of ${retired.maker}'s: off ${path.join('bots', retired.bot, 'bot.yaml')}, and its conversations kept in the book under retired`,
827
- ...unreadLines(retired.unread),
780
+ ...processLines(retired.processes),
828
781
  ...leftLines(retired.promptsLeft),
829
782
  ],
830
783
  };
@@ -1189,6 +1142,7 @@ const commands = {
1189
1142
  text: values.text,
1190
1143
  textFile: values['text-file'],
1191
1144
  thread: values.thread,
1145
+ interrupt: values.interrupt === true,
1192
1146
  });
1193
1147
  const where = `${answer.to.bot}/${answer.to.session}`;
1194
1148
 
@@ -1207,7 +1161,7 @@ const commands = {
1207
1161
  ...(answer.file === undefined
1208
1162
  ? []
1209
1163
  : [` it was too long to travel as itself, so it went as a file: ${answer.file}`]),
1210
- ...[nudgeLine(answer, where)],
1164
+ ...sentLines(answer, where),
1211
1165
  ],
1212
1166
  };
1213
1167
  },
@@ -1348,46 +1302,23 @@ function toLines(answer, bots, where) {
1348
1302
  }
1349
1303
 
1350
1304
  /**
1351
- * What became of the line that tells the receiver to look. The message is in
1352
- * its mailbox whatever this says, so each of these is about the tab and not
1353
- * about the message.
1305
+ * What the send says after `sent`: the road it took, and what the interrupt
1306
+ * came to when one was asked for (#555).
1354
1307
  */
1355
- function nudgeLine(answer, where) {
1356
- const seconds = answer.watchedMs === undefined ? '' : ` in ${Math.max(Math.round(answer.watchedMs / 1000), 1)} s`;
1357
- if (answer.signal === 'orca') return ` Orca's notice reached it${seconds}, so no line was typed: it reads the mail from there.`;
1358
- if (answer.signal === 'hook') {
1359
- return answer.because === 'busy'
1360
- ? " it is busy with a turn, so nothing was typed into its tab: its own hook tells it about the mail when that turn ends."
1361
- : ` a turn of other work started in its tab${seconds}, not Orca's notice, so nothing was typed: its own hook tells it about the mail when that turn ends.`;
1362
- }
1363
- const why = {
1364
- 'no-turn': `no turn started in its tab in ${WATCH_SECONDS} s, so the kit's line was typed`,
1365
- busy: "it is busy with a turn, and Codex takes a line into the turn it is having, so the kit's line was typed",
1366
- 'other-turn': "a turn of other work started in its tab, not Orca's notice, so the kit's line was typed",
1367
- }[answer.because];
1368
- const head = why === undefined ? ' ' : ` ${why}: `;
1369
- if (answer.nudged && answer.nudgeWatched === false) {
1370
- return `${head}its tab was typed into, and Orca could not say whether it was taken: ${answer.nudgeUnseen} The message waits in its mailbox for its next check either way.`;
1371
- }
1372
- if (answer.nudged && answer.nudgeUnseen !== undefined) {
1373
- return `${head}its tab was typed into, but Orca did not see the line start a turn: it may be queued behind the work in hand, or lost. The message waits in its mailbox for its next check either way.`;
1374
- }
1375
- if (answer.nudged) return `${head}its tab was told to look; it will read it when it is done with what it is doing.`;
1376
- if (answer.blocked !== undefined) {
1377
- return ` its tab has something waiting to be answered (${answer.blocked}), so nothing was typed into it. Settle that, and the mail is there.`;
1378
- }
1379
- if (answer.nudgeLeft) {
1380
- return ` it is queued, and its tab could not be told to look from here: ${answer.nudgeTrouble}. So the nudge is left for this session's hook, which looks at the tab from outside Codex's sandbox when this command is done, and says what it did.`;
1308
+ function sentLines(answer, where) {
1309
+ const road = " it is in its Orca mailbox, and Orca's own notice tells it to look once it is idle.";
1310
+ if (answer.interrupted === true) {
1311
+ return [
1312
+ `${road.replace('once it is idle', 'now')}`,
1313
+ ` it was busy, so the kit pressed Escape once in its tab to end its turn.${answer.to.harness === 'codex' ? ' Codex keeps a command it was running as a background terminal after an Escape.' : ''}`,
1314
+ ];
1381
1315
  }
1382
- if (answer.nudgeTrouble !== undefined) {
1383
- return ` it is queued, and its tab could not be told to look: ${answer.nudgeTrouble}`;
1316
+ if (answer.interrupted === false) {
1317
+ return [road, answer.idle ? ` it is idle, so it was not interrupted.` : ` it was not interrupted: ${answer.interruptTrouble}.`];
1384
1318
  }
1385
- return ` ${where} is not up, so nothing was typed anywhere: the message waits in its mailbox.`;
1319
+ return [road];
1386
1320
  }
1387
1321
 
1388
- /** The watch before the kit's line, as the send says it (#509, ADR 0035). */
1389
- const WATCH_SECONDS = WATCH_MS / 1000;
1390
-
1391
1322
  /**
1392
1323
  * What there is to say about the work dir this command was given: one finding
1393
1324
  * when it leads out of the bot home, through `..` or a link or as a path
@@ -1459,12 +1390,19 @@ const closedLines = (closed) => closed.map((tab) => `closed ${tab.bot} ${tab
1459
1390
  const withLines = (retiredWith, session) => retiredWith.flatMap((gone) => [
1460
1391
  ...closedLines(gone.closed),
1461
1392
  `retired ${gone.bot} ${gone.session}, a temporary session of ${gone.maker}'s, along with ${session}: off ${path.join('bots', gone.bot, 'bot.yaml')}, and its conversations kept in the book under retired`,
1462
- ...unreadLines(gone.unread),
1393
+ ...processLines(gone.processes),
1463
1394
  ...leftLines(gone.promptsLeft),
1464
1395
  ]);
1465
1396
 
1466
- /** The mail a retired session did not read, as far as the kit knows (#509). */
1467
- const unreadLines = (unread, who = 'it') => (unread === undefined ? [] : [`unread ${unreadWords(unread, who)}`]);
1397
+ /** What a retire stopped of what its sessions left running, and what it named and left (#537). */
1398
+ function processLines(processes) {
1399
+ if (processes === undefined) return [];
1400
+ if (processes.unreadable !== undefined) return [`processes the kit cannot tell which processes were left running in the work dir, so it stopped none: ${processes.unreadable}`];
1401
+ return [
1402
+ ...processes.stopped.map((one) => `stopped pid ${one.pid} of ${one.session}, with ${one.signal}: ${one.command}${one.cwd === null ? '' : ` in ${one.cwd}`}`),
1403
+ ...processes.left.map((one) => `left pid ${one.pid} in ${one.cwd ?? 'a folder lsof did not name'}: ${one.command}. Not stopped: ${one.why}.`),
1404
+ ];
1405
+ }
1468
1406
 
1469
1407
  /** What a retire could not remove, and how to: nothing reads these files now (#393). */
1470
1408
  const leftLines = (left = []) => left.flatMap(({ file, reason }) => [
package/src/commands.js CHANGED
@@ -37,8 +37,6 @@ export const COMMANDS = {
37
37
  'message check': ['bots'],
38
38
  'session record': ['bots', 'bot'],
39
39
  'session sent': ['bots', 'bot'],
40
- 'session nudge': ['bots', 'bot'],
41
- 'session mail': ['bots', 'bot'],
42
40
  'session name': ['bots', 'bot'],
43
41
  'session mailbox': ['bots', 'bot', 'session'],
44
42
  'session trust-hooks': ['bots', 'bot', 'session'],
@@ -61,7 +59,7 @@ export const DEFAULT_COMMANDS = [
61
59
  'skills add', 'skills remove', 'skills build', 'skills fetch', 'skills update', 'source add',
62
60
  'session add', 'session change', 'session clear', 'session compact',
63
61
  'message to', 'message send', 'message check',
64
- 'session record', 'session sent', 'session nudge', 'session mail', 'session name', 'session mailbox',
62
+ 'session record', 'session sent', 'session name', 'session mailbox',
65
63
  'session trust-hooks', 'session answer',
66
64
  'temp make', 'temp roles', 'temp retire', 'temp trust-hooks', 'temp answer',
67
65
  ];
@@ -159,43 +159,6 @@ export function heldAsUserTurn(harness, file, text) {
159
159
  return held;
160
160
  }
161
161
 
162
- /**
163
- * Where the harness keeps the conversation `id` of this bot home, and how long
164
- * that record is now: `{ harness, file, size }`, so a later look reads only
165
- * what was written after this moment. Undefined when there is no id, no
166
- * record, or it cannot be read (#509).
167
- */
168
- export function recordMark(harness, home, id) {
169
- if (typeof id !== 'string' || id === '') return undefined;
170
- try {
171
- const file = harness === 'codex' ? codexRollout(id) : claudeTranscript(home, id);
172
- if (file === undefined) return undefined;
173
- const found = statSync(file);
174
- return found.isFile() ? { harness, file, size: found.size } : undefined;
175
- } catch {
176
- return undefined;
177
- }
178
- }
179
-
180
- /**
181
- * Whether a turn of the user's written after `mark` holds `text` anywhere in
182
- * it (#509). A record that cannot be read says no: not seeing a thing is not
183
- * seeing it.
184
- */
185
- export function userTurnSince(mark, text) {
186
- if (mark === undefined || mark === null) return false;
187
- let held = false;
188
- try {
189
- eachLine(mark.file, (line) => {
190
- held = line !== undefined && userTexts(mark.harness, line).some((said) => said.includes(text));
191
- return held;
192
- }, { from: mark.size });
193
- } catch {
194
- return false;
195
- }
196
- return held;
197
- }
198
-
199
162
  /** The texts of one record line, when it is a turn of the user's. */
200
163
  function userTexts(harness, line) {
201
164
  let parsed;