@assuredloop/orca-bot-kit 0.6.0 → 0.8.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/orca.js CHANGED
@@ -1,4 +1,4 @@
1
- // Every call the kit makes to Orca goes through here (ADR 0021). Orca's CLI
1
+ // Every call the kit makes to Orca goes through here (ADR 0023). Orca's CLI
2
2
  // changes often, so the kit reads `--json` and never the human text, and keeps
3
3
  // the parsing in one place.
4
4
  //
@@ -192,7 +192,7 @@ const CLIENT_KILL_MS = 3000;
192
192
  * `setup-update` and `setup-delete` do not say so. `project.update` with no
193
193
  * changes does, and Orca's CLI does not offer it, so this goes through Orca's
194
194
  * own runtime client out of the installed app, run by Orca's binary the way
195
- * its `bin/orca` runs its CLI (ADR 0021). None of that is Orca's published
195
+ * its `bin/orca` runs its CLI (ADR 0023). None of that is Orca's published
196
196
  * interface, so anything that goes wrong is a quiet false: it never throws and
197
197
  * is never tried twice, and the caller prints `RELOAD_LINE` either way.
198
198
  */
@@ -262,7 +262,9 @@ export const retitleTab = (handle, title) =>
262
262
  * undefined when that cannot be read, with `unreadable` saying why. `answered` (an `ok` from `tui-idle`) and
263
263
  * `agent` are Orca's own hints, for a caller that has nothing better.
264
264
  * `blockedReason` is Orca saying something on screen wants answering, which is
265
- * for the caller to deal with, not the kit.
265
+ * for the caller to deal with, not the kit. `question` is the kit's own look at
266
+ * the screen, as `questionIn` answers, and `screenUnreadable` says why there
267
+ * was none.
266
268
  */
267
269
  export function harnessInTab(handle, timeoutMs) {
268
270
  const args = ['terminal', 'wait', '--terminal', handle, '--for', 'tui-idle', '--timeout-ms', String(timeoutMs)];
@@ -281,9 +283,59 @@ export function harnessInTab(handle, timeoutMs) {
281
283
  blockedReason: answer.result?.wait?.blockedReason,
282
284
  agent,
283
285
  ...frontOf(shown?.ptyId),
286
+ ...screenOf(handle),
284
287
  };
285
288
  }
286
289
 
