@assuredloop/orca-bot-kit 0.18.0 → 0.19.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
@@ -383,16 +383,26 @@ export const QUESTION_ON_SCREEN = 'question-on-screen';
383
383
  * section 1).
384
384
  */
385
385
  function screenOf(handle, readMs) {
386
+ const read = screenRows(handle, readMs);
387
+ return read.rows === undefined ? { screenUnreadable: read.unreadable } : { question: questionIn(read.rows) };
388
+ }
389
+
390
+ /**
391
+ * The rows the tab `handle` renders, `{ rows }`, or `{ unreadable: <why> }`.
392
+ * Orca refusing, or answering with anything but the rendered screen, is a
393
+ * screen that cannot be read, not an error.
394
+ */
395
+ export function screenRows(handle, readMs) {
386
396
  let read;
387
397
  try {
388
398
  read = orca(['terminal', 'read', '--terminal', handle, '--screen'], { timeoutMs: readMs }).terminal;
389
399
  } catch (error) {
390
- return { screenUnreadable: `Orca would not read its screen: ${error.message}` };
400
+ return { unreadable: `Orca would not read its screen: ${error.message}` };
391
401
  }
392
402
  if (read?.source !== 'screen' || !Array.isArray(read.tail)) {
393
- return { screenUnreadable: `Orca gave no rendered screen for it (source: ${read?.source ?? 'none'})` };
403
+ return { unreadable: `Orca gave no rendered screen for it (source: ${read?.source ?? 'none'})` };
394
404
  }
395
- return { question: questionIn(read.tail) };
405
+ return { rows: read.tail };
396
406
  }
397
407
 
398
408
  /** A row the harness starts with its selection pointer: `›` on Codex, `❯` on Claude Code. */
package/src/temp.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // `obk temp make` and `obk temp retire`: a session's own temporary sessions
2
- // (PRD 6.4, #227).
2
+ // (PRD 6.4, #227). And `obk temp trust-hooks`, the one answer a maker gives its
3
+ // Codex run's hooks review (#238).
3
4
  //
4
5
  // Any long-lived session can make a temporary session of its own bot for a
5
6
  // piece of work, and retire it when the work is done, without Bot Father, who
@@ -22,11 +23,13 @@
22
23
 
23
24
  import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
24
25
  import path from 'node:path';
26
+ import { setTimeout as pause } from 'node:timers/promises';
25
27
 
26
28
  import { readBook, updateBook } from './book.js';
27
29
  import { addSession, dropSession, NAME, readBot } from './bot.js';
28
- import { isShortPrompt, ownCli, shellWord, startPrompt, workDirOf } from './launch.js';
30
+ import { harnessOf, isShortPrompt, ownCli, shellWord, startPrompt, workDirOf } from './launch.js';
29
31
  import { sessionInTab } from './message.js';
32
+ import { orca, screenRows, tabs } from './orca.js';
30
33
  import { retireSession } from './retire.js';
31
34
  import { bringUp, promptPath } from './up.js';
32
35
 
