@assuredloop/orca-bot-kit 0.26.1 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +68 -41
- package/SETUP.md +26 -13
- package/package.json +1 -1
- package/rules/mail.md +8 -8
- package/skills/obk-bot-building/SKILL.md +24 -15
- package/skills/obk-grooming/SKILL.md +4 -3
- package/src/book.js +143 -23
- package/src/bot.js +246 -118
- package/src/clear.js +7 -3
- package/src/cli.js +129 -130
- package/src/commands.js +21 -14
- package/src/conversations.js +73 -40
- package/src/groom.js +27 -9
- package/src/health.js +79 -11
- package/src/hooks.js +42 -64
- package/src/init.js +36 -29
- package/src/launch.js +221 -20
- package/src/message.js +83 -335
- package/src/name.js +1 -1
- package/src/orca-reload.applescript +1 -1
- package/src/orca-runtime.cjs +1 -1
- package/src/orca.js +35 -42
- package/src/permissions.js +30 -16
- package/src/pi.js +55 -0
- package/src/processes.js +392 -0
- package/src/retire.js +43 -40
- package/src/rules.js +5 -2
- package/src/skills.js +51 -35
- package/src/sources.js +73 -4
- package/src/temp.js +65 -20
- package/src/up.js +80 -14
- package/src/unread.js +0 -160
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
|
/**
|
|
@@ -438,7 +444,7 @@ const LOOK_MS = 2000;
|
|
|
438
444
|
* `blocked`) or `unknown` (with `trouble`).
|
|
439
445
|
*
|
|
440
446
|
* Claude Code gets `/reload-skills` typed into its tab, through the same gate
|
|
441
|
-
* as the mail
|
|
447
|
+
* as the mail interrupt. A busy session queues it and runs it as a command when
|
|
442
448
|
* its turn ends, so it never reaches the model as text. Codex has no reload
|
|
443
449
|
* command and takes the change at the start of its next turn by itself, so
|
|
444
450
|
* nothing is typed, and the caller is given the SKILL.md of each skill linked
|
|
@@ -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
|
}
|
package/src/up.js
CHANGED
|
@@ -4,16 +4,18 @@
|
|
|
4
4
|
// conversation and Orca's resume record with it. Run it twice and the second
|
|
5
5
|
// run does nothing.
|
|
6
6
|
|
|
7
|
+
import { randomUUID } from 'node:crypto';
|
|
7
8
|
import { existsSync, mkdirSync, realpathSync, writeFileSync } from 'node:fs';
|
|
8
9
|
import path from 'node:path';
|
|
9
10
|
import { setTimeout as pause } from 'node:timers/promises';
|
|
10
11
|
|
|
11
|
-
import { forgetClaimed, forgetSession, MAILBOX_WAIT_MS, readBook, sessionIdsIn, tabIdsIn, takeLineTurn, takeMailboxTurn, TYPING_HELD, TYPING_WAIT_MS, updateBook, withUnclaimed } from './book.js';
|
|
12
|
+
import { forgetClaimed, forgetSession, MAILBOX_WAIT_MS, readBook, rememberSession, sessionIdsIn, tabIdsIn, takeLineTurn, takeMailboxTurn, TYPING_HELD, TYPING_WAIT_MS, updateBook, withUnclaimed } from './book.js';
|
|
12
13
|
import { botDir, botNames, displayName, projectName, readBot } from './bot.js';
|
|
13
14
|
import { conversationsIn, hasConversation, heldAsUserTurn, transcriptsIn } from './conversations.js';
|
|
14
15
|
import { installHook } from './hooks.js';
|
|
15
16
|
import { writePermissions } from './permissions.js';
|
|
16
|
-
import {
|
|
17
|
+
import { piTrouble } from './pi.js';
|
|
18
|
+
import { addressOf, HARNESSES, harnessOf, isAddressOf, isShortPrompt, kitFolders, launchCommand, mailboxStep, reachesMail, sessionTrouble, startPrompt, workDirOf } from './launch.js';
|
|
17
19
|
import { asFolderProject, coordinatorOf, findProject, harnessInTab, makeMailbox, makeProject, openTab, QUESTION_ON_SCREEN, retitleTab, runMissing, screenRows, shellInTab, tabs, tabToTypeInto, TERMINAL_ENV, TIMED_OUT, tellWindow, typeIntoTab, useMailbox } from './orca.js';
|
|
18
20
|
import { TAB_ENV } from './record.js';
|
|
19
21
|
import { buildAgents, rulesStamp } from './rules.js';
|
|
@@ -110,6 +112,7 @@ export function prepareBots(bots, names, onlySession) {
|
|
|
110
112
|
return { home, bot: readBot(home, name) };
|
|
111
113
|
});
|
|
112
114
|
for (const { bot, home } of chosen) refuseWhatCannotStart(bot, home, onlySession);
|
|
115
|
+
refuseWhenPiIsNotReady(chosen, onlySession);
|
|
113
116
|
|
|
114
117
|
// Every bot's AGENTS.md is built before Orca is asked for anything, because a
|
|
115
118
|
// session reads that file as it starts: a build landing after the tab was
|
|
@@ -170,7 +173,7 @@ export function prepareBots(bots, names, onlySession) {
|
|
|
170
173
|
export function botsNamed(bots, onlyBot) {
|
|
171
174
|
const names = botNames(bots);
|
|
172
175
|
if (names.length === 0) {
|
|
173
|
-
throw new Error(`there are no bots in ${bots} yet: make one with obk bot create --bots <path> --name <name> --harness claude|codex.`);
|
|
176
|
+
throw new Error(`there are no bots in ${bots} yet: make one with obk bot create --bots <path> --name <name> --harness claude|codex|pi.`);
|
|
174
177
|
}
|
|
175
178
|
if (onlyBot !== undefined && !names.includes(onlyBot)) {
|
|
176
179
|
throw new Error(`there is no bot called ${onlyBot} in ${bots}. The bots there are: ${names.join(', ')}.`);
|
|
@@ -185,6 +188,23 @@ function refuseWhatCannotStart(bot, home, onlySession) {
|
|
|
185
188
|
}
|
|
186
189
|
}
|
|
187
190
|
|
|
191
|
+
/**
|
|
192
|
+
* Refuse the run before Orca is asked for anything when it would start a Pi
|
|
193
|
+
* session and Pi cannot start one: Pi does not run, can use no model, or
|
|
194
|
+
* cannot use the one a session names (#578). The user signs in to Pi on their
|
|
195
|
+
* own. A run with no Pi session does not ask Pi anything.
|
|
196
|
+
*/
|
|
197
|
+
function refuseWhenPiIsNotReady(chosen, onlySession) {
|
|
198
|
+
const onPi = chosen.flatMap(({ bot }) => awake(sessionsOf(bot, onlySession))
|
|
199
|
+
.filter((session) => harnessOf(session, bot.harness) === 'pi')
|
|
200
|
+
.map((session) => ({ bot, session })));
|
|
201
|
+
if (onPi.length === 0) return;
|
|
202
|
+
const trouble = piTrouble(onPi.map(({ session }) => session.model).filter((model) => typeof model === 'string' && model.trim() !== ''));
|
|
203
|
+
if (trouble !== undefined) {
|
|
204
|
+
throw new Error(`${trouble} It would start ${onPi.map(({ bot, session }) => `${bot.name} ${session.name}`).join(', ')}.`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
188
208
|
/**
|
|
189
209
|
* The sessions that are not paused. A paused session is left out of a run
|
|
190
210
|
* altogether, so what it is set to asks nothing of the ones that will start;
|
|
@@ -273,12 +293,18 @@ async function bringUpSession(bots, home, live, session, bot, title) {
|
|
|
273
293
|
// Asked before the book is held, because it reads a folder of the harness's
|
|
274
294
|
// own files: nothing slow happens under the lock.
|
|
275
295
|
let which = whichConversation(book, home, bot, session, was, harness);
|
|
276
|
-
|
|
296
|
+
// A Pi session is told which conversation to be, and a new one is named here:
|
|
297
|
+
// the book is where that name is kept (ADR 0012, tech notes, section 6).
|
|
298
|
+
const fresh = harness === 'pi' ? randomUUID() : undefined;
|
|
299
|
+
const launchOf = (chosen) => launchFor(bots, bot, session, harness, home, workDir, chosen.resume, was?.address, fresh);
|
|
277
300
|
let launch = launchOf(which);
|
|
278
301
|
|
|
279
302
|
// A work dir is a plain folder, made for the session before it is told about
|
|
280
303
|
// it (PRD 6.4). Nothing here is a git worktree.
|
|
281
304
|
if (workDir !== undefined) mkdirSync(workDir, { recursive: true });
|
|
305
|
+
// The kit's own folders for the bot, which a Codex launch line names for its
|
|
306
|
+
// sandbox to write, are there before the line is (#534).
|
|
307
|
+
if (harness === 'codex') for (const folder of Object.values(kitFolders(bots, bot.name))) mkdirSync(folder, { recursive: true });
|
|
282
308
|
writePrompt(launch);
|
|
283
309
|
|
|
284
310
|
const made = openTab(home, tabTitle);
|
|
@@ -314,10 +340,16 @@ async function bringUpSession(bots, home, live, session, bot, title) {
|
|
|
314
340
|
// gets, and trust-hooks asks what this one got (#506).
|
|
315
341
|
let entry = { ...current.sessions[session.name], tab: made.tabId, launched, launched_with: session.extra_args ?? [], rules };
|
|
316
342
|
if (rules === undefined) delete entry.rules;
|
|
343
|
+
// Only Claude Code answers to an address. One left from a Claude run
|
|
344
|
+
// before a move to another harness would draw mail to nobody (#565).
|
|
345
|
+
if (harness !== 'claude') delete entry.address;
|
|
317
346
|
held = entry.session;
|
|
318
347
|
// Before the line is typed, so the hook finds no id here and takes the one
|
|
319
348
|
// it reports for a start rather than a clear, which would tell the duty twice.
|
|
320
|
-
if (which.noConversation !== undefined && held === which.noConversation) entry =
|
|
349
|
+
if (which.noConversation !== undefined && held === which.noConversation) entry = setAside(entry, which);
|
|
350
|
+
// The id a new Pi conversation is started under, written before the line
|
|
351
|
+
// that starts it, as a hook writes Claude Code's and Codex's.
|
|
352
|
+
if (fresh !== undefined && launch.resume === undefined) entry = rememberSession(entry, fresh);
|
|
321
353
|
current.sessions[session.name] = withUnclaimed(entry, which.unclaimed ?? []);
|
|
322
354
|
forgetClaimed(current);
|
|
323
355
|
});
|
|
@@ -328,13 +360,15 @@ async function bringUpSession(bots, home, live, session, bot, title) {
|
|
|
328
360
|
// The old tab's hook can name another conversation while the tab is being
|
|
329
361
|
// opened. A fresh start chosen before that is chosen again from the id the
|
|
330
362
|
// book holds now: resumed if the harness has it, and otherwise set aside the
|
|
331
|
-
// same way (review of PR #310).
|
|
332
|
-
|
|
333
|
-
|
|
363
|
+
// same way (review of PR #310). A Pi session has no hook, and the id this run
|
|
364
|
+
// named for it is the one the book now holds.
|
|
365
|
+
if (fresh === undefined && which.noConversation !== undefined && typeof held === 'string' && held !== which.noConversation) {
|
|
366
|
+
which = hasConversation(harness, home, held) ? { resume: held } : notHere(harness, home, held);
|
|
334
367
|
if (which.noConversation !== undefined) {
|
|
368
|
+
const chosen = which;
|
|
335
369
|
await updateBook(home, (current) => {
|
|
336
370
|
const entry = current.sessions[session.name];
|
|
337
|
-
if (entry?.session === held) current.sessions[session.name] =
|
|
371
|
+
if (entry?.session === held) current.sessions[session.name] = setAside(entry, chosen);
|
|
338
372
|
});
|
|
339
373
|
}
|
|
340
374
|
launch = launchOf(which);
|
|
@@ -399,7 +433,9 @@ async function bringUpSession(bots, home, live, session, bot, title) {
|
|
|
399
433
|
promptReceived,
|
|
400
434
|
promptFile: launch.promptFile,
|
|
401
435
|
resumed: launch.resume !== undefined,
|
|
402
|
-
noConversation: which.noConversation,
|
|
436
|
+
noConversation: which.ranOn === undefined ? which.noConversation : undefined,
|
|
437
|
+
harnessChanged: which.ranOn === undefined ? undefined : which.noConversation,
|
|
438
|
+
ranOn: which.ranOn,
|
|
403
439
|
unclaimed: which.unclaimed,
|
|
404
440
|
listLine: list?.typed,
|
|
405
441
|
listLineTrouble: list?.why,
|
|
@@ -529,7 +565,7 @@ function lookAndType(home, tabId, line, until) {
|
|
|
529
565
|
* The line that starts this session: resuming `resume`, or, with none, a fresh
|
|
530
566
|
* start carrying its duty. Returns `{ resume, prompt, promptFile, command, address }`.
|
|
531
567
|
*/
|
|
532
|
-
function launchFor(bots, bot, session, harness, home, workDir, resume, held) {
|
|
568
|
+
function launchFor(bots, bot, session, harness, home, workDir, resume, held, fresh) {
|
|
533
569
|
const prompt = resume === undefined ? startPrompt(session, { home, workDir }) : undefined;
|
|
534
570
|
// Anything longer than a line goes to the harness out of a file, rather than
|
|
535
571
|
// through the tab's shell a character at a time.
|
|
@@ -550,6 +586,10 @@ function launchFor(bots, bot, session, harness, home, workDir, resume, held) {
|
|
|
550
586
|
promptFile,
|
|
551
587
|
resume,
|
|
552
588
|
address,
|
|
589
|
+
kit: kitFolders(bots, bot.name),
|
|
590
|
+
// A new Pi conversation's id, and the name its tab and session list show,
|
|
591
|
+
// `<bot>.<session>` as a Codex thread is named (#480).
|
|
592
|
+
...(harness === 'pi' ? { sessionId: resume === undefined ? fresh : undefined, name: `${bot.name}.${session.name}` } : {}),
|
|
553
593
|
});
|
|
554
594
|
// The session's mailbox first, from inside its own tab (#317), and the
|
|
555
595
|
// harness whatever became of it.
|
|
@@ -827,7 +867,7 @@ function whichConversation(book, home, bot, session, was, harness) {
|
|
|
827
867
|
// it: a session paused before its first turn is one. There is nothing to
|
|
828
868
|
// resume, so it starts again with its duty, and the book keeps the id in the
|
|
829
869
|
// history with the reason (#295).
|
|
830
|
-
return
|
|
870
|
+
return notHere(harness, home, was.session);
|
|
831
871
|
}
|
|
832
872
|
// No tab: nothing has ever run for this session, so there is nothing to find.
|
|
833
873
|
if (typeof was?.tab !== 'string') return {};
|
|
@@ -847,6 +887,22 @@ function whichConversation(book, home, bot, session, was, harness) {
|
|
|
847
887
|
return { unclaimed: known.map((one) => one.id) };
|
|
848
888
|
}
|
|
849
889
|
|
|
890
|
+
/**
|
|
891
|
+
* An id the book holds that this harness has no conversation behind, so the
|
|
892
|
+
* session starts fresh: `{ noConversation: id }`, and `ranOn`, the harness that
|
|
893
|
+
* does have it, when the session has moved to another harness since it ran
|
|
894
|
+
* (#565). That conversation is not lost, only not this harness's to resume.
|
|
895
|
+
*/
|
|
896
|
+
function notHere(harness, home, id) {
|
|
897
|
+
const ranOn = HARNESSES.find((other) => other !== harness && hasConversation(other, home, id));
|
|
898
|
+
return ranOn === undefined ? { noConversation: id } : { noConversation: id, ranOn };
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/** The entry with the id `which` set aside moved into its history, with why. */
|
|
902
|
+
const setAside = (entry, which) => (which.ranOn === undefined
|
|
903
|
+
? forgetSession(entry, 'no conversation')
|
|
904
|
+
: forgetSession(entry, 'harness changed', { harness: which.ranOn }));
|
|
905
|
+
|
|
850
906
|
/**
|
|
851
907
|
* A bot's Orca project, made if it is not there yet, with `change` saying
|
|
852
908
|
* whether this run `made` it, `renamed` it or left it as it was (undefined).
|
|
@@ -870,12 +926,19 @@ const ids = (setup, change) => ({ project: setup.projectId, setup: setup.id, cha
|
|
|
870
926
|
*
|
|
871
927
|
* Beside *this* bots folder, and not in a shared temp directory: two bots
|
|
872
928
|
* folders may each hold an api-bot with a daily session, and one file for both
|
|
873
|
-
* of them is one bot's duty handed to another's session.
|
|
929
|
+
* of them is one bot's duty handed to another's session. In the bot's own
|
|
930
|
+
* folder there, which a Codex session of the bot may write and no other bot's
|
|
931
|
+
* may: a temporary session's maker writes its prompt from inside its sandbox
|
|
932
|
+
* (#534).
|
|
874
933
|
*/
|
|
875
934
|
export const promptPath = (bots, bot, session) =>
|
|
935
|
+
path.join(kitFolders(bots, bot).prompts, `${encodeURIComponent(session)}.txt`);
|
|
936
|
+
|
|
937
|
+
/** Where a session's start prompt was before #534, flat beside the others: removed with the session. */
|
|
938
|
+
export const oldPromptPath = (bots, bot, session) =>
|
|
876
939
|
path.join(`${bots}.prompts`, `${encodeURIComponent(bot)}.${encodeURIComponent(session)}.txt`);
|
|
877
940
|
|
|
878
|
-
function entry(tab, { bot, name, created, running = false, blockedReason, promptReceived, promptFile, resumed, noConversation, unclaimed, noMailbox = false, listLine, listLineTrouble, notLaunched }) {
|
|
941
|
+
function entry(tab, { bot, name, created, running = false, blockedReason, promptReceived, promptFile, resumed, noConversation, harnessChanged, ranOn, unclaimed, noMailbox = false, listLine, listLineTrouble, notLaunched }) {
|
|
879
942
|
const made = { bot, name, title: tab.title, tabId: tab.tabId, terminal: tab.handle, created, harnessStarted: running };
|
|
880
943
|
// A tab whose shell was not ready for the launch line, and why: nothing was
|
|
881
944
|
// typed into it (#498).
|
|
@@ -889,6 +952,9 @@ function entry(tab, { bot, name, created, running = false, blockedReason, prompt
|
|
|
889
952
|
if (listLineTrouble !== undefined) made.listLineTrouble = listLineTrouble;
|
|
890
953
|
// The id the book held that had no conversation behind it, now in the history.
|
|
891
954
|
if (noConversation !== undefined) made.noConversation = noConversation;
|
|
955
|
+
// The id the book held that ran on another harness, the one this session
|
|
956
|
+
// has since moved from, now in the history (#565).
|
|
957
|
+
if (harnessChanged !== undefined) Object.assign(made, { harnessChanged, ranOn });
|
|
892
958
|
// Orca's own words for what is on screen waiting to be answered, when it
|
|
893
959
|
// gave any: the caller acts on it, the kit only passes it on.
|
|
894
960
|
if (blockedReason !== undefined) made.blockedReason = blockedReason;
|