@assuredloop/orca-bot-kit 0.31.0 → 0.33.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 +19 -14
- package/SETUP.md +10 -8
- package/package.json +1 -1
- package/skills/obk-grooming/SKILL.md +3 -2
- package/src/bot.js +1 -1
- package/src/clear.js +106 -37
- package/src/cli.js +19 -8
- package/src/conversations.js +1 -1
- package/src/health.js +72 -10
- package/src/init.js +2 -1
- package/src/launch.js +23 -0
- package/src/orca.js +92 -6
- package/src/pi-record.js +96 -0
- package/src/pi.js +55 -17
- package/src/rules.js +9 -1
- package/src/settings.js +109 -19
- package/src/skills.js +22 -9
- package/src/temp.js +69 -10
package/src/health.js
CHANGED
|
@@ -23,11 +23,12 @@ import path from 'node:path';
|
|
|
23
23
|
|
|
24
24
|
import { bookFile, readBook, sessionIdsIn, tabIdsIn } from './book.js';
|
|
25
25
|
import { botDir, botNames, botsDir, readBot, tempRoles, unknownKeys } from './bot.js';
|
|
26
|
-
import { transcriptsIn } from './conversations.js';
|
|
26
|
+
import { hasConversation, transcriptsIn } from './conversations.js';
|
|
27
27
|
import { hookTrouble } from './hooks.js';
|
|
28
28
|
import { APPROVALS, bypassFlags, harnessOf, HARNESSES, isAddressOf, ownCli, promptFile, sessionTrouble, set, SHELL_ENV, shellWord, workDirOf } from './launch.js';
|
|
29
29
|
import { frontOfTab, mailIn, orcaDefaultArgs, projects, runMissing, tabs, tabToTypeInto, wordsOfProcess } from './orca.js';
|
|
30
30
|
import { permissionsTrouble } from './permissions.js';
|
|
31
|
+
import { askPi } from './pi.js';
|
|
31
32
|
import { processesIn } from './processes.js';
|
|
32
33
|
import { TAB_ENV } from './record.js';
|
|
33
34
|
import { agentsTrouble, rulesStamp } from './rules.js';
|
|
@@ -51,7 +52,9 @@ export function checkHealth(bots, { bot: onlyBot } = {}) {
|
|
|
51
52
|
|
|
52
53
|
const found = [...orcaSettingFindings(), ...leftInOrca(bots, setups), ...leftBeside(bots)];
|
|
53
54
|
const sessions = [];
|
|
54
|
-
|
|
55
|
+
// Pi is asked only when a bot has a Pi session, and each question once (#660).
|
|
56
|
+
const pi = askPi();
|
|
57
|
+
for (const name of names) found.push(...aboutBot(bots, name, setups, sessions, pi));
|
|
55
58
|
return { found, sessions };
|
|
56
59
|
}
|
|
57
60
|
|
|
@@ -185,7 +188,7 @@ function leftBeside(bots) {
|
|
|
185
188
|
}
|
|
186
189
|
|
|
187
190
|
/** Everything wrong with one bot. Its running sessions are added to `sessions`. */
|
|
188
|
-
function aboutBot(bots, name, setups, sessions) {
|
|
191
|
+
function aboutBot(bots, name, setups, sessions, pi) {
|
|
189
192
|
const home = botDir(bots, name);
|
|
190
193
|
|
|
191
194
|
let bot;
|
|
@@ -206,6 +209,7 @@ function aboutBot(bots, name, setups, sessions) {
|
|
|
206
209
|
...agentsTrouble(bots, home, bot).map(said('config')),
|
|
207
210
|
...hooksOf(bots, home, bot).map(said('config')),
|
|
208
211
|
...permissionsTrouble(home, bot).map(said('config')),
|
|
212
|
+
...piReadiness(home, bot, pi).map(said('config')),
|
|
209
213
|
...skillsTrouble(bots, home, bot).map(said('skill')),
|
|
210
214
|
...inOrca(bots, home, bot, setups, sessions),
|
|
211
215
|
...leftRunning(home, bot),
|
|
@@ -261,6 +265,40 @@ function sessionSettings(home, bot) {
|
|
|
261
265
|
});
|
|
262
266
|
}
|
|
263
267
|
|
|
268
|
+
/**
|
|
269
|
+
* Why Pi cannot run this bot's Pi sessions, paused ones too, in the words
|
|
270
|
+
* `up` would refuse them with (#660): Pi does not run, can use no model, or
|
|
271
|
+
* cannot use the model a session names. When Pi cannot list its models at
|
|
272
|
+
* all, that is said instead, and no model is judged. Pi is asked read only,
|
|
273
|
+
* through the answers `pi` keeps for the whole run, and never about its
|
|
274
|
+
* credentials.
|
|
275
|
+
*/
|
|
276
|
+
function piReadiness(home, bot, pi) {
|
|
277
|
+
const onPi = bot.sessions.filter((session) => harnessOf(session, bot.harness) === 'pi');
|
|
278
|
+
if (onPi.length === 0) return [];
|
|
279
|
+
const where = path.join(home, 'bot.yaml');
|
|
280
|
+
const names = onPi.map((session) => session.name).join(', ');
|
|
281
|
+
const runs = pi.runs();
|
|
282
|
+
if (runs !== undefined) {
|
|
283
|
+
return [{ where, says: `Pi does not run here (${runs}), and ${bot.name}'s sessions ${names} run on Pi. obk up will not start them until it does. Install Pi.` }];
|
|
284
|
+
}
|
|
285
|
+
const models = pi.models();
|
|
286
|
+
if (models.failed !== undefined) {
|
|
287
|
+
return [{ where, says: `Pi could not list its models: pi --list-models failed (${models.failed}), and ${bot.name}'s sessions ${names} run on Pi. The kit cannot say whether Pi can use their models. Run that command yourself to see why.` }];
|
|
288
|
+
}
|
|
289
|
+
if (!models.any) {
|
|
290
|
+
return [{ where, says: `Pi has no model it can use: it is signed in to no provider, and ${bot.name}'s sessions ${names} run on Pi. obk up will not start them until it is. Sign in to Pi yourself (start pi and use /login, or set a provider's API key). The kit never handles Pi's credentials.` }];
|
|
291
|
+
}
|
|
292
|
+
return onPi.filter((session) => set(session.model)).flatMap((session) => {
|
|
293
|
+
const model = String(session.model).trim();
|
|
294
|
+
const found = pi.model(model);
|
|
295
|
+
if (found.failed !== undefined) {
|
|
296
|
+
return [{ where, says: `${bot.name}'s session ${session.name} runs on Pi with the model ${model}, and the kit cannot say whether Pi can use it: pi --list-models ${found.search} failed (${found.failed}). Run that command yourself to see why.` }];
|
|
297
|
+
}
|
|
298
|
+
return found.listed ? [] : [{ where, says: `${bot.name}'s session ${session.name} runs on Pi with the model ${model}, and Pi cannot use it: pi --list-models lists none matching it. obk up will not start the session. Sign in to its provider in Pi yourself, or give the session a model Pi lists.` }];
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
264
302
|
/** What is wrong with the roles the bot's temporary sessions are made in (#465). */
|
|
265
303
|
function rolesTrouble(home, bot) {
|
|
266
304
|
const where = path.join(home, 'bot.yaml');
|
|
@@ -471,7 +509,7 @@ function inOrca(bots, home, bot, setups, sessions) {
|
|
|
471
509
|
}
|
|
472
510
|
}
|
|
473
511
|
|
|
474
|
-
found.push(...offTheBook(real ?? home, bot, book, onRecord));
|
|
512
|
+
found.push(...offTheBook(bots, real ?? home, bot, book, onRecord));
|
|
475
513
|
|
|
476
514
|
// A tab outside the book is somebody else's, and in Bot Father's project it
|
|
477
515
|
// is the ops tab: the one tab the kit deliberately keeps no record of, so it
|
|
@@ -555,9 +593,11 @@ function unannounced(bots, home, bot, name, entry) {
|
|
|
555
593
|
* The conversations a harness has on record in this bot's folder that the book
|
|
556
594
|
* does not name anywhere. The hook is how an id reaches the book, and when it
|
|
557
595
|
* fails it fails quietly (ADR 0022), so this is where a stale book shows: the
|
|
558
|
-
* harness's own record set beside it (ADR 0012).
|
|
596
|
+
* harness's own record set beside it (ADR 0012). Pi has no hook of the kit's,
|
|
597
|
+
* so for Pi it also names a session whose conversation in the book Pi has no
|
|
598
|
+
* record of (#660).
|
|
559
599
|
*/
|
|
560
|
-
function offTheBook(home, bot, book, onRecord) {
|
|
600
|
+
function offTheBook(bots, home, bot, book, onRecord) {
|
|
561
601
|
// A retired session's conversations are in the book too, under `retired`.
|
|
562
602
|
const named = sessionIdsIn(book);
|
|
563
603
|
for (const entry of Object.values(book.sessions)) {
|
|
@@ -566,10 +606,32 @@ function offTheBook(home, bot, book, onRecord) {
|
|
|
566
606
|
|
|
567
607
|
// A subagent's conversation is no session's, so the book has no reason to name it.
|
|
568
608
|
const stray = onRecord.filter((one) => !one.subagent && !named.has(one.id));
|
|
569
|
-
|
|
609
|
+
const listed = (some) => some.map((one) => `${one.id} (begun ${one.at})`).join(', ');
|
|
610
|
+
const give = `To give one to a session, write it into ${bookFile(home)} under that session as session: <id> and run obk up again.`;
|
|
611
|
+
const found = [];
|
|
612
|
+
|
|
613
|
+
const hooked = stray.filter((one) => one.harness !== 'pi');
|
|
614
|
+
if (hooked.length > 0) {
|
|
615
|
+
found.push(finding('session', bookFile(home), `The harness has conversations on record in ${bot.name}'s folder that the book does not name: ${listed(hooked)}. The kit's hook may have missed them: a clear it did not record, or a Codex hooks file trusted late. ${give}`, bot.name));
|
|
616
|
+
}
|
|
570
617
|
|
|
571
|
-
|
|
572
|
-
|
|
618
|
+
// The kit has no hook in Pi: it names a Pi session's conversation on the
|
|
619
|
+
// launch line, so one the book does not name was begun inside Pi (#660).
|
|
620
|
+
const onPi = stray.filter((one) => one.harness === 'pi');
|
|
621
|
+
if (onPi.length === 0) return found;
|
|
622
|
+
found.push(finding('session', bookFile(home), `Pi has conversations on record in ${bot.name}'s folder that the book does not name: ${listed(onPi)}. Pi begins one when /new is typed in its tab, and the kit's Pi extension tells the book of it, so these were missed: the tab ran without the extension (a tab Orca brought back by itself, or one the kit started before it had the extension), or the extension's record went wrong. ${give}`, bot.name));
|
|
623
|
+
|
|
624
|
+
// A Pi session whose conversation in the book Pi has no record of, while Pi
|
|
625
|
+
// has one the book does not name: it is in another conversation than the
|
|
626
|
+
// book holds, and its next start would begin the book's one afresh. A book
|
|
627
|
+
// id with no record and no such conversation is a session before its first
|
|
628
|
+
// turn, which Pi has written nothing for yet, and is not said.
|
|
629
|
+
for (const session of bot.sessions) {
|
|
630
|
+
const id = book.sessions[session.name]?.session;
|
|
631
|
+
if (harnessOf(session, bot.harness) !== 'pi' || bot.paused === true || session.paused === true || typeof id !== 'string' || hasConversation('pi', home, id)) continue;
|
|
632
|
+
found.push(finding('session', bookFile(home), `${bot.name}'s session ${session.name} has the conversation ${id} in the book, and Pi has no record of it in this bot's folder, while Pi has conversations there that the book does not name. So it is likely in one of those, and its next start would begin ${id} afresh rather than go on with it. Write the one it is in into ${bookFile(home)} under ${session.name} as session: <id>, then ${shellWord(ownCli())} restart --bots ${shellWord(bots)} --bot ${bot.name} --session ${session.name}`, bot.name));
|
|
633
|
+
}
|
|
634
|
+
return found;
|
|
573
635
|
}
|
|
574
636
|
|
|
575
637
|
/** 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. */
|
|
@@ -580,7 +642,7 @@ function recordIn(home, bot, book) {
|
|
|
580
642
|
if (Array.isArray(book.retired) && book.retired.length > 0) for (const harness of HARNESSES) harnesses.add(harness);
|
|
581
643
|
return [...harnesses]
|
|
582
644
|
.filter((harness) => HARNESSES.includes(harness))
|
|
583
|
-
.flatMap((harness) => transcriptsIn(harness, home));
|
|
645
|
+
.flatMap((harness) => transcriptsIn(harness, home).map((one) => ({ ...one, harness })));
|
|
584
646
|
}
|
|
585
647
|
|
|
586
648
|
/** What a directory holds, and nothing at all when there is no directory. */
|
package/src/init.js
CHANGED
|
@@ -97,7 +97,8 @@ charter: |
|
|
|
97
97
|
names in paths, tokens, what a charter says. Put in the command that was run
|
|
98
98
|
and its whole output, what you expected instead, and what these print:
|
|
99
99
|
obk --version, obk health --bots <folder> --json, node --version, the Orca
|
|
100
|
-
version, and claude --version or
|
|
100
|
+
version, and claude --version, codex --version or pi --version; and the
|
|
101
|
+
operating system.
|
|
101
102
|
One problem per issue. If you cannot file it yourself, give your owner the
|
|
102
103
|
text to paste.
|
|
103
104
|
rules: []
|
package/src/launch.js
CHANGED
|
@@ -226,6 +226,19 @@ export const set = (value) => value !== undefined && value !== null && String(va
|
|
|
226
226
|
/** Which harness a session runs on: its own, or the bot's. */
|
|
227
227
|
export const harnessOf = (session, botHarness) => (set(session.harness) ? session.harness : botHarness);
|
|
228
228
|
|
|
229
|
+
/** Whether a bot runs on Pi: as its own harness, or in one of its sessions. */
|
|
230
|
+
export const runsOnPi = (bot) => bot.harness === 'pi'
|
|
231
|
+
|| bot.sessions.some((session) => harnessOf(session, bot.harness) === 'pi');
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* The folder a Pi session loads its bot's skills from: the bot's own
|
|
235
|
+
* `.agents/skills`, which the kit links every bot's skills into for Codex
|
|
236
|
+
* (`SKILL_DIRS` in skills.js). `--no-approve` keeps Pi from the folder's own
|
|
237
|
+
* skills, and a folder named with `--skill` loads whatever the trust (tech
|
|
238
|
+
* notes, section 6, #659).
|
|
239
|
+
*/
|
|
240
|
+
export const piSkillsDir = (home) => path.join(home, '.agents', 'skills');
|
|
241
|
+
|
|
229
242
|
/**
|
|
230
243
|
* A session's name on Claude Code, which is also the address another Claude
|
|
231
244
|
* session writes to (ADR 0042). It goes on the launch line as `-n`, and it is
|
|
@@ -431,6 +444,13 @@ export function launchCommand(session, { harness, home, workDir, prompt, promptF
|
|
|
431
444
|
// Pi trusts none of the folder's own files (`.pi/`, `.agents/skills`), so it
|
|
432
445
|
// shows no trust screen and runs nothing the kit has not looked at (#578).
|
|
433
446
|
words.push('--no-approve');
|
|
447
|
+
// So it is given the bot's skills by name, from the bot folder and nowhere
|
|
448
|
+
// every Pi session reads (#659).
|
|
449
|
+
words.push('--skill', piSkillsDir(home));
|
|
450
|
+
// The kit's own extension, from this kit's own folder: Pi has no hooks, so
|
|
451
|
+
// this is how the book hears of a `/new` in the tab (#662). Pi loads a file
|
|
452
|
+
// named here whatever the trust.
|
|
453
|
+
words.push('--extension', PI_EXTENSION);
|
|
434
454
|
// The id the book holds, resumed or new: Pi opens the conversation with it,
|
|
435
455
|
// and makes it when there is none (tech notes, section 6).
|
|
436
456
|
words.push('--session-id', resume ?? sessionId);
|
|
@@ -576,6 +596,9 @@ export const shellWord = quoted;
|
|
|
576
596
|
export const mailboxStep = (bots, bot, session) =>
|
|
577
597
|
[ownCli(), 'session', 'mailbox', '--bots', bots, '--bot', bot, '--session', session].map(quoted).join(' ');
|
|
578
598
|
|
|
599
|
+
/** The kit's Pi extension, beside this file (src/pi-record.js). */
|
|
600
|
+
const PI_EXTENSION = fileURLToPath(new URL('./pi-record.js', import.meta.url));
|
|
601
|
+
|
|
579
602
|
/**
|
|
580
603
|
* The CLI that is running, by the path it was started by: the kit calls itself
|
|
581
604
|
* back with this, never with the bare name `obk`, because the bare name is
|
package/src/orca.js
CHANGED
|
@@ -484,7 +484,7 @@ export function harnessInTab(handle, timeoutMs, readMs) {
|
|
|
484
484
|
blockedReason: answer.result?.wait?.blockedReason,
|
|
485
485
|
agent,
|
|
486
486
|
...frontOf(handle, shown?.ptyId, readMs),
|
|
487
|
-
...screenOf(handle, readMs),
|
|
487
|
+
...screenOf(handle, readMs, agent),
|
|
488
488
|
};
|
|
489
489
|
}
|
|
490
490
|
|
|
@@ -502,14 +502,15 @@ export const QUESTION_ON_SCREEN = 'question-on-screen';
|
|
|
502
502
|
|
|
503
503
|
/**
|
|
504
504
|
* What the tab `handle` renders: `{ question }`, true when it shows a question
|
|
505
|
-
* of its harness's own,
|
|
505
|
+
* of its harness's own, `agent` the one Orca names there, with the `rows` it
|
|
506
|
+
* read, or `{ screenUnreadable: <why> }`. Orca refusing is an
|
|
506
507
|
* answer that cannot be read, not an error, and so is anything that is not the
|
|
507
508
|
* rendered screen: accumulated output comes back in fragments (tech notes,
|
|
508
509
|
* section 1).
|
|
509
510
|
*/
|
|
510
|
-
function screenOf(handle, readMs) {
|
|
511
|
+
function screenOf(handle, readMs, agent) {
|
|
511
512
|
const read = screenRows(handle, readMs);
|
|
512
|
-
return read.rows === undefined ? { screenUnreadable: read.unreadable } : { question: questionIn(read.rows), rows: read.rows, draft: read.draft };
|
|
513
|
+
return read.rows === undefined ? { screenUnreadable: read.unreadable } : { question: questionIn(read.rows, agent), rows: read.rows, draft: read.draft };
|
|
513
514
|
}
|
|
514
515
|
|
|
515
516
|
/**
|
|
@@ -539,6 +540,83 @@ const POINTER_ROW = /^ *[›❯]/;
|
|
|
539
540
|
/** How a harness's form says it is answered, as Claude Code 2.1.283 draws its foot (#416). */
|
|
540
541
|
const FORM_FOOT = /Enter to (?:continue|confirm|select)\b.*Esc to cancel/i;
|
|
541
542
|
|
|
543
|
+
/**
|
|
544
|
+
* How Pi 1.3.0 ends each of its selectors and dialogs: its cancel key, by
|
|
545
|
+
* default `escape/ctrl+c` (`Escape/Ctrl+C` under /model), then what it does.
|
|
546
|
+
* Pi points with `→` and has no numbers, so neither rule above sees one, and
|
|
547
|
+
* Orca calls the tab idle under it (developer-660, live, 2026-10-19; tech
|
|
548
|
+
* notes, section 6, for the trust screen). Pi draws it from the user's
|
|
549
|
+
* keybindings, so a rebound cancel key is not seen.
|
|
550
|
+
*/
|
|
551
|
+
const PI_FOOT = /\bescape\/ctrl\+c (?:to )?(?:cancel|close|skip setup)\b/i;
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* The rules of Pi 1.3.0's input box, each across the whole width: the bottom
|
|
555
|
+
* one only `─`, or with Pi's ` ↓ <n> more ` (or ` ↑ <n> more `) while a long
|
|
556
|
+
* draft scrolls; the top one starting `──`, with Pi's status in it while it
|
|
557
|
+
* works, `── ⠋ Working ───…` (pi-tui `editor.js`, `custom-editor.js`; read in
|
|
558
|
+
* code, #663). Pi wraps its editor's text a column short of the width, so no
|
|
559
|
+
* row of a draft is as long as a rule.
|
|
560
|
+
*/
|
|
561
|
+
const PI_SCROLL = / [↑↓] \d+ more /;
|
|
562
|
+
const PI_RULE = new RegExp(`^─+(?:${PI_SCROLL.source}─*)?$`);
|
|
563
|
+
const PI_TOP_RULE = /^──.*─$/;
|
|
564
|
+
|
|
565
|
+
/** The words in a row of Pi's, once its rule characters and its scroll label are taken out. */
|
|
566
|
+
const piWords = (row) => row.replace(PI_SCROLL, '').replaceAll('─', '').trim();
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Pi's two rules on a rendered screen, `{ bottom, top }` or `{ bottom, status
|
|
570
|
+
* }` as indexes into `rows`, or `{}` with no bottom rule. The bottom rule is
|
|
571
|
+
* the lowest rule. Then, up from it, only rows with a rule's ends count, and
|
|
572
|
+
* the first that decides is taken: one as long is the top rule, and rows
|
|
573
|
+
* above it are the chat (Pi's own dividers among them). One not as long,
|
|
574
|
+
* with words of more than ASCII in it, is a status: Pi lays out by columns
|
|
575
|
+
* and the kit counts characters, so a status with wide or combining
|
|
576
|
+
* characters is not as long. An ASCII status always is, so one not as long
|
|
577
|
+
* with ASCII words is an editor row, and the walk goes on (reviews of PR
|
|
578
|
+
* #666).
|
|
579
|
+
*/
|
|
580
|
+
function piRules(rows) {
|
|
581
|
+
const bottom = rows.findLastIndex((row) => PI_RULE.test(row.trimEnd()));
|
|
582
|
+
if (bottom < 0) return {};
|
|
583
|
+
const width = [...rows[bottom].trimEnd()].length;
|
|
584
|
+
for (let at = bottom - 1; at >= 0; at -= 1) {
|
|
585
|
+
const row = rows[at].trimEnd();
|
|
586
|
+
if (!PI_TOP_RULE.test(row)) continue;
|
|
587
|
+
if ([...row].length === width) return { bottom, top: at };
|
|
588
|
+
if (/[^\x20-\x7e]/.test(piWords(row))) return { bottom, status: at };
|
|
589
|
+
}
|
|
590
|
+
return { bottom };
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Pi's input box on a rendered screen, `{ top, editor, below }`: its top rule,
|
|
595
|
+
* every row between its two rules, and the rows under the bottom one (Pi's
|
|
596
|
+
* slash menu, then its footer). Undefined when `piRules` finds no top rule.
|
|
597
|
+
*/
|
|
598
|
+
export function piBox(rows) {
|
|
599
|
+
const { bottom, top } = piRules(rows);
|
|
600
|
+
if (top === undefined) return undefined;
|
|
601
|
+
return { top: rows[top], editor: rows.slice(top + 1, bottom), below: rows.slice(bottom + 1) };
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* The row of a rendered screen that says the harness is at work, as only Pi
|
|
606
|
+
* draws it, or undefined: its input box's top rule with anything in it but
|
|
607
|
+
* the rule and the scroll label, or a status `piRules` found instead. Pi's
|
|
608
|
+
* usual status is a plain `Working`, and an extension can put its own words
|
|
609
|
+
* there, so no word is looked for; Pi's startup help says `escape to
|
|
610
|
+
* interrupt` too, and is no turn. `agent` is the harness Orca names in the
|
|
611
|
+
* tab: the row counts only where it is pi (#663).
|
|
612
|
+
*/
|
|
613
|
+
export function piAtWorkIn(rows, agent) {
|
|
614
|
+
if (agent !== 'pi') return undefined;
|
|
615
|
+
const { top, status } = piRules(rows);
|
|
616
|
+
if (status !== undefined) return rows[status];
|
|
617
|
+
return top !== undefined && piWords(rows[top]) !== '' ? rows[top] : undefined;
|
|
618
|
+
}
|
|
619
|
+
|
|
542
620
|
/** That pointer on a numbered choice, and the column its number starts in. */
|
|
543
621
|
const ON_A_CHOICE = /^( *[›❯] +)\d+\. /;
|
|
544
622
|
|
|
@@ -553,8 +631,14 @@ const ON_A_CHOICE = /^( *[›❯] +)\d+\. /;
|
|
|
553
631
|
* while it is the last thing drawn right above the input box (#491).
|
|
554
632
|
* Unnumbered lists do not count. Codex puts a status row right under its input
|
|
555
633
|
* line, lined up with it, and by its layout that is one.
|
|
634
|
+
*
|
|
635
|
+
* `agent` is the harness Orca names in the tab. For Pi, a row anywhere with
|
|
636
|
+
* Pi's own foot counts too (#659): Pi keeps no closed selector in its chat, so
|
|
637
|
+
* only text a model printed can put those words higher up, and then nothing is
|
|
638
|
+
* typed, which is the safe way to be wrong. Other harnesses never see it.
|
|
556
639
|
*/
|
|
557
|
-
export function questionIn(rows) {
|
|
640
|
+
export function questionIn(rows, agent) {
|
|
641
|
+
if (agent === 'pi' && rows.some((row) => PI_FOOT.test(row))) return true;
|
|
558
642
|
if (feedbackPanelIn(rows)) return true;
|
|
559
643
|
const at = rows.findLastIndex((row) => POINTER_ROW.test(row));
|
|
560
644
|
// A form of the harness's own has no numbers but a foot that says how to
|
|
@@ -704,7 +788,9 @@ export function tabToTypeInto(home, tabId, timeoutMs) {
|
|
|
704
788
|
if (seen.question === undefined) {
|
|
705
789
|
return { unsure: `the kit could not tell whether a question is waiting on its screen (${seen.screenUnreadable}), so nothing was typed` };
|
|
706
790
|
}
|
|
707
|
-
|
|
791
|
+
// Orca's tui-idle does not answer the same each time for a Pi tab (tech
|
|
792
|
+
// notes, section 6), so Pi's own word that it is at work counts too.
|
|
793
|
+
return { handle: live.handle, agent: seen.agent, idle: seen.answered && piAtWorkIn(seen.rows, seen.agent) === undefined, rows: seen.rows, draft: seen.draft };
|
|
708
794
|
}
|
|
709
795
|
|
|
710
796
|
/**
|
package/src/pi-record.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// The kit's Pi extension: what a Claude Code or Codex session's SessionStart
|
|
2
|
+
// hook does, for a Pi session (#662).
|
|
3
|
+
//
|
|
4
|
+
// Pi has no hooks; it has extensions. Every Pi launch line names this file with
|
|
5
|
+
// `--extension`, and Pi loads it even with `--no-approve`. At each session_start,
|
|
6
|
+
// a `/new` in the tab included, it runs the kit's own `session record`, as the
|
|
7
|
+
// hooks do (src/hooks.js), and hands Pi the start prompt when the kit answers
|
|
8
|
+
// with one. Seen live on Pi 1.3.0 (tech notes, section 6): the extension runs in
|
|
9
|
+
// Pi's own process, so the command it starts is Pi's child, as a hook is the
|
|
10
|
+
// harness's, and it has Pi's environment, the tab's id included.
|
|
11
|
+
//
|
|
12
|
+
// It runs inside the user's session, so it stays out of the way: whatever goes
|
|
13
|
+
// wrong, it goes wrong quietly, and a book left stale is for the health check
|
|
14
|
+
// to find (ADR 0022). It handles no credentials, types into no tab and reaches
|
|
15
|
+
// no network.
|
|
16
|
+
|
|
17
|
+
import { spawn } from 'node:child_process';
|
|
18
|
+
import { existsSync } from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
|
|
22
|
+
/** The CLI beside this file: the kit whose launch line named it (#220). */
|
|
23
|
+
const CLI = fileURLToPath(new URL('./cli.js', import.meta.url));
|
|
24
|
+
|
|
25
|
+
/** How long the kit is given to answer, as the hooks' TIMEOUT gives a harness. */
|
|
26
|
+
const WAIT_MS = 10_000;
|
|
27
|
+
|
|
28
|
+
/** What Pi calls the start prompt the kit hands it. */
|
|
29
|
+
const CUSTOM_TYPE = 'obk-start-prompt';
|
|
30
|
+
|
|
31
|
+
export default function piRecord(pi) {
|
|
32
|
+
pi.on('session_start', async (event, ctx) => {
|
|
33
|
+
try {
|
|
34
|
+
const bot = botAbove(ctx.cwd);
|
|
35
|
+
if (bot === undefined) return;
|
|
36
|
+
|
|
37
|
+
const said = { session_id: ctx.sessionManager.getSessionId(), source: event.reason, hook_event_name: event.type };
|
|
38
|
+
const duty = (await record(bot, said))?.hookSpecificOutput?.additionalContext;
|
|
39
|
+
// Into the conversation for its next turn, as Claude Code's
|
|
40
|
+
// additionalContext is: no turn is started for it.
|
|
41
|
+
if (typeof duty === 'string' && duty !== '') pi.sendMessage({ customType: CUSTOM_TYPE, content: duty, display: true });
|
|
42
|
+
} catch {
|
|
43
|
+
// Nothing: see above.
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The bot whose home is `cwd` or the nearest folder above it, as
|
|
50
|
+
* `{ bots, bot }`, or undefined. A bot lives at `<bots>/bots/<bot>` with its
|
|
51
|
+
* `bot.yaml` (botDir in src/bot.js). A kit tab starts at the bot home; a folder
|
|
52
|
+
* below it holds too.
|
|
53
|
+
*/
|
|
54
|
+
function botAbove(cwd) {
|
|
55
|
+
if (typeof cwd !== 'string' || cwd === '') return undefined;
|
|
56
|
+
for (let dir = path.resolve(cwd); ; dir = path.dirname(dir)) {
|
|
57
|
+
const parent = path.dirname(dir);
|
|
58
|
+
if (path.basename(parent) === 'bots' && existsSync(path.join(dir, 'bot.yaml'))) {
|
|
59
|
+
return { bots: path.dirname(parent), bot: path.basename(dir) };
|
|
60
|
+
}
|
|
61
|
+
if (parent === dir) return undefined;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The kit's answer to what Pi said, or undefined when it has none or none could be had. */
|
|
66
|
+
function record({ bots, bot }, said) {
|
|
67
|
+
return new Promise((resolve) => {
|
|
68
|
+
let child;
|
|
69
|
+
try {
|
|
70
|
+
child = spawn(CLI, ['session', 'record', '--bots', bots, '--bot', bot], {
|
|
71
|
+
stdio: ['pipe', 'pipe', 'ignore'],
|
|
72
|
+
timeout: WAIT_MS,
|
|
73
|
+
killSignal: 'SIGKILL',
|
|
74
|
+
});
|
|
75
|
+
} catch {
|
|
76
|
+
resolve(undefined);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
let out = '';
|
|
80
|
+
child.stdout.setEncoding('utf8');
|
|
81
|
+
child.stdout.on('data', (chunk) => {
|
|
82
|
+
out += chunk;
|
|
83
|
+
});
|
|
84
|
+
child.on('error', () => resolve(undefined));
|
|
85
|
+
child.on('close', (code) => {
|
|
86
|
+
if (code !== 0) return resolve(undefined);
|
|
87
|
+
try {
|
|
88
|
+
resolve(JSON.parse(out));
|
|
89
|
+
} catch {
|
|
90
|
+
resolve(undefined);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
child.stdin.on('error', () => {});
|
|
94
|
+
child.stdin.end(JSON.stringify(said));
|
|
95
|
+
});
|
|
96
|
+
}
|
package/src/pi.js
CHANGED
|
@@ -20,36 +20,74 @@ const THINKING = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
|
|
|
20
20
|
* the models they name; a session that names none takes Pi's own default.
|
|
21
21
|
*/
|
|
22
22
|
export function piTrouble(models) {
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
const any = ask(['--list-models']);
|
|
30
|
-
if (any.error !== undefined || any.status !== 0 || /^No models available\b/m.test(any.stdout ?? '')) {
|
|
23
|
+
const pi = askPi();
|
|
24
|
+
const runs = pi.runs();
|
|
25
|
+
if (runs !== undefined) return `Pi does not run here (${runs}), so no Pi session was started. Install Pi, then run this again.`;
|
|
26
|
+
if (pi.models().any !== true) {
|
|
31
27
|
return 'Pi has no model it can use: it is signed in to no provider. Sign in to Pi yourself (start pi and use /login, or set a provider\'s API key), then run this again. The kit never handles Pi\'s credentials.';
|
|
32
28
|
}
|
|
33
|
-
|
|
34
29
|
for (const model of new Set(models)) {
|
|
35
|
-
const found =
|
|
36
|
-
if (found.
|
|
37
|
-
|
|
38
|
-
return `Pi cannot say whether it can use the model ${model}: pi --list-models ${withoutThinking(model)} failed (${why}), so no Pi session was started. Run that command yourself to see why, then run this again.`;
|
|
30
|
+
const found = pi.model(model);
|
|
31
|
+
if (found.failed !== undefined) {
|
|
32
|
+
return `Pi cannot say whether it can use the model ${model}: pi --list-models ${found.search} failed (${found.failed}), so no Pi session was started. Run that command yourself to see why, then run this again.`;
|
|
39
33
|
}
|
|
40
|
-
|
|
41
|
-
// that one too, when a provider dropped out since the question above.
|
|
42
|
-
if (/^No models (matching|available)\b/m.test(found.stdout ?? '')) {
|
|
34
|
+
if (!found.listed) {
|
|
43
35
|
return `Pi cannot use the model ${model}: pi --list-models lists none matching it. Sign in to its provider in Pi yourself, or give the session a model Pi lists.`;
|
|
44
36
|
}
|
|
45
37
|
}
|
|
46
38
|
return undefined;
|
|
47
39
|
}
|
|
48
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Pi's answers, each question asked once however often it is asked here: for
|
|
43
|
+
* a caller that asks about many sessions, as `obk health` does (#660).
|
|
44
|
+
*
|
|
45
|
+
* - `runs()`: undefined when `pi --version` answers, or why it does not.
|
|
46
|
+
* - `models()`: `{ any }`, whether `pi --list-models` lists any model, or
|
|
47
|
+
* `{ failed }` with why that question failed.
|
|
48
|
+
* - `model(m)`: `{ search, listed }`, whether `pi --list-models <search>` lists
|
|
49
|
+
* a model matching `m` without its thinking level, or `{ search, failed }`
|
|
50
|
+
* with why that question failed.
|
|
51
|
+
*/
|
|
52
|
+
export function askPi() {
|
|
53
|
+
const asked = new Map();
|
|
54
|
+
const once = (args) => {
|
|
55
|
+
const key = args.join('\0');
|
|
56
|
+
if (!asked.has(key)) asked.set(key, ask(args));
|
|
57
|
+
return asked.get(key);
|
|
58
|
+
};
|
|
59
|
+
return {
|
|
60
|
+
runs() {
|
|
61
|
+
const version = once(['--version']);
|
|
62
|
+
if (version.error === undefined && version.status === 0) return undefined;
|
|
63
|
+
return version.error?.code === 'ENOENT' ? 'there is no pi on PATH' : (version.error?.message ?? ((version.stderr ?? '').trim() || `it exited with ${version.status}`));
|
|
64
|
+
},
|
|
65
|
+
models() {
|
|
66
|
+
const all = once(['--list-models']);
|
|
67
|
+
if (failed(all)) return { failed: why(all) };
|
|
68
|
+
return { any: !/^No models available\b/m.test(all.stdout ?? '') };
|
|
69
|
+
},
|
|
70
|
+
model(model) {
|
|
71
|
+
const search = withoutThinking(model);
|
|
72
|
+
const found = once(['--list-models', search]);
|
|
73
|
+
if (failed(found)) return { search, failed: why(found) };
|
|
74
|
+
// Pi says it has no model at all before it searches, so the answer may be
|
|
75
|
+
// that one too, when a provider dropped out since the question above.
|
|
76
|
+
return { search, listed: !/^No models (matching|available)\b/m.test(found.stdout ?? '') };
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
49
81
|
/** The model without a trailing `:<thinking level>`, which `--list-models` does not match. */
|
|
50
|
-
function withoutThinking(model) {
|
|
82
|
+
export function withoutThinking(model) {
|
|
51
83
|
const at = model.lastIndexOf(':');
|
|
52
84
|
return at > 0 && THINKING.includes(model.slice(at + 1)) ? model.slice(0, at) : model;
|
|
53
85
|
}
|
|
54
86
|
|
|
87
|
+
const failed = (answer) => answer.error !== undefined || answer.status !== 0;
|
|
88
|
+
|
|
89
|
+
/** Why a question to Pi failed: the error, what Pi printed, or how it ended. */
|
|
90
|
+
const why = (answer) => answer.error?.message
|
|
91
|
+
?? ((answer.stderr ?? '').trim() || (answer.signal ? `it was killed by ${answer.signal}` : `it exited with ${answer.status}`));
|
|
92
|
+
|
|
55
93
|
const ask = (args) => spawnSync('pi', args, { encoding: 'utf8', timeout: ASK_MS });
|
package/src/rules.js
CHANGED
|
@@ -22,7 +22,7 @@ import { parse } from 'yaml';
|
|
|
22
22
|
|
|
23
23
|
import { botDir, botNames, displayName, readBot, refuseOutside } from './bot.js';
|
|
24
24
|
import { dropRemovedHooks } from './hooks.js';
|
|
25
|
-
import { ownCli, shellWord } from './launch.js';
|
|
25
|
+
import { ownCli, runsOnPi, shellWord } from './launch.js';
|
|
26
26
|
|
|
27
27
|
/** The kit's own units, inside the installed package. */
|
|
28
28
|
const KIT_RULES = fileURLToPath(new URL('../rules', import.meta.url));
|
|
@@ -73,8 +73,16 @@ const checksum = (text) => createHash('sha256').update(text).digest('hex').slice
|
|
|
73
73
|
* What the bot carries is `failed` — a name that resolves to nothing, a unit
|
|
74
74
|
* that cannot be read — and the user fixes it in a list. The file itself is
|
|
75
75
|
* `conflict`, which only the person who edited it can settle.
|
|
76
|
+
*
|
|
77
|
+
* A bot that runs on Pi has `pi: true` too, for the report to say what the
|
|
78
|
+
* kit does and does not write for Pi (#659).
|
|
76
79
|
*/
|
|
77
80
|
export function buildAgents(bots, home, bot) {
|
|
81
|
+
const built = agentsInto(bots, home, bot);
|
|
82
|
+
return runsOnPi(bot) ? { ...built, pi: true } : built;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function agentsInto(bots, home, bot) {
|
|
78
86
|
const file = path.join(home, AGENTS);
|
|
79
87
|
|
|
80
88
|
let built;
|