@@ -61,10 +64,14 @@ export async function makeTemp(bots, { tab, ...given }) {
61
64
  const settings = { name: given.name };
62
65
  const harness = given.harness ?? maker?.harness;
63
66
  if (harness !== undefined) settings.harness = harness;
67
+ // On another harness than its maker's, the model, the effort and the context
68
+ // are that harness's own, and only the approval carries over (#238, PRD 6.4).
69
+ const same = (harness ?? bot.harness) === harnessOf(maker ?? {}, bot.harness);
64
70
  for (const field of INHERITED) {
65
- const value = given[field] ?? maker?.[field];
71
+ const value = given[field] ?? (same || field === 'approval' ? maker?.[field] : undefined);
66
72
  if (value !== undefined) settings[field] = value;
67
73
  }
74
+ if (given.extra_args !== undefined) settings.extra_args = given.extra_args;
68
75
  if (given.prompt !== undefined) settings.prompt = given.prompt;
69
76
  else settings.prompt_file = given.prompt_file;
70
77
  settings.work_dir = `work/${given.name}`;
@@ -145,28 +152,110 @@ function writePromptFirst(bots, caller, settings) {
145
152
  */
146
153
  export async function retireTemp(bots, { tab, name }) {
147
154
  const caller = callerIn(bots, tab, 'retire');
155
+ ownTemp(caller, name, 'retire', 'Nothing was retired.');
156
+ return { ...(await retireSession(bots, { bot: caller.bot, session: name })), maker: caller.session };
157
+ }
158
+
159
+ /**
160
+ * The caller's own temporary session `name`, from bot.yaml, or a refusal: one
161
+ * the bot does not have, a long-lived one, or one another session made.
162
+ * `verb` finishes "to <verb>", and `nothing` is the sentence that ends each.
163
+ */
164
+ function ownTemp(caller, name, verb, nothing) {
148
165
  const bot = readBot(caller.home, caller.bot);
149
- if (!bot.sessions.some((session) => session.name === name)) {
150
- throw new Error(`${caller.bot} has no session called ${name}, so there is nothing of yours to retire. Nothing was retired.`);
166
+ const session = bot.sessions.find((one) => one.name === name);
167
+ if (session === undefined) {
168
+ throw new Error(`${caller.bot} has no session called ${name}, so there is nothing of yours to ${verb}. ${nothing}`);
151
169
  }
152
170
  const temporary = readBook(caller.home).sessions[name]?.temporary;
153
171
  if (temporary === undefined) {
154
- throw new Error(`${caller.bot}/${name} is a long-lived session, and those are Bot Father's to retire. Nothing was retired.`);
172
+ throw new Error(`${caller.bot}/${name} is a long-lived session, and those are Bot Father's to ${verb}. ${nothing}`);
155
173
  }
156
174
  if (temporary.maker !== caller.session) {
157
- throw new Error(`${caller.bot}/${name} is a temporary session ${temporary.maker} made, not ${caller.session}, so it is ${temporary.maker}'s to retire. Nothing was retired.`);
175
+ throw new Error(`${caller.bot}/${name} is a temporary session ${temporary.maker} made, not ${caller.session}, so it is ${temporary.maker}'s to ${verb}. ${nothing}`);
158
176
  }
159
- return { ...(await retireSession(bots, { bot: caller.bot, session: name })), maker: caller.session };
177
+ return { bot, session };
178
+ }
179
+
180
+ /** Codex's hooks review, and the one of its choices this kit answers it with (tech notes, section 3). */
181
+ const HOOKS_REVIEW = 'Hooks need review';
182
+ const TRUST_ALL = 'Trust all and continue';
183
+
184
+ /** How long the review is given to go once answered, looked at every half second. */
185
+ const REVIEW_GONE_MS = 5000;
186
+
187
+ /**
188
+ * Answer the hooks review of a Codex run the caller made with "Trust all and
189
+ * continue", and confirm the review went (#238, the owner's choice (b)). The
190
+ * one answer a permission rule of its own can allow: a raw `orca terminal
191
+ * send` cannot be narrowed to a tab or to these keys. Everything that can
192
+ * refuse is checked before a key is sent. Returns `{ bot, session, maker }`.
193
+ */
194
+ export async function trustHooks(bots, { tab, name }) {
195
+ const caller = callerIn(bots, tab, 'trust-hooks', "answer a run's hooks review");
196
+ const nothing = 'Nothing was typed.';
197
+ const { bot, session } = ownTemp(caller, name, 'answer for', nothing);
198
+ const run = `${caller.bot}/${name}`;
199
+ const harness = harnessOf(session, bot.harness);
200
+ if (harness !== 'codex') {
201
+ throw new Error(`${run} runs on ${harness}, and temp trust-hooks answers a Codex run's "${HOOKS_REVIEW}" alone. ${nothing}`);
202
+ }
203
+ const tabId = readBook(caller.home).sessions[name]?.tab;
204
+ const handle = tabId === undefined ? undefined : tabs(caller.home).find((one) => one.tabId === tabId)?.handle;
205
+ if (handle === undefined) {
206
+ throw new Error(`${run} has no tab open in Orca, so there is no review of its to answer. ${nothing}`);
207
+ }
208
+ const seen = screenRows(handle);
209
+ if (seen.rows === undefined) {
210
+ throw new Error(`${run}'s screen could not be read (${seen.unreadable}), so the kit cannot tell whether its hooks review is there. ${nothing}`);
211
+ }
212
+ const keys = keysToTrustAll(seen.rows);
213
+ if (keys === undefined) {
214
+ throw new Error(`${run}'s screen shows no "${HOOKS_REVIEW}" with its choices, so there is nothing for this to answer. It shows: ${shown(seen.rows)}. ${nothing}`);
215
+ }
216
+ // The return is inside the text: `--enter` would be a second key.
217
+ orca(['terminal', 'send', '--terminal', handle, '--text', keys]);
218
+ for (let waited = 0; ; waited += 500) {
219
+ const after = screenRows(handle);
220
+ if (after.rows !== undefined && !after.rows.some((row) => row.includes(HOOKS_REVIEW))) break;
221
+ if (waited >= REVIEW_GONE_MS) {
222
+ const still = after.rows === undefined ? `its screen could not be read again (${after.unreadable})` : `its screen still shows "${HOOKS_REVIEW}"`;
223
+ throw new Error(`${run}: "${TRUST_ALL}" was chosen on its hooks review, but ${REVIEW_GONE_MS / 1000} seconds later ${still}. Look at its tab.`);
224
+ }
225
+ await pause(500);
226
+ }
227
+ return { bot: caller.bot, session: name, maker: caller.session };
228
+ }
229
+
230
+ /**
231
+ * The keys that take Codex's hooks review to "2. Trust all and continue" and
232
+ * answer it, from wherever its pointer is: arrows, then return, never a digit
233
+ * (a digit and return once took whatever was highlighted). Undefined when the
234
+ * rows show no such review with the pointer on one of its choices.
235
+ */
236
+ function keysToTrustAll(rows) {
237
+ if (!rows.some((row) => row.includes(HOOKS_REVIEW))) return undefined;
238
+ if (!rows.some((row) => new RegExp(`^ *(?:› +)?2\\. ${TRUST_ALL}`).test(row))) return undefined;
239
+ const pointer = rows.map((row) => /^ *› +(\d+)\. /.exec(row)).findLast((found) => found !== null);
240
+ if (pointer === undefined) return undefined;
241
+ const moves = 2 - Number(pointer[1]);
242
+ return (moves > 0 ? '\x1b[B'.repeat(moves) : '\x1b[A'.repeat(-moves)) + '\r';
243
+ }
244
+
245
+ /** What a screen shows, on one line: its rows with words in them, cut short. */
246
+ function shown(rows) {
247
+ const text = rows.map((row) => row.trim()).filter((row) => row !== '').join(' ⏎ ');
248
+ return text.length > 500 ? `${text.slice(0, 500)}…` : text;
160
249
  }
161
250
 
162
251
  /**
163
252
  * The session this runs in, as the book knows it, with its home and whether it
164
253
  * is itself temporary.
165
254
  */
166
- function callerIn(bots, tab, what) {
255
+ function callerIn(bots, tab, what, doing = `${what} it`) {
167
256
  const found = tab === undefined ? undefined : sessionInTab(bots, tab);
168
257
  if (found === undefined) {
169
- throw new Error(`temp ${what} is run by a session in its own tab, and this is not one of the fleet's tabs, so there is no session here to ${what} it for. Nothing was done.`);
258
+ throw new Error(`temp ${what} is run by a session in its own tab, and this is not one of the fleet's tabs, so there is no session here to ${doing} for. Nothing was done.`);
170
259
  }
171
260
  const entry = readBook(found.home).sessions[found.session] ?? {};
172
261
  return { bot: found.bot, session: found.session, home: found.home, temporary: entry.temporary };