290
+ /** The kit's word for a tab whose screen shows a question of its harness's own (#329). */
291
+ export const QUESTION_ON_SCREEN = 'question-on-screen';
292
+
293
+ /**
294
+ * What the tab `handle` renders: `{ question }`, true when it shows a question
295
+ * of its harness's own, or `{ screenUnreadable: <why> }`. Orca refusing is an
296
+ * answer that cannot be read, not an error, and so is anything that is not the
297
+ * rendered screen: accumulated output comes back in fragments (tech notes,
298
+ * section 1).
299
+ */
300
+ function screenOf(handle) {
301
+ let read;
302
+ try {
303
+ read = orca(['terminal', 'read', '--terminal', handle, '--screen']).terminal;
304
+ } catch (error) {
305
+ return { screenUnreadable: `Orca would not read its screen: ${error.message}` };
306
+ }
307
+ if (read?.source !== 'screen' || !Array.isArray(read.tail)) {
308
+ return { screenUnreadable: `Orca gave no rendered screen for it (source: ${read?.source ?? 'none'})` };
309
+ }
310
+ return { question: questionIn(read.tail) };
311
+ }
312
+
313
+ /** A row the harness starts with its selection pointer: `›` on Codex, `❯` on Claude Code. */
314
+ const POINTER_ROW = /^ *[›❯]/;
315
+
316
+ /** That pointer on a numbered choice, and the column its number starts in. */
317
+ const ON_A_CHOICE = /^( *[›❯] +)\d+\. /;
318
+
319
+ /**
320
+ * Whether the rows of a rendered screen hold a question of the harness's own.
321
+ *
322
+ * Every one seen is a numbered list of choices with the harness's pointer on
323
+ * one (tech notes, section 1; ADR 0023). The same pointer starts the harness's
324
+ * input line and its echo of the user's past turns, and the input line is the
325
+ * lowest of them whenever it is on screen, so only the lowest pointer row is
326
+ * asked about: a question counts while it stands in the input line's place.
327
+ * Unnumbered lists do not count. Codex puts a status row right under its input
328
+ * line, lined up with it, and by its layout that is one.
329
+ */
330
+ export function questionIn(rows) {
331
+ const at = rows.findLastIndex((row) => POINTER_ROW.test(row));
332
+ const pointer = at < 0 ? null : ON_A_CHOICE.exec(rows[at]);
333
+ if (pointer === null) return false;
334
+ const column = pointer[1].length;
335
+ const choice = (row) => typeof row === 'string' && row.slice(0, column).trim() === '' && /^\d+\. /.test(row.slice(column));
336
+ return choice(rows[at - 1]) || choice(rows[at + 1]);
337
+ }
338
+
287
339
  /**
288
340
  * Whether the kit may type a line into `tabId`, the tab the book holds for a
289
341
  * session in the Orca project at `home`, and the handle to type into when it
@@ -323,6 +375,13 @@ export function tabToTypeInto(home, tabId, timeoutMs) {
323
375
  : `${seen.command} holds its terminal, and Orca names ${seen.agent ?? 'no agent'} in it`;
324
376
  return { unsure: `the kit could not tell whether a harness is running in it (${why}), so nothing was typed` };
325
377
  }
378
+ // Orca's reason does not cover every question: it called Codex's update
379
+ // offer idle, and a return typed into it took "Update now" (#329). So the
380
+ // screen is read too, and one that cannot be read is not a reason to type.
381
+ if (seen.question === true) return { blocked: QUESTION_ON_SCREEN };
382
+ if (seen.question === undefined) {
383
+ return { unsure: `the kit could not tell whether a question is waiting on its screen (${seen.screenUnreadable}), so nothing was typed` };
384
+ }
326
385
  return { handle: live.handle, agent: seen.agent };
327
386
  }
328
387
 
@@ -346,11 +405,11 @@ const psCli = () => process.env.OBK_PS || '/bin/ps';
346
405
 
347
406
  /**
348
407
  * Who holds the terminal of the pane `ptyId`: `{ front: 'shell' }`,
349
- * `{ front: 'program', command }` with the name of the process leading the
350
- * group in front, or `{ unreadable: <why> }`.
408
+ * `{ front: 'program', command, pid }` with the name and pid of the process
409
+ * leading the group in front, or `{ unreadable: <why> }`.
351
410
  *
352
411
  * Orca gives the pane's pid in `diagnostics memory` and nowhere else, and `ps`
353
- * gives that pid's terminal's foreground process group (ADR 0021).
412
+ * gives that pid's terminal's foreground process group (ADR 0023).
354
413
  * On macOS the pane is `login` with the shell as its child, so the shell is in
355
414
  * front when the group is the pane's own or that of a child of a `login` pane.
356
415
  * `diagnostics memory` is a diagnostics command and may change, so everything
@@ -374,7 +433,20 @@ function frontOf(ptyId) {
374
433
  const front = psLine(own.tpgid);
375
434
  if (front === undefined) return { unreadable: `ps could not read the process in front, pid ${own.tpgid}` };
376
435
  const shell = front.ppid === pane && path.basename(own.comm) === 'login';
377
- return shell ? { front: 'shell' } : { front: 'program', command: path.basename(front.comm) };
436
+ return shell ? { front: 'shell' } : { front: 'program', command: path.basename(front.comm), pid: own.tpgid };
437
+ }
438
+
439
+ /**
440
+ * The process `pid` as `ps -E` gives it, word by word: its command, its
441
+ * arguments, then its environment as `NAME=value` words. Undefined when it
442
+ * cannot be read. macOS gives a same-user harness's environment this way (tech
443
+ * notes, section 1); nothing in it says where the arguments end, so a caller
444
+ * looks for one whole word it knows.
445
+ */
446
+ export function wordsOfProcess(pid) {
447
+ const asked = spawnSync(psCli(), ['-E', '-ww', '-o', 'command=', '-p', String(pid)], { encoding: 'utf8' });
448
+ if (asked.error || asked.status !== 0) return undefined;
449
+ return asked.stdout.trim().split(/\s+/);
378
450
  }
