@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/README.md +18 -2
- package/package.json +1 -1
- package/skills/obk-bot-building/SKILL.md +3 -2
- package/src/book.js +94 -3
- package/src/clear.js +554 -0
- package/src/cli.js +61 -0
- package/src/groom.js +17 -10
- package/src/health.js +4 -7
- package/src/hooks.js +67 -31
- package/src/lines.js +7 -4
- package/src/message.js +15 -1
- package/src/name.js +138 -0
- package/src/orca.js +6 -4
- package/src/record.js +5 -5
- package/src/skills.js +10 -1
- package/src/temp.js +17 -10
- package/src/up.js +49 -26
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
|
|
199
|
-
if (
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
|
326
|
-
// (tech notes,
|
|
327
|
-
|
|
328
|
-
|
|
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:
|
|
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
|
|
142
|
-
// right while the warning after a native message (ADR 0032),
|
|
143
|
-
// a Codex sender left (ADR 0034),
|
|
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
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
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(
|
|
240
|
-
if (
|
|
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
|
|
261
|
-
* session hook: one entry of the
|
|
262
|
-
*
|
|
263
|
-
* when there is none. A group
|
|
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
|
-
|
|
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.
|
|
29
|
-
*
|
|
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,
|
|
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
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
|
|
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
|
}
|