@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/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
  }