@assuredloop/orca-bot-kit 0.28.0 → 0.29.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
@@ -166,8 +166,8 @@ nobody asked: its own commands for this bots folder, reading a long message,
166
166
  `git add` and `git commit`
167
167
  ([ADR 0050](docs/adr/0050-the-kit-writes-every-bots-default-permission-rules.md)).
168
168
  `obk permission`, `obk retire`, `obk pause`, `obk restart`, `obk bot create`,
169
- `obk bot change`, `obk session add`, `obk session change` and `obk init` are
170
- not in the set, and keep your yes
169
+ `obk bot change`, `obk session add`, `obk session change`, `obk init` and
170
+ `obk run` are not in the set, and keep your yes
171
171
  ([ADR 0051](docs/adr/0051-permission-changes-and-risky-operations-have-commands-of-their-own.md)).
172
172
  A rule beyond the set is written by `obk permission allow`, after your yes to
173
173
  the exact rule. A bot that still holds a rule that the kit gave every bot
@@ -577,6 +577,13 @@ What it looks for:
577
577
  `<bot>.<session>` every fleet's sessions had before 0.10.0, which a session of
578
578
  another fleet can write to. Its next start through the kit gives it one of its
579
579
  own, so the finding names the `obk restart … --session` that does it;
580
+ - **a session whose mail waits and Orca has not told it**: mail older than an
581
+ hour that is unread and that Orca's notice never reached, while the session's
582
+ harness is idle now. Orca can stop telling a session that sat idle through an
583
+ Orca restart or update, so the finding names the `obk restart … --session`
584
+ that opens a new tab, which Orca tells. It counts the letters and gives the
585
+ time of the oldest; it never shows their subject or text. A busy session is
586
+ not named: Orca tells it when its turn ends;
580
587
  - **a process still running in a retired session's work dir**: one from before
581
588
  retire stopped them, or one it could not stop, with its pid and command;
582
589
  - **leftovers no book owns**: an Orca project inside your bots folder with no
package/SETUP.md CHANGED
@@ -54,7 +54,10 @@ folder.
54
54
 
55
55
  On the Orca CLI path: `/usr/local/bin/orca` is a root-only symlink on some
56
56
  machines and fails for an ordinary user, so use the full path above. If Orca
57
- lives somewhere else here, find it and set `OBK_ORCA` to it.
57
+ lives somewhere else here, find it and set `OBK_ORCA` to it. If that is another
58
+ build of Orca with its own data folder, also set `OBK_ORCA_DATA` to the full
59
+ path of that folder, for example `$HOME/Library/Application Support/orca-lean`.
60
+ The kit reads Orca's launch arguments from the `profiles` folder there.
58
61
 
59
62
  If Node is older than 24.21.0, say so and stop. The kit's book of sessions is a
60
63
  SQLite write transaction through Node's own `node:sqlite`, and 24.21.0 is the
@@ -141,8 +144,8 @@ Read everything it prints. Three parts of it matter to them:
141
144
  beside the bots folder, and its commits. The kit writes this set for every
142
145
  bot, with nobody asked. Say in a sentence what it allows, and that
143
146
  `obk permission`, `obk retire`, `obk pause`, `obk restart`, `obk bot create`,
144
- `obk bot change`, `obk session add`, `obk session change` and `obk init` are
145
- not in it and keep their yes;
147
+ `obk bot change`, `obk session add`, `obk session change`, `obk init` and
148
+ `obk run` are not in it and keep their yes;
146
149
  - **anything it says about Orca's own default launch arguments.** Orca adds
147
150
  these to every agent it launches, relaunches and resumes. When they carry a
148
151
  permission bypass, every session runs in that mode whatever approval level the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assuredloop/orca-bot-kit",
3
- "version": "0.28.0",
3
+ "version": "0.29.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -214,7 +214,7 @@ does not have yet to its `bot.yaml` and its settings, or for Codex its
214
214
  `.codex/rules/obk.rules`, and say which rules they wrote. The set leaves out
215
215
  the commands that keep the user's yes: `obk permission`, `obk retire`, `obk
216
216
  pause`, `obk restart`, `obk bot create`, `obk bot change`, `obk session add`,
217
- `obk session change` and `obk init`. A bot that still holds a rule that the
217
+ `obk session change`, `obk init` and `obk run`. A bot that still holds a rule that the
218
218
  kit gave every bot before and that is no longer in the set (`obk restart`,
219
219
  `obk bot create`, `obk bot change`, `obk session add`, `obk session change`,
220
220
  `obk session mail`, `obk session nudge`, or Orca's `orca orchestration check
@@ -245,7 +245,11 @@ out; the kit refuses it, and it refuses a rule of its own default set too. Keep
245
245
  each rule narrow: a program and what it does. The kit refuses a rule that lets
246
246
  the bot run any command, a program with any arguments, or a shell or
247
247
  interpreter with any arguments; a user who wants one for a bot adds it to that
248
- bot's settings themselves. Propose no rule for what the charter does not grant.
248
+ bot's settings themselves. For a test run with a time limit, propose `obk run`
249
+ with the real command after `--`, such as
250
+ `Bash(<kit> run --timeout 300 -- go test:*)`: the kit refuses a rule for
251
+ `perl -e 'alarm …'`, since perl runs whatever its arguments say. Propose no
252
+ rule for what the charter does not grant.
249
253
 
250
254
  A bot that should learn across its sessions can use its harness's own memory,
251
255
  turned on for that bot alone, whatever the user's own setting is for everything
