@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/health.js
CHANGED
|
@@ -25,8 +25,8 @@ import { bookFile, readBook, sessionIdsIn, tabIdsIn } from './book.js';
|
|
|
25
25
|
import { botDir, botNames, botsDir, readBot, tempRoles, unknownKeys } from './bot.js';
|
|
26
26
|
import { transcriptsIn } from './conversations.js';
|
|
27
27
|
import { hookTrouble } from './hooks.js';
|
|
28
|
-
import { APPROVALS, bypassFlags, harnessOf, HARNESSES, isAddressOf, ownCli, sessionTrouble, SHELL_ENV, shellWord, workDirOf } from './launch.js';
|
|
29
|
-
import { frontOfTab, orcaDefaultArgs, projects, runMissing, tabs, wordsOfProcess } from './orca.js';
|
|
28
|
+
import { APPROVALS, bypassFlags, harnessOf, HARNESSES, isAddressOf, ownCli, promptFile, sessionTrouble, set, SHELL_ENV, shellWord, workDirOf } from './launch.js';
|
|
29
|
+
import { frontOfTab, mailIn, orcaDefaultArgs, projects, runMissing, tabs, tabToTypeInto, wordsOfProcess } from './orca.js';
|
|
30
30
|
import { permissionsTrouble } from './permissions.js';
|
|
31
31
|
import { processesIn } from './processes.js';
|
|
32
32
|
import { TAB_ENV } from './record.js';
|
|
@@ -140,8 +140,17 @@ function leftBeside(bots) {
|
|
|
140
140
|
let known = true;
|
|
141
141
|
for (const name of botNames(bots)) {
|
|
142
142
|
try {
|
|
143
|
-
|
|
143
|
+
const home = botDir(bots, name);
|
|
144
|
+
for (const session of readBot(home, name).sessions) {
|
|
144
145
|
owned.add(promptPath(bots, name, session.name));
|
|
146
|
+
// The file a session names as its own prompt, which the kit reads to
|
|
147
|
+
// start it, and the file a link there leads to (#577).
|
|
148
|
+
if (set(session.prompt_file)) {
|
|
149
|
+
const file = promptFile(session, home);
|
|
150
|
+
owned.add(file);
|
|
151
|
+
const real = realpathOf(file);
|
|
152
|
+
if (real !== undefined) owned.add(real);
|
|
153
|
+
}
|
|
145
154
|
// Where a live session's prompt was before #534: nothing reads it once
|
|
146
155
|
// the session starts again, and its retire removes it.
|
|
147
156
|
owned.add(oldPromptPath(bots, name, session.name));
|
|
@@ -153,7 +162,7 @@ function leftBeside(bots) {
|
|
|
153
162
|
|
|
154
163
|
if (known) {
|
|
155
164
|
for (const at of promptFilesIn(`${bots}.prompts`)) {
|
|
156
|
-
if (owned.has(at)) continue;
|
|
165
|
+
if (owned.has(at) || owned.has(realpathOf(at))) continue;
|
|
157
166
|
found.push(finding('leftover', at, `${at} is a start prompt the kit wrote beside your bots folder, and no session of any bot answers to it now. Nothing reads it.`));
|
|
158
167
|
}
|
|
159
168
|
}
|
|
@@ -255,7 +264,7 @@ function sessionSettings(home, bot) {
|
|
|
255
264
|
/** What is wrong with the roles the bot's temporary sessions are made in (#465). */
|
|
256
265
|
function rolesTrouble(home, bot) {
|
|
257
266
|
const where = path.join(home, 'bot.yaml');
|
|
258
|
-
// The widest approval they may be made at, the user's yes (ADR
|
|
267
|
+
// The widest approval they may be made at, the user's yes (ADR 0051).
|
|
259
268
|
const level = bot.temp_approval;
|
|
260
269
|
const approval = level === undefined || APPROVALS.includes(level) ? [] : [{
|
|
261
270
|
where,
|
|
@@ -424,7 +433,7 @@ function inOrca(bots, home, bot, setups, sessions) {
|
|
|
424
433
|
|
|
425
434
|
// What the harness has on record in this bot's folder, read once: it is a walk
|
|
426
435
|
// through every conversation on the machine.
|
|
427
|
-
const onRecord = recordIn(real ?? home, bot);
|
|
436
|
+
const onRecord = recordIn(real ?? home, bot, book);
|
|
428
437
|
// A conversation the harness marks as a subagent's was never a session's, so a
|
|
429
438
|
// note that holds one is not a loose end for it (#287). The note stays as it is.
|
|
430
439
|
const helpers = new Set(onRecord.filter((one) => one.subagent).map((one) => one.id));
|
|
@@ -450,6 +459,12 @@ function inOrca(bots, home, bot, setups, sessions) {
|
|
|
450
459
|
found.push(finding('session', bookFile(home), `${bot.name}'s session ${name} has the mailbox ${entry.mailbox} in the book, and Orca has no Run of that id: it was made by another Orca, as on another machine, so mail sent to it is refused. The session's next start makes it a new one: ${fix}`, bot.name));
|
|
451
460
|
}
|
|
452
461
|
|
|
462
|
+
// Mail Orca has not told a running session of (#630). A session that is
|
|
463
|
+
// paused or whose tab is gone reads no mail by design, or is named above.
|
|
464
|
+
if (typeof entry?.mailbox === 'string' && there.has(entry.tab) && !closed.has(name)) {
|
|
465
|
+
found.push(...unannounced(bots, home, bot, name, entry));
|
|
466
|
+
}
|
|
467
|
+
|
|
453
468
|
const unclaimed = (Array.isArray(entry?.unclaimed) ? entry.unclaimed : []).filter((id) => !helpers.has(id));
|
|
454
469
|
if (unclaimed.length > 0) {
|
|
455
470
|
found.push(finding('leftover', bookFile(home), `The book notes conversations under ${bot.name}'s session ${name} that no session of this bot claims: ${unclaimed.join(', ')}. They ran in this bot's folder, and the kit will not say whose they were. To give one back, write it into ${bookFile(home)} under ${name} as session: <id> and run obk up again.`, bot.name));
|
|
@@ -472,6 +487,70 @@ function inOrca(bots, home, bot, setups, sessions) {
|
|
|
472
487
|
return found;
|
|
473
488
|
}
|
|
474
489
|
|
|
490
|
+
/** How old an unread letter Orca has not rung for is before it counts as waiting (#630). */
|
|
491
|
+
const WAITING_MS = 60 * 60 * 1000;
|
|
492
|
+
|
|
493
|
+
/** How long the look at a session's tab waits for its harness to be idle. */
|
|
494
|
+
const IDLE_LOOK_MS = 2000;
|
|
495
|
+
|
|
496
|
+
/** How many of a mailbox's newest letters health reads: one read, whatever the mailbox holds. */
|
|
497
|
+
const MAIL_READ = 100;
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* The session `name`'s mail that Orca has not told it of, while its harness is
|
|
501
|
+
* idle now: one finding, or none (#630). Orca rings only a tab whose harness it
|
|
502
|
+
* has seen go idle since Orca last started, so a session that sat idle through
|
|
503
|
+
* an Orca restart or update is never told (tech notes, section 1). A busy one
|
|
504
|
+
* is told when its turn ends, so it is not named. Only flags and times are
|
|
505
|
+
* read: a letter's subject and text are the session's own. A mailbox or a tab
|
|
506
|
+
* Orca will not answer about leaves it unsaid.
|
|
507
|
+
*
|
|
508
|
+
* One read of the newest letters (review of PR #632). Orca hands mail to its
|
|
509
|
+
* reader oldest first, so a full read whose oldest letter is read is taken to
|
|
510
|
+
* hold every unread one. That misses an older letter left unread below a letter
|
|
511
|
+
* `orca orchestration reply` marked read out of order; the kit never runs it.
|
|
512
|
+
* A full read whose oldest letter is unread may have more below it, and the
|
|
513
|
+
* finding says so rather than count what it did not read.
|
|
514
|
+
*/
|
|
515
|
+
function unannounced(bots, home, bot, name, entry) {
|
|
516
|
+
let mail;
|
|
517
|
+
try {
|
|
518
|
+
mail = mailIn(entry.mailbox, MAIL_READ);
|
|
519
|
+
} catch {
|
|
520
|
+
return [];
|
|
521
|
+
}
|
|
522
|
+
const before = Date.now() - WAITING_MS;
|
|
523
|
+
const waiting = mail
|
|
524
|
+
.filter((one) => one.read === 0 && one.delivered_at == null && Date.parse(one.created_at) < before)
|
|
525
|
+
.sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at));
|
|
526
|
+
const last = mail.at(-1);
|
|
527
|
+
const more = mail.length >= MAIL_READ && last.read === 0;
|
|
528
|
+
// Unread and never rung for, with more of the mailbox below it.
|
|
529
|
+
const below = more && last.delivered_at == null;
|
|
530
|
+
if (waiting.length === 0 && !below) return [];
|
|
531
|
+
|
|
532
|
+
// The gate the kit's own keys go through tells an idle harness from a busy
|
|
533
|
+
// one, a question on its screen or a shell; only its answer is kept.
|
|
534
|
+
let look;
|
|
535
|
+
try {
|
|
536
|
+
look = tabToTypeInto(home, entry.tab, IDLE_LOOK_MS);
|
|
537
|
+
} catch {
|
|
538
|
+
return [];
|
|
539
|
+
}
|
|
540
|
+
if (look.handle === undefined || look.idle !== true) return [];
|
|
541
|
+
|
|
542
|
+
const count = `${more ? 'at least ' : ''}${waiting.length}`;
|
|
543
|
+
const letters = waiting.length === 1 && !more ? '1 letter' : `${count} letters`;
|
|
544
|
+
const read = `Health reads the newest ${MAIL_READ} letters of a mailbox, and the oldest of those is unread too, so older letters may wait as well.`;
|
|
545
|
+
const what = waiting.length === 0
|
|
546
|
+
? `has mail in its mailbox ${entry.mailbox} that Orca has not told it of: the oldest of the newest ${MAIL_READ} letters, from ${last.created_at}, is unread, and Orca has not rung for it. Older letters may wait below it, which health does not read.`
|
|
547
|
+
: more
|
|
548
|
+
? `has ${letters} in its mailbox ${entry.mailbox} that it has not read and that Orca has not told it of, each more than an hour old; the oldest of them that health read is from ${waiting[0].created_at}. ${read}`
|
|
549
|
+
: `has ${letters} in its mailbox ${entry.mailbox} that it has not read and that Orca has not told it of, the oldest from ${waiting[0].created_at}, more than an hour ago.`;
|
|
550
|
+
const restart = `${shellWord(ownCli())} restart --bots ${shellWord(bots)} --bot ${bot.name} --session ${name}`;
|
|
551
|
+
return [finding('mail', entry.mailbox, `${bot.name}'s session ${name} ${what} Its harness is idle now, and Orca tells an idle session of its mail within minutes, unless the session sat idle through an Orca restart or update: then Orca does not tell it until it takes a turn. ${restart} opens a new tab for it, which Orca tells. A restart drops what lives only in the old tab, such as Claude Code schedules, so the session makes them again.`, bot.name)];
|
|
552
|
+
}
|
|
553
|
+
|
|
475
554
|
/**
|
|
476
555
|
* The conversations a harness has on record in this bot's folder that the book
|
|
477
556
|
* does not name anywhere. The hook is how an id reaches the book, and when it
|
|
@@ -493,10 +572,12 @@ function offTheBook(home, bot, book, onRecord) {
|
|
|
493
572
|
return [finding('session', bookFile(home), `The harness has conversations on record in ${bot.name}'s folder that the book does not name: ${listed}. The kit's hook may have missed them: a clear it did not record, or a Codex hooks file trusted late. To give one to a session, write it into ${bookFile(home)} under that session as session: <id> and run obk up again.`, bot.name)];
|
|
494
573
|
}
|
|
495
574
|
|
|
496
|
-
/** Every conversation on record in the bot's folder, from every harness any of its sessions runs on, as `obk usage` reads them. */
|
|
497
|
-
function recordIn(home, bot) {
|
|
575
|
+
/** Every conversation on record in the bot's folder, from every harness any of its sessions runs on or a retired one may have, as `obk usage` reads them. */
|
|
576
|
+
function recordIn(home, bot, book) {
|
|
498
577
|
const harnesses = new Set(bot.sessions.map((session) => harnessOf(session, bot.harness)));
|
|
499
578
|
if (harnesses.size === 0) harnesses.add(bot.harness);
|
|
579
|
+
// The book does not say which harness a retired session ran on, so any is (#573).
|
|
580
|
+
if (Array.isArray(book.retired) && book.retired.length > 0) for (const harness of HARNESSES) harnesses.add(harness);
|
|
500
581
|
return [...harnesses]
|
|
501
582
|
.filter((harness) => HARNESSES.includes(harness))
|
|
502
583
|
.flatMap((harness) => transcriptsIn(harness, home));
|
package/src/hooks.js
CHANGED
|
@@ -17,7 +17,11 @@ import { isDeepStrictEqual } from 'node:util';
|
|
|
17
17
|
import { leadsOutside } from './bot.js';
|
|
18
18
|
import { ownCli, shellWord } from './launch.js';
|
|
19
19
|
|
|
20
|
-
/**
|
|
20
|
+
/**
|
|
21
|
+
* Where each harness reads a project's hooks, inside the bot home. Pi has no
|
|
22
|
+
* hook of the kit's: the kit names a Pi session's id itself, on its launch
|
|
23
|
+
* line (#578).
|
|
24
|
+
*/
|
|
21
25
|
const HOOK_FILE = { claude: '.claude/settings.json', codex: '.codex/hooks.json' };
|
|
22
26
|
|
|
23
27
|
/** The hooks file `harness` reads in the bot at `home`. */
|
|
@@ -82,6 +86,7 @@ export const nameCommand = (bots, bot, cli = ownCli()) =>
|
|
|
82
86
|
* the kit's own entry is written (PRD 6.3).
|
|
83
87
|
*/
|
|
84
88
|
export function installHook(home, harness, { bots, bot }) {
|
|
89
|
+
if (HOOK_FILE[harness] === undefined) return;
|
|
85
90
|
const file = path.join(home, HOOK_FILE[harness]);
|
|
86
91
|
const away = outside(home, file);
|
|
87
92
|
if (away !== undefined) throw new Error(away);
|
|
@@ -96,7 +101,7 @@ export function installHook(home, harness, { bots, bot }) {
|
|
|
96
101
|
const started = withKitHook(settings.hooks, file, mine);
|
|
97
102
|
// The Claude Code mail hook and the Codex nudge hook went with the kit's
|
|
98
103
|
// typed line (#555), so an entry of either is taken out.
|
|
99
|
-
const wanted = { ...settings, hooks: toolHooksOf(harness, bots, bot).reduce(withToolHook, withoutHooks(started,
|
|
104
|
+
const wanted = { ...settings, hooks: toolHooksOf(harness, bots, bot).reduce(withToolHook, withoutHooks(started, REMOVED_HOOKS)) };
|
|
100
105
|
|
|
101
106
|
// Compared as documents, not as text: how the user laid their file out is
|
|
102
107
|
// theirs, and a run that changes nothing writes nothing.
|
|
@@ -107,6 +112,41 @@ export function installHook(home, harness, { bots, bot }) {
|
|
|
107
112
|
return HOOK_FILE[harness];
|
|
108
113
|
}
|
|
109
114
|
|
|
115
|
+
/**
|
|
116
|
+
* Take the kit's hooks of commands it no longer has out of every hooks file the
|
|
117
|
+
* bot at `home` has, whatever its sessions run on now and whether or not it is
|
|
118
|
+
* paused (#613). `installHook` does this only for a harness a session runs on,
|
|
119
|
+
* so a file the kit wrote for a harness the bot has since left kept them.
|
|
120
|
+
* Returns the files it wrote, relative to the bot home.
|
|
121
|
+
*
|
|
122
|
+
* It adds nothing and changes no other entry. A file it cannot read, a hooks
|
|
123
|
+
* entry that is not a mapping of events, or a link out of the bot folder is
|
|
124
|
+
* left as it is: the kit cannot tell its entries there, and health and
|
|
125
|
+
* `installHook` speak for a harness that runs.
|
|
126
|
+
*/
|
|
127
|
+
export function dropRemovedHooks(home) {
|
|
128
|
+
return Object.values(HOOK_FILE).flatMap((relative) => {
|
|
129
|
+
const file = path.join(home, relative);
|
|
130
|
+
if (!existsSync(file) || outside(home, file) !== undefined) return [];
|
|
131
|
+
let settings;
|
|
132
|
+
try {
|
|
133
|
+
settings = readSettings(file);
|
|
134
|
+
} catch {
|
|
135
|
+
return [];
|
|
136
|
+
}
|
|
137
|
+
const { hooks } = settings;
|
|
138
|
+
if (hooks === null || typeof hooks !== 'object' || Array.isArray(hooks)) return [];
|
|
139
|
+
const kept = withoutHooks(hooks, REMOVED_HOOKS);
|
|
140
|
+
if (isDeepStrictEqual(hooks, kept)) return [];
|
|
141
|
+
try {
|
|
142
|
+
writeFileSync(file, `${JSON.stringify({ ...settings, hooks: kept }, null, 2)}\n`);
|
|
143
|
+
} catch (error) {
|
|
144
|
+
throw new Error(`${file} cannot be written (${error.code ?? error.message}), and it holds the kit's hook of a command the kit no longer has, which the kit takes out. Let the kit write it, then run the command again.`);
|
|
145
|
+
}
|
|
146
|
+
return [relative];
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
110
150
|
/**
|
|
111
151
|
* Whether the kit's hook is in place for one harness, and what to say when it
|
|
112
152
|
* is not: `{ where, says }`, or nothing when the file already holds it.
|
|
@@ -117,6 +157,7 @@ export function installHook(home, harness, { bots, bot }) {
|
|
|
117
157
|
* nothing else says so — the book simply stops being true.
|
|
118
158
|
*/
|
|
119
159
|
export function hookTrouble(home, harness, { bots, bot }) {
|
|
160
|
+
if (HOOK_FILE[harness] === undefined) return undefined;
|
|
120
161
|
const file = path.join(home, HOOK_FILE[harness]);
|
|
121
162
|
const stale = `Until it is there, nothing tells the book which conversation this bot's ${harness} sessions are running as, and the book goes stale.`;
|
|
122
163
|
|
|
@@ -355,6 +396,7 @@ const KIT_SENT = new RegExp(String.raw`^(${WORD}) session sent --bots ${WORD} --
|
|
|
355
396
|
const KIT_NUDGE = new RegExp(String.raw`^(${WORD}) session nudge --bots ${WORD} --bot ${WORD} 2>/dev/null \|\| true$`);
|
|
356
397
|
const KIT_MAIL = new RegExp(String.raw`^(${WORD}) session mail --bots ${WORD} --bot ${WORD} 2>/dev/null \|\| true$`);
|
|
357
398
|
const KIT_NAME = new RegExp(String.raw`^(${WORD}) session name --bots ${WORD} --bot ${WORD} 2>/dev/null \|\| true$`);
|
|
399
|
+
const REMOVED_HOOKS = [KIT_MAIL, KIT_NUDGE];
|
|
358
400
|
|
|
359
401
|
/**
|
|
360
402
|
* Whether a hook entry is one of the kit's own as this kit writes it: a
|
package/src/init.js
CHANGED
|
@@ -10,7 +10,7 @@ import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, statSync,
|
|
|
10
10
|
import path from 'node:path';
|
|
11
11
|
import { parseDocument } from 'yaml';
|
|
12
12
|
|
|
13
|
-
import { BOT_GITIGNORE, changesExactly, YAML_OUT } from './bot.js';
|
|
13
|
+
import { BOT_GITIGNORE, changesExactly, refuseOutside, withBotTurn, writeWhole, YAML_OUT } from './bot.js';
|
|
14
14
|
import { RECORD, SKILL_DIRS } from './skills.js';
|
|
15
15
|
|
|
16
16
|
/**
|
|
@@ -266,6 +266,11 @@ function checkBotFather(file, harness) {
|
|
|
266
266
|
if (bot.sessions !== undefined && bot.sessions !== null && !Array.isArray(bot.sessions)) {
|
|
267
267
|
throw new Error(`${file} has a sessions entry that is not a list, so init cannot add Bot Father's daily session to it. Fix it, then run init again.`);
|
|
268
268
|
}
|
|
269
|
+
// One init would complete is refused here, before init writes anything, when a
|
|
270
|
+
// link takes it out of the bot folder (#569).
|
|
271
|
+
if (bot.harness === undefined || !Array.isArray(bot.sessions) || bot.sessions.length === 0) {
|
|
272
|
+
refuseOutside(path.dirname(file), file);
|
|
273
|
+
}
|
|
269
274
|
}
|
|
270
275
|
|
|
271
276
|
/**
|
|
@@ -281,35 +286,37 @@ function checkBotFather(file, harness) {
|
|
|
281
286
|
* are theirs, and `up` brings up what it finds.
|
|
282
287
|
*/
|
|
283
288
|
function completeBotFather(file, harness) {
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
289
|
+
return withBotTurn(path.dirname(file), () => {
|
|
290
|
+
const source = readFileSync(file, 'utf8');
|
|
291
|
+
const doc = parseDocument(source);
|
|
292
|
+
|
|
293
|
+
// What is missing is settled from the document, not from the text it is
|
|
294
|
+
// written back as: a file that needs nothing is not rewritten, however the
|
|
295
|
+
// library would have laid it out.
|
|
296
|
+
const sessions = doc.get('sessions', true);
|
|
297
|
+
const needsSession = sessions?.items === undefined || sessions.items.length === 0;
|
|
298
|
+
const needsHarness = doc.get('harness') === undefined;
|
|
299
|
+
if (!needsSession && !needsHarness) return [];
|
|
300
|
+
|
|
301
|
+
if (sessions?.items === undefined) {
|
|
302
|
+
doc.set('sessions', [{ name: DAILY_SESSION }]);
|
|
303
|
+
} else if (needsSession) {
|
|
304
|
+
sessions.flow = false;
|
|
305
|
+
doc.addIn(['sessions'], { name: DAILY_SESSION });
|
|
306
|
+
}
|
|
307
|
+
if (needsHarness) doc.set('harness', harness);
|
|
302
308
|
|
|
303
|
-
|
|
309
|
+
const text = doc.toString(YAML_OUT);
|
|
304
310
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
311
|
+
if (!changesExactly(source, text, (was) => ({
|
|
312
|
+
...was,
|
|
313
|
+
harness: was.harness ?? harness,
|
|
314
|
+
sessions: was.sessions?.length > 0 ? was.sessions : [{ name: DAILY_SESSION }],
|
|
315
|
+
}))) {
|
|
316
|
+
throw new Error(`${file} cannot be completed without changing something else in it, so nothing was written. Give it a harness and a session by hand, then run init again.`);
|
|
317
|
+
}
|
|
312
318
|
|
|
313
|
-
|
|
314
|
-
|
|
319
|
+
writeWhole(file, text);
|
|
320
|
+
return [BOT_FATHER_YAML];
|
|
321
|
+
});
|
|
315
322
|
}
|
package/src/launch.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// the harness up and carries the session's start prompt with it.
|
|
3
3
|
//
|
|
4
4
|
// The flag mapping is PRD 6.4 and ADR 0015, re-checked against the installed
|
|
5
|
-
// CLIs (Claude Code 2.1.278, Codex 0.155.1). Every session is given its
|
|
5
|
+
// CLIs (Claude Code 2.1.278, Codex 0.155.1, Pi 1.1.0). Every session is given its
|
|
6
6
|
// approval level explicitly, so a user's own global harness defaults cannot
|
|
7
7
|
// leak into a bot. The kit names no model: a session that sets none gets the
|
|
8
8
|
// harness's own.
|
|
@@ -27,6 +27,11 @@ const APPROVAL = {
|
|
|
27
27
|
ask: ['-a', 'on-request'],
|
|
28
28
|
'dangerously-skip': ['--dangerously-bypass-approvals-and-sandbox'],
|
|
29
29
|
},
|
|
30
|
+
// Pi asks before no tool call and has no rule for each command, so it has
|
|
31
|
+
// one level, and no flag for it (the owner, #547).
|
|
32
|
+
pi: {
|
|
33
|
+
'dangerously-skip': [],
|
|
34
|
+
},
|
|
30
35
|
};
|
|
31
36
|
|
|
32
37
|
export const APPROVALS = Object.keys(APPROVAL.claude);
|
|
@@ -44,32 +49,179 @@ export const HARNESSES = Object.keys(APPROVAL);
|
|
|
44
49
|
export const approvalRank = (level) => ['ask', 'auto', 'dangerously-skip'].indexOf(level);
|
|
45
50
|
|
|
46
51
|
/**
|
|
47
|
-
* The harness flags that set approval or permissions,
|
|
48
|
-
* `obk permission approval` may change, not an
|
|
49
|
-
* Codex's
|
|
52
|
+
* The harness flags that set approval or permissions, or widen what a session
|
|
53
|
+
* may write or run, which only `obk permission approval` may change, not an
|
|
54
|
+
* extra argument (ADR 0051, ADR 0049). Codex's short flags take their value
|
|
55
|
+
* glued on too. Pi's `--approve` and `-a` make it trust the folder's own
|
|
56
|
+
* files, which the kit's `--no-approve` keeps it from (#578).
|
|
50
57
|
*/
|
|
51
58
|
const APPROVAL_FLAGS = {
|
|
52
|
-
claude: [
|
|
53
|
-
|
|
59
|
+
claude: [
|
|
60
|
+
'--permission-mode', '--dangerously-skip-permissions', '--allow-dangerously-skip-permissions', '--allowedTools', '--allowed-tools',
|
|
61
|
+
'--add-dir', '--settings', '--setting-sources', '--plugin-dir', '--plugin-url', '--mcp-config', '--agents', '--agent', '--debug-file', '--bare', '--restricted',
|
|
62
|
+
],
|
|
63
|
+
codex: [
|
|
64
|
+
'-a', '--ask-for-approval', '--approve-for-me', '--dangerously-bypass-approvals-and-sandbox', '-s', '--sandbox',
|
|
65
|
+
'--add-dir', '-C', '--cd', '-p', '--profile', '--enable', '--disable', '--remote', '--remote-auth-token-env',
|
|
66
|
+
],
|
|
67
|
+
pi: ['--approve', '-a'],
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The Codex `-c` keys an extra argument may set, with any value or with only
|
|
72
|
+
* the values named. Every other key is refused: Codex 0.163.0's config has
|
|
73
|
+
* dozens that widen a session, and more in each release (ADR 0049).
|
|
74
|
+
*/
|
|
75
|
+
const CONFIG_KEYS = {
|
|
76
|
+
model: true,
|
|
77
|
+
model_reasoning_effort: true,
|
|
78
|
+
model_reasoning_summary: true,
|
|
79
|
+
model_verbosity: true,
|
|
80
|
+
model_context_window: true,
|
|
81
|
+
model_auto_compact_token_limit: true,
|
|
82
|
+
plan_mode_reasoning_effort: true,
|
|
83
|
+
service_tier: true,
|
|
84
|
+
hide_agent_reasoning: true,
|
|
85
|
+
show_raw_agent_reasoning: true,
|
|
86
|
+
tool_output_token_limit: true,
|
|
87
|
+
'tui.animations': true,
|
|
88
|
+
'tui.show_tooltips': true,
|
|
89
|
+
'tui.notifications': true,
|
|
90
|
+
'tui.notification_method': true,
|
|
91
|
+
'tui.notification_condition': true,
|
|
92
|
+
'tui.alternate_screen': true,
|
|
93
|
+
'tui.theme': true,
|
|
94
|
+
'tui.vim_mode_default': true,
|
|
95
|
+
'tui.status_line': true,
|
|
96
|
+
'tui.terminal_title': true,
|
|
97
|
+
'tui.raw_output_mode': true,
|
|
98
|
+
'tui.disable_paste_burst': true,
|
|
99
|
+
'tui.fullscreen_transcript': true,
|
|
100
|
+
'tui.mouse_scroll_speed': true,
|
|
101
|
+
'tui.show_server_version_notice': true,
|
|
102
|
+
// The network the kit turns on, which a user may turn off (NETWORK_ACCESS).
|
|
103
|
+
'sandbox_workspace_write.network_access': ['true', 'false'],
|
|
104
|
+
// These only narrow: `false` is Codex's default.
|
|
105
|
+
'sandbox_workspace_write.exclude_tmpdir_env_var': ['true', 'false'],
|
|
106
|
+
'sandbox_workspace_write.exclude_slash_tmp': ['true', 'false'],
|
|
107
|
+
'features.sleep_tool': ['false'],
|
|
108
|
+
// No update check at start, so no update offer (#581).
|
|
109
|
+
check_for_update_on_startup: ['false'],
|
|
54
110
|
};
|
|
55
111
|
|
|
112
|
+
/**
|
|
113
|
+
* The one trust a `-c` may give: one folder, an absolute path in a TOML basic
|
|
114
|
+
* string, trusted. It is what the kit's own answer to Codex's trust screen
|
|
115
|
+
* gives, for that run only and with nothing written to config.toml (ADR 0049).
|
|
116
|
+
*/
|
|
117
|
+
const TRUST = /^\{[ \t]*"([^"\\\x00-\x1f\x7f]*)"[ \t]*=[ \t]*\{[ \t]*trust_level[ \t]*=[ \t]*"trusted"[ \t]*\}[ \t]*\}$/;
|
|
118
|
+
|
|
119
|
+
/** Whether a Codex `-c` value, `key=value`, sets only what an extra argument may. */
|
|
120
|
+
function allowedConfig(setting) {
|
|
121
|
+
const at = setting.indexOf('=');
|
|
122
|
+
if (at === -1) return false;
|
|
123
|
+
const key = setting.slice(0, at).trim();
|
|
124
|
+
const value = setting.slice(at + 1).trim();
|
|
125
|
+
if (key === 'projects') {
|
|
126
|
+
const folder = TRUST.exec(value)?.[1];
|
|
127
|
+
return folder !== undefined && folder.startsWith('/') && !folder.split('/').includes('..');
|
|
128
|
+
}
|
|
129
|
+
if (!/^[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*$/.test(key) || !Object.hasOwn(CONFIG_KEYS, key)) return false;
|
|
130
|
+
return CONFIG_KEYS[key] === true || CONFIG_KEYS[key].includes(value);
|
|
131
|
+
}
|
|
132
|
+
|
|
56
133
|
/**
|
|
57
134
|
* Refuse extra arguments that set a session's approval or permissions on
|
|
58
|
-
* `harness`,
|
|
135
|
+
* `harness`, or widen what it may write or run, before anything is written,
|
|
136
|
+
* naming the command that does it (ADR 0051, ADR 0049).
|
|
137
|
+
* `args` is a list, or, as a user may write `extra_args` by hand, one string
|
|
138
|
+
* of shell text. The kit types such a string into the tab's shell as it
|
|
139
|
+
* stands, so the guard takes it only when it can be sure of the words the
|
|
140
|
+
* shell makes of it, and refuses it otherwise (#565).
|
|
59
141
|
*/
|
|
60
142
|
export function refuseApprovalArgs(harness, args) {
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
143
|
+
const words = Array.isArray(args) ? args.map(String) : set(args) ? sureWords(String(args)) : [];
|
|
144
|
+
if (words === undefined) {
|
|
145
|
+
throw new Error(`extra_args is written as one string, ${quoted(String(args))}, with shell syntax the kit cannot be sure of the words of, so it cannot tell whether it sets the session's approval or permissions. Write extra_args as a list, one --extra-arg for each argument. Nothing was changed.`);
|
|
146
|
+
}
|
|
147
|
+
for (let at = 0; at < words.length; at += 1) {
|
|
148
|
+
const arg = words[at];
|
|
149
|
+
if ((APPROVAL_FLAGS[harness] ?? []).some((flag) => arg === flag
|
|
150
|
+
|| arg.startsWith(`${flag}=`)
|
|
151
|
+
|| (/^-[asCp]$/.test(flag) && arg.startsWith(flag) && !arg.startsWith('--')))) {
|
|
152
|
+
throw new Error(`--extra-arg=${arg} sets the session's approval or permissions on ${harness}, or widens what it may write or run, and only obk permission approval changes those, after the user's yes. Nothing was changed.`);
|
|
153
|
+
}
|
|
154
|
+
// Codex's config override: the value is the next word, or after `=`, or glued on.
|
|
155
|
+
const setting = harness !== 'codex' ? undefined
|
|
156
|
+
: arg === '-c' || arg === '--config' ? (at += 1, words[at] ?? '')
|
|
157
|
+
: arg.startsWith('--config=') ? arg.slice('--config='.length)
|
|
158
|
+
: arg.startsWith('-c') && !arg.startsWith('--') ? arg.slice(2).replace(/^=/, '')
|
|
159
|
+
: undefined;
|
|
160
|
+
if (setting !== undefined && !allowedConfig(setting)) {
|
|
161
|
+
throw new Error(`--extra-arg=-c ${setting} sets a Codex setting that an extra argument may not set: the kit takes -c only for the model, the display, the network switch and a few settings that narrow (ADR 0049). Set the others in your own ~/.codex/config.toml; the session's approval and sandbox change only through obk permission approval, after the user's yes. Nothing was changed.`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The words of the shell text `text`, when the kit can be sure they are the
|
|
168
|
+
* ones the tab's shell makes of it, and undefined when it cannot (#565). Sure
|
|
169
|
+
* means plain words of the characters `quoted` leaves bare, separated by
|
|
170
|
+
* spaces, with no unquoted `=` before any other character of its word, empty
|
|
171
|
+
* quotes included (zsh's equals expansion makes `''=sh` into /bin/sh); single quotes with no
|
|
172
|
+
* backslash (fish reads `\'` and `\\` there) and no control character; and
|
|
173
|
+
* double quotes with none of `$`, backquote, backslash or `!`, and no control
|
|
174
|
+
* character. Read so, it is the same words in zsh, bash and fish. Everything
|
|
175
|
+
* else, a newline or a tab included, is shell syntax this does not read.
|
|
176
|
+
*/
|
|
177
|
+
function sureWords(text) {
|
|
178
|
+
const words = [];
|
|
179
|
+
let word;
|
|
180
|
+
let quote;
|
|
181
|
+
for (const char of text) {
|
|
182
|
+
if (/[\x00-\x1f\x7f]/.test(char)) return undefined;
|
|
183
|
+
if (quote === "'") {
|
|
184
|
+
if (char === '\\') return undefined;
|
|
185
|
+
if (char === "'") quote = undefined;
|
|
186
|
+
else word += char;
|
|
187
|
+
} else if (quote === '"') {
|
|
188
|
+
if ('$`\\!'.includes(char)) return undefined;
|
|
189
|
+
if (char === '"') quote = undefined;
|
|
190
|
+
else word += char;
|
|
191
|
+
} else if (char === ' ') {
|
|
192
|
+
if (word !== undefined) words.push(word);
|
|
193
|
+
word = undefined;
|
|
194
|
+
} else if (char === "'" || char === '"') {
|
|
195
|
+
word ??= '';
|
|
196
|
+
quote = char;
|
|
197
|
+
} else if (/^[A-Za-z0-9,._+:@%/=-]$/.test(char) && !(char === '=' && !word)) {
|
|
198
|
+
word = (word ?? '') + char;
|
|
199
|
+
} else {
|
|
200
|
+
return undefined;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
if (quote !== undefined) return undefined;
|
|
204
|
+
if (word !== undefined) words.push(word);
|
|
205
|
+
return words;
|
|
66
206
|
}
|
|
67
207
|
|
|
68
208
|
/** The level a session that names none runs at (ADR 0015). */
|
|
69
209
|
export const DEFAULT_APPROVAL = 'auto';
|
|
70
210
|
|
|
211
|
+
/** The level a session on `harness` is written with when it names none: Pi has only one (#547). */
|
|
212
|
+
export const defaultApproval = (harness) => (harness === 'pi' ? 'dangerously-skip' : DEFAULT_APPROVAL);
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Why a level is refused on Pi, or undefined when it is taken there. The
|
|
216
|
+
* owner's decision (#547): every Pi session counts as dangerously-skip.
|
|
217
|
+
*/
|
|
218
|
+
export function piApprovalTrouble(level, who = 'this session') {
|
|
219
|
+
if (!set(level) || level === 'dangerously-skip') return undefined;
|
|
220
|
+
return `${who} runs on Pi and is set to approval ${level}. Pi has no approval prompt and no permission rules, so every Pi session runs as dangerously-skip (the owner's decision, #547); auto and ask are refused on Pi. Set it to dangerously-skip, or run the session on claude or codex.`;
|
|
221
|
+
}
|
|
222
|
+
|
|
71
223
|
/** A setting the user left out. An empty string is one too: it means the harness's own. */
|
|
72
|
-
const set = (value) => value !== undefined && value !== null && String(value).trim() !== '';
|
|
224
|
+
export const set = (value) => value !== undefined && value !== null && String(value).trim() !== '';
|
|
73
225
|
|
|
74
226
|
/** Which harness a session runs on: its own, or the bot's. */
|
|
75
227
|
export const harnessOf = (session, botHarness) => (set(session.harness) ? session.harness : botHarness);
|
|
@@ -201,6 +353,22 @@ export function sessionTrouble(session, harness, home) {
|
|
|
201
353
|
if (set(session.approval) && !APPROVALS.includes(session.approval)) {
|
|
202
354
|
return `${where(session)} is set to approval ${session.approval}, and the levels are ${APPROVALS.join(', ')}.`;
|
|
203
355
|
}
|
|
356
|
+
if (harness === 'pi') {
|
|
357
|
+
const trouble = piApprovalTrouble(session.approval, where(session));
|
|
358
|
+
if (trouble !== undefined) return trouble;
|
|
359
|
+
// Pi has no context setting: a model's own name carries it, as in
|
|
360
|
+
// cursor/claude-opus-5-5@1m (tech notes, section 6).
|
|
361
|
+
if (set(session.context)) {
|
|
362
|
+
return `${where(session)} sets a context of ${session.context} on Pi, and Pi has no context setting: the model's name carries it, as in cursor/claude-opus-5-5@1m. Name such a model, or leave the context out.`;
|
|
363
|
+
}
|
|
364
|
+
// Pi 1.1.0 reads a word after `--` that starts with `@` as a file to
|
|
365
|
+
// attach, and has no escape for it, so such a prompt cannot arrive as
|
|
366
|
+
// written (tech notes, section 6). It is checked trimmed, as it is sent.
|
|
367
|
+
const said = set(session.prompt_file) ? readFileSync(promptFile(session, home), 'utf8') : session.prompt;
|
|
368
|
+
if (set(said) && String(said).trim().startsWith('@')) {
|
|
369
|
+
return `${where(session)} runs on Pi, and its prompt begins with @. Pi would read the prompt as a file to attach, not as text. Give it a prompt that does not begin with @.`;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
204
372
|
// Codex is given the context window as a number of tokens. `1m` is what a
|
|
205
373
|
// Claude session would say, and copying it here does not fail at the kit but
|
|
206
374
|
// in Codex, which exits with a type error the moment it starts.
|
|
@@ -231,16 +399,21 @@ const where = (session) => (set(session.name) ? `session ${session.name}` : 'thi
|
|
|
231
399
|
* send would land on that list and answer it. A prompt given as an argument is
|
|
232
400
|
* held by the harness until it is ready for it (tech notes, section 1).
|
|
233
401
|
*/
|
|
234
|
-
export function launchCommand(session, { harness, home, workDir, prompt, promptFile: fromFile, resume, address, kit }) {
|
|
402
|
+
export function launchCommand(session, { harness, home, workDir, prompt, promptFile: fromFile, resume, address, kit, sessionId, name }) {
|
|
235
403
|
const trouble = sessionTrouble(session, harness, home);
|
|
236
404
|
if (trouble !== undefined) throw new Error(trouble);
|
|
405
|
+
// Pi is told which conversation it is, a new one too: the kit names it, and
|
|
406
|
+
// that is how the book knows it (tech notes, section 6).
|
|
407
|
+
if (harness === 'pi' && resume === undefined && sessionId === undefined) {
|
|
408
|
+
throw new Error(`${where(session)} runs on Pi, and a Pi session is started with the id the book gives it, so the kit names one before it launches. None was given.`);
|
|
409
|
+
}
|
|
237
410
|
|
|
238
411
|
const words = [harness];
|
|
239
412
|
// Codex resumes through a subcommand, which has to come first; Claude Code
|
|
240
413
|
// resumes through a flag, which goes with the session id at the end. Both
|
|
241
414
|
// take every other flag exactly as a fresh session does.
|
|
242
415
|
if (resume !== undefined && harness === 'codex') words.push('resume');
|
|
243
|
-
words.push(...APPROVAL[harness][set(session.approval) ? session.approval :
|
|
416
|
+
words.push(...APPROVAL[harness][set(session.approval) ? session.approval : defaultApproval(harness)]);
|
|
244
417
|
// Codex 0.157 shares one background server between sessions by default, and
|
|
245
418
|
// a resume through it was seen to fail; without it Codex runs as 0.156 did
|
|
246
419
|
// (tech notes, section 3, #330). Codex refuses the flag twice, so the user's
|
|
@@ -254,6 +427,16 @@ export function launchCommand(session, { harness, home, workDir, prompt, promptF
|
|
|
254
427
|
// The context window rides on the model name: `sonnet[1m]`.
|
|
255
428
|
if (set(session.model)) words.push('--model', set(session.context) ? `${session.model}[${session.context}]` : session.model);
|
|
256
429
|
if (set(session.effort)) words.push('--effort', String(session.effort));
|
|
430
|
+
} else if (harness === 'pi') {
|
|
431
|
+
// Pi trusts none of the folder's own files (`.pi/`, `.agents/skills`), so it
|
|
432
|
+
// shows no trust screen and runs nothing the kit has not looked at (#578).
|
|
433
|
+
words.push('--no-approve');
|
|
434
|
+
// The id the book holds, resumed or new: Pi opens the conversation with it,
|
|
435
|
+
// and makes it when there is none (tech notes, section 6).
|
|
436
|
+
words.push('--session-id', resume ?? sessionId);
|
|
437
|
+
if (name !== undefined) words.push('--name', name);
|
|
438
|
+
if (set(session.model)) words.push('--model', session.model);
|
|
439
|
+
if (set(session.effort)) words.push('--thinking', String(session.effort));
|
|
257
440
|
} else {
|
|
258
441
|
// Without this the Orca CLI cannot reach Orca from inside the sandbox, so
|
|
259
442
|
// the session is in no fleet at all. The user's own setting of it wins.
|
|
@@ -313,7 +496,7 @@ export function startPrompt(session, { home, workDir }) {
|
|
|
313
496
|
}
|
|
314
497
|
|
|
315
498
|
/** Where a session's prompt file is: in the bot home, where the user put it. */
|
|
316
|
-
export const promptFile = (session, home) => path.resolve(home, session.prompt_file);
|
|
499
|
+
export const promptFile = (session, home) => path.resolve(home, String(session.prompt_file));
|
|
317
500
|
|
|
318
501
|
/**
|
|
319
502
|
* A prompt short and simple enough to type into the launch line as it is: one
|
|
@@ -336,10 +519,11 @@ function promptWords(prompt, fromFile) {
|
|
|
336
519
|
|
|
337
520
|
/**
|
|
338
521
|
* The session the harness is told to pick up again, when there is one: a flag
|
|
339
|
-
* on Claude Code, the argument of the `resume` subcommand on Codex.
|
|
522
|
+
* on Claude Code, the argument of the `resume` subcommand on Codex. Pi's is
|
|
523
|
+
* its `--session-id`, which goes with its other flags.
|
|
340
524
|
*/
|
|
341
525
|
const resumeWords = (harness, resume) => {
|
|
342
|
-
if (resume === undefined) return [];
|
|
526
|
+
if (resume === undefined || harness === 'pi') return [];
|
|
343
527
|
return harness === 'claude' ? ['--resume', quoted(resume)] : [quoted(resume)];
|
|
344
528
|
};
|
|
345
529
|
|