@assuredloop/orca-bot-kit 0.31.0 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -4
- package/SETUP.md +1 -1
- package/package.json +1 -1
- package/src/clear.js +50 -14
- package/src/cli.js +18 -5
- package/src/health.js +72 -10
- 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/README.md
CHANGED
|
@@ -152,14 +152,19 @@ a Codex session
|
|
|
152
152
|
([ADR 0045](docs/adr/0045-a-codex-sessions-work-dir-stays-in-its-bot-folder.md)).
|
|
153
153
|
|
|
154
154
|
A bot or a session can also run on [Pi](https://pi.dev) (`--harness pi`, tested
|
|
155
|
-
with Pi 1.
|
|
155
|
+
with Pi 1.3.0). Pi asks before no tool call, so a Pi session is always
|
|
156
156
|
`dangerously-skip`, and the kit refuses `auto` and `ask` for it. Sign in to Pi
|
|
157
157
|
yourself first: `obk up` asks Pi which models it can use, and starts nothing
|
|
158
158
|
when Pi has none, or not the one the session names. The kit gives a Pi session
|
|
159
159
|
its id, so `obk up` resumes it after its tab is closed. Pi has no context
|
|
160
|
-
setting: name a model that carries it.
|
|
161
|
-
|
|
162
|
-
`obk
|
|
160
|
+
setting: name a model that carries it. A Pi session reads its bot's
|
|
161
|
+
`AGENTS.md` and loads its bot's skills from `.agents/skills`; when
|
|
162
|
+
`obk skills build` changes them, it types `/reload` into an idle Pi tab. Pi has
|
|
163
|
+
no permission rules and no hooks, so the bot's allow list does not limit a Pi
|
|
164
|
+
session. `obk health` reads a Pi session's model and thinking level from Pi's
|
|
165
|
+
own record, and says when Pi cannot run it. `obk session clear` types Pi's
|
|
166
|
+
`/new` into a Pi tab. `obk session compact` on Pi types nothing, and says so.
|
|
167
|
+
`obk init --harness pi` and `obk session compact` on Pi come later.
|
|
163
168
|
|
|
164
169
|
Every bot gets the kit's default permission rules, written by the kit with
|
|
165
170
|
nobody asked: its own commands for this bots folder, reading a long message,
|
package/SETUP.md
CHANGED
|
@@ -38,7 +38,7 @@ git --version
|
|
|
38
38
|
/Applications/Orca.app/Contents/Resources/bin/orca status --json
|
|
39
39
|
claude --version # at least one of these
|
|
40
40
|
codex --version # 0.156.1 or newer
|
|
41
|
-
pi --version # 1.
|
|
41
|
+
pi --version # 1.3.0, for a bot on Pi
|
|
42
42
|
```
|
|
43
43
|
|
|
44
44
|
The Orca line must come back with `ok: true` and `result.runtime.reachable:
|
package/package.json
CHANGED
package/src/clear.js
CHANGED
|
@@ -19,12 +19,12 @@ import { botDir, readBot } from './bot.js';
|
|
|
19
19
|
import { claudeTranscript, codexRollout } from './conversations.js';
|
|
20
20
|
import { harnessOf, ownCli, shellWord } from './launch.js';
|
|
21
21
|
import { eachLine, firstLine } from './lines.js';
|
|
22
|
-
import { FEEDBACK_PANEL, feedbackPanelIn, findProject, orca, QUESTION_ON_SCREEN, questionIn, screenRows, tabs, tabToTypeInto, TIMED_OUT } from './orca.js';
|
|
22
|
+
import { FEEDBACK_PANEL, feedbackPanelIn, findProject, orca, piAtWorkIn, piBox, QUESTION_ON_SCREEN, questionIn, screenRows, tabs, tabToTypeInto, TIMED_OUT } from './orca.js';
|
|
23
23
|
import { botsNamed, sessionsOf, typeListLine } from './up.js';
|
|
24
24
|
|
|
25
|
-
/** The harness's own command for each, as typed into its input line. Codex's clear is `/new` (tech notes,
|
|
25
|
+
/** The harness's own command for each, as typed into its input line. Codex's and Pi's clear is `/new` (tech notes, sections 3 and 6). */
|
|
26
26
|
const COMMANDS = {
|
|
27
|
-
clear: { claude: '/clear', codex: '/new' },
|
|
27
|
+
clear: { claude: '/clear', codex: '/new', pi: '/new' },
|
|
28
28
|
compact: { claude: '/compact', codex: '/compact' },
|
|
29
29
|
};
|
|
30
30
|
|
|
@@ -100,7 +100,13 @@ const CHAR_GAP_MS = 100;
|
|
|
100
100
|
const AT_WORK = [/esc to interrupt/i, /^\s*\S\s+\S+…\s+\(\d/];
|
|
101
101
|
|
|
102
102
|
/**
|
|
103
|
-
*
|
|
103
|
+
* The selected row of Pi 1.3.0's slash menu, which it draws below its input
|
|
104
|
+
* box (pi-tui's `select-list.js`; read in code, #663).
|
|
105
|
+
*/
|
|
106
|
+
const PI_SELECTED = /^ *→ +/;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Clear the session: `/clear` on Claude Code, `/new` on Codex and Pi. Returns
|
|
104
110
|
* `{ bot, session, harness, was, now }`, the conversation the book held before
|
|
105
111
|
* (or null) and the one it holds now.
|
|
106
112
|
*/
|
|
@@ -168,7 +174,7 @@ export async function compactSession(bots, { bot, session }) {
|
|
|
168
174
|
/**
|
|
169
175
|
* The session as something to type into, once everything that refuses before
|
|
170
176
|
* Orca is asked to type has been asked: `{ name, home, session, harness, tabId }`.
|
|
171
|
-
* A
|
|
177
|
+
* A compact is refused on Pi: a later slice of #547 may add it.
|
|
172
178
|
*/
|
|
173
179
|
export function sessionToType(bots, bot, session, verb) {
|
|
174
180
|
botsNamed(bots, bot);
|
|
@@ -176,7 +182,7 @@ export function sessionToType(bots, bot, session, verb) {
|
|
|
176
182
|
const known = readBot(home, bot);
|
|
177
183
|
const [settings] = sessionsOf(known, session);
|
|
178
184
|
const name = `${bot} ${session}`;
|
|
179
|
-
if (verb
|
|
185
|
+
if (verb === 'compact' && harnessOf(settings, known.harness) === 'pi') {
|
|
180
186
|
throw new Error(`${name} runs on Pi, and obk session ${verb} does not work on a Pi session yet: a later slice of #547 adds it. Nothing was typed.`);
|
|
181
187
|
}
|
|
182
188
|
const unpause = `${shellWord(ownCli())} unpause --bots ${shellWord(bots)} --bot ${bot}`;
|
|
@@ -233,9 +239,16 @@ export async function typeCommand(it, command, { before = () => {}, check = menu
|
|
|
233
239
|
// Nothing may be in the input line before: no draft for the command to join.
|
|
234
240
|
// Orca gives a draft in the line beside the screen, and the screen's line
|
|
235
241
|
// then reads the pointer alone (#510, #516). Where it gives none, Codex's
|
|
236
|
-
// line on the screen has to be empty.
|
|
242
|
+
// line on the screen has to be empty, and so do Pi's editor rows.
|
|
237
243
|
const emptyLine = ({ rows, draft }) => {
|
|
238
244
|
if (draft !== undefined) throw new Error(`${it.name}: nothing was typed, because its input line holds a draft: it reads "${draft}".${shownEnd(rows)}`);
|
|
245
|
+
if (it.harness === 'pi') {
|
|
246
|
+
const box = piBox(rows);
|
|
247
|
+
if (box === undefined) throw new Error(`${it.name}: nothing was typed, because its screen shows no input box.${shownEnd(rows)}`);
|
|
248
|
+
const line = box.editor.map((row) => row.trim()).filter((row) => row !== '').join(' ⏎ ');
|
|
249
|
+
if (line !== '') throw new Error(`${it.name}: nothing was typed, because its input line is not empty: it reads "${line}".${shownEnd(rows)}`);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
239
252
|
if (it.harness !== 'codex') return;
|
|
240
253
|
const line = rows.findLast((row) => /^ *›/.test(row))?.trim();
|
|
241
254
|
if (!CODEX_EMPTY.includes(line)) {
|
|
@@ -295,7 +308,7 @@ export async function typeCommand(it, command, { before = () => {}, check = menu
|
|
|
295
308
|
// A send Orca did not answer in time may have gone in all the same.
|
|
296
309
|
// Counted only where the line was empty before the first key, so the
|
|
297
310
|
// one backspace too many can take nothing of the user's (#480 review).
|
|
298
|
-
if (error.code === TIMED_OUT && it.harness === 'codex') typed += 1;
|
|
311
|
+
if (error.code === TIMED_OUT && (it.harness === 'codex' || it.harness === 'pi')) typed += 1;
|
|
299
312
|
throw error;
|
|
300
313
|
}
|
|
301
314
|
typed += 1;
|
|
@@ -373,7 +386,7 @@ function lookAt(it, { idle = true } = {}) {
|
|
|
373
386
|
}
|
|
374
387
|
if (found.agent !== it.harness) return { why: `Orca names ${found.agent} in it, and the session runs on ${it.harness}` };
|
|
375
388
|
// The gate has read the screen already: it saw no question on it.
|
|
376
|
-
const signal = signalIn(found.rows);
|
|
389
|
+
const signal = signalIn(found.rows, it.harness);
|
|
377
390
|
if (signal !== undefined) return { why: signal };
|
|
378
391
|
if (idle && !found.idle) return { why: 'it is busy with a turn: Orca\'s tui-idle did not answer ok' };
|
|
379
392
|
return { handle: found.handle, rows: found.rows, draft: found.draft };
|
|
@@ -394,7 +407,7 @@ async function typedWrong(handle, harness, command, version, check) {
|
|
|
394
407
|
const seen = screenRows(handle, READ_MS);
|
|
395
408
|
// The screen that lets the return in has to pass the look too: a turn or
|
|
396
409
|
// a question that shows on it stops it at once (review of c1e5ba4).
|
|
397
|
-
const signal = seen.rows === undefined ? undefined : signalIn(seen.rows);
|
|
410
|
+
const signal = seen.rows === undefined ? undefined : signalIn(seen.rows, harness);
|
|
398
411
|
if (signal !== undefined) return { why: signal, rows: seen.rows };
|
|
399
412
|
const wrong = seen.rows === undefined ? { why: `its screen could not be read (${seen.unreadable})` } : check(seen.rows, harness, command, version, seen.draft);
|
|
400
413
|
if (wrong === undefined || Date.now() >= until) return wrong && { ...wrong, rows: seen.rows };
|
|
@@ -409,9 +422,9 @@ async function typedWrong(handle, harness, command, version, check) {
|
|
|
409
422
|
*/
|
|
410
423
|
const PANEL_WHY = 'its panel of feedback drafts is open above its input box, and the kit does not answer it, since a draft sent from it goes to Anthropic: it clears once the user reviews, sends or dismisses the drafts in that tab';
|
|
411
424
|
|
|
412
|
-
/** Why `rows` say not to type now, as the look says it, or undefined: a turn at work, or a question. */
|
|
413
|
-
function signalIn(rows) {
|
|
414
|
-
const working = rows.find((row) => AT_WORK.some((marker) => marker.test(row)));
|
|
425
|
+
/** Why `rows` of `harness` say not to type now, as the look says it, or undefined: a turn at work, or a question. */
|
|
426
|
+
function signalIn(rows, harness) {
|
|
427
|
+
const working = rows.find((row) => AT_WORK.some((marker) => marker.test(row))) ?? piAtWorkIn(rows, harness);
|
|
415
428
|
if (working !== undefined) return `it is busy with a turn: its screen shows "${working.trim()}"`;
|
|
416
429
|
if (feedbackPanelIn(rows)) return PANEL_WHY;
|
|
417
430
|
if (questionIn(rows)) return 'a question of its harness\'s own is on its screen';
|
|
@@ -419,6 +432,7 @@ function signalIn(rows) {
|
|
|
419
432
|
}
|
|
420
433
|
|
|
421
434
|
function menuWrong(rows, harness, command, version, draft) {
|
|
435
|
+
if (harness === 'pi') return piMenuWrong(rows, command, draft);
|
|
422
436
|
const at = rows.findLastIndex((row) => /^ *[›❯]/.test(row));
|
|
423
437
|
if (at < 0) return { why: 'its screen shows no input line' };
|
|
424
438
|
// Claude Code 2.1.288 puts a non-breaking space after its pointer (live run 4).
|
|
@@ -478,6 +492,28 @@ function codexMenuWrong(rows, at, line, command, version) {
|
|
|
478
492
|
return undefined;
|
|
479
493
|
}
|
|
480
494
|
|
|
495
|
+
/**
|
|
496
|
+
* With the slash menu open, Return in Pi 1.3.0 runs the selected row's
|
|
497
|
+
* command (pi-tui's `editor.js`, `tui.select.confirm`; read in code, #663).
|
|
498
|
+
* So the editor's one row, or Orca's draft where it gives one, reads the
|
|
499
|
+
* command, and the selected row below the box names it. A menu still drawn
|
|
500
|
+
* for an earlier part of the command looks the same when its selected row is
|
|
501
|
+
* the command too: the kit's gap between keys and its reads make that
|
|
502
|
+
* unlikely, and this cannot tell it apart.
|
|
503
|
+
*/
|
|
504
|
+
function piMenuWrong(rows, command, draft) {
|
|
505
|
+
const box = piBox(rows);
|
|
506
|
+
if (box === undefined) return { why: 'its screen shows no input box' };
|
|
507
|
+
const lines = box.editor.map((row) => row.trim()).filter((row) => row !== '');
|
|
508
|
+
const line = draft ?? lines.join(' ⏎ ');
|
|
509
|
+
if (line !== command || (draft === undefined && lines.length !== 1)) return { why: `its input line reads "${line}"` };
|
|
510
|
+
const selected = box.below.find((row) => PI_SELECTED.test(row));
|
|
511
|
+
if (selected === undefined) return { why: 'no menu of commands came up below its input box', menu: true };
|
|
512
|
+
const name = selected.replace(PI_SELECTED, '').trim().split(/\s+/)[0];
|
|
513
|
+
if (`/${name}` !== command) return { why: `the selected row of its menu is "${selected.trim()}"`, menu: true };
|
|
514
|
+
return undefined;
|
|
515
|
+
}
|
|
516
|
+
|
|
481
517
|
/** The Codex version that wrote the rollout of `conversation`, from its session_meta line; undefined when that cannot be read. */
|
|
482
518
|
function codexVersion(conversation) {
|
|
483
519
|
const file = typeof conversation === 'string' ? codexRollout(conversation) : undefined;
|
|
@@ -580,4 +616,4 @@ const SEND_MS = 5000;
|
|
|
580
616
|
/** Type `keys` into the tab as they are, with no return of Orca's. */
|
|
581
617
|
const send = (handle, keys) => orca(['terminal', 'send', '--terminal', handle, '--text', keys], { timeoutMs: SEND_MS });
|
|
582
618
|
|
|
583
|
-
const harnessName = (harness) => (
|
|
619
|
+
const harnessName = (harness) => ({ claude: 'Claude Code', codex: 'Codex', pi: 'Pi' })[harness];
|
package/src/cli.js
CHANGED
|
@@ -122,7 +122,7 @@ Usage:
|
|
|
122
122
|
moves the folder only once Orca no longer lists
|
|
123
123
|
the project.
|
|
124
124
|
obk temp make --bots <path> --name <session> [--role <role>[:<option>]]
|
|
125
|
-
(--prompt <text> | --prompt-file <path>) [--harness claude|codex]
|
|
125
|
+
(--prompt <text> | --prompt-file <path>) [--harness claude|codex|pi]
|
|
126
126
|
[--model <m>] [--effort <e>] [--context <c>]
|
|
127
127
|
[--approval ${APPROVALS.join('|')}] [--extra-arg=<arg>]...
|
|
128
128
|
Run in a session's own tab: make a temporary
|
|
@@ -133,7 +133,9 @@ Usage:
|
|
|
133
133
|
you say otherwise; on another harness than yours
|
|
134
134
|
it takes only your approval. --approval is refused
|
|
135
135
|
wider than yours, unless obk permission approval
|
|
136
|
-
--temps allowed it for the bot.
|
|
136
|
+
--temps allowed it for the bot. On Pi it runs as
|
|
137
|
+
dangerously-skip, Pi's one level, so that holds
|
|
138
|
+
there too. It works in
|
|
137
139
|
work/<session>, and has the task as its start
|
|
138
140
|
prompt. The book records it as temporary, made by
|
|
139
141
|
you.
|
|
@@ -168,7 +170,8 @@ Usage:
|
|
|
168
170
|
"Teach auto mode" screen on a temporary session it
|
|
169
171
|
made with Not now, in a shape the kit knows, and
|
|
170
172
|
check the screen went. It refuses any other
|
|
171
|
-
screen, and types nothing then.
|
|
173
|
+
screen, and types nothing then. On a Pi session
|
|
174
|
+
it answers nothing, and says what the screen is.
|
|
172
175
|
obk rules build --bots <path> [--bot <bot>]
|
|
173
176
|
Build every bot's AGENTS.md from its charter and
|
|
174
177
|
the rule units it carries, or just the one you
|
|
@@ -1840,6 +1843,11 @@ function rulesLines(rules, bots) {
|
|
|
1840
1843
|
...(entry.bytes > CODEX_CAP
|
|
1841
1844
|
? [` it is over the ${CODEX_CAP / 1024} KiB Codex reads, so a Codex session will not see all of it.`]
|
|
1842
1845
|
: []),
|
|
1846
|
+
// What applies to Pi and what does not, said wherever the rules are
|
|
1847
|
+
// reported, so the reader never takes the allow list for a limit (#659).
|
|
1848
|
+
...(entry.pi === true
|
|
1849
|
+
? [`${'pi'.padEnd(9)} ${entry.bot} its Pi sessions read AGENTS.md when they start. Pi has no permission rules and no hooks, so the kit writes neither for Pi: a Pi session runs as dangerously-skip, and the bot's allow list does not limit it.`]
|
|
1850
|
+
: []),
|
|
1843
1851
|
];
|
|
1844
1852
|
});
|
|
1845
1853
|
}
|
|
@@ -1853,6 +1861,7 @@ function skillsLines(skills) {
|
|
|
1853
1861
|
return skills.flatMap((entry) => [
|
|
1854
1862
|
entry.bot,
|
|
1855
1863
|
...(entry.trouble === undefined ? [] : [` ${'trouble'.padEnd(9)} ${entry.trouble}`]),
|
|
1864
|
+
...(entry.pi === undefined ? [] : [` ${'pi'.padEnd(9)} its Pi sessions load the skills in .agents/skills, which the kit names on each Pi launch line; a running Pi session takes a change at /reload`]),
|
|
1856
1865
|
...entry.skills.map((skill) => ` ${(skill.managed ? 'linked' : 'yours').padEnd(9)} ${skill.name.padEnd(24)} ${skill.from ?? 'no list names it; the kit leaves it alone'}`),
|
|
1857
1866
|
...(entry.removed ?? []).map((name) => ` ${'removed'.padEnd(9)} ${name.padEnd(24)} no list names it now`),
|
|
1858
1867
|
...(entry.sessions ?? []).map((one) => toldLine(entry.bot, one)),
|
|
@@ -1862,9 +1871,13 @@ function skillsLines(skills) {
|
|
|
1862
1871
|
/** What `skills build` did about one running session of a bot whose skills changed. */
|
|
1863
1872
|
function toldLine(bot, { session, harness, state, read, blocked, trouble }) {
|
|
1864
1873
|
const who = `${bot}/${session}`;
|
|
1865
|
-
const
|
|
1874
|
+
const reload = harness === 'pi' ? '/reload' : '/reload-skills';
|
|
1875
|
+
const after = harness === 'codex' ? 'Its next turn takes the change by itself.' : `Once that is settled, type ${reload} in its tab.`;
|
|
1866
1876
|
const [word, says] = {
|
|
1867
|
-
reloaded: ['reloaded',
|
|
1877
|
+
reloaded: ['reloaded', harness === 'pi'
|
|
1878
|
+
? '/reload was typed into its tab; Pi takes the change at its next turn'
|
|
1879
|
+
: '/reload-skills was typed into its tab; a busy session runs it when its turn ends'],
|
|
1880
|
+
busy: ['not told', 'it is busy, and Pi refuses /reload while it works, so nothing was typed. Once its turn ends, type /reload in its tab.'],
|
|
1868
1881
|
'next-turn': ['next turn', `Codex takes the change at the start of its next turn. If a skill is not there then, a restart makes it appear${read?.length > 0 ? `; meanwhile it can read ${read.join(', ')}` : ''}`],
|
|
1869
1882
|
'not-up': ['not told', 'not up; it reads its skills when it starts'],
|
|
1870
1883
|
blocked: ['not told', `its tab is waiting for an answer (${blocked}), so nothing was typed. ${after}`],
|
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/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;
|
package/src/settings.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
//
|
|
3
3
|
// A session can run on settings other than the ones bot.yaml asks for: it
|
|
4
4
|
// started before a change, a default of the user's own leaked in, or a flag was
|
|
5
|
-
// dropped. Each harness writes down what it used (tech notes, sections 2 and
|
|
5
|
+
// dropped. Each harness writes down what it used (tech notes, sections 2, 3 and 6),
|
|
6
6
|
// so the two can be put side by side. Read only: these are the harness's files.
|
|
7
7
|
//
|
|
8
8
|
// Four answers, and only one of them is good news. A record that cannot be read,
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
// setting the session never asked for is not a disagreement either, whatever the
|
|
11
11
|
// harness chose: it is `not-asked-for`.
|
|
12
12
|
|
|
13
|
-
import {
|
|
13
|
+
import { defaultApproval } from './launch.js';
|
|
14
|
+
import { withoutThinking } from './pi.js';
|
|
14
15
|
import { lines } from './usage.js';
|
|
15
16
|
|
|
16
17
|
export const SETTINGS = ['model', 'effort', 'context', 'approval'];
|
|
@@ -22,47 +23,56 @@ export const SETTINGS = ['model', 'effort', 'context', 'approval'];
|
|
|
22
23
|
* every setting asked for is then unknown.
|
|
23
24
|
*/
|
|
24
25
|
export function settingsInUse(harness, session, file, since) {
|
|
25
|
-
const
|
|
26
|
-
|
|
26
|
+
const entries = file === undefined ? [] : lines(file);
|
|
27
|
+
const start = startOf(entries, since);
|
|
28
|
+
// Pi's reader also looks before the start, for the selection a reply follows.
|
|
29
|
+
const used = harness === 'pi' ? fromPi(entries, start)
|
|
30
|
+
: (harness === 'claude' ? fromClaude : fromCodex)(start === -1 ? [] : entries.slice(start));
|
|
31
|
+
return Object.fromEntries(SETTINGS.map((name) => [name, judged(harness, name, asked(harness, session, name), used[name])]));
|
|
27
32
|
}
|
|
28
33
|
|
|
29
34
|
/**
|
|
30
|
-
*
|
|
31
|
-
* the conversation it had, so the file still holds what the
|
|
32
|
-
* wrote, and that says what that process ran with, not this
|
|
35
|
+
* Where the record since the kit last started the session begins, or -1. A
|
|
36
|
+
* start resumes the conversation it had, so the file still holds what the
|
|
37
|
+
* process before it wrote, and that says what that process ran with, not this
|
|
38
|
+
* one. Everything
|
|
33
39
|
* from the first line written at or after the start counts, a line with no time
|
|
34
40
|
* of its own included. With no start to go by, no line can be shown to be this
|
|
35
41
|
* process's, so none counts.
|
|
36
42
|
*/
|
|
37
|
-
function
|
|
43
|
+
function startOf(entries, since) {
|
|
38
44
|
const from = Date.parse(since ?? '');
|
|
39
|
-
if (Number.isNaN(from)) return
|
|
40
|
-
|
|
41
|
-
return first === -1 ? [] : entries.slice(first);
|
|
45
|
+
if (Number.isNaN(from)) return -1;
|
|
46
|
+
return entries.findIndex((entry) => Date.parse(entry.timestamp ?? '') >= from);
|
|
42
47
|
}
|
|
43
48
|
|
|
44
49
|
/** What the session asks for, as bot.yaml has it, or undefined when it asks for nothing. */
|
|
45
|
-
function asked(session, name) {
|
|
50
|
+
function asked(harness, session, name) {
|
|
46
51
|
const value = session[name];
|
|
47
52
|
const set = value !== undefined && value !== null && String(value).trim() !== '';
|
|
48
53
|
// The kit puts an approval level on every launch line, so a session always
|
|
49
|
-
// asks for one:
|
|
50
|
-
|
|
54
|
+
// asks for one: its harness's default, when it names none (ADR 0015). Pi
|
|
55
|
+
// has one level (#547).
|
|
56
|
+
if (name === 'approval') return set ? String(value).trim() : defaultApproval(harness);
|
|
51
57
|
return set ? String(value).trim() : undefined;
|
|
52
58
|
}
|
|
53
59
|
|
|
54
60
|
function judged(harness, name, configured, observed) {
|
|
55
|
-
|
|
61
|
+
// A Pi reply can leave two values open (see fromPi): its own, which shows,
|
|
62
|
+
// and the selection before it. Agreeing with one of the two is not a match.
|
|
63
|
+
const choices = observed?.choices ?? [observed];
|
|
64
|
+
const shown = choices[0] === undefined ? {} : { observed: said(name, choices[0]) };
|
|
56
65
|
if (configured === undefined) return { state: 'not-asked-for', ...shown };
|
|
57
|
-
const
|
|
58
|
-
if (verdict ===
|
|
59
|
-
return { state:
|
|
66
|
+
const verdicts = choices.map((choice) => same(harness, name, configured, choice));
|
|
67
|
+
if (verdicts.every((verdict) => verdict === false)) return { state: 'mismatch', configured, ...shown };
|
|
68
|
+
if (verdicts.length === 1 && verdicts[0] === true) return { state: 'match', configured, ...shown };
|
|
69
|
+
return { state: 'unknown', configured, ...shown };
|
|
60
70
|
}
|
|
61
71
|
|
|
62
72
|
/** Whether the record says what was asked for; undefined when it cannot say. */
|
|
63
73
|
function same(harness, name, configured, observed) {
|
|
64
74
|
if (observed === undefined) return undefined;
|
|
65
|
-
if (name === 'model') return sameModel(configured, observed);
|
|
75
|
+
if (name === 'model') return harness === 'pi' ? samePiModel(configured, observed) : sameModel(configured, observed);
|
|
66
76
|
// Codex reports the window it was given at 95%, rounded down (tech notes,
|
|
67
77
|
// section 3): 200000 comes back as 190000.
|
|
68
78
|
if (name === 'context') return Number(observed) === Math.floor(Number(configured) * 0.95);
|
|
@@ -82,6 +92,27 @@ function sameModel(configured, observed) {
|
|
|
82
92
|
return model.includes('-') ? observed === model : observed.split('-').includes(model);
|
|
83
93
|
}
|
|
84
94
|
|
|
95
|
+
/**
|
|
96
|
+
* A Pi model as bot.yaml names it, `openai/gpt-6-luna` or `gpt-6-luna`, maybe
|
|
97
|
+
* with a trailing `:<thinking level>`, against the `<provider>/<model>` Pi
|
|
98
|
+
* wrote down: it matches either whole, in any case, as Pi does. Pi also takes
|
|
99
|
+
* a pattern that is part of a model's id, and which model it then chose
|
|
100
|
+
* depends on what else it lists, so such a pattern cannot be judged (Pi 1.3.0,
|
|
101
|
+
* resolveModelReference). Pi matches a display name too, which the record
|
|
102
|
+
* does not hold: such a pattern shows as a mismatch.
|
|
103
|
+
*/
|
|
104
|
+
function samePiModel(configured, observed) {
|
|
105
|
+
const model = withoutThinking(configured).toLowerCase();
|
|
106
|
+
const ran = observed.toLowerCase();
|
|
107
|
+
const provider = ran.slice(0, ran.indexOf('/'));
|
|
108
|
+
const id = ran.slice(ran.indexOf('/') + 1);
|
|
109
|
+
if (model === ran || model === id) return true;
|
|
110
|
+
// A provider before a slash is only a hint: the whole pattern may be part of an id.
|
|
111
|
+
const slash = model.indexOf('/');
|
|
112
|
+
const part = id.includes(model) || (slash !== -1 && model.slice(0, slash) === provider && id.includes(model.slice(slash + 1)));
|
|
113
|
+
return part ? undefined : false;
|
|
114
|
+
}
|
|
115
|
+
|
|
85
116
|
/** The permission mode Claude Code records for each level (tech notes, section 2). */
|
|
86
117
|
const CLAUDE_MODES = { auto: 'auto', ask: 'default', 'dangerously-skip': 'bypassPermissions' };
|
|
87
118
|
|
|
@@ -134,6 +165,65 @@ function fromClaude(entries) {
|
|
|
134
165
|
return used;
|
|
135
166
|
}
|
|
136
167
|
|
|
168
|
+
/** The api of a virtual model's catalog entry (Pi 1.3.0, virtual-models.js). */
|
|
169
|
+
const PI_VIRTUAL = 'pi-virtual';
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* What a Pi record says it runs with, from `start` on: the model, as
|
|
173
|
+
* `<provider>/<model>`, and the thinking level (tech notes, section 6). Pi
|
|
174
|
+
* writes nothing about approval.
|
|
175
|
+
*
|
|
176
|
+
* The latest line that names one decides. A `model_change` or
|
|
177
|
+
* `thinking_level_change` line decides alone. A reply names the model that
|
|
178
|
+
* answered, and the level it was asked at. On a resume with `--model` or
|
|
179
|
+
* `--thinking`, Pi writes no change line, so a change before a reply may be
|
|
180
|
+
* stale; but a change to a virtual model, such as `router/auto`, stays the
|
|
181
|
+
* selection while the router is registered, and its replies name the model
|
|
182
|
+
* it routed to (Pi 1.3.0, getBranchSelection, docs/virtual-models.md). The
|
|
183
|
+
* record cannot show what is registered now. So the reply stands when the
|
|
184
|
+
* change names the same model, or a model a reply shows is physical, and
|
|
185
|
+
* otherwise the two are left open. A router may change the level as well, so
|
|
186
|
+
* a reply's level stands alone only on a physical model. A reply a router
|
|
187
|
+
* could not route names the virtual model, and is passed over, as Pi does.
|
|
188
|
+
*/
|
|
189
|
+
function fromPi(entries, start) {
|
|
190
|
+
const named = (provider, model) => (typeof provider === 'string' && typeof model === 'string' ? `${provider}/${model}` : undefined);
|
|
191
|
+
const text = (value) => (typeof value === 'string' ? value : undefined);
|
|
192
|
+
const assistant = (entry) => (entry.type === 'message' && entry.message?.role === 'assistant' ? entry.message : undefined);
|
|
193
|
+
const reply = (entry) => (assistant(entry)?.api === PI_VIRTUAL ? undefined : assistant(entry));
|
|
194
|
+
const modelOf = (entry) => (entry.type === 'model_change' ? named(entry.provider, entry.modelId) : named(reply(entry)?.provider, reply(entry)?.model));
|
|
195
|
+
const levelOf = (entry) => text(entry.type === 'thinking_level_change' ? entry.thinkingLevel : reply(entry)?.thinkingLevel);
|
|
196
|
+
const changeBefore = (type, read, at) => {
|
|
197
|
+
for (let before = at - 1; before >= 0; before -= 1) if (entries[before].type === type) return read(entries[before]);
|
|
198
|
+
return undefined;
|
|
199
|
+
};
|
|
200
|
+
const physical = (model) => entries.some((entry) => entry.type !== 'model_change' && modelOf(entry) === model);
|
|
201
|
+
const latest = (read) => {
|
|
202
|
+
if (start === -1) return -1;
|
|
203
|
+
for (let at = entries.length - 1; at >= start; at -= 1) if (read(entries[at]) !== undefined) return at;
|
|
204
|
+
return -1;
|
|
205
|
+
};
|
|
206
|
+
/** The model the line at `at` says, and whether it is a physical one that a reply's level is sure of. */
|
|
207
|
+
const modelAt = (at) => {
|
|
208
|
+
const own = modelOf(entries[at]);
|
|
209
|
+
if (entries[at].type === 'model_change') return { model: own, sure: false };
|
|
210
|
+
const earlier = changeBefore('model_change', modelOf, at);
|
|
211
|
+
if (own === undefined) return { sure: false };
|
|
212
|
+
// The reply itself shows its own model is physical, so a change to that model is settled too.
|
|
213
|
+
if (earlier === undefined || physical(earlier)) return { model: own, sure: true };
|
|
214
|
+
return { model: { choices: [own, earlier] }, sure: false };
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const model = latest(modelOf);
|
|
218
|
+
const level = latest(levelOf);
|
|
219
|
+
const own = level === -1 ? undefined : levelOf(entries[level]);
|
|
220
|
+
const earlier = level === -1 || entries[level].type === 'thinking_level_change' ? undefined : changeBefore('thinking_level_change', levelOf, level);
|
|
221
|
+
return {
|
|
222
|
+
model: model === -1 ? undefined : modelAt(model).model,
|
|
223
|
+
effort: earlier === undefined || earlier === own || modelAt(level).sure ? own : { choices: [own, earlier] },
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
137
227
|
/** What a Codex rollout says it runs with: its latest turn, and its latest count. */
|
|
138
228
|
function fromCodex(entries) {
|
|
139
229
|
const used = {};
|
package/src/skills.js
CHANGED
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
// away reaches it without a restart. Claude Code takes it through its own
|
|
9
9
|
// `/reload-skills`, which `obk skills build` types into the session's tab.
|
|
10
10
|
// Codex has no such command and takes it at the start of its next turn (tech
|
|
11
|
-
// notes, sections 2 and 3).
|
|
11
|
+
// notes, sections 2 and 3). Pi loads the Codex directory, which its launch
|
|
12
|
+
// line names, and takes a change at its own `/reload`, typed only while it is
|
|
13
|
+
// idle (section 6, #659). Outwards: the kit takes away only the links it put
|
|
12
14
|
// there, and never the skill one was pointing at.
|
|
13
15
|
//
|
|
14
16
|
// An entry names one of four shelves: the kit's own (`kit:<name>`), an online
|
|
@@ -25,7 +27,7 @@ import { parse, parseDocument, stringify } from 'yaml';
|
|
|
25
27
|
|
|
26
28
|
import { bookFile, readBook, takeLineTurn, TYPING_HELD, TYPING_WAIT_MS } from './book.js';
|
|
27
29
|
import { botDir, botNames, changesExactly, leadsOutside, readBot, refuseOutside, withBotTurn, writeWhole, YAML_OUT } from './bot.js';
|
|
28
|
-
import { harnessOf, ownCli, shellWord } from './launch.js';
|
|
30
|
+
import { harnessOf, ownCli, piSkillsDir, runsOnPi, shellWord } from './launch.js';
|
|
29
31
|
import { orcaTrouble, tabToTypeInto, typeIntoTab } from './orca.js';
|
|
30
32
|
import { listIn } from './rules.js';
|
|
31
33
|
import { cloneDir, isCloned, leavesClone, readSources, skillsIn, wrongClone } from './sources.js';
|
|
@@ -142,12 +144,18 @@ export function removeSkill(bots, bot, ref) {
|
|
|
142
144
|
/**
|
|
143
145
|
* Give one bot the skills its lists name. Returns what there is to report:
|
|
144
146
|
* `{ bot, skills, linked, removed }` for a bot whose links are in place, and
|
|
145
|
-
* `{ bot, skills, trouble }` for one whose list the kit could not follow.
|
|
147
|
+
* `{ bot, skills, trouble }` for one whose list the kit could not follow. A bot
|
|
148
|
+
* that runs on Pi has `pi` too: the directory its Pi sessions load (#659).
|
|
146
149
|
*
|
|
147
150
|
* A list with one entry the kit cannot follow links nothing at all: half a
|
|
148
151
|
* bot's skills is not a bot anybody asked for, and the user fixes one line.
|
|
149
152
|
*/
|
|
150
153
|
export function linkSkills(bots, home, bot) {
|
|
154
|
+
const entry = linkInto(bots, home, bot);
|
|
155
|
+
return runsOnPi(bot) ? { ...entry, pi: piSkillsDir(home) } : entry;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function linkInto(bots, home, bot) {
|
|
151
159
|
const record = readRecord(home);
|
|
152
160
|
const held = new Map();
|
|
153
161
|
|
|
@@ -440,7 +448,7 @@ const LOOK_MS = 2000;
|
|
|
440
448
|
* harness's own means (PRD 6.5: changes reach running sessions without a
|
|
441
449
|
* restart, and the sessions a change affects are told). Returns one entry per
|
|
442
450
|
* session, in the bot's order: `{ session, harness, state }`, where the state
|
|
443
|
-
* is `reloaded`, `next-turn` (with `read`), `not-up`, `blocked` (with
|
|
451
|
+
* is `reloaded`, `next-turn` (with `read`), `busy`, `not-up`, `blocked` (with
|
|
444
452
|
* `blocked`) or `unknown` (with `trouble`).
|
|
445
453
|
*
|
|
446
454
|
* Claude Code gets `/reload-skills` typed into its tab, through the same gate
|
|
@@ -449,7 +457,11 @@ const LOOK_MS = 2000;
|
|
|
449
457
|
* command and takes the change at the start of its next turn by itself, so
|
|
450
458
|
* nothing is typed, and the caller is given the SKILL.md of each skill linked
|
|
451
459
|
* in, for the session to read if the skill has not appeared (tech notes,
|
|
452
|
-
* sections 2 and 3).
|
|
460
|
+
* sections 2 and 3). Pi gets its own `/reload` through the same gate, and only
|
|
461
|
+
* while it is idle: a busy Pi refuses the command and keeps nothing for later,
|
|
462
|
+
* so a busy one is `busy`, and one with a draft in its input line is typed
|
|
463
|
+
* nothing either, since the command would join the draft (section 6, #659).
|
|
464
|
+
* Nothing is ever restarted.
|
|
453
465
|
*/
|
|
454
466
|
function tellSessions(home, bot, linked) {
|
|
455
467
|
let book;
|
|
@@ -478,9 +490,6 @@ function tellSessions(home, bot, linked) {
|
|
|
478
490
|
const told = { session: session.name, harness };
|
|
479
491
|
const tab = typeof book[session.name]?.tab === 'string' ? book[session.name].tab : undefined;
|
|
480
492
|
if (tab === undefined) return { ...told, state: 'not-up' };
|
|
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
493
|
if (down !== undefined) return { ...told, state: 'unknown', trouble: `the kit could not look at its tab, and nothing was typed: ${down}` };
|
|
485
494
|
let turn;
|
|
486
495
|
try {
|
|
@@ -505,7 +514,11 @@ function tellSessions(home, bot, linked) {
|
|
|
505
514
|
const read = linked.map((name) => path.join(home, SKILL_DIRS.codex, name, MANIFEST)).filter((file) => existsSync(file));
|
|
506
515
|
return { ...told, state: 'next-turn', read };
|
|
507
516
|
}
|
|
508
|
-
|
|
517
|
+
if (harness === 'pi') {
|
|
518
|
+
if (found.draft !== undefined) return { ...told, state: 'unknown', trouble: `its input line holds a draft, "${found.draft}", and /reload would join it, so nothing was typed` };
|
|
519
|
+
if (!found.idle) return { ...told, state: 'busy' };
|
|
520
|
+
}
|
|
521
|
+
typeIntoTab(found.handle, harness === 'pi' ? '/reload' : '/reload-skills');
|
|
509
522
|
return { ...told, state: 'reloaded' };
|
|
510
523
|
} catch (error) {
|
|
511
524
|
// The links are made whatever Orca says, so this is reported, not
|
package/src/temp.js
CHANGED
|
@@ -34,9 +34,10 @@ import { setTimeout as pause } from 'node:timers/promises';
|
|
|
34
34
|
import { readBook, takeLineTurn, TYPING_HELD, TYPING_WAIT_MS, updateBook } from './book.js';
|
|
35
35
|
import { addSession, dropSession, NAME, readBot, tempRoles } from './bot.js';
|
|
36
36
|
import { untrustedKitHooks } from './hook-trust.js';
|
|
37
|
-
import { approvalRank, DEFAULT_APPROVAL, harnessOf, isShortPrompt, ownCli, refuseApprovalArgs, shellWord, startPrompt, workDirOf } from './launch.js';
|
|
37
|
+
import { approvalRank, DEFAULT_APPROVAL, harnessOf, isShortPrompt, ownCli, piApprovalTrouble, refuseApprovalArgs, set, shellWord, startPrompt, workDirOf } from './launch.js';
|
|
38
38
|
import { findSession, sessionInTab } from './message.js';
|
|
39
39
|
import { orca, screenRows, tabs } from './orca.js';
|
|
40
|
+
import { piTrouble } from './pi.js';
|
|
40
41
|
import { retireSession } from './retire.js';
|
|
41
42
|
import { BOT_FATHER, bringUp, promptPath } from './up.js';
|
|
42
43
|
|
|
@@ -75,11 +76,23 @@ export async function makeTemp(bots, { tab, ...given }) {
|
|
|
75
76
|
|
|
76
77
|
const maker = bot.sessions.find((session) => session.name === caller.session);
|
|
77
78
|
const { settings, chosen } = settingsFor(bot, maker, role?.option ?? {}, given);
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
79
|
+
const onPi = chosen.harness.value === 'pi';
|
|
80
|
+
// A blank --approval names no level, so on Pi it is judged as left out (the review of PR #651).
|
|
81
|
+
const asked = onPi && !set(given.approval) ? undefined : given.approval;
|
|
82
|
+
if (onPi) {
|
|
83
|
+
// Pi has one level, whatever the maker's is (the owner, #547).
|
|
84
|
+
const trouble = piApprovalTrouble(asked, name);
|
|
85
|
+
if (trouble !== undefined) throw new Error(`${trouble} Nothing was made.`);
|
|
86
|
+
settings.approval = 'dangerously-skip';
|
|
87
|
+
if (asked === undefined) chosen.approval = { value: 'dangerously-skip', from: 'harness' };
|
|
88
|
+
}
|
|
89
|
+
refuseWiderApproval(bots, caller, bot, maker, asked ?? (onPi ? settings.approval : undefined), onPi && asked === undefined ? name : undefined);
|
|
82
90
|
refuseApprovalArgs(chosen.harness.value, given.extra_args);
|
|
91
|
+
// Asked before anything is written, so a Pi that cannot start one makes nothing (#578).
|
|
92
|
+
if (onPi) {
|
|
93
|
+
const trouble = piTrouble(set(settings.model) ? [settings.model] : []);
|
|
94
|
+
if (trouble !== undefined) throw new Error(`${trouble} Nothing was made.`);
|
|
95
|
+
}
|
|
83
96
|
settings.name = name;
|
|
84
97
|
if (given.extra_args !== undefined) settings.extra_args = given.extra_args;
|
|
85
98
|
if (given.prompt !== undefined) settings.prompt = given.prompt;
|
|
@@ -189,9 +202,10 @@ function settingsFor(bot, maker, option, given) {
|
|
|
189
202
|
/**
|
|
190
203
|
* Refuse an approval wider than the widest of the maker's own and the bot's
|
|
191
204
|
* `temp_approval`, which only the user's yes through `permission approval
|
|
192
|
-
* --temps` widens (ADR 0051).
|
|
205
|
+
* --temps` widens (ADR 0051). `onPi` names a Pi session that was given no
|
|
206
|
+
* level: Pi's one level is what is judged then (#648).
|
|
193
207
|
*/
|
|
194
|
-
function refuseWiderApproval(bots, caller, bot, maker, asked) {
|
|
208
|
+
function refuseWiderApproval(bots, caller, bot, maker, asked, onPi) {
|
|
195
209
|
if (asked === undefined) return;
|
|
196
210
|
// A blank level is the kit's default at launch (ADR 0015), so it is judged as that.
|
|
197
211
|
const effective = (given) => (given === undefined || given === null || String(given).trim() === '' ? DEFAULT_APPROVAL : given);
|
|
@@ -199,7 +213,8 @@ function refuseWiderApproval(bots, caller, bot, maker, asked) {
|
|
|
199
213
|
const own = effective(maker?.approval);
|
|
200
214
|
const widest = approvalRank(bot.temp_approval) > approvalRank(own) ? bot.temp_approval : own;
|
|
201
215
|
if (approvalRank(level) <= approvalRank(widest)) return;
|
|
202
|
-
|
|
216
|
+
const what = onPi === undefined ? `--approval ${level}` : `${onPi} would run on Pi, and every Pi session runs as ${level}, which`;
|
|
217
|
+
throw new Error(`${what} is wider than ${caller.session}'s own approval, ${own}${widest === own ? '' : `, and than ${caller.bot}'s temp_approval, ${widest}`}, and a session does not widen another's approval. The user allows it for ${caller.bot}'s temporary sessions with ${shellWord(ownCli())} permission approval --bots ${shellWord(bots)} --bot ${caller.bot} --temps --approval ${level}. Nothing was made.`);
|
|
203
218
|
}
|
|
204
219
|
|
|
205
220
|
/**
|
|
@@ -651,11 +666,54 @@ const wholeScreen = (rows) => rows.filter((row) => row.trim() !== '').map((row)
|
|
|
651
666
|
*/
|
|
652
667
|
export async function answerTemp(bots, { tab, name }) {
|
|
653
668
|
const caller = callerIn(bots, tab, 'answer', "answer a session's first-run screen");
|
|
654
|
-
ownTemp(caller, name, 'answer for', 'Nothing was typed.');
|
|
655
|
-
const
|
|
669
|
+
const { bot, session } = ownTemp(caller, name, 'answer for', 'Nothing was typed.');
|
|
670
|
+
const target = { run: `${caller.bot}/${name}`, home: caller.home, session: name };
|
|
671
|
+
if (harnessOf(session, bot.harness) === 'pi') sayPiScreen(target, 'temp');
|
|
672
|
+
const sent = await answerTeach(target, 'temp');
|
|
656
673
|
return { bot: caller.bot, session: name, maker: caller.session, sent };
|
|
657
674
|
}
|
|
658
675
|
|
|
676
|
+
/**
|
|
677
|
+
* The foot of an extension's question in Pi 1.3.0: `ctx.ui.select` and
|
|
678
|
+
* `confirm` draw the trust question's list foot, `ctx.ui.input` its own.
|
|
679
|
+
*/
|
|
680
|
+
const PI_ASK_FOOT = /(?:↑↓ navigate +enter select|enter submit) +escape\/ctrl\+c cancel/;
|
|
681
|
+
|
|
682
|
+
/** The foot of each step of Pi 1.3.0's first-time setup. */
|
|
683
|
+
const PI_SETUP_FOOT = /enter (?:continue|finish) +escape\/ctrl\+c skip setup/;
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* Refuse to answer a Pi session's screen, and say what it is (#648). The kit
|
|
687
|
+
* starts Pi with `--no-approve`, and then no screen of Pi's own waits for a
|
|
688
|
+
* key where Pi has its settings already (tech notes, section 6). What still
|
|
689
|
+
* can is named and left to the user: Pi's trust question, its first-time
|
|
690
|
+
* setup, its warning that waits for a key, and an extension's question.
|
|
691
|
+
* Nothing is typed, so no turn for a line is taken.
|
|
692
|
+
*/
|
|
693
|
+
function sayPiScreen({ run, home, session }, kind) {
|
|
694
|
+
const nothing = 'Nothing was typed.';
|
|
695
|
+
const tabId = readBook(home).sessions[session]?.tab;
|
|
696
|
+
const handle = tabId === undefined ? undefined : tabs(home).find((one) => one.tabId === tabId)?.handle;
|
|
697
|
+
if (handle === undefined) {
|
|
698
|
+
throw new Error(`${run} runs on Pi and has no tab open in Orca, so there is no screen of its to answer. ${nothing}`);
|
|
699
|
+
}
|
|
700
|
+
const seen = screenRows(handle);
|
|
701
|
+
if (seen.rows === undefined) {
|
|
702
|
+
throw new Error(`${run} runs on Pi, and its screen could not be read (${seen.unreadable}). ${nothing}`);
|
|
703
|
+
}
|
|
704
|
+
const has = (test) => seen.rows.some((row) => test(row.trim()));
|
|
705
|
+
const what = has((row) => row === 'Trust project folder?')
|
|
706
|
+
? `Pi's project-trust question, which Pi does not ask when the kit starts it with --no-approve. Trusting a folder is the user's choice`
|
|
707
|
+
: has((row) => PI_SETUP_FOOT.test(row))
|
|
708
|
+
? 'Pi\'s first-time setup, which asks for a theme and whether to share anonymous usage data (analytics) when Pi has no settings.json yet. Both are the user\'s choice'
|
|
709
|
+
: has((row) => row.endsWith('Press any key to continue...'))
|
|
710
|
+
? 'a warning of Pi\'s that waits for a key, such as its notice of an old hooks/ folder in ~/.pi/agent or the project. It is the user\'s to read and answer'
|
|
711
|
+
: has((row) => PI_ASK_FOOT.test(row))
|
|
712
|
+
? 'a question from an extension of Pi\'s, not from Pi or the kit. It is the user\'s to answer'
|
|
713
|
+
: `no first-run screen of Pi's: the kit starts Pi with --no-approve, and then Pi asks nothing before it works`;
|
|
714
|
+
throw new Error(`${run} runs on Pi, and its screen shows ${what}. ${kind} answer answers none of Pi's screens. ${nothing} It shows:\n${wholeScreen(seen.rows)}`);
|
|
715
|
+
}
|
|
716
|
+
|
|
659
717
|
/**
|
|
660
718
|
* Answer `target`'s Teach auto mode screen with Not now, for `temp answer` or
|
|
661
719
|
* `session answer` (`kind`). Returns what answered it.
|
|
@@ -714,6 +772,7 @@ export async function trustSessionHooks(bots, { tab, bot, session }) {
|
|
|
714
772
|
*/
|
|
715
773
|
export async function answerSession(bots, { tab, bot, session }) {
|
|
716
774
|
const target = longLivedTarget(bots, { tab, bot, session }, 'answer');
|
|
775
|
+
if (target.harness === 'pi') sayPiScreen(target, 'session');
|
|
717
776
|
const sent = await answerTeach(target, 'session');
|
|
718
777
|
return { bot: target.bot, session: target.session, sent };
|
|
719
778
|
}
|