package/src/cli.js CHANGED
@@ -34,6 +34,7 @@ import { addSkill, buildSkills, linkSkills, removeSkill } from './skills.js';
34
34
  import { addSource, fetchSources } from './sources.js';
35
35
  import { answerSession, answerTemp, listRoles, makeTemp, optionsLine, retireTemp, trustHooks, trustSessionHooks } from './temp.js';
36
36
  import { readUsage } from './usage.js';
37
+ import { parseRun, runBounded } from './run.js';
37
38
  import { BOT_FATHER, bringUp, ownMailbox } from './up.js';
38
39
 
39
40
  const USAGE = `obk — Orca Bot Kit.
@@ -326,14 +327,28 @@ Usage:
326
327
  For the kit's own launch line, not for typing: run
327
328
  in the session's own tab, it gives the session its
328
329
  mailbox, or binds the one it has, to that tab.
330
+ obk run --timeout <seconds> -- <command> [<arg> ...]
331
+ Run one command with a hard time limit, with no
332
+ shell, and exit with its exit code. When the time
333
+ runs out, it stops the command and everything it
334
+ started (SIGTERM, then SIGKILL after 3 s), says so
335
+ on stderr, and exits 124. A command it cannot find
336
+ exits 127. A rule can allow it for one command,
337
+ such as Bash(obk run --timeout 300 -- go test:*).
338
+ It needs no --bots, and takes no --json.
329
339
  obk --version Print the kit's version.
330
340
  obk --help Print this text.
331
341
 
332
342
  Every command is safe to run again. Most only add what is missing; the ones
333
343
  that close a tab or take something away say so above. Every bot is allowed
334
- the kit's commands by default, except obk permission, retire, pause and init,
335
- which keep the user's yes.
344
+ the kit's commands by default, except obk permission, retire, pause, init and
345
+ run, which keep the user's yes.
336
346
  Add --json to any of them for the same answer as JSON.
347
+
348
+ For another build of Orca, OBK_ORCA names its CLI, and OBK_ORCA_DATA the full
349
+ path of the folder it keeps its data in, such as
350
+ $HOME/Library/Application Support/orca-lean.
351
+ The kit reads Orca's launch arguments from the profiles folder there.
337
352
  `;
338
353
 
339
354
 
@@ -381,6 +396,10 @@ function version() {
381
396
  }
382
397
 
