@assuredloop/orca-bot-kit 0.30.0 → 0.32.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 +18 -86
- package/SETUP.md +3 -3
- package/package.json +1 -1
- package/skills/obk-bot-building/SKILL.md +5 -8
- package/src/bot.js +0 -20
- package/src/clear.js +50 -14
- package/src/cli.js +22 -23
- package/src/commands.js +5 -8
- package/src/health.js +75 -30
- package/src/launch.js +23 -0
- package/src/orca.js +104 -18
- package/src/permissions.js +35 -518
- package/src/pi-record.js +96 -0
- package/src/pi.js +55 -17
- package/src/processes.js +8 -9
- package/src/rules.js +9 -1
- package/src/settings.js +109 -19
- package/src/skills.js +22 -9
- package/src/temp.js +69 -10
- package/src/up.js +2 -2
- package/src/run.js +0 -155
package/src/temp.js
CHANGED
|
@@ -34,9 +34,10 @@ import { setTimeout as pause } from 'node:timers/promises';
|
|
|
34
34
|
import { readBook, takeLineTurn, TYPING_HELD, TYPING_WAIT_MS, updateBook } from './book.js';
|
|
35
35
|
import { addSession, dropSession, NAME, readBot, tempRoles } from './bot.js';
|
|
36
36
|
import { untrustedKitHooks } from './hook-trust.js';
|
|
37
|
-
import { approvalRank, DEFAULT_APPROVAL, harnessOf, isShortPrompt, ownCli, refuseApprovalArgs, shellWord, startPrompt, workDirOf } from './launch.js';
|
|
37
|
+
import { approvalRank, DEFAULT_APPROVAL, harnessOf, isShortPrompt, ownCli, piApprovalTrouble, refuseApprovalArgs, set, shellWord, startPrompt, workDirOf } from './launch.js';
|
|
38
38
|
import { findSession, sessionInTab } from './message.js';
|
|
39
39
|
import { orca, screenRows, tabs } from './orca.js';
|
|
40
|
+
import { piTrouble } from './pi.js';
|
|
40
41
|
import { retireSession } from './retire.js';
|
|
41
42
|
import { BOT_FATHER, bringUp, promptPath } from './up.js';
|
|
42
43
|
|
|
@@ -75,11 +76,23 @@ export async function makeTemp(bots, { tab, ...given }) {
|
|
|
75
76
|
|
|
76
77
|
const maker = bot.sessions.find((session) => session.name === caller.session);
|
|
77
78
|
const { settings, chosen } = settingsFor(bot, maker, role?.option ?? {}, given);
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
79
|
+
const onPi = chosen.harness.value === 'pi';
|
|
80
|
+
// A blank --approval names no level, so on Pi it is judged as left out (the review of PR #651).
|
|
81
|
+
const asked = onPi && !set(given.approval) ? undefined : given.approval;
|
|
82
|
+
if (onPi) {
|
|
83
|
+
// Pi has one level, whatever the maker's is (the owner, #547).
|
|
84
|
+
const trouble = piApprovalTrouble(asked, name);
|
|
85
|
+
if (trouble !== undefined) throw new Error(`${trouble} Nothing was made.`);
|
|
86
|
+
settings.approval = 'dangerously-skip';
|
|
87
|
+
if (asked === undefined) chosen.approval = { value: 'dangerously-skip', from: 'harness' };
|
|
88
|
+
}
|
|
89
|
+
refuseWiderApproval(bots, caller, bot, maker, asked ?? (onPi ? settings.approval : undefined), onPi && asked === undefined ? name : undefined);
|
|
82
90
|
refuseApprovalArgs(chosen.harness.value, given.extra_args);
|
|
91
|
+
// Asked before anything is written, so a Pi that cannot start one makes nothing (#578).
|
|
92
|
+
if (onPi) {
|
|
93
|
+
const trouble = piTrouble(set(settings.model) ? [settings.model] : []);
|
|
94
|
+
if (trouble !== undefined) throw new Error(`${trouble} Nothing was made.`);
|
|
95
|
+
}
|
|
83
96
|
settings.name = name;
|
|
84
97
|
if (given.extra_args !== undefined) settings.extra_args = given.extra_args;
|
|
85
98
|
if (given.prompt !== undefined) settings.prompt = given.prompt;
|
|
@@ -189,9 +202,10 @@ function settingsFor(bot, maker, option, given) {
|
|
|
189
202
|
/**
|
|
190
203
|
* Refuse an approval wider than the widest of the maker's own and the bot's
|
|
191
204
|
* `temp_approval`, which only the user's yes through `permission approval
|
|
192
|
-
* --temps` widens (ADR 0051).
|
|
205
|
+
* --temps` widens (ADR 0051). `onPi` names a Pi session that was given no
|
|
206
|
+
* level: Pi's one level is what is judged then (#648).
|
|
193
207
|
*/
|
|
194
|
-
function refuseWiderApproval(bots, caller, bot, maker, asked) {
|
|
208
|
+
function refuseWiderApproval(bots, caller, bot, maker, asked, onPi) {
|
|
195
209
|
if (asked === undefined) return;
|
|
196
210
|
// A blank level is the kit's default at launch (ADR 0015), so it is judged as that.
|
|
197
211
|
const effective = (given) => (given === undefined || given === null || String(given).trim() === '' ? DEFAULT_APPROVAL : given);
|
|
@@ -199,7 +213,8 @@ function refuseWiderApproval(bots, caller, bot, maker, asked) {
|
|
|
199
213
|
const own = effective(maker?.approval);
|
|
200
214
|
const widest = approvalRank(bot.temp_approval) > approvalRank(own) ? bot.temp_approval : own;
|
|
201
215
|
if (approvalRank(level) <= approvalRank(widest)) return;
|
|
202
|
-
|
|
216
|
+
const what = onPi === undefined ? `--approval ${level}` : `${onPi} would run on Pi, and every Pi session runs as ${level}, which`;
|
|
217
|
+
throw new Error(`${what} is wider than ${caller.session}'s own approval, ${own}${widest === own ? '' : `, and than ${caller.bot}'s temp_approval, ${widest}`}, and a session does not widen another's approval. The user allows it for ${caller.bot}'s temporary sessions with ${shellWord(ownCli())} permission approval --bots ${shellWord(bots)} --bot ${caller.bot} --temps --approval ${level}. Nothing was made.`);
|
|
203
218
|
}
|
|
204
219
|
|
|
205
220
|
/**
|
|
@@ -651,11 +666,54 @@ const wholeScreen = (rows) => rows.filter((row) => row.trim() !== '').map((row)
|
|
|
651
666
|
*/
|
|
652
667
|
export async function answerTemp(bots, { tab, name }) {
|
|
653
668
|
const caller = callerIn(bots, tab, 'answer', "answer a session's first-run screen");
|
|
654
|
-
ownTemp(caller, name, 'answer for', 'Nothing was typed.');
|
|
655
|
-
const
|
|
669
|
+
const { bot, session } = ownTemp(caller, name, 'answer for', 'Nothing was typed.');
|
|
670
|
+
const target = { run: `${caller.bot}/${name}`, home: caller.home, session: name };
|
|
671
|
+
if (harnessOf(session, bot.harness) === 'pi') sayPiScreen(target, 'temp');
|
|
672
|
+
const sent = await answerTeach(target, 'temp');
|
|
656
673
|
return { bot: caller.bot, session: name, maker: caller.session, sent };
|
|
657
674
|
}
|
|
658
675
|
|
|
676
|
+
/**
|
|
677
|
+
* The foot of an extension's question in Pi 1.3.0: `ctx.ui.select` and
|
|
678
|
+
* `confirm` draw the trust question's list foot, `ctx.ui.input` its own.
|
|
679
|
+
*/
|
|
680
|
+
const PI_ASK_FOOT = /(?:↑↓ navigate +enter select|enter submit) +escape\/ctrl\+c cancel/;
|
|
681
|
+
|
|
682
|
+
/** The foot of each step of Pi 1.3.0's first-time setup. */
|
|
683
|
+
const PI_SETUP_FOOT = /enter (?:continue|finish) +escape\/ctrl\+c skip setup/;
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* Refuse to answer a Pi session's screen, and say what it is (#648). The kit
|
|
687
|
+
* starts Pi with `--no-approve`, and then no screen of Pi's own waits for a
|
|
688
|
+
* key where Pi has its settings already (tech notes, section 6). What still
|
|
689
|
+
* can is named and left to the user: Pi's trust question, its first-time
|
|
690
|
+
* setup, its warning that waits for a key, and an extension's question.
|
|
691
|
+
* Nothing is typed, so no turn for a line is taken.
|
|
692
|
+
*/
|
|
693
|
+
function sayPiScreen({ run, home, session }, kind) {
|
|
694
|
+
const nothing = 'Nothing was typed.';
|
|
695
|
+
const tabId = readBook(home).sessions[session]?.tab;
|
|
696
|
+
const handle = tabId === undefined ? undefined : tabs(home).find((one) => one.tabId === tabId)?.handle;
|
|
697
|
+
if (handle === undefined) {
|
|
698
|
+
throw new Error(`${run} runs on Pi and has no tab open in Orca, so there is no screen of its to answer. ${nothing}`);
|
|
699
|
+
}
|
|
700
|
+
const seen = screenRows(handle);
|
|
701
|
+
if (seen.rows === undefined) {
|
|
702
|
+
throw new Error(`${run} runs on Pi, and its screen could not be read (${seen.unreadable}). ${nothing}`);
|
|
703
|
+
}
|
|
704
|
+
const has = (test) => seen.rows.some((row) => test(row.trim()));
|
|
705
|
+
const what = has((row) => row === 'Trust project folder?')
|
|
706
|
+
? `Pi's project-trust question, which Pi does not ask when the kit starts it with --no-approve. Trusting a folder is the user's choice`
|
|
707
|
+
: has((row) => PI_SETUP_FOOT.test(row))
|
|
708
|
+
? 'Pi\'s first-time setup, which asks for a theme and whether to share anonymous usage data (analytics) when Pi has no settings.json yet. Both are the user\'s choice'
|
|
709
|
+
: has((row) => row.endsWith('Press any key to continue...'))
|
|
710
|
+
? 'a warning of Pi\'s that waits for a key, such as its notice of an old hooks/ folder in ~/.pi/agent or the project. It is the user\'s to read and answer'
|
|
711
|
+
: has((row) => PI_ASK_FOOT.test(row))
|
|
712
|
+
? 'a question from an extension of Pi\'s, not from Pi or the kit. It is the user\'s to answer'
|
|
713
|
+
: `no first-run screen of Pi's: the kit starts Pi with --no-approve, and then Pi asks nothing before it works`;
|
|
714
|
+
throw new Error(`${run} runs on Pi, and its screen shows ${what}. ${kind} answer answers none of Pi's screens. ${nothing} It shows:\n${wholeScreen(seen.rows)}`);
|
|
715
|
+
}
|
|
716
|
+
|
|
659
717
|
/**
|
|
660
718
|
* Answer `target`'s Teach auto mode screen with Not now, for `temp answer` or
|
|
661
719
|
* `session answer` (`kind`). Returns what answered it.
|
|
@@ -714,6 +772,7 @@ export async function trustSessionHooks(bots, { tab, bot, session }) {
|
|
|
714
772
|
*/
|
|
715
773
|
export async function answerSession(bots, { tab, bot, session }) {
|
|
716
774
|
const target = longLivedTarget(bots, { tab, bot, session }, 'answer');
|
|
775
|
+
if (target.harness === 'pi') sayPiScreen(target, 'session');
|
|
717
776
|
const sent = await answerTeach(target, 'session');
|
|
718
777
|
return { bot: target.bot, session: target.session, sent };
|
|
719
778
|
}
|
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,
|
|
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,
|
|
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
|
-
}
|