@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 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.1.0). Pi asks before no tool call, so a Pi session is always
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. Mail, temporary sessions, skills and
161
- health for a Pi session come later, and so do `obk init --harness pi` and
162
- `obk session clear` and `compact` on Pi.
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.1.0, for a bot on Pi
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assuredloop/orca-bot-kit",
3
- "version": "0.31.0",
3
+ "version": "0.32.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
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, section 3). */
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
- * Clear the session: `/clear` on Claude Code, `/new` on Codex. Returns
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 `verb`, clear or compact, is refused on Pi until a later slice of #547.
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 !== undefined && harnessOf(settings, known.harness) === 'pi') {
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) => (harness === 'claude' ? 'Claude Code' : 'Codex');
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. It works in
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 after = harness === 'claude' ? 'Once that is settled, type /reload-skills in its tab.' : 'Its next turn takes the change by itself.';
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', '/reload-skills was typed into its tab; a busy session runs it when its turn ends'],
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
- 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/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;
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 3),
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 { DEFAULT_APPROVAL } from './launch.js';
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 used = file === undefined ? {} : (harness === 'claude' ? fromClaude : fromCodex)(sinceStart(lines(file), since));
26
- return Object.fromEntries(SETTINGS.map((name) => [name, judged(harness, name, asked(session, name), used[name])]));
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
- * What the record says since the kit last started the session. A start resumes
31
- * the conversation it had, so the file still holds what the process before it
32
- * wrote, and that says what that process ran with, not this one. Everything
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 sinceStart(entries, since) {
43
+ function startOf(entries, since) {
38
44
  const from = Date.parse(since ?? '');
39
- if (Number.isNaN(from)) return [];
40
- const first = entries.findIndex((entry) => Date.parse(entry.timestamp ?? '') >= from);
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: the default, when it names none (ADR 0015).
50
- if (name === 'approval') return set ? String(value).trim() : DEFAULT_APPROVAL;
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
- const shown = observed === undefined ? {} : { observed: said(name, observed) };
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 verdict = same(harness, name, configured, observed);
58
- if (verdict === undefined) return { state: 'unknown', configured, ...shown };
59
- return { state: verdict ? 'match' : 'mismatch', configured, ...shown };
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). Outwards: the kit takes away only the links it put
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). Nothing is ever restarted.
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
- typeIntoTab(found.handle, '/reload-skills');
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
- if (chosen.harness.value === 'pi') {
79
- throw new Error(`${name} would run on Pi, and a temporary session on Pi comes in a later slice of #547. Make it on claude or codex. Nothing was made.`);
80
- }
81
- refuseWiderApproval(bots, caller, bot, maker, given.approval);
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
- throw new Error(`--approval ${level} 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.`);
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 sent = await answerTeach({ run: `${caller.bot}/${name}`, home: caller.home, session: name }, 'temp');
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
  }