@assuredloop/orca-bot-kit 0.29.0 → 0.31.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,16 +166,18 @@ 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`, `obk init` and
170
- `obk run` are not in the set, and keep your yes
169
+ `obk bot change`, `obk session add`, `obk session change` and `obk init` are
170
+ 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
174
174
  before and that is no longer in the set keeps it: the rule of `obk restart`,
175
175
  `obk bot create`, `obk bot change`, `obk session add`, `obk session change`,
176
176
  `obk session mail` or `obk session nudge`, or of Orca's
177
- `orca orchestration check --run`. `obk rules build` and `obk up` name it, with
178
- the `obk permission disallow` command that takes it back.
177
+ `orca orchestration check --run`. So does a bot that holds a rule you allowed
178
+ for the kit's `run` command, which the kit no longer has. `obk rules build` and
179
+ `obk up` name each such rule, with the `obk permission disallow` command that
180
+ takes it back.
179
181
 
180
182
  Neither command touches Orca. `obk up` is what makes it all real there: an Orca
181
183
  project per bot, a tab per session, each started at the bot home with its own
@@ -680,82 +682,7 @@ fleet at all. Turn it off for a session with your own
680
682
  the kit will report that session as out of reach of fleet mail rather than
681
683
  failing quietly.
682
684
 
683
- ## Working on the kit
685
+ ## Contributing
684
686
 
685
- ```sh
686
- npm test # the whole suite, in a few minutes
687
- npm run test:system # what the system tests would drive, and nothing else
688
- npm run test:system -- --yes # drive them, on this machine, for real
689
- npm run test:system -- --yes test/system/groom.test.js # only the ones you name
690
- npm run mutate # the mutation audit, on what your branch changed
691
- ```
692
-
693
- The tests come in two layers, and every test file belongs to one of them.
694
-
695
- `npm test` is `test/*.test.js` and `test/dev/*.test.js`: unit tests and
696
- end-to-end runs of the CLI in a throwaway folder, with a fake `orca` on PATH. It
697
- needs nothing but Node, so [GitHub Actions](.github/workflows/ci.yml) runs it on
698
- every pull request, split into shards that run side by side, on Node 25.8.0, the
699
- current line. A release runs every shard on 25.8.0 and again on 24.21.0, the
700
- floor `engines.node` promises users, with only the kit's own dependencies
701
- installed, before anything is published. The floor leaves out `test/dev/`, the
702
- tests that need a development tool.
703
-
704
- `npm run test:system` is `test/system/*.test.js`: the real `obk` against the
705
- real Orca and the real harnesses on your own machine. No CI runner can do that,
706
- so these are run by hand before a change that touches Orca or a harness is
707
- merged. A system test touches only what it creates and removes it afterwards,
708
- except the mailbox Runs Orca gives no way to delete, which the run names; it
709
- never closes a tab it did not open. Before any test loads, the command copies
710
- your `~/.claude.json` and `~/.codex/config.toml` into a new
711
- `obk-config-backup-*` folder in your temp folder, readable by you alone, and
712
- says where; if a copy fails, no test runs. After the run it says whether each
713
- file still matches its copy, and never copies one back: that is yours to
714
- decide. The trust its harnesses write into your `~/.codex/config.toml` and
715
- `~/.claude.json` for the run's own throwaway folders is taken out again after
716
- the run, and named; nothing else in them is changed.
717
- An Orca project the run leaves in one of its own throwaway folders is named,
718
- with its setup id, and fails the run; the command removes none itself.
719
-
720
- They run on the machine the command is typed on, so the command will not start
721
- them by itself. On its own it says what it is about to drive — whose machine,
722
- which Orca, which files, and what they will do to it — and then stops without
723
- driving any of it, answering non-zero so that a run which did not run them
724
- cannot be read as one that passed. `-- --yes` is how you say you meant it. When
725
- Orca is not answering, the command says which Orca it asked and skips, rather
726
- than report a kit that is not broken. Name files after it to run only those;
727
- each must be one of the `*.test.js` files under `test/system/`, links followed,
728
- and a name that is not is refused before anything runs. This command is the
729
- only way in: a system test loaded any other way, with `node --test` or an
730
- editor's runner, skips and says how to run it.
731
-
732
- `npm run mutate` runs [StrykerJS](https://stryker-mutator.io) over the
733
- JavaScript this branch changed against `main` — committed, still in the working
734
- tree, or not tracked yet — and reports the mutants the tests do not kill. It is
735
- an audit, not a step of every pull request
736
- ([PRD 7.3](docs/prd/prd.md#prd-21-73-decided-rules-inside-the-skills)): the everyday
737
- check is the implementer's own hand check, and the tool runs over the whole
738
- suite at a milestone or when the owner asks, or once, narrowed to the changed
739
- logic, for a change at the core of the kit. A surviving mutant is a change to
740
- the code that no test objects to. It is worth a test only where it shows a gap
741
- in behaviour the requirement cares about, and that test is written by the
742
- separate test author. Name targets yourself to check a whole area instead:
743
-
744
- ```sh
745
- npm run mutate -- 'src/**/*.js'
746
- ```
747
-
748
- Every mutant means running the tests again, so the check does not run them the
749
- way `npm test` does. It runs [`scripts/mutation-suite.js`](scripts/mutation-suite.js)
750
- instead: the same test files, one at a time, stopping at the first file that
751
- fails, trying the file that killed the last mutant first and then the quickest.
752
- On a ten-core machine, `src/up.js` as it was when this was measured (128 lines,
753
- 76 mutants) took about twelve minutes that way. Running the whole suite for every mutant, as it used to, took
754
- forty, and counted a mutant as killed whenever a run of it ran out of time. A
755
- change across several files takes proportionally longer, so check one or two at
756
- a time.
757
-
758
- The check still takes minutes where the suite takes seconds, which is why it is
759
- a command of its own. StrykerJS is a development dependency of this repo, pinned
760
- to an exact version and needing Node 22 or newer; the kit itself neither ships
761
- it nor needs it.
687
+ How to run the kit's own tests, and how they are laid out, is in
688
+ [CONTRIBUTING.md](CONTRIBUTING.md). The package does not ship it.
package/SETUP.md CHANGED
@@ -144,8 +144,8 @@ Read everything it prints. Three parts of it matter to them:
144
144
  beside the bots folder, and its commits. The kit writes this set for every
145
145
  bot, with nobody asked. Say in a sentence what it allows, and that
146
146
  `obk permission`, `obk retire`, `obk pause`, `obk restart`, `obk bot create`,
147
- `obk bot change`, `obk session add`, `obk session change`, `obk init` and
148
- `obk run` are not in it and keep their yes;
147
+ `obk bot change`, `obk session add`, `obk session change` and `obk init` are
148
+ not in it and keep their yes;
149
149
  - **anything it says about Orca's own default launch arguments.** Orca adds
150
150
  these to every agent it launches, relaunches and resumes. When they carry a
151
151
  permission bypass, every session runs in that mode whatever approval level the
@@ -218,7 +218,7 @@ answers that screen, by the command and its arguments below the table.
218
218
  | Codex's `/new`: `Where should the new conversation run?` | `\r` | return: its selection starts on **Current checkout** (bot home); on 0.162.0 it is called **1. Use current Git worktree** (Keep using the current working directory). Never **Create new Git worktree** | Codex 0.160.0; Codex 0.162.0 (#516) |
219
219
  | Codex's update offer, `1. Update now` | `\r` | return: its selection starts on **Update now**, which accepts it | Codex 0.156.1; seen on 0.157.1 (offering 0.158.0) with the selection on **Update now**, not pressed; 0.158.0 offered 0.160.0, taken on 2026-10-02 by Bot Father's restarts |
220
220
  | `[oh-my-zsh] Would you like to update?` | `n` | they update their own shell | oh-my-zsh bf77e35 |
221
- | Claude Code's `Teach auto mode about your environment?` | `obk session answer`, or `obk temp answer` for a temporary session | the kit sends Esc, which cancels it (**Not now**). On 2.1.283 and 2.1.302 it is a form, not a list: `How you use Claude here`, `Also scan shell history`, `Also scan your other repos`, `Continue`, and `Enter to continue · Esc to cancel`. On 2.1.283, `Also scan shell history` shows `true` and the foot starts `←/→ to change usage`. On 2.1.302, it shows `false` and the foot starts `←/→ to change`. **Never send a return**: Enter is **Continue**, which starts a scan of the project, recent Claude sessions and, by default, the machine's shell history | Not yet proven: Esc cancels in Claude Code 2.1.283's code, not yet seen live (#370); the form was seen live twice on 2.1.283, in #261's live test and #239's live run 1 (#416); 2.1.278 showed a numbered list, answered with 2. Not now; the 2.1.302 form was seen live once, in #604 |
221
+ | Claude Code's `Teach auto mode about your environment?` | `obk session answer`, or `obk temp answer` for a temporary session | the kit sends Esc, which cancels it (**Not now**). On 2.1.283 and 2.1.302 it is a form, not a list: `How you use Claude here`, `Also scan shell history`, `Also scan your other repos`, `Continue`, and `Enter to continue · Esc to cancel`. On 2.1.283 the foot starts `←/→ to change usage`, and on 2.1.302 it starts `←/→ to change`. The values after the three settings can differ: `Also scan shell history` showed `true` on 2.1.283, and both `false` and `true` on 2.1.302. The kit sends Esc whatever the values are. **Never send a return**: Enter is **Continue**, which starts a scan of the project, recent Claude sessions and, by default, the machine's shell history | Not yet proven: Esc cancels in Claude Code 2.1.283's code, not yet seen live (#370); the form was seen live twice on 2.1.283, in #261's live test and #239's live run 1 (#416); 2.1.278 showed a numbered list, answered with 2. Not now; the 2.1.302 form was seen live twice, in #604 and in #646, where it showed `Also scan shell history` on `true` and one Esc sent by hand removed it |
222
222
  | Claude Code 2.1.289's `Teach auto mode about your environment?`, a numbered list: `1. Yes`, `2. Not now`, `3. Don't show again`, and `Enter to confirm · Esc to cancel`, above the input box | `obk session answer`, or `obk temp answer` for a temporary session | the kit goes down, then returns, to **2. Not now**: its selection starts on **1. Yes**. The kit reads the screen after the down arrow, and sends the return only with the selection on **2. Not now**. Never **1. Yes**, which starts the teach scan, and never **3. Don't show again** | Not yet proven: the list was seen live on 2.1.289 with its selection on 1. Yes, in #489's live run 1, and was not answered there |