379
451
 
380
452
  /** One process as `ps` gives it, read only: `{ ppid, tpgid, comm }`, or undefined. */
package/src/record.js CHANGED
@@ -166,22 +166,26 @@ function unclaimedFor(book, home, bot, tabId, id) {
166
166
  }
167
167
 
168
168
  /**
169
- * Whether the harness that ran this hook is the session's own — the one the kit
170
- * typed into the tab — rather than a harness started inside the session.
169
+ * Whether the harness that ran this hook is the session's own — the one the
170
+ * tab's shell started — rather than a harness started inside the session.
171
171
  *
172
172
  * Proved live on both harnesses (tech notes, sections 2 and 3): a hook's process
173
173
  * is the harness's own child, or the child of the shell the harness ran the hook
174
174
  * command through. Either way the harness itself is the process just above, and
175
- * the session's harness is the child of the shell named in the launch line. A
176
- * harness started inside the session sits further down a longer chain, under
177
- * whatever shell the agent ran it from, so it can never answer this.
175
+ * the session's harness is the child of the tab's shell. A harness started
176
+ * inside the session sits further down a longer chain, under whatever shell the
177
+ * agent ran it from, so it can never answer this.
178
+ *
179
+ * The launch line names the tab's shell. A tab Orca brought back by itself has
180
+ * no launch line (#318), and there the tab's shell is the one `login` started
181
+ * for the pane, which is where Orca's own resume puts the harness too (tech
182
+ * notes, section 1).
178
183
  *
179
184
  * Anything it cannot establish is a no: a report that cannot be shown to be the
180
185
  * session's own is not written down.
181
186
  */
