@assuredloop/orca-bot-kit 0.26.1 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/orca.js CHANGED
@@ -1,4 +1,4 @@
1
- // Every call the kit makes to Orca goes through here (ADR 0034). Orca's CLI
1
+ // Every call the kit makes to Orca goes through here (ADR 0046). Orca's CLI
2
2
  // changes often, so the kit reads `--json` and never the human text, and keeps
3
3
  // the parsing in one place.
4
4
  //
@@ -106,6 +106,15 @@ export function orcaTrouble() {
106
106
  if (asked.error) return `could not run Orca at ${orcaCli()}: ${asked.error.message}. ${where}`;
107
107
 
108
108
  const answer = parse(asked.stdout);
109
+ // A CLI that dies with no answer has not reached the app at all: it can fail
110
+ // on its own, as on a current folder macOS will not let it read, while Orca
111
+ // runs (#556). So what it said is passed on, and nobody is sent to start Orca.
112
+ if (answer === undefined && asked.status !== 0) {
113
+ const exit = asked.status ?? `signal ${asked.signal}`;
114
+ return `the Orca CLI at ${orcaCli()} failed before it answered status (exit ${exit}; `
115
+ + `stdout ${said(asked.stdout)}; stderr ${said(asked.stderr)}). `
116
+ + 'Fix what it says and run the command again, or point OBK_ORCA at another Orca CLI.';
117
+ }
109
118
  // Orca knowing what is wrong beats the kit guessing, so its own words go
110
119
  // first when it gave any.
111
120
  if (answer !== null && typeof answer === 'object' && answer.ok !== true && answer.error?.message) {
@@ -319,7 +328,7 @@ const CLIENT_KILL_MS = 3000;
319
328
  *
320
329
  * Orca's window re-reads only when its runtime says the projects changed, and
321
330
  * `setup-update` and `setup-delete` do not say so. `project.update` with no
322
- * changes does, and Orca's CLI does not offer it (ADR 0034). The caller prints
331
+ * changes does, and Orca's CLI does not offer it (ADR 0046). The caller prints
323
332
  * `RELOAD_LINE` either way. It does not take a removed project out of the
324
333
  * sidebar: `reloadWindow` does.
325
334
  */
@@ -330,7 +339,7 @@ export const tellWindow = (projectId) => askRuntime('project.update', { projectI
330
339
  * runtime's answer, or undefined when there is none to be had.
331
340
  *
332
341
  * It goes through Orca's own runtime client out of the installed app, run by
333
- * Orca's binary the way its `bin/orca` runs its CLI (ADR 0034). None of that is
342
+ * Orca's binary the way its `bin/orca` runs its CLI (ADR 0046). None of that is
334
343
  * Orca's published interface, so anything that goes wrong is a quiet
335
344
  * undefined: it never throws and is never tried twice.
336
345
  */
@@ -367,7 +376,7 @@ const RELOAD_KILL_MS = 5000;
367
376
 
368
377
  /**
369
378
  * Have Orca's window force-reload itself, after a project was removed: true
370
- * only when it did (#343, ADR 0034).
379
+ * only when it did (#343, ADR 0046).
371
380
  *
372
381
  * The window keeps a removed project in its sidebar until it is rebuilt, and
373
382
  * `tellWindow` only relabels the row "Unknown" (stablyai/orca#23224; once a
@@ -426,23 +435,6 @@ export const openTab = (home, title) =>
426
435
  export const retitleTab = (handle, title) =>
427
436
  orca(['terminal', 'rename', '--terminal', handle, '--title', title]).rename;
428
437
 
429
- /**
430
- * One quick look at whether the harness in a tab is at rest, for a caller that
431
- * already knows a harness is there and is watching for a turn to start (#509):
432
- * `idle` when Orca's tui-idle answers ok, `busy` when it times out, and
433
- * `unknown` when Orca refuses or does not answer within `waitMs` and a second
434
- * more. Never throws.
435
- */
436
- export function idleNow(handle, waitMs) {
437
- try {
438
- const answer = ask(['terminal', 'wait', '--terminal', handle, '--for', 'tui-idle', '--timeout-ms', String(waitMs)], waitMs + 1000);
439
- if (answer.ok === true) return answer.result?.wait?.blockedReason === undefined ? 'idle' : 'unknown';
440
- return answer.error?.code === 'timeout' ? 'busy' : 'unknown';
441
- } catch {
442
- return 'unknown';
443
- }
444
- }
445
-
446
438
  /**
447
439
  * What can be learned about the harness in a tab: what Orca says, and who is
448
440
  * in front of the tab's terminal.
@@ -541,7 +533,7 @@ const ON_A_CHOICE = /^( *[›❯] +)\d+\. /;
541
533
  * Whether the rows of a rendered screen hold a question of the harness's own.
542
534
  *
543
535
  * Every one seen is a numbered list of choices with the harness's pointer on
544
- * one (tech notes, section 1; ADR 0034). The same pointer starts the harness's
536
+ * one (tech notes, section 1; ADR 0046). The same pointer starts the harness's
545
537
  * input line and its echo of the user's past turns, and the input line is the
546
538
  * lowest of them whenever it is on screen, so the lowest pointer row is asked
547
539
  * about: a question counts while it stands in the input line's place, or
@@ -637,14 +629,14 @@ function pointedChoiceAt(rows, at) {
637
629
  * is typed, and the answer says why. `idle` says whether Orca's `tui-idle` wait
638
630
  * answered ok, and `rows` is the screen the gate read, for a caller that must
639
631
  * not type into a busy harness (#391), with the `draft` Orca gave beside it (#510):
640
- * `{}` for a tab with no harness in it (none in the book, none Orca lists, or
641
- * the shell in front), `{ blocked }` for one with something on screen waiting
632
+ * `{}` for a tab with no harness in it (none in the book, or the shell in
633
+ * front), `{ gone: true }` for one Orca does not list, `{ blocked }` for one with something on screen waiting
642
634
  * to be answered, and `{ unsure }`, a sentence, for one the kit cannot tell
643
635
  * about, with `psUnread` where `ps` could not read the tab (#350). Orca
644
636
  * refusing throws, for the caller to report.
645
637
  *
646
638
  * The one gate for every line the kit types into a running session: the mail
647
- * nudge and the skills reload. A busy harness passes it, since both harnesses
639
+ * interrupt and the skills reload. A busy harness passes it, since both harnesses
648
640
  * take a typed line as their next turn.
649
641
  */
650
642
  export function tabToTypeInto(home, tabId, timeoutMs) {
@@ -655,7 +647,7 @@ export function tabToTypeInto(home, tabId, timeoutMs) {
655
647
  let seen;
656
648
  try {
657
649
  live = tabs(home, { timeoutMs: GATE_READ_MS }).find((tab) => tab.tabId === tabId);
658
- if (live === undefined) return {};
650
+ if (live === undefined) return { gone: true };
659
651
  seen = harnessInTab(live.handle, timeoutMs, GATE_READ_MS);
660
652
  } catch (error) {
661
653
  if (error.code !== TIMED_OUT) throw error;
@@ -737,7 +729,7 @@ export function frontOfTab(handle) {
737
729
  }
738
730
 
739
731
  /** The `ps` this run reads. OBK_PS overrides it, as OBK_ORCA does Orca. */
740
- const psCli = () => process.env.OBK_PS || '/bin/ps';
732
+ export const psCli = () => process.env.OBK_PS || '/bin/ps';
741
733
 
742
734
  /**
743
735
  * Who holds the terminal of the tab `handle`, whose pane is `ptyId`:
@@ -747,7 +739,7 @@ const psCli = () => process.env.OBK_PS || '/bin/ps';
747
739
  *
748
740
  * `ps` is asked first. Where it cannot read the tab, as inside Codex's
749
741
  * sandbox, where it does not start at all, Orca's runtime is asked instead
750
- * (#298, ADR 0034). `readMs` bounds each call: a number, or a function that
742
+ * (#298, ADR 0046). `readMs` bounds each call: a number, or a function that
751
743
  * says what is left of a wait, asked before each one (#498). A `ps` that runs
752
744
  * out of it ends the look, `late`, with no runtime asked after it.
753
745
  */
@@ -787,7 +779,7 @@ function frontByOrca(handle, readMs) {
787
779
  * Who holds the terminal of the pane `ptyId`, as `ps` says it.
788
780
  *
789
781
  * Orca gives the pane's pid in `diagnostics memory` and nowhere else, and `ps`
790
- * gives that pid's terminal's foreground process group (ADR 0034).
782
+ * gives that pid's terminal's foreground process group (ADR 0046).
791
783
  * On macOS the pane is `login` with the shell as its child, so the shell is in
792
784
  * front when the group is the pane's own or that of a child of a `login` pane.
793
785
  * `diagnostics memory` is a diagnostics command and may change, so everything
@@ -834,7 +826,7 @@ function panePid(ptyId, readMs) {
834
826
  }
835
827
 
836
828
  /** The `lsof` and `stty` this run asks. OBK_LSOF and OBK_STTY override them, as OBK_PS does `ps`. */
837
- const lsofCli = () => process.env.OBK_LSOF || '/usr/sbin/lsof';
829
+ export const lsofCli = () => process.env.OBK_LSOF || '/usr/sbin/lsof';
838
830
  const sttyCli = () => process.env.OBK_STTY || '/bin/stty';
839
831
 
840
832
  /**
@@ -968,18 +960,12 @@ export const closeTerminal = (handle) =>
968
960
  * Seen live on 2026-09-21 in a system test run; not reproduced since, including
969
961
  * from a plain shell, long lines and lines sent while the agent was working.
970
962
  *
971
- * The wait is short because the caller is telling a session it has mail, not
972
- * handing it work: mail that has to wait for the next check is a smaller cost
973
- * than a command that hangs for half a minute.
974
- *
975
- * With `watch`, the line is sent with that wait from the start, and Orca's
976
- * receipt says whether it saw the line start a turn (#394). It gives back
977
- * Orca's `result`: the receipt is in its `send.prompt`, and Orca's words about
978
- * a turn it did not see in its `warnings`.
963
+ * The wait is short: a line that has to wait is a smaller cost than a
964
+ * command that hangs for half a minute.
979
965
  */
980
- export function typeIntoTab(handle, text, { watch = false } = {}) {
966
+ export function typeIntoTab(handle, text) {
981
967
  const args = ['terminal', 'send', '--terminal', handle, '--text', text, '--enter'];
982
- const answer = ask(watch ? [...args, '--wait-submit', String(SUBMIT_WAIT_S)] : args);
968
+ const answer = ask(args);
983
969
  if (answer.ok === true) return answer.result;
984
970
 
985
971
  const again = answer.error?.data?.orchestrationRequestId;
@@ -990,11 +976,18 @@ export function typeIntoTab(handle, text, { watch = false } = {}) {
990
976
  }
991
977
 
992
978
  /**
993
- * How long a watched or re-issued line is given to be submitted before Orca
994
- * gives up on it. An idle harness started its turn in about 2 s, live (#394).
979
+ * How long a re-issued line is given to be submitted before Orca gives up on
980
+ * it. An idle harness started its turn in about 2 s, live (#394).
995
981
  */
996
982
  const SUBMIT_WAIT_S = 5;
997
983
 
984
+ /**
985
+ * Press Escape once in a tab, with no return after it: what `obk message send
986
+ * --interrupt` sends to a busy receiver (#555).
987
+ */
988
+ export const pressEscape = (handle) =>
989
+ orca(['terminal', 'send', '--terminal', handle, '--text', '\x1b']);
990
+
998
991
  /** Where Orca names the terminal a process runs in, in every pane it opens. */
999
992
  export const TERMINAL_ENV = 'ORCA_TERMINAL_HANDLE';
1000
993
 
@@ -6,7 +6,7 @@
6
6
  // body beside the bots folder, commit. A matching allow rule is settled before
7
7
  // that check (Claude Code's permissions docs), so the kit gives every bot a
8
8
  // default set, spelled with its real CLI and bots folder, with nobody asked
9
- // (#527, ADR 0036). A rule beyond it is the user's yes, which
9
+ // (#527, ADR 0050). A rule beyond it is the user's yes, which
10
10
  // `permission allow` keeps in bot.yaml's `allow`. The kit's code writes what
11
11
  // `allow` holds into `.claude/settings.json` in the bot folder, beside its hook
12
12
  // (ADR 0022). A model editing the file by hand may get the format wrong; code
@@ -24,7 +24,7 @@ import { accessSync, constants, existsSync, mkdirSync, readdirSync, readFileSync
24
24
  import path from 'node:path';
25
25
 
26
26
  import { allowRules, leadsOutside } from './bot.js';
27
- import { DEFAULT_COMMANDS, NO_LONGER_DEFAULT } from './commands.js';
27
+ import { DEFAULT_COMMANDS, NO_LONGER_DEFAULT, REMOVED } from './commands.js';
28
28
  import { readSettings } from './hooks.js';
29
29
  import { harnessOf, ownCli, shellWord } from './launch.js';
30
30
 
@@ -36,11 +36,10 @@ const CODEX_RULES = '.codex/rules';
36
36
  const CODEX_FILE = `${CODEX_RULES}/obk.rules`;
37
37
 
38
38
  /**
39
- * The rules every bot is given (ADR 0036): each of the kit's default commands,
40
- * and none kept back for the user's yes (ADR 0041), narrowed to this bots folder and
39
+ * The rules every bot is given (ADR 0050): each of the kit's default commands,
40
+ * and none kept back for the user's yes (ADR 0051), narrowed to this bots folder and
41
41
  * spelled as the kit prints them, which is what a bot runs; reading a long
42
- * message; committing; and the check Orca's mail notice tells a session to
43
- * run. `//` is Claude's form for an absolute path.
42
+ * message; and committing. `//` is Claude's form for an absolute path.
44
43
  */
45
44
  export function defaultRules(bots, cli = ownCli()) {
46
45
  const kit = shellWord(cli);
@@ -50,19 +49,27 @@ export function defaultRules(bots, cli = ownCli()) {
50
49
  `Read(/${bots}.messages/**)`,
51
50
  'Bash(git add:*)',
52
51
  'Bash(git commit:*)',
53
- 'Bash(orca orchestration check --run:*)',
54
52
  ];
55
53
  }
56
54
 
57
55
  /**
58
- * The rules of the commands that were defaults once and are kept back now
59
- * (#548), spelled as `defaultRules` spelled them, that the bot's `allow`
60
- * holds. The kit cannot tell its own old entry from a user's yes to the same
61
- * rule, so it names them and leaves them (ADR 0041).
56
+ * Orca's check, which the set held until #564: the kit's rules tell a bot to
57
+ * read its mail with `obk message check`, never with the bare check, which
58
+ * leaves it unread (ADR 0042).
59
+ */
60
+ const ORCA_CHECK = 'Bash(orca orchestration check --run:*)';
61
+
62
+ /**
63
+ * The rules that were defaults once and are not now, that the bot's `allow`
64
+ * 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
67
+ * names them and leaves them (ADR 0051, ADR 0050).
62
68
  */
63
69
  export function heldRules(bots, home, bot, cli = ownCli()) {
64
70
  const allowed = allowOf(home, bot);
65
- return NO_LONGER_DEFAULT.map((command) => `Bash(${shellWord(cli)} ${command} --bots ${shellWord(bots)}:*)`)
71
+ return [...NO_LONGER_DEFAULT, ...REMOVED].map((command) => `Bash(${shellWord(cli)} ${command} --bots ${shellWord(bots)}:*)`)
72
+ .concat(ORCA_CHECK)
66
73
  .filter((rule) => allowed.includes(rule));
67
74
  }
68
75
 
@@ -220,9 +227,16 @@ export const runsOnCodex = (bot) => bot.harness === 'codex'
220
227
  */
221
228
  function missingDefaults(bots, home, bot) {
222
229
  const allowed = allowOf(home, bot);
223
- return defaultRules(bots)
224
- .filter((rule) => runsOnClaude(bot) || codexForm(rule).line !== undefined)
225
- .filter((rule) => !allowed.includes(rule));
230
+ return botDefaults(bots, bot).filter((rule) => !allowed.includes(rule));
231
+ }
232
+
233
+ /**
234
+ * The default rules a bot on Claude or Codex is given, in the default order:
235
+ * what `bot create` writes into a new bot's bot.yaml (#595).
236
+ */
237
+ export function botDefaults(bots, bot) {
238
+ if (!runsOnClaude(bot) && !runsOnCodex(bot)) return [];
239
+ return defaultRules(bots).filter((rule) => runsOnClaude(bot) || codexForm(rule).line !== undefined);
226
240
  }
227
241
 
228
242
  /**
@@ -270,7 +284,7 @@ export function refuseNoCodexForm(bot, rules) {
270
284
  }
271
285
 
272
286
  /**
273
- * Add the default rules the bot is not allowed yet to its `allow` (ADR 0036),
287
+ * Add the default rules the bot is not allowed yet to its `allow` (ADR 0050),
274
288
  * then write what it is allowed into the files of the harnesses it runs on:
275
289
  * one entry per file, Claude's first, each with `defaults`, the rules this run
276
290
  * added, and `held`, the rules of commands no longer in the set that the bot
package/src/pi.js ADDED
@@ -0,0 +1,55 @@
1
+ // Whether Pi is ready to start a session: it runs, it can use a model, and it
2
+ // can use the one the session names (#578).
3
+ //
4
+ // The kit never handles Pi's credentials: the user signs in to Pi on their own,
5
+ // and the kit only asks Pi what it can use. `pi auth check` is not asked, since
6
+ // it does not know a provider an extension of Pi's brings, such as Cursor's;
7
+ // `pi --list-models` lists the models Pi can use, those included (tech notes,
8
+ // section 6).
9
+
10
+ import { spawnSync } from 'node:child_process';
11
+
12
+ /** How long each question to Pi is given. Each took about 0.3 s on Pi 1.1.0. */
13
+ const ASK_MS = 30_000;
14
+
15
+ /** Pi's thinking levels, which a model name may carry after a colon. */
16
+ const THINKING = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
17
+
18
+ /**
19
+ * Why Pi cannot start these sessions, or undefined when it can. `models` are
20
+ * the models they name; a session that names none takes Pi's own default.
21
+ */
22
+ export function piTrouble(models) {
23
+ const version = ask(['--version']);
24
+ if (version.error !== undefined || version.status !== 0) {
25
+ const why = version.error?.code === 'ENOENT' ? 'there is no pi on PATH' : (version.error?.message ?? ((version.stderr ?? '').trim() || `it exited with ${version.status}`));
26
+ return `Pi does not run here (${why}), so no Pi session was started. Install Pi, then run this again.`;
27
+ }
28
+
29
+ const any = ask(['--list-models']);
30
+ if (any.error !== undefined || any.status !== 0 || /^No models available\b/m.test(any.stdout ?? '')) {
31
+ return 'Pi has no model it can use: it is signed in to no provider. Sign in to Pi yourself (start pi and use /login, or set a provider\'s API key), then run this again. The kit never handles Pi\'s credentials.';
32
+ }
33
+
34
+ for (const model of new Set(models)) {
35
+ const found = ask(['--list-models', withoutThinking(model)]);
36
+ if (found.error !== undefined || found.status !== 0) {
37
+ const why = found.error?.message ?? ((found.stderr ?? '').trim() || `it exited with ${found.status ?? found.signal}`);
38
+ return `Pi cannot say whether it can use the model ${model}: pi --list-models ${withoutThinking(model)} failed (${why}), so no Pi session was started. Run that command yourself to see why, then run this again.`;
39
+ }
40
+ // Pi says it has no model at all before it searches, so the answer may be
41
+ // that one too, when a provider dropped out since the question above.
42
+ if (/^No models (matching|available)\b/m.test(found.stdout ?? '')) {
43
+ return `Pi cannot use the model ${model}: pi --list-models lists none matching it. Sign in to its provider in Pi yourself, or give the session a model Pi lists.`;
44
+ }
45
+ }
46
+ return undefined;
47
+ }
48
+
49
+ /** The model without a trailing `:<thinking level>`, which `--list-models` does not match. */
50
+ function withoutThinking(model) {
51
+ const at = model.lastIndexOf(':');
52
+ return at > 0 && THINKING.includes(model.slice(at + 1)) ? model.slice(0, at) : model;
53
+ }
54
+
55
+ const ask = (args) => spawnSync('pi', args, { encoding: 'utf8', timeout: ASK_MS });