223
223
 
224
224
  Each key counts on where the selection starts, as it did on the version named.
@@ -248,7 +248,7 @@ rule to type into any tab. On Codex's hooks review, the maker runs
248
248
  `Teach auto mode` screen, it runs `obk temp answer --bots <their path> --name <session>`.
249
249
  That command reads the screen and answers only when it holds one of the
250
250
  shapes in the table exactly as the kit knows it: Esc for the 2.1.283 and
251
- 2.1.302 forms, and
251
+ 2.1.302 forms, whatever values their settings show, and
252
252
  **2. Not now** for the 2.1.289 list. On the list it sends the arrows, reads the
253
253
  screen again, and sends the return only with the selection on **2. Not now**.
254
254
  It refuses any other screen, types nothing then, and prints what it saw.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assuredloop/orca-bot-kit",
3
- "version": "0.29.0",
3
+ "version": "0.31.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -214,12 +214,13 @@ 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`, `obk init` and `obk run`. A bot that still holds a rule that the
217
+ `obk session change` and `obk init`. 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
221
- --run`) keeps it, and `obk rules build` and `obk up` name
222
- it with the `obk permission disallow` command that takes it back; run that
221
+ --run`) keeps it, and so does a bot that holds a rule the user allowed for the
222
+ kit's `run` command, which the kit no longer has. `obk rules build` and `obk up`
223
+ name each such rule with the `obk permission disallow` command that takes it back; run that
223
224
  command only after the user's yes. Never write a
