@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.
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
- import { kitRunTrouble, permissionsTrouble, runsOnClaude, runsOnCodex } from './permissions.js';
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';
@@ -49,29 +50,14 @@ export function checkHealth(bots, { bot: onlyBot } = {}) {
49
50
  const names = botsNamed(bots, onlyBot);
50
51
  const setups = projects();
51
52
 
52
- const found = [...orcaSettingFindings(), ...leftInOrca(bots, setups), ...leftBeside(bots), ...kitRunElsewhere(bots)];
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
 
58
- /**
59
- * The rules beside every bot's folder that let the kit's run run any command
60
- * (#635), for the harnesses the fleet's bots run on. A bot the kit cannot
61
- * read is named by its own check.
62
- */
63
- function kitRunElsewhere(bots) {
64
- const read = botNames(bots).flatMap((name) => {
65
- try {
66
- return [readBot(botDir(bots, name), name)];
67
- } catch {
68
- return [];
69
- }
70
- });
71
- return kitRunTrouble(bots, { claude: read.some(runsOnClaude), codex: read.some(runsOnCodex) })
72
- .map(({ where, says }) => finding('config', where, says));
73
- }
74
-
75
61
  /** One finding, as the report and the `--json` answer carry it. */
76
62
  const finding = (kind, where, says, bot) => ({ kind, ...(bot === undefined ? {} : { bot }), where, says });
77
63
 
@@ -202,7 +188,7 @@ function leftBeside(bots) {
202
188
  }
203
189
 
204
190
  /** Everything wrong with one bot. Its running sessions are added to `sessions`. */
205
- function aboutBot(bots, name, setups, sessions) {
191
+ function aboutBot(bots, name, setups, sessions, pi) {
206
192
  const home = botDir(bots, name);
207
193
 
208
194
  let bot;
@@ -222,7 +208,8 @@ function aboutBot(bots, name, setups, sessions) {
222
208
  ...rolesTrouble(home, bot).map(said('config')),
223
209
  ...agentsTrouble(bots, home, bot).map(said('config')),
224
210
  ...hooksOf(bots, home, bot).map(said('config')),
225
- ...permissionsTrouble(bots, home, bot).map(said('config')),
211
+ ...permissionsTrouble(home, bot).map(said('config')),
212
+ ...piReadiness(home, bot, pi).map(said('config')),
226
213
  ...skillsTrouble(bots, home, bot).map(said('skill')),
227
214
  ...inOrca(bots, home, bot, setups, sessions),
228
215
  ...leftRunning(home, bot),
@@ -278,6 +265,40 @@ function sessionSettings(home, bot) {
278
265
  });
279
266
  }
280
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
+
281
302
  /** What is wrong with the roles the bot's temporary sessions are made in (#465). */
282
303
  function rolesTrouble(home, bot) {
283
304
  const where = path.join(home, 'bot.yaml');
@@ -488,7 +509,7 @@ function inOrca(bots, home, bot, setups, sessions) {
488
509
  }
489
510
  }
490
511
 
491
- found.push(...offTheBook(real ?? home, bot, book, onRecord));
512
+ found.push(...offTheBook(bots, real ?? home, bot, book, onRecord));
492
513
 
493
514
  // A tab outside the book is somebody else's, and in Bot Father's project it
494
515
  // is the ops tab: the one tab the kit deliberately keeps no record of, so it
@@ -572,9 +593,11 @@ function unannounced(bots, home, bot, name, entry) {
572
593
  * The conversations a harness has on record in this bot's folder that the book
573
594
  * does not name anywhere. The hook is how an id reaches the book, and when it
574
595
  * fails it fails quietly (ADR 0022), so this is where a stale book shows: the
575
- * 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).
576
599
  */
577
- function offTheBook(home, bot, book, onRecord) {
600
+ function offTheBook(bots, home, bot, book, onRecord) {
578
601
  // A retired session's conversations are in the book too, under `retired`.
579
602
  const named = sessionIdsIn(book);
580
603
  for (const entry of Object.values(book.sessions)) {
@@ -583,10 +606,32 @@ function offTheBook(home, bot, book, onRecord) {
583
606
 
584
607
  // A subagent's conversation is no session's, so the book has no reason to name it.
585
608
  const stray = onRecord.filter((one) => !one.subagent && !named.has(one.id));
586
- 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
+ }
587
617
 
588
- const listed = stray.map((one) => `${one.id} (begun ${one.at})`).join(', ');
589
- 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;
590
635
  }
591
636
 
592
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. */
@@ -597,7 +642,7 @@ function recordIn(home, bot, book) {
597
642
  if (Array.isArray(book.retired) && book.retired.length > 0) for (const harness of HARNESSES) harnesses.add(harness);
598
643
  return [...harnesses]
599
644
  .filter((harness) => HARNESSES.includes(harness))
600
- .flatMap((harness) => transcriptsIn(harness, home));
645
+ .flatMap((harness) => transcriptsIn(harness, home).map((one) => ({ ...one, harness })));
601
646
  }
602
647
 
603
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
@@ -381,8 +381,8 @@ function askRuntime(method, params, killMs = CLIENT_KILL_MS) {
381
381
 
382
382
  const RUNTIME_SCRIPT = fileURLToPath(new URL('./orca-runtime.cjs', import.meta.url));
383
383
 
384
- /** The `osascript` this run asks. OBK_OSASCRIPT overrides it, as OBK_PS does `ps`. */
385
- const osascriptCli = () => process.env.OBK_OSASCRIPT || '/usr/bin/osascript';
384
+ /** The `osascript` the kit asks, by its fixed path: no setting changes it (#652). */
385
+ const OSASCRIPT = '/usr/bin/osascript';
386
386
 
387
387
  /** How long macOS is given to click the menu item. */
388
388
  const RELOAD_KILL_MS = 5000;
@@ -406,7 +406,7 @@ export function reloadWindow() {
406
406
  try {
407
407
  // <Orca.app>/Contents/Resources/bin/orca, often reached through a link.
408
408
  const app = path.resolve(realpathSync(orcaCli()), '..', '..', '..', '..');
409
- const asked = spawnSync(osascriptCli(), [RELOAD_SCRIPT, app], {
409
+ const asked = spawnSync(OSASCRIPT, [RELOAD_SCRIPT, app], {
410
410
  encoding: 'utf8',
411
411
  stdio: ['ignore', 'pipe', 'pipe'],
412
412
  timeout: RELOAD_KILL_MS,
@@ -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
  /**
@@ -741,8 +827,8 @@ export function frontOfTab(handle) {
741
827
  return frontOf(handle, shown?.ptyId);
742
828
  }
743
829
 
744
- /** The `ps` this run reads. OBK_PS overrides it, as OBK_ORCA does Orca. */
745
- export const psCli = () => process.env.OBK_PS || '/bin/ps';
830
+ /** The `ps` the kit reads, by its fixed path: no setting changes it (#652). */
831
+ export const PS = '/bin/ps';
746
832
 
747
833
  /**
748
834
  * Who holds the terminal of the tab `handle`, whose pane is `ptyId`:
@@ -838,9 +924,9 @@ function panePid(ptyId, readMs) {
838
924
  return { pane };
839
925
  }
840
926
 
841
- /** The `lsof` and `stty` this run asks. OBK_LSOF and OBK_STTY override them, as OBK_PS does `ps`. */
842
- export const lsofCli = () => process.env.OBK_LSOF || '/usr/sbin/lsof';
843
- const sttyCli = () => process.env.OBK_STTY || '/bin/stty';
927
+ /** The `lsof` and `stty` the kit asks, by their fixed paths, as `ps`. */
928
+ export const LSOF = '/usr/sbin/lsof';
929
+ const STTY = '/bin/stty';
844
930
 
845
931
  /**
846
932
  * One look at the shell of a tab the kit has just opened, before anything is
@@ -893,7 +979,7 @@ export function shellInTab(handle, until) {
893
979
  * or as the pane itself where the pane is the shell.
894
980
  */
895
981
  function ttyMode(pid, left) {
896
- const listed = spawnSync(lsofCli(), ['-a', '-R', '-d', '0', '-u', String(process.getuid()), '-FpRn'], { encoding: 'utf8', timeout: left() });
982
+ const listed = spawnSync(LSOF, ['-a', '-R', '-d', '0', '-u', String(process.getuid()), '-FpRn'], { encoding: 'utf8', timeout: left() });
897
983
  if (listed.error?.code === 'ETIMEDOUT') return { unreadable: 'lsof did not answer in time', late: true };
898
984
  if (listed.error || listed.status !== 0) return { unreadable: `lsof could not list the processes' ttys` };
899
985
  // One record per process: `p<pid>`, `R<parent pid>`, `f0`, `n<name>`.
@@ -906,7 +992,7 @@ function ttyMode(pid, left) {
906
992
  }
907
993
  if (tty === undefined) return { unreadable: `lsof named no tty for the shell of its pane, pid ${pid}` };
908
994
 
909
- const read = spawnSync(sttyCli(), ['-a', '-f', tty], { encoding: 'utf8', timeout: left() });
995
+ const read = spawnSync(STTY, ['-a', '-f', tty], { encoding: 'utf8', timeout: left() });
910
996
  if (read.error?.code === 'ETIMEDOUT') return { unreadable: `stty did not answer in time for ${tty}`, late: true };
911
997
  if (read.error || read.status !== 0) return { unreadable: `stty could not read ${tty}` };
912
998
  const words = read.stdout.split(/\s+/);
@@ -926,14 +1012,14 @@ function ttyMode(pid, left) {
926
1012
  * looks for one whole word it knows.
927
1013
  */
928
1014
  export function wordsOfProcess(pid) {
929
- const asked = spawnSync(psCli(), ['-E', '-ww', '-o', 'command=', '-p', String(pid)], { encoding: 'utf8' });
1015
+ const asked = spawnSync(PS, ['-E', '-ww', '-o', 'command=', '-p', String(pid)], { encoding: 'utf8' });
930
1016
  if (asked.error || asked.status !== 0) return undefined;
931
1017
  return asked.stdout.trim().split(/\s+/);
932
1018
  }
933
1019
 
934
1020
  /** One process as `ps` gives it, read only: `{ ppid, tpgid, comm }`, `{ late: true }` past `readMs`, or undefined. */
935
1021
  function psLine(pid, readMs) {
936
- const asked = spawnSync(psCli(), ['-o', 'pid=,ppid=,tpgid=,comm=', '-p', String(pid)], { encoding: 'utf8', ...(readMs === undefined ? {} : { timeout: readMs }) });
1022
+ const asked = spawnSync(PS, ['-o', 'pid=,ppid=,tpgid=,comm=', '-p', String(pid)], { encoding: 'utf8', ...(readMs === undefined ? {} : { timeout: readMs }) });
937
1023
  if (asked.error?.code === 'ETIMEDOUT') return { late: true };
938
1024
  if (asked.error || asked.status !== 0) return undefined;
939
1025
  const line = /^\s*(\d+)\s+(\d+)\s+(-?\d+)\s+(.+?)\s*$/.exec(asked.stdout);