383
398
  async function run(argv) {
399
+ // What follows `obk run --timeout <n> --` is the command's own, flags and
400
+ // all, so it is read by `run` alone, before the kit reads its flags (#532).
401
+ if (argv[0] === 'run') return runBounded(parseRun(argv.slice(1)));
402
+
384
403
  const { values, positionals, tokens } = parseArgs({
385
404
  args: argv,
386
405
  allowPositionals: true,
package/src/commands.js CHANGED
@@ -46,6 +46,7 @@ export const COMMANDS = {
46
46
  'temp retire': ['bots', 'name'],
47
47
  'temp trust-hooks': ['bots', 'name'],
48
48
  'temp answer': ['bots', 'name'],
49
+ run: ['timeout'],
49
50
  };
50
51
 
51
52
  /**
@@ -68,12 +69,14 @@ export const DEFAULT_COMMANDS = [
68
69
  * The commands kept out of the default permission set, which keep the user's
69
70
  * yes (ADR 0051): the ones that change permissions, close a long-lived
70
71
  * session, make the bots folder, or make or change a bot or its sessions,
71
- * which the owner leaves to Bot Father (#548, #615).
72
+ * which the owner leaves to Bot Father (#548, #615); and `run`, which runs
73
+ * whatever its arguments say, so a rule for it names its command (#532).
72
74
  */
73
75
  export const KEPT_BACK = [
74
76
  'init', 'retire', 'pause', 'restart', 'bot create',
75
77
  'bot change', 'session add', 'session change',
76
78
  'permission allow', 'permission disallow', 'permission approval',
79
+ 'run',
77
80
  ];
78
81
 
79
82
  /**
package/src/health.js CHANGED
@@ -26,7 +26,7 @@ import { botDir, botNames, botsDir, readBot, tempRoles, unknownKeys } from './bo
26
26
  import { transcriptsIn } from './conversations.js';
27
27
  import { hookTrouble } from './hooks.js';
28
28
  import { APPROVALS, bypassFlags, harnessOf, HARNESSES, isAddressOf, ownCli, promptFile, sessionTrouble, set, SHELL_ENV, shellWord, workDirOf } from './launch.js';
29
- import { frontOfTab, orcaDefaultArgs, projects, runMissing, tabs, wordsOfProcess } from './orca.js';
29
+ import { frontOfTab, mailIn, orcaDefaultArgs, projects, runMissing, tabs, tabToTypeInto, wordsOfProcess } from './orca.js';
30
30
  import { permissionsTrouble } from './permissions.js';
31
31
  import { processesIn } from './processes.js';
32
32
  import { TAB_ENV } from './record.js';
@@ -459,6 +459,12 @@ function inOrca(bots, home, bot, setups, sessions) {
459
459
  found.push(finding('session', bookFile(home), `${bot.name}'s session ${name} has the mailbox ${entry.mailbox} in the book, and Orca has no Run of that id: it was made by another Orca, as on another machine, so mail sent to it is refused. The session's next start makes it a new one: ${fix}`, bot.name));
460
460
  }
461
461
 
462
+ // Mail Orca has not told a running session of (#630). A session that is
463
+ // paused or whose tab is gone reads no mail by design, or is named above.
464
+ if (typeof entry?.mailbox === 'string' && there.has(entry.tab) && !closed.has(name)) {
465
+ found.push(...unannounced(bots, home, bot, name, entry));
466
+ }
467
+
462
468
  const unclaimed = (Array.isArray(entry?.unclaimed) ? entry.unclaimed : []).filter((id) => !helpers.has(id));
463
469
  if (unclaimed.length > 0) {
464
470
  found.push(finding('leftover', bookFile(home), `The book notes conversations under ${bot.name}'s session ${name} that no session of this bot claims: ${unclaimed.join(', ')}. They ran in this bot's folder, and the kit will not say whose they were. To give one back, write it into ${bookFile(home)} under ${name} as session: <id> and run obk up again.`, bot.name));
@@ -481,6 +487,70 @@ function inOrca(bots, home, bot, setups, sessions) {
481
487
  return found;
482
488
  }
483
489
 
490
+ /** How old an unread letter Orca has not rung for is before it counts as waiting (#630). */
491
+ const WAITING_MS = 60 * 60 * 1000;
492
+
493
+ /** How long the look at a session's tab waits for its harness to be idle. */
494
+ const IDLE_LOOK_MS = 2000;
495
+
496
+ /** How many of a mailbox's newest letters health reads: one read, whatever the mailbox holds. */
497
+ const MAIL_READ = 100;
498
+
499
+ /**
500
+ * The session `name`'s mail that Orca has not told it of, while its harness is
501
+ * idle now: one finding, or none (#630). Orca rings only a tab whose harness it
502
+ * has seen go idle since Orca last started, so a session that sat idle through
503
+ * an Orca restart or update is never told (tech notes, section 1). A busy one
504
+ * is told when its turn ends, so it is not named. Only flags and times are
505
+ * read: a letter's subject and text are the session's own. A mailbox or a tab
506
+ * Orca will not answer about leaves it unsaid.
507
+ *
508
+ * One read of the newest letters (review of PR #632). Orca hands mail to its
509
+ * reader oldest first, so a full read whose oldest letter is read is taken to
510
+ * hold every unread one. That misses an older letter left unread below a letter
511
+ * `orca orchestration reply` marked read out of order; the kit never runs it.
512
+ * A full read whose oldest letter is unread may have more below it, and the
513
+ * finding says so rather than count what it did not read.
514
+ */
515
+ function unannounced(bots, home, bot, name, entry) {
516
+ let mail;
517
+ try {
518
+ mail = mailIn(entry.mailbox, MAIL_READ);
519
+ } catch {
520
+ return [];
521
+ }
522
+ const before = Date.now() - WAITING_MS;
523
+ const waiting = mail
524
+ .filter((one) => one.read === 0 && one.delivered_at == null && Date.parse(one.created_at) < before)
525
+ .sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at));
526
+ const last = mail.at(-1);
527
+ const more = mail.length >= MAIL_READ && last.read === 0;
528
+ // Unread and never rung for, with more of the mailbox below it.
529
+ const below = more && last.delivered_at == null;
530
+ if (waiting.length === 0 && !below) return [];
531
+
532
+ // The gate the kit's own keys go through tells an idle harness from a busy
533
+ // one, a question on its screen or a shell; only its answer is kept.
534
+ let look;
535
+ try {
536
+ look = tabToTypeInto(home, entry.tab, IDLE_LOOK_MS);
537
+ } catch {
538
+ return [];
539
+ }
540
+ if (look.handle === undefined || look.idle !== true) return [];
541
+
542
+ const count = `${more ? 'at least ' : ''}${waiting.length}`;
543
+ const letters = waiting.length === 1 && !more ? '1 letter' : `${count} letters`;
544
+ const read = `Health reads the newest ${MAIL_READ} letters of a mailbox, and the oldest of those is unread too, so older letters may wait as well.`;
545
+ const what = waiting.length === 0
546
+ ? `has mail in its mailbox ${entry.mailbox} that Orca has not told it of: the oldest of the newest ${MAIL_READ} letters, from ${last.created_at}, is unread, and Orca has not rung for it. Older letters may wait below it, which health does not read.`
547
+ : more
548
+ ? `has ${letters} in its mailbox ${entry.mailbox} that it has not read and that Orca has not told it of, each more than an hour old; the oldest of them that health read is from ${waiting[0].created_at}. ${read}`
549
+ : `has ${letters} in its mailbox ${entry.mailbox} that it has not read and that Orca has not told it of, the oldest from ${waiting[0].created_at}, more than an hour ago.`;
550
+ const restart = `${shellWord(ownCli())} restart --bots ${shellWord(bots)} --bot ${bot.name} --session ${name}`;
551
+ return [finding('mail', entry.mailbox, `${bot.name}'s session ${name} ${what} Its harness is idle now, and Orca tells an idle session of its mail within minutes, unless the session sat idle through an Orca restart or update: then Orca does not tell it until it takes a turn. ${restart} opens a new tab for it, which Orca tells. A restart drops what lives only in the old tab, such as Claude Code schedules, so the session makes them again.`, bot.name)];
552
+ }
553
+
484
554
  /**
485
555
  * The conversations a harness has on record in this bot's folder that the book
486
556
  * does not name anywhere. The hook is how an id reaches the book, and when it
package/src/hooks.js CHANGED
@@ -101,7 +101,7 @@ export function installHook(home, harness, { bots, bot }) {
101
101
  const started = withKitHook(settings.hooks, file, mine);
102
102
  // The Claude Code mail hook and the Codex nudge hook went with the kit's
103
103
  // typed line (#555), so an entry of either is taken out.
104
- const wanted = { ...settings, hooks: toolHooksOf(harness, bots, bot).reduce(withToolHook, withoutHooks(started, [KIT_MAIL, KIT_NUDGE])) };
104
+ const wanted = { ...settings, hooks: toolHooksOf(harness, bots, bot).reduce(withToolHook, withoutHooks(started, REMOVED_HOOKS)) };
105
105
 
106
106
  // Compared as documents, not as text: how the user laid their file out is
107
107
  // theirs, and a run that changes nothing writes nothing.
@@ -112,6 +112,41 @@ export function installHook(home, harness, { bots, bot }) {
112
112
  return HOOK_FILE[harness];
113
113
  }
114
114
 
115
+ /**
116
+ * Take the kit's hooks of commands it no longer has out of every hooks file the
117
+ * bot at `home` has, whatever its sessions run on now and whether or not it is
118
+ * paused (#613). `installHook` does this only for a harness a session runs on,
119
+ * so a file the kit wrote for a harness the bot has since left kept them.
120
+ * Returns the files it wrote, relative to the bot home.
121
+ *
122
+ * It adds nothing and changes no other entry. A file it cannot read, a hooks
123
+ * entry that is not a mapping of events, or a link out of the bot folder is
124
+ * left as it is: the kit cannot tell its entries there, and health and
125
+ * `installHook` speak for a harness that runs.
126
+ */
127
+ export function dropRemovedHooks(home) {
128
+ return Object.values(HOOK_FILE).flatMap((relative) => {
129
+ const file = path.join(home, relative);
130
+ if (!existsSync(file) || outside(home, file) !== undefined) return [];
131
+ let settings;
132
+ try {
133
+ settings = readSettings(file);
134
+ } catch {
135
+ return [];
136
+ }
137
+ const { hooks } = settings;
138
+ if (hooks === null || typeof hooks !== 'object' || Array.isArray(hooks)) return [];
139
+ const kept = withoutHooks(hooks, REMOVED_HOOKS);
140
+ if (isDeepStrictEqual(hooks, kept)) return [];
141
+ try {
142
+ writeFileSync(file, `${JSON.stringify({ ...settings, hooks: kept }, null, 2)}\n`);
143
+ } catch (error) {
144
+ throw new Error(`${file} cannot be written (${error.code ?? error.message}), and it holds the kit's hook of a command the kit no longer has, which the kit takes out. Let the kit write it, then run the command again.`);
145
+ }
146
+ return [relative];
147
+ });
148
+ }
149
+
115
150
  /**
116
151
  * Whether the kit's hook is in place for one harness, and what to say when it
117
152
  * is not: `{ where, says }`, or nothing when the file already holds it.
@@ -361,6 +396,7 @@ const KIT_SENT = new RegExp(String.raw`^(${WORD}) session sent --bots ${WORD} --
361
396
  const KIT_NUDGE = new RegExp(String.raw`^(${WORD}) session nudge --bots ${WORD} --bot ${WORD} 2>/dev/null \|\| true$`);
362
397
  const KIT_MAIL = new RegExp(String.raw`^(${WORD}) session mail --bots ${WORD} --bot ${WORD} 2>/dev/null \|\| true$`);
363
398
  const KIT_NAME = new RegExp(String.raw`^(${WORD}) session name --bots ${WORD} --bot ${WORD} 2>/dev/null \|\| true$`);
399
+ const REMOVED_HOOKS = [KIT_MAIL, KIT_NUDGE];
364
400
 
365
401
  /**
366
402
  * Whether a hook entry is one of the kit's own as this kit writes it: a
@@ -16,29 +16,28 @@
16
16
  on run argv
17
17
  set appPath to item 1 of argv
18
18
  tell application "System Events"
19
- repeat with candidate in (every application process whose name is "Orca")
20
- if POSIX path of application file of candidate is appPath then
21
- tell candidate
22
- -- Menu 1 is Apple's own.
23
- repeat with b from 2 to count of menu bar items of menu bar 1
24
- set m to menu 1 of menu bar item b of menu bar 1
25
- set labels to name of menu items of m
26
- repeat with i from 1 to count of labels
27
- set label to item i of labels
28
- if label is not missing value then
29
- if label is "Force Reload" or label starts with ("Force Reload" & tab) or label ends with (tab & "⌘⇧R") then
30
- -- Asked last, right before the click, so Orca has had no time to
31
- -- lose the front since.
32
- if frontmost of candidate is false then return "Orca is not the front app"
33
- click menu item i of m
34
- return "reloaded"
35
- end if
19
+ -- By its path alone: another build of Orca has another name (#551).
20
+ repeat with candidate in (every application process whose POSIX path of application file is appPath)
21
+ tell candidate
22
+ -- Menu 1 is Apple's own.
23
+ repeat with b from 2 to count of menu bar items of menu bar 1
24
+ set m to menu 1 of menu bar item b of menu bar 1
25
+ set labels to name of menu items of m
26
+ repeat with i from 1 to count of labels
27
+ set label to item i of labels
28
+ if label is not missing value then
29
+ if label is "Force Reload" or label starts with ("Force Reload" & tab) or label ends with (tab & "⌘⇧R") then
30
+ -- Asked last, right before the click, so Orca has had no time to
31
+ -- lose the front since.
32
+ if frontmost of candidate is false then return "Orca is not the front app"
33
+ click menu item i of m
34
+ return "reloaded"
36
35
  end if
37
- end repeat
36
+ end if
38
37
  end repeat
39
- end tell
40
- return "no Force Reload in Orca's menus"
41
- end if
38
+ end repeat
39
+ end tell
40
+ return "no Force Reload in Orca's menus"
42
41
  end repeat
43
42
  end tell
44
43
  return "no Orca running from " & appPath
package/src/orca.js CHANGED
@@ -29,10 +29,11 @@ export const orcaCli = () => process.env.OBK_ORCA || BUILT_IN;
29
29
  * Run one Orca command and give back its `result`.
30
30
  * Every call asks for `--json`; anything else would be text to guess at.
31
31
  * With `timeoutMs`, Orca is given that long to answer, and the call is ended
32
- * and refused if it has not.
32
+ * and refused if it has not. With `whole`, an answer of any size is taken,
33
+ * where Node would otherwise stop at a megabyte (review of PR #632).
33
34
  */
34
- export function orca(args, { timeoutMs } = {}) {
35
- const answer = ask(args, timeoutMs);
35
+ export function orca(args, { timeoutMs, whole = false } = {}) {
36
+ const answer = ask(args, timeoutMs, whole);
36
37
  if (answer.ok !== true) {
37
38
  // Orca's own words: it knows what went wrong, and the caller is an LLM
38
39
  // that can act on them.
@@ -55,8 +56,12 @@ const refusal = (args, answer) => Object.assign(
55
56
  );
56
57
 
57
58
  /** One Orca command, and the whole envelope back, refusals included. */
58
- function ask(args, timeoutMs) {
59
- const asked = spawnSync(orcaCli(), [...args, '--json'], { encoding: 'utf8', ...(timeoutMs === undefined ? {} : { timeout: timeoutMs }) });
59
+ function ask(args, timeoutMs, whole = false) {
60
+ const asked = spawnSync(orcaCli(), [...args, '--json'], {
61
+ encoding: 'utf8',
62
+ ...(timeoutMs === undefined ? {} : { timeout: timeoutMs }),
63
+ ...(whole ? { maxBuffer: Infinity } : {}),
64
+ });
60
65
  if (asked.error?.code === 'ETIMEDOUT') {
61
66
  throw Object.assign(new Error(`Orca did not answer ${args.join(' ')} within ${timeoutMs / 1000} seconds.`), { code: TIMED_OUT });
62
67
  }
@@ -135,8 +140,16 @@ export function findProject(home) {
135
140
  return projects().find((setup) => setup.path === home);
136
141
  }
137
142
 
138
- /** Where Orca keeps its own settings: one file per profile, under the user's home. */
139
- export const profilesDir = () => path.join(homedir(), 'Library', 'Application Support', 'orca', 'profiles');
143
+ /**
144
+ * Where Orca keeps its own settings: one file per profile, under the user's home.
145
+ * OBK_ORCA_DATA names the data folder of another build of Orca, as OBK_ORCA
146
+ * names its CLI: a build's folder is written only inside its own CLI, so the
147
+ * kit cannot tell it from the app (#551).
148
+ */
149
+ export const profilesDir = () => path.join(
150
+ process.env.OBK_ORCA_DATA || path.join(homedir(), 'Library', 'Application Support', 'orca'),
151
+ 'profiles',
152
+ );
140
153
 
141
154
  /**
142
155
  * Orca's own default launch arguments, per profile it keeps:
@@ -1057,6 +1070,17 @@ export function postMessage({ to, from, subject, body, type = 'status', thread }
1057
1070
  export const readMailbox = (id, { peek = false, handle } = {}, options) =>
1058
1071
  orca(['orchestration', 'check', '--run', id, ...as(handle), ...(peek ? ['--peek'] : [])], options);
1059
1072
 
1073
+ /**
1074
+ * The newest `limit` messages in a mailbox, newest first, with every column
1075
+ * Orca keeps for them: `read` and `delivered_at` among them, which `check`
1076
+ * leaves out. Orca asks no reader for this, so any tab can read any mailbox,
1077
+ * and nothing is marked read or delivered (tech notes, section 1; #630). The
1078
+ * answer carries each letter's whole text, and a local letter's text has no
1079
+ * cap in Orca, so the answer is taken whatever its size.
1080
+ */
1081
+ export const mailIn = (id, limit) =>
1082
+ orca(['orchestration', 'inbox', '--terminal', `run:${id}`, '--limit', String(limit)], { whole: true }).messages ?? [];
1083
+
1060
1084
  /** Say a batch has been read. Orca answers with the batch after it. */
1061
1085
  export const ackMailbox = (id, delivery, handle, options) =>
1062
1086
  orca(['orchestration', 'check', '--run', id, ...as(handle), '--ack', delivery], options);
@@ -165,12 +165,14 @@ function commandBroadness(all) {
165
165
  if (star === -1) return undefined;
166
166
 
167
167
  const program = path.basename(words[0].replace(/:?\*.*$/, ''));
168
+ if (isKit(words[0]) && mayRun(words[1] ?? '')) return runBroadness(words);
168
169
  if (runnerOf(program) !== undefined) {
169
170
  // Only what runs, fixed by its absolute path, narrows it. A wrapper's
170
- // program is then judged by itself; an interpreter's script is the end.
171
+ // program is then judged by itself; an interpreter's script is the end,
172
+ // unless it is the kit, which is judged as the kit.
171
173
  const next = (words[1] ?? '').replace(/:\*$/, '');
172
174
  const fixed = next.startsWith('/') && !next.includes('*');
173
- if (fixed && WRAPPERS.has(program)) return commandBroadness(words.slice(1));
175
+ if (fixed && (WRAPPERS.has(program) || isKit(next))) return commandBroadness(words.slice(1));
174
176
  if (fixed && runnerOf(path.basename(next)) === undefined) return undefined;
175
177
  return `${program} runs whatever its arguments say, so ${ANY}`;
176
178
  }
@@ -183,6 +185,37 @@ function commandBroadness(all) {
183
185
  return undefined;
184
186
  }
185
187
 
188
+ /** Whether a word is the kit's CLI: `obk` by its name, or the path the kit runs as. */
189
+ const isKit = (word) => path.basename(word) === 'obk' || word === ownCli();
190
+
191
+ /**
192
+ * Whether a rule's word can match `run`: Claude's `*` matches any characters,
193
+ * spaces too, so `ru*` matches `run --timeout 1 -- sh`, while `:*` matches
194
+ * only at the end of a word.
195
+ */
196
+ function mayRun(word) {
197
+ const star = word.indexOf('*');
198
+ if (star === -1) return word === 'run';
199
+ const before = word.slice(0, star);
200
+ return before.endsWith(':') ? before === 'run:' : 'run'.startsWith(before);
201
+ }
202
+
203
+ /**
204
+ * Why a rule for `obk run`, as shell words, is broad, or undefined (#532). It
205
+ * runs whatever its arguments say, so only a fixed time limit, `--`, and a
206
+ * command after it that is narrow as a rule of its own narrow it.
207
+ */
208
+ function runBroadness(words) {
209
+ const [, , ...rest] = words;
210
+ let command;
211
+ if (rest[0] === '--timeout' && /^\d+$/.test(rest[1] ?? '') && rest[2] === '--') command = rest.slice(3);
212
+ if (/^--timeout=\d+$/.test(rest[0] ?? '') && rest[1] === '--') command = rest.slice(2);
213
+ if (words[1] !== 'run' || command === undefined || command.length === 0) {
214
+ return `obk run runs whatever its arguments say, so ${ANY}. A rule for it names the command after --, such as Bash(obk run --timeout 300 -- go test:*)`;
215
+ }
216
+ return commandBroadness(command);
217
+ }
218
+
186
219
  /**
187
220
  * Refuse the rules `permission allow` was given when any is broad, before anything is
188
221
  * written, naming the file the user can add it to themselves.
@@ -220,6 +253,15 @@ export function allowOf(home, bot) {
220
253
  export const runsOnCodex = (bot) => bot.harness === 'codex'
221
254
  || bot.sessions.some((session) => harnessOf(session, bot.harness) === 'codex');
222
255
 
256
+ /**
257
+ * Whether the kit keeps the bot's Codex rules file: while the bot runs on
258
+ * Codex, and after it has left Codex for as long as the file it wrote is
259
+ * there in the bot folder, so the file never holds a rule that `allow` no
260
+ * longer does (#613). A link out of the bot folder is not the kit's file.
261
+ */
262
+ const keepsCodex = (bot, home) => runsOnCodex(bot)
263
+ || (existsSync(path.join(home, CODEX_FILE)) && leadsOutside(home, path.join(home, CODEX_FILE)) === undefined);
264
+
223
265
  /**
224
266
  * The default rules this bot is not allowed yet, in the default order. A bot
225
267
  * only on Codex is not given the Read rule: its sandbox reads every file
@@ -293,7 +335,7 @@ export function refuseNoCodexForm(bot, rules) {
293
335
  * not write is an entry with its `trouble` rather than a throw.
294
336
  */
295
337
  export function writePermissions(bots, home, bot, { keepGoing = false } = {}) {
296
- const writers = [[runsOnClaude, FILE], [runsOnCodex, CODEX_FILE]].filter(([runs]) => runs(bot));
338
+ const writers = [[runsOnClaude, FILE], [keepsCodex, CODEX_FILE]].filter(([runs]) => runs(bot, home));
297
339
  let defaults = [];
298
340
  // Read before anything is written, so a file's trouble still names them.
299
341
  let held = [];
@@ -324,8 +366,8 @@ export function writePermissions(bots, home, bot, { keepGoing = false } = {}) {
324
366
  */
325
367
  export function allowIn(bots, home, bot) {
326
368
  refuseUnwritable(path.join(home, 'bot.yaml'));
327
- const plans = [[runsOnClaude, planClaude], [runsOnCodex, planCodex]]
328
- .filter(([runs]) => runs(bot)).map(([, plan]) => plan(bots, home, bot));
369
+ const plans = [[runsOnClaude, planClaude], [keepsCodex, planCodex]]
370
+ .filter(([runs]) => runs(bot, home)).map(([, plan]) => plan(bots, home, bot));
329
371
  for (const plan of plans) if (plan.writes) refuseUnwritable(plan.answer.file);
330
372
  return () => plans.map((plan) => plan.write());
331
373
  }
@@ -404,7 +446,8 @@ export function takeBack(bots, home, bot, rules) {
404
446
  }
405
447
  const codex = path.join(home, CODEX_FILE);
406
448
  let before = [];
407
- if (runsOnCodex(bot)) {
449
+ const codexKept = keepsCodex(bot, home);
450
+ if (codexKept) {
408
451
  refuseOutside(home, codex);
409
452
  if (existsSync(codex)) {
410
453
  before = ruleLines(readFileSync(codex, 'utf8'));
@@ -422,7 +465,7 @@ export function takeBack(bots, home, bot, rules) {
422
465
  }
423
466
  entries.push({ bot: bot.name, file, written: [], removed });
424
467
  }
425
- if (runsOnCodex(after)) {
468
+ if (codexKept) {
426
469
  const entry = planCodex(bots, home, after).write();
427
470
  // A line goes only when no rule still allowed gives it.
428
471
  const kept = [...codexOf(home, after).lines.keys()];
package/src/rules.js CHANGED
@@ -21,6 +21,7 @@ import { fileURLToPath } from 'node:url';
21
21
  import { parse } from 'yaml';
22
22
 
23
23
  import { botDir, botNames, displayName, readBot, refuseOutside } from './bot.js';
24
+ import { dropRemovedHooks } from './hooks.js';
24
25
  import { ownCli, shellWord } from './launch.js';
25
26
 
26
27
  /** The kit's own units, inside the installed package. */
@@ -109,13 +110,23 @@ export function buildRules(bots, { bot: onlyBot } = {}) {
109
110
 
110
111
  return (onlyBot === undefined ? names : [onlyBot]).map((name) => {
111
112
  const home = botDir(bots, name);
113
+ let built;
112
114
  try {
113
- return buildAgents(bots, home, readBot(home, name));
115
+ built = buildAgents(bots, home, readBot(home, name));
114
116
  } catch (error) {
115
117
  // A `bot.yaml` the kit cannot read at all. Same answer as a list it
116
118
  // cannot follow: this bot is not built, and nothing else changes.
117
- return { bot: name, file: path.join(home, AGENTS), state: 'failed', trouble: error.message };
119
+ built = { bot: name, file: path.join(home, AGENTS), state: 'failed', trouble: error.message };
118
120
  }
121
+ // Every bot, a paused one too, so this is the command that reaches each
122
+ // hooks file the kit wrote, whatever the bot's sessions run on now (#613).
123
+ // A file it cannot write is this bot's trouble, as above.
124
+ try {
125
+ dropRemovedHooks(home);
126
+ } catch (error) {
127
+ return { ...built, trouble: [built.trouble, error.message].filter((one) => one !== undefined).join(' ') };
128
+ }
129
+ return built;
119
130
  });
120
131
  }
121
132
 
package/src/run.js ADDED
@@ -0,0 +1,155 @@
1
+ // `obk run --timeout <seconds> -- <command…>`: one command run with a hard time
2
+ // limit (#532). macOS has no `timeout`, and the fleet's form for a bounded run,
3
+ // `perl -e 'alarm …; exec @ARGV' -- <command>`, cannot be allowed by a narrow
4
+ // rule, since perl runs whatever its arguments say. A rule for this command
5
+ // names the real command after `--`, and the kit judges that command as a rule
6
+ // of its own (permissions.js). It runs whatever its arguments say too, so it is
7
+ // never in the default set (ADR 0050).
8
+ //
9
+ // The command runs with no shell, in a process group of its own, so the time
10
+ // limit stops what it started as well. Its exit code comes back as it is; one
11
+ // ended by a signal the kit did not send comes back as 128 and the signal's
12
+ // number, as a shell gives it.
13
+
14
+ import { spawn } from 'node:child_process';
15
+ import { constants } from 'node:os';
16
+
17
+ /** The exit code of a run the time limit stopped, as GNU timeout gives it. */
18
+ const TIMED_OUT = 124;
19
+
20
+ /** The exit codes a shell gives a command it cannot find, and one it cannot run. */
21
+ const NOT_FOUND = 127;
22
+ const CANNOT_RUN = 126;
23
+
24
+ /** How long the group has after SIGTERM before SIGKILL, as `retire` gives a session's processes (#537). */
25
+ const GRACE_MS = 3000;
26
+
27
+ /** How often the kit looks whether anything of the group still runs. */
28
+ const LOOK_MS = 100;
29
+
30
+ /** The longest time limit a timer holds, in seconds: about 24 days. */
31
+ const LONGEST = Math.floor((2 ** 31 - 1) / 1000);
32
+
33
+ /** The signals that end the kit and that it passes on to the command's group, so it is not left running. */
34
+ const PASSED_ON = ['SIGINT', 'SIGTERM', 'SIGHUP'];
35
+
36
+ const USE = 'obk run takes --timeout <seconds>, then --, then the command: obk run --timeout 300 -- go test ./...';
37
+
38
+ /**
39
+ * The time limit and the command from `obk run`'s arguments, or a refusal.
40
+ * Only `--timeout` goes before `--`; everything after it is the command's.
41
+ */
42
+ export function parseRun(args) {
43
+ const end = args.indexOf('--');
44
+ const options = end === -1 ? args : args.slice(0, end);
45
+ let value;
46
+ for (let at = 0; at < options.length; at += 1) {
47
+ const option = options[at];
48
+ if (option === '--timeout' && at + 1 < options.length && value === undefined) {
49
+ value = options[at + 1];
50
+ at += 1;
51
+ } else if (option.startsWith('--timeout=') && value === undefined) {
52
+ value = option.slice('--timeout='.length);
53
+ } else {
54
+ throw new Error(`run does not take ${option}. ${USE}. Nothing was run.`);
55
+ }
56
+ }
57
+ if (value === undefined) throw new Error(`run needs --timeout <seconds>: how long the command may run. ${USE}. Nothing was run.`);
58
+ if (!/^\d+$/.test(value) || Number(value) === 0 || Number(value) > LONGEST) {
59
+ throw new Error(`run's --timeout is a whole number of seconds from 1 to ${LONGEST}, and got: ${value === '' ? 'nothing' : value}. Nothing was run.`);
60
+ }
61
+ const command = end === -1 ? [] : args.slice(end + 1);
62
+ if (command.length === 0) throw new Error(`run needs the command after --. ${USE}. Nothing was run.`);
63
+ return { seconds: Number(value), command };
64
+ }
65
+
66
+ /**
67
+ * Run the command with its time limit, its input and output the caller's, and
68
+ * give back the exit code `obk run` exits with. The run ends when nothing of
69
+ * the command's group runs any more: a process the command started and left
70
+ * running is held to the same limit, and to a signal the kit passes on.
71
+ */
72
+ export function runBounded({ seconds, command }) {
73
+ return new Promise((resolve) => {
74
+ // `detached` makes the command the leader of a group of its own, so its
75
+ // pid, taken here at spawn, is the group's id.
76
+ const child = spawn(command[0], command.slice(1), { stdio: 'inherit', detached: true });
77
+ // The leader's exit code, once it has exited.
78
+ let exitCode;
79
+ let stopping = false;
80
+ let result;
81
+ let killAt;
82
+ let deadline;
83
+ let look;
84
+ const signal = (pid, name) => {
85
+ try {
86
+ process.kill(pid, name);
87
+ return true;
88
+ } catch {
89
+ return false;
90
+ }
91
+ };
92
+ // Each process of the group gets a signal once. The leader gets it by its
93
+ // own pid as well only when it may have left the group: when nothing is
94
+ // left in the group, or for SIGKILL, which no handler sees. And only
95
+ // while it has not exited, since its pid is free again after that.
96
+ const group = (name) => {
97
+ const sent = signal(-child.pid, name);
98
+ if (exitCode === undefined && name !== 0 && (!sent || name === 'SIGKILL')) signal(child.pid, name);
99
+ return sent;
100
+ };
101
+
102
+ const finish = (code) => {
103
+ clearTimeout(deadline);
104
+ clearTimeout(look);
105
+ for (const name of PASSED_ON) process.off(name, passOn);
106
+ resolve(code);
107
+ };
108
+
109
+ // Once the leader has exited or the group is being stopped: look until
110
+ // nothing of the group runs, and send SIGKILL when the grace is over.
111
+ const watch = () => {
112
+ if (exitCode !== undefined && !group(0)) return finish(result ?? exitCode);
113
+ if (killAt !== undefined && Date.now() >= killAt) {
114
+ group('SIGKILL');
115
+ killAt = undefined;
116
+ }
117
+ look = setTimeout(watch, LOOK_MS);
118
+ return undefined;
119
+ };
120
+
121
+ // Stop the group: `name` first, then SIGKILL to what still runs after the
122
+ // grace. obk run exits with `code`, or with the leader's own when it is
123
+ // undefined.
124
+ const stop = (name, code) => {
125
+ if (stopping) return;
126
+ stopping = true;
127
+ result = code;
128
+ clearTimeout(deadline);
129
+ group(name);
130
+ killAt = Date.now() + GRACE_MS;
131
+ clearTimeout(look);
132
+ watch();
133
+ };
134
+ const passOn = (name) => stop(name, undefined);
135
+
136
+ child.on('error', (error) => {
137
+ const missing = error.code === 'ENOENT';
138
+ process.stderr.write(`obk: run could not start ${command[0]}: ${missing ? 'there is no such command' : error.code ?? error.message}.\n`);
139
+ finish(missing ? NOT_FOUND : CANNOT_RUN);
140
+ });
141
+ if (child.pid === undefined) return;
142
+ for (const name of PASSED_ON) process.on(name, passOn);
143
+
144
+ child.on('exit', (code, name) => {
145
+ exitCode = code ?? 128 + constants.signals[name];
146
+ clearTimeout(look);
147
+ watch();
148
+ });
149
+
150
+ deadline = setTimeout(() => {
151
+ process.stderr.write(`obk: run stopped ${command.join(' ')}: it ran past its time limit of ${seconds} seconds, and its whole process group was stopped.\n`);
152
+ stop('SIGTERM', TIMED_OUT);
153
+ }, seconds * 1000);
154
+ });
155
+ }
package/src/up.js CHANGED
@@ -12,7 +12,7 @@ import { setTimeout as pause } from 'node:timers/promises';
12
12
  import { forgetClaimed, forgetSession, MAILBOX_WAIT_MS, readBook, rememberSession, sessionIdsIn, tabIdsIn, takeLineTurn, takeMailboxTurn, TYPING_HELD, TYPING_WAIT_MS, updateBook, withUnclaimed } from './book.js';
13
13
  import { botDir, botNames, displayName, projectName, readBot } from './bot.js';
14
14
  import { conversationsIn, hasConversation, heldAsUserTurn, transcriptsIn } from './conversations.js';
15
- import { installHook } from './hooks.js';
15
+ import { dropRemovedHooks, installHook } from './hooks.js';
16
16
  import { writePermissions } from './permissions.js';
17
17
  import { piTrouble } from './pi.js';
18
18
  import { addressOf, HARNESSES, harnessOf, isAddressOf, isShortPrompt, kitFolders, launchCommand, mailboxStep, reachesMail, sessionTrouble, startPrompt, workDirOf } from './launch.js';
@@ -148,8 +148,10 @@ export function prepareBots(bots, names, onlySession) {
148
148
  // so one written later would miss the session it was written for (ADR 0022),
149
149
  // and a bot folder the kit cannot write it into stops the run with nothing
150
150
  // opened anywhere. Only the harnesses a bot actually runs on; a bot with no
151
- // sessions gets none.
151
+ // sessions gets none. The hooks of commands the kit no longer has go from
152
+ // every hooks file the bot has, of a harness it has left too (#613).
152
153
  for (const { bot, home } of running) {
154
+ dropRemovedHooks(home);
153
155
  for (const harness of new Set(awake(sessionsOf(bot, onlySession)).map((session) => harnessOf(session, bot.harness)))) {
154
156
  installHook(home, harness, { bots, bot: bot.name });
155
157
  }