182
187
  export function startedTheSession(shellPid, pid = process.pid) {
183
- if (!/^[0-9]+$/.test(String(shellPid ?? ''))) return false;
184
- const shell = Number(shellPid);
188
+ if (shellPid !== undefined && !/^[0-9]+$/.test(String(shellPid))) return false;
185
189
 
186
190
  const tree = processTree();
187
191
  if (tree === undefined) return false;
@@ -190,11 +194,21 @@ export function startedTheSession(shellPid, pid = process.pid) {
190
194
  const parent = mine === undefined ? undefined : tree.get(mine.ppid);
191
195
  if (parent === undefined) return false;
192
196
 
193
- if (parent.ppid === shell) return true;
194
- if (!isShell(parent.command)) return false;
197
+ if (shellPid !== undefined) {
198
+ const shell = Number(shellPid);
199
+ if (parent.ppid === shell) return true;
200
+ if (!isShell(parent.command)) return false;
201
+
202
+ const above = tree.get(parent.ppid);
203
+ return above !== undefined && above.ppid === shell;
204
+ }
195
205
 
196
- const above = tree.get(parent.ppid);
197
- return above !== undefined && above.ppid === shell;
206
+ // With no shell named, which process is the harness has to be settled first:
207
+ // the one above the shell the hook ran through, or the hook's own parent.
208
+ const harness = isShell(parent.command) ? tree.get(parent.ppid) : parent;
209
+ const shell = harness === undefined ? undefined : tree.get(harness.ppid);
210
+ const pane = shell === undefined ? undefined : tree.get(shell.ppid);
211
+ return pane !== undefined && path.basename(pane.command) === 'login';
198
212
  }
199
213
 
200
214
  /** Every process on the machine, by pid: who its parent is and what it runs. */
package/src/skills.js CHANGED
@@ -292,7 +292,7 @@ export function unlinkSkills(home) {
292
292
  * user made, or one of the kit's that they have since repointed, is theirs.
293
293
  *
294
294
  * It is the kit's own file and lives beside the hook files it already keeps in
295
- * the bot folder (ADR 0020). A record that cannot be read is treated as an
295
+ * the bot folder (ADR 0022). A record that cannot be read is treated as an
296
296
  * empty one: the worst that follows is that the kit leaves alone something it
297
297
  * would otherwise have tidied up, which is the safe way round.
298
298
  */
package/src/up.js CHANGED
@@ -13,7 +13,7 @@ import { botDir, botNames, displayName, readBot } from './bot.js';
13
13
  import { conversationsIn, hasConversation, heldAsUserTurn, transcriptsIn } from './conversations.js';
14
14
  import { installHook } from './hooks.js';
15
15
  import { addressOf, harnessOf, isAddressOf, isShortPrompt, launchCommand, mailboxStep, reachesMail, sessionTrouble, startPrompt, workDirOf } from './launch.js';
16
- import { asFolderProject, coordinatorOf, findProject, harnessInTab, makeMailbox, makeProject, openTab, retitleTab, tabs, TERMINAL_ENV, TIMED_OUT, tellWindow, typeIntoTab, useMailbox } from './orca.js';
16
+ import { asFolderProject, coordinatorOf, findProject, harnessInTab, makeMailbox, makeProject, openTab, QUESTION_ON_SCREEN, retitleTab, tabs, TERMINAL_ENV, TIMED_OUT, tellWindow, typeIntoTab, useMailbox } from './orca.js';
17
17
  import { TAB_ENV } from './record.js';
18
18
  import { buildAgents, rulesStamp } from './rules.js';
19
19
  import { linkSkills } from './skills.js';
@@ -94,7 +94,7 @@ export async function bringUp(bots, { bot: onlyBot, session: onlySession } = {})
94
94
  *
95
95
  * Separate from the tabs because the order matters twice over. A session reads
96
96
  * its rules, its skills and its hooks as it comes up, so all three have to be
97
- * there before the tab is (PRD 6.6, ADR 0020) — and a restart closes a tab in
97
+ * there before the tab is (PRD 6.6, ADR 0022) — and a restart closes a tab in
98
98
  * between, so everything that can refuse must have refused before that. It
99
99
  * refuses by throwing, exactly as `up` always has; a bot it leaves out of
100
100
  * `running` is one whose sessions must not be started.
@@ -140,7 +140,7 @@ export function prepareBots(bots, names, onlySession) {
140
140
 
141
141
  // And the kit's hook goes into every bot folder before Orca is asked for
142
142
  // anything, for the same reason: a harness reads its hooks when it comes up,
143
- // so one written later would miss the session it was written for (ADR 0020),
143
+ // so one written later would miss the session it was written for (ADR 0022),
144
144
  // and a bot folder the kit cannot write it into stops the run with nothing
145
145
  // opened anywhere. Only the harnesses a bot actually runs on; a bot with no
146
146
  // sessions gets none.
@@ -440,7 +440,7 @@ async function heldInRecord(home, name, harness, prompt, { launched, running })
440
440
 
441
441
  /**
442
442
  * One look at a tab the kit just typed a launch line into: whether a harness
443
- * is running there, and what it is waiting on if Orca says.
443
+ * is running there, and what it is waiting on if Orca or its screen says.
444
444
  *
445
445
  * The harness is up when something other than the shell holds the tab's
446
446
  * terminal, busy or not, or when Orca sees a question on its screen. Orca's
@@ -452,7 +452,10 @@ function lookFor(handle, timeoutMs) {
452
452
  const seen = harnessInTab(handle, timeoutMs);
453
453
  const running = seen.blockedReason !== undefined
454
454
  || (seen.front === undefined ? seen.answered || seen.agent !== undefined : seen.front === 'program');
455
- return { running, blockedReason: seen.blockedReason };
455
+ // A question the kit sees on the screen of a harness that is running, where
456
+ // Orca named none (#329).
457
+ const question = running && seen.question === true ? QUESTION_ON_SCREEN : undefined;
458
+ return { running, blockedReason: seen.blockedReason ?? question };
456
459
  }
457
460
 
458
461
  /**