@assuredloop/orca-bot-kit 0.22.0 → 0.24.1

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/cli.js CHANGED
@@ -16,6 +16,7 @@ import { addSession, allowedNow, allowRules, botDir, changeBot, changeSession, c
16
16
  import { addCommand, groomCommand, grooming, upCommand } from './groom.js';
17
17
  import { checkHealth, orcaSettingFindings } from './health.js';
18
18
  import { initBots } from './init.js';
19
+ import { nameSession } from './name.js';
19
20
  import { APPROVALS, HARNESSES, ownCli, shellWord, workDirOf } from './launch.js';
20
21
  import { checkMail, decideLeftNudges, lookUp, noMailboxYet, sendMessage, sessionInTab } from './message.js';
21
22
  import { orcaCli, orcaTrouble, RELOAD_LINE, TERMINAL_ENV } from './orca.js';
@@ -23,6 +24,7 @@ import { allowCommand, allowIn, beyondDefaults, refuseBroad, refuseNoCodexForm,
23
24
  import { pauseSessions, unpauseSessions } from './pause.js';
24
25
  import { recordSession, SHELL_ENV, TAB_ENV } from './record.js';
25
26
  import { restartSessions } from './restart.js';
27
+ import { clearSession, compactSession } from './clear.js';
26
28
  import { retireBot, retireSession } from './retire.js';
27
29
  import { sentWarning } from './sent.js';
28
30
  import { readRoster } from './roster.js';
@@ -204,6 +206,18 @@ Usage:
204
206
  one command that closes a tab, it closes only the
205
207
  tabs your book names, and it will not close one
206
208
  whose conversation the book cannot name.
209
+ obk session clear --bots <path> --bot <bot> --session <name>
210
+ Start the session on a new conversation: /clear on
211
+ Claude Code, /new on Codex. It waits up to 30 s for
212
+ the session to be idle, types nothing into a busy
213
+ one or one with a question on its screen, and
214
+ answers once the book holds the new conversation.
215
+ The kit's hook gives it its start prompt again.
216
+ obk session compact --bots <path> --bot <bot> --session <name>
217
+ Compact the session's conversation with /compact,
218
+ as safely as session clear, and answer once the
219
+ harness's record shows it, or after 5 minutes that
220
+ it is not confirmed yet.
207
221
  obk health --bots <path> [--bot <bot>]
208
222
  Say what is wrong with your setup: configuration
209
223
  that will not work, a skill that is not where its
@@ -261,6 +275,11 @@ Usage:
261
275
  nudges a send there left because it could not
262
276
  tell from inside Codex's sandbox, and types each
263
277
  one or says why not.
278
+ obk session name --bots <path> --bot <bot>
279
+ For the kit's own hook, not for typing: after a
280
+ Codex session's turn, it names the session's thread
281
+ <bot>.<session> with Codex's /rename, once the
282
+ session is idle, so its tab says which it is.
264
283
  obk session mailbox --bots <path> --bot <bot> --session <name>
265
284
  For the kit's own launch line, not for typing: run
266
285
  in the session's own tab, it gives the session its
@@ -296,12 +315,15 @@ const COMMANDS = {
296
315
  'source add': ['bots', 'name', 'repo', 'ref'],
297
316
  'session add': ['bots', 'bot', 'name'],
298
317
  'session change': ['bots', 'bot', 'session'],
318
+ 'session clear': ['bots', 'bot', 'session'],
319
+ 'session compact': ['bots', 'bot', 'session'],
299
320
  'message to': ['bots', 'to'],
300
321
  'message send': ['bots', 'to', 'subject'],
301
322
  'message check': ['bots'],
302
323
  'session record': ['bots', 'bot'],
303
324
  'session sent': ['bots', 'bot'],
304
325
  'session nudge': ['bots', 'bot'],
326
+ 'session name': ['bots', 'bot'],
305
327
  'session mailbox': ['bots', 'bot', 'session'],
306
328
  'temp make': ['bots', 'name'],
307
329
  'temp roles': ['bots'],
@@ -434,6 +456,7 @@ async function run(argv) {
434
456
  // there included, so it goes before anything that can complain (ADR 0032).
435
457
  if (command === SENT) return sent(path.resolve(values.bots));
436
458
  if (command === NUDGE) return nudgeLeft();
459
+ if (command === NAME) return nameThread(path.resolve(values.bots), values.bot);
437
460
 
438
461
  // One fleet, one identity, whatever spelling of its path was given (#164).
439
462
  const bots = command === 'init' ? path.resolve(values.bots) : sameFleet(path.resolve(values.bots));
@@ -502,6 +525,7 @@ const ANSWER_IT = [
502
525
  const RECORD = 'session record';
503
526
  const SENT = 'session sent';
504
527
  const NUDGE = 'session nudge';
528
+ const NAME = 'session name';
505
529
 
506
530
  /**
507
531
  * What the kit's send hook says after a native message: a warning the session
@@ -541,6 +565,21 @@ function nudgeLeft() {
541
565
  return 0;
542
566
  }
543
567
 
568
+ /**
569
+ * What the kit's Codex Stop hook does at a turn end: name the session's thread
570
+ * after its bot and session, once it is idle (#480). Silent, never a decision,
571
+ * and never anything but exit 0: what it could not type now waits for the next
572
+ * turn end.
573
+ */
574
+ async function nameThread(bots, bot) {
575
+ try {
576
+ await nameSession(bots, bot, JSON.parse(readFileSync(0, 'utf8')), process.env[TAB_ENV]);
577
+ } catch {
578
+ // Nothing: a hook does not disturb the session it runs in.
579
+ }
580
+ return 0;
581
+ }
582
+
544
583
  /**
545
584
  * What the kit's hook does with what the harness told it, and what it answers.
546
585
  *
@@ -1136,6 +1175,28 @@ const commands = {
1136
1175
  ],
1137
1176
  };
1138
1177
  },
1178
+
1179
+ async 'session clear'(bots, values) {
1180
+ refuseWhenOrcaIsDown();
1181
+ const cleared = await clearSession(bots, { bot: values.bot, session: values.session });
1182
+ const after = cleared.was === null ? '' : `, in place of ${cleared.was}`;
1183
+ return {
1184
+ answer: { bots, cleared },
1185
+ lines: [`cleared ${cleared.bot} ${cleared.session}: the book holds its new conversation ${cleared.now}${after}.`],
1186
+ };
1187
+ },
1188
+
1189
+ async 'session compact'(bots, values) {
1190
+ refuseWhenOrcaIsDown();
1191
+ const compacted = await compactSession(bots, { bot: values.bot, session: values.session });
1192
+ const what = `${compacted.bot} ${compacted.session}`;
1193
+ return {
1194
+ answer: { bots, compacted },
1195
+ lines: [compacted.confirmed
1196
+ ? `compacted ${what}: the harness's record of its conversation ${compacted.conversation} shows the compaction.`
1197
+ : `asked ${what} to compact: /compact went in, and after 5 minutes the harness's record shows no compaction, so it is not confirmed yet. Look at its tab.`],
1198
+ };
1199
+ },
1139
1200
  };
1140
1201
 
1141
1202
  /**
package/src/groom.js CHANGED
@@ -42,7 +42,7 @@ import { existsSync, mkdirSync, realpathSync, writeFileSync } from 'node:fs';
42
42
  import path from 'node:path';
43
43
 
44
44
  import { botDir, readBot, requireBotsFolder } from './bot.js';
45
- import { readBook } from './book.js';
45
+ import { readBook, takeLineTurn, TYPING_HELD, TYPING_WAIT_MS } from './book.js';
46
46
  import { claudeTranscript } from './conversations.js';
47
47
  import { harnessOf, ownCli, shellWord } from './launch.js';
48
48
  import { tabs, tabToTypeInto, typeIntoTab } from './orca.js';
@@ -189,21 +189,28 @@ function refuseRun(run, ask) {
189
189
  /**
190
190
  * Type `line` into the grooming tab, through the kit's one gate for typing into
191
191
  * a running session: nothing goes into a tab with something on screen waiting
192
- * for an answer, or with anything but Claude Code in front of it.
192
+ * for an answer, or with anything but Claude Code in front of it. In the
193
+ * session's turn for a line, so it never lands in what the kit is typing (#482).
193
194
  */
194
195
  function typeInto(bots, home, session, tab, line) {
195
196
  if (!session.up) {
196
197
  throw new Error(`${BOT_FATHER}'s ${GROOMING} session is not up, so nothing was typed. Bring it up with ${upCommand(bots)}, then run this again.`);
197
198
  }
198
- const found = tabToTypeInto(home, tab, LOOK_MS);
199
- if (found.blocked !== undefined) {
200
- throw new Error(`the ${GROOMING} tab is waiting for an answer (${found.blocked}), so nothing was typed. Answer it in the tab, then run this again.`);
201
- }
202
- if (found.unsure !== undefined) throw new Error(`the ${GROOMING} tab: ${found.unsure}.`);
203
- if (found.handle === undefined || found.agent !== 'claude') {
204
- throw new Error(`the ${GROOMING} tab has no Claude Code in front of it, so nothing was typed. Bring it back with ${restartCommand(bots)}, then run this again.`);
199
+ const turn = takeLineTurn(home, GROOMING, TYPING_WAIT_MS);
200
+ if (turn === undefined) throw new Error(`the ${GROOMING} tab: ${TYPING_HELD}. Run this again in a moment.`);
201
+ try {
202
+ const found = tabToTypeInto(home, tab, LOOK_MS);
203
+ if (found.blocked !== undefined) {
204
+ throw new Error(`the ${GROOMING} tab is waiting for an answer (${found.blocked}), so nothing was typed. Answer it in the tab, then run this again.`);
205
+ }
206
+ if (found.unsure !== undefined) throw new Error(`the ${GROOMING} tab: ${found.unsure}.`);
207
+ if (found.handle === undefined || found.agent !== 'claude') {
208
+ throw new Error(`the ${GROOMING} tab has no Claude Code in front of it, so nothing was typed. Bring it back with ${restartCommand(bots)}, then run this again.`);
209
+ }
210
+ typeIntoTab(found.handle, line);
211
+ } finally {
212
+ turn.release();
205
213
  }
206
- typeIntoTab(found.handle, line);
207
214
  }
208
215
 
209
216
  /**
package/src/health.js CHANGED
@@ -322,13 +322,10 @@ function runningOn(bots, home, bot, book, handles, sessions) {
322
322
 
323
323
  if (rules.state === 'older') {
324
324
  const agents = path.join(home, 'AGENTS.md');
325
- // A clear on Claude Code reads the instructions again, as a start does
326
- // (tech notes, section 2); whether a new conversation on Codex does is
327
- // not established, so a start is all that is offered there.
328
- const how = harness === 'claude'
329
- ? `A Claude Code session reads it again at a /clear in its tab, or when it starts: ${restart}`
330
- : `A Codex session reads it when it starts: ${restart}`;
331
- found.push(finding('session', agents, `${bot.name}'s session ${session.name} is running on older rules: ${agents} has changed since the kit noted which version this session read. ${how}`, bot.name));
325
+ // A clear reads the instructions again, as a start does, on both
326
+ // harnesses (tech notes, sections 2 and 3; #391).
327
+ const clear = `${shellWord(ownCli())} session clear --bots ${shellWord(bots)} --bot ${bot.name} --session ${session.name}`;
328
+ found.push(finding('session', agents, `${bot.name}'s session ${session.name} is running on older rules: ${agents} has changed since the kit noted which version this session read. A session reads it again when it is cleared: ${clear}, or when it starts: ${restart}`, bot.name));
332
329
  }
333
330
  }
334
331
  return found;
package/src/hooks.js CHANGED
@@ -69,6 +69,21 @@ const NUDGE_TIMEOUT = 30;
69
69
  export const nudgeCommand = (bots, bot, cli = ownCli()) =>
70
70
  `${shellWord(cli)} session nudge --bots ${shellWord(bots)} --bot ${shellWord(bot)} 2>/dev/null || true`;
71
71
 
72
+ /**
73
+ * Codex's third kit hook: at each turn end, the session's thread is named after
74
+ * its bot and session, so its tab says which it is (#480). Async, so the turn
75
+ * ends without waiting on it: it waits for the session to be idle, up to 30 s,
76
+ * then types and confirms. A naming took 73 s live, each character waiting on
77
+ * a look through Orca's gate, and a hook stopped partway would leave half the
78
+ * command in the input line, so the timeout leaves room to spare.
79
+ */
80
+ const NAME_EVENT = 'Stop';
81
+ const NAME_TIMEOUT = 300;
82
+
83
+ /** What the naming hook runs, by the kit's own path, as `hookCommand` does. */
84
+ export const nameCommand = (bots, bot, cli = ownCli()) =>
85
+ `${shellWord(cli)} session name --bots ${shellWord(bots)} --bot ${shellWord(bot)} 2>/dev/null || true`;
86
+
72
87
  /**
73
88
  * Make sure the bot at `home` has the kit's SessionStart hook for `harness`.
74
89
  * Returns the file it wrote, relative to the bot home, or undefined when the
@@ -91,7 +106,7 @@ export function installHook(home, harness, { bots, bot }) {
91
106
  // longer asks about is taken out, and a hook of the user's beside the kit's
92
107
  // stays where they put it.
93
108
  const started = withKitHook(settings.hooks, file, mine);
94
- const wanted = { ...settings, hooks: withToolHook(started, toolHookOf(harness, bots, bot)) };
109
+ const wanted = { ...settings, hooks: toolHooksOf(harness, bots, bot).reduce(withToolHook, started) };
95
110
 
96
111
  // Compared as documents, not as text: how the user laid their file out is
97
112
  // theirs, and a run that changes nothing writes nothing.
@@ -138,26 +153,29 @@ export function hookTrouble(home, harness, { bots, bot }) {
138
153
  }
139
154
  const program = running ?? ownCli();
140
155
  wanted = { ...settings, hooks: withKitHook(settings.hooks, file, { type: 'command', command: hookCommand(bots, bot, program), timeout: TIMEOUT }) };
141
- // The harness's second hook is judged on its own: the session hook may be
142
- // right while the warning after a native message (ADR 0032), or the nudge
143
- // a Codex sender left (ADR 0034), is missing.
156
+ // The harness's other hooks are each judged on their own: the session hook
157
+ // may be right while the warning after a native message (ADR 0032), the
158
+ // nudge a Codex sender left (ADR 0034), or the naming of a Codex thread
159
+ // (#480) is missing.
144
160
  if (isDeepStrictEqual(settings, wanted)) {
145
- const tool = toolHookOf(harness, bots, bot);
146
- const toolRunning = programIn(settings, tool.pattern);
147
- // One that runs a kit no longer there is as quiet as a missing one:
148
- // `|| true` hides it (#451 review).
149
- if (toolRunning !== undefined && toolRunning !== BARE && !existsSync(toolRunning)) {
150
- return {
151
- where: file,
152
- says: `${file} holds the kit's ${tool.event} hook for ${tool.matcher}, but it runs ${toolRunning}, which is not there any more, so ${tool.missing}. obk up puts back one that runs the kit you have.`,
153
- };
154
- }
155
- const withTool = { ...settings, hooks: withToolHook(settings.hooks, toolHookOf(harness, bots, bot, toolRunning ?? program)) };
156
- if (!isDeepStrictEqual(settings, withTool)) {
157
- return {
158
- where: file,
159
- says: `${file} does not hold the kit's ${tool.event} hook for ${tool.matcher}, without which ${tool.missing}. obk up puts it back.`,
160
- };
161
+ for (const tool of toolHooksOf(harness, bots, bot)) {
162
+ const toolRunning = programIn(settings, tool.pattern);
163
+ const what = tool.matcher === undefined ? `${tool.event} hook` : `${tool.event} hook for ${tool.matcher}`;
164
+ // One that runs a kit no longer there is as quiet as a missing one:
165
+ // `|| true` hides it (#451 review).
166
+ if (toolRunning !== undefined && toolRunning !== BARE && !existsSync(toolRunning)) {
167
+ return {
168
+ where: file,
169
+ says: `${file} holds the kit's ${what}, but it runs ${toolRunning}, which is not there any more, so ${tool.missing}. obk up puts back one that runs the kit you have.`,
170
+ };
171
+ }
172
+ const withTool = { ...settings, hooks: withToolHook(settings.hooks, toolHookOf(tool.kind, bots, bot, toolRunning ?? program)) };
173
+ if (!isDeepStrictEqual(settings, withTool)) {
174
+ return {
175
+ where: file,
176
+ says: `${file} does not hold the kit's ${what}, without which ${tool.missing}. obk up puts it back.`,
177
+ };
178
+ }
161
179
  }
162
180
  }
163
181
  } catch (error) {
@@ -230,15 +248,29 @@ function withKitHook(hooks, file, mine) {
230
248
  return { ...events, [EVENT]: [...(events[EVENT] ?? []), { hooks: [mine] }] };
231
249
  }
232
250
 
251
+ /** Each harness's kit hooks beside its session hook, as `toolHookOf` gives each. */
252
+ const toolHooksOf = (harness, bots, bot, cli = ownCli()) =>
253
+ (harness === 'claude' ? ['sent'] : ['nudge', 'name']).map((kind) => toolHookOf(kind, bots, bot, cli));
254
+
233
255
  /**
234
- * Each harness's second kit hook, after one of its tools: Claude Code's after a
235
- * native `SendMessage` (ADR 0032), Codex's after its shell tool (ADR 0034).
236
- * The entry, where it goes, how the kit knows its own, and what is lost
237
- * without it.
256
+ * One of the kit's hooks beside its session hook: Claude Code's after a native
257
+ * `SendMessage` (`sent`, ADR 0032), Codex's after its shell tool (`nudge`,
258
+ * ADR 0034), and Codex's at a turn end (`name`, #480). The entry, where it
259
+ * goes, how the kit knows its own, and what is lost without it.
238
260
  */
239
- function toolHookOf(harness, bots, bot, cli = ownCli()) {
240
- if (harness === 'claude') {
261
+ function toolHookOf(kind, bots, bot, cli = ownCli()) {
262
+ if (kind === 'name') {
263
+ return {
264
+ kind,
265
+ event: NAME_EVENT,
266
+ pattern: KIT_NAME,
267
+ mine: { type: 'command', command: nameCommand(bots, bot, cli), timeout: NAME_TIMEOUT, async: true },
268
+ missing: "a Codex session's thread is never named after its bot and session, and its tab shows a topic Codex made up instead (#480)",
269
+ };
270
+ }
271
+ if (kind === 'sent') {
241
272
  return {
273
+ kind,
242
274
  event: SENT_EVENT,
243
275
  matcher: SENT_MATCHER,
244
276
  pattern: KIT_SENT,
@@ -247,6 +279,7 @@ function toolHookOf(harness, bots, bot, cli = ownCli()) {
247
279
  };
248
280
  }
249
281
  return {
282
+ kind,
250
283
  event: NUDGE_EVENT,
251
284
  matcher: NUDGE_MATCHER,
252
285
  pattern: KIT_NUDGE,
@@ -257,10 +290,11 @@ function toolHookOf(harness, bots, bot, cli = ownCli()) {
257
290
 
258
291
  /**
259
292
  * The hooks with the kit's tool hook in a group of its event matched to its
260
- * tool, and everything else as the user left it, as `withKitHook` keeps the
261
- * session hook: one entry of the kit's, written in place where it already sits
262
- * in such a group, taken out of anywhere else, and added in a group of its own
263
- * when there is none. A group the kit empties goes.
293
+ * tool (or with no matcher, for one that has none), and everything else as the
294
+ * user left it, as `withKitHook` keeps the session hook: one entry of the
295
+ * kit's, written in place where it already sits in such a group, taken out of
296
+ * anywhere else, and added in a group of its own when there is none. A group
297
+ * the kit empties goes.
264
298
  */
265
299
  function withToolHook(hooks, { event: wantedEvent, matcher, pattern, mine }) {
266
300
  let placed = false;
@@ -279,7 +313,8 @@ function withToolHook(hooks, { event: wantedEvent, matcher, pattern, mine }) {
279
313
  return kept.length === 0 ? [] : [[event, kept]];
280
314
  }));
281
315
  if (placed) return events;
282
- return { ...events, [wantedEvent]: [...(events[wantedEvent] ?? []), { matcher, hooks: [mine] }] };
316
+ const group = matcher === undefined ? { hooks: [mine] } : { matcher, hooks: [mine] };
317
+ return { ...events, [wantedEvent]: [...(events[wantedEvent] ?? []), group] };
283
318
  }
284
319
 
285
320
  /** The program a command of the kit's of this pattern runs, unquoted, or undefined for any other command. */
@@ -320,6 +355,7 @@ const WORD = String.raw`(?:[A-Za-z0-9,._+:@%/=-]+|'(?:[^']|'\\'')*')`;
320
355
  const KIT_HOOK = new RegExp(String.raw`^(${WORD}) session record --bots ${WORD} --bot ${WORD} 2>/dev/null \|\| true$`);
321
356
  const KIT_SENT = new RegExp(String.raw`^(${WORD}) session sent --bots ${WORD} --bot ${WORD} 2>/dev/null \|\| true$`);
322
357
  const KIT_NUDGE = new RegExp(String.raw`^(${WORD}) session nudge --bots ${WORD} --bot ${WORD} 2>/dev/null \|\| true$`);
358
+ const KIT_NAME = new RegExp(String.raw`^(${WORD}) session name --bots ${WORD} --bot ${WORD} 2>/dev/null \|\| true$`);
323
359
 
324
360
  /** The program a bot made before the kit named itself by path runs. */
325
361
  const BARE = 'obk';
package/src/lines.js CHANGED
@@ -25,18 +25,21 @@ const LONGEST = 64 * 1024 * 1024;
25
25
  /**
26
26
  * Each line of `file`, in order, to `onLine`: its text, or `undefined` for a
27
27
  * line too long to hold. A line `onLine` answers `true` to is the last one
28
- * read. Throws what the file system throws when the file cannot be opened or
29
- * read.
28
+ * read. With `from`, reading starts at that byte, for a caller that wants only
29
+ * what was written after it (#391). Throws what the file system throws when
30
+ * the file cannot be opened or read.
30
31
  */
31
- export function eachLine(file, onLine) {
32
+ export function eachLine(file, onLine, { from = 0 } = {}) {
32
33
  const fd = openSync(file, 'r');
33
34
  try {
34
35
  const decoder = new StringDecoder('utf8');
35
36
  const buffer = Buffer.allocUnsafe(CHUNK);
36
37
  let pending = '';
37
38
  let tooLong = false;
39
+ let position = from;
38
40
  for (;;) {
39
- const read = readSync(fd, buffer, 0, CHUNK, null);
41
+ const read = readSync(fd, buffer, 0, CHUNK, position);
42
+ position += read;
40
43
  const text = read === 0 ? decoder.end() : decoder.write(buffer.subarray(0, read));
41
44
  let start = 0;
42
45
  for (let at = text.indexOf('\n'); at !== -1; at = text.indexOf('\n', start)) {
package/src/message.js CHANGED
@@ -19,7 +19,7 @@ import { mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync,
19
19
  import os from 'node:os';
20
20
  import path from 'node:path';
21
21
 
22
- import { MAILBOX_WAIT_MS, readBook, takeMailboxTurn } from './book.js';
22
+ import { MAILBOX_WAIT_MS, readBook, takeLineTurn, takeMailboxTurn, TYPING_HELD, TYPING_WAIT_MS } from './book.js';
23
23
  import { botDir, botNames, readBot } from './bot.js';
24
24
  import { harnessOf, isAddressOf, ownCli, reachesMail, SHELL_ENV, shellWord } from './launch.js';
25
25
  import { ackMailbox, coordinatorOf, postMessage, readMailbox, tabs, tabToTypeInto, TERMINAL_ENV, TIMED_OUT, typeIntoTab, useMailbox } from './orca.js';
@@ -456,6 +456,18 @@ const stamp = () => new Date().toISOString().replaceAll(':', '-').replace('.', '
456
456
  function nudge(to, from, subject, tab) {
457
457
  if (to.tab === undefined) return { nudged: false };
458
458
 
459
+ // A line typed while the kit types a command one key at a time would land
460
+ // in it and send it with its own return (#480): so the receiver's turn for
461
+ // a line first, for a bounded time, and the mail waits in its mailbox if not.
462
+ let turn;
463
+ try {
464
+ turn = takeLineTurn(to.home, to.session, TYPING_WAIT_MS);
465
+ } catch (error) {
466
+ return { nudged: false, nudgeTrouble: error.message };
467
+ }
468
+ if (turn === undefined) {
469
+ return { nudged: false, nudgeTrouble: TYPING_HELD };
470
+ }
459
471
  try {
460
472
  const found = lookAt(to);
461
473
  if (found.blocked !== undefined) return { nudged: false, blocked: found.blocked };
@@ -483,6 +495,8 @@ function nudge(to, from, subject, tab) {
483
495
  // silent `false` would read as "the session is not up", which is a
484
496
  // different thing from "Orca would not say".
485
497
  return { nudged: false, nudgeTrouble: error.message };
498
+ } finally {
499
+ turn.release();
486
500
  }
487
501
  }
488
502
 
package/src/name.js ADDED
@@ -0,0 +1,138 @@
1
+ // `obk session name`: the kit names a Codex session's thread `<bot>.<session>`
2
+ // (#480), so the tab, which Codex titles with the thread's name after every
3
+ // turn, says which bot and session it is. A Claude session has its name from
4
+ // the kit's `-n` already.
5
+ //
6
+ // Codex 0.160.0 has no launch-time way to name a thread, so this types Codex's
7
+ // own `/rename` (the architect's ruling on #480). It runs from the kit's async
8
+ // Stop hook (src/hooks.js) at each turn end, never inside a turn: a fresh
9
+ // start's first turn is its start prompt, which nothing may cut off. It types
10
+ // only through the path `obk session clear` takes (src/clear.js, #391), and
11
+ // what is not safe to type now waits for the next turn end.
12
+
13
+ import { readFileSync, realpathSync } from 'node:fs';
14
+ import { homedir } from 'node:os';
15
+ import path from 'node:path';
16
+ import { setTimeout as pause } from 'node:timers/promises';
17
+
18
+ import { readBook, takeNameTurn, takeTypingTurn } from './book.js';
19
+ import { botDir, readBot } from './bot.js';
20
+ import { sessionToType, typeCommand } from './clear.js';
21
+ import { harnessOf } from './launch.js';
22
+ import { sessionsOf } from './up.js';
23
+
24
+ /**
25
+ * Where Codex keeps its threads' names: a line `{ id, thread_name, updated_at }`
26
+ * a name, the file only growing and the newest line for an id winning
27
+ * (codex-rs/rollout/src/session_index.rs, rust-v0.160.0).
28
+ */
29
+ const sessionIndex = () => path.join(homedir(), '.codex', 'session_index.jsonl');
30
+
31
+ /**
32
+ * When a naming stops typing, counted from its start: well inside the hook's
33
+ * 300 s (src/hooks.js), at which Codex kills the hook's whole process group
34
+ * (codex-rs/hooks/src/engine/command_runner.rs), so that what was typed is
35
+ * taken back first.
36
+ */
37
+ const DEADLINE_MS = 240_000;
38
+
39
+ /** How long Codex is given to write the name down after the return. */
40
+ const CONFIRM_MS = 10_000;
41
+ const ASK_MS = 500;
42
+
43
+ /**
44
+ * Name the thread that just ended a turn in the tab `tabId`, as Codex's Stop
45
+ * hook `said` it, when that tab is a Codex session of `bot` and its thread does
46
+ * not have the name yet. Returns whether Codex's record names it afterwards,
47
+ * or undefined when there was nothing to do. Throws when it could not type.
48
+ */
49
+ export async function nameSession(bots, bot, said, tabId) {
50
+ const deadline = Date.now() + DEADLINE_MS;
51
+ if (said?.hook_event_name !== 'Stop') return undefined;
52
+ if (typeof tabId !== 'string' || tabId === '') return undefined;
53
+
54
+ const home = realpathSync(botDir(bots, bot));
55
+ const [session, entry] = Object.entries(readBook(home).sessions).find(([, one]) => one?.tab === tabId) ?? [];
56
+ if (session === undefined) return undefined;
57
+ const known = readBot(home, bot);
58
+ const [settings] = sessionsOf(known, session);
59
+ if (harnessOf(settings, known.harness) !== 'codex') return undefined;
60
+ // Only the thread the book says this tab is running. A Codex that Orca
61
+ // brought back by itself runs its hooks in Codex's shared background server,
62
+ // under another session's tab (#408), and that tab shows another thread.
63
+ const thread = said.session_id;
64
+ if (typeof thread !== 'string' || thread !== entry.session) return undefined;
65
+
66
+ const name = `${bot}.${session}`;
67
+ if (nameOf(thread) === name) return undefined;
68
+
69
+ const turn = takeNameTurn(home, session);
70
+ if (turn === undefined) return undefined;
71
+ let typing;
72
+ try {
73
+ // Another naming may have finished while this one waited for its turn.
74
+ if (nameOf(thread) === name) return undefined;
75
+ // Only into the tab the hook ran in: one whose Codex was killed hard can
76
+ // leave its hook running (Codex starts each hook in a session of its own,
77
+ // codex-rs/hooks/src/engine/command_runner.rs), and the tab the book holds
78
+ // now shows another process.
79
+ const it = { ...sessionToType(bots, bot, session), conversation: thread };
80
+ if (it.tabId !== tabId) return undefined;
81
+ // The session's typing turn, taken once it is idle and not before, so a
82
+ // mail nudge waits seconds for it and not the whole wait for idle. Held by
83
+ // anything else: nothing is typed, and the next turn end tries again.
84
+ const before = () => {
85
+ typing = takeTypingTurn(home, session, 0);
86
+ if (typing === undefined) throw new Error(`${it.name}: nothing was typed, because the kit is typing into its tab already.`);
87
+ };
88
+ try {
89
+ await typeCommand(it, `/rename ${name}`, { check: renameWrong, deadline, before });
90
+ } finally {
91
+ typing?.release();
92
+ }
93
+ const until = Date.now() + CONFIRM_MS;
94
+ while (nameOf(thread) !== name && Date.now() < until) await pause(ASK_MS);
95
+ return nameOf(thread) === name;
96
+ } finally {
97
+ turn.release();
98
+ }
99
+ }
100
+
101
+ /** The name Codex's record gives `thread` now, or undefined. */
102
+ function nameOf(thread) {
103
+ let text;
104
+ try {
105
+ text = readFileSync(sessionIndex(), 'utf8');
106
+ } catch {
107
+ return undefined;
108
+ }
109
+ let name;
110
+ for (const line of text.split('\n')) {
111
+ let entry;
112
+ try {
113
+ entry = JSON.parse(line);
114
+ } catch {
115
+ continue;
116
+ }
117
+ if (entry?.id === thread && typeof entry.thread_name === 'string') name = entry.thread_name;
118
+ }
119
+ return name;
120
+ }
121
+
122
+ /**
123
+ * Why the screen does not show `command` typed and ready, or undefined when it
124
+ * does: the input line, the lowest row Codex's pointer starts, reads the
125
+ * pointer and the command exactly, and no row of its slash menu stands above
126
+ * it. With words after the command Codex has closed its menu (chat_composer.rs
127
+ * `sync_command_popup`, rust-v0.160.0), so a menu still open means the line is
128
+ * not what it seems.
129
+ */
130
+ function renameWrong(rows, _harness, command) {
131
+ const at = rows.findLastIndex((row) => /^ *›/.test(row));
132
+ if (at < 0) return { why: 'its screen shows no input line' };
133
+ const line = rows[at].replaceAll(' ', ' ').trim();
134
+ if (line !== `› ${command}`) return { why: `its input line reads "${line}"` };
135
+ const above = rows.slice(0, at).findLast((row) => row.trim() !== '');
136
+ if (above !== undefined && /^ *(?:› +)?\/\S/.test(above)) return { why: `a menu of commands stands above its input line: "${above.trim()}"` };
137
+ return undefined;
138
+ }
package/src/orca.js CHANGED
@@ -377,14 +377,14 @@ export const QUESTION_ON_SCREEN = 'question-on-screen';
377
377
 
378
378
  /**
379
379
  * What the tab `handle` renders: `{ question }`, true when it shows a question
380
- * of its harness's own, or `{ screenUnreadable: <why> }`. Orca refusing is an
380
+ * of its harness's own, with the `rows` it read, or `{ screenUnreadable: <why> }`. Orca refusing is an
381
381
  * answer that cannot be read, not an error, and so is anything that is not the
382
382
  * rendered screen: accumulated output comes back in fragments (tech notes,
383
383
  * section 1).
384
384
  */
385
385
  function screenOf(handle, readMs) {
386
386
  const read = screenRows(handle, readMs);
387
- return read.rows === undefined ? { screenUnreadable: read.unreadable } : { question: questionIn(read.rows) };
387
+ return read.rows === undefined ? { screenUnreadable: read.unreadable } : { question: questionIn(read.rows), rows: read.rows };
388
388
  }
389
389
 
390
390
  /**
@@ -445,7 +445,9 @@ export function questionIn(rows) {
445
445
  * Whether the kit may type a line into `tabId`, the tab the book holds for a
446
446
  * session in the Orca project at `home`, and the handle to type into when it
447
447
  * may: `{ handle, agent }`, with the agent Orca names there. Otherwise nothing
448
- * is typed, and the answer says why:
448
+ * is typed, and the answer says why. `idle` says whether Orca's `tui-idle` wait
449
+ * answered ok, and `rows` is the screen the gate read, for a caller that must
450
+ * not type into a busy harness (#391):
449
451
  * `{}` for a tab with no harness in it (none in the book, none Orca lists, or
450
452
  * the shell in front), `{ blocked }` for one with something on screen waiting
451
453
  * to be answered, and `{ unsure }`, a sentence, for one the kit cannot tell
@@ -508,7 +510,7 @@ export function tabToTypeInto(home, tabId, timeoutMs) {
508
510
  if (seen.question === undefined) {
509
511
  return { unsure: `the kit could not tell whether a question is waiting on its screen (${seen.screenUnreadable}), so nothing was typed` };
510
512
  }
511
- return { handle: live.handle, agent: seen.agent };
513
+ return { handle: live.handle, agent: seen.agent, idle: seen.answered, rows: seen.rows };
512
514
  }
513
515
 
514
516
  /**
package/src/record.js CHANGED
@@ -80,11 +80,10 @@ export async function recordSession(bots, name, said, tabId, shellPid) {
80
80
  // this bot's folder, which every session of the bot shares — so it can say
81
81
  // that a conversation nobody claims exists, and never whose it is.
82
82
  const unclaimed = unclaimedFor(readBook(home), home, bot, tabId, id);
83
- // A clear on Claude Code reads the bot's instructions again, as a start does
84
- // (tech notes, section 2), so the book notes which ones it read. Codex has no
85
- // word for its clear, and whether its new conversation reads them again is
86
- // not established, so nothing is noted for it.
87
- const rules = said.source === CLEARED ? rulesStamp(home) : undefined;
83
+ // A clear reads the bot's instructions again, as a start does, so the book
84
+ // notes which ones it read: Claude Code's `/clear` (tech notes, section 2),
85
+ // and Codex's `/new`, seen here as a new id (#391).
86
+ const stamp = rulesStamp(home);
88
87
 
89
88
  let cleared = false;
90
89
  let told;
@@ -106,6 +105,7 @@ export async function recordSession(bots, name, said, tabId, shellPid) {
106
105
  const uncertain = was.session === undefined && unclaimed.length > 0;
107
106
  told = session;
108
107
 
108
+ const rules = cleared ? stamp : undefined;
109
109
  const now = rememberSession(was, id, said.source);
110
110
  book.sessions[session] = withUnclaimed(rules === undefined ? now : { ...now, rules }, unclaimed);
111
111
  forgetClaimed(book);
package/src/skills.js CHANGED
@@ -23,7 +23,7 @@ import { fileURLToPath } from 'node:url';
23
23
 
24
24
  import { parse, parseDocument, stringify } from 'yaml';
25
25
 
26
- import { bookFile, readBook } from './book.js';
26
+ import { bookFile, readBook, takeLineTurn, TYPING_HELD, TYPING_WAIT_MS } from './book.js';
27
27
  import { botDir, botNames, changesExactly, leadsOutside, readBot, YAML_OUT } from './bot.js';
28
28
  import { harnessOf, ownCli, shellWord } from './launch.js';
29
29
  import { orcaCli, orcaTrouble, tabToTypeInto, typeIntoTab } from './orca.js';
@@ -473,7 +473,14 @@ function tellSessions(home, bot, linked) {
473
473
  const tab = typeof book[session.name]?.tab === 'string' ? book[session.name].tab : undefined;
474
474
  if (tab === undefined) return { ...told, state: 'not-up' };
475
475
  if (down !== undefined) return { ...told, state: 'unknown', trouble: `Orca is not answering at ${orcaCli()}, so the kit could not look at its tab, and nothing was typed` };
476
+ let turn;
476
477
  try {
478
+ // The session's turn for a line (#482), for the look and its line. Codex
479
+ // is typed nothing here, so it waits for none.
480
+ if (harness !== 'codex') {
481
+ turn = takeLineTurn(real, session.name, TYPING_WAIT_MS);
482
+ if (turn === undefined) return { ...told, state: 'unknown', trouble: TYPING_HELD };
483
+ }
477
484
  const found = tabToTypeInto(real, tab, LOOK_MS);
478
485
  if (found.blocked !== undefined) return { ...told, state: 'blocked', blocked: found.blocked };
479
486
  if (found.unsure !== undefined) return { ...told, state: 'unknown', trouble: found.unsure };
@@ -495,6 +502,8 @@ function tellSessions(home, bot, linked) {
495
502
  // The links are made whatever Orca says, so this is reported, not
496
503
  // thrown: the build did what it was asked, and the session was not told.
497
504
  return { ...told, state: 'unknown', trouble: error.message };
505
+ } finally {
506
+ turn?.release();
498
507
  }
499
508
  });
500
509
  }