224
225
  permission rule into a settings or rules file by hand.
225
226
 
@@ -245,11 +246,7 @@ out; the kit refuses it, and it refuses a rule of its own default set too. Keep
245
246
  each rule narrow: a program and what it does. The kit refuses a rule that lets
246
247
  the bot run any command, a program with any arguments, or a shell or
247
248
  interpreter with any arguments; a user who wants one for a bot adds it to that
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
+ bot's settings themselves. Propose no rule for what the charter does not grant.
253
250
 
254
251
  A bot that should learn across its sessions can use its harness's own memory,
255
252
  turned on for that bot alone, whatever the user's own setting is for everything
@@ -328,7 +325,9 @@ numbered list, answered with `2. Not now`, and on 2.1.289 it is one again:
328
325
  selection on 1. Yes (seen live, not answered, in #489). The kit goes down to
329
326
  2. Not now, reads the screen, and only then returns. On 2.1.302 it is the
330
327
  form again, with `Also scan shell history` on `false` and a foot that starts
331
- `←/→ to change` (seen live in #604); the kit sends it Esc too. Codex's hooks
328
+ `←/→ to change` (seen live in #604), and later with it on `true` (#646). The
329
+ values of its three settings can differ, and the kit sends it Esc whatever
330
+ they are. Codex's hooks
332
331
  question matters most. The kit's hook is how the book learns which
333
332
  conversation the session is running, and until it is answered the
334
333
  conversation has not started. Codex's trust applies to the repository root,
package/src/bot.js CHANGED
@@ -8,7 +8,6 @@
8
8
  // writes nothing, rather than leave a bot half made or a bot.yaml half edited.
9
9
 
10
10
  import { accessSync, constants, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
11
- import os from 'node:os';
12
11
  import path from 'node:path';
13
12
  import { isDeepStrictEqual } from 'node:util';
14
13
  import { isSeq, parse, parseDocument, stringify } from 'yaml';
@@ -133,25 +132,6 @@ function whereItLeads(target) {
133
132
  export const displayName = (name) =>
134
133
  name.split('-').map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(' ');
135
134
 
136
- /**
137
- * What Orca is asked to call a bot's project. A fleet whose bots folder is under
138
- * the system's temp folder, as a system test's or a live check's is, gets a mark
139
- * and the name of the first folder under the temp folder, so its Bot Father is
140
- * never taken for the owner's in Orca's sidebar (#401). Every other fleet's
141
- * project is called what it always was. Tab titles keep `displayName` either way.
142
- */
143
- export function projectName(bots, name) {
144
- let under;
145
- try {
146
- under = path.relative(realpathSync(os.tmpdir()), realpathSync(bots));
147
- } catch {
148
- return displayName(name);
149
- }
150
- // The temp folder itself, or anywhere outside it, is not a throwaway fleet's.
151
- if (under === '' || under === '..' || under.startsWith(`..${path.sep}`) || path.isAbsolute(under)) return displayName(name);
152
- return `${displayName(name)} · temp fleet ${under.split(path.sep)[0]}`;
153
- }
154
-
155
135
  /**
156
136
  * A bot's own file: which harness its sessions run on unless they say
157
137
  * otherwise, and the sessions it keeps.
package/src/cli.js CHANGED
@@ -21,7 +21,7 @@ import { nameSession } from './name.js';
21
21
  import { APPROVALS, harnessOf, HARNESSES, ownCli, refuseApprovalArgs, shellWord, workDirOf } from './launch.js';
22
22
  import { checkMail, lookUp, noMailboxYet, sendMessage, sessionInTab } from './message.js';
23
23
  import { orcaCli, orcaTrouble, RELOAD_LINE, TERMINAL_ENV } from './orca.js';
24
- import { allowIn, beyondDefaults, botDefaults, refuseBroad, refuseNoCodexForm, runsOnClaude, runsOnCodex, takeBack, writePermissions } from './permissions.js';
24
+ import { allowIn, beyondDefaults, botDefaults, isRunRule, 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';
@@ -34,7 +34,6 @@ 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';
38
37
  import { BOT_FATHER, bringUp, ownMailbox } from './up.js';
39
38
 
40
39
  const USAGE = `obk — Orca Bot Kit.
@@ -327,22 +326,13 @@ Usage:
327
326
  For the kit's own launch line, not for typing: run
328
327
  in the session's own tab, it gives the session its
329
328
  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.
339
329
  obk --version Print the kit's version.
340
330
  obk --help Print this text.
341
331
 
342
332
  Every command is safe to run again. Most only add what is missing; the ones
343
333
  that close a tab or take something away say so above. Every bot is allowed
344
- the kit's commands by default, except obk permission, retire, pause, init and
345
- run, which keep the user's yes.
334
+ the kit's commands by default, except obk permission, retire, pause and init,
335
+ which keep the user's yes.
346
336
  Add --json to any of them for the same answer as JSON.
347
337
 
348
338
  For another build of Orca, OBK_ORCA names its CLI, and OBK_ORCA_DATA the full
@@ -396,10 +386,6 @@ function version() {
396
386
  }
397
387
 
398
388
  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
-
403
389
  const { values, positionals, tokens } = parseArgs({
404
390
  args: argv,
405
391
  allowPositionals: true,
@@ -1819,7 +1805,7 @@ function permissionsLines(permissions, bots) {
1819
1805
  ...(entry.trouble === undefined ? [] : [`${'refused'.padEnd(9)} ${entry.trouble}`]),
1820
1806
  ...(listed.has(entry.bot) || !listed.add(entry.bot) ? [] : [
1821
1807
  ...(entry.defaults ?? []).map((rule) => `${'allowed'.padEnd(9)} ${entry.bot} ${rule}`),
1822
- ...(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)}`),
1808
+ ...(entry.held ?? []).map((rule) => `${'holds'.padEnd(9)} ${entry.bot} ${rule}, ${isRunRule(rule) ? 'which is for obk run, a command the kit no longer has' : "which is no longer one of the kit's defaults"}. To take it back: ${disallow(entry.bot, rule)}`),
1823
1809
  ]),
1824
1810
  ...(entry.written.length === 0
1825
1811
  ? []
package/src/commands.js CHANGED
@@ -46,7 +46,6 @@ 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'],
50
49
  };
51
50
 
52
51
  /**
@@ -69,14 +68,12 @@ export const DEFAULT_COMMANDS = [
69
68
  * The commands kept out of the default permission set, which keep the user's
70
69
  * yes (ADR 0051): the ones that change permissions, close a long-lived
71
70
  * session, make the bots folder, or make or change a bot or its sessions,
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).
71
+ * which the owner leaves to Bot Father (#548, #615).
74
72
  */
75
73
  export const KEPT_BACK = [
76
74
  'init', 'retire', 'pause', 'restart', 'bot create',
77
75
  'bot change', 'session add', 'session change',
78
76
  'permission allow', 'permission disallow', 'permission approval',
79
- 'run',
80
77
  ];
81
78
 
82
79
  /**
@@ -87,8 +84,8 @@ export const KEPT_BACK = [
87
84
  export const NO_LONGER_DEFAULT = ['restart', 'bot create', 'bot change', 'session add', 'session change'];
88
85
 
89
86
  /**
90
- * The commands kit 0.26.0 gave every bot by default and that the kit no longer
91
- * has (#555). A bot may still hold their rules, which the kit names and leaves
92
- * (#564).
87
+ * The commands the kit no longer has: the two kit 0.26.0 gave every bot by
88
+ * default (#555), and `run` (#532), which #652 took out of the kit. A bot may
89
+ * still hold their rules, which the kit names and leaves (#564).
93
90
  */
94
- export const REMOVED = ['session mail', 'session nudge'];
91
+ export const REMOVED = ['session mail', 'session nudge', 'run'];
package/src/orca.js CHANGED
@@ -381,8 +381,8 @@ function askRuntime(method, params, killMs = CLIENT_KILL_MS) {
381
381
 
382
382
  const RUNTIME_SCRIPT = fileURLToPath(new URL('./orca-runtime.cjs', import.meta.url));
383
383
 
384
- /** The `osascript` this run asks. OBK_OSASCRIPT overrides it, as OBK_PS does `ps`. */
385
- const osascriptCli = () => process.env.OBK_OSASCRIPT || '/usr/bin/osascript';
384
+ /** The `osascript` the kit asks, by its fixed path: no setting changes it (#652). */
385
+ const OSASCRIPT = '/usr/bin/osascript';
386
386
 
387
387
  /** How long macOS is given to click the menu item. */
388
388
  const RELOAD_KILL_MS = 5000;
@@ -406,7 +406,7 @@ export function reloadWindow() {
406
406
  try {
407
407
  // <Orca.app>/Contents/Resources/bin/orca, often reached through a link.
408
408
  const app = path.resolve(realpathSync(orcaCli()), '..', '..', '..', '..');
409
- const asked = spawnSync(osascriptCli(), [RELOAD_SCRIPT, app], {
409
+ const asked = spawnSync(OSASCRIPT, [RELOAD_SCRIPT, app], {
410
410
  encoding: 'utf8',
411
411
  stdio: ['ignore', 'pipe', 'pipe'],
412
412
  timeout: RELOAD_KILL_MS,
@@ -741,8 +741,8 @@ export function frontOfTab(handle) {
741
741
  return frontOf(handle, shown?.ptyId);
742
742
  }
743
743
 
744
- /** The `ps` this run reads. OBK_PS overrides it, as OBK_ORCA does Orca. */
745
- export const psCli = () => process.env.OBK_PS || '/bin/ps';
744
+ /** The `ps` the kit reads, by its fixed path: no setting changes it (#652). */
745
+ export const PS = '/bin/ps';
746
746
 
747
747
  /**
748
748
  * Who holds the terminal of the tab `handle`, whose pane is `ptyId`:
@@ -838,9 +838,9 @@ function panePid(ptyId, readMs) {
838
838
  return { pane };
839
839
  }
840
840
 
841
- /** The `lsof` and `stty` this run asks. OBK_LSOF and OBK_STTY override them, as OBK_PS does `ps`. */
842
- export const lsofCli = () => process.env.OBK_LSOF || '/usr/sbin/lsof';
843
- const sttyCli = () => process.env.OBK_STTY || '/bin/stty';
841
+ /** The `lsof` and `stty` the kit asks, by their fixed paths, as `ps`. */
842
+ export const LSOF = '/usr/sbin/lsof';
843
+ const STTY = '/bin/stty';
844
844
 
845
845
  /**
846
846
  * One look at the shell of a tab the kit has just opened, before anything is
@@ -893,7 +893,7 @@ export function shellInTab(handle, until) {
893
893
  * or as the pane itself where the pane is the shell.
894
894
  */
895
895
  function ttyMode(pid, left) {
896
- const listed = spawnSync(lsofCli(), ['-a', '-R', '-d', '0', '-u', String(process.getuid()), '-FpRn'], { encoding: 'utf8', timeout: left() });
896
+ const listed = spawnSync(LSOF, ['-a', '-R', '-d', '0', '-u', String(process.getuid()), '-FpRn'], { encoding: 'utf8', timeout: left() });
897
897
  if (listed.error?.code === 'ETIMEDOUT') return { unreadable: 'lsof did not answer in time', late: true };
898
898
  if (listed.error || listed.status !== 0) return { unreadable: `lsof could not list the processes' ttys` };
899
899
  // One record per process: `p<pid>`, `R<parent pid>`, `f0`, `n<name>`.
@@ -906,7 +906,7 @@ function ttyMode(pid, left) {
906
906
  }
907
907
  if (tty === undefined) return { unreadable: `lsof named no tty for the shell of its pane, pid ${pid}` };
908
908
 
909
- const read = spawnSync(sttyCli(), ['-a', '-f', tty], { encoding: 'utf8', timeout: left() });
909
+ const read = spawnSync(STTY, ['-a', '-f', tty], { encoding: 'utf8', timeout: left() });
910
910
  if (read.error?.code === 'ETIMEDOUT') return { unreadable: `stty did not answer in time for ${tty}`, late: true };
911
911
  if (read.error || read.status !== 0) return { unreadable: `stty could not read ${tty}` };
912
912
  const words = read.stdout.split(/\s+/);
@@ -926,14 +926,14 @@ function ttyMode(pid, left) {
926
926
  * looks for one whole word it knows.
927
927
  */
928
928
  export function wordsOfProcess(pid) {
929
- const asked = spawnSync(psCli(), ['-E', '-ww', '-o', 'command=', '-p', String(pid)], { encoding: 'utf8' });
929
+ const asked = spawnSync(PS, ['-E', '-ww', '-o', 'command=', '-p', String(pid)], { encoding: 'utf8' });
930
930
  if (asked.error || asked.status !== 0) return undefined;
931
931
  return asked.stdout.trim().split(/\s+/);
932
932
  }
933
933
 
934
934
  /** One process as `ps` gives it, read only: `{ ppid, tpgid, comm }`, `{ late: true }` past `readMs`, or undefined. */
935
935
  function psLine(pid, readMs) {
936
- const asked = spawnSync(psCli(), ['-o', 'pid=,ppid=,tpgid=,comm=', '-p', String(pid)], { encoding: 'utf8', ...(readMs === undefined ? {} : { timeout: readMs }) });
936
+ const asked = spawnSync(PS, ['-o', 'pid=,ppid=,tpgid=,comm=', '-p', String(pid)], { encoding: 'utf8', ...(readMs === undefined ? {} : { timeout: readMs }) });
937
937
  if (asked.error?.code === 'ETIMEDOUT') return { late: true };
938
938
  if (asked.error || asked.status !== 0) return undefined;
939
939
  const line = /^\s*(\d+)\s+(\d+)\s+(-?\d+)\s+(.+?)\s*$/.exec(asked.stdout);
@@ -59,18 +59,34 @@ export function defaultRules(bots, cli = ownCli()) {
59
59
  */
60
60
  const ORCA_CHECK = 'Bash(orca orchestration check --run:*)';
61
61
 
62
+ /**
63
+ * Whether a rule is one for `obk run` (#532), which #652 took out of the kit:
64
+ * the kit, then `run`. The kit is its CLI as `permission allow` recorded it,
65
+ * the bare `obk` that `obk run`'s own help spelled, or any path to an `obk`,
66
+ * quoted or not. A rule for it named the command after `--`, so it is known by
67
+ * its first two shell words.
68
+ */
69
+ export function isRunRule(rule, cli = ownCli()) {
70
+ const text = /^Bash\((.*)\)$/s.exec(rule)?.[1];
71
+ const [kit, word] = (text === undefined ? undefined : shellWords(text).words) ?? [];
72
+ if (kit === undefined || word === undefined) return false;
73
+ return (kit === cli || path.basename(kit) === 'obk') && (word === 'run' || word.startsWith('run:'));
74
+ }
75
+
62
76
  /**
63
77
  * The rules that were defaults once and are not now, that the bot's `allow`
64
78
  * holds: those of the commands kept back (#548, #615) or gone from the kit (#555),
65
- * spelled as `defaultRules` spelled them, and Orca's check (#564). The kit
66
- * cannot tell its own old entry from a user's yes to the same rule, so it
79
+ * spelled as `defaultRules` spelled them, and Orca's check (#564); and any rule
80
+ * for `obk run`, which the user allowed and the kit no longer has (#652). The
81
+ * kit cannot tell its own old entry from a user's yes to the same rule, so it
67
82
  * names them and leaves them (ADR 0051, ADR 0050).
68
83
  */
69
84
  export function heldRules(bots, home, bot, cli = ownCli()) {
70
85
  const allowed = allowOf(home, bot);
71
- return [...NO_LONGER_DEFAULT, ...REMOVED].map((command) => `Bash(${shellWord(cli)} ${command} --bots ${shellWord(bots)}:*)`)
86
+ const spelled = [...NO_LONGER_DEFAULT, ...REMOVED].map((command) => `Bash(${shellWord(cli)} ${command} --bots ${shellWord(bots)}:*)`)
72
87
  .concat(ORCA_CHECK)
73
88
  .filter((rule) => allowed.includes(rule));
89
+ return [...new Set([...spelled, ...allowed.filter((rule) => isRunRule(rule, cli))])];
74
90
  }
75
91
 
76
92
  /** Command wrappers: programs that run the command their arguments name. */
@@ -165,14 +181,12 @@ function commandBroadness(all) {
165
181
  if (star === -1) return undefined;
166
182
 
167
183
  const program = path.basename(words[0].replace(/:?\*.*$/, ''));
168
- if (isKit(words[0]) && mayRun(words[1] ?? '')) return runBroadness(words);
169
184
  if (runnerOf(program) !== undefined) {
170
185
  // Only what runs, fixed by its absolute path, narrows it. A wrapper's
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.
186
+ // program is then judged by itself; an interpreter's script is the end.
173
187
  const next = (words[1] ?? '').replace(/:\*$/, '');
174
188
  const fixed = next.startsWith('/') && !next.includes('*');
175
- if (fixed && (WRAPPERS.has(program) || isKit(next))) return commandBroadness(words.slice(1));
189
+ if (fixed && WRAPPERS.has(program)) return commandBroadness(words.slice(1));
176
190
  if (fixed && runnerOf(path.basename(next)) === undefined) return undefined;
177
191
  return `${program} runs whatever its arguments say, so ${ANY}`;
178
192
  }
@@ -185,37 +199,6 @@ function commandBroadness(all) {
185
199
  return undefined;
186
200
  }
187
201
 
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
-
219
202
  /**
220
203
  * Refuse the rules `permission allow` was given when any is broad, before anything is
221
204
  * written, naming the file the user can add it to themselves.
package/src/processes.js CHANGED
@@ -19,19 +19,18 @@
19
19
  // signals: SIGTERM first, to the whole group where every process in it is the
20
20
  // session's own, then up to 3 seconds for them to end, then SIGKILL to each
21
21
  // that still runs. Inside Codex's sandbox `ps` does not start (#298), and then
22
- // nothing is stopped and the answer says it cannot tell. OBK_PS, OBK_LSOF and
23
- // OBK_KILL override the three, as OBK_ORCA does Orca, so the suite runs on
24
- // fakes and sends no real signal.
22
+ // nothing is stopped and the answer says it cannot tell. The kit runs the three
23
+ // by their fixed paths, and no setting changes them (#652).
25
24
 
26
25
  import { spawnSync } from 'node:child_process';
27
26
  import { realpathSync } from 'node:fs';
28
27
  import path from 'node:path';
29
28
  import { setTimeout as delay } from 'node:timers/promises';
30
29
 
31
- import { lsofCli, psCli } from './orca.js';
30
+ import { LSOF, PS } from './orca.js';
32
31
 
33
- /** The `kill` this run sends signals with. OBK_KILL overrides it. */
34
- const killCli = () => process.env.OBK_KILL || '/bin/kill';
32
+ /** The `kill` the kit sends signals with, by its fixed path. */
33
+ const KILL = '/bin/kill';
35
34
 
36
35
  /** How long one `ps`, `lsof` or `kill` may take. */
37
36
  const READ_MS = 10_000;
@@ -273,7 +272,7 @@ function readProcesses() {
273
272
  * READ_MS.
274
273
  */
275
274
  function workingFolders(ms = READ_MS) {
276
- const asked = spawnSync(lsofCli(), ['-a', '-d', 'cwd', '-u', String(process.getuid()), '-Fpn'], { encoding: 'utf8', timeout: bound(ms), maxBuffer: 64 * 1024 * 1024 });
275
+ const asked = spawnSync(LSOF, ['-a', '-d', 'cwd', '-u', String(process.getuid()), '-Fpn'], { encoding: 'utf8', timeout: bound(ms), maxBuffer: 64 * 1024 * 1024 });
277
276
  if (asked.error?.code === 'ETIMEDOUT') return { unreadable: 'lsof did not answer in time' };
278
277
  if (asked.error || asked.status !== 0) return { unreadable: `lsof could not list the processes' working folders${because(asked)}` };
279
278
  const cwds = new Map();
@@ -292,7 +291,7 @@ function workingFolders(ms = READ_MS) {
292
291
  * own command line (review of PR #543). Or `{ unreadable: <why> }`.
293
292
  */
294
293
  function processTable(ms = READ_MS) {
295
- const asked = spawnSync(psCli(), ['-A', '-ww', '-o', 'pid=,ppid=,pgid=,uid=,stat=,lstart=,command='], { encoding: 'utf8', timeout: bound(ms), maxBuffer: 64 * 1024 * 1024 });
294
+ const asked = spawnSync(PS, ['-A', '-ww', '-o', 'pid=,ppid=,pgid=,uid=,stat=,lstart=,command='], { encoding: 'utf8', timeout: bound(ms), maxBuffer: 64 * 1024 * 1024 });
296
295
  if (asked.error?.code === 'ETIMEDOUT') return { unreadable: 'ps did not answer in time' };
297
296
  if (asked.error || asked.status !== 0) return { unreadable: `ps could not read the process table${because(asked)}` };
298
297
  const table = new Map();
@@ -383,7 +382,7 @@ const inside = (cwd, dir) => cwd === dir || cwd.startsWith(`${dir}${path.sep}`);
383
382
  */
384
383
  function signal(name, target, ms = READ_MS) {
385
384
  if (!Number.isInteger(target) || Math.abs(target) <= 1) return { refused: `the kit does not signal ${target}` };
386
- const sent = spawnSync(killCli(), ['-s', name, '--', String(target)], { encoding: 'utf8', timeout: bound(ms) });
385
+ const sent = spawnSync(KILL, ['-s', name, '--', String(target)], { encoding: 'utf8', timeout: bound(ms) });
387
386
  // Cut off at its time: whether the signal went is not known.
388
387
  if (sent.error?.code === 'ETIMEDOUT') return { late: true };
389
388
  if (!sent.error && sent.status === 0) return {};
package/src/temp.js CHANGED
@@ -530,10 +530,30 @@ const TEACH_TITLE = 'Teach auto mode about your environment?';
530
530
  /** The foot that ends a Claude Code form, below which its input box sits. */
531
531
  const TEACH_FOOT = /Enter to .*· Esc to cancel/;
532
532
 
533
+ /**
534
+ * A setting row of the Teach form: its name, then spaces and a value. The value
535
+ * is whatever the setting shows, `Mixed`, `true` or `false` as read, and Esc
536
+ * cancels the form whatever it is (#646).
537
+ */
538
+ const setting = (name) => ({ name, fits: (row) => row.startsWith(name) && /^ +\S/.test(row.slice(name.length)) });
539
+
540
+ /** The Teach form's rows, from its title to its foot, with `foot`. */
541
+ const teachForm = (foot) => [
542
+ TEACH_TITLE,
543
+ 'Claude Code reads this project, your recent Claude sessions, and optionally your shell history and other',
544
+ 'repositories. Claude analyzes this data and customizes auto mode to make better decisions.',
545
+ setting('How you use Claude here'),
546
+ setting('Also scan shell history'),
547
+ setting('Also scan your other repos'),
548
+ 'Continue',
549
+ foot,
550
+ ];
551
+
533
552
  /**
534
553
  * The known shapes of the Teach screen, from its title to its foot, each as
535
554
  * captured live, with what answers it. Rows are compared trimmed, the pointer
536
- * `❯` taken out, so the pointer may sit on any one of them.
555
+ * `❯` taken out, so the pointer may sit on any one of them. A setting row
556
+ * matches by its name, whatever its value.
537
557
  *
538
558
  * Claude Code 2.1.283's form (#261's live test, #416) and 2.1.302's (#604) are
539
559
  * answered with Esc, which cancels them (Not now), never a return, which is
@@ -542,34 +562,8 @@ const TEACH_FOOT = /Enter to .*· Esc to cancel/;
542
562
  * architect's ruling on #489).
543
563
  */
544
564
  const TEACH_SHAPES = [
545
- {
546
- name: 'the 2.1.283 form',
547
- rows: [
548
- TEACH_TITLE,
549
- 'Claude Code reads this project, your recent Claude sessions, and optionally your shell history and other',
550
- 'repositories. Claude analyzes this data and customizes auto mode to make better decisions.',
551
- 'How you use Claude here Mixed',
552
- 'Also scan shell history true',
553
- 'Also scan your other repos false',
554
- 'Continue',
555
- '←/→ to change usage · Enter to continue · Esc to cancel',
556
- ],
557
- escape: true,
558
- },
559
- {
560
- name: 'the 2.1.302 form',
561
- rows: [
562
- TEACH_TITLE,
563
- 'Claude Code reads this project, your recent Claude sessions, and optionally your shell history and other',
564
- 'repositories. Claude analyzes this data and customizes auto mode to make better decisions.',
565
- 'How you use Claude here Mixed',
566
- 'Also scan shell history false',
567
- 'Also scan your other repos false',
568
- 'Continue',
569
- '←/→ to change · Enter to continue · Esc to cancel',
570
- ],
571
- escape: true,
572
- },
565
+ { name: 'the 2.1.283 form', rows: teachForm('←/→ to change usage · Enter to continue · Esc to cancel'), escape: true },
566
+ { name: 'the 2.1.302 form', rows: teachForm('←/→ to change · Enter to continue · Esc to cancel'), escape: true },
573
567
  {
574
568
  name: 'the 2.1.289 list',
575
569
  rows: [
@@ -589,6 +583,12 @@ const NOT_NOW_CHOICE = '2. Not now';
589
583
  /** A row as a shape is compared: trimmed, with its pointer, wherever it sits, taken out. */
590
584
  const unpointed = (row) => row.replace('❯', ' ').trim();
591
585
 
586
+ /** Whether a shape's row `want` is the screen's `row`, as unpointed gives it. */
587
+ const fitsRow = (want, row) => (typeof want === 'string' ? row === want : want.fits(row));
588
+
589
+ /** A shape's row as a refusal names it. */
590
+ const shapeRow = (want) => (typeof want === 'string' ? want : `${want.name} and a value`);
591
+
592
592
  /** The full-width rule Claude Code draws right above the screen's title: `─` in 2.1.289, `▔` in 2.1.283. */
593
593
  const ABOVE_TITLE = /^(?:─+|▔+)$/;
594
594
 
@@ -622,13 +622,13 @@ function teachScreen(rows) {
622
622
  const misfits = TEACH_SHAPES.map((shape) => {
623
623
  const pointers = block.filter((row) => row.includes('❯')).length;
624
624
  if (pointers !== 1) return `${pointers} rows with the pointer ❯, where ${shape.name} has one`;
625
- const odd = block.find((row) => !shape.rows.includes(unpointed(row)));
625
+ const odd = block.find((row) => !shape.rows.some((want) => fitsRow(want, unpointed(row))));
626
626
  if (odd !== undefined) return `a row ${shape.name} does not have: ${odd.trim()}`;
627
- const missing = shape.rows.find((row) => !block.map(unpointed).includes(row));
628
- if (missing !== undefined) return `no row ${missing}, which ${shape.name} has`;
627
+ const missing = shape.rows.find((want) => !block.some((row) => fitsRow(want, unpointed(row))));
628
+ if (missing !== undefined) return `no row ${shapeRow(missing)}, which ${shape.name} has`;
629
629
  if (block.length !== shape.rows.length) return `${block.length} rows, where ${shape.name} has ${shape.rows.length}`;
630
- const moved = block.findIndex((row, at) => unpointed(row) !== shape.rows[at]);
631
- if (moved >= 0) return `the row ${block[moved].trim()} where ${shape.name} has ${shape.rows[moved]}`;
630
+ const moved = block.findIndex((row, at) => !fitsRow(shape.rows[at], unpointed(row)));
631
+ if (moved >= 0) return `the row ${block[moved].trim()} where ${shape.name} has ${shapeRow(shape.rows[moved])}`;
632
632
  return undefined;
633
633
  });
634
634
  const fits = misfits.findIndex((misfit) => misfit === undefined);
@@ -642,7 +642,7 @@ const wholeScreen = (rows) => rows.filter((row) => row.trim() !== '').map((row)
642
642
  /**
643
643
  * Answer the first-run screen of a temporary session the caller made (#489):
644
644
  * Claude Code's Teach auto mode screen, in one of the shapes the kit knows,
645
- * matched exactly. The 2.1.283 and 2.1.302 forms get Esc. The 2.1.289 list gets arrows to
645
+ * matched exactly. The 2.1.283 and 2.1.302 forms get Esc, whatever their settings show. The 2.1.289 list gets arrows to
646
646
  * "2. Not now" where needed, a second look that the pointer is there and the
647
647
  * list is otherwise as it was, and only then a return. Anything else is refused with
648
648
  * nothing typed. Like `trustHooks`, it is one command a permission rule of its
package/src/up.js CHANGED
@@ -10,7 +10,7 @@ import path from 'node:path';
10
10
  import { setTimeout as pause } from 'node:timers/promises';
11
11
 
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
- import { botDir, botNames, displayName, projectName, readBot } from './bot.js';
13
+ import { botDir, botNames, displayName, readBot } from './bot.js';
14
14
  import { conversationsIn, hasConversation, heldAsUserTurn, transcriptsIn } from './conversations.js';
15
15
  import { dropRemovedHooks, installHook } from './hooks.js';
16
16
  import { writePermissions } from './permissions.js';
@@ -233,7 +233,7 @@ async function bringUpBot(bots, home, bot, onlySession) {
233
233
  // Orca is asked first and the book is written after: nothing that takes time
234
234
  // happens while the book is held, because a session's own hook may be writing
235
235
  // its id into that same file at any moment.
236
- const { change, ...orca } = orcaProject(home, projectName(bots, name));
236
+ const { change, ...orca } = orcaProject(home, title);
237
237
  await updateBook(home, (book) => { book.orca = orca; });
238
238
  // Orca's window does not see a project made or renamed on the command line
239
239
  // until it is told (#224).
package/src/run.js DELETED
@@ -1,155 +0,0 @@
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
- }