@assuredloop/orca-bot-kit 0.30.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.
@@ -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/processes.js CHANGED
@@ -19,19 +19,18 @@
19
19
  // signals: SIGTERM first, to the whole group where every process in it is the
20
20
  // session's own, then up to 3 seconds for them to end, then SIGKILL to each
21
21
  // that still runs. Inside Codex's sandbox `ps` does not start (#298), and then
22
- // nothing is stopped and the answer says it cannot tell. OBK_PS, OBK_LSOF and
23
- // OBK_KILL override the three, as OBK_ORCA does Orca, so the suite runs on
24
- // fakes and sends no real signal.
22
+ // nothing is stopped and the answer says it cannot tell. The kit runs the three
23
+ // by their fixed paths, and no setting changes them (#652).
25
24
 
26
25
  import { spawnSync } from 'node:child_process';
27
26
  import { realpathSync } from 'node:fs';
28
27
  import path from 'node:path';
29
28
  import { setTimeout as delay } from 'node:timers/promises';
30
29
 
31
- import { lsofCli, psCli } from './orca.js';
30
+ import { LSOF, PS } from './orca.js';
32
31
 
33
- /** The `kill` this run sends signals with. OBK_KILL overrides it. */
34
- const killCli = () => process.env.OBK_KILL || '/bin/kill';
32
+ /** The `kill` the kit sends signals with, by its fixed path. */
33
+ const KILL = '/bin/kill';
35
34
 
36
35
  /** How long one `ps`, `lsof` or `kill` may take. */
37
36
  const READ_MS = 10_000;
@@ -273,7 +272,7 @@ function readProcesses() {
273
272
  * READ_MS.
274
273
  */
275
274
  function workingFolders(ms = READ_MS) {
276
- const asked = spawnSync(lsofCli(), ['-a', '-d', 'cwd', '-u', String(process.getuid()), '-Fpn'], { encoding: 'utf8', timeout: bound(ms), maxBuffer: 64 * 1024 * 1024 });
275
+ const asked = spawnSync(LSOF, ['-a', '-d', 'cwd', '-u', String(process.getuid()), '-Fpn'], { encoding: 'utf8', timeout: bound(ms), maxBuffer: 64 * 1024 * 1024 });
277
276
  if (asked.error?.code === 'ETIMEDOUT') return { unreadable: 'lsof did not answer in time' };
278
277
  if (asked.error || asked.status !== 0) return { unreadable: `lsof could not list the processes' working folders${because(asked)}` };
279
278
  const cwds = new Map();
@@ -292,7 +291,7 @@ function workingFolders(ms = READ_MS) {
292
291
  * own command line (review of PR #543). Or `{ unreadable: <why> }`.
293
292
  */
294
293
  function processTable(ms = READ_MS) {
295
- const asked = spawnSync(psCli(), ['-A', '-ww', '-o', 'pid=,ppid=,pgid=,uid=,stat=,lstart=,command='], { encoding: 'utf8', timeout: bound(ms), maxBuffer: 64 * 1024 * 1024 });
294
+ const asked = spawnSync(PS, ['-A', '-ww', '-o', 'pid=,ppid=,pgid=,uid=,stat=,lstart=,command='], { encoding: 'utf8', timeout: bound(ms), maxBuffer: 64 * 1024 * 1024 });
296
295
  if (asked.error?.code === 'ETIMEDOUT') return { unreadable: 'ps did not answer in time' };
297
296
  if (asked.error || asked.status !== 0) return { unreadable: `ps could not read the process table${because(asked)}` };
298
297
  const table = new Map();
@@ -383,7 +382,7 @@ const inside = (cwd, dir) => cwd === dir || cwd.startsWith(`${dir}${path.sep}`);
383
382
  */
384
383
  function signal(name, target, ms = READ_MS) {
385
384
  if (!Number.isInteger(target) || Math.abs(target) <= 1) return { refused: `the kit does not signal ${target}` };
386
- const sent = spawnSync(killCli(), ['-s', name, '--', String(target)], { encoding: 'utf8', timeout: bound(ms) });
385
+ const sent = spawnSync(KILL, ['-s', name, '--', String(target)], { encoding: 'utf8', timeout: bound(ms) });
387
386
  // Cut off at its time: whether the signal went is not known.
388
387
  if (sent.error?.code === 'ETIMEDOUT') return { late: true };
389
388
  if (!sent.error && sent.status === 0) return {};
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