@assuredloop/orca-bot-kit 0.26.0 → 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/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
  };
@@ -909,7 +862,7 @@ const commands = {
909
862
  throw new Error(`bot change does not change a bot's harness: its sessions' conversations belong to the harness they ran on. To move to ${values.harness}, give it a session on ${values.harness} with obk session add, or retire the bot with obk retire and create a new one.`);
910
863
  }
911
864
  // Permission changes have commands of their own, which keep the user's yes
912
- // (ADR 0037): the old spelling refuses, and changes nothing.
865
+ // (ADR 0041): the old spelling refuses, and changes nothing.
913
866
  const moved = [['allow', 'permission allow'], ['disallow', 'permission disallow']].filter(([flag]) => values[flag] !== undefined);
914
867
  if (moved.length > 0) {
915
868
  throw new Error(`bot change no longer takes ${moved.map(([flag]) => `--${flag}`).join(' or ')}: permission rules change through ${moved.map(([, now]) => `${shellWord(ownCli())} ${now} --bots ${shellWord(bots)} --bot ${values.bot} --rule <rule>`).join(' and ')}, after the user's yes. Nothing was changed.`);
@@ -1116,7 +1069,7 @@ const commands = {
1116
1069
  ...(values.effort === undefined ? {} : { effort: values.effort }),
1117
1070
  ...(values['extra-arg'] === undefined ? {} : { extra_args: values['extra-arg'] }),
1118
1071
  };
1119
- // A run's launch may not widen its approval (ADR 0037).
1072
+ // A run's launch may not widen its approval (ADR 0041).
1120
1073
  if (values['run-on'] === 'codex') refuseApprovalArgs('codex', values['extra-arg']);
1121
1074
  const groom = grooming(bots, { at: values.at, ask, run });
1122
1075
  return { answer: { bots, groom }, lines: groomLines(groom, bots) };
@@ -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
@@ -1407,7 +1338,7 @@ function workDirFound(values, bot, home, session) {
1407
1338
 
1408
1339
  /**
1409
1340
  * Refuse what would change a session's approval through `session add` or
1410
- * `session change` (ADR 0037): `--approval`, and an extra argument that sets
1341
+ * `session change` (ADR 0041): `--approval`, and an extra argument that sets
1411
1342
  * it on the session's harness. Only `permission approval` changes it.
1412
1343
  */
1413
1344
  function refuseApproval(bots, values, command) {
@@ -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 }) => [
@@ -1787,17 +1725,22 @@ function charterRulesLines(bots, bot, rules) {
1787
1725
 
1788
1726
  /**
1789
1727
  * What became of each bot's permission rules: each of the kit's default rules
1790
- * the run added to the bot, word for word (ADR 0036), and the file the allowed
1791
- * ones were written into. Nothing for a bot with nothing added or written.
1728
+ * the run added to the bot, word for word (ADR 0036), each rule of a command no
1729
+ * longer in the set that the bot still holds, with the command that takes it
1730
+ * back (ADR 0041), and the file the allowed ones were written into. Nothing for
1731
+ * a bot with nothing added, held or written.
1792
1732
  */
1793
1733
  function permissionsLines(permissions, bots) {
1794
1734
  // A bot on both harnesses has an entry for each file, with the same
1795
- // defaults: they are listed once, with the first.
1735
+ // defaults and held rules: they are listed once, with the first.
1796
1736
  const listed = new Set();
1737
+ const disallow = (bot, rule) => `${shellWord(ownCli())} permission disallow --bots ${shellWord(bots)} --bot ${shellWord(bot)} --rule ${shellWord(rule)}`;
1797
1738
  return permissions.flatMap((entry) => [
1798
1739
  ...(entry.trouble === undefined ? [] : [`${'refused'.padEnd(9)} ${entry.trouble}`]),
1799
- ...((entry.defaults ?? []).length === 0 || listed.has(entry.bot) || !listed.add(entry.bot) ? []
1800
- : entry.defaults.map((rule) => `${'allowed'.padEnd(9)} ${entry.bot} ${rule}`)),
1740
+ ...(listed.has(entry.bot) || !listed.add(entry.bot) ? [] : [
1741
+ ...(entry.defaults ?? []).map((rule) => `${'allowed'.padEnd(9)} ${entry.bot} ${rule}`),
1742
+ ...(entry.held ?? []).map((rule) => `${'holds'.padEnd(9)} ${entry.bot} ${rule}, which is no longer one of the kit's defaults. To take it back: ${disallow(entry.bot, rule)}`),
1743
+ ]),
1801
1744
  ...(entry.written.length === 0
1802
1745
  ? []
1803
1746
  : [`${'wrote'.padEnd(9)} ${path.relative(bots, entry.file)} ${entry.written.length} permission rule${entry.written.length === 1 ? '' : 's'} from its allow list`]),
package/src/commands.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // The kit's commands, each with the flags it cannot do without, which `obk`
2
2
  // reads to parse a command line; and the two lists every command is on one of:
3
3
  // those every bot is allowed by default (#527, ADR 0036), and those kept back
4
- // for the user's yes (ADR 0037). A new command is on neither until someone
4
+ // for the user's yes (ADR 0041). A new command is on neither until someone
5
5
  // puts it on one, and a test holds every command to that choice.
6
6
 
7
7
  /** The commands, and the flags each one cannot do without. */
@@ -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'],
@@ -56,19 +54,29 @@ export const COMMANDS = {
56
54
  * runs whatever its arguments say never is.
57
55
  */
58
56
  export const DEFAULT_COMMANDS = [
59
- 'up', 'restart', 'unpause', 'health', 'groom', 'roster', 'usage',
60
- 'bot create', 'bot change', 'rules build',
57
+ 'up', 'unpause', 'health', 'groom', 'roster', 'usage',
58
+ 'bot change', 'rules build',
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
  ];
68
66
 
69
67
  /**
70
68
  * The commands kept out of the default permission set, which keep the user's
71
- * yes (ADR 0037): the ones that change permissions, close a long-lived
72
- * session, or make the bots folder.
69
+ * yes (ADR 0041): the ones that change permissions, close a long-lived
70
+ * session, make the bots folder, or make a bot, which the owner leaves to Bot
71
+ * Father (#548).
73
72
  */
74
- export const KEPT_BACK = ['init', 'retire', 'pause', 'permission allow', 'permission disallow', 'permission approval'];
73
+ export const KEPT_BACK = [
74
+ 'init', 'retire', 'pause', 'restart', 'bot create',
75
+ 'permission allow', 'permission disallow', 'permission approval',
76
+ ];
77
+
78
+ /**
79
+ * The commands kit 0.26.0 gave every bot by default and that are kept back now
80
+ * (#548). A bot may still hold their rules, which the kit names and leaves.
81
+ */
82
+ export const NO_LONGER_DEFAULT = ['restart', 'bot create'];
@@ -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;
package/src/health.js CHANGED
@@ -18,22 +18,23 @@
18
18
  // is, the one thing to go and look at, and a sentence naming it — so that
19
19
  // somebody who cannot read code knows what was found and where.
20
20
 
21
- import { existsSync, readdirSync, realpathSync } from 'node:fs';
21
+ import { existsSync, readdirSync, realpathSync, statSync } from 'node:fs';
22
22
  import path from 'node:path';
23
23
 
24
24
  import { bookFile, readBook, sessionIdsIn, tabIdsIn } from './book.js';
25
25
  import { botDir, botNames, botsDir, readBot, tempRoles, unknownKeys } from './bot.js';
26
26
  import { transcriptsIn } from './conversations.js';
27
27
  import { hookTrouble } from './hooks.js';
28
- import { APPROVALS, bypassFlags, harnessOf, HARNESSES, isAddressOf, ownCli, sessionTrouble, SHELL_ENV, shellWord } from './launch.js';
28
+ import { APPROVALS, bypassFlags, harnessOf, HARNESSES, isAddressOf, ownCli, sessionTrouble, SHELL_ENV, shellWord, workDirOf } from './launch.js';
29
29
  import { frontOfTab, orcaDefaultArgs, projects, runMissing, tabs, wordsOfProcess } from './orca.js';
30
30
  import { permissionsTrouble } from './permissions.js';
31
+ import { processesIn } from './processes.js';
31
32
  import { TAB_ENV } from './record.js';
32
33
  import { agentsTrouble, rulesStamp } from './rules.js';
33
34
  import { settingsInUse } from './settings.js';
34
35
  import { skillsTrouble } from './skills.js';
35
36
  import { readSources, sourcesDir } from './sources.js';
36
- import { BOT_FATHER, botsNamed, promptPath } from './up.js';
37
+ import { BOT_FATHER, botsNamed, oldPromptPath, promptPath } from './up.js';
37
38
 
38
39
  /**
39
40
  * Everything wrong with the setup at `bots`, in one list: what Orca's own
@@ -141,6 +142,9 @@ function leftBeside(bots) {
141
142
  try {
142
143
  for (const session of readBot(botDir(bots, name), name).sessions) {
143
144
  owned.add(promptPath(bots, name, session.name));
145
+ // Where a live session's prompt was before #534: nothing reads it once
146
+ // the session starts again, and its retire removes it.
147
+ owned.add(oldPromptPath(bots, name, session.name));
144
148
  }
145
149
  } catch {
146
150
  known = false;
@@ -148,8 +152,7 @@ function leftBeside(bots) {
148
152
  }
149
153
 
150
154
  if (known) {
151
- for (const file of namesIn(`${bots}.prompts`)) {
152
- const at = path.join(`${bots}.prompts`, file);
155
+ for (const at of promptFilesIn(`${bots}.prompts`)) {
153
156
  if (owned.has(at)) continue;
154
157
  found.push(finding('leftover', at, `${at} is a start prompt the kit wrote beside your bots folder, and no session of any bot answers to it now. Nothing reads it.`));
155
158
  }
@@ -196,9 +199,48 @@ function aboutBot(bots, name, setups, sessions) {
196
199
  ...permissionsTrouble(home, bot).map(said('config')),
197
200
  ...skillsTrouble(bots, home, bot).map(said('skill')),
198
201
  ...inOrca(bots, home, bot, setups, sessions),
202
+ ...leftRunning(home, bot),
199
203
  ];
200
204
  }
201
205
 
206
+ /**
207
+ * What still runs in the work dir of a retired session (#537): a retire stops
208
+ * it, so this is one from before that, or one a retire could not stop. The
209
+ * book keeps a retired session's work dir; a temporary session retired before
210
+ * it did worked in `work/<name>`, where `obk temp make` puts it. A work dir a
211
+ * session in bot.yaml still uses is that session's, and not looked at, by its
212
+ * real path, so a link to it is not taken for a retired one (review of PR #543).
213
+ */
214
+ function leftRunning(home, bot) {
215
+ let book;
216
+ try {
217
+ book = readBook(home);
218
+ } catch {
219
+ // A book that cannot be read is said where the book is read.
220
+ return [];
221
+ }
222
+ const real = (dir) => (dir === undefined ? undefined : realpathOf(dir));
223
+ const live = new Set(bot.sessions.map((session) => real(workDirOf(session, home))));
224
+ const dirs = [];
225
+ const seen = new Set();
226
+ for (const entry of Array.isArray(book.retired) ? book.retired : []) {
227
+ const named = typeof entry?.work_dir === 'string' && entry.work_dir !== '' ? entry.work_dir : undefined;
228
+ const dir = workDirOf({ work_dir: named ?? (entry?.temporary === undefined ? undefined : `work/${entry.name}`) }, home);
229
+ // One that is not there holds nothing.
230
+ const at = real(dir);
231
+ if (at === undefined || live.has(at) || seen.has(at)) continue;
232
+ seen.add(at);
233
+ dirs.push({ session: entry.name, dir });
234
+ }
235
+ if (dirs.length === 0) return [];
236
+
237
+ const read = processesIn(dirs);
238
+ if (read.unreadable !== undefined) {
239
+ return [finding('process', bookFile(home), `The kit cannot tell whether anything still runs in the work dirs of ${bot.name}'s retired sessions: ${read.unreadable}.`, bot.name)];
240
+ }
241
+ return read.running.map((one) => finding('process', one.dir, `pid ${one.pid} still runs in the work dir of ${bot.name}/${one.session}, which was retired: ${one.command}. Nothing of the kit's stops it now. If it is not wanted, stop it yourself: kill ${one.pid}`, bot.name));
242
+ }
243
+
202
244
  /** A session the kit would refuse to start, in the words `up` refuses it with. */
203
245
  function sessionSettings(home, bot) {
204
246
  const file = path.join(home, 'bot.yaml');
@@ -213,7 +255,7 @@ function sessionSettings(home, bot) {
213
255
  /** What is wrong with the roles the bot's temporary sessions are made in (#465). */
214
256
  function rolesTrouble(home, bot) {
215
257
  const where = path.join(home, 'bot.yaml');
216
- // The widest approval they may be made at, the user's yes (ADR 0037).
258
+ // The widest approval they may be made at, the user's yes (ADR 0041).
217
259
  const level = bot.temp_approval;
218
260
  const approval = level === undefined || APPROVALS.includes(level) ? [] : [{
219
261
  where,
@@ -461,6 +503,21 @@ function recordIn(home, bot) {
461
503
  }
462
504
 
463
505
  /** What a directory holds, and nothing at all when there is no directory. */
506
+ /**
507
+ * The files in the kit's folder of start prompts: in each bot's own folder
508
+ * there (#534), and flat in the folder itself, where they were before.
509
+ */
510
+ function promptFilesIn(dir) {
511
+ return namesIn(dir).flatMap((name) => {
512
+ const at = path.join(dir, name);
513
+ try {
514
+ return statSync(at).isDirectory() ? namesIn(at).map((file) => path.join(at, file)) : [at];
515
+ } catch {
516
+ return [];
517
+ }
518
+ });
519
+ }
520
+
464
521
  function namesIn(dir) {
465
522
  try {
466
523
  return readdirSync(dir);