@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/README.md +55 -15
- package/SETUP.md +30 -14
- package/package.json +1 -1
- package/rules/mail.md +3 -3
- package/skills/obk-bot-building/SKILL.md +29 -16
- package/skills/obk-grooming/SKILL.md +4 -3
- package/src/book.js +91 -10
- package/src/bot.js +246 -118
- package/src/clear.js +7 -3
- package/src/cli.js +116 -36
- package/src/commands.js +23 -11
- package/src/conversations.js +73 -3
- package/src/groom.js +27 -9
- package/src/health.js +89 -8
- package/src/hooks.js +44 -2
- package/src/init.js +36 -29
- package/src/launch.js +202 -18
- package/src/message.js +42 -2
- package/src/orca-reload.applescript +21 -22
- package/src/orca-runtime.cjs +1 -1
- package/src/orca.js +47 -14
- package/src/permissions.js +80 -23
- package/src/pi.js +55 -0
- package/src/rules.js +18 -4
- package/src/run.js +155 -0
- package/src/skills.js +50 -34
- package/src/sources.js +73 -4
- package/src/temp.js +65 -20
- package/src/up.js +72 -15
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 =
|
|
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
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
36
|
+
end if
|
|
38
37
|
end repeat
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
|
|
38
|
+
end repeat
|
|
39
|
+
end tell
|
|
40
|
+
return "no Force Reload in Orca's menus"
|
|
42
41
|
end repeat
|
|
43
42
|
end tell
|
|
44
43
|
return "no Orca running from " & appPath
|
package/src/orca-runtime.cjs
CHANGED
|
@@ -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
|
|
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
|
|
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'], {
|
|
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
|
-
/**
|
|
130
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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);
|
package/src/permissions.js
CHANGED
|
@@ -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
|
|
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
|
|
40
|
-
* and none kept back for the user's yes (ADR
|
|
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
|
|
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
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
|
|
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
|
|
224
|
-
|
|
225
|
-
|
|
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
|
|
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], [
|
|
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], [
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|