@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/run.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
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
|
+
}
|
package/src/skills.js
CHANGED
|
@@ -24,11 +24,11 @@ import { fileURLToPath } from 'node:url';
|
|
|
24
24
|
import { parse, parseDocument, stringify } from 'yaml';
|
|
25
25
|
|
|
26
26
|
import { bookFile, readBook, takeLineTurn, TYPING_HELD, TYPING_WAIT_MS } from './book.js';
|
|
27
|
-
import { botDir, botNames, changesExactly, leadsOutside, readBot, YAML_OUT } from './bot.js';
|
|
27
|
+
import { botDir, botNames, changesExactly, leadsOutside, readBot, refuseOutside, withBotTurn, writeWhole, YAML_OUT } from './bot.js';
|
|
28
28
|
import { harnessOf, ownCli, shellWord } from './launch.js';
|
|
29
|
-
import {
|
|
29
|
+
import { orcaTrouble, tabToTypeInto, typeIntoTab } from './orca.js';
|
|
30
30
|
import { listIn } from './rules.js';
|
|
31
|
-
import { cloneDir, isCloned, readSources, skillsIn, wrongClone } from './sources.js';
|
|
31
|
+
import { cloneDir, isCloned, leavesClone, readSources, skillsIn, wrongClone } from './sources.js';
|
|
32
32
|
|
|
33
33
|
/** The kit's own skills, inside the installed package. */
|
|
34
34
|
const KIT_SKILLS = fileURLToPath(new URL('../skills', import.meta.url));
|
|
@@ -84,25 +84,28 @@ export function addSkill(bots, bot, ref) {
|
|
|
84
84
|
throw new Error(`there is no bot called ${bot} in ${bots}: ${file} is not there. Create it with obk bot create.`);
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
const doc = parseDocument(was);
|
|
91
|
-
const listed = doc.get('skills', true);
|
|
92
|
-
if (listed?.items === undefined) {
|
|
93
|
-
// No list to add to: an empty `skills:`, or no skills key at all.
|
|
94
|
-
doc.set('skills', [ref]);
|
|
95
|
-
} else {
|
|
96
|
-
listed.flow = false;
|
|
97
|
-
doc.addIn(['skills'], ref);
|
|
98
|
-
}
|
|
87
|
+
return withBotTurn(home, () => {
|
|
88
|
+
if (listIn(file, 'skills').includes(ref)) return { bot, home, skill: ref, state: 'there' };
|
|
89
|
+
refuseOutside(home, file);
|
|
99
90
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
91
|
+
const was = readFileSync(file, 'utf8');
|
|
92
|
+
const doc = parseDocument(was);
|
|
93
|
+
const listed = doc.get('skills', true);
|
|
94
|
+
if (listed?.items === undefined) {
|
|
95
|
+
// No list to add to: an empty `skills:`, or no skills key at all.
|
|
96
|
+
doc.set('skills', [ref]);
|
|
97
|
+
} else {
|
|
98
|
+
listed.flow = false;
|
|
99
|
+
doc.addIn(['skills'], ref);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const text = doc.toString(YAML_OUT);
|
|
103
|
+
if (!changesExactly(was, text, (had) => ({ ...had, skills: [...(had.skills ?? []), ref] }))) {
|
|
104
|
+
throw new Error(`${file} cannot have a skill added to it without changing something else in it, so nothing was written. Add ${ref} to its skills list by hand.`);
|
|
105
|
+
}
|
|
106
|
+
writeWhole(file, text);
|
|
107
|
+
return { bot, home, skill: ref, state: 'added' };
|
|
108
|
+
});
|
|
106
109
|
}
|
|
107
110
|
|
|
108
111
|
/**
|
|
@@ -118,19 +121,22 @@ export function removeSkill(bots, bot, ref) {
|
|
|
118
121
|
throw new Error(`there is no bot called ${bot} in ${bots}: ${file} is not there.`);
|
|
119
122
|
}
|
|
120
123
|
|
|
121
|
-
|
|
122
|
-
|
|
124
|
+
return withBotTurn(home, () => {
|
|
125
|
+
const index = listIn(file, 'skills').indexOf(ref);
|
|
126
|
+
if (index === -1) return { bot, home, skill: ref, state: 'absent' };
|
|
127
|
+
refuseOutside(home, file);
|
|
123
128
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
129
|
+
const was = readFileSync(file, 'utf8');
|
|
130
|
+
const doc = parseDocument(was);
|
|
131
|
+
doc.deleteIn(['skills', index]);
|
|
127
132
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
133
|
+
const text = doc.toString(YAML_OUT);
|
|
134
|
+
if (!changesExactly(was, text, (had) => ({ ...had, skills: had.skills.filter((one, at) => at !== index) }))) {
|
|
135
|
+
throw new Error(`${file} cannot have a skill taken off it without changing something else in it, so nothing was written. Take ${ref} off its skills list by hand.`);
|
|
136
|
+
}
|
|
137
|
+
writeWhole(file, text);
|
|
138
|
+
return { bot, home, skill: ref, state: 'removed' };
|
|
139
|
+
});
|
|
134
140
|
}
|
|
135
141
|
|
|
136
142
|
/**
|
|
@@ -472,7 +478,10 @@ function tellSessions(home, bot, linked) {
|
|
|
472
478
|
const told = { session: session.name, harness };
|
|
473
479
|
const tab = typeof book[session.name]?.tab === 'string' ? book[session.name].tab : undefined;
|
|
474
480
|
if (tab === undefined) return { ...told, state: 'not-up' };
|
|
475
|
-
|
|
481
|
+
// Skills reach a Pi session in a later slice (#547), and nothing is typed
|
|
482
|
+
// into its tab before then.
|
|
483
|
+
if (harness === 'pi') return { ...told, state: 'unknown', trouble: 'it runs on Pi, and the kit does not give a Pi session skills yet, so nothing was typed' };
|
|
484
|
+
if (down !== undefined) return { ...told, state: 'unknown', trouble: `the kit could not look at its tab, and nothing was typed: ${down}` };
|
|
476
485
|
let turn;
|
|
477
486
|
try {
|
|
478
487
|
// The session's turn for a line (#482), for the look and its line. Codex
|
|
@@ -564,7 +573,14 @@ function follow(bots, ref, sources) {
|
|
|
564
573
|
}
|
|
565
574
|
const wrong = wrongClone(bots, source);
|
|
566
575
|
if (wrong !== undefined) throw new Error(`${ref}: ${wrong}`);
|
|
567
|
-
|
|
576
|
+
// The pinned clone is the only place a source's skill comes from. A path,
|
|
577
|
+
// a skill name or a link the repo committed can lead out of it (#571).
|
|
578
|
+
const inClone = path.join(skillsIn(bots, source), after);
|
|
579
|
+
const real = leavesClone(bots, named, inClone);
|
|
580
|
+
if (real !== undefined) {
|
|
581
|
+
throw new Error(`${ref} leads outside the clone of the source ${named}, to ${real}, and a skill from a source comes only from the version skills.yaml pins for it. Name a skill inside ${cloneDir(bots, named)} with no link out of it.`);
|
|
582
|
+
}
|
|
583
|
+
return inClone;
|
|
568
584
|
},
|
|
569
585
|
common: () => path.join(bots, 'skills', ref),
|
|
570
586
|
// The user's own spelling: absolute, at their home, or read from the bots
|
package/src/sources.js
CHANGED
|
@@ -14,11 +14,11 @@
|
|
|
14
14
|
// file, so the difference is theirs to see.
|
|
15
15
|
|
|
16
16
|
import { spawnSync } from 'node:child_process';
|
|
17
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
17
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
18
18
|
import path from 'node:path';
|
|
19
19
|
import { parseDocument } from 'yaml';
|
|
20
20
|
|
|
21
|
-
import { changesExactly, requireBotsFolder, YAML_OUT } from './bot.js';
|
|
21
|
+
import { changesExactly, leadsOutside, requireBotsFolder, YAML_OUT } from './bot.js';
|
|
22
22
|
import { ownCli, shellWord } from './launch.js';
|
|
23
23
|
|
|
24
24
|
/** The user's file that lists where skills come from. */
|
|
@@ -65,6 +65,9 @@ export function addSource(bots, { name, repo, ref, path: inside }) {
|
|
|
65
65
|
if (TAKEN.includes(name)) {
|
|
66
66
|
throw new Error(`${name} is the kit's own shelf, which a bot names kit:<name>, so a source cannot be called that. Call it something else.`);
|
|
67
67
|
}
|
|
68
|
+
if (inside !== undefined && !staysInRepo(inside)) {
|
|
69
|
+
throw new Error(`${inside} cannot be the path of the source ${name}: it leads out of the repo, and a path is a subfolder inside it, such as skills.`);
|
|
70
|
+
}
|
|
68
71
|
|
|
69
72
|
const file = path.join(bots, SKILLS_YAML);
|
|
70
73
|
// Read through the same reader every other command uses, so a file this one
|
|
@@ -151,7 +154,6 @@ function bring(bots, source, moving) {
|
|
|
151
154
|
const wrong = wrongClone(bots, source);
|
|
152
155
|
if (wrong !== undefined) {
|
|
153
156
|
if (!moving) throw new Error(wrong);
|
|
154
|
-
rmSync(dir, { recursive: true, force: true });
|
|
155
157
|
return clone(bots, source, source.ref, false);
|
|
156
158
|
}
|
|
157
159
|
|
|
@@ -174,11 +176,16 @@ function bring(bots, source, moving) {
|
|
|
174
176
|
* Clone a source and check it out out of the way, and put it in place only once
|
|
175
177
|
* both have worked. `<name>.fetching` is where it happens; a run that cannot
|
|
176
178
|
* finish leaves that behind and not a source.
|
|
179
|
+
*
|
|
180
|
+
* A clone already there, from a repository the file no longer names, stays
|
|
181
|
+
* where it is until the new one is ready (#568). It may hold the user's own
|
|
182
|
+
* files, and a replacement that fails must not cost them.
|
|
177
183
|
*/
|
|
178
184
|
function clone(bots, source, rev, recorded) {
|
|
179
185
|
mkdirSync(sourcesDir(bots), { recursive: true });
|
|
180
186
|
const dir = cloneDir(bots, source.name);
|
|
181
187
|
const fetching = `${dir}.fetching`;
|
|
188
|
+
const replaced = `${dir}.replaced`;
|
|
182
189
|
|
|
183
190
|
try {
|
|
184
191
|
rmSync(fetching, { recursive: true, force: true });
|
|
@@ -186,7 +193,25 @@ function clone(bots, source, rev, recorded) {
|
|
|
186
193
|
// repository and never one of git's own options, whatever it begins with.
|
|
187
194
|
git(['clone', '--quiet', '--', source.repo, fetching], undefined, `${source.name}: ${source.repo} could not be cloned`);
|
|
188
195
|
checkout(fetching, source, rev, recorded);
|
|
189
|
-
|
|
196
|
+
if (existsSync(dir)) {
|
|
197
|
+
rmSync(replaced, { recursive: true, force: true });
|
|
198
|
+
renameSync(dir, replaced);
|
|
199
|
+
try {
|
|
200
|
+
renameSync(fetching, dir);
|
|
201
|
+
} catch (error) {
|
|
202
|
+
renameSync(replaced, dir);
|
|
203
|
+
throw error;
|
|
204
|
+
}
|
|
205
|
+
// The new clone is in place, so the update has worked. An old copy that
|
|
206
|
+
// will not go stays beside it, where health names it as a leftover.
|
|
207
|
+
try {
|
|
208
|
+
rmSync(replaced, { recursive: true, force: true });
|
|
209
|
+
} catch {
|
|
210
|
+
// Nothing to undo.
|
|
211
|
+
}
|
|
212
|
+
} else {
|
|
213
|
+
renameSync(fetching, dir);
|
|
214
|
+
}
|
|
190
215
|
} finally {
|
|
191
216
|
rmSync(fetching, { recursive: true, force: true });
|
|
192
217
|
}
|
|
@@ -250,6 +275,47 @@ function git(args, cwd, trouble) {
|
|
|
250
275
|
return result.stdout.trim();
|
|
251
276
|
}
|
|
252
277
|
|
|
278
|
+
/**
|
|
279
|
+
* Whether a source's path, as written, names a folder inside its repo: not an
|
|
280
|
+
* absolute path, and no `..` that climbs above the top (#571). A link inside the
|
|
281
|
+
* clone can still lead out; `skills build` follows it before linking.
|
|
282
|
+
*/
|
|
283
|
+
const staysInRepo = (inside) => !path.isAbsolute(inside) && path.normalize(inside).split(path.sep)[0] !== '..';
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Where a source's skill at `dir` really leads when a link takes it, or anything
|
|
287
|
+
* in it, out of the source's clone; undefined when all of it stays inside (#571).
|
|
288
|
+
* A harness reads any file in a skill, so the whole folder is walked, through
|
|
289
|
+
* the links that stay inside as well.
|
|
290
|
+
*/
|
|
291
|
+
export function leavesClone(bots, name, dir) {
|
|
292
|
+
const clone = cloneDir(bots, name);
|
|
293
|
+
const seen = new Set();
|
|
294
|
+
const walk = (at) => {
|
|
295
|
+
const real = leadsOutside(clone, at);
|
|
296
|
+
if (real !== undefined) return real;
|
|
297
|
+
let entries;
|
|
298
|
+
try {
|
|
299
|
+
const where = realpathSync.native(at);
|
|
300
|
+
// A folder reached twice, through a link back into the skill, is walked once.
|
|
301
|
+
if (seen.has(where) || !statSync(where).isDirectory()) return undefined;
|
|
302
|
+
seen.add(where);
|
|
303
|
+
entries = readdirSync(where);
|
|
304
|
+
} catch (error) {
|
|
305
|
+
// Nothing there: whatever links it says so.
|
|
306
|
+
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return undefined;
|
|
307
|
+
// A folder the kit may not read can hold a link out, and nothing shows it.
|
|
308
|
+
throw new Error(`${name}: the kit could not read ${at} (${error.code ?? error.message}), so it cannot tell whether anything in the skill leads out of the source's clone, and it links nothing from there.`);
|
|
309
|
+
}
|
|
310
|
+
for (const entry of entries) {
|
|
311
|
+
const found = walk(path.join(at, entry));
|
|
312
|
+
if (found !== undefined) return found;
|
|
313
|
+
}
|
|
314
|
+
return undefined;
|
|
315
|
+
};
|
|
316
|
+
return walk(dir);
|
|
317
|
+
}
|
|
318
|
+
|
|
253
319
|
/** Where a source's skills sit inside its clone: its root, or the subfolder it names. */
|
|
254
320
|
export const skillsIn = (bots, source) => path.join(cloneDir(bots, source.name), source.path ?? '');
|
|
255
321
|
|
|
@@ -331,6 +397,9 @@ export function readSources(file) {
|
|
|
331
397
|
if (source.path !== undefined && typeof source.path !== 'string') {
|
|
332
398
|
throw new Error(`${file}: the source ${name} has a path that is not a subfolder inside the repo.`);
|
|
333
399
|
}
|
|
400
|
+
if (source.path !== undefined && !staysInRepo(source.path)) {
|
|
401
|
+
throw new Error(`${file}: the source ${name} has the path ${source.path}, which leads out of the repo, and a path is a subfolder inside it, such as skills.`);
|
|
402
|
+
}
|
|
334
403
|
return source;
|
|
335
404
|
});
|
|
336
405
|
}
|
package/src/temp.js
CHANGED
|
@@ -75,6 +75,9 @@ export async function makeTemp(bots, { tab, ...given }) {
|
|
|
75
75
|
|
|
76
76
|
const maker = bot.sessions.find((session) => session.name === caller.session);
|
|
77
77
|
const { settings, chosen } = settingsFor(bot, maker, role?.option ?? {}, given);
|
|
78
|
+
if (chosen.harness.value === 'pi') {
|
|
79
|
+
throw new Error(`${name} would run on Pi, and a temporary session on Pi comes in a later slice of #547. Make it on claude or codex. Nothing was made.`);
|
|
80
|
+
}
|
|
78
81
|
refuseWiderApproval(bots, caller, bot, maker, given.approval);
|
|
79
82
|
refuseApprovalArgs(chosen.harness.value, given.extra_args);
|
|
80
83
|
settings.name = name;
|
|
@@ -83,27 +86,42 @@ export async function makeTemp(bots, { tab, ...given }) {
|
|
|
83
86
|
else settings.prompt_file = given.prompt_file ?? role.prompt_file;
|
|
84
87
|
settings.work_dir = `work/${name}`;
|
|
85
88
|
|
|
86
|
-
const written = bot.sessions.some((session) => session.name === name) ? undefined : writePromptFirst(bots, caller, settings);
|
|
87
89
|
// bot.yaml and the book are written under the book's lock, so that a second
|
|
88
90
|
// make from the same maker waits, and then sees this one (#464). addSession
|
|
89
91
|
// refuses a name the bot already has, and a setting that will not work,
|
|
90
92
|
// before it writes anything.
|
|
91
93
|
const made = new Date().toISOString();
|
|
92
94
|
let locked = false;
|
|
95
|
+
let written;
|
|
93
96
|
let added;
|
|
94
97
|
try {
|
|
95
98
|
await updateBook(caller.home, (book) => {
|
|
96
99
|
locked = true;
|
|
97
100
|
refuseNested(bots, caller, book);
|
|
98
101
|
if (role !== undefined) refuseCap(caller, role, book);
|
|
99
|
-
|
|
102
|
+
// The prompt file is written under the lock too, and only for a name the
|
|
103
|
+
// bot does not have yet: a make of the same name that waited on this one
|
|
104
|
+
// writes nothing, so it has nothing of this one's to remove (#572).
|
|
105
|
+
written = readBot(caller.home, caller.bot).sessions.some((session) => session.name === name) ? undefined : writePromptFirst(bots, caller, settings);
|
|
106
|
+
try {
|
|
107
|
+
added = addSession(bots, caller.bot, settings);
|
|
108
|
+
} catch (error) {
|
|
109
|
+
// Taken back while the lock is held: once it is let go, another make
|
|
110
|
+
// of this name may write the same file.
|
|
111
|
+
const left = removeWritten(written);
|
|
112
|
+
if (left !== undefined) throw promptLeft(error.message.replace(/ ?Nothing was made\.$/, ''), written, left);
|
|
113
|
+
throw error;
|
|
114
|
+
}
|
|
100
115
|
const kind = role === undefined ? {} : { role: role.name, option: role.option.name };
|
|
101
116
|
book.sessions[name] = { ...book.sessions[name], temporary: { maker: caller.session, made, ...kind } };
|
|
102
117
|
});
|
|
103
118
|
} catch (error) {
|
|
104
|
-
// A refusal under the lock has written nothing
|
|
105
|
-
|
|
119
|
+
// A refusal under the lock has written nothing, or taken its prompt file back.
|
|
120
|
+
if (locked && added === undefined) throw error;
|
|
106
121
|
const failed = `${caller.bot}/${name} could not be recorded in the book as temporary (${error.message})`;
|
|
122
|
+
// The prompt file goes while the session is still in bot.yaml, where it
|
|
123
|
+
// keeps any other make of its name from writing that file.
|
|
124
|
+
const left = removeWritten(written);
|
|
107
125
|
// A session in bot.yaml that the book does not call temporary would be a
|
|
108
126
|
// long-lived one to everything that reads it, its maker's retire included,
|
|
109
127
|
// so it is taken back off rather than left that way.
|
|
@@ -114,15 +132,8 @@ export async function makeTemp(bots, { tab, ...given }) {
|
|
|
114
132
|
throw new Error(`${failed}, and taking it back off bot.yaml failed too (${undo.message}). It is in bot.yaml as a session the book does not call temporary. Take it off with ${shellWord(ownCli())} retire --bots ${shellWord(bots)} --bot ${caller.bot} --session ${name}`);
|
|
115
133
|
}
|
|
116
134
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
const why = refused ? error.message.replace(/ ?Nothing was made\.$/, '') : `${failed}${added === undefined ? '.' : ', so it was taken back off bot.yaml.'}`;
|
|
120
|
-
try {
|
|
121
|
-
if (written !== undefined) rmSync(written, { force: true });
|
|
122
|
-
} catch (left) {
|
|
123
|
-
throw new Error(`${why} Nothing was made. Its start prompt ${written} could not be removed (${left.message}), and nothing reads it. Remove it with rm ${shellWord(written)}`);
|
|
124
|
-
}
|
|
125
|
-
if (refused) throw error;
|
|
135
|
+
const why = `${failed}${added === undefined ? '.' : ', so it was taken back off bot.yaml.'}`;
|
|
136
|
+
if (left !== undefined) throw promptLeft(why, written, left);
|
|
126
137
|
throw new Error(`${why} Nothing was made. Run this again once the book can be written.`);
|
|
127
138
|
}
|
|
128
139
|
|
|
@@ -178,7 +189,7 @@ function settingsFor(bot, maker, option, given) {
|
|
|
178
189
|
/**
|
|
179
190
|
* Refuse an approval wider than the widest of the maker's own and the bot's
|
|
180
191
|
* `temp_approval`, which only the user's yes through `permission approval
|
|
181
|
-
* --temps` widens (ADR
|
|
192
|
+
* --temps` widens (ADR 0051).
|
|
182
193
|
*/
|
|
183
194
|
function refuseWiderApproval(bots, caller, bot, maker, asked) {
|
|
184
195
|
if (asked === undefined) return;
|
|
@@ -316,6 +327,24 @@ function writePromptFirst(bots, caller, settings) {
|
|
|
316
327
|
return file;
|
|
317
328
|
}
|
|
318
329
|
|
|
330
|
+
/** Remove the start prompt's file `written`, when there is one, and answer why it could not be. */
|
|
331
|
+
function removeWritten(written) {
|
|
332
|
+
try {
|
|
333
|
+
if (written !== undefined) rmSync(written, { force: true });
|
|
334
|
+
} catch (error) {
|
|
335
|
+
return error;
|
|
336
|
+
}
|
|
337
|
+
return undefined;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* The refusal `why` for a make whose start prompt's file `written` could not
|
|
342
|
+
* be removed (`left`). Off bot.yaml, nothing is made; a prompt file that stays
|
|
343
|
+
* is only a file, and nothing reads it (#393).
|
|
344
|
+
*/
|
|
345
|
+
const promptLeft = (why, written, left) =>
|
|
346
|
+
new Error(`${why} Nothing was made. Its start prompt ${written} could not be removed (${left.message}), and nothing reads it. Remove it with rm ${shellWord(written)}`);
|
|
347
|
+
|
|
319
348
|
/**
|
|
320
349
|
* Retire a temporary session the caller made. Returns what `retireSession`
|
|
321
350
|
* does, with the maker.
|
|
@@ -506,10 +535,11 @@ const TEACH_FOOT = /Enter to .*· Esc to cancel/;
|
|
|
506
535
|
* captured live, with what answers it. Rows are compared trimmed, the pointer
|
|
507
536
|
* `❯` taken out, so the pointer may sit on any one of them.
|
|
508
537
|
*
|
|
509
|
-
* Claude Code 2.1.283's form (#261's live test, #416)
|
|
510
|
-
* which cancels
|
|
511
|
-
* 2.1.289's numbered list (#489's live run 1) is
|
|
512
|
-
* never "1. Yes" or "3. Don't show again" (the
|
|
538
|
+
* Claude Code 2.1.283's form (#261's live test, #416) and 2.1.302's (#604) are
|
|
539
|
+
* answered with Esc, which cancels them (Not now), never a return, which is
|
|
540
|
+
* Continue. Claude Code 2.1.289's numbered list (#489's live run 1) is
|
|
541
|
+
* answered with "2. Not now", never "1. Yes" or "3. Don't show again" (the
|
|
542
|
+
* architect's ruling on #489).
|
|
513
543
|
*/
|
|
514
544
|
const TEACH_SHAPES = [
|
|
515
545
|
{
|
|
@@ -524,6 +554,21 @@ const TEACH_SHAPES = [
|
|
|
524
554
|
'Continue',
|
|
525
555
|
'←/→ to change usage · Enter to continue · Esc to cancel',
|
|
526
556
|
],
|
|
557
|
+
escape: true,
|
|
558
|
+
},
|
|
559
|
+
{
|
|
560
|
+
name: 'the 2.1.302 form',
|
|
561
|
+
rows: [
|
|
562
|
+
TEACH_TITLE,
|
|
563
|
+
'Claude Code reads this project, your recent Claude sessions, and optionally your shell history and other',
|
|
564
|
+
'repositories. Claude analyzes this data and customizes auto mode to make better decisions.',
|
|
565
|
+
'How you use Claude here Mixed',
|
|
566
|
+
'Also scan shell history false',
|
|
567
|
+
'Also scan your other repos false',
|
|
568
|
+
'Continue',
|
|
569
|
+
'←/→ to change · Enter to continue · Esc to cancel',
|
|
570
|
+
],
|
|
571
|
+
escape: true,
|
|
527
572
|
},
|
|
528
573
|
{
|
|
529
574
|
name: 'the 2.1.289 list',
|
|
@@ -597,7 +642,7 @@ const wholeScreen = (rows) => rows.filter((row) => row.trim() !== '').map((row)
|
|
|
597
642
|
/**
|
|
598
643
|
* Answer the first-run screen of a temporary session the caller made (#489):
|
|
599
644
|
* Claude Code's Teach auto mode screen, in one of the shapes the kit knows,
|
|
600
|
-
* matched exactly. The 2.1.283
|
|
645
|
+
* matched exactly. The 2.1.283 and 2.1.302 forms get Esc. The 2.1.289 list gets arrows to
|
|
601
646
|
* "2. Not now" where needed, a second look that the pointer is there and the
|
|
602
647
|
* list is otherwise as it was, and only then a return. Anything else is refused with
|
|
603
648
|
* nothing typed. Like `trustHooks`, it is one command a permission rule of its
|
|
@@ -628,7 +673,7 @@ function answerTeach(target, kind) {
|
|
|
628
673
|
const hooks = rows.some((row) => row.includes(HOOKS_REVIEW)) ? ` A Codex session's "${HOOKS_REVIEW}" is answered with ${kind} trust-hooks.` : '';
|
|
629
674
|
throw new Error(`${run}'s screen is not one ${kind} answer answers: ${seen.misfit}.${hooks} ${nothing} It shows:\n${wholeScreen(rows)}`);
|
|
630
675
|
}
|
|
631
|
-
if (seen.shape
|
|
676
|
+
if (seen.shape.escape) {
|
|
632
677
|
send('\x1b');
|
|
633
678
|
return 'Esc (Not now)';
|
|
634
679
|
}
|