@assuredloop/orca-bot-kit 0.9.0 → 0.11.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 0023). Orca's CLI
1
+ // Every call the kit makes to Orca goes through here (ADR 0025). 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
  //
@@ -177,7 +177,7 @@ export function asFolderProject(setupId, title) {
177
177
  */
178
178
  export const deleteProject = (setupId) => orca(['project', 'setup-delete', '--setup', setupId]);
179
179
 
180
- /** Said after a run that made, renamed or removed a project, whatever `tellWindow` answered. */
180
+ /** Said after a run that made or renamed a project, whatever `tellWindow` answered, and after a removal `reloadWindow` could not follow. */
181
181
  export const RELOAD_LINE = "If Orca's sidebar does not show it, reload the window with Cmd+Shift+R.";
182
182
 
183
183
  /** How long Orca's client waits for its runtime, and how long the kit waits for the client. */
@@ -190,13 +190,22 @@ const CLIENT_KILL_MS = 3000;
190
190
  *
191
191
  * Orca's window re-reads only when its runtime says the projects changed, and
192
192
  * `setup-update` and `setup-delete` do not say so. `project.update` with no
193
- * changes does, and Orca's CLI does not offer it, so this goes through Orca's
194
- * own runtime client out of the installed app, run by Orca's binary the way
195
- * its `bin/orca` runs its CLI (ADR 0023). None of that is Orca's published
196
- * interface, so anything that goes wrong is a quiet false: it never throws and
197
- * is never tried twice, and the caller prints `RELOAD_LINE` either way.
193
+ * changes does, and Orca's CLI does not offer it (ADR 0025). The caller prints
194
+ * `RELOAD_LINE` either way. It does not take a removed project out of the
195
+ * sidebar: `reloadWindow` does.
198
196
  */
199
- export function tellWindow(projectId) {
197
+ export const tellWindow = (projectId) => askRuntime('project.update', { projectId, updates: {} }) !== undefined;
198
+
199
+ /**
200
+ * One call to Orca's runtime for a method its CLI does not offer, and the
201
+ * runtime's answer, or undefined when there is none to be had.
202
+ *
203
+ * It goes through Orca's own runtime client out of the installed app, run by
204
+ * Orca's binary the way its `bin/orca` runs its CLI (ADR 0025). None of that is
205
+ * Orca's published interface, so anything that goes wrong is a quiet
206
+ * undefined: it never throws and is never tried twice.
207
+ */
208
+ function askRuntime(method, params) {
200
209
  try {
201
210
  // The CLI sits at <Orca.app>/Contents/Resources/bin/orca, often reached
202
211
  // through a link.
@@ -209,16 +218,55 @@ export function tellWindow(projectId) {
209
218
 
210
219
  const asked = spawnSync(
211
220
  path.join(contents, 'MacOS', 'Orca'),
212
- [WINDOW_SCRIPT, client, projectId, String(CLIENT_WAIT_MS)],
213
- { env, stdio: 'ignore', timeout: CLIENT_KILL_MS },
221
+ [RUNTIME_SCRIPT, client, method, JSON.stringify(params), String(CLIENT_WAIT_MS)],
222
+ { env, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: CLIENT_KILL_MS },
214
223
  );
215
- return asked.error === undefined && asked.status === 0;
224
+ if (asked.error !== undefined || asked.status !== 0) return undefined;
225
+ return parse(asked.stdout) ?? undefined;
226
+ } catch {
227
+ return undefined;
228
+ }
229
+ }
230
+
231
+ const RUNTIME_SCRIPT = fileURLToPath(new URL('./orca-runtime.cjs', import.meta.url));
232
+
233
+ /** The `osascript` this run asks. OBK_OSASCRIPT overrides it, as OBK_PS does `ps`. */
234
+ const osascriptCli = () => process.env.OBK_OSASCRIPT || '/usr/bin/osascript';
235
+
236
+ /** How long macOS is given to click the menu item. */
237
+ const RELOAD_KILL_MS = 5000;
238
+
239
+ /**
240
+ * Have Orca's window force-reload itself, after a project was removed: true
241
+ * only when it did (#343, ADR 0025).
242
+ *
243
+ * The window keeps a removed project in its sidebar until it is rebuilt, and
244
+ * `tellWindow` only relabels the row "Unknown" (stablyai/orca#23224; once a
245
+ * release fixes it, this can go behind a version check). Orca's own menu item
246
+ * Force Reload rebuilds it and keeps every terminal. So on macOS the kit has
247
+ * System Events click that item, in the Orca app its CLI belongs to and no
248
+ * other, and only while that Orca is the front app: a click on a background
249
+ * Orca is taken and does nothing, and the kit does not take the user's screen. None of that is Orca's published interface, and macOS may refuse it,
250
+ * so anything that goes wrong is a quiet false, as with `tellWindow`: it never
251
+ * throws and is never tried twice, and the caller prints `RELOAD_LINE`.
252
+ */
253
+ export function reloadWindow() {
254
+ if (process.platform !== 'darwin') return false;
255
+ try {
256
+ // <Orca.app>/Contents/Resources/bin/orca, often reached through a link.
257
+ const app = path.resolve(realpathSync(orcaCli()), '..', '..', '..', '..');
258
+ const asked = spawnSync(osascriptCli(), [RELOAD_SCRIPT, app], {
259
+ encoding: 'utf8',
260
+ stdio: ['ignore', 'pipe', 'pipe'],
261
+ timeout: RELOAD_KILL_MS,
262
+ });
263
+ return asked.error === undefined && asked.status === 0 && asked.stdout.trim() === 'reloaded';
216
264
  } catch {
217
265
  return false;
218
266
  }
219
267
  }
220
268
 
221
- const WINDOW_SCRIPT = fileURLToPath(new URL('./orca-window.cjs', import.meta.url));
269
+ const RELOAD_SCRIPT = fileURLToPath(new URL('./orca-reload.applescript', import.meta.url));
222
270
 
223
271
  /**
224
272
  * The live tabs of the Orca project at `home`, each under its own tab id.
@@ -229,9 +277,9 @@ const WINDOW_SCRIPT = fileURLToPath(new URL('./orca-window.cjs', import.meta.url
229
277
  * the real id, so an orphaned entry is given that one: the tab id is the key
230
278
  * (PRD 6.2), and this is where Orca is asked for it (tech notes, section 1).
231
279
  */
232
- export const tabs = (home) => orca(['terminal', 'list', '--worktree', `path:${home}`]).terminals
280
+ export const tabs = (home, options) => orca(['terminal', 'list', '--worktree', `path:${home}`], options).terminals
233
281
  .map((tab) => (tab.orphaned === true
234
- ? { ...tab, tabId: orca(['terminal', 'show', '--terminal', tab.handle]).terminal.tabId }
282
+ ? { ...tab, tabId: orca(['terminal', 'show', '--terminal', tab.handle], options).terminal.tabId }
235
283
  : tab));
236
284
 
237
285
  /**
@@ -282,11 +330,14 @@ export function harnessInTab(handle, timeoutMs) {
282
330
  answered: answer.ok === true,
283
331
  blockedReason: answer.result?.wait?.blockedReason,
284
332
  agent,
285
- ...frontOf(shown?.ptyId),
333
+ ...frontOf(handle, shown?.ptyId),
286
334
  ...screenOf(handle),
287
335
  };
288
336
  }
289
337
 
338
+ /** Orca's reason for a folder-trust screen, which it can go on giving once that is answered (#342). */
339
+ const TRUST_REASON = 'agent-trust-workspace';
340
+
290
341
  /** The kit's word for a tab whose screen shows a question of its harness's own (#329). */
291
342
  export const QUESTION_ON_SCREEN = 'question-on-screen';
292
343
 
@@ -320,7 +371,7 @@ const ON_A_CHOICE = /^( *[›❯] +)\d+\. /;
320
371
  * Whether the rows of a rendered screen hold a question of the harness's own.
321
372
  *
322
373
  * 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
374
+ * one (tech notes, section 1; ADR 0025). The same pointer starts the harness's
324
375
  * input line and its echo of the user's past turns, and the input line is the
325
376
  * lowest of them whenever it is on screen, so only the lowest pointer row is
326
377
  * asked about: a question counts while it stands in the input line's place.
@@ -359,6 +410,14 @@ export function tabToTypeInto(home, tabId, timeoutMs) {
359
410
  // A line typed into a screen waiting for an answer is that answer: once, it
360
411
  // confirmed Claude Code's folder-trust default, `No, exit` (tech notes,
361
412
  // section 1).
413
+ // Except where Orca still reports Codex's trust screen after it was
414
+ // answered, over an idle screen (#342). Nothing is typed then either, but
415
+ // there is nothing to answer, so the kit says what it saw. Claude Code's
416
+ // trust list is not numbered and the screen read cannot see it, so this is
417
+ // for Codex alone.
418
+ if (seen.blockedReason === TRUST_REASON && seen.agent === 'codex' && seen.question === false) {
419
+ return { unsure: `Orca still reports ${TRUST_REASON} in it, and its screen shows no question: Orca can go on reporting the trust screen after it is answered, and refuse a typed line while it does, so nothing was typed` };
420
+ }
362
421
  if (seen.blockedReason !== undefined) return { blocked: seen.blockedReason };
363
422
  // The shell in front is a tab with no harness, whatever a stale
364
423
  // `agentIdentity` says.
@@ -397,25 +456,63 @@ export function frontOfTab(handle) {
397
456
  } catch (error) {
398
457
  return { unreadable: `Orca would not show the tab: ${error.message}` };
399
458
  }
400
- return frontOf(shown?.ptyId);
459
+ return frontOf(handle, shown?.ptyId);
401
460
  }
402
461
 
403
462
  /** The `ps` this run reads. OBK_PS overrides it, as OBK_ORCA does Orca. */
404
463
  const psCli = () => process.env.OBK_PS || '/bin/ps';
405
464
 
406
465
  /**
407
- * Who holds the terminal of the pane `ptyId`: `{ front: 'shell' }`,
408
- * `{ front: 'program', command, pid }` with the name and pid of the process
409
- * leading the group in front, or `{ unreadable: <why> }`.
466
+ * Who holds the terminal of the tab `handle`, whose pane is `ptyId`:
467
+ * `{ front: 'shell' }`, `{ front: 'program', command, pid }` with the name and,
468
+ * where `ps` gave it, the pid of the process leading the group in front, or
469
+ * `{ unreadable: <why> }`.
470
+ *
471
+ * `ps` is asked first. Where it cannot read the tab, as inside Codex's
472
+ * sandbox, where it does not start at all, Orca's runtime is asked instead
473
+ * (#298, ADR 0025).
474
+ */
475
+ function frontOf(handle, ptyId) {
476
+ const read = frontByPs(ptyId);
477
+ if (read.unreadable === undefined) return read;
478
+ const asked = frontByOrca(handle);
479
+ return asked.unreadable === undefined ? asked : { unreadable: `${read.unreadable}, and ${asked.unreadable}` };
480
+ }
481
+
482
+ /**
483
+ * Who holds the tab's terminal as Orca's runtime says it, through
484
+ * `terminal.inspectProcess`: Orca's own daemon reads the process table from
485
+ * outside any sandbox of the kit's, and names what leads the terminal's
486
+ * foreground group. Only a `live` answer counts. A process named there is the
487
+ * program in front; none named and no child in front is the shell. Anything
488
+ * else is "cannot tell": for a moment after a harness quits, Orca can name
489
+ * nothing and still see a child in front (read in the Orca 1.4.212 bundle).
490
+ */
491
+ function frontByOrca(handle) {
492
+ const seen = askRuntime('terminal.inspectProcess', { terminal: handle })?.result?.process;
493
+ const evidence = seen?.foregroundProcessEvidence;
494
+ if (evidence?.verdict !== 'live') {
495
+ const why = typeof evidence?.reason === 'string' ? `: ${evidence.reason}` : '';
496
+ return { unreadable: `Orca's runtime could not say who is in front of it${why}` };
497
+ }
498
+ const name = (word) => typeof word === 'string' && word !== '';
499
+ if (name(evidence.processName)) return { front: 'program', command: evidence.processName };
500
+ if (evidence.processName === null && name(seen.foregroundProcess)) return { front: 'program', command: seen.foregroundProcess };
501
+ if (evidence.processName === null && seen.foregroundProcess === null && seen.hasChildProcesses === false) return { front: 'shell' };
502
+ return { unreadable: "Orca's runtime gave no answer the kit can read about who is in front of it" };
503
+ }
504
+
505
+ /**
506
+ * Who holds the terminal of the pane `ptyId`, as `ps` says it.
410
507
  *
411
508
  * Orca gives the pane's pid in `diagnostics memory` and nowhere else, and `ps`
412
- * gives that pid's terminal's foreground process group (ADR 0023).
509
+ * gives that pid's terminal's foreground process group (ADR 0025).
413
510
  * On macOS the pane is `login` with the shell as its child, so the shell is in
414
511
  * front when the group is the pane's own or that of a child of a `login` pane.
415
512
  * `diagnostics memory` is a diagnostics command and may change, so everything
416
513
  * here ends in "cannot tell" rather than in an error or a guess.
417
514
  */
418
- function frontOf(ptyId) {
515
+ function frontByPs(ptyId) {
419
516
  let pane;
420
517
  try {
421
518
  pane = orca(['diagnostics', 'memory']).worktrees
@@ -548,12 +645,12 @@ export function postMessage({ to, from, subject, body, type = 'status', thread }
548
645
  * the terminal `handle` when one is given, which is how a read works from a tab
549
646
  * bound to another Run; otherwise as this process's own terminal.
550
647
  */
551
- export const readMailbox = (id, { peek = false, handle } = {}) =>
552
- orca(['orchestration', 'check', '--run', id, ...as(handle), ...(peek ? ['--peek'] : [])]);
648
+ export const readMailbox = (id, { peek = false, handle } = {}, options) =>
649
+ orca(['orchestration', 'check', '--run', id, ...as(handle), ...(peek ? ['--peek'] : [])], options);
553
650
 
554
651
  /** Say a batch has been read. Orca answers with the batch after it. */
555
- export const ackMailbox = (id, delivery, handle) =>
556
- orca(['orchestration', 'check', '--run', id, ...as(handle), '--ack', delivery]);
652
+ export const ackMailbox = (id, delivery, handle, options) =>
653
+ orca(['orchestration', 'check', '--run', id, ...as(handle), '--ack', delivery], options);
557
654
 
558
655
  /** The reader of a `check`: the terminal named, or this process's own. */
559
656
  const as = (handle) => (handle === undefined ? [] : ['--terminal', handle]);