@assuredloop/orca-bot-kit 0.27.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/src/message.js CHANGED
@@ -13,7 +13,8 @@
13
13
  // One road and one signal (#555, ADR 0042): the letter goes into the
14
14
  // receiver's mailbox, and Orca's own notice tells the receiver to look once it
15
15
  // is idle. The kit types nothing into the receiver's tab, except the one
16
- // Escape a sender asks for with --interrupt.
16
+ // Escape a sender asks for with --interrupt. Orca gives no notice to a tab its
17
+ // window has not loaded, so the send says when the receiver's is one (#579).
17
18
 
18
19
  import { mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
19
20
  import path from 'node:path';
@@ -126,7 +127,7 @@ export function lookUp(bots, { to: target, from: sender, tab }) {
126
127
  * nothing, which is the whole of "the bot never picks the transport".
127
128
  */
128
129
  export async function sendMessage(bots, { to: target, from: sender, tab, subject, text, textFile, thread, interrupt = false }) {
129
- const from = whoIsWriting(bots, sender, tab);
130
+ const from = whoIsSending(bots, sender, tab);
130
131
  const to = findSession(bots, target);
131
132
  const road = roadBetween(from, to);
132
133
 
@@ -159,6 +160,8 @@ export async function sendMessage(bots, { to: target, from: sender, tab, subject
159
160
  }
160
161
 
161
162
  const written = bodyOf(bots, { from, to, subject, text, textFile });
163
+ // Looked at before the post, which is the send's last call to Orca (PRD 6.9).
164
+ const shown = loaded(to);
162
165
  // The Escape goes first, so the turn has ended when the letter is in.
163
166
  const interrupted = interrupt ? interruptBusy(to) : {};
164
167
  const message = postMessage({
@@ -176,9 +179,30 @@ export async function sendMessage(bots, { to: target, from: sender, tab, subject
176
179
  thread: message.thread_id ?? thread,
177
180
  file: written.file,
178
181
  ...interrupted,
182
+ ...shown,
179
183
  };
180
184
  }
181
185
 
186
+ /**
187
+ * Whether Orca's window has loaded the receiver's tab, looked at just before
188
+ * the letter goes in. Orca gives its notice only to a pane the window has loaded,
189
+ * and lists one it has not as orphaned, as after a window restart (#579; tech
190
+ * notes, section 1). So an orphaned tab is said, with its handle, and a
191
+ * listing that cannot be read is said too, rather than taken for a loaded tab.
192
+ * The look is made once: a tab orphaned after the send is not seen here.
193
+ */
194
+ function loaded(to) {
195
+ if (to.tab === undefined) return {};
196
+ let listed;
197
+ try {
198
+ listed = tabs(to.home, { timeoutMs: LOOK_MS });
199
+ } catch (error) {
200
+ return { loadTrouble: error.message };
201
+ }
202
+ const tab = listed.find((one) => one.tabId === to.tab);
203
+ return tab?.orphaned === true ? { orphaned: true, handle: tab.handle } : {};
204
+ }
205
+
182
206
  /**
183
207
  * What is waiting for a session, and the reading of it.
184
208
  *
@@ -364,6 +388,22 @@ function whoIsWriting(bots, sender, tab, asked = '--from <bot>/<session>: which
364
388
  return found;
365
389
  }
366
390
 
391
+ /**
392
+ * The session that is sending. In one of this fleet's tabs it is the tab's own
393
+ * session, and `--from` may name only that one: otherwise a bot could send as
394
+ * another, and the reply and the receiver's check would take it for that one
395
+ * (#570). Outside the fleet's tabs `--from` says who is writing, as before.
396
+ */
397
+ function whoIsSending(bots, sender, tab) {
398
+ const caller = tab === undefined ? undefined : sessionInTab(bots, tab);
399
+ if (caller === undefined) return whoIsWriting(bots, sender, tab);
400
+ if (sender === undefined) return caller;
401
+
402
+ const named = findSession(bots, sender);
403
+ if (named.bot === caller.bot && named.session === caller.session) return caller;
404
+ throw new Error(`this command runs in ${caller.bot}/${caller.session}'s Orca tab, ${tab}, so it sends as ${caller.bot}/${caller.session} and not as ${named.bot}/${named.session}. Nothing was sent. Leave out --from, or name ${caller.bot}/${caller.session}.`);
405
+ }
406
+
367
407
  /**
368
408
  * The session the book says is in this Orca tab, wherever in the fleet it is,
369
409
  * when the caller is that session.
@@ -1,5 +1,5 @@
1
1
  -- Run by the kit as `osascript orca-reload.applescript <Orca.app path>`, after
2
- -- a project was removed (#343, ADR 0034).
2
+ -- a project was removed (#343, ADR 0046).
3
3
  --
4
4
  -- Orca's window keeps a removed project in its sidebar until the window is
5
5
  -- rebuilt (stablyai/orca#23224; once a release fixes it, this can go behind 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
@@ -2,7 +2,7 @@
2
2
  // CLI: `ELECTRON_RUN_AS_NODE=1 <Orca> orca-runtime.cjs <client> <method> <params> <wait>`.
3
3
  //
4
4
  // It makes one call to Orca's runtime, through Orca's own client, for a method
5
- // Orca's CLI does not offer (ADR 0034): `project.update`, which makes Orca's
5
+ // Orca's CLI does not offer (ADR 0046): `project.update`, which makes Orca's
6
6
  // window read its projects again (#224), and `terminal.inspectProcess`, which
7
7
  // says who is in front of a tab where `ps` cannot (#298). `params` is JSON. It
8
8
  // prints the runtime's answer as JSON and exits 0 only when the runtime took
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
  //
@@ -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
  }
@@ -106,6 +111,15 @@ export function orcaTrouble() {
106
111
  if (asked.error) return `could not run Orca at ${orcaCli()}: ${asked.error.message}. ${where}`;
107
112
 
108
113
  const answer = parse(asked.stdout);
114
+ // A CLI that dies with no answer has not reached the app at all: it can fail
115
+ // on its own, as on a current folder macOS will not let it read, while Orca
116
+ // runs (#556). So what it said is passed on, and nobody is sent to start Orca.
117
+ if (answer === undefined && asked.status !== 0) {
118
+ const exit = asked.status ?? `signal ${asked.signal}`;
119
+ return `the Orca CLI at ${orcaCli()} failed before it answered status (exit ${exit}; `
120
+ + `stdout ${said(asked.stdout)}; stderr ${said(asked.stderr)}). `
121
+ + 'Fix what it says and run the command again, or point OBK_ORCA at another Orca CLI.';
122
+ }
109
123
  // Orca knowing what is wrong beats the kit guessing, so its own words go
110
124
  // first when it gave any.
111
125
  if (answer !== null && typeof answer === 'object' && answer.ok !== true && answer.error?.message) {
@@ -126,8 +140,16 @@ export function findProject(home) {
126
140
  return projects().find((setup) => setup.path === home);
127
141
  }
128
142
 
129
- /** Where Orca keeps its own settings: one file per profile, under the user's home. */
130
- 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
+ );
131
153
 
132
154
  /**
133
155
  * Orca's own default launch arguments, per profile it keeps:
@@ -319,7 +341,7 @@ const CLIENT_KILL_MS = 3000;
319
341
  *
320
342
  * Orca's window re-reads only when its runtime says the projects changed, and
321
343
  * `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
344
+ * changes does, and Orca's CLI does not offer it (ADR 0046). The caller prints
323
345
  * `RELOAD_LINE` either way. It does not take a removed project out of the
324
346
  * sidebar: `reloadWindow` does.
325
347
  */
@@ -330,7 +352,7 @@ export const tellWindow = (projectId) => askRuntime('project.update', { projectI
330
352
  * runtime's answer, or undefined when there is none to be had.
331
353
  *
332
354
  * 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
355
+ * Orca's binary the way its `bin/orca` runs its CLI (ADR 0046). None of that is
334
356
  * Orca's published interface, so anything that goes wrong is a quiet
335
357
  * undefined: it never throws and is never tried twice.
336
358
  */
@@ -367,7 +389,7 @@ const RELOAD_KILL_MS = 5000;
367
389
 
368
390
  /**
369
391
  * Have Orca's window force-reload itself, after a project was removed: true
370
- * only when it did (#343, ADR 0034).
392
+ * only when it did (#343, ADR 0046).
371
393
  *
372
394
  * The window keeps a removed project in its sidebar until it is rebuilt, and
373
395
  * `tellWindow` only relabels the row "Unknown" (stablyai/orca#23224; once a
@@ -524,7 +546,7 @@ const ON_A_CHOICE = /^( *[›❯] +)\d+\. /;
524
546
  * Whether the rows of a rendered screen hold a question of the harness's own.
525
547
  *
526
548
  * Every one seen is a numbered list of choices with the harness's pointer on
527
- * one (tech notes, section 1; ADR 0034). The same pointer starts the harness's
549
+ * one (tech notes, section 1; ADR 0046). The same pointer starts the harness's
528
550
  * input line and its echo of the user's past turns, and the input line is the
529
551
  * lowest of them whenever it is on screen, so the lowest pointer row is asked
530
552
  * about: a question counts while it stands in the input line's place, or
@@ -730,7 +752,7 @@ export const psCli = () => process.env.OBK_PS || '/bin/ps';
730
752
  *
731
753
  * `ps` is asked first. Where it cannot read the tab, as inside Codex's
732
754
  * sandbox, where it does not start at all, Orca's runtime is asked instead
733
- * (#298, ADR 0034). `readMs` bounds each call: a number, or a function that
755
+ * (#298, ADR 0046). `readMs` bounds each call: a number, or a function that
734
756
  * says what is left of a wait, asked before each one (#498). A `ps` that runs
735
757
  * out of it ends the look, `late`, with no runtime asked after it.
736
758
  */
@@ -770,7 +792,7 @@ function frontByOrca(handle, readMs) {
770
792
  * Who holds the terminal of the pane `ptyId`, as `ps` says it.
771
793
  *
772
794
  * Orca gives the pane's pid in `diagnostics memory` and nowhere else, and `ps`
773
- * gives that pid's terminal's foreground process group (ADR 0034).
795
+ * gives that pid's terminal's foreground process group (ADR 0046).
774
796
  * On macOS the pane is `login` with the shell as its child, so the shell is in
775
797
  * front when the group is the pane's own or that of a child of a `login` pane.
776
798
  * `diagnostics memory` is a diagnostics command and may change, so everything
@@ -1048,6 +1070,17 @@ export function postMessage({ to, from, subject, body, type = 'status', thread }
1048
1070
  export const readMailbox = (id, { peek = false, handle } = {}, options) =>
1049
1071
  orca(['orchestration', 'check', '--run', id, ...as(handle), ...(peek ? ['--peek'] : [])], options);
1050
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
+
1051
1084
  /** Say a batch has been read. Orca answers with the batch after it. */
1052
1085
  export const ackMailbox = (id, delivery, handle, options) =>
1053
1086
  orca(['orchestration', 'check', '--run', id, ...as(handle), '--ack', delivery], options);
@@ -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
 
@@ -158,12 +165,14 @@ function commandBroadness(all) {
158
165
  if (star === -1) return undefined;
159
166
 
160
167
  const program = path.basename(words[0].replace(/:?\*.*$/, ''));
168
+ if (isKit(words[0]) && mayRun(words[1] ?? '')) return runBroadness(words);
161
169
  if (runnerOf(program) !== undefined) {
162
170
  // Only what runs, fixed by its absolute path, narrows it. A wrapper's
163
- // 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.
164
173
  const next = (words[1] ?? '').replace(/:\*$/, '');
165
174
  const fixed = next.startsWith('/') && !next.includes('*');
166
- if (fixed && WRAPPERS.has(program)) return commandBroadness(words.slice(1));
175
+ if (fixed && (WRAPPERS.has(program) || isKit(next))) return commandBroadness(words.slice(1));
167
176
  if (fixed && runnerOf(path.basename(next)) === undefined) return undefined;
168
177
  return `${program} runs whatever its arguments say, so ${ANY}`;
169
178
  }
@@ -176,6 +185,37 @@ function commandBroadness(all) {
176
185
  return undefined;
177
186
  }
178
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
+
179
219
  /**
180
220
  * Refuse the rules `permission allow` was given when any is broad, before anything is
181
221
  * written, naming the file the user can add it to themselves.
@@ -213,6 +253,15 @@ export function allowOf(home, bot) {
213
253
  export const runsOnCodex = (bot) => bot.harness === 'codex'
214
254
  || bot.sessions.some((session) => harnessOf(session, bot.harness) === 'codex');
215
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
+
216
265
  /**
217
266
  * The default rules this bot is not allowed yet, in the default order. A bot
218
267
  * only on Codex is not given the Read rule: its sandbox reads every file
@@ -220,9 +269,16 @@ export const runsOnCodex = (bot) => bot.harness === 'codex'
220
269
  */
221
270
  function missingDefaults(bots, home, bot) {
222
271
  const allowed = allowOf(home, bot);
223
- return defaultRules(bots)
224
- .filter((rule) => runsOnClaude(bot) || codexForm(rule).line !== undefined)
225
- .filter((rule) => !allowed.includes(rule));
272
+ return botDefaults(bots, bot).filter((rule) => !allowed.includes(rule));
273
+ }
274
+
275
+ /**
276
+ * The default rules a bot on Claude or Codex is given, in the default order:
277
+ * what `bot create` writes into a new bot's bot.yaml (#595).
278
+ */
279
+ export function botDefaults(bots, bot) {
280
+ if (!runsOnClaude(bot) && !runsOnCodex(bot)) return [];
281
+ return defaultRules(bots).filter((rule) => runsOnClaude(bot) || codexForm(rule).line !== undefined);
226
282
  }
227
283
 
228
284
  /**
@@ -270,7 +326,7 @@ export function refuseNoCodexForm(bot, rules) {
270
326
  }
271
327
 
272
328
  /**
273
- * Add the default rules the bot is not allowed yet to its `allow` (ADR 0036),
329
+ * Add the default rules the bot is not allowed yet to its `allow` (ADR 0050),
274
330
  * then write what it is allowed into the files of the harnesses it runs on:
275
331
  * one entry per file, Claude's first, each with `defaults`, the rules this run
276
332
  * added, and `held`, the rules of commands no longer in the set that the bot
@@ -279,7 +335,7 @@ export function refuseNoCodexForm(bot, rules) {
279
335
  * not write is an entry with its `trouble` rather than a throw.
280
336
  */
281
337
  export function writePermissions(bots, home, bot, { keepGoing = false } = {}) {
282
- const writers = [[runsOnClaude, FILE], [runsOnCodex, CODEX_FILE]].filter(([runs]) => runs(bot));
338
+ const writers = [[runsOnClaude, FILE], [keepsCodex, CODEX_FILE]].filter(([runs]) => runs(bot, home));
283
339
  let defaults = [];
284
340
  // Read before anything is written, so a file's trouble still names them.
285
341
  let held = [];
@@ -310,8 +366,8 @@ export function writePermissions(bots, home, bot, { keepGoing = false } = {}) {
310
366
  */
311
367
  export function allowIn(bots, home, bot) {
312
368
  refuseUnwritable(path.join(home, 'bot.yaml'));
313
- const plans = [[runsOnClaude, planClaude], [runsOnCodex, planCodex]]
314
- .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));
315
371
  for (const plan of plans) if (plan.writes) refuseUnwritable(plan.answer.file);
316
372
  return () => plans.map((plan) => plan.write());
317
373
  }
@@ -390,7 +446,8 @@ export function takeBack(bots, home, bot, rules) {
390
446
  }
391
447
  const codex = path.join(home, CODEX_FILE);
392
448
  let before = [];
393
- if (runsOnCodex(bot)) {
449
+ const codexKept = keepsCodex(bot, home);
450
+ if (codexKept) {
394
451
  refuseOutside(home, codex);
395
452
  if (existsSync(codex)) {
396
453
  before = ruleLines(readFileSync(codex, 'utf8'));
@@ -408,7 +465,7 @@ export function takeBack(bots, home, bot, rules) {
408
465
  }
409
466
  entries.push({ bot: bot.name, file, written: [], removed });
410
467
  }
411
- if (runsOnCodex(after)) {
468
+ if (codexKept) {
412
469
  const entry = planCodex(bots, home, after).write();
413
470
  // A line goes only when no rule still allowed gives it.
414
471
  const kept = [...codexOf(home, after).lines.keys()];
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 });
package/src/rules.js CHANGED
@@ -20,7 +20,8 @@ import path from 'node:path';
20
20
  import { fileURLToPath } from 'node:url';
21
21
  import { parse } from 'yaml';
22
22
 
23
- import { botDir, botNames, displayName, readBot } from './bot.js';
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. */
@@ -85,6 +86,7 @@ export function buildAgents(bots, home, bot) {
85
86
 
86
87
  let state;
87
88
  try {
89
+ refuseOutside(home, file);
88
90
  state = writeBlock(file, built.body);
89
91
  } catch (error) {
90
92
  return { bot: bot.name, file, state: 'conflict', trouble: error.message };
@@ -108,13 +110,23 @@ export function buildRules(bots, { bot: onlyBot } = {}) {
108
110
 
109
111
  return (onlyBot === undefined ? names : [onlyBot]).map((name) => {
110
112
  const home = botDir(bots, name);
113
+ let built;
111
114
  try {
112
- return buildAgents(bots, home, readBot(home, name));
115
+ built = buildAgents(bots, home, readBot(home, name));
113
116
  } catch (error) {
114
117
  // A `bot.yaml` the kit cannot read at all. Same answer as a list it
115
118
  // cannot follow: this bot is not built, and nothing else changes.
116
- 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 };
117
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;
118
130
  });
119
131
  }
120
132
 
@@ -416,7 +428,9 @@ export function agentsTrouble(bots, home, bot) {
416
428
  /** Whether two paths are the same file in the end. One that leads nowhere is not. */
417
429
  function sameFile(one, other) {
418
430
  try {
419
- return realpathSync(one) === realpathSync(other);
431
+ // Native, as in leadsOutside: the JS one takes `hop/..` away before `hop`
432
+ // is followed.
433
+ return realpathSync.native(one) === realpathSync.native(other);
420
434
  } catch {
421
435
  return false;
422
436
  }