@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/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
- for (const name of names) found.push(...aboutBot(bots, name, setups, sessions));
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
- if (stray.length === 0) return [];
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
- const listed = stray.map((one) => `${one.id} (begun ${one.at})`).join(', ');
572
- return [finding('session', bookFile(home), `The harness has conversations on record in ${bot.name}'s folder that the book does not name: ${listed}. The kit's hook may have missed them: a clear it did not record, or a Codex hooks file trusted late. To give one to a session, write it into ${bookFile(home)} under that session as session: <id> and run obk up again.`, bot.name)];
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 codex --version; and the operating system.
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, with the `rows` it read, or `{ screenUnreadable: <why> }`. Orca refusing is an
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
- return { handle: live.handle, agent: seen.agent, idle: seen.answered, rows: seen.rows, draft: seen.draft };
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
  /**
@@ -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 version = ask(['--version']);
24
- if (version.error !== undefined || version.status !== 0) {
25
- const why = version.error?.code === 'ENOENT' ? 'there is no pi on PATH' : (version.error?.message ?? ((version.stderr ?? '').trim() || `it exited with ${version.status}`));
26
- return `Pi does not run here (${why}), so no Pi session was started. Install Pi, then run this again.`;
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 = ask(['--list-models', withoutThinking(model)]);
36
- if (found.error !== undefined || found.status !== 0) {
37
- const why = found.error?.message ?? ((found.stderr ?? '').trim() || `it exited with ${found.status ?? found.signal}`);
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
- // Pi says it has no model at all before it searches, so the